Files
tribunal-app/CLAUDE.md
T
Valentin ROBIN 6af56d2ade
Build and deploy / deploy (push) Successful in 37s
Ajoute La Course du Char : mini-jeu de course 2D quotidien
Premier mini-jeu compétitif du Tribunal (/course) : piste 2D générée
de façon déterministe à partir de la date du jour (identique pour tout
le monde, jamais stockée côté serveur), pilotage au joystick tactile
virtuel, murs + obstacles qui ralentissent temporairement sans jamais
stopper la course, fantômes du top 3 du jour. Chacun rejoue autant
qu'il veut, seul le meilleur temps compte.

Moteur de jeu maison (src/lib/chariot/) : canvas + requestAnimationFrame,
sans dépendance externe, dans la continuité de l'existant (roulette).

Navigation : nouvelle entrée "La Course du Char" (IconChariot), route
protégée par le proxy comme le reste de l'app.
2026-07-27 18:54:04 +02:00

159 lines
15 KiB
Markdown

@AGENTS.md
# Le Tribunal
Application web pour un groupe d'amis (~12 personnes) qui anime un jeu et des olympiades pendant une semaine de vacances : comptes, rôles, leaderboard en direct, attribution de points par des juges, journal public des événements. Habillée sur le thème d'un tribunal de la Grèce antique (voir section 6).
## 1. État actuel
V1 à V6 sont livrées :
- **V1** : comptes, profils, leaderboard statique.
- **V2** : authentification par email réel (plus de pseudo+email interne), rôles `public`/`judge`, verrou de pseudo (modifiable une fois), section Admin.
- **V3** : attribution de points par les juges (RPC sécurisée, +/- avec confirmation explicite), journal public « le crieur », podium top 3, flèches de progression, recadrage/compression photo côté client.
- **V4** : direction artistique Grèce antique (marbre/or/mer de nuit, polices Cinzel/Cormorant Garamond/Manrope, vocabulaire Archontes/Citoyens/Agora/Crieur), header global avec chip utilisateur.
- **V5** : navigation entièrement repliée dans le menu déroulant du chip (header épuré : logo + chip seulement), page **La Roulette** (tirage au sort animé), page **Le Calendrier des Dieux** (agenda de la semaine, panthéon, date du Tribunal), boutons de validation des points unifiés (`PointsConfirmControls`) entre podium et classement.
- **V6** : premier mini-jeu compétitif, **La Course du Char** (`/course`) — piste 2D générée aléatoirement chaque jour (identique pour tout le monde, calculée côté client à partir de la date), joystick tactile virtuel, murs/obstacles qui ralentissent temporairement, fantômes du top 3 du jour, seul mécanisme de points de tout le projet qui s'attribue **automatiquement** (tâche planifiée `pg_cron`, sans intervention d'un Archonte).
Hors périmètre pour l'instant (voir roadmap en fin de doc) : éditions/saisons, le reste du jeu du Tribunal (ostracisme, timer du Gardien — la Course du Char est livrée en V6), mode grand écran.
---
## 2. Stack technique
- **Framework** : Next.js 16 (App Router) + TypeScript
- **Style** : Tailwind CSS v4 (design tokens en variables CSS, voir `globals.css`)
- **Auth + Base + Stockage + Temps réel** : Supabase (Auth, Postgres, Storage, Realtime)
- **Recadrage photo** : `react-easy-crop` + canvas natif (resize/compression client, jamais d'upload de l'image brute)
- **Déploiement** : Vercel (front) + Supabase (managé) — pas encore déployé, tourne en local pour l'instant
Next.js 16 a renommé `middleware.ts` en `proxy.ts` (fichier `src/proxy.ts`, fonction `proxy`). `src/lib/supabase/middleware.ts` est un nom de fichier utilitaire interne, sans rapport avec cette convention.
---
## 3. Modèle de données (`supabase/schema.sql`, source de vérité)
Le script est **idempotent** : toujours le ré-exécuter en entier après une modification, jamais de migration incrémentale séparée.
### `public.profiles` (liée à `auth.users`, créée automatiquement par un trigger sur `auth.users`)
| Colonne | Notes |
|-----------------|-------------------------------------------------------------------|
| `id` | = `auth.users.id` |
| `pseudo` | affiché partout, unique (insensible à la casse) |
| `avatar_url` | URL publique Storage (`{user_id}.webp`) |
| `points` | jamais modifiable directement — uniquement via RPC `award_points` |
| `role` | `public` (défaut) ou `judge` |
| `pseudo_locked` | passe à `true` automatiquement au 1er changement de pseudo |
| `previous_rank` | repère de classement, mis à jour par `reset_rank_reference()` |
### `public.points_log` (journal, lecture seule côté client)
`id, target_id, judge_id, delta, reason, created_at` — écrit uniquement par la RPC `award_points`.
### `public.days` / `public.events` / `public.settings` (Calendrier des Dieux)
- `days` : `id, date (unique), god_name, god_domain, description, created_at`.
- `events` : `id, day_id (fk days), title, description, type ('activite'|'defi'|'epreuve'|'tribunal'), start_time, location, created_by (fk profiles, forcé par trigger), created_at`.
- `settings` : ligne unique (`id boolean primary key default true`, contrainte `check (id)`) — `tribunal_date`.
- RLS : `SELECT` ouvert à tout authentifié ; `INSERT`/`UPDATE`/`DELETE` réservés au rôle `judge`, vérifié par policy (`exists (select 1 from profiles where id = auth.uid() and role = 'judge')`) — pas de trigger `BEFORE UPDATE` ici car il n'y a pas de colonne à protéger *dans une ligne par ailleurs modifiable par tous* (contrairement à `profiles`) : toute la table est verrouillée en écriture aux juges.
### `public.chariot_runs` / `public.chariot_race_closes` (La Course du Char)
- `chariot_runs` : `user_id, race_date, best_time_ms, ghost_path (jsonb), updated_at` — clé primaire `(user_id, race_date)`, une seule ligne par joueur et par jour (le meilleur temps uniquement, pas l'historique des tentatives). Verrouillée comme `points_log` : lecture ouverte aux authentifiés, écriture uniquement via la RPC `submit_chariot_run`.
- `chariot_race_closes` : table interne (aucune policy) qui marque les jours déjà clôturés, pour que la tâche planifiée reste idempotente.
- Pas de table `tracks` : la piste du jour est une fonction pure côté client (`src/lib/chariot/track.ts`, `generateTrack(dateKey)`), graine = date du jour Europe/Paris — jamais stockée côté serveur.
### Colonnes sensibles : RLS + trigger, jamais confiance au client
La RLS est **au niveau ligne** : elle ne peut pas exprimer « cette colonne seulement si tel rôle ». Le verrouillage fin des colonnes (`role`, `points`, `pseudo_locked`, pseudo figé, `previous_rank`) passe par le trigger `enforce_profile_update()` :
- `points` : bloqué pour **tout le monde**, y compris les juges, en écriture directe. Seule la RPC `award_points()` (SECURITY DEFINER) peut la modifier, via un flag de session (`app.bypass_points_lock`) qui autorise explicitement CETTE écriture précise.
- `role`, `previous_rank` : modifiables uniquement par un `judge`.
- `pseudo` : modifiable une fois par le propriétaire (auto-lock ensuite), ou à tout moment par un `judge`.
- `auth.uid() is null` (SQL Editor, migrations) bypass le trigger : ces accès sont déjà fiables par construction.
Ce pattern (RLS pour l'accès à la ligne + trigger `BEFORE UPDATE` pour l'accès aux colonnes) est la convention du projet — le reproduire pour toute nouvelle colonne sensible plutôt que d'inventer autre chose.
### RPC exposées
- `is_pseudo_taken(p_pseudo)` — anon + authenticated, ne renvoie qu'un booléen (l'anon ne peut pas lire `profiles`).
- `award_points(p_target_id, p_delta, p_reason)` — authenticated, vérifie `role = 'judge'` côté serveur, jamais côté client.
- `reset_rank_reference()` — authenticated, vérifie `role = 'judge'`, fige le classement courant dans `previous_rank`.
- `submit_chariot_run(p_time_ms, p_ghost_path)` — authenticated, calcule `race_date` côté serveur (jamais fourni par le client), borne le temps (anti-triche minimal), n'écrase le meilleur temps du jour que s'il est strictement battu.
- `close_daily_chariot_race()`**pas de grant à authenticated**, appelée uniquement par la tâche planifiée `pg_cron` (ou depuis le SQL Editor) : classe la journée précédente, attribue les gloires du top 3 automatiquement (`judge_id = null` dans `points_log`, affiché comme « Le Tribunal » dans Le Crieur).
---
## 4. Authentification
**Sur invitation uniquement**, plus d'auto-inscription publique : "Allow new users to sign up" est désactivé côté Supabase. Un Archonte invite via Authentication → Users → Invite user ; le lien reçu établit une session directement sur `/signup`, qui sert alors à finaliser le compte (mot de passe, pseudo, photo) — sans session valide, la page affiche un message « invitation requise ». Le profil est créé dès l'invitation par le trigger `handle_new_user` (pseudo temporaire `"Nouveau membre"` jusqu'à finalisation). Nécessite un SMTP custom côté Supabase (le service intégré est trop limité en volume, voir README). Le pseudo est un simple nom d'affichage stocké dans `profiles`, jamais l'email n'est montré aux autres membres.
---
## 5. Pages & navigation
| Route | Contenu |
|----------------|---------------------------------------------------------------------------|
| `/` | Redirige vers `/leaderboard` si connecté, sinon `/login` |
| `/login` | Email + mot de passe |
| `/signup` | Email + mot de passe + pseudo + photo (recadrée) ; gère l'attente de confirmation email |
| `/leaderboard` | « L'Agora » — podium top 3 + liste, Realtime, contrôles de points pour les juges |
| `/roulette` | « La Roulette » — tirage au sort animé parmi les membres (ou Citoyens uniquement) |
| `/course` | « La Course du Char » — mini-jeu de course 2D (piste quotidienne, joystick tactile, fantômes du top 3), points attribués automatiquement |
| `/calendrier` | « Le Calendrier des Dieux » — agenda de la semaine, panthéon, date du Tribunal, édition réservée aux juges |
| `/journal` | « Le Crieur » — fil live des décrets (attributions de points), lecture pour tous |
| `/profile` | Pseudo (verrouillable), photo, déconnexion |
| `/admin` | « Le Conseil des Archontes » — juges uniquement, **vérifié côté serveur** (Server Component) — gestion des membres, reset du repère de classement |
`/leaderboard`, `/profile`, `/admin`, `/journal`, `/roulette`, `/calendrier`, `/course` sont protégées par `src/proxy.ts` (redirection `/login` si non connecté). L'accès juge-only de `/admin` n'est **pas** géré par le proxy (il n'a pas facilement le rôle) — c'est la page elle-même qui vérifie et redirige ; même principe pour les boutons d'édition du Calendrier (RLS + vérification serveur, jamais un simple masquage front).
Le header global (`components/header.tsx`) est rendu dans `layout.tsx` pour tout utilisateur connecté : logo (couronne de laurier + « Le Tribunal », jamais masqué même à 320px) à gauche, chip utilisateur (avatar, pseudo, points, jamais masqués) à droite. **Aucun lien de navigation dans le header lui-même** — le chip ouvre un menu déroulant qui contient toutes les pages (avec icônes), fermeture au clic extérieur/Échap, entrée courante surlignée.
---
## 6. Look & feel — Grèce antique
Thème **fixe et codé en dur** (pas de système de re-thématisation par édition — voir roadmap). Un tribunal égéen de nuit : marbre, or, mer sombre, sceaux de cire.
Tokens couleur (`globals.css`, exposés comme utilities Tailwind via `@theme inline`) :
- `--ink #0A1B33` / `--ink-2 #0F2748` : fond de l'app (dégradés radiaux mer de nuit), header, boutons primaires.
- `--marble #F4ECD8` / `--marble-2 #E9DEC2` : surfaces claires (`.marble-surface`, classe utilitaire avec léger dégradé/grain).
- `--gold #C9A227` / `--gold-bright #E7C560` : accents, bordures, 1ʳᵉ place. **Jamais** pour du texte courant sur `marble` (contraste insuffisant) — utiliser `--text-marble` à la place.
- `--oxblood #A5342A` : blâmes (points négatifs), sceaux, erreurs.
- `--olive #5E6B3B` : honneurs (points positifs), succès.
- `--sea #2E6E7E` : accent secondaire.
- `--silver #C7CDD6` / `--bronze #B08D57` : accents 2ᵉ/3ᵉ place du podium.
- `--text-marble #2A2116` / `--text-mut #7A6A4C` : texte sur surfaces claires.
`components/points-confirm-controls.tsx` : boutons ✓/✕ compacts (32px, icône 16px) de validation/annulation des points en attente — utilisés à l'identique par le podium et le reste du classement (`JudgePointControls`). Ne pas dupliquer cette UI ailleurs, toujours passer par ce composant.
Polices (`next/font/google`, variables CSS, exposées comme `font-heading`/`font-serif`/`font-sans`) : **Cinzel** (titres, noms, chiffres de score — majuscules, `tracking-[0.1em]`), **Cormorant Garamond** (citations/sous-titres, italique), **Manrope** (interface, corps de texte).
Ornements en SVG/CSS inline (jamais d'image bitmap) :
- `components/laurel-wreath.tsx` — couronne de laurier calculée par trigonométrie (deux arcs symétriques de feuilles), utilisée en logo, sur le rang 1 du podium, etc. Le favicon (`src/app/icon.svg`) est une version statique des mêmes coordonnées.
- `.meander-divider` (classe globale) — frise à méandre grecque en `background-image` SVG data-URI, séparateur discret.
- `components/diamond-divider.tsx` — séparateur ◆ avec filets dégradés.
- `.marble-surface` (classe globale) — texture marbre via dégradés (pas d'image de bruit).
Convention de carte : `marble-surface rounded-2xl border border-gold/40 shadow-xl` (formulaires, podium) ou `rounded-lg border-gold/25` pour les lignes de liste. Bouton primaire : `bg-ink-2 text-gold-bright font-heading uppercase hover:bg-ink`. Bouton secondaire : `border border-gold/30 text-text-marble hover:bg-gold/10`.
Vocabulaire diégétique (toujours accompagné du terme fonctionnel dans le code/les commentaires, pour que ça reste maintenable) : juges/admins → **Archontes**, membres → **Citoyens**, leaderboard → **L'Agora**, journal des points → **Le Crieur** (décrets), points positifs/négatifs → **Honneurs**/**Blâmes**, points → **gloires**, section admin → **Le Conseil des Archontes**.
---
## 7. Conventions
- Toujours passer par les policies RLS + trigger ; ne jamais exposer la `service_role key` côté client.
- Toute nouvelle colonne sensible sur `profiles` (ou une future table) suit le pattern RLS+trigger de la section 3, pas un `grant`/`revoke` par colonne (qui ne peut pas distinguer les rôles applicatifs).
- Aperçus locaux d'image avant upload (crop, fichier choisi) = `blob:`/`data:` URL → toujours `unoptimized` sur `next/image` ou une balise `<img>` classique (`next/image` ne sait pas résoudre ces schémas côté serveur).
- Commandes : `npm run dev`, `npm run build`, `npm run lint`.
- Le schéma SQL (`supabase/schema.sql`) est la seule source de vérité pour la base — le tenir à jour à chaque changement de données/sécurité, et le garder idempotent.
---
## 8. Roadmap (pas encore codé)
- **Éditions** : table `editions` (année, mood board) ; les points deviendront rattachables à une édition donnée. Le thème visuel (Grèce antique) reste fixe, ce n'est pas un système de re-thématisation par édition.
- **Le jeu du Tribunal (suite)** : batailles de cul sec, ostracisme, timer du Gardien. (La Roulette est livrée en V5, la Course du Char en V6.)
- **Mode grand écran** : vue leaderboard optimisée pour vidéoprojecteur.
- **Déploiement** : pas encore fait (Vercel + Supabase managé prévus, voir README).