rubis/docs/tech/frontend.md
ordinarthur 1c5a58e09a
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 26s
Build & Deploy Landing / build-and-deploy (push) Successful in 27s
Build & Deploy API / build-and-deploy (push) Successful in 1m18s
chore(domain): migrate rubis.arthurbarre.fr → rubis.pro
Bascule du domaine principal vers rubis.pro / app.rubis.pro :

- K3s ConfigMaps (api.yml, web.yml) : APP_URL, WEB_URL,
  COOKIE_DOMAIN, OAUTH callbacks pointent vers app.rubis.pro
- Dockerfile.web : ARG VITE_API_URL et VITE_PUBLIC_LANDING_URL
- Workflows Gitea : commentaires + build args web → rubis.pro
- Code API (mail_dispatcher, send_test_email, config/mail) :
  defaults env LANDING_URL et MAIL_FROM_ADDRESS migrés
- Templates env (.env.example) idem
- Docs (architecture, backend, frontend, brand-identity) idem
- AGENTS.md / CLAUDE.md / deploy-memory : pointeurs domaine MAJ

Note : MAIL_FROM_ADDRESS dans le secret K3s reste sur
rubis@arthurbarre.fr tant que le domaine rubis.pro n'est pas
Verified dans Resend. À switcher manuellement après vérif Resend.

Compat : un 301 Traefik redirige rubis.arthurbarre.fr → rubis.pro
(et app.X aussi) — config Ansible dans le repo proxmox.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 21:32:31 +02:00

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-white en pleine page → toujours bg-cream (#FAF7F2)
  • Pas de bg-black ni text-black → utiliser bg-ink et text-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.pro
  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.tsx colocalisé avec le composant

Style code

  • TypeScript strict: true, pas de any non justifié
  • Imports absolus via alias @/* mappé sur src/* (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.pro

Production via secret K3s injecté dans le build Vite :

VITE_API_URL=https://api.rubis.pro
VITE_PUBLIC_LANDING_URL=https://rubis.pro

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.md section 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/refresh au 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 les paths du tsconfig.json pour 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.