Files
tribunal-app/CLAUDE.md
T
Valentin ROBIN f30f77a394
Build and deploy / deploy (push) Successful in 36s
Ajoute Le Jeu de l'Agora : Le Char et Le Gardien du Silence
Page /char (16:9, projetée) pour piloter les deux jeux physiques du
Tribunal : Le Char (2 colonnes de 3 emplacements côte à côte avec un
VS et une illustration de char vu du dessus, sélection en un clic,
duels, compteur et classement des passages) et Le Gardien du Silence
(tirage aléatoire parmi les Citoyens, minuteur avec reroll auto et
manuel). Banque de questions gérée séparément sur /char/questions
(pensée pour être pilotée depuis un téléphone pendant que /char est
projetée depuis un ordinateur).
2026-08-11 18:17:45 +02:00

19 KiB

@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 à V7 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, Le Vol d'Icare (/icare) — Flappy Bird grec (Icare vole entre des colonnes de temple), record personnel all-time (pas de piste par jour), touche l'écran pour battre des ailes, échec net au premier contact (score remis à zéro, comme l'original). Les scores sont figés dès que la date du Tribunal est atteinte, puis les gloires du top 3 sont attribuées automatiquement (tâche planifiée pg_cron, sans intervention d'un Archonte) — seul mécanisme de points de tout le projet qui ne passe pas par une décision de juge.
  • V7 : outils numériques pour le vrai jeu du Tribunal (soirée physique/sociale, pas un mini-jeu digital), page Le Jeu de l'Agora (/char) — pensée format paysage 16:9 (projetée sur grand écran) plutôt que mobile-first comme le reste de l'app. Le Char : 2 colonnes fixes de 3 emplacements affichées côte à côte avec un « VS » et une illustration de char vu du dessus entre les deux, affectation d'un Citoyen à un emplacement vide en un clic (liste des Citoyens libres directement cliquables, pas de menu déroulant), bouton « Retirer », boutons « Colonne A/B remporte le duel » (vide la colonne gagnante après une brève animation — les vainqueurs sortent, les perdants restent), compteur du nombre de fois que chacun est monté dans le char, avec un petit classement récapitulatif en bas de page (tout le monde, y compris ceux qui n'y sont jamais monté). La banque de questions (ajout/édition/réordonnancement/bascule afficher-masquer) vit sur une page séparée réservée aux Archontes, /char/questions — pensée pour être pilotée depuis un téléphone pendant que /char est projetée depuis un ordinateur ; /char n'affiche que la question actuellement révélée, en lecture seule. Le Gardien du Silence : rôle tiré au sort parmi les Citoyens (jamais les Archontes), affiché de façon discrète (barre compacte, pas une grande carte), minuteur avec reroll automatique à expiration (RPC auto-protégée, filet pg_cron) et bouton de reroll manuel pour les Archontes. L'app n'arbitre jamais le jeu lui-même, elle affiche/synchronise un état piloté à la main.

Hors périmètre pour l'instant (voir roadmap en fin de doc) : éditions/saisons, 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.icarus_scores (Le Vol d'Icare)

  • user_id, best_score, updated_at — clé primaire user_id : un seul record personnel all-time par joueur (pas de notion de jour). Verrouillée comme points_log : lecture ouverte aux authentifiés, écriture uniquement via la RPC submit_icarus_score.
  • settings.icarus_points_awarded : marqueur d'idempotence (un seul événement — l'attribution au début du Tribunal — pas une clôture quotidienne comme l'ancienne Course du Char).
  • Génération des colonnes/obstacles entièrement côté client, sans graine partagée (pas de piste identique pour tout le monde à reproduire ici — chaque partie est procédurale).

public.chariot_questions / public.chariot_slots / public.chariot_entries (Le Jeu de l'Agora — Le Char)

  • chariot_questions : id, text, position, created_at — banque de questions ordonnée, réordonnée par swap de position (pas de contrainte unique dessus, deux update non transactionnels ; toujours trier order by position, created_at). RLS : lecture ouverte, écriture réservée aux juges (comme days/events). Gérée exclusivement depuis /char/questions, jamais depuis /char.
  • chariot_slots : slot (pk, 1 à 6), user_id nullable — 6 emplacements fixes pré-remplis une fois pour toutes (1-3 = Colonne A, 4-6 = Colonne B), jamais insert/delete côté client, seulement des update pour affecter/vider un emplacement. Même RLS que chariot_questions.
  • chariot_entries : id, user_id, created_at — journal append-only (comme points_log) d'une ligne à chaque affectation d'un Citoyen à un emplacement, sert uniquement à compter combien de fois chacun est monté dans le char (count(*) group by user_id, calculé côté client). RLS : lecture ouverte, insertion réservée aux juges, aucune mise à jour/suppression possible.
  • settings.chariot_revealed_question_id : question actuellement affichée sur /char, directement modifiable par les juges (rien à protéger dans cette colonne-là) — bascule effectuée depuis /char/questions.
  • Préfixe chariot_* (pas char_*) volontaire : sans rapport avec l'ancienne Course du Char digitale supprimée, qui utilisait déjà ce préfixe.

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.

Même pattern sur public.settings depuis V7, via enforce_settings_update() : gardien_holder_id/gardien_expires_at sont bloqués en écriture directe pour tout le monde, y compris les juges — seule la RPC reroll_gardien() peut les modifier (flag app.bypass_gardien_lock), pour garantir que le Gardien est réellement tiré au sort et pas choisi à la main. tribunal_date, icarus_points_awarded et chariot_revealed_question_id restent, eux, directement modifiables par les juges (rien à protéger dans ces colonnes-là).

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_icarus_score(p_score) — authenticated, vérifie côté serveur si la date du Tribunal est déjà passée (scores figés : no-op silencieux plutôt qu'une erreur), n'écrase le record que s'il est strictement battu.
  • award_icarus_points_if_due()pas de grant à authenticated, appelée uniquement par la tâche planifiée pg_cron (ou depuis le SQL Editor) : dès que la date du Tribunal est atteinte, attribue les gloires du top 3 automatiquement (judge_id = null dans points_log, affiché comme « Le Tribunal » dans Le Crieur), une seule fois (settings.icarus_points_awarded).
  • reroll_gardien(p_force default false) — authenticated. Tire un nouveau Gardien parmi les Citoyens (jamais le détenteur actuel si possible) et repousse gardien_expires_at de 5 minutes. p_force=true (vérifie role = 'judge') reroll immédiatement ; p_force=false (appelée par le minuteur côté client à expiration, et par pg_cron en filet) ne fait rien tant que gardien_expires_at n'est pas atteint — un seul UPDATE ... WHERE atomique (pas de SELECT puis UPDATE), pour qu'un reroll naturel avec plusieurs téléphones ouverts au même moment ne reroll qu'une seule fois.

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)
/icare « Le Vol d'Icare » — Flappy Bird grec, record personnel all-time, scores figés au Tribunal, points attribués automatiquement
/char « Le Jeu de l'Agora » — format paysage (projeté) : Le Char (2 colonnes de 3, boutons de duel) et Le Gardien du Silence (tirage aléatoire, minuteur discret)
/char/questions Banque de questions du Char (ajout/édition/réordonnancement/afficher-masquer) — juges uniquement, pilotée depuis un téléphone pendant que /char est projetée
/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, /icare, /char (et donc /char/questions, couverte par le même préfixe) sont protégées par src/proxy.ts (redirection /login si non connecté). L'accès juge-only de /admin et /char/questions n'est pas géré par le proxy (il n'a pas facilement le rôle) — la page elle-même 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.
  • Mode grand écran : vue leaderboard optimisée pour vidéoprojecteur.
  • Déploiement : pas encore fait (Vercel + Supabase managé prévus, voir README).