Monorepo Turborepo (pnpm workspaces) avec 3 packages :
- apps/web : SPA React 19 + Vite 8 + Tailwind v4 (CSS-first)
• TanStack Router (file-based, auto code-splitting), Query, Form
• Radix primitives bruts + CVA + clsx + tailwind-merge
• MSW pour mocker l'API tant qu'Adonis n'est pas branché
• Polices Bricolage Grotesque + Inter self-hostées via fontsource
• Tokens marque (rubis, cream, ink) exposés via @theme
• Primitives maison : Gem, Brand, Eyebrow, Button, Input, Field
• Route /login full flow : TanStack Form + Zod + mutation Query
- apps/api : Adonis 7 (kit api, scaffold via create-adonisjs)
• Auth access tokens (Bearer) — cf. ADR-017
• Tuyau core déjà câblé pour la génération de types
• Routes /api/v1/auth/{signup,login} + /api/v1/account/{profile,logout}
• Minimal — uniquement le pont front ↔ back
- packages/shared : types TS + schemas Zod + constantes
• Source unique de vérité partagée api ↔ web
• Domaines : User, Org, Auth, Client, Invoice, Plan
Tooling racine : Turbo, ESLint v9 flat, Prettier, husky, lint-staged.
CLAUDE.md et docs/decisions.md mis à jour avec ADR-014 à ADR-018
(stack, monorepo, PG existant, Bearer tokens, MinIO existant)
et le pointeur vers docs/tech/architecture.md.
Logo Rubis déplacé de landing/assets/ vers /assets/ (source unique
réutilisée par la landing et l'app).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
23 KiB
Guide d'implémentation — Frontend
Version : 0.1 · Dernière maj : 2026-05-05 Décisions de référence : ADR-014 (stack), ADR-015 (monorepo), ADR-017 (auth).
Ce document est le guide pratique d'implémentation du SPA. Il complète architecture.md (qui décrit le quoi) en expliquant le comment : commandes exactes, snippets de config, conventions de dossier.
À lire avant :
/CLAUDE.md— contexte top-level/docs/produit.md— flows utilisateur, IN/OUT V1/docs/marque.md— palette, typo, voix, do/don't/docs/wireframes-mvp.html— les 13 écrans MVP avec annotations/docs/tech/architecture.md— vue d'ensemble du système
1. Vue d'ensemble
L'app web (apps/web/) est un SPA React 19 buildé par Vite, qui consomme l'API AdonisJS apps/api/ via un client HTTP type-safe (Tuyau). Le routing client est géré par TanStack Router (file-based, type-safe), le state serveur par TanStack Query, le styling par Tailwind CSS v4 avec les tokens de marque issus de marque.md.
Périmètre V1 : 13 écrans listés dans wireframes-mvp.html. Auth Bearer (cf. ADR-017) avec refresh token httpOnly cookie. Mobile responsive, pas d'app native.
Hors scope V1 : SSR (pas nécessaire pour un SaaS B2B authentifié), i18n (FR uniquement), PWA offline (nice-to-have V2).
2. Dépendances
Bootstrap du workspace
À exécuter à la racine du monorepo (après avoir créé pnpm-workspace.yaml) :
mkdir -p apps/web && cd apps/web
pnpm create vite@latest . --template react-ts
Choix Vite : react-ts (TypeScript natif).
Dépendances runtime
# Routing & state serveur
pnpm add @tanstack/react-router @tanstack/react-query @tanstack/react-query-devtools
# Tooling Vite pour TanStack Router (file-based)
pnpm add -D @tanstack/router-plugin
# Client HTTP typé pour AdonisJS
pnpm add @tuyau/client
# UI primitives & icônes
pnpm add lucide-react
pnpm add clsx tailwind-merge
# Validation côté client (réutilise schemas Zod de packages/shared)
pnpm add zod
# Notifications/toasts
pnpm add sonner
# Dates (formatage français)
pnpm add date-fns
Dépendances dev
# Tailwind v4
pnpm add -D tailwindcss @tailwindcss/vite
# TS strict + lint
pnpm add -D typescript@latest @types/react @types/react-dom
pnpm add -D eslint @typescript-eslint/eslint-plugin @typescript-eslint/parser
pnpm add -D eslint-plugin-react-hooks eslint-plugin-react-refresh
# Tests
pnpm add -D vitest @testing-library/react @testing-library/jest-dom jsdom
# Type-checking strict
pnpm add -D tsc-files
Référence au package shared
Dans apps/web/package.json :
{
"dependencies": {
"@rubis/shared": "workspace:*"
}
}
Permet d'importer les types et schemas Zod depuis packages/shared/ sans publication npm.
3. Tailwind CSS v4 + tokens de marque
Tailwind v4 utilise une configuration CSS-first (plus de tailwind.config.js requis). Les tokens de marque issus de marque.md deviennent des CSS variables.
Installation
vite.config.ts :
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { TanStackRouterVite } from '@tanstack/router-plugin/vite'
export default defineConfig({
plugins: [
TanStackRouterVite({ routesDirectory: 'src/routes' }),
react(),
tailwindcss(),
],
})
Tokens de marque dans src/styles/app.css
@import "tailwindcss";
@theme {
/* Couleurs rubis */
--color-rubis: #9F1239;
--color-rubis-deep: #771328;
--color-rubis-light: #C9415C;
--color-rubis-glow: #FBE4EA;
/* Neutres chauds */
--color-cream: #FAF7F2;
--color-cream-2: #F5EFE7;
--color-line: #E8E0D6;
--color-ink: #1A1410;
--color-ink-2: #4F4640;
--color-ink-3: #8A7F76;
/* Typographies */
--font-display: "Bricolage Grotesque", -apple-system, BlinkMacSystemFont, sans-serif;
--font-sans: "Inter", -apple-system, BlinkMacSystemFont, sans-serif;
}
/* Globals */
body {
font-family: var(--font-sans);
background: var(--color-cream);
color: var(--color-ink);
font-feature-settings: "ss01", "cv11";
-webkit-font-smoothing: antialiased;
}
/* Selection */
::selection {
background: var(--color-rubis);
color: white;
}
Importé dans main.tsx : import './styles/app.css'.
Polices Google Fonts
index.html :
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,400;12..96,500;12..96,600;12..96,700;12..96,800&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
Usage typique
<button className="bg-rubis text-white hover:bg-rubis-deep px-4 py-2 rounded-md font-medium">
Démarrer
</button>
<h1 className="font-display text-4xl tracking-tight">
Bonjour Arthur
</h1>
Règles de marque appliquées
- Pas de
bg-whiteen pleine page → toujoursbg-cream(#FAF7F2) - Pas de
bg-blacknitext-black→ utiliserbg-inkettext-ink(#1A1410) - Le rubis est rare → un seul aplat fort par écran maximum
- Italique rubis sur le mot-clé d'un titre :
<em className="italic text-rubis"> - Le ◆ est un SVG custom, jamais une icône Lucide (cf.
marque.md)
4. TanStack Router — routing file-based
Pourquoi file-based
Le routing file-based est type-safe nativement (les params, search params, et loaders sont inférés depuis les fichiers), il évite la déclaration manuelle d'un router central, et il s'aligne avec la structure d'écrans du wireframe.
Structure des routes
Référence : les 13 écrans dans wireframes-mvp.html.
apps/web/src/routes/
├── __root.tsx # Layout global, providers, AuthGate
├── login.tsx # 1.2 Connexion
├── signup.tsx # 1.1 Inscription
├── _onboarding/ # Layout onboarding (sans sidebar)
│ ├── _onboarding.tsx
│ ├── compte.tsx # 1.3 step 1
│ ├── entreprise.tsx # 1.3 step 2
│ └── signature.tsx # 1.3 step 3
└── _app/ # Layout app authentifiée
├── _app.tsx # Layout : sidebar + topbar + tab bar mobile
├── index.tsx # 4.1 Dashboard
├── factures.tsx # 2.4 Liste filtrable
├── factures.$id.tsx # 4.2 Détail facture (timeline)
├── factures.import.$batchId.tsx # 2.2 Vérification OCR
├── plans.tsx # 3.1 Bibliothèque
├── plans.$slug.tsx # 3.2 Éditeur (cadence + templates)
├── clients.tsx # liste clients
└── parametres.tsx # paramètres compte
Les routes commençant par _ sont des layout routes (n'ajoutent pas de segment URL).
Configuration root
src/routes/__root.tsx :
import { createRootRoute, Outlet } from '@tanstack/react-router'
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { Toaster } from 'sonner'
export const Route = createRootRoute({
component: RootLayout,
notFoundComponent: NotFound,
})
function RootLayout() {
return (
<>
<Outlet />
<Toaster position="bottom-right" />
{import.meta.env.DEV && (
<>
<TanStackRouterDevtools />
<ReactQueryDevtools />
</>
)}
</>
)
}
function NotFound() {
return <div className="p-8">Page introuvable.</div>
}
Auth guard sur le layout _app
src/routes/_app/_app.tsx :
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
import { AppLayout } from '@/components/layout/AppLayout'
import { authStore } from '@/lib/auth'
export const Route = createFileRoute('/_app')({
beforeLoad: async ({ location }) => {
if (!authStore.isAuthenticated()) {
throw redirect({
to: '/login',
search: { redirect: location.href },
})
}
},
component: AppLayoutComponent,
})
function AppLayoutComponent() {
return (
<AppLayout>
<Outlet />
</AppLayout>
)
}
Search params type-safe (filtres factures)
src/routes/_app/factures.tsx :
import { z } from 'zod'
import { createFileRoute } from '@tanstack/react-router'
const filterSchema = z.object({
statut: z.enum(['toutes', 'a_relancer', 'en_relance', 'encaissees', 'litige']).optional(),
q: z.string().optional(),
page: z.number().int().min(1).optional().default(1),
})
export const Route = createFileRoute('/_app/factures')({
validateSearch: filterSchema,
component: FacturesPage,
})
→ Les filtres sont dans l'URL, partageables par lien, persistés au reload, type-safe à l'usage : Route.useSearch() retourne le bon type.
5. TanStack Query — state serveur
Provider racine
src/main.tsx :
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { RouterProvider, createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000, // 30s par défaut, ajusté par query
gcTime: 5 * 60_000, // 5 min en cache
retry: 1,
refetchOnWindowFocus: false,
},
mutations: {
retry: 0,
},
},
})
const router = createRouter({
routeTree,
context: { queryClient },
defaultPreload: 'intent',
})
declare module '@tanstack/react-router' {
interface Register { router: typeof router }
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</StrictMode>
)
Convention queryKeys
Dans src/lib/queryKeys.ts :
export const queryKeys = {
me: () => ['me'] as const,
invoices: {
all: () => ['invoices'] as const,
list: (filters: InvoiceFilters) => ['invoices', 'list', filters] as const,
detail: (id: string) => ['invoices', 'detail', id] as const,
},
plans: {
all: () => ['plans'] as const,
detail: (slug: string) => ['plans', 'detail', slug] as const,
},
clients: {
all: () => ['clients'] as const,
},
dashboard: {
kpis: () => ['dashboard', 'kpis'] as const,
activity: () => ['dashboard', 'activity'] as const,
},
} as const
→ Permet d'invalider précisément après une mutation : queryClient.invalidateQueries({ queryKey: queryKeys.invoices.all() }).
Patterns d'invalidation après mutation
const markPaidMutation = useMutation({
mutationFn: (id: string) => api.invoices({ id }).markPaid.$post(),
onSuccess: (_, id) => {
queryClient.invalidateQueries({ queryKey: queryKeys.invoices.all() })
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard.kpis() })
toast.success('Facture marquée encaissée. + 1 rubis.')
},
})
6. Tuyau — client HTTP typé pour AdonisJS
Tuyau est l'équivalent tRPC pour AdonisJS. Il génère un client TS qui connaît toutes les routes API, leurs payloads, et leurs réponses — depuis le code Adonis lui-même.
Côté API (à faire dans apps/api/)
cd apps/api
pnpm add @tuyau/core
node ace add @tuyau/core
Configuration : annoter les routes Adonis avec un nom (utilisé pour l'autocomplete TS) :
// apps/api/start/routes.ts
router.group(() => {
router.post('/auth/login', '#controllers/auth_controller.login').as('auth.login')
router.get('/me', '#controllers/me_controller.show').as('me.show').use(middleware.auth())
router.get('/invoices', '#controllers/invoices_controller.index')
.as('invoices.index').use(middleware.auth())
router.post('/invoices', '#controllers/invoices_controller.store')
.as('invoices.store').use(middleware.auth())
router.get('/invoices/:id', '#controllers/invoices_controller.show')
.as('invoices.show').use(middleware.auth())
// ...
}).prefix('/api/v1')
Génération du client typé :
node ace tuyau:generate
Crée .adonisjs/api.ts qui décrit toutes les routes en types TS. Ce fichier est versionné OU regénéré au build (à choisir).
Côté SPA (apps/web/)
cd apps/web
pnpm add @tuyau/client
src/lib/api.ts :
import { createTuyau } from '@tuyau/client'
import { api } from '../../../api/.adonisjs/api' // import des types depuis l'API
export const tuyau = createTuyau({
api,
baseUrl: import.meta.env.VITE_API_URL, // ex. https://api.rubis-sur-l-ongle.fr
credentials: 'include', // envoie les cookies (refresh token)
headers: () => ({
Authorization: authStore.token ? `Bearer ${authStore.token}` : '',
}),
})
Intégration avec TanStack Query
import { useQuery } from '@tanstack/react-query'
import { tuyau } from '@/lib/api'
import { queryKeys } from '@/lib/queryKeys'
export function useInvoices(filters: InvoiceFilters) {
return useQuery({
queryKey: queryKeys.invoices.list(filters),
queryFn: async () => {
const { data, error } = await tuyau.api.v1.invoices.$get({ query: filters })
if (error) throw error
return data // typé depuis le controller Adonis
},
})
}
→ Zéro DTO manuel, autocomplete partout, erreur de compilation si l'API change sans MAJ du SPA. Le contrat API ↔ web est verrouillé par TS.
Génération automatique au dev
Ajouter un script apps/api/package.json :
{
"scripts": {
"tuyau:watch": "node ace tuyau:generate --watch"
}
}
→ Pendant le dev, modifier une route API régénère les types instantanément côté SPA.
7. Auth Bearer + refresh token (cf. ADR-017)
Auth store (en mémoire, pas localStorage)
src/lib/auth.ts :
type AuthState = {
token: string | null
user: User | null
}
class AuthStore {
private state: AuthState = { token: null, user: null }
private listeners = new Set<() => void>()
get token() { return this.state.token }
get user() { return this.state.user }
isAuthenticated() { return this.state.token !== null }
setSession(token: string, user: User) {
this.state = { token, user }
this.notify()
}
clear() {
this.state = { token: null, user: null }
this.notify()
}
subscribe(fn: () => void) {
this.listeners.add(fn)
return () => this.listeners.delete(fn)
}
private notify() { this.listeners.forEach(fn => fn()) }
}
export const authStore = new AuthStore()
→ Le token vit en mémoire. Au refresh de la page, il est perdu mais récupérable via /auth/refresh (qui lit le cookie httpOnly).
Bootstrap session au boot du SPA
src/main.tsx (avant le render) :
async function bootstrap() {
try {
const { data } = await tuyau.api.v1.auth.refresh.$post()
if (data) authStore.setSession(data.accessToken, data.user)
} catch {
// pas de refresh valide, rester anonyme
}
}
bootstrap().then(() => render())
→ Si l'utilisateur a déjà une session valide (cookie refresh non expiré), le SPA récupère un access token avant le 1er render. Pas d'écran flash de login.
Auto-refresh sur 401
Intercepteur dans tuyau (à customiser) ou dans un wrapper api() qui retry sur 401 après un refresh silent.
8. Pages à construire
Référence visuelle : /docs/wireframes-mvp.html. Référence brand : /docs/marque.md.
| # | Route | Wireframe | Priorité | Notes |
|---|---|---|---|---|
| 1 | /login |
1.2 | P0 | Email + password + Google SSO. |
| 2 | /signup |
1.1 | P0 | 2 champs + Google SSO. |
| 3 | /onboarding/compte |
1.3 step 1 | P0 | Préfilled email après signup. |
| 4 | /onboarding/entreprise |
1.3 step 2 | P0 | Wizard avec chips volume mensuel. |
| 5 | /onboarding/signature |
1.3 step 3 | P0 | Signature email pour les relances. |
| 6 | /_app/ (Dashboard) |
4.1 | P0 | Hero rubis, KPIs, activité du jour. |
| 7 | /_app/factures |
2.4 | P0 | Liste + chips de filtre + actions en lot. |
| 8 | /_app/factures (empty) |
2.1 | P0 | Dropzone + drag&drop. |
| 9 | /_app/factures/import/$batchId |
2.2 | P0 | Split PDF / formulaire OCR. |
| 10 | (modal) | 2.3 | P1 | Saisie manuelle. |
| 11 | /_app/factures/$id |
4.2 | P0 | Timeline + sidepanel client + notes. |
| 12 | /_app/plans |
3.1 | P0 | Cards 4 plans pré-fournis. |
| 13 | /_app/plans/$slug |
3.2 | P0 | Éditeur cadence + email avec variables chips. |
| 14 | (storyboard 3 clics) | 3.3 | — | Concept landing, pas un écran applicatif. |
| 15 | Mobile dashboard | 4.3 | P0 | Responsive, pas une route séparée. |
Composants UI partagés à factoriser tôt :
<Brand>— gem ◆ + wordmark "Rubis" / "Rubis Sur l'Ongle"<Button variant="primary"|"secondary"|"ghost">— cohérent avec la landing<Input>,<Textarea>,<Select>— avec label + erreur<Card>,<Panel>— surfaces standardisées<Modal>— pour mise en demeure (validation manuelle obligatoire), saisie manuelle<Chip>— filtres et tags de volume/tonalité<RubisCounter>— composant héros gamification<Stepper>— wizard onboarding 3 étapes<Timeline>— pour le détail facture<Dropzone>— drag & drop multi-fichiers PDF
9. Conventions
Structure de dossiers
apps/web/src/
├── routes/ # TanStack Router file-based
├── components/
│ ├── ui/ # Primitives (Button, Input, Card…)
│ ├── layout/ # AppLayout, OnboardingLayout
│ ├── factures/ # Composants spécifiques au domaine factures
│ ├── plans/ # Composants spécifiques aux plans
│ └── shared/ # Brand, RubisCounter, Stepper…
├── lib/
│ ├── api.ts # Client Tuyau
│ ├── auth.ts # Auth store
│ ├── queryKeys.ts # Convention queryKeys
│ ├── format.ts # Formateurs (€, dates, durées en rubis…)
│ └── utils.ts # cn() helper, divers
├── hooks/
│ ├── useInvoices.ts # Wrapper TanStack Query par domaine
│ ├── usePlans.ts
│ └── …
├── styles/
│ └── app.css # Imports Tailwind + tokens
└── main.tsx # Bootstrap + providers
Naming
- Composants :
PascalCase.tsx - Hooks :
useCamelCase.ts - Helpers :
camelCase.ts - Routes :
kebab-case.tsx(TanStack Router file-based) - Tests :
*.test.tsxcolocalisé avec le composant
Style code
- TypeScript
strict: true, pas deanynon justifié - Imports absolus via alias
@/*mappé sursrc/*(tsconfig.json+vite.config.ts) - Préférer destructuring + early returns plutôt qu'imbriquer
- Composants fonctionnels uniquement, hooks pour la logique
- Une responsabilité par composant — refactor en plusieurs fichiers dès qu'un composant dépasse ~200 lignes
Formateurs métier
Centraliser dans src/lib/format.ts les conversions récurrentes :
export const formatEuros = (cents: number) => /* "1 240,00 €" */
export const formatRubisToHours = (rubis: number) => /* "20 h 40" */
export const formatDate = (iso: string) => /* "5 mai 2026" */
export const formatRelativeDate = (iso: string) => /* "dans 3 jours" */
→ Cohérence de l'affichage partout, et on évite que chaque composant fasse son Intl.NumberFormat à la main.
10. Variables d'environnement
apps/web/.env.local (git-ignoré) :
VITE_API_URL=http://localhost:3333
VITE_PUBLIC_LANDING_URL=https://rubis-sur-l-ongle.fr
Production via secret K3s injecté dans le build Vite :
VITE_API_URL=https://api.rubis-sur-l-ongle.fr
VITE_PUBLIC_LANDING_URL=https://rubis-sur-l-ongle.fr
Toutes les vars accessibles côté SPA doivent être préfixées VITE_ (sinon Vite ne les expose pas au bundle).
11. Build & déploiement
Build local
pnpm -F web build # produit apps/web/dist/
Image Docker
Dockerfile.web à la racine du monorepo :
# Stage 1 : build
FROM node:22-alpine AS builder
RUN npm install -g pnpm
WORKDIR /app
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
COPY apps/web ./apps/web
COPY packages/shared ./packages/shared
RUN pnpm install --frozen-lockfile
ARG VITE_API_URL
ENV VITE_API_URL=$VITE_API_URL
RUN pnpm -F web build
# Stage 2 : nginx
FROM nginx:1.27-alpine
COPY --from=builder /app/apps/web/dist /usr/share/nginx/html
# Réutilise le même nginx.conf que la landing (try_files / SPA)
COPY infra/nginx-spa.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
Ce Dockerfile sera buildé en parallèle du Dockerfile.api par le CI Gitea, puis poussé en git.arthurbarre.fr/ordinarthur/rubis-web:<sha>.
12. Pointeurs vers l'existant
Quand tu reconstruis l'app, rien ne se réinvente sans consulter :
- Wireframes :
/docs/wireframes-mvp.html— 13 écrans avec annotations UX par écran - Brand visuel :
/docs/brand-identity.html— logo direction A, palette en application - Brand écrit :
/docs/marque.md— règles do/don't, voix - Voix & microcopy :
/docs/marque.mdsection 7 — messages de notif, empty states, erreurs - Landing déployée :
/landing/index.html— référence vivante de couleurs/spacing/typo en HTML/Tailwind-compatible
→ Avant de styler un composant, regarde la landing déployée ou le wireframe correspondant. Cohérence visuelle = signature de marque tenue.
13. Points d'attention
- Auth Bearer en mémoire : si l'utilisateur reload, l'access token est perdu — toujours appeler
/auth/refreshau boot avant le render initial (cf. section 7) - Tuyau import path : le client SPA importe les types depuis
apps/api/.adonisjs/api.ts— bien configurer lespathsdutsconfig.jsonpour que ça fonctionne en monorepo - Polices : Bricolage Grotesque doit être préchargée (preconnect Google Fonts) sinon FOUT/FOIT visible sur les titres
- Le ◆ : c'est un SVG inline, pas une icône Lucide. Composant
<Gem />à coder à part — réutilisé partout (sidebar, dashboard hero, badge mobile, CTA gamification) - Mobile-first sur les actions critiques : la photo de facture depuis le tel est un usage clé (cf. wireframe 4.3) — ne pas la traiter comme une feature secondaire
- 3 clics maximum : règle de design (cf. ADR-011) — chaque parcours doit être contesté à l'aune de cette règle
14. Décisions encore à prendre côté frontend
| Sujet | Quand trancher |
|---|---|
| Lib de formulaires (TanStack Form vs react-hook-form vs natif) | Avant le 1er formulaire complexe (signup ou plan editor) |
| State client local (Zustand vs context vs Jotai) | Probablement pas nécessaire en V1 — TanStack Query gère 95 % du state |
| Composants accessibility primitives (Radix vs Headless UI vs natif) | Avant le 1er Modal/Select complexe |
| Tests E2E (Playwright vs Cypress) | Phase polish |
Maintenu par Arthur + Claude. Les décisions structurelles passent par un ADR dans /docs/decisions.md.