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

764 lines
23 KiB
Markdown

# 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`) :
```bash
mkdir -p apps/web && cd apps/web
pnpm create vite@latest . --template react-ts
```
Choix Vite : `react-ts` (TypeScript natif).
### Dépendances runtime
```bash
# 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
```bash
# 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` :
```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` :
```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`
```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` :
```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
```tsx
<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` :
```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` :
```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` :
```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` :
```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` :
```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
```ts
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](https://github.com/Julien-R44/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/`)
```bash
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) :
```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é :
```bash
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/`)
```bash
cd apps/web
pnpm add @tuyau/client
```
`src/lib/api.ts` :
```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
```tsx
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` :
```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` :
```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) :
```ts
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 :
```ts
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é) :
```bash
VITE_API_URL=http://localhost:3333
VITE_PUBLIC_LANDING_URL=https://rubis.pro
```
Production via secret K3s injecté dans le build Vite :
```bash
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
```bash
pnpm -F web build # produit apps/web/dist/
```
### Image Docker
`Dockerfile.web` à la racine du monorepo :
```dockerfile
# 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`.*