From 6ae1a2c618b6ceb95049a134737d63ec34d88121 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Mon, 12 Oct 2026 14:34:12 +0700 Subject: [PATCH 01/24] feat(shared): describe the admin sections and the roles that open them Co-Authored-By: Claude Opus 5.5 --- packages/shared/src/admin-sections.test.ts | 89 ++++++++++++++++++++++ packages/shared/src/admin-sections.ts | 71 +++++++++++++++++ packages/shared/src/index.ts | 1 + 3 files changed, 161 insertions(+) create mode 100644 packages/shared/src/admin-sections.test.ts create mode 100644 packages/shared/src/admin-sections.ts diff --git a/packages/shared/src/admin-sections.test.ts b/packages/shared/src/admin-sections.test.ts new file mode 100644 index 00000000..c1c7c031 --- /dev/null +++ b/packages/shared/src/admin-sections.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; +import { + adminSections, + adminSectionsOf, + canOpenAdminSection, + countedAdminSections, + isTeamRole, + type AdminSection, +} from './admin-sections'; +import { taxonomies, type UserRole } from './taxonomies'; + +// The matrix of the wiki (Admin-Web › Rôles), row by row: editor, moderator, admin. +const matrix: Record = { + dashboard: [true, true, true], + moderation: [false, true, true], + reports: [false, true, true], + catalog: [true, true, true], + users: [false, true, true], + aiDrafts: [true, false, true], + translations: [true, true, true], + affiliation: [true, false, true], + b2bClients: [true, false, true], + settings: [false, false, true], + actionLog: [false, false, true], + support: [false, true, true], +}; + +describe('canOpenAdminSection', () => { + it.each(adminSections)('follows the matrix of the wiki for %s', (section) => { + const teamRoles = ['editor', 'moderator', 'admin'] as const; + expect(teamRoles.map((role) => canOpenAdminSection(role, section))).toEqual(matrix[section]); + }); + + it.each(adminSections)('refuses %s to a user', (section) => { + expect(canOpenAdminSection('user', section)).toBe(false); + }); +}); + +describe('adminSectionsOf', () => { + it('gives nothing to a user', () => { + expect(adminSectionsOf('user')).toEqual([]); + }); + + it('gives an editor its sections only, in the order of the admin', () => { + expect(adminSectionsOf('editor')).toEqual([ + 'dashboard', + 'catalog', + 'aiDrafts', + 'translations', + 'affiliation', + 'b2bClients', + ]); + }); + + it('gives a moderator its sections only', () => { + expect(adminSectionsOf('moderator')).toEqual([ + 'dashboard', + 'moderation', + 'reports', + 'catalog', + 'users', + 'translations', + 'support', + ]); + }); + + it('gives an admin every section', () => { + expect(adminSectionsOf('admin')).toEqual([...adminSections]); + }); +}); + +describe('isTeamRole', () => { + it('holds for every role but user', () => { + const teamRoles = taxonomies.userRoles.filter((role: UserRole) => isTeamRole(role)); + expect(teamRoles).toEqual(['editor', 'moderator', 'admin']); + }); +}); + +describe('countedAdminSections', () => { + it('lists the counters of the navigation (wiki Admin-Web › Modèles communs)', () => { + expect(countedAdminSections).toEqual([ + 'moderation', + 'reports', + 'support', + 'aiDrafts', + 'translations', + ]); + }); +}); diff --git a/packages/shared/src/admin-sections.ts b/packages/shared/src/admin-sections.ts new file mode 100644 index 00000000..82e752f2 --- /dev/null +++ b/packages/shared/src/admin-sections.ts @@ -0,0 +1,71 @@ +import type { UserRole } from './taxonomies'; + +/** Roles of the Widoo team: the only ones the admin lets in (wiki Admin-Web › Rôles). */ +export type TeamRole = Exclude; + +/** Sections of the admin, in the order of its screens A-02 to A-13, the dashboard (A-05) first. */ +export const adminSections = [ + 'dashboard', + 'moderation', + 'reports', + 'catalog', + 'users', + 'aiDrafts', + 'translations', + 'affiliation', + 'b2bClients', + 'settings', + 'actionLog', + 'support', +] as const; + +export type AdminSection = (typeof adminSections)[number]; + +/** + * Who opens each section (wiki Admin-Web › Rôles): a role is listed as soon as the matrix gives + * it anything there, read only included. What a role may do inside a section (a moderator only + * reads the catalog, an editor only sees the team's routes) is decided by each route of the API. + * Written out for `admin` too, rather than derived from `hasRole`: `editor` and `moderator` do + * not imply each other, and the matrix is the reference. + */ +const sectionRoles = { + dashboard: ['editor', 'moderator', 'admin'], + moderation: ['moderator', 'admin'], + reports: ['moderator', 'admin'], + catalog: ['editor', 'moderator', 'admin'], + users: ['moderator', 'admin'], + aiDrafts: ['editor', 'admin'], + translations: ['editor', 'moderator', 'admin'], + affiliation: ['editor', 'admin'], + b2bClients: ['editor', 'admin'], + settings: ['admin'], + actionLog: ['admin'], + support: ['moderator', 'admin'], +} as const satisfies Record; + +/** True for a member of the team: `editor`, `moderator` or `admin`. */ +export function isTeamRole(role: UserRole): role is TeamRole { + return role !== 'user'; +} + +/** True when `role` opens `section`. The API decides; the admin only hides what is refused. */ +export function canOpenAdminSection(role: UserRole, section: AdminSection): boolean { + const roles: readonly UserRole[] = sectionRoles[section]; + return roles.includes(role); +} + +/** The sections `role` opens, in the order of `adminSections`; none for a `user`. */ +export function adminSectionsOf(role: UserRole): AdminSection[] { + return adminSections.filter((section) => canOpenAdminSection(role, section)); +} + +/** Sections whose navigation entry carries a "to handle" counter (`GET /admin/counters`). */ +export const countedAdminSections = [ + 'moderation', + 'reports', + 'support', + 'aiDrafts', + 'translations', +] as const satisfies readonly AdminSection[]; + +export type CountedAdminSection = (typeof countedAdminSections)[number]; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index ab566006..0fc8aa1e 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,3 +1,4 @@ +export * from './admin-sections'; export * from './analytics'; export * from './app-version'; export { arrondissementLabel } from './arrondissement'; From efb4cc5b759168073f2ce4806ec5fbce870ada02 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Mon, 12 Oct 2026 14:34:12 +0700 Subject: [PATCH 02/24] feat(ui): add the shared web package with the shadcn theme read from the tokens Co-Authored-By: Claude Opus 5.5 --- packages/ui/package.json | 34 ++ packages/ui/shadcn-theme.cjs | 40 ++ packages/ui/src/components/Button.tsx | 55 ++ packages/ui/src/components/DropdownMenu.tsx | 50 ++ packages/ui/src/components/Input.tsx | 25 + packages/ui/src/components/Notice.tsx | 31 ++ packages/ui/src/components/Tooltip.tsx | 33 ++ packages/ui/src/index.ts | 12 + packages/ui/src/lib/cn.ts | 9 + packages/ui/src/theme.test.ts | 65 +++ packages/ui/tailwind-preset.cjs | 95 ++++ packages/ui/tsconfig.json | 10 + packages/ui/vitest.config.ts | 1 + pnpm-lock.yaml | 556 ++++++++++++++++++++ 14 files changed, 1016 insertions(+) create mode 100644 packages/ui/package.json create mode 100644 packages/ui/shadcn-theme.cjs create mode 100644 packages/ui/src/components/Button.tsx create mode 100644 packages/ui/src/components/DropdownMenu.tsx create mode 100644 packages/ui/src/components/Input.tsx create mode 100644 packages/ui/src/components/Notice.tsx create mode 100644 packages/ui/src/components/Tooltip.tsx create mode 100644 packages/ui/src/index.ts create mode 100644 packages/ui/src/lib/cn.ts create mode 100644 packages/ui/src/theme.test.ts create mode 100644 packages/ui/tailwind-preset.cjs create mode 100644 packages/ui/tsconfig.json create mode 100644 packages/ui/vitest.config.ts diff --git a/packages/ui/package.json b/packages/ui/package.json new file mode 100644 index 00000000..1f4c5a6b --- /dev/null +++ b/packages/ui/package.json @@ -0,0 +1,34 @@ +{ + "name": "@widoo/ui", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./tailwind-preset": "./tailwind-preset.cjs" + }, + "scripts": { + "lint": "eslint .", + "typecheck": "tsc", + "test": "vitest run" + }, + "dependencies": { + "@phosphor-icons/react": "^2.1.10", + "@radix-ui/react-dropdown-menu": "^2.1.25", + "@radix-ui/react-slot": "~1.4.0", + "@radix-ui/react-tooltip": "^1.3.0", + "@widoo/tokens": "workspace:*", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": "19.2.3", + "tailwindcss": "3.4.19" + }, + "devDependencies": { + "@types/node": "^22.20.4", + "@types/react": "~19.2.2", + "react": "19.2.3", + "tailwindcss": "3.4.19" + } +} diff --git a/packages/ui/shadcn-theme.cjs b/packages/ui/shadcn-theme.cjs new file mode 100644 index 00000000..5994bac6 --- /dev/null +++ b/packages/ui/shadcn-theme.cjs @@ -0,0 +1,40 @@ +/** + * Theme of shadcn/ui for Widoo: each variable of shadcn names the color token of tokens.json it + * takes its value from (wiki Ecrans › Admin web, « Thème shadcn/ui », validated on 2026-10-09, + * D-086). No color is written here: the preset reads the values in @widoo/tokens. + */ +const shadcnColorTokens = { + background: 'bg', + foreground: 'ink', + card: 'bg', + 'card-foreground': 'ink', + popover: 'bg', + 'popover-foreground': 'ink', + primary: 'blue', + 'primary-foreground': 'on-blue', + secondary: 'surface', + 'secondary-foreground': 'ink', + muted: 'surface', + 'muted-foreground': 'muted', + accent: 'surface', + 'accent-foreground': 'ink', + destructive: 'error-ink', + // White on the red of a destructive button, 6.6:1 (board « Contrastes vérifiés »). + 'destructive-foreground': 'bg', + border: 'line', + input: 'control-border', + ring: 'ink', + sidebar: 'surface', + 'sidebar-foreground': 'ink', + 'sidebar-accent': 'bg', + 'sidebar-accent-foreground': 'ink', + 'sidebar-primary': 'blue', + 'sidebar-primary-foreground': 'on-blue', + 'sidebar-border': 'line', + 'sidebar-ring': 'ink', +}; + +/** Blocks, tiles, panels and tables: 12 px, the `--radius` of shadcn (`radius-photo`). */ +const shadcnRadiusToken = 'photo'; + +module.exports = { shadcnColorTokens, shadcnRadiusToken }; diff --git a/packages/ui/src/components/Button.tsx b/packages/ui/src/components/Button.tsx new file mode 100644 index 00000000..958fc4f2 --- /dev/null +++ b/packages/ui/src/components/Button.tsx @@ -0,0 +1,55 @@ +import { Slot } from '@radix-ui/react-slot'; +import { cva, type VariantProps } from 'class-variance-authority'; +import type { ComponentProps } from 'react'; +import { cn } from '../lib/cn'; + +/** + * Buttons of the web interfaces (wiki Ecrans › Admin web, « Formes et tailles »): pills of 36 px, + * 32 px small, 44 px on the sign-in page. `destructive` is kept for the confirmation that repeats + * the action; elsewhere a destructive action opens with `destructiveOutline`. + */ +const buttonVariants = cva( + 'inline-flex shrink-0 items-center justify-center gap-8 whitespace-nowrap rounded-pill transition-colors disabled:pointer-events-none disabled:opacity-50', + { + variants: { + variant: { + primary: 'bg-primary text-primary-foreground hover:bg-blue-ink', + secondary: 'bg-secondary text-secondary-foreground hover:bg-line', + outline: 'border border-input bg-background text-foreground hover:bg-accent', + ghost: 'text-foreground hover:bg-accent', + destructive: 'bg-destructive text-destructive-foreground', + destructiveOutline: 'border border-destructive bg-background text-destructive', + }, + size: { + small: 'min-h-32 px-12 text-label-strong', + medium: 'min-h-chip-h px-16 text-control', + large: 'min-h-touch-min px-20 text-button', + }, + }, + defaultVariants: { variant: 'primary', size: 'medium' }, + }, +); + +export type ButtonProps = ComponentProps<'button'> & + VariantProps & { + /** Renders the child (a link) with the look of the button instead of a `button`. */ + asChild?: boolean; + }; + +export function Button({ + className, + variant, + size, + asChild = false, + type = 'button', + ...props +}: ButtonProps) { + const Component = asChild ? Slot : 'button'; + return ( + + ); +} diff --git a/packages/ui/src/components/DropdownMenu.tsx b/packages/ui/src/components/DropdownMenu.tsx new file mode 100644 index 00000000..46bb5be8 --- /dev/null +++ b/packages/ui/src/components/DropdownMenu.tsx @@ -0,0 +1,50 @@ +'use client'; + +import * as MenuPrimitive from '@radix-ui/react-dropdown-menu'; +import type { ComponentProps } from 'react'; +import { cn } from '../lib/cn'; + +export const DropdownMenu = MenuPrimitive.Root; +export const DropdownMenuTrigger = MenuPrimitive.Trigger; + +/** Floating panel of a menu, under its trigger and aligned on its right edge by default. */ +export function DropdownMenuContent({ + className, + align = 'end', + sideOffset = 8, + ...props +}: ComponentProps) { + return ( + + + + ); +} + +/** An action of a menu: a row as high as a button, highlighted under the pointer or the focus. */ +export function DropdownMenuItem({ + className, + ...props +}: ComponentProps) { + return ( + + ); +} + +export function DropdownMenuSeparator() { + return ; +} diff --git a/packages/ui/src/components/Input.tsx b/packages/ui/src/components/Input.tsx new file mode 100644 index 00000000..0e32574f --- /dev/null +++ b/packages/ui/src/components/Input.tsx @@ -0,0 +1,25 @@ +import type { ComponentProps } from 'react'; +import { cn } from '../lib/cn'; + +/** Text field of 40 px (wiki Ecrans › Admin web, « Formes et tailles »); 44 px with `large`. */ +export function Input({ + className, + large = false, + ...props +}: ComponentProps<'input'> & { large?: boolean }) { + return ( + + ); +} + +/** Label of a field, tied to it by `htmlFor`. */ +export function Label({ className, ...props }: ComponentProps<'label'>) { + return