From a5d8f5359372576221d6ba4d7da67dffa0b041ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Caruelle?= Date: Fri, 31 Jul 2026 03:11:36 +0200 Subject: [PATCH 01/11] =?UTF-8?q?feat(profil):=20le=20hors-s=C3=A9rie,=20e?= =?UTF-8?q?t=20le=20timbre=20de=20Sissi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un hors-série est l'inverse d'une famille sur tous les points : pas de palier, pas de seuil, pas de valeur faciale à gravir ; on le détient ou non ; et un profil qui ne le détient pas n'affiche RIEN, parce qu'un timbre jamais mis en vente ne peut pas manquer à un album. C'est cette asymétrie qui lui vaut son propre module plutôt qu'une cinquième famille : en émettre un ne peut jamais élargir la série courante, donc la mise en page de la feuille reste bornée quoi qu'on ajoute. Sissi est gravée couchée sur un plateau, ce qu'elle fait réellement. Le plateau est en cases carrées — celui du jeu l'est. Elle est unie, gris et blanc : ni rayures sur le dos ni anneaux à la queue, une bicolore n'est pas une tigrée. Rien ne l'attribue pour l'instant. L'easter egg qui la donne n'est pas décidé, donc le timbre est émis mais indécrochable et chaque profil porte une liste vide. Quand le déclencheur existera il n'aura qu'à la remplir. Au passage, le chrome du timbre (dentelure, papier, burelage, cadre, bandeau de pied, oblitération) sort en composant partagé : une valeur de série et un hors-série sont le même objet avec une gravure différente au milieu, et il n'y a toujours qu'une seule source du dessin. --- project.inlang/messages/en.json | 3 + project.inlang/messages/fr.json | 3 + src/lib/commemoratives.ts | 27 ++ src/server/profileData.ts | 6 + src/ui/components/Stamp.tsx | 399 ++++++++++++++++++++------ src/ui/screens/ProfileScreen.test.tsx | 66 +++++ src/ui/screens/ProfileScreen.tsx | 18 +- 7 files changed, 439 insertions(+), 83 deletions(-) create mode 100644 src/lib/commemoratives.ts create mode 100644 src/ui/screens/ProfileScreen.test.tsx diff --git a/project.inlang/messages/en.json b/project.inlang/messages/en.json index 4f0f7d6..46f719a 100644 --- a/project.inlang/messages/en.json +++ b/project.inlang/messages/en.json @@ -230,6 +230,9 @@ "profile_stamp_pending": "to come", "profile_stamp_goal": "{next} to reach", "profile_stamp_earned": "{family} · {threshold}, earned on {date}", + "profile_stamp_sissi": "Sissi", + "profile_stamp_commemorative": "{name} — special issue, earned on {date}", + "profile_horsserie_title": "Special issue", "flash_no_split": "Can't split here.", "flash_no_shift": "Shift blocked — capped at ±{max}.", "rule_decalage_aligned": "⇄ World (or {alt}) slides the magenta film. The registration marks must line up at the end.", diff --git a/project.inlang/messages/fr.json b/project.inlang/messages/fr.json index 5b8d696..f8bc91a 100644 --- a/project.inlang/messages/fr.json +++ b/project.inlang/messages/fr.json @@ -230,6 +230,9 @@ "profile_stamp_pending": "à venir", "profile_stamp_goal": "{next} à atteindre", "profile_stamp_earned": "{family} · {threshold}, décroché le {date}", + "profile_stamp_sissi": "Sissi", + "profile_stamp_commemorative": "{name} — hors-série, décroché le {date}", + "profile_horsserie_title": "Hors-série", "flash_no_split": "Scission impossible ici.", "flash_no_shift": "Décalage impossible — borné à ±{max}.", "rule_decalage_aligned": "⇄ Monde (ou {alt}) glisse le film magenta. Les croix de repérage doivent finir alignées.", diff --git a/src/lib/commemoratives.ts b/src/lib/commemoratives.ts new file mode 100644 index 0000000..6153d8d --- /dev/null +++ b/src/lib/commemoratives.ts @@ -0,0 +1,27 @@ +// The hors-série: stamps that are NOT part of the current series. +// +// The four families of `distinctions.ts` are the série courante — always four, +// bounded by construction, and every profile shows all four (earned, or as an +// empty album mount). A commemorative is the opposite on every count: it has no +// tiers, no threshold and no face value to climb; you either hold it or you do +// not; and a profile that does not hold one shows NOTHING, because a stamp that +// was never put on sale cannot be missing from an album. +// +// That asymmetry is why this is its own module rather than a fifth family: +// issuing one here can never widen the series row, so the page's layout stays +// fixed however many commemoratives get struck. +// +// Nothing grants `sissi` yet. The easter egg that hands it over is not designed, +// so the stamp is issued but unobtainable and every profile carries an empty +// list. When the trigger exists it only has to fill that list — the artwork, the +// layout and the profile plumbing are already in place. + +export type Commemorative = "sissi"; + +/** A commemorative a player holds, with the day it was struck — the postmark. + * Mirrors what a series stamp carries, so `Stamp` can render either. */ +export interface Held { + readonly key: Commemorative; + /** YYYY-MM-DD (UTC). */ + readonly earnedOn: string; +} diff --git a/src/server/profileData.ts b/src/server/profileData.ts index 141913f..fa620fa 100644 --- a/src/server/profileData.ts +++ b/src/server/profileData.ts @@ -15,6 +15,7 @@ import { USERNAME_RE } from "../lib/username.ts"; import { PAR } from "../engine/par.ts"; import { WEEKEND_TIER } from "./dailyPuzzle.ts"; import type { DistinctionInput } from "../lib/distinctions.ts"; +import type { Held } from "../lib/commemoratives.ts"; /** One campaign board the player has put on the record. */ export interface PlateRecord { @@ -33,6 +34,10 @@ export interface DailyHistory { plates: PlateRecord[]; /** Correction-free solves, daily and campaign — one of the four figures. */ cleanCount: number; + /** The hors-série this account holds. Always empty for now: no easter egg + * grants one yet, so the Sissi stamp is issued but unobtainable. When the + * trigger exists it fills this list and nothing else has to change. */ + commemoratives: Held[]; } const FIELDS = { @@ -120,6 +125,7 @@ async function historyFor(row: { cleanCount: daily.filter((r) => r.undos === 0).length + campaign.filter((r) => r.undos === 0).length, + commemoratives: [], }; } diff --git a/src/ui/components/Stamp.tsx b/src/ui/components/Stamp.tsx index 2bd1657..ee6cdb1 100644 --- a/src/ui/components/Stamp.tsx +++ b/src/ui/components/Stamp.tsx @@ -13,6 +13,7 @@ import { m } from "../../paraglide/messages.js"; import type { Distinction, Family } from "../../lib/distinctions.ts"; +import type { Held } from "../../lib/commemoratives.ts"; // One plate colour per family. The four stamps carry four different engravings, // so it is the FAMILY that needs telling apart at a glance; the tier is read off @@ -254,87 +255,219 @@ function Engraving({ d, ink }: { d: Distinction; ink: string }) { } } -// ─── The stamp ─────────────────────────────────────────────── +// ─── Sissi ─────────────────────────────────────────────────── +// The workshop's cat, loafed across a board. Not an allegory of anything: it is +// what she actually does, which is sit on whatever you are working on. Drawn +// solid grey and white — she is a bicolour, not a tabby, so there is not a +// single stripe on her anywhere. + +const CAT = "#55504a"; +const CAT_FUR = "#f7f3ec"; +const CAT_EAR = "#e2a8ad"; +const CAT_NOSE = "#e08a9a"; +const CAT_EYE = "#8ca55e"; +const CAT_LID = "#e6c2c4"; + +/** One eye: a wide green ring around a big round pupil, the lid dark only along + * the top. Ringing it all round is what made earlier passes read as a cartoon. */ +function CatEye() { + return ( + + + + + + + + + ); +} + +const SKULL = + "M0 -13.5 C9 -13.5 14 -7 14 0 C14 8 8 13 0 13 C-8 13 -14 8 -14 0 C-14 -7 -9 -13.5 0 -13.5 Z"; + +/** Her head. The markings are the likeness: a grey cap that comes down to a + * POINT between the eyes, split by a white blaze running up to the right ear — + * not a left/right split, which is what several earlier passes drew. */ +function CatHead() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +/** The board she is lying on: 5 × 5 SQUARE cells, because the game's board is + * square. An earlier pass stretched it to 13.6 × 10 and it read as wrong. */ +function SissiPlate() { + return ( + + + + + {/* the two inks, on a square she has not taken yet */} + + + + + + + + + + + + + + + + + + ); +} + +// ─── The sheet ─────────────────────────────────────────────── const W = 120; const H = 148; -export function Stamp({ - distinction: d, - width = 116, +/** The stamp itself, minus its subject: perforation, paper, burelage, frame, + * issuer, footer band and cancellation. Shared so a commemorative and a series + * value are the same object with a different engraving in the middle. */ +function Sheet({ + uid, + ink, + width, + label, + face, + alt, + mark, + thickInner = false, + children, }: { - distinction: Distinction; - width?: number; + uid: string; + ink: string; + width: number; + label: string; + face: string; + alt: string; + mark: { day: string; year: string } | null; + thickInner?: boolean; + children: React.ReactNode; }) { const height = Math.round((width * H) / W); - const name = FAMILY_NAME[d.family](); - - // an unopened family: the empty album mount, with what it takes written in - if (d.tier === 0) { - const goal = d.next === null ? "" : m.profile_stamp_goal({ next: d.next }); - return ( - - {`${name} — ${goal}`} - - - - - - {m.profile_stamp_pending()} - - - {goal.toUpperCase()} - - - ); - } - - const ink = INK[d.family]; - const uid = `st-${d.family}`; - const face = String(d.threshold); // a four-figure value needs a wider cartouche than "5" does const wide = face.length > 3; const cartoucheX = wide ? 70 : 76; const cartoucheW = wide ? 32 : 26; - const mark = d.earnedOn ? postmark(d.earnedOn) : null; - const alt = m.profile_stamp_earned({ - family: name, - threshold: d.threshold ?? 0, - date: d.earnedOn ?? "", - }); return ( // xmlns is redundant in the DOM but required once this same markup is @@ -416,8 +549,6 @@ export function Stamp({ /> - {/* frame — the top value thickens the inner rule, the only difference - between two values of one series besides the figure itself */} @@ -452,7 +583,7 @@ export function Stamp({ SUPERPOSITION - + {children} {/* footer band: label left, face value right — they can never collide */} - {FAMILY_LABEL[d.family]()} + {label} - + ); } + +// ─── The two kinds of stamp ────────────────────────────────── + +export function Stamp({ + distinction: d, + width = 116, +}: { + distinction: Distinction; + width?: number; +}) { + const name = FAMILY_NAME[d.family](); + + // an unopened family: the empty album mount, with what it takes written in + if (d.tier === 0) { + const height = Math.round((width * H) / W); + const goal = d.next === null ? "" : m.profile_stamp_goal({ next: d.next }); + return ( + + {`${name} — ${goal}`} + + + + + + {m.profile_stamp_pending()} + + + {goal.toUpperCase()} + + + ); + } + + return ( + + + + ); +} + +/** A hors-série. It carries no face value to climb, so its cartouche reads + * "H.S." — the philatelic mark for an issue outside the current series. */ +export function CommemorativeStamp({ + held, + width = 116, +}: { + held: Held; + width?: number; +}) { + const name = m.profile_stamp_sissi(); + return ( + + + + ); +} diff --git a/src/ui/screens/ProfileScreen.test.tsx b/src/ui/screens/ProfileScreen.test.tsx new file mode 100644 index 0000000..14534f6 --- /dev/null +++ b/src/ui/screens/ProfileScreen.test.tsx @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { ProfileScreen } from "./ProfileScreen.tsx"; +import type { DailyHistory } from "../../server/profile.ts"; + +// The hors-série row is the only part of the profile no account can reach yet: +// nothing grants a commemorative, so the server always sends an empty list. These +// pin the two halves of that contract — that an empty list prints NOTHING (a +// stamp never put on sale cannot be missing from an album, so there is no empty +// mount for it), and that the row appears the moment the list is filled, which is +// all the future easter egg will have to do. + +const blank: DailyHistory = { + name: "Sissi", + joinedAt: "2026-06-08", + days: [], + marks: { days: [], bat: [], artist: [], plates: [] }, + plates: [], + cleanCount: 0, + commemoratives: [], +}; + +const noop = () => {}; + +describe("ProfileScreen — hors-série", () => { + it("prints no commemorative row when the account holds none", () => { + render(); + expect( + screen.queryByRole("img", { name: /hors-série|special issue/i }), + ).toBeNull(); + }); + + it("prints the stamp once the account holds one", () => { + render( + , + ); + const stamp = screen.getByRole("img", { + name: /Sissi.*(hors-série|special issue)/i, + }); + expect(stamp).toBeDefined(); + }); + + it("still prints the four series stamps beside it", () => { + render( + , + ); + // a blank account opens no family, so all four print as empty album mounts + expect( + screen.getAllByRole("img", { name: /—/ }).length, + ).toBeGreaterThanOrEqual(4); + }); +}); diff --git a/src/ui/screens/ProfileScreen.tsx b/src/ui/screens/ProfileScreen.tsx index a2b7d21..6587998 100644 --- a/src/ui/screens/ProfileScreen.tsx +++ b/src/ui/screens/ProfileScreen.tsx @@ -20,7 +20,7 @@ import { PAR } from "../../engine/par.ts"; import { Room } from "../components/Room.tsx"; import { LangToggle } from "../components/LangToggle.tsx"; import { ContributionGraph } from "../components/ContributionGraph.tsx"; -import { Stamp } from "../components/Stamp.tsx"; +import { CommemorativeStamp, Stamp } from "../components/Stamp.tsx"; import { PRINT_EASE, reducedMotion as reduced } from "../motion.ts"; import type { DailyHistory } from "../../server/profile.ts"; @@ -215,6 +215,22 @@ export function ProfileScreen({ ))} + {/* the hors-série. Shown only to whoever holds one: a commemorative was + never put on sale, so it cannot be missing from anyone's album — no + empty mount here, unlike the series above. The row can grow without + ever widening the series, which is the point of keeping the two + apart (see lib/commemoratives.ts). */} + {history.commemoratives.length > 0 && ( + + +
+ {history.commemoratives.map((h) => ( + + ))} +
+
+ )} + {/* register strip — two figures of presence, two of quality */} Date: Fri, 7 Aug 2026 22:15:28 +0200 Subject: [PATCH 02/11] docs: la spec de la passe de finition 1.0 --- .../specs/2026-08-07-version-finale-design.md | 241 ++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-07-version-finale-design.md diff --git a/docs/superpowers/specs/2026-08-07-version-finale-design.md b/docs/superpowers/specs/2026-08-07-version-finale-design.md new file mode 100644 index 0000000..551b2c8 --- /dev/null +++ b/docs/superpowers/specs/2026-08-07-version-finale-design.md @@ -0,0 +1,241 @@ +# La version finale — la passe de finition + +Statut : validé en brainstorming le 2026-08-07. + +## Le problème + +Le dépôt n'est pas en dette au sens habituel. L'état mesuré avant d'écrire cette +spec : + +``` +bun run lint → exit 0 (10 warnings react/only-export-components, inhérents + aux modules de route TanStack) +tsc --noEmit → exit 0 +bun run test → exit 0 — 33 fichiers, 320 tests +bun run build → exit 0 — 665 Ko de JS client brut, sortie Nitro complète +git status → propre +``` + +Passer en 1.0 ne consiste donc pas à réparer, mais à fermer quatre écarts qui +tirent chacun dans une direction différente : + +1. **Aucune frontière de route à la racine.** Ni `notFoundComponent` ni + `errorComponent` sur `__root.tsx` ; seul `daily.$tier` en porte un. Une URL + inconnue, ou une erreur de loader, tombe sur l'écran par défaut du routeur — + hors direction artistique, non localisé, sans issue vers le jeu. C'est le seul + trou objectif de la revue. +2. **L'écart `ever_clean`**, consigné dans « Next steps » d'AGENTS.md depuis la + synchro de progression : le serveur garde UNE ligne par niveau, pas un + historique. Un joueur dont le run sans retouche n'était pas son meilleur score + n'a aucun fait « déjà résolu proprement » à récupérer sur un nouvel appareil. + Son sceau est perdu avec son localStorage. +3. **~30 symboles exportés sans consommateur externe** (`FAMILIES`, `SHADE_HEX`, + `randomLevel`, les `*Relations` du schéma, une douzaine de types internes). + Du bruit dans la surface publique des modules, pas de la dette de + comportement. +4. **Pas de sitemap, `robots.txt` minimal** (`Disallow:` seul, `/api/` ouvert au + crawl, aucune directive `Sitemap:`), et AGENTS.md qui décrit encore l'écart + `ever_clean` comme ouvert. + +## Le découpage retenu + +**Un lot par axe, une branche par lot**, mergés dans l'ordre du risque : +frontières → `ever_clean` → hygiène → release. + +Le lot `ever_clean` touche le schéma Postgres. Le mélanger à une suppression +d'exports dans un même diff donnerait un merge qu'on ne sait plus bisecter ni +révoquer proprement. Quatre branches courtes, chacune révisable et +`git revert`-able seule, contre un cycle de vérification par lot : c'est le bon +échange ici, et c'est ce que demandent déjà les règles git du dépôt (une branche +par changement, ~150 lignes de diff visées). + +--- + +## Lot 1 — Les frontières de route + +Branche `fix/route-boundaries`. + +### La forme + +**Un seul composant pour les deux cas.** `src/ui/screens/FallbackScreen.tsx`, +paramétré par titre, corps et action, monté deux fois depuis `__root.tsx` : +`notFoundComponent` et `errorComponent`. + +Deux écrans séparés dériveraient : c'est la même page — « tu es sorti de +l'atelier, voici la porte » — avec deux textes. Le dépôt tranche déjà ce genre de +question par un propriétaire unique (cf. le tableau « Don't recreate » +d'AGENTS.md) ; un écran de repli ne fait pas exception. + +### Le contenu + +Direction artistique table lumineuse, comme le reste. Textes ajoutés à +`project.inlang/messages/fr.json` et `en.json` — rien en dur dans le composant. + +- **404** : le titre, une ligne, un retour vers `/levels`. +- **Erreur** : le titre, une ligne, un « réessayer » qui appelle + `router.invalidate()`, et le même retour vers `/levels`. + +L'`errorComponent` **n'affiche jamais la stack ni le message de l'erreur**. Une +erreur de loader porte des détails serveur (requête, chemin, parfois un fragment +de SQL) ; les rendre au visiteur est une fuite d'information pour un gain nul — +il ne peut rien en faire. L'erreur reste dans les logs serveur. + +### Le rendu côté serveur + +La racine est en `ssr: true` (pour que le profil public puisse rendre +server-side). Le `FallbackScreen` ne touche donc aucune API navigateur : +pas d'`AudioContext`, pas de `localStorage`, pas de `window` au premier rendu. +C'est une contrainte du composant, pas une option. + +### Vérification + +`FallbackScreen.test.tsx`, projet `dom` : rend les deux modes, vérifie que le +texte vient bien des messages et que l'action de retour est câblée. Plus un +passage manuel sur `/nimportequoi`. + +--- + +## Lot 2 — L'écart `ever_clean` + +Branche `feat/ever-clean`. + +### Le schéma + +Une colonne sur `level_score` : + +```ts +everClean: boolean("ever_clean").notNull().default(false), +``` + +Migration générée par `drizzle-kit generate`, **plus un backfill écrit à la main +dans le même fichier SQL** : + +```sql +UPDATE level_score SET ever_clean = true WHERE undos = 0; +``` + +Sans ce backfill, tout joueur dont la meilleure ligne actuelle est propre +perdrait son sceau à la première resynchro — la migration créerait l'écart +qu'elle est censée fermer. + +### Le point délicat : l'écriture ne peut pas passer par `upsertBestScore` + +`upsertBestScore` garde son `onConflictDoUpdate` par `beatenBy(...)`. Un run sans +retouche qui **ne bat pas** la ligne stockée n'écrirait donc rien — et c'est +exactement le cas que ce lot existe pour fermer. Y greffer le `ever_clean` +donnerait une colonne qui ne se remplit que dans les cas déjà couverts. + +Donc **deux instructions** dans `submitLevelScore` : + +1. l'upsert existant, inchangé ; +2. puis, si `result.corrections === 0`, un + `UPDATE level_score SET ever_clean = true WHERE level_id = ? AND user_id = ?`. + +Idempotent, et la ligne existe forcément après l'étape 1. Un aller-retour DB de +plus sur le seul chemin d'une soumission propre. + +**Ce code vit dans `src/server/campaign.ts`, pas dans `leaderboard.ts`.** +`leaderboard.ts` est le module partagé entre les deux tableaux ; la colonne +n'existe que sur `level_score`. L'y mettre imposerait un troisième garde +`everCleanColumn(table)` à côté de `elapsedColumn` pour une règle qui n'a qu'un +seul appelant. + +### La lecture + +`getMyLevelScores` renvoie `everClean` en plus de `moves` et `undos`. +`ServerScore` (dans `src/ui/progressSync.ts`) le porte. `asWin` lit +`clean: s.everClean` au lieu de `s.undos === 0`. + +### Ce qui ne bouge pas, volontairement + +- **Le sceau des tableaux.** `LeaderRow.clean` et `MyResult.clean` restent + dérivés de la ligne affichée (`undos === 0`). Un tableau qui classerait une + ligne sur ses coups tout en la scellant d'après un autre run mentirait sur ce + qu'il montre. `ever_clean` est un fait de **progression** (le registre local), + pas un fait de **classement**. +- **`planUploads`.** Sa règle de départage — sur égalité de coups, seul un tracé + local propre vaut un envoi — porte sur la ligne stockée, qui garde sa + sémantique. Aucune raison de la toucher. + +### Vérification + +- `progressSync.test.ts` étendu sur `asWin`. +- **Limite assumée** : le dépôt n'a pas de harnais de test contre Postgres. La + partie SQL — la migration, le backfill, la collance de l'`UPDATE` — sera + vérifiée à la main contre le Postgres de `docker-compose.yml`, pas par un test + automatisé. Le scénario manuel : résoudre proprement sans battre son record, + vider le localStorage, se reconnecter, constater que le sceau revient. + +### Documentation + +La note « Residual gap » de « Next steps » dans AGENTS.md disparaît ; une ligne +entre au tableau « Don't recreate » : le fait « déjà résolu proprement » a un +propriétaire unique, la colonne `level_score.ever_clean` écrite par +`submitLevelScore`. + +--- + +## Lot 3 — L'hygiène + +Branche `chore/dead-exports`. + +Les ~30 symboles exportés sans consommateur hors de leur propre fichier, triés en +trois : + +- **contrat de framework** (`startInstance`, `getRouter`, les `*Relations` de + Drizzle) — on ne touche pas ; +- **type interne** — on retire le `export` ; +- **réellement mort** — on supprime. + +Zéro changement de comportement. Vérifié par `tsc --noEmit`, les 320 tests et le +build : un symbole retiré à tort casse la compilation, ce qui rend ce lot sûr par +construction. + +**Hors périmètre, signalé** : `src/ui/screens/PlayScreen.tsx` fait 422 lignes, le +plus gros fichier du dépôt, et porte visiblement plusieurs responsabilités. Le +découper est un refactor à part entière — pas de l'hygiène, et pas quelque chose +qu'on glisse dans une passe de finition. La note reste, la décision est +ultérieure. + +--- + +## Lot 4 — La release + +Branche `chore/release-1.0`. + +### `sitemap.xml` et `robots.txt` deviennent des routes serveur + +La directive `Sitemap:` exige une **URL absolue** : les deux fichiers ont donc +besoin de l'origine, que seul le serveur connaît (`BETTER_AUTH_URL`). Les garder +statiques dans `public/` obligerait à coder l'origine de production en dur dans +le dépôt. Deux routes, montées à côté des routes API existantes ; +`public/robots.txt` est supprimé (une route et un fichier statique de même nom se +disputeraient la même URL). + +Le sitemap liste **les routes statiques seulement** : `/`, `/levels`, +`/level/1` à `/level/22`, `/align`. Les profils sont des données utilisateur — +publiables une par une, mais pas énumérables dans un fichier que l'on sert à tout +le monde. `robots.txt` ferme `/api/`. + +### Documentation et livraison + +README et AGENTS.md remis en phase avec le dépôt. Puis merge des quatre branches +dans `main` dans l'ordre ci-dessus, et tag `v1.0.0`. Le déploiement Railway reste +un geste de l'auteur. + +--- + +## Vérification, par lot + +Après chaque lot, les quatre commandes et leurs codes de sortie collés au +rapport : + +``` +bun run lint +tsc --noEmit +bun run test +bun run build +``` + +Le lot 2 ajoute `bun run db:migrate` contre le Postgres local et le scénario +manuel décrit plus haut. From 35f404032ec35bf863b93c2aac3450f8192dff61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Caruelle?= Date: Fri, 7 Aug 2026 22:59:27 +0200 Subject: [PATCH 03/11] fix(routes): la racine ouvre une porte quand la route se perd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ni notFoundComponent ni errorComponent sur la racine : une URL inconnue ou un loader en échec tombait sur l'écran par défaut du routeur — hors direction artistique, non localisé, sans issue vers le jeu. Un seul FallbackScreen rend les deux frontières ; l'erreur n'est jamais affichée au visiteur, elle reste dans les logs serveur. --- project.inlang/messages/en.json | 6 ++ project.inlang/messages/fr.json | 6 ++ src/routes/__root.tsx | 44 ++++++++++++++- src/ui/screens/FallbackScreen.test.tsx | 68 +++++++++++++++++++++++ src/ui/screens/FallbackScreen.tsx | 76 ++++++++++++++++++++++++++ 5 files changed, 199 insertions(+), 1 deletion(-) create mode 100644 src/ui/screens/FallbackScreen.test.tsx create mode 100644 src/ui/screens/FallbackScreen.tsx diff --git a/project.inlang/messages/en.json b/project.inlang/messages/en.json index 46f719a..1dd965a 100644 --- a/project.inlang/messages/en.json +++ b/project.inlang/messages/en.json @@ -133,6 +133,12 @@ "offline_daily_back": "back to the boards", "error_daily_title": "Something went wrong", "error_daily_body": "The daily challenge failed to load. Try again in a moment.", + "fallback_not_found_title": "No such plate", + "fallback_not_found_body": "This page was never pulled. The edition, though, is still on the table.", + "fallback_error_title": "The pull failed", + "fallback_error_body": "Something slipped out of register on the way. We can run the sheet again.", + "fallback_retry": "run the sheet again", + "fallback_back": "back to the boards", "daily_solved": [ { "declarations": ["input count", "local countPlural = count: plural"], diff --git a/project.inlang/messages/fr.json b/project.inlang/messages/fr.json index f8bc91a..d58d04e 100644 --- a/project.inlang/messages/fr.json +++ b/project.inlang/messages/fr.json @@ -133,6 +133,12 @@ "offline_daily_back": "retour aux planches", "error_daily_title": "Une erreur est survenue", "error_daily_body": "Le défi du jour n'a pas pu se charger. Réessayez dans un instant.", + "fallback_not_found_title": "Planche introuvable", + "fallback_not_found_body": "Cette page n'a jamais été tirée. L'édition, elle, est toujours sur la table.", + "fallback_error_title": "Le tirage a raté", + "fallback_error_body": "Quelque chose s'est décalé en chemin. On peut repasser la feuille.", + "fallback_retry": "repasser la feuille", + "fallback_back": "retour aux planches", "daily_solved": [ { "declarations": ["input count", "local countPlural = count: plural"], diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 5c5921c..2f1a555 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -1,7 +1,14 @@ import { useEffect } from "react"; -import { HeadContent, Scripts, createRootRoute } from "@tanstack/react-router"; +import { + HeadContent, + Scripts, + createRootRoute, + useNavigate, + useRouter, +} from "@tanstack/react-router"; import appCss from "../index.css?url"; +import { FallbackScreen } from "../ui/screens/FallbackScreen.tsx"; import { m } from "../paraglide/messages.js"; import { cookieName, @@ -88,9 +95,44 @@ export const Route = createRootRoute({ ], }; }, + // The app's two boundaries, mounted here because an unknown URL matches no + // child route: without them the visitor lands on the router's default screen — + // outside the art direction, unlocalised, with no way back into the game. One + // screen renders both. + notFoundComponent: NotFound, + errorComponent: RouteError, shellComponent: RootDocument, }); +function NotFound() { + const navigate = useNavigate(); + return ( + navigate({ to: "/levels" })} + /> + ); +} + +// The error itself is never rendered: a loader failure carries server detail +// (the query, a path, sometimes a fragment of SQL) the visitor cannot act on and +// has no business seeing. It stays in the logs. +function RouteError() { + const router = useRouter(); + const navigate = useNavigate(); + return ( + navigate({ to: "/levels" })} + // invalidate() reruns the loader AND rearms the boundary; merely + // remounting the component would replay the same failure + onRetry={() => router.invalidate()} + /> + ); +} + function RootDocument({ children }: { children: React.ReactNode }) { // One-time migration off the old localStorage strategy. Before the switch to // the cookie strategy the chosen locale lived in localStorage; those visitors diff --git a/src/ui/screens/FallbackScreen.test.tsx b/src/ui/screens/FallbackScreen.test.tsx new file mode 100644 index 0000000..932fbd7 --- /dev/null +++ b/src/ui/screens/FallbackScreen.test.tsx @@ -0,0 +1,68 @@ +// The one screen both route boundaries mount. What is worth pinning is not the +// layout but the three things that made it exist: the copy comes from the +// message catalogue rather than the component, the way back to the game is +// always wired, and the error mode gains a retry WITHOUT ever printing the +// error — a loader failure carries server detail the visitor must not see. + +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { m } from "../../paraglide/messages.js"; +import { FallbackScreen } from "./FallbackScreen.tsx"; + +// the two callsites of __root.tsx, mirrored: same messages, and the retry is +// what tells them apart +const notFound = (onBack: () => void) => ( + +); +const failed = (onBack: () => void, onRetry: () => void) => ( + +); + +const back = () => screen.getByRole("button", { name: m.fallback_back() }); +const retry = () => screen.queryByRole("button", { name: m.fallback_retry() }); + +describe("FallbackScreen", () => { + it("prints the 404 copy from the messages, with the way back wired", () => { + const onBack = vi.fn(); + render(notFound(onBack)); + + expect(screen.getByRole("heading").textContent).toBe( + m.fallback_not_found_title(), + ); + expect(screen.getByText(m.fallback_not_found_body())).toBeDefined(); + + fireEvent.click(back()); + expect(onBack).toHaveBeenCalledOnce(); + }); + + it("offers no retry on a 404 — there is nothing to reload", () => { + render(notFound(vi.fn())); + expect(retry()).toBeNull(); + }); + + it("prints the error copy and wires both the retry and the way back", () => { + const onBack = vi.fn(); + const onRetry = vi.fn(); + render(failed(onBack, onRetry)); + + expect(screen.getByRole("heading").textContent).toBe( + m.fallback_error_title(), + ); + expect(screen.getByText(m.fallback_error_body())).toBeDefined(); + + fireEvent.click(retry()!); + expect(onRetry).toHaveBeenCalledOnce(); + + fireEvent.click(back()); + expect(onBack).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/ui/screens/FallbackScreen.tsx b/src/ui/screens/FallbackScreen.tsx new file mode 100644 index 0000000..dc12867 --- /dev/null +++ b/src/ui/screens/FallbackScreen.tsx @@ -0,0 +1,76 @@ +// The workshop door: one page for both of the root's route boundaries — a URL +// that names no plate, and a load that failed. Twin screens would drift apart; +// only the title, the body line and the presence of a retry differ, so they get +// a single owner. +// +// Mounted from `__root.tsx`, which is `ssr: true`: this component touches no +// browser API on the first render — no AudioContext, no localStorage, no window. +// It animates nothing either: motion serialises its `initial` into the server's +// HTML and would ship a blank page until hydration (the reason for +// ProfileScreen's `hydrated` flag), and the way back into the game is precisely +// what has to be readable on the first frame. The composition stays the +// edition's — same caisson, same type — so the visitor stays in the workshop +// rather than being thrown out of it. + +import { m } from "../../paraglide/messages.js"; +import { Room } from "../components/Room.tsx"; +import { Wordmark } from "../components/Wordmark.tsx"; +import { reducedMotion as reduced } from "../motion.ts"; + +export function FallbackScreen({ + title, + body, + onBack, + onRetry, +}: { + title: string; + body: string; + /** The way out, always there: back to the edition. */ + onBack: () => void; + /** Error side only — `router.invalidate()`, which reruns the loader and rearms + * the boundary. Absent on a 404: there is nothing to reload. */ + onRetry?: () => void; +}) { + return ( +
+ {/* the same room as the rest of the game (variant 0: the warm lamp) */} + + +
+ {/* the mark left out of register — that IS the page's state: the two + films never superposed. Everywhere else it is `aligned`. */} +
+ +
+ +
+

+ {title} +

+

+ {body} +

+
+ +
+ {onRetry && ( + + )} + +
+
+
+ ); +} From 8cd9c76a145b849828fe025e81b0153746dfa636 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Caruelle?= Date: Fri, 7 Aug 2026 23:00:18 +0200 Subject: [PATCH 04/11] =?UTF-8?q?feat(campagne):=20garder=20le=20fait=20?= =?UTF-8?q?=C2=AB=20d=C3=A9j=C3=A0=20r=C3=A9solu=20proprement=20=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le serveur ne garde qu'une ligne par (niveau, joueur), pas un historique : un run sans retouche qui ne battait pas le record ne laissait aucune trace, et son sceau mourait avec le localStorage du joueur. Une colonne level_score.ever_clean, écrite par une seconde instruction — l'upsert est gardé par beatenBy, donc il ne peut pas la porter. Le sceau des tableaux ne bouge pas : il décrit la ligne affichée, pas la progression. --- AGENTS.md | 10 +- src/db/migrations/0010_foamy_emma_frost.sql | 7 + src/db/migrations/meta/0010_snapshot.json | 747 ++++++++++++++++++++ src/db/migrations/meta/_journal.json | 7 + src/db/schema.ts | 5 + src/server/campaign.ts | 41 +- src/ui/hooks/useProgressSync.ts | 7 +- src/ui/progressSync.test.ts | 61 +- src/ui/progressSync.ts | 19 +- 9 files changed, 863 insertions(+), 41 deletions(-) create mode 100644 src/db/migrations/0010_foamy_emma_frost.sql create mode 100644 src/db/migrations/meta/0010_snapshot.json diff --git a/AGENTS.md b/AGENTS.md index 1cfc1e6..3493b58 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -164,6 +164,7 @@ import that (or `db`) from a module the client route tree pulls in. | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | who drives the board (game vs tutorial) | `src/ui/plateDriver.ts` — the `PlateDriver` interface + its two PURE adapters (`playingPlate`, `guidedPlate`). PlayScreen binds ONE; never branch on "is the tutorial live?" anywhere else | | the "clean pull" mark | two forms, one idea: `src/ui/components/CleanSeal.tsx` on the BOARDS (ranked rows, standing footer), and the `sp-ink-frame` class in `src/index.css` on the EDITION (a plate's record frame changes ink instead of wearing a badge) | +| the "ever solved cleanly" fact | `level_score.ever_clean` — set by `markEverClean` in `src/server/campaign.ts` (a SECOND statement: the upsert's guard skips a clean run that isn't a best), read by `asWin` in `src/ui/progressSync.ts`. Progression, not ranking: boards seal what they show | | profile distinctions (families + thresholds) | `src/lib/distinctions.ts` — the SOLE owner of what a family measures, where its four face values sit, and which day postmarks a tier. `Stamp.tsx` draws what it is handed; `profileData.ts` only gathers dates. Put no threshold in either | | the profile stamp artwork | `src/ui/components/Stamp.tsx` — the four engravings, the perforation, the burelage, the empty album mount. The OG card must reuse it rather than paint a second version. No `mix-blend-mode`: satori cannot render it, so the two inks are painted explicitly | | the tutorial's on-board content | `src/ui/components/DemoOverlay.tsx` | @@ -245,15 +246,6 @@ Runtime network dependency: the Instrument Serif web font (Google Fonts). ### Next steps -- **Residual gap — a clean run that was never your best row can't be - recovered.** `getMyLevelScores` now returns `undos` alongside `moves`, and - `asWin` (`src/ui/progressSync.ts`) turns a stored row into a ledger win with - `clean: undos === 0`, so a new device recovers both records and seals. What it - still cannot recover: the server keeps ONE row per level, not a history, so a - player whose clean run was not their best row has no "ever solved cleanly" - fact for the server to return. Closing that needs a column - (`level_score.ever_clean`, set on any correction-free submission), which is a - migration — not worth it until someone asks. - The route split is done (see "Stack & toolchain"); `` and its screen router are gone. - Old project instructions live in `superposition-old/CLAUDE.md` (engine diff --git a/src/db/migrations/0010_foamy_emma_frost.sql b/src/db/migrations/0010_foamy_emma_frost.sql new file mode 100644 index 0000000..77fd8ab --- /dev/null +++ b/src/db/migrations/0010_foamy_emma_frost.sql @@ -0,0 +1,7 @@ +ALTER TABLE "level_score" ADD COLUMN "ever_clean" boolean DEFAULT false NOT NULL;--> statement-breakpoint +-- Hand-written data step. Without it the column lands false everywhere, and any +-- player whose current best row is already clean would lose their seal on the +-- first resync — the migration would dig the very gap it closes. A row with zero +-- corrections IS proof of a "sans retouche" run, so the backfill reads it +-- straight off. Idempotent. +UPDATE "level_score" SET "ever_clean" = true WHERE "undos" = 0; diff --git a/src/db/migrations/meta/0010_snapshot.json b/src/db/migrations/meta/0010_snapshot.json new file mode 100644 index 0000000..a91d2df --- /dev/null +++ b/src/db/migrations/meta/0010_snapshot.json @@ -0,0 +1,747 @@ +{ + "id": "42259754-60da-4bd4-aa91-8f71c255ce25", + "prevId": "71b0e97e-bee2-41b5-afa3-22e1b6a4a742", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.daily_puzzle": { + "name": "daily_puzzle", + "schema": "", + "columns": { + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "level": { + "name": "level", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "generated": { + "name": "generated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "optimal": { + "name": "optimal", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "daily_puzzle_date_tier_pk": { + "name": "daily_puzzle_date_tier_pk", + "columns": [ + "date", + "tier" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.daily_score": { + "name": "daily_score", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "moves": { + "name": "moves", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "undos": { + "name": "undos", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "elapsed_ms": { + "name": "elapsed_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "inputs": { + "name": "inputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "daily_score_date_tier_user": { + "name": "daily_score_date_tier_user", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tier", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "daily_score_user_id_user_id_fk": { + "name": "daily_score_user_id_user_id_fk", + "tableFrom": "daily_score", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "daily_score_date_tier_daily_puzzle_date_tier_fk": { + "name": "daily_score_date_tier_daily_puzzle_date_tier_fk", + "tableFrom": "daily_score", + "tableTo": "daily_puzzle", + "columnsFrom": [ + "date", + "tier" + ], + "columnsTo": [ + "date", + "tier" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.daily_view": { + "name": "daily_view", + "schema": "", + "columns": { + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "served_at": { + "name": "served_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "certified": { + "name": "certified", + "type": "boolean", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "daily_view_user_id_user_id_fk": { + "name": "daily_view_user_id_user_id_fk", + "tableFrom": "daily_view", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "daily_view_date_tier_user_id_pk": { + "name": "daily_view_date_tier_user_id_pk", + "columns": [ + "date", + "tier", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.level_score": { + "name": "level_score", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "level_id": { + "name": "level_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "moves": { + "name": "moves", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "undos": { + "name": "undos", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "ever_clean": { + "name": "ever_clean", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inputs": { + "name": "inputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "level_score_level_user": { + "name": "level_score_level_user", + "columns": [ + { + "expression": "level_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "level_score_user_id_user_id_fk": { + "name": "level_score_user_id_user_id_fk", + "tableFrom": "level_score", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_username": { + "name": "display_username", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "user_username_unique": { + "name": "user_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 58b3ee7..d7493fc 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1785426776812, "tag": "0009_conscious_tempest", "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1786135862775, + "tag": "0010_foamy_emma_frost", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/schema.ts b/src/db/schema.ts index d86e1d4..e5e850c 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -218,6 +218,11 @@ export const levelScore = pgTable( .references(() => user.id, { onDelete: "cascade" }), moves: integer("moves").notNull(), undos: integer("undos").notNull().default(0), + // The row is a BEST score, not a history: a "sans retouche" run that does + // not beat the stored row leaves no trace in it. This flag keeps the "ever + // solved cleanly" fact across every replacement of the row — outside the + // player's own localStorage it is the only memory of the seal. + everClean: boolean("ever_clean").notNull().default(false), // physical column is legacy-named "inputs"; it stores the full TraceStep[] trace: jsonb("inputs").$type().notNull(), createdAt: timestamp("created_at").defaultNow().notNull(), diff --git a/src/server/campaign.ts b/src/server/campaign.ts index b45734a..96ac037 100644 --- a/src/server/campaign.ts +++ b/src/server/campaign.ts @@ -4,7 +4,7 @@ // through the pure engine (see replay.ts) before a row is written. import { createServerFn } from "@tanstack/react-start"; -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { levelScore } from "../db/schema.ts"; import { db } from "../db/index.ts"; import { LEVELS } from "../engine/levels.ts"; @@ -66,6 +66,26 @@ export interface SubmitResult { moves: number; } +/** + * Stamps the "ever solved cleanly" fact onto a player's row for a level. + * + * A second statement rather than one more field on `upsertBestScore`, because + * that upsert's `onConflictDoUpdate` is gated by `beatenBy(...)`: a "sans + * retouche" run that does NOT beat the stored row writes nothing — precisely + * the case this column exists to cover. Grafted onto the upsert, it would only + * ever fill in where the seal was already recoverable. + * + * It lives here and not in leaderboard.ts because the column exists on + * `level_score` alone; sharing it would mean a per-table guard (cf. + * `elapsedColumn`) for a rule with exactly one caller. + */ +async function markEverClean(levelId: string, userId: string): Promise { + await db + .update(levelScore) + .set({ everClean: true }) + .where(and(eq(levelScore.levelId, levelId), eq(levelScore.userId, userId))); +} + /** * Records a player's best result for a campaign level. The server re-resolves * the level from the code bank (never from the client) and replays the full @@ -94,6 +114,9 @@ export const submitLevelScore = createServerFn({ method: "POST" }) trace: data.trace, }); + // the row is guaranteed to exist after the upsert, so this always lands + if (result.corrections === 0) await markEverClean(data.levelId, userId); + return { ok: true, moves: result.moves }; }); @@ -101,13 +124,16 @@ export const submitLevelScore = createServerFn({ method: "POST" }) * shape: returns `[]` when not signed in (no throw). Feeds the client's * login-time progress reconciliation (useProgressSync). * - * `undos` rides along so a player arriving on a new device recovers their - * "sans retouche" seals and not just their move records — the local ledger is - * the only other place that flag lives, and localStorage does not travel. It - * is the correction count of the STORED BEST row, which is what the boards - * already rank and seal on. */ + * Two facts, two columns, and both are needed. `undos` is the correction count + * of the STORED BEST row — what the boards rank and seal on, and what + * `planUploads` breaks a tie of equal moves on. `everClean` is the progression + * fact: the level has been solved "sans retouche" at least once, true even + * when that run is not the row the server kept. It is the one the local ledger + * restores on a new device, where localStorage does not travel. */ export const getMyLevelScores = createServerFn({ method: "GET" }).handler( - async (): Promise<{ levelId: string; moves: number; undos: number }[]> => { + async (): Promise< + { levelId: string; moves: number; undos: number; everClean: boolean }[] + > => { const userId = await currentUserId(); if (!userId) return []; return db @@ -115,6 +141,7 @@ export const getMyLevelScores = createServerFn({ method: "GET" }).handler( levelId: levelScore.levelId, moves: levelScore.moves, undos: levelScore.undos, + everClean: levelScore.everClean, }) .from(levelScore) .where(eq(levelScore.userId, userId)); diff --git a/src/ui/hooks/useProgressSync.ts b/src/ui/hooks/useProgressSync.ts index b7c0ab1..d7f2554 100644 --- a/src/ui/hooks/useProgressSync.ts +++ b/src/ui/hooks/useProgressSync.ts @@ -42,10 +42,9 @@ export function useProgressSync(uid: string | null, deps: Deps) { // object, so this costs a reference comparison. No trace is sent — the // ledger drops any stale one, since it would no longer match the record. // - // A row seals only when the STORED BEST was clean. A player whose clean - // run was not their best row keeps their seal locally but cannot recover - // it on a new device: the server holds one row per level, not a history, - // so "ever solved cleanly" is not a fact it has. + // A row seals on `everClean`, the column the server carries for exactly + // this moment — so a player whose "sans retouche" run was never their + // best row still gets their seal back here. for (const s of server) record(asWin(s)); // independent per-level upserts: fire together rather than serializing N // round-trips. A stale/invalid trace must not break the others — swallow diff --git a/src/ui/progressSync.test.ts b/src/ui/progressSync.test.ts index 7b186fc..6e46fb8 100644 --- a/src/ui/progressSync.test.ts +++ b/src/ui/progressSync.test.ts @@ -20,12 +20,14 @@ const L = ( traces: Record = {}, ): Ledger => ({ ...emptyLedger, best, traces }); -// `undos` is irrelevant to the upload decision (the server re-derives it from -// the trace we send), so the rows here carry a plain zero +// `undos` and `everClean` are irrelevant to the upload decision (the server +// re-derives the corrections from the trace we send), so the rows here carry +// plain defaults const remote = (levelId: string, moves: number): ServerScore => ({ levelId, moves, undos: 0, + everClean: false, }); describe("planUploads", () => { @@ -80,49 +82,80 @@ describe("planUploads", () => { }); describe("asWin — a stored row offered to the ledger", () => { - it("carries the record and seals a row solved with no correction", () => { - expect(asWin({ levelId: "a", moves: 4, undos: 0 })).toEqual({ + it("carries the record and seals a level ever solved cleanly", () => { + expect( + asWin({ levelId: "a", moves: 4, undos: 0, everClean: true }), + ).toEqual({ levelId: "a", moves: 4, clean: true, }); }); - it("does not seal a row whose best solve used corrections", () => { - expect(asWin({ levelId: "a", moves: 4, undos: 2 })).toMatchObject({ - clean: false, - }); + it("does not seal a level never solved cleanly", () => { + expect( + asWin({ levelId: "a", moves: 4, undos: 2, everClean: false }), + ).toMatchObject({ clean: false }); + }); + + it("seals on the level's history, not on the stored row's corrections", () => { + // THE gap this column closes: the clean run was not the best row, so the + // stored best carries corrections — the seal must come from `everClean` + expect( + asWin({ levelId: "a", moves: 4, undos: 2, everClean: true }), + ).toMatchObject({ clean: true }); + }); + + it("withholds the seal from a clean-looking row the server never marked", () => { + // the mirror case: `undos === 0` is the boards' rule, not progression's. + // Reading it here would resurrect the old spelling behind the new column. + expect( + asWin({ levelId: "a", moves: 4, undos: 0, everClean: false }), + ).toMatchObject({ clean: false }); }); it("carries no trace — the ledger drops any that no longer fits", () => { - expect(asWin({ levelId: "a", moves: 4, undos: 0 }).trace).toBeUndefined(); + expect( + asWin({ levelId: "a", moves: 4, undos: 0, everClean: true }).trace, + ).toBeUndefined(); }); it("restores records and seals when folded over a fresh ledger", () => { // the new-device path: an empty ledger, every stored row offered to it const server: ServerScore[] = [ - { levelId: "accord", moves: 4, undos: 0 }, - { levelId: "retenue", moves: 9, undos: 3 }, + { levelId: "accord", moves: 4, undos: 0, everClean: true }, + { levelId: "retenue", moves: 9, undos: 3, everClean: false }, ]; const restored = server.reduce( (acc, s) => recordWin(acc, asWin(s)), emptyLedger, ); expect(plate(restored, "accord")).toMatchObject({ record: 4, sans: true }); - expect(plate(restored, "retenue")).toMatchObject({ record: 9, sans: false }); + expect(plate(restored, "retenue")).toMatchObject({ + record: 9, + sans: false, + }); }); it("leaves a better local record alone while still raising its seal", () => { // partial local progress: the ledger's own min rule keeps the local 3, and // the seal rises anyway because it is sticky and ungated const local = recordWin(emptyLedger, { levelId: "accord", moves: 3 }); - const merged = recordWin(local, asWin({ levelId: "accord", moves: 5, undos: 0 })); + const merged = recordWin( + local, + asWin({ levelId: "accord", moves: 5, undos: 0, everClean: true }), + ); expect(plate(merged, "accord")).toMatchObject({ record: 3, sans: true }); }); it("costs nothing when the row tells the ledger nothing new", () => { const l = recordWin(emptyLedger, { levelId: "accord", moves: 4 }); // same reference back: React bails out, no render - expect(recordWin(l, asWin({ levelId: "accord", moves: 9, undos: 1 }))).toBe(l); + expect( + recordWin( + l, + asWin({ levelId: "accord", moves: 9, undos: 1, everClean: false }), + ), + ).toBe(l); }); }); diff --git a/src/ui/progressSync.ts b/src/ui/progressSync.ts index 886fd67..9b3b239 100644 --- a/src/ui/progressSync.ts +++ b/src/ui/progressSync.ts @@ -16,12 +16,16 @@ import type { TraceStep } from "../engine/types.ts"; import type { Ledger, Win } from "./progression.ts"; import { undosOf } from "./submissionPolicy.ts"; -/** One of the caller's stored rows. `undos` is the correction count of that - * stored best — zero means the row earned the clean seal. */ +/** One of the caller's stored rows. Two facts, deliberately not one: `undos` is + * the correction count of that STORED BEST — what the boards rank and seal on, + * and what `planUploads` breaks a tie on — while `everClean` says the level was + * once solved with no correction at all, true even when that run is not the row + * the server kept. */ export interface ServerScore { levelId: string; moves: number; undos: number; + everClean: boolean; } /** @@ -31,15 +35,16 @@ export interface ServerScore { * store a trace for a record it may not match. The ledger drops any stale one * instead, and the upload path re-reads the trace from the server anyway. * - * The seal comes from the row's own correction count, which is what the boards - * already rank and seal on. Note what this cannot recover: the server keeps one - * row per level, not a history, so a player whose clean run was NOT their best - * row has no "ever solved cleanly" fact for the server to return. + * The seal comes from `everClean`, NOT from the row's own correction count. The + * server keeps one best row per level, not a history, so reading the row would + * drop the seal of a player whose "sans retouche" run was not their record — + * the very gap that column was added to close. It remembers the fact on the + * player's behalf; here the ledger just takes it, sticky as ever. */ export const asWin = (s: ServerScore): Win => ({ levelId: s.levelId, moves: s.moves, - clean: s.undos === 0, + clean: s.everClean, }); export interface Upload { From 4b29e31b48f2a577bd8f7b03e95678efccefcdcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Caruelle?= Date: Fri, 7 Aug 2026 23:11:52 +0200 Subject: [PATCH 05/11] chore: ne plus exporter ce que personne n'importe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trente-deux symboles sans consommateur hors de leur propre fichier : vingt-six perdent leur export, trois sont supprimés (signOut, SHADE_HEX, FAMILIES), trois relations Drizzle restent intactes — le framework les consomme par convention, pas par import. Aucun changement de comportement. --- src/engine/mechanics/registry.ts | 2 +- src/lib/auth-client.ts | 4 ++-- src/lib/commemoratives.ts | 2 +- src/lib/contribGrid.ts | 24 +++++++----------------- src/lib/distinctions.ts | 14 +++----------- src/lib/streak.ts | 2 +- src/server/daily.ts | 2 +- src/server/dailyPuzzle.ts | 5 +---- src/server/profileData.ts | 2 +- src/server/ranking.ts | 2 +- src/server/replay.ts | 6 +++--- src/server/replay/gif.ts | 2 +- src/solver/bfs.ts | 2 +- src/solver/hunt.ts | 4 ++-- src/ui/altGesture.ts | 2 +- src/ui/demos.ts | 2 +- src/ui/hooks/useDemo.ts | 2 +- src/ui/hooks/useDiscoveryClock.ts | 4 ++-- src/ui/hooks/useKeyboard.ts | 2 +- src/ui/progressSync.ts | 2 +- src/ui/screens/PlayScreen.tsx | 2 +- src/ui/signatures.ts | 2 +- 22 files changed, 35 insertions(+), 56 deletions(-) diff --git a/src/engine/mechanics/registry.ts b/src/engine/mechanics/registry.ts index 4c44fb5..4f2e1c9 100644 --- a/src/engine/mechanics/registry.ts +++ b/src/engine/mechanics/registry.ts @@ -22,7 +22,7 @@ export const MECHANICS: Record = { /** Per-level derived mechanics, computed once — successors() runs per BFS * expansion, so anything re-derivable from the immutable level is hoisted here. */ -export interface CompiledMechanics { +interface CompiledMechanics { mechs: Mechanic[]; resolveMove?: (pos: Pos, dir: Pos, ctx: MoveCtx) => Pos; mapDirB?: (dir: Pos) => Pos; diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts index 2e08948..3ee0be9 100644 --- a/src/lib/auth-client.ts +++ b/src/lib/auth-client.ts @@ -4,6 +4,6 @@ import { createAuthClient } from "better-auth/react"; import { usernameClient } from "better-auth/client/plugins"; -export const authClient = createAuthClient({ plugins: [usernameClient()] }); +const authClient = createAuthClient({ plugins: [usernameClient()] }); -export const { signIn, signUp, signOut, useSession } = authClient; +export const { signIn, signUp, useSession } = authClient; diff --git a/src/lib/commemoratives.ts b/src/lib/commemoratives.ts index 6153d8d..de01d66 100644 --- a/src/lib/commemoratives.ts +++ b/src/lib/commemoratives.ts @@ -16,7 +16,7 @@ // list. When the trigger exists it only has to fill that list — the artwork, the // layout and the profile plumbing are already in place. -export type Commemorative = "sissi"; +type Commemorative = "sissi"; /** A commemorative a player holds, with the day it was struck — the postmark. * Mirrors what a series stamp carries, so `Stamp` can render either. */ diff --git a/src/lib/contribGrid.ts b/src/lib/contribGrid.ts index 20f0ff0..d0cbb58 100644 --- a/src/lib/contribGrid.ts +++ b/src/lib/contribGrid.ts @@ -1,26 +1,23 @@ -// The contribution grid geometry, shared by the on-screen ContributionGraph and -// the server-rendered OG card so the two can't drift. Pure: it lays out one -// calendar year (Jan–Dec) of played days as Monday-first week columns, GitHub -// style. Colour/markup is the caller's job — this owns only the shape. +// The contribution grid geometry behind the on-screen ContributionGraph. Pure: +// it lays out one calendar year (Jan–Dec) of played days as Monday-first week +// columns, GitHub style. Colour/markup is the caller's job — this owns only the +// shape. import { shiftDay } from "./day.ts"; -export interface DayCount { +interface DayCount { date: string; count: number; } -export interface GridCell { +interface GridCell { date: string; count: number; spacer: boolean; // a pad cell: outside the year, or a future day not yet played } // The completion ramp, indexed by tiers solved (0 = empty base … 4 = amber lock). -// Two representations of the SAME ramp, kept adjacent so a retint touches both: -// the on-screen grid blends alpha over its panel (Tailwind opacity classes), the -// OG card has no alpha-over-background so it needs pre-blended opaque hex. Keep -// these two arrays visually in sync. +// Tailwind opacity classes: the on-screen grid blends alpha over its panel. export const SHADE_CLASSES = [ "bg-paper/[0.07]", "bg-paper/25", @@ -28,13 +25,6 @@ export const SHADE_CLASSES = [ "bg-paper/70", "bg-tape/90", ] as const; -export const SHADE_HEX = [ - "#2a2620", - "#4a453c", - "#7d766a", - "#b8b0a2", - "#e8b84b", -] as const; /** Monday-first weekday index (0 = Monday … 6 = Sunday). */ function mondayIndex(date: string): number { diff --git a/src/lib/distinctions.ts b/src/lib/distinctions.ts index c1b40d6..9402b0c 100644 --- a/src/lib/distinctions.ts +++ b/src/lib/distinctions.ts @@ -13,14 +13,6 @@ import { shiftDay } from "./day.ts"; export type Family = "regularite" | "maitrise" | "rarete" | "edition"; -/** The four families, in the order they are printed on the sheet. */ -export const FAMILIES: readonly Family[] = [ - "regularite", - "maitrise", - "rarete", - "edition", -] as const; - /** Each family's four face values. Ascending, always four. */ export const THRESHOLDS: Record = { regularite: [7, 30, 100, 365], @@ -122,9 +114,9 @@ function resolve( }; } -/** The four distinctions, always all four and always in `FAMILIES` order. A - * family the player has not opened yet comes back at tier 0 with `next` set — - * that is the empty album mount, not an absence. */ +/** The four distinctions, always all four and always in the order they are + * printed on the sheet. A family the player has not opened yet comes back at + * tier 0 with `next` set — that is the empty album mount, not an absence. */ export function distinctions(input: DistinctionInput): Distinction[] { const runs = runsOf(input.days); diff --git a/src/lib/streak.ts b/src/lib/streak.ts index d3ad598..830fc25 100644 --- a/src/lib/streak.ts +++ b/src/lib/streak.ts @@ -4,7 +4,7 @@ import { shiftDay } from "./day.ts"; -export interface Streaks { +interface Streaks { total: number; // distinct days played current: number; // consecutive days ending today (or yesterday, grace window) longest: number; // longest consecutive run ever diff --git a/src/server/daily.ts b/src/server/daily.ts index 8962920..3b60bc2 100644 --- a/src/server/daily.ts +++ b/src/server/daily.ts @@ -57,7 +57,7 @@ function isSubmittableDay(date: string): boolean { } /** What the board needs to start: the grid, and the clock it starts. */ -export interface DailyOpening { +interface DailyOpening { /** Null only for an absent weekend épreuve — the route redirects. */ puzzle: DailyPuzzle | null; /** The immutable anchor for this player — null whenever this result will not diff --git a/src/server/dailyPuzzle.ts b/src/server/dailyPuzzle.ts index d5d99e9..05c2c06 100644 --- a/src/server/dailyPuzzle.ts +++ b/src/server/dailyPuzzle.ts @@ -92,10 +92,7 @@ export async function fetchRow( /** A campaign-band tier (0–2): the DB row, else the deterministic bank fallback, * so /daily is always playable. NOT for the weekend tier (it has no fallback — * use `puzzleFor`, which may resolve null). */ -export async function resolveDaily( - date: string, - tier: number, -): Promise { +async function resolveDaily(date: string, tier: number): Promise { return (await fetchRow(date, tier)) ?? fallbackPuzzle(date, tier); } diff --git a/src/server/profileData.ts b/src/server/profileData.ts index fa620fa..208c03e 100644 --- a/src/server/profileData.ts +++ b/src/server/profileData.ts @@ -18,7 +18,7 @@ import type { DistinctionInput } from "../lib/distinctions.ts"; import type { Held } from "../lib/commemoratives.ts"; /** One campaign board the player has put on the record. */ -export interface PlateRecord { +interface PlateRecord { levelId: string; moves: number; undos: number; diff --git a/src/server/ranking.ts b/src/server/ranking.ts index 34cce92..8dc33b0 100644 --- a/src/server/ranking.ts +++ b/src/server/ranking.ts @@ -39,7 +39,7 @@ import { /** The columns the rule reads. Both score tables expose the first three; * `elapsedMs` is the daily's alone. */ -export interface RankColumns { +interface RankColumns { moves: Column; undos: Column; createdAt: Column; diff --git a/src/server/replay.ts b/src/server/replay.ts index 0a05511..56eecde 100644 --- a/src/server/replay.ts +++ b/src/server/replay.ts @@ -27,9 +27,9 @@ function assertValidInput(step: { kind?: unknown; dir?: unknown }): void { // Hard ceiling on submitted length: a raw trace carries corrections (undo/reset) // so it runs longer than a clean solution, but no honest play is near this long // and it bounds replay work per request. -export const MAX_TRACE = 2000; +const MAX_TRACE = 2000; -export interface TraceResult { +interface TraceResult { ok: boolean; moves: number; // length of the final winning line (corrections don't count) corrections: number; // undos on the winning attempt, resets excluded (0 = clean solve) @@ -37,7 +37,7 @@ export interface TraceResult { const TRACE_REJECT: TraceResult = { ok: false, moves: 0, corrections: 0 }; -export interface ReplayRun { +interface ReplayRun { states: GameState[]; // initial state first, then one per surviving input corrections: number; // undos since the last reset (a reset zeroes the tally) } diff --git a/src/server/replay/gif.ts b/src/server/replay/gif.ts index 676a4b9..bf4a753 100644 --- a/src/server/replay/gif.ts +++ b/src/server/replay/gif.ts @@ -19,7 +19,7 @@ export interface GifFrame { delayCs: number; } -export interface GifOptions { +interface GifOptions { width: number; height: number; palette: Palette; diff --git a/src/solver/bfs.ts b/src/solver/bfs.ts index 456fb20..d725a28 100644 --- a/src/solver/bfs.ts +++ b/src/solver/bfs.ts @@ -6,7 +6,7 @@ import { hashState, initialState, isWin } from "../engine/state.ts"; import { successors } from "../engine/successors.ts"; import { MECHANICS } from "../engine/mechanics/registry.ts"; -export interface Solution { +interface Solution { inputs: Input[]; } diff --git a/src/solver/hunt.ts b/src/solver/hunt.ts index e09b671..682def2 100644 --- a/src/solver/hunt.ts +++ b/src/solver/hunt.ts @@ -10,7 +10,7 @@ import { levelSignature } from "./signature.ts"; const rnd = (n: number) => Math.floor(Math.random() * n); -export function randomLevel(mods: MechanicId[], size: number): Level { +function randomLevel(mods: MechanicId[], size: number): Level { const cell = (): Pos => [rnd(size), rnd(size)]; const ck = (p: Pos) => p[0] * size + p[1]; const aStart = cell(); @@ -66,7 +66,7 @@ export interface Found { sig: string; // symmetry-aware canonical signature (see signature.ts) } -export interface HuntOpts { +interface HuntOpts { mods: MechanicId[]; size: number; minLen: number; diff --git a/src/ui/altGesture.ts b/src/ui/altGesture.ts index d493d0f..93d435c 100644 --- a/src/ui/altGesture.ts +++ b/src/ui/altGesture.ts @@ -7,7 +7,7 @@ import type { GameState, Input, Level } from "../engine/types.ts"; -export type AltKind = "split" | "shift"; +type AltKind = "split" | "shift"; /** The alt gesture `st` offers on `level`, or null when there is none. */ export function altGesture(st: GameState, level: Level): AltKind | null { diff --git a/src/ui/demos.ts b/src/ui/demos.ts index cf74608..783f1a6 100644 --- a/src/ui/demos.ts +++ b/src/ui/demos.ts @@ -242,7 +242,7 @@ export const DEMOS: Demo[] = [ /** One played input: the resulting state (unchanged when `blocked`), plus flags * the player turns into feedback — `blocked` bumps the box, `merged` blooms. */ -export interface DemoStep { +interface DemoStep { input: Input; state: GameState; blocked: boolean; diff --git a/src/ui/hooks/useDemo.ts b/src/ui/hooks/useDemo.ts index 09f9a47..db08fbe 100644 --- a/src/ui/hooks/useDemo.ts +++ b/src/ui/hooks/useDemo.ts @@ -51,7 +51,7 @@ export function useSeenDemos() { /** Which control to light up next. `arm`: the ✕/world control must be pressed * first; `dir`: the arrow to press; `any`: every arrow works — pick one. */ -export interface DemoGuidance { +interface DemoGuidance { arm: boolean; dir: Pos | null; any: boolean; diff --git a/src/ui/hooks/useDiscoveryClock.ts b/src/ui/hooks/useDiscoveryClock.ts index f3e82f6..6406bf8 100644 --- a/src/ui/hooks/useDiscoveryClock.ts +++ b/src/ui/hooks/useDiscoveryClock.ts @@ -10,7 +10,7 @@ import { useCallback, useEffect, useState } from "react"; /** Where the clock reads from: the server's anchor for this player, and the * server's own time when it replied. */ -export interface ClockSource { +interface ClockSource { servedAt: string | null; serverNow: string; } @@ -61,7 +61,7 @@ export function useDiscoveryClock( * · null — ranked and unmeasured: there is no time to show; * · a number — ranked and measured. */ -export type Recorded = number | null | undefined; +type Recorded = number | null | undefined; export const recordedFrom = ( mine: { elapsedMs?: number | null } | null, diff --git a/src/ui/hooks/useKeyboard.ts b/src/ui/hooks/useKeyboard.ts index fd20184..aaba1f6 100644 --- a/src/ui/hooks/useKeyboard.ts +++ b/src/ui/hooks/useKeyboard.ts @@ -16,7 +16,7 @@ const DIRS: Record = { // turns the release into "R", the abandon is missed, and the sweep confirms anyway. const isReset = (e: KeyboardEvent) => e.key.toLowerCase() === "r"; -export interface KeyHandlers { +interface KeyHandlers { play: (dir: Pos, wantAlt: boolean) => void; undo: () => void; resetDown: () => void; diff --git a/src/ui/progressSync.ts b/src/ui/progressSync.ts index 9b3b239..4480683 100644 --- a/src/ui/progressSync.ts +++ b/src/ui/progressSync.ts @@ -47,7 +47,7 @@ export const asWin = (s: ServerScore): Win => ({ clean: s.everClean, }); -export interface Upload { +interface Upload { levelId: string; trace: TraceStep[]; } diff --git a/src/ui/screens/PlayScreen.tsx b/src/ui/screens/PlayScreen.tsx index d95382b..61b0c89 100644 --- a/src/ui/screens/PlayScreen.tsx +++ b/src/ui/screens/PlayScreen.tsx @@ -102,7 +102,7 @@ const chip = /** Daily mode: one tier of the day's challenge, played for the shared * per-tier leaderboard rather than the campaign. Swaps the HUD banner and the * win overlay. `tier` is 0 easy · 1 medium · 2 hard. */ -export interface DailyMode { +interface DailyMode { date: string; tier: number; optimal: number; diff --git a/src/ui/signatures.ts b/src/ui/signatures.ts index d6e210c..404deb7 100644 --- a/src/ui/signatures.ts +++ b/src/ui/signatures.ts @@ -7,7 +7,7 @@ import type { GameState, Input, Level } from "../engine/types.ts"; import type { SoundFx } from "./hooks/useSound.ts"; -export interface Signature { +interface Signature { fx: keyof Pick< SoundFx, "block" | "merge" | "shift" | "split" | "slide" | "move" From e6dcf378762dd75c3a22d2de04417bc7ab809f0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Caruelle?= Date: Fri, 7 Aug 2026 23:14:01 +0200 Subject: [PATCH 06/11] fix(solveur): le projet TS du solveur retypecheck MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/solver est exclu du tsconfig de l'app, donc ni build ni CI ne le couvraient : son propre projet échouait depuis un moment sur six TS2345 dans hunt.test.ts (mods littéral readonly contre MechanicId[] mutable). HuntOpts.mods et randomLevel prennent un readonly — le hunt ne fait que lire cette liste — et le niveau généré copie l'array au lieu de l'aliaser. --- src/solver/hunt.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/solver/hunt.ts b/src/solver/hunt.ts index 682def2..3f46db4 100644 --- a/src/solver/hunt.ts +++ b/src/solver/hunt.ts @@ -10,7 +10,7 @@ import { levelSignature } from "./signature.ts"; const rnd = (n: number) => Math.floor(Math.random() * n); -function randomLevel(mods: MechanicId[], size: number): Level { +function randomLevel(mods: readonly MechanicId[], size: number): Level { const cell = (): Pos => [rnd(size), rnd(size)]; const ck = (p: Pos) => p[0] * size + p[1]; const aStart = cell(); @@ -33,7 +33,9 @@ function randomLevel(mods: MechanicId[], size: number): Level { ch: "GEN", name: "gen", size, - mods, + // copied, not aliased: `Level.mods` is mutable across the engine, and every + // generated level would otherwise share the caller's one array + mods: [...mods], a: { start: aStart, goal: aGoal, @@ -67,7 +69,10 @@ export interface Found { } interface HuntOpts { - mods: MechanicId[]; + // readonly: the hunt only ever reads this list, and every caller passes a + // literal (`["fusion", "scission"] as const` in the tests, the tier tables in + // generate-daily). A mutable array here rejected all of them. + mods: readonly MechanicId[]; size: number; minLen: number; budgetMs: number; From 4d0aa42cf71b1b4e646284c4a898394eebd8a7a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Caruelle?= Date: Sat, 8 Aug 2026 00:41:35 +0200 Subject: [PATCH 07/11] feat(profil): quitter la table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'app avait des comptes et aucun moyen d'en sortir : sur un poste partagé, c'était la session d'un autre qui jouait. Le tri des exports morts l'a révélé — signOut était exporté sans appelant parce que l'écran n'existait pas. Un bouton sous le colophon de /profile/me, gouverné par une prop optionnelle que le profil public ne passe pas. La navigation part de onSuccess : le client better-auth ne rejette pas, un aller-retour manqué laisserait le joueur croire qu'il est déconnecté. --- project.inlang/messages/en.json | 1 + project.inlang/messages/fr.json | 1 + src/lib/auth-client.ts | 2 +- src/routes/profile.me.tsx | 21 ++++++++++++++-- src/ui/screens/ProfileScreen.test.tsx | 36 +++++++++++++++++++++++++-- src/ui/screens/ProfileScreen.tsx | 17 +++++++++++++ 6 files changed, 73 insertions(+), 5 deletions(-) diff --git a/project.inlang/messages/en.json b/project.inlang/messages/en.json index 1dd965a..3da8264 100644 --- a/project.inlang/messages/en.json +++ b/project.inlang/messages/en.json @@ -221,6 +221,7 @@ "profile_more": "more", "profile_empty": "No prints yet — solve today's puzzle.", "profile_signin_prompt": "Sign in to see your prints.", + "profile_signout": "leave the table", "profile_edition_title": "The edition", "profile_bat_total": "ready to print", "profile_clean_total": "no touch-ups", diff --git a/project.inlang/messages/fr.json b/project.inlang/messages/fr.json index d58d04e..cb7bf7b 100644 --- a/project.inlang/messages/fr.json +++ b/project.inlang/messages/fr.json @@ -221,6 +221,7 @@ "profile_more": "plus", "profile_empty": "Aucun tirage encore — résous le défi du jour.", "profile_signin_prompt": "Connecte-toi pour voir tes tirages.", + "profile_signout": "quitter la table", "profile_edition_title": "L'édition", "profile_bat_total": "bons à tirer", "profile_clean_total": "sans retouche", diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts index 3ee0be9..45bdb42 100644 --- a/src/lib/auth-client.ts +++ b/src/lib/auth-client.ts @@ -6,4 +6,4 @@ import { usernameClient } from "better-auth/client/plugins"; const authClient = createAuthClient({ plugins: [usernameClient()] }); -export const { signIn, signUp, useSession } = authClient; +export const { signIn, signUp, signOut, useSession } = authClient; diff --git a/src/routes/profile.me.tsx b/src/routes/profile.me.tsx index 337ff0e..9974269 100644 --- a/src/routes/profile.me.tsx +++ b/src/routes/profile.me.tsx @@ -7,7 +7,7 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useEffect, useState } from "react"; import { m } from "../paraglide/messages.js"; import { getMyDailyHistory, type DailyHistory } from "../server/profile.ts"; -import { useSession } from "../lib/auth-client.ts"; +import { signOut, useSession } from "../lib/auth-client.ts"; import { utcDay } from "../lib/day.ts"; import { ProfileScreen } from "../ui/screens/ProfileScreen.tsx"; import { AuthPanel } from "../ui/components/AuthPanel.tsx"; @@ -41,9 +41,26 @@ function MeRoute() { const back = () => navigate({ to: "/levels" }); + // Better Auth clears its session store on /sign-out exactly as it fills it on + // /sign-in, so `useSession` flips and this page would fall through to its own + // sign-in gate — a private page inviting the player back in, with the account + // they just put down. Leave for the edition instead: that is already where + // the gate's own way out points. Navigating from onSuccess so a request that + // never landed leaves both the session and the page as they were. + const leave = () => { + void signOut({ fetchOptions: { onSuccess: back } }); + }; + // signed in with history loaded — the real page if (session && history) - return ; + return ( + + ); // the fetch failed while signed in — offer a way out rather than a dead-end // blank table (a transient error would otherwise strand the page forever) diff --git a/src/ui/screens/ProfileScreen.test.tsx b/src/ui/screens/ProfileScreen.test.tsx index 14534f6..f1b9dff 100644 --- a/src/ui/screens/ProfileScreen.test.tsx +++ b/src/ui/screens/ProfileScreen.test.tsx @@ -1,5 +1,6 @@ -import { describe, expect, it } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { m } from "../../paraglide/messages.js"; import { ProfileScreen } from "./ProfileScreen.tsx"; import type { DailyHistory } from "../../server/profile.ts"; @@ -64,3 +65,34 @@ describe("ProfileScreen — hors-série", () => { ).toBeGreaterThanOrEqual(4); }); }); + +// The way out of an account — for a long time there was none, and a shared +// machine kept whoever signed in first. What is worth pinning is that the +// control is wired to the route (which is what calls signOut), that its label +// comes from the catalogue rather than the component, and that it stays off a +// public profile, which is somebody else's sheet. + +const signout = () => + screen.queryByRole("button", { name: m.profile_signout() }); + +describe("ProfileScreen — leaving the table", () => { + it("prints no way out when the page is not the reader's own", () => { + render(); + expect(signout()).toBeNull(); + }); + + it("hands the click to the route, which ends the session", () => { + const onSignOut = vi.fn(); + render( + , + ); + + fireEvent.click(signout()!); + expect(onSignOut).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/ui/screens/ProfileScreen.tsx b/src/ui/screens/ProfileScreen.tsx index 6587998..0cfbeb0 100644 --- a/src/ui/screens/ProfileScreen.tsx +++ b/src/ui/screens/ProfileScreen.tsx @@ -146,10 +146,14 @@ export function ProfileScreen({ history, today, onBack, + onSignOut, }: { history: DailyHistory; today: string; onBack: () => void; + /** Ending the session — handed in by /profile/me only. A public profile is + * somebody else's sheet, so the control is simply not printed there. */ + onSignOut?: () => void; }) { // true on the server render and the client render that hydrates it, false on // every subsequent client navigation — gates the entrance animation (below). @@ -203,6 +207,19 @@ export function ProfileScreen({ {m.profile_member_since({ since })} + {/* under the colophon, where the sheet says whose it is — the one + place an account can be put down again. On a shared machine that + matters more than being discreet, so it sits above the fold + rather than at the foot of a very long sheet. */} + {onSignOut && ( + + )} {/* the series — four stamps, or the empty mounts where they will go */} From 252b775c2e19a9e86b13409238ae04e345cd39ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Caruelle?= Date: Sat, 8 Aug 2026 00:45:51 +0200 Subject: [PATCH 08/11] feat(seo): le sitemap et robots deviennent des routes serveur MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La directive Sitemap: exige une URL absolue, que seul le serveur connaît (BETTER_AUTH_URL) : les garder statiques imposait de coder l'origine de production en dur. public/robots.txt disparaît, une route et un fichier de même URL se disputeraient l'adresse. Le sitemap ne liste que les routes statiques, dérivées de LEVELS. Les profils en sont absents : publiables un par un, pas énumérables dans un fichier servi à tout le monde. Sans origine il répond 404 plutôt que d'enseigner des URL relatives à un crawler ; robots, lui, reste utile et perd juste sa ligne. README et AGENTS.md remis en phase avec le dépôt. --- AGENTS.md | 102 ++++++++++++++++++++---------------- README.md | 49 +++++++++-------- public/robots.txt | 3 -- src/routeTree.gen.ts | 42 +++++++++++++++ src/routes/robots[.]txt.ts | 20 +++++++ src/routes/sitemap[.]xml.ts | 23 ++++++++ src/server/seo.test.ts | 72 +++++++++++++++++++++++++ src/server/seo.ts | 65 +++++++++++++++++++++++ 8 files changed, 305 insertions(+), 71 deletions(-) delete mode 100644 public/robots.txt create mode 100644 src/routes/robots[.]txt.ts create mode 100644 src/routes/sitemap[.]xml.ts create mode 100644 src/server/seo.test.ts create mode 100644 src/server/seo.ts diff --git a/AGENTS.md b/AGENTS.md index 3493b58..95061a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,8 +67,10 @@ routing, and build were migrated. `/profile/$username` the player pages. Session state that used to be lifted (sound, mute) is per-route via `usePersistedSound`; progression is read from localStorage by `useBestScores` in whichever route needs it. - `__root.tsx` is the HTML shell (`lang="fr"`, theme-color `#14110E`, - `src/index.css`). + `__root.tsx` is the HTML shell (`lang` from the request's resolved locale, + theme-color `#14110E`, `src/index.css`) and carries the app's two route + boundaries, `notFoundComponent` and `errorComponent`. + `/sitemap.xml` and `/robots.txt` are server routes too (see "Don't recreate"). - React Compiler kept, applied via `@rolldown/plugin-babel` + `reactCompilerPreset` from `@vitejs/plugin-react` (plugin-react v6 in rolldown-vite has **no** `babel` option — do not try `viteReact({ babel })`). @@ -100,7 +102,7 @@ routing, and build were migrated. - `bun run db:generate` / `db:migrate` — Drizzle migrations (needs `DATABASE_URL`) - `bun run auth:generate` — regenerate the Better Auth tables via the CLI (`bunx`, overwrites `src/db/schema.ts` — re-append our tables from git after: - `dailyPuzzle`, `dailyScore`, `levelScore`) + `dailyPuzzle`, `dailyScore`, `dailyView`, `levelScore`) - `bun run lint` — oxlint - `bun run generate-routes` — regenerate the route tree (`tsr generate`) @@ -116,12 +118,16 @@ the real values. Railway use `${{Postgres.DATABASE_URL}}` (private host, no SSL). - `BETTER_AUTH_SECRET` — `openssl rand -base64 32`. - `BETTER_AUTH_URL` — the app's public origin (also added to `trustedOrigins`). + Doubles as the origin for every absolute URL the server emits: `og:image` and + the two crawler documents. Unset, `/sitemap.xml` answers 404 rather than serve + relative ``s, and `robots.txt` drops its `Sitemap:` line. ## Server layer — daily level, accounts, leaderboard A shared daily puzzle (Wordle-style) with email/password accounts, a per-day -leaderboard, and public player profiles. Components never render server-side -(`defaultSsr: "data-only"`); data flows through server functions + auth/OG API +leaderboard, and public player profiles. The public profile is the one route +that renders server-side; everywhere else `defaultSsr: "data-only"` keeps the +components client-only and data flows through server functions + auth/OG API routes. Profiles: `username` plugin (Better Auth) gives each account a unique handle; `/profile/me` (private) and `/profile/$username` (public) show a daily contribution grid + streaks; `/api/og/$username` renders a dynamic Open Graph @@ -131,8 +137,8 @@ import that (or `db`) from a module the client route tree pulls in. - **DB**: Postgres + Drizzle (`pg` pool). `src/db/index.ts` (pool + client), `src/db/schema.ts` (auth tables generated by the Better Auth CLI + our - `daily_puzzle` / `daily_score`). Migrations in `src/db/migrations/`, config in - `src/db/drizzle.config.ts`. + `daily_puzzle` / `daily_score` / `daily_view` / `level_score`). Migrations in + `src/db/migrations/`, config in `src/db/drizzle.config.ts`. - **Auth**: Better Auth, email + password, Drizzle adapter (`provider: "pg"`). `src/lib/auth.ts` (server) — `tanstackStartCookies()` MUST be the **last** plugin. `src/lib/auth-client.ts` (`better-auth/react`). Mounted at @@ -149,10 +155,11 @@ import that (or `db`) from a module the client route tree pulls in. the server derives both the move count and the correction count for the clean-solve rank). Tested in `src/server/replay.test.ts`. - **Generation** (`src/solver/`): `hunt.ts` (extracted from `generate.ts` — - `randomLevel` + certified search, importable), `generate-daily.ts` (cron CLI: + random board draw + certified search behind the single `hunt` export), + `generate-daily.ts` (cron CLI: hunt today + J+1/J+2, insert, **close the pool and `exit(0)`**). -- **UI**: `/daily` route plays the day's level through `PlayScreen` in daily - mode. The shared `LeaderboardRail` (bound to the day via `DailyBoard`, to a +- **UI**: the `/daily/$tier` route plays the day's level through `PlayScreen` in + daily mode. The shared `LeaderboardRail` (bound to the day via `DailyBoard`, to a campaign level via `LevelBoard`) reads the board, gates on an account (`AuthPanel`), auto-submits the winning trace, and shows the standing; `DailyOverlay` is now the daily win celebration only. `useGame` records the @@ -160,39 +167,41 @@ import that (or `db`) from a module the client route tree pulls in. ### Don't recreate -| Concern | Lives in | -| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| who drives the board (game vs tutorial) | `src/ui/plateDriver.ts` — the `PlateDriver` interface + its two PURE adapters (`playingPlate`, `guidedPlate`). PlayScreen binds ONE; never branch on "is the tutorial live?" anywhere else | -| the "clean pull" mark | two forms, one idea: `src/ui/components/CleanSeal.tsx` on the BOARDS (ranked rows, standing footer), and the `sp-ink-frame` class in `src/index.css` on the EDITION (a plate's record frame changes ink instead of wearing a badge) | -| the "ever solved cleanly" fact | `level_score.ever_clean` — set by `markEverClean` in `src/server/campaign.ts` (a SECOND statement: the upsert's guard skips a clean run that isn't a best), read by `asWin` in `src/ui/progressSync.ts`. Progression, not ranking: boards seal what they show | -| profile distinctions (families + thresholds) | `src/lib/distinctions.ts` — the SOLE owner of what a family measures, where its four face values sit, and which day postmarks a tier. `Stamp.tsx` draws what it is handed; `profileData.ts` only gathers dates. Put no threshold in either | -| the profile stamp artwork | `src/ui/components/Stamp.tsx` — the four engravings, the perforation, the burelage, the empty album mount. The OG card must reuse it rather than paint a second version. No `mix-blend-mode`: satori cannot render it, so the two inks are painted explicitly | -| the tutorial's on-board content | `src/ui/components/DemoOverlay.tsx` | -| progression ledger + stamps + chapter folding | `src/ui/progression.ts` (`Ledger`, `recordWin`, `markHinted`, `plate`, `setPulled`, `atPar`). `useBestScores` is only its React/localStorage adapter — put no rule there | -| discovery-anchor lifecycle + the measurement | `src/server/discovery.ts` (`AnchorStore`, `claimAnchor`, `shownAnchor`, `discoveryTime`); the Postgres adapter is `anchorsFor()` in `src/server/daily.ts` | -| the daily clock's three-state reconciliation | `useDailyClock` in `src/ui/hooks/useDiscoveryClock.ts` | -| auth guard on score WRITES | `requireUserId()` in `src/server/leaderboard.ts` (reads keep `currentUserId`, which answers a signed-out caller instead of throwing) | -| pg pool + drizzle client | `src/db/index.ts` | -| DB tables (auth + daily + campaign) | `src/db/schema.ts` | -| Better Auth server / client | `src/lib/auth.ts` / `src/lib/auth-client.ts` | -| auth HTTP mount | `src/routes/api/auth/$.ts` | -| daily server functions | `src/server/daily.ts` | -| campaign leaderboard functions | `src/server/campaign.ts` | -| trace replay + shape validation | `src/server/replay.ts` (`validateTrace`, `validateTraceShape`) | -| level generation core | `src/solver/hunt.ts` | -| daily cron entry | `src/solver/generate-daily.ts` | -| daily win overlay / auth form | `src/ui/components/DailyOverlay.tsx` / `AuthPanel.tsx` | -| leaderboard row list | `src/ui/components/LeaderboardRows.tsx` | -| leaderboard rail (shared) | `src/ui/components/LeaderboardRail.tsx` (bound by `DailyBoard.tsx` / `LevelBoard.tsx`) | -| ranking rule (upsert guard + ORDER BY + rank) | `src/server/ranking.ts` — the ONLY place "fewer moves, then fewer corrections" lives | -| score upsert + board types + currentUserId | `src/server/leaderboard.ts` (`upsertBestScore`, `LeaderRow`/`MyResult`/`BoardData`) | -| merge equation (`a == b + off`) | `merges()` in `src/engine/state.ts` — sole owner, used by successors AND decalage | -| per-level compiled mechanics | `compiled()` in `src/engine/mechanics/registry.ts` (WeakMap; single-owner guard for `resolveMove`/`mapDirB`/`settle`) | -| mechanic necessity semantics | `removedWith`/`mustBeNeeded` on each mechanic; probe = `isRequired()` in `src/solver/bfs.ts` | -| verb → sound/haptic signature | `inputSignature()` in `src/ui/signatures.ts` (game + tutorial both consume it) | -| alt-gesture rule (split/world) | `altGesture()` in `src/ui/altGesture.ts` | -| solve value + submission policy | `src/ui/submissionPolicy.ts` (`Solve`, `decideSubmission` — pure, owns the UI status) | -| hold-to-confirm (state, then control) | `src/ui/hooks/useHold.ts` + `src/ui/components/HoldButton.tsx` — reset is HELD, never clicked. The state lives in `PlayScreen` so the button and the R key share ONE sweep; `useKeyboard` takes `resetDown`/`resetUp` rather than a `reset` callback. | +| Concern | Lives in | +| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| who drives the board (game vs tutorial) | `src/ui/plateDriver.ts` — the `PlateDriver` interface + its two PURE adapters (`playingPlate`, `guidedPlate`). PlayScreen binds ONE; never branch on "is the tutorial live?" anywhere else | +| the "clean pull" mark | two forms, one idea: `src/ui/components/CleanSeal.tsx` on the BOARDS (ranked rows, standing footer), and the `sp-ink-frame` class in `src/index.css` on the EDITION (a plate's record frame changes ink instead of wearing a badge) | +| the "ever solved cleanly" fact | `level_score.ever_clean` — set by `markEverClean` in `src/server/campaign.ts` (a SECOND statement: the upsert's guard skips a clean run that isn't a best), read by `asWin` in `src/ui/progressSync.ts`. Progression, not ranking: boards seal what they show | +| profile distinctions (families + thresholds) | `src/lib/distinctions.ts` — the SOLE owner of what a family measures, where its four face values sit, and which day postmarks a tier. `Stamp.tsx` draws what it is handed; `profileData.ts` only gathers dates. Put no threshold in either | +| the profile stamp artwork | `src/ui/components/Stamp.tsx` — the four engravings, the perforation, the burelage, the empty album mount. The OG card must reuse it rather than paint a second version. No `mix-blend-mode`: satori cannot render it, so the two inks are painted explicitly | +| the 404 AND the error screen | `src/ui/screens/FallbackScreen.tsx` — ONE screen, parameterised by title/body/action, mounted twice from `__root.tsx` (`notFoundComponent`, `errorComponent`). It never renders the error itself: a loader failure carries server detail the visitor can't act on. Touches no browser API (the root is `ssr: true`) | +| what crawlers are told (sitemap + robots) | `src/server/seo.ts` (`sitemapXml`, `robotsTxt`) — pure text, served by `src/routes/sitemap[.]xml.ts` / `robots[.]txt.ts` (brackets escape the dot). Routes, not `public/` files: both need the absolute origin from `BETTER_AUTH_URL`. Static paths only — no profile is ever enumerated there | +| the tutorial's on-board content | `src/ui/components/DemoOverlay.tsx` | +| progression ledger + stamps + chapter folding | `src/ui/progression.ts` (`Ledger`, `recordWin`, `markHinted`, `plate`, `setPulled`, `atPar`). `useBestScores` is only its React/localStorage adapter — put no rule there | +| discovery-anchor lifecycle + the measurement | `src/server/discovery.ts` (`AnchorStore`, `claimAnchor`, `shownAnchor`, `discoveryTime`); the Postgres adapter is `anchorsFor()` in `src/server/daily.ts` | +| the daily clock's three-state reconciliation | `useDailyClock` in `src/ui/hooks/useDiscoveryClock.ts` | +| auth guard on score WRITES | `requireUserId()` in `src/server/leaderboard.ts` (reads keep `currentUserId`, which answers a signed-out caller instead of throwing) | +| pg pool + drizzle client | `src/db/index.ts` | +| DB tables (auth + daily + campaign) | `src/db/schema.ts` | +| Better Auth server / client | `src/lib/auth.ts` / `src/lib/auth-client.ts` | +| auth HTTP mount | `src/routes/api/auth/$.ts` | +| daily server functions | `src/server/daily.ts` | +| campaign leaderboard functions | `src/server/campaign.ts` | +| trace replay + shape validation | `src/server/replay.ts` (`validateTrace`, `validateTraceShape`) | +| level generation core | `src/solver/hunt.ts` | +| daily cron entry | `src/solver/generate-daily.ts` | +| daily win overlay / auth form | `src/ui/components/DailyOverlay.tsx` / `AuthPanel.tsx` | +| leaderboard row list | `src/ui/components/LeaderboardRows.tsx` | +| leaderboard rail (shared) | `src/ui/components/LeaderboardRail.tsx` (bound by `DailyBoard.tsx` / `LevelBoard.tsx`) | +| ranking rule (upsert guard + ORDER BY + rank) | `src/server/ranking.ts` — the ONLY place "fewer moves, then fewer corrections" lives | +| score upsert + board types + currentUserId | `src/server/leaderboard.ts` (`upsertBestScore`, `LeaderRow`/`MyResult`/`BoardData`) | +| merge equation (`a == b + off`) | `merges()` in `src/engine/state.ts` — sole owner, used by successors AND decalage | +| per-level compiled mechanics | `compiled()` in `src/engine/mechanics/registry.ts` (WeakMap; single-owner guard for `resolveMove`/`mapDirB`/`settle`) | +| mechanic necessity semantics | `removedWith`/`mustBeNeeded` on each mechanic; probe = `isRequired()` in `src/solver/bfs.ts` | +| verb → sound/haptic signature | `inputSignature()` in `src/ui/signatures.ts` (game + tutorial both consume it) | +| alt-gesture rule (split/world) | `altGesture()` in `src/ui/altGesture.ts` | +| solve value + submission policy | `src/ui/submissionPolicy.ts` (`Solve`, `decideSubmission` — pure, owns the UI status) | +| hold-to-confirm (state, then control) | `src/ui/hooks/useHold.ts` + `src/ui/components/HoldButton.tsx` — reset is HELD, never clicked. The state lives in `PlayScreen` so the button and the R key share ONE sweep; `useKeyboard` takes `resetDown`/`resetUp` rather than a `reset` callback. | ### Deployment @@ -234,9 +243,10 @@ Runtime network dependency: the Instrument Serif web font (Google Fonts). explicit relative imports with `.ts`/`.tsx` extensions — keep that convention. - `tsconfig.json` has `allowJs`/`allowArbitraryExtensions` for the Paraglide `.js` output, and excludes `src/solver` + `scripts` (Node/bun context). -- One oxlint warning on `__root.tsx` (`react/only-export-components`) is inherent - to TanStack file-route modules (they export both `Route` and a component); the - same warning fires on every route file (`daily.tsx`, etc.). +- The dozen `react/only-export-components` oxlint warnings are inherent to + TanStack file-route modules (they export both `Route` and a component), so + `bun run lint` is green with them; `__root.tsx` alone accounts for three (the + shell plus the two boundaries). A THIRTEENTH is a regression. - `@better-auth/core` must resolve to the **same** version as `better-auth` (1.6.23). `@better-auth/cli` lags (1.4.21) and drags an old core, so it's NOT a dependency — `auth:generate` runs it via `bunx @better-auth/cli@latest`. diff --git a/README.md b/README.md index ed66cec..f19a4e8 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,9 @@ same way.

The last level, Tectonique, played out by the solver: moves, world drift (decalage), fusion (the pawns overlap to white), scission, then the amber lock — "ready to print".

A React web game running on TanStack Start, fully playable in the browser. The -core game is client-only; the optional **daily mode** (accounts, shared board, -leaderboard) is the one part that talks to a server. +core game is client-only. Everything that talks to a server is optional: the +**daily mode** (accounts, shared board, leaderboard), the per-level +leaderboards, and the public player pages. ## The idea @@ -49,28 +50,31 @@ hashable**: no randomness during play, no real time, no hidden information. The direct consequence is that the game and the solver consume exactly the same API (`successors` / `isWin` / `hashState`), so they cannot drift apart. -| Path | Purpose | -| --------------------------------------- | ------------------------------------------------------------------------------------ | -| `src/engine/types.ts` | The contract: the game state and the mechanic protocol | -| `src/engine/{grid,state,successors}.ts` | Geometry, lifecycle, move enumeration | -| `src/engine/mechanics/` | One mechanic = one file + `registry.ts` | -| `src/engine/levels.ts` | The level bank (pure data, 22 boards) | -| `src/solver/` | Rule-agnostic BFS + the `verify` / `gen` CLIs | -| `src/ui/screens/` | The title / select / play screens | -| `src/ui/components/` | Board, InkLayer, RegMark, Wordmark, Hud, Controls… | -| `src/ui/hooks/` | `useGame`, `useSound`, `useKeyboard`, `useSwipe`, `useBestScores` | -| `src/routes/` | TanStack Start file-based routes (single `/` mounts the game; `api/` for daily mode) | -| `src/db/` | Postgres + Drizzle: schema, client, `drizzle.config.ts`, `migrations/` | -| `src/lib/` | Better Auth setup (email + password) | -| `project.inlang/messages/{fr,en}.json` | i18n catalogue (translation source, inlang format) | +| Path | Purpose | +| --------------------------------------- | ------------------------------------------------------------------------------------------- | +| `src/engine/types.ts` | The contract: the game state and the mechanic protocol | +| `src/engine/{grid,state,successors}.ts` | Geometry, lifecycle, move enumeration | +| `src/engine/mechanics/` | One mechanic = one file + `registry.ts` | +| `src/engine/levels.ts` | The level bank (pure data, 22 boards) | +| `src/solver/` | Rule-agnostic BFS + the `verify` / `gen` CLIs | +| `src/ui/screens/` | The title / select / play / profile screens, plus the 404-and-error fallback | +| `src/ui/components/` | Board, InkLayer, RegMark, Wordmark, Hud, Controls… | +| `src/ui/hooks/` | `useGame`, `useSound`, `useKeyboard`, `useSwipe`, `useBestScores` | +| `src/routes/` | TanStack Start file-based routes — one per screen, plus `api/`, `sitemap.xml`, `robots.txt` | +| `src/server/` | Server-only: daily puzzle, leaderboards, trace replay, OG cards, crawler documents | +| `src/db/` | Postgres + Drizzle: schema, client, `drizzle.config.ts`, `migrations/` | +| `src/lib/` | Better Auth setup (email + password), streaks, distinctions | +| `project.inlang/messages/{fr,en}.json` | i18n catalogue (translation source, inlang format) | Data flow: input (keyboard / swipe / buttons) → `useGame.play` → `engine.applyInput` → new state → render. This repo is a TanStack Start (React 19 + Vite + Nitro) shell around the -original game. SSR is disabled app-wide — the game needs `AudioContext`, -`localStorage`, and keyboard/swipe — so the core loop is client-only. See -`AGENTS.md` for the full port history and toolchain notes. +original game. Components render client-side by default — the game needs +`AudioContext`, `localStorage`, and keyboard/swipe — so the core loop is +client-only; loaders and `` still run on the server, and the public +profile page renders there in full so crawlers see it. See `AGENTS.md` for the +full port history and toolchain notes. ## Getting started @@ -95,7 +99,7 @@ bun run db:migrate # apply migrations to the database ```sh bun run dev # dev server bun run build # paraglide + tsc typecheck + vite build -bun run test # Vitest suite (engine only) +bun run test # Vitest: pure (engine, solver, rules) + dom (components) bun run lint # oxlint bun run verify # certify every board in the bank is solvable (via the solver) bun run gen # hunt for new boards @@ -126,8 +130,9 @@ bun run gen -- --mods fusion,scission --size 5 --min 18 --ms 30000 Every displayed string goes through a key in `project.inlang/messages/{fr,en}.json` and is read as `m.key()` (Paraglide). `src/paraglide/` is generated (git-ignored) -and regenerated on build, or by hand with `bun run paraglide`. The default -language follows the browser, with a French fallback. +and regenerated on build, or by hand with `bun run paraglide`. The locale is +resolved from a cookie first, then the browser's preference, with a French +fallback. ## Adding content diff --git a/public/robots.txt b/public/robots.txt deleted file mode 100644 index e9e57dc..0000000 --- a/public/robots.txt +++ /dev/null @@ -1,3 +0,0 @@ -# https://www.robotstxt.org/robotstxt.html -User-agent: * -Disallow: diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index b521e43..5065d6b 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -9,6 +9,8 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' +import { Route as SitemapDotxmlRouteImport } from './routes/sitemap[.]xml' +import { Route as RobotsDottxtRouteImport } from './routes/robots[.]txt' import { Route as LevelsRouteImport } from './routes/levels' import { Route as AlignRouteImport } from './routes/align' import { Route as IndexRouteImport } from './routes/index' @@ -20,6 +22,16 @@ import { Route as ApiReplaySplatRouteImport } from './routes/api/replay/$' import { Route as ApiOgUsernameRouteImport } from './routes/api/og/$username' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' +const SitemapDotxmlRoute = SitemapDotxmlRouteImport.update({ + id: '/sitemap.xml', + path: '/sitemap.xml', + getParentRoute: () => rootRouteImport, +} as any) +const RobotsDottxtRoute = RobotsDottxtRouteImport.update({ + id: '/robots.txt', + path: '/robots.txt', + getParentRoute: () => rootRouteImport, +} as any) const LevelsRoute = LevelsRouteImport.update({ id: '/levels', path: '/levels', @@ -75,6 +87,8 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/align': typeof AlignRoute '/levels': typeof LevelsRoute + '/robots.txt': typeof RobotsDottxtRoute + '/sitemap.xml': typeof SitemapDotxmlRoute '/daily/$tier': typeof DailyTierRoute '/level/$plate': typeof LevelPlateRoute '/profile/$username': typeof ProfileUsernameRoute @@ -87,6 +101,8 @@ export interface FileRoutesByTo { '/': typeof IndexRoute '/align': typeof AlignRoute '/levels': typeof LevelsRoute + '/robots.txt': typeof RobotsDottxtRoute + '/sitemap.xml': typeof SitemapDotxmlRoute '/daily/$tier': typeof DailyTierRoute '/level/$plate': typeof LevelPlateRoute '/profile/$username': typeof ProfileUsernameRoute @@ -100,6 +116,8 @@ export interface FileRoutesById { '/': typeof IndexRoute '/align': typeof AlignRoute '/levels': typeof LevelsRoute + '/robots.txt': typeof RobotsDottxtRoute + '/sitemap.xml': typeof SitemapDotxmlRoute '/daily/$tier': typeof DailyTierRoute '/level/$plate': typeof LevelPlateRoute '/profile/$username': typeof ProfileUsernameRoute @@ -114,6 +132,8 @@ export interface FileRouteTypes { | '/' | '/align' | '/levels' + | '/robots.txt' + | '/sitemap.xml' | '/daily/$tier' | '/level/$plate' | '/profile/$username' @@ -126,6 +146,8 @@ export interface FileRouteTypes { | '/' | '/align' | '/levels' + | '/robots.txt' + | '/sitemap.xml' | '/daily/$tier' | '/level/$plate' | '/profile/$username' @@ -138,6 +160,8 @@ export interface FileRouteTypes { | '/' | '/align' | '/levels' + | '/robots.txt' + | '/sitemap.xml' | '/daily/$tier' | '/level/$plate' | '/profile/$username' @@ -151,6 +175,8 @@ export interface RootRouteChildren { IndexRoute: typeof IndexRoute AlignRoute: typeof AlignRoute LevelsRoute: typeof LevelsRoute + RobotsDottxtRoute: typeof RobotsDottxtRoute + SitemapDotxmlRoute: typeof SitemapDotxmlRoute DailyTierRoute: typeof DailyTierRoute LevelPlateRoute: typeof LevelPlateRoute ProfileUsernameRoute: typeof ProfileUsernameRoute @@ -162,6 +188,20 @@ export interface RootRouteChildren { declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/sitemap.xml': { + id: '/sitemap.xml' + path: '/sitemap.xml' + fullPath: '/sitemap.xml' + preLoaderRoute: typeof SitemapDotxmlRouteImport + parentRoute: typeof rootRouteImport + } + '/robots.txt': { + id: '/robots.txt' + path: '/robots.txt' + fullPath: '/robots.txt' + preLoaderRoute: typeof RobotsDottxtRouteImport + parentRoute: typeof rootRouteImport + } '/levels': { id: '/levels' path: '/levels' @@ -239,6 +279,8 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, AlignRoute: AlignRoute, LevelsRoute: LevelsRoute, + RobotsDottxtRoute: RobotsDottxtRoute, + SitemapDotxmlRoute: SitemapDotxmlRoute, DailyTierRoute: DailyTierRoute, LevelPlateRoute: LevelPlateRoute, ProfileUsernameRoute: ProfileUsernameRoute, diff --git a/src/routes/robots[.]txt.ts b/src/routes/robots[.]txt.ts new file mode 100644 index 0000000..a787228 --- /dev/null +++ b/src/routes/robots[.]txt.ts @@ -0,0 +1,20 @@ +// robots.txt, served as a route rather than a file in public/ because its +// `Sitemap:` directive needs an absolute URL, and only the server knows the +// origin (BETTER_AUTH_URL). The brackets escape the dot, which file-based +// routing would otherwise read as a path separator (/robots/txt). +// +// The document itself is built in server/seo.ts, pure and tested there. + +import { createFileRoute } from "@tanstack/react-router"; +import { robotsTxt } from "../server/seo.ts"; + +export const Route = createFileRoute("/robots.txt")({ + server: { + handlers: { + GET: () => + new Response(robotsTxt(process.env.BETTER_AUTH_URL), { + headers: { "content-type": "text/plain; charset=utf-8" }, + }), + }, + }, +}); diff --git a/src/routes/sitemap[.]xml.ts b/src/routes/sitemap[.]xml.ts new file mode 100644 index 0000000..683d977 --- /dev/null +++ b/src/routes/sitemap[.]xml.ts @@ -0,0 +1,23 @@ +// sitemap.xml, a route for the same reason as robots.txt: every must be +// absolute, and the origin lives in BETTER_AUTH_URL. The brackets escape the +// dot for file-based routing. +// +// The document itself is built in server/seo.ts, pure and tested there; with no +// origin configured it declines to exist rather than emit relative URLs. + +import { createFileRoute } from "@tanstack/react-router"; +import { sitemapXml } from "../server/seo.ts"; + +export const Route = createFileRoute("/sitemap.xml")({ + server: { + handlers: { + GET: () => { + const body = sitemapXml(process.env.BETTER_AUTH_URL); + if (body === null) return new Response(null, { status: 404 }); + return new Response(body, { + headers: { "content-type": "application/xml; charset=utf-8" }, + }); + }, + }, + }, +}); diff --git a/src/server/seo.test.ts b/src/server/seo.test.ts new file mode 100644 index 0000000..2f436c1 --- /dev/null +++ b/src/server/seo.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { LEVELS } from "../engine/levels.ts"; +import { robotsTxt, sitemapXml } from "./seo.ts"; + +const ORIGIN = "https://superposition.example"; + +function locs(xml: string): string[] { + return [...xml.matchAll(/([^<]+)<\/loc>/g)].map((m) => m[1]!); +} + +describe("sitemapXml", () => { + it("lists every plate in the bank, plus the two static screens", () => { + const urls = locs(sitemapXml(ORIGIN)!); + expect(urls).toHaveLength(LEVELS.length + 2); + expect(urls).toContain(`${ORIGIN}/`); + expect(urls).toContain(`${ORIGIN}/levels`); + expect(urls).toContain(`${ORIGIN}/level/1`); + expect(urls).toContain(`${ORIGIN}/level/${LEVELS.length}`); + }); + + // a throwaway prototype, never wired into the screen flow: listing it would + // invite a crawler to index a test page as if it were the game + it("does not list the align prototype", () => { + expect(sitemapXml(ORIGIN)!).not.toContain("/align"); + }); + + it("emits only absolute URLs on the configured origin", () => { + for (const url of locs(sitemapXml(ORIGIN)!)) { + expect(url.startsWith(`${ORIGIN}/`)).toBe(true); + } + }); + + // profiles are user data: publishable one by one, not enumerable in a file + // served to everyone + it("names no profile and no api path", () => { + const xml = sitemapXml(ORIGIN)!; + expect(xml).not.toContain("/profile"); + expect(xml).not.toContain("/api"); + expect(xml).not.toContain("/daily"); + }); + + it("does not double the slash of a trailing-slash origin", () => { + expect(locs(sitemapXml(`${ORIGIN}/`)!)).toEqual(locs(sitemapXml(ORIGIN)!)); + }); + + // relative s are invalid; no sitemap beats a broken one + it("declines to exist without an origin", () => { + expect(sitemapXml(undefined)).toBeNull(); + expect(sitemapXml("")).toBeNull(); + }); +}); + +describe("robotsTxt", () => { + it("closes /api/ and points at the absolute sitemap", () => { + const txt = robotsTxt(ORIGIN); + expect(txt).toContain("User-agent: *"); + expect(txt).toContain("Disallow: /api/"); + expect(txt).toContain(`Sitemap: ${ORIGIN}/sitemap.xml`); + }); + + it("does not double the slash of a trailing-slash origin", () => { + expect(robotsTxt(`${ORIGIN}/`)).toBe(robotsTxt(ORIGIN)); + }); + + // the Disallow rules stand on their own, so only the directive that needs an + // absolute URL drops out + it("keeps its rules but drops the directive without an origin", () => { + const txt = robotsTxt(undefined); + expect(txt).toContain("Disallow: /api/"); + expect(txt).not.toContain("Sitemap:"); + }); +}); diff --git a/src/server/seo.ts b/src/server/seo.ts new file mode 100644 index 0000000..f38a793 --- /dev/null +++ b/src/server/seo.ts @@ -0,0 +1,65 @@ +// The two crawler documents, as pure text builders so they can be tested +// without a server (their routes are thin: read the env, pick a status, set a +// Content-Type). +// +// Both hinge on an ORIGIN the repo cannot know: a sitemap's must be +// absolute by protocol, and robots' `Sitemap:` directive too — which is why +// these are routes reading BETTER_AUTH_URL and not files in public/. The origin +// is normalised here, the same way __root.tsx and profile.$username.tsx +// normalise it for og:image: a hand-written `https://host/` would otherwise +// yield `https://host//levels`, a protocol-relative URL pointing at the host +// `levels`. +// +// The sitemap lists STATIC routes only. Profiles are user data: publishable one +// by one from a leaderboard row, but enumerating every account in a file served +// to everyone is a different act, and not one a sitemap should perform. + +import { LEVELS } from "../engine/levels.ts"; + +/** Trailing slashes stripped; an unset or blank origin answers null so each + * caller decides what a document with no absolute URL should be. */ +function normalise(raw: string | undefined): string | null { + const origin = (raw ?? "").replace(/\/+$/, ""); + return origin === "" ? null : origin; +} + +/** Every crawlable path, derived from the bank so adding a plate to LEVELS + * extends the sitemap on its own. + * + * `/align` is deliberately absent: it is a throwaway prototype route, not + * wired into the app's screen flow, and a sitemap is a claim that a page is + * worth landing on. */ +function paths(): string[] { + return ["/", "/levels", ...LEVELS.map((_, i) => `/level/${i + 1}`)]; +} + +/** The sitemap, or null when no origin is configured (local dev): a sitemap of + * relative s is invalid, and an invalid one is worse than none — the + * route answers 404 rather than teaching a crawler a broken URL. */ +export function sitemapXml(rawOrigin: string | undefined): string | null { + const origin = normalise(rawOrigin); + if (origin === null) return null; + const urls = paths() + .map((p) => ` ${origin}${p}`) + .join("\n"); + return ` + +${urls} + +`; +} + +/** robots.txt. Unlike the sitemap it stays useful without an origin — the + * Disallow rules are relative by design — so a missing origin only drops the + * `Sitemap:` line instead of failing the document. + * + * /api/ is closed: auth endpoints, replay GIFs and OG cards are machinery, not + * pages. Nothing else is hidden here — robots.txt is public, so listing a path + * to keep it private would advertise it instead. */ +export function robotsTxt(rawOrigin: string | undefined): string { + const origin = normalise(rawOrigin); + const sitemap = origin === null ? "" : `\nSitemap: ${origin}/sitemap.xml\n`; + return `User-agent: * +Disallow: /api/ +${sitemap}`; +} From e841b1d08f84d7c7667204e38cf283bbac826b37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Caruelle?= Date: Mon, 14 Sep 2026 00:18:33 +0200 Subject: [PATCH 09/11] =?UTF-8?q?ci:=20garder=20le=20d=C3=A9p=C3=B4t=20der?= =?UTF-8?q?ri=C3=A8re=20lint,=20test,=20verify=20et=20build?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rien ne tournait automatiquement : le seul garde-fou était le `tsc --noEmit` niché dans le build Railway, donc une suite rouge atteignait la production et n'était visible qu'après le déploiement. `--max-warnings=12` rend exécutable l'invariant que AGENTS.md n'énonçait qu'en prose : les douze avertissements react(only-export-components) sont inhérents aux modules de route TanStack, un treizième est une régression. `packageManager` épingle bun pour Railpack et pour setup-bun d'un même littéral ; bun.lock avait dérivé de package.json, ce que --frozen-lockfile refusait — régénéré ici. --- .github/workflows/ci.yml | 46 ++++++++++++++++++++++++ bun.lock | 76 ---------------------------------------- package.json | 3 +- 3 files changed, 48 insertions(+), 77 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a9df477 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,46 @@ +# The gate in front of Railway. Railway's build runs `tsc --noEmit`, but nothing +# else: a red test suite or a broken level bank reaches production and only +# surfaces after the deploy. These five steps run first, on every push to main +# and every pull request. +# +# No Postgres service here on purpose: no test file imports `src/db/index.ts` or +# reads DATABASE_URL, so the suite is stateless and stays fast. The day a test +# needs a database, add the service then — not in advance. +name: CI + +on: + push: + branches: [main] + pull_request: + +# A new push to the same ref makes the run in flight pointless. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + # Same literal as `packageManager` in package.json — CI and Railway build + # on one version of bun, or the pin means nothing. + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.4.0 + + - run: bun install --frozen-lockfile + + # --max-warnings=12 is the invariant from AGENTS.md made executable: the + # dozen react(only-export-components) warnings are inherent to TanStack + # route modules, a thirteenth is a regression. + - run: bun run lint + + # `test` and `build` each compile paraglide first; src/paraglide is + # gitignored, so no separate generation step is needed. + - run: bun run test + + - run: bun run verify + + - run: bun run build diff --git a/bun.lock b/bun.lock index bd97357..b7040b4 100644 --- a/bun.lock +++ b/bun.lock @@ -262,8 +262,6 @@ "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.73.0", "", { "os": "win32", "cpu": "x64" }, "sha512-WaNRvh4f6zY9CvUQk2YoA1O90ieWrIklI84+HXFr9Isjz9CSESrdqo/RtIYt4Dll/cAchqGDMehfaZd0vqEFZw=="], - "@prisma/client": ["@prisma/client@5.22.0", "", { "peerDependencies": { "prisma": "*" }, "optionalPeers": ["prisma"] }, "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA=="], - "@resvg/resvg-js": ["@resvg/resvg-js@2.6.2", "", { "optionalDependencies": { "@resvg/resvg-js-android-arm-eabi": "2.6.2", "@resvg/resvg-js-android-arm64": "2.6.2", "@resvg/resvg-js-darwin-arm64": "2.6.2", "@resvg/resvg-js-darwin-x64": "2.6.2", "@resvg/resvg-js-linux-arm-gnueabihf": "2.6.2", "@resvg/resvg-js-linux-arm64-gnu": "2.6.2", "@resvg/resvg-js-linux-arm64-musl": "2.6.2", "@resvg/resvg-js-linux-x64-gnu": "2.6.2", "@resvg/resvg-js-linux-x64-musl": "2.6.2", "@resvg/resvg-js-win32-arm64-msvc": "2.6.2", "@resvg/resvg-js-win32-ia32-msvc": "2.6.2", "@resvg/resvg-js-win32-x64-msvc": "2.6.2" } }, "sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q=="], "@resvg/resvg-js-android-arm-eabi": ["@resvg/resvg-js-android-arm-eabi@2.6.2", "", { "os": "android", "cpu": "arm" }, "sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA=="], @@ -474,18 +472,10 @@ "better-call": ["better-call@1.3.7", "", { "dependencies": { "@better-auth/utils": "^0.4.0", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-Al51/hjp2SSp6CRTa3F2ptcx4yQVS1xWKoY6jcVXqNYOap6mHFP2jUBn5EwIL4iIed1/Sq4hlQ+Umm6EflZG+w=="], - "better-sqlite3": ["better-sqlite3@12.11.1", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA=="], - "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], - "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="], - - "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], - "browserslist": ["browserslist@4.28.6", "", { "dependencies": { "baseline-browser-mapping": "^2.10.42", "caniuse-lite": "^1.0.30001803", "electron-to-chromium": "^1.5.389", "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw=="], - "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], - "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], "camelize": ["camelize@1.0.1", "", {}, "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ=="], @@ -496,8 +486,6 @@ "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], - "chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], - "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], @@ -538,12 +526,8 @@ "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], - "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], - "dedent": ["dedent@1.5.1", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg=="], - "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], - "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], @@ -564,8 +548,6 @@ "emoji-regex-xs": ["emoji-regex-xs@2.0.1", "", {}, "sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g=="], - "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], - "enhanced-resolve": ["enhanced-resolve@5.21.6", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ=="], "entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="], @@ -584,8 +566,6 @@ "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], - "expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="], - "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], "exsolve": ["exsolve@1.1.0", "", {}, "sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw=="], @@ -596,12 +576,8 @@ "fflate": ["fflate@0.7.4", "", {}, "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw=="], - "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="], - "framer-motion": ["framer-motion@12.42.2", "", { "dependencies": { "motion-dom": "^12.42.2", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw=="], - "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], - "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], @@ -610,8 +586,6 @@ "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], - "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], - "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "graphql": ["graphql@16.14.2", "", {}, "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA=="], @@ -630,12 +604,6 @@ "human-id": ["human-id@4.2.0", "", { "bin": { "human-id": "dist/cli.js" } }, "sha512-K3GbkIWqyvvlpfhBPlbEvD97TtqBpAYA4kt+cn2lD2x2HuohzZCibcA2nOlnJT6exqvJLggoB5nv2dNf192nEA=="], - "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], - - "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - - "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], - "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], @@ -694,12 +662,6 @@ "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], - "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], - - "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], - - "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], - "motion": ["motion@12.42.2", "", { "dependencies": { "framer-motion": "^12.42.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-Atvv11yUKIid41cVrRBDVX5m8tF8kNpExRSlbpt6APClhDjtwQssgFHhQzejxw7/7YYbjHSPKBVbHo05BuJT5Q=="], "motion-dom": ["motion-dom@12.42.2", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA=="], @@ -712,14 +674,10 @@ "nanostores": ["nanostores@1.4.0", "", {}, "sha512-i0tloweeudshAEuddpDxcg9Ik6pkPfVsHIgKyf143JrgG7/MOh0+q7BypdLXZPoOP7fOYt1eTcwGkyiVmhJFkA=="], - "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="], - "nf3": ["nf3@0.3.22", "", {}, "sha512-NOcqlHu22X1rUakd0SrqllP8kb3LWFmSAi1svTxaKxz+yB604xlaOmUfI3oO+RkODvaocKDDy8bgthnZL8hrkw=="], "nitro": ["nitro@3.0.260610-beta", "", { "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.6", "db0": "^0.3.4", "env-runner": "^0.1.12", "h3": "2.0.1-rc.22", "hookable": "^6.1.1", "nf3": "^0.3.17", "ocache": "^0.1.5", "ofetch": "2.0.0-alpha.3", "ohash": "^2.0.11", "rolldown": "^1.1.0", "srvx": "^0.11.16", "unenv": "2.0.0-rc.24", "unstorage": "2.0.0-alpha.7" }, "peerDependencies": { "@vercel/queue": "^0.3.0", "dotenv": "*", "giget": "*", "jiti": "^2.7.0", "rollup": "^4.61.1", "vite": "^7 || ^8", "xml2js": "^0.6.2", "zephyr-agent": "^0.2.0" }, "optionalPeers": ["@vercel/queue", "dotenv", "giget", "jiti", "rollup", "vite", "xml2js", "zephyr-agent"], "bin": { "nitro": "dist/cli/index.mjs" } }, "sha512-KPb4L5yaF/Rx/xoGMpgHRJvZhbhGiqbRKOwwPLCH9jKTKTsEUHLjnJas85AeCzaswqa8Wi52eQBtRsODC4PS0Q=="], - "node-abi": ["node-abi@3.94.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g=="], - "node-releases": ["node-releases@2.0.51", "", {}, "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ=="], "obug": ["obug@2.1.3", "", {}, "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg=="], @@ -730,8 +688,6 @@ "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], - "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], - "oxlint": ["oxlint@1.73.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.73.0", "@oxlint/binding-android-arm64": "1.73.0", "@oxlint/binding-darwin-arm64": "1.73.0", "@oxlint/binding-darwin-x64": "1.73.0", "@oxlint/binding-freebsd-x64": "1.73.0", "@oxlint/binding-linux-arm-gnueabihf": "1.73.0", "@oxlint/binding-linux-arm-musleabihf": "1.73.0", "@oxlint/binding-linux-arm64-gnu": "1.73.0", "@oxlint/binding-linux-arm64-musl": "1.73.0", "@oxlint/binding-linux-ppc64-gnu": "1.73.0", "@oxlint/binding-linux-riscv64-gnu": "1.73.0", "@oxlint/binding-linux-riscv64-musl": "1.73.0", "@oxlint/binding-linux-s390x-gnu": "1.73.0", "@oxlint/binding-linux-x64-gnu": "1.73.0", "@oxlint/binding-linux-x64-musl": "1.73.0", "@oxlint/binding-openharmony-arm64": "1.73.0", "@oxlint/binding-win32-arm64-msvc": "1.73.0", "@oxlint/binding-win32-ia32-msvc": "1.73.0", "@oxlint/binding-win32-x64-msvc": "1.73.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.24.0", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-u91G9TJzU6yqKWNZUYprQB07W7YvntZXaRxQ6CkoytepYhLWUXWsr1M8zUJ34VatNPuUAr3Z8GH+O2A331CluQ=="], "pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="], @@ -774,28 +730,20 @@ "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], - "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], - "prettier": ["prettier@3.9.5", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg=="], "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], - "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], - "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "railway": ["railway@3.5.7", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.2.0", "graphql": "^16.10.0", "tsx": "^4.20.0" }, "bin": { "railway-iac-ts": "dist/iac/bin.js" } }, "sha512-2DdaAw2eSQuCtSn+/VuPNOdnbynxTVmRIQlefhc0maULZTr16Uy/5/FjumnI1lONZgflKG/WMGm9QmUdnApZFw=="], - "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], - "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], "react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], - "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], - "readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], @@ -808,8 +756,6 @@ "rou3": ["rou3@0.8.1", "", {}, "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA=="], - "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], - "satori": ["satori@0.28.0", "", { "dependencies": { "@shuding/opentype.js": "1.4.0-beta.0", "css-background-parser": "^0.1.0", "css-box-shadow": "1.0.0-3", "css-gradient-parser": "^0.0.17", "css-to-react-native": "^3.0.0", "emoji-regex-xs": "^2.0.1", "escape-html": "^1.0.3", "linebreak": "^1.1.0", "parse-css-color": "^0.2.1", "postcss-value-parser": "^4.2.0", "yoga-layout": "^3.2.1" } }, "sha512-FhOx2irXIrxbNpPyE0x/+k3p8G+FVWfaTPAKuwMaB4kk02PehHFumYyJ4NtiLNC7moNAspeDt06S+DqUjJ9Fsg=="], "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], @@ -826,10 +772,6 @@ "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], - "simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="], - - "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], - "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], @@ -850,22 +792,14 @@ "string.prototype.codepointat": ["string.prototype.codepointat@0.2.1", "", {}, "sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg=="], - "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], - "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], - "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], "tailwindcss": ["tailwindcss@4.3.2", "", {}, "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA=="], "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], - "tar-fs": ["tar-fs@2.1.5", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw=="], - - "tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="], - "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], @@ -888,8 +822,6 @@ "tsx": ["tsx@4.23.1", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ=="], - "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], - "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], "ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="], @@ -912,8 +844,6 @@ "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], - "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], - "uuid": ["uuid@14.0.1", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew=="], "vite": ["vite@8.1.4", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", "postcss": "^8.5.16", "rolldown": "~1.1.4", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ=="], @@ -936,8 +866,6 @@ "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], - "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], "xmlbuilder2": ["xmlbuilder2@4.0.3", "", { "dependencies": { "@oozcitak/dom": "^2.0.2", "@oozcitak/infra": "^2.0.2", "@oozcitak/util": "^10.0.0", "js-yaml": "^4.1.1" } }, "sha512-bx8Q1STctnNaaDymWnkfQLKofs0mGNN7rLLapJlGuV3VlvegD7Ls4ggMjE3aUSWItCCzU0PEv45lI87iSigiCA=="], @@ -980,14 +908,10 @@ "better-call/rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], - "buffer/base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], - "data-urls/whatwg-url": ["whatwg-url@16.0.1", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw=="], "nitro/consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], - "node-abi/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "tsx/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], diff --git a/package.json b/package.json index c67a48b..22a2d46 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "superposition", "private": true, "type": "module", + "packageManager": "bun@1.4.0", "imports": { "#/*": "./src/*" }, @@ -13,7 +14,7 @@ "gen:icons": "bun scripts/gen-icons.ts", "start": "bun .output/server/index.mjs", "preview": "vite preview", - "lint": "oxlint", + "lint": "oxlint --max-warnings=12", "test": "bun run paraglide && vitest run", "verify": "bun src/solver/verify.ts", "gen": "bun src/solver/generate.ts", From 1a48ad6b563ec58fc2f0205b996974633c763258 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Caruelle?= Date: Mon, 14 Sep 2026 00:18:43 +0200 Subject: [PATCH 10/11] feat(deploy): le healthcheck interroge la base MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `healthcheckPath: "/"` servait la coquille SPA et répondait 200 avec Postgres à terre : une instance web incapable de joindre sa base était déclarée saine. `probe()` court contre un minuteur de 2 s parce que le Pool n'a aucun délai configuré — sans lui, une base injoignable fait traîner la requête au lieu de la faire échouer. La course vit dans probe() et non sur le Pool partagé : imposer un délai à toutes les requêtes du serveur est une autre décision. Le client est un paramètre plutôt qu'un import de db/index.ts, qui lève à l'import sans DATABASE_URL — la CI n'a pas de base, et une sonde qu'on ne peut pas pointer vers un client en panne ne peut pas être testée en panne. Le corps reste opaque en échec : la route est publique et un message d'erreur du driver nomme l'hôte et l'utilisateur. La raison part dans les logs. --- .railway/railway.ts | 4 ++- src/routeTree.gen.ts | 21 ++++++++++++++ src/routes/api/health.ts | 38 +++++++++++++++++++++++++ src/server/health.test.ts | 43 +++++++++++++++++++++++++++++ src/server/health.ts | 58 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 src/routes/api/health.ts create mode 100644 src/server/health.test.ts create mode 100644 src/server/health.ts diff --git a/.railway/railway.ts b/.railway/railway.ts index 1fba785..3265fb5 100644 --- a/.railway/railway.ts +++ b/.railway/railway.ts @@ -25,7 +25,9 @@ export default defineRailway(() => { build: "bun run build", start: "bun run start", preDeployCommand: ["bun run db:migrate"], - healthcheckPath: "/", + // `/` is the SPA shell: it answers 200 with the database on the floor. + // /api/health runs `select 1` first. + healthcheckPath: "/api/health", env: { DATABASE_URL: Postgres.env.DATABASE_URL, // secrets set out-of-band (dashboard / CLI) — keep their remote values diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 5065d6b..c6df1f0 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -18,6 +18,7 @@ import { Route as ProfileMeRouteImport } from './routes/profile.me' import { Route as ProfileUsernameRouteImport } from './routes/profile.$username' import { Route as LevelPlateRouteImport } from './routes/level.$plate' import { Route as DailyTierRouteImport } from './routes/daily.$tier' +import { Route as ApiHealthRouteImport } from './routes/api/health' import { Route as ApiReplaySplatRouteImport } from './routes/api/replay/$' import { Route as ApiOgUsernameRouteImport } from './routes/api/og/$username' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' @@ -67,6 +68,11 @@ const DailyTierRoute = DailyTierRouteImport.update({ path: '/daily/$tier', getParentRoute: () => rootRouteImport, } as any) +const ApiHealthRoute = ApiHealthRouteImport.update({ + id: '/api/health', + path: '/api/health', + getParentRoute: () => rootRouteImport, +} as any) const ApiReplaySplatRoute = ApiReplaySplatRouteImport.update({ id: '/api/replay/$', path: '/api/replay/$', @@ -89,6 +95,7 @@ export interface FileRoutesByFullPath { '/levels': typeof LevelsRoute '/robots.txt': typeof RobotsDottxtRoute '/sitemap.xml': typeof SitemapDotxmlRoute + '/api/health': typeof ApiHealthRoute '/daily/$tier': typeof DailyTierRoute '/level/$plate': typeof LevelPlateRoute '/profile/$username': typeof ProfileUsernameRoute @@ -103,6 +110,7 @@ export interface FileRoutesByTo { '/levels': typeof LevelsRoute '/robots.txt': typeof RobotsDottxtRoute '/sitemap.xml': typeof SitemapDotxmlRoute + '/api/health': typeof ApiHealthRoute '/daily/$tier': typeof DailyTierRoute '/level/$plate': typeof LevelPlateRoute '/profile/$username': typeof ProfileUsernameRoute @@ -118,6 +126,7 @@ export interface FileRoutesById { '/levels': typeof LevelsRoute '/robots.txt': typeof RobotsDottxtRoute '/sitemap.xml': typeof SitemapDotxmlRoute + '/api/health': typeof ApiHealthRoute '/daily/$tier': typeof DailyTierRoute '/level/$plate': typeof LevelPlateRoute '/profile/$username': typeof ProfileUsernameRoute @@ -134,6 +143,7 @@ export interface FileRouteTypes { | '/levels' | '/robots.txt' | '/sitemap.xml' + | '/api/health' | '/daily/$tier' | '/level/$plate' | '/profile/$username' @@ -148,6 +158,7 @@ export interface FileRouteTypes { | '/levels' | '/robots.txt' | '/sitemap.xml' + | '/api/health' | '/daily/$tier' | '/level/$plate' | '/profile/$username' @@ -162,6 +173,7 @@ export interface FileRouteTypes { | '/levels' | '/robots.txt' | '/sitemap.xml' + | '/api/health' | '/daily/$tier' | '/level/$plate' | '/profile/$username' @@ -177,6 +189,7 @@ export interface RootRouteChildren { LevelsRoute: typeof LevelsRoute RobotsDottxtRoute: typeof RobotsDottxtRoute SitemapDotxmlRoute: typeof SitemapDotxmlRoute + ApiHealthRoute: typeof ApiHealthRoute DailyTierRoute: typeof DailyTierRoute LevelPlateRoute: typeof LevelPlateRoute ProfileUsernameRoute: typeof ProfileUsernameRoute @@ -251,6 +264,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DailyTierRouteImport parentRoute: typeof rootRouteImport } + '/api/health': { + id: '/api/health' + path: '/api/health' + fullPath: '/api/health' + preLoaderRoute: typeof ApiHealthRouteImport + parentRoute: typeof rootRouteImport + } '/api/replay/$': { id: '/api/replay/$' path: '/api/replay/$' @@ -281,6 +301,7 @@ const rootRouteChildren: RootRouteChildren = { LevelsRoute: LevelsRoute, RobotsDottxtRoute: RobotsDottxtRoute, SitemapDotxmlRoute: SitemapDotxmlRoute, + ApiHealthRoute: ApiHealthRoute, DailyTierRoute: DailyTierRoute, LevelPlateRoute: LevelPlateRoute, ProfileUsernameRoute: ProfileUsernameRoute, diff --git a/src/routes/api/health.ts b/src/routes/api/health.ts new file mode 100644 index 0000000..9e19b44 --- /dev/null +++ b/src/routes/api/health.ts @@ -0,0 +1,38 @@ +// Deployment healthcheck (`healthcheckPath` in .railway/railway.ts). Answers +// 200 only if this instance can reach Postgres; 503 otherwise. The old target, +// `/`, served the SPA shell and answered 200 to an instance with no database. +// +// The body stays opaque on failure: this route is public, and a driver error +// message carries the host and user of the connection. The reason goes to the +// logs instead. + +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/api/health")({ + server: { + handlers: { + GET: async () => { + // import.meta.env.SSR is statically false in the client build, so this + // branch — and the `pg` pool it imports — is dead-code-eliminated from + // the client bundle. Same shape as /api/replay/$. + if (!import.meta.env.SSR) return new Response(null, { status: 404 }); + + const [{ probe }, { pool }] = await Promise.all([ + import("../../server/health.ts"), + import("../../db/index.ts"), + ]); + const health = await probe(pool); + if (!health.ok) + console.error("[health] database unreachable:", health.error); + + return Response.json( + { status: health.ok ? "ok" : "degraded" }, + { + status: health.ok ? 200 : 503, + headers: { "cache-control": "no-store" }, + }, + ); + }, + }, + }, +}); diff --git a/src/server/health.test.ts b/src/server/health.test.ts new file mode 100644 index 0000000..71b77c7 --- /dev/null +++ b/src/server/health.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { probe, type Queryable } from "./health.ts"; + +/** A client whose single query settles however the test says. */ +function client(behaviour: Promise): Queryable { + return { query: () => behaviour }; +} + +describe("probe", () => { + it("reports ok, with a latency, when the query answers", async () => { + const health = await probe( + client(Promise.resolve({ rows: [{ "?column?": 1 }] })), + ); + expect(health.ok).toBe(true); + if (health.ok) expect(health.latencyMs).toBeGreaterThanOrEqual(0); + }); + + it("reports the failure instead of throwing when the query rejects", async () => { + const health = await probe( + client(Promise.reject(new Error("ECONNREFUSED"))), + ); + expect(health).toEqual({ ok: false, error: "ECONNREFUSED" }); + }); + + // the case the healthcheck exists for: `pg` has no timeout, so an unreachable + // database hangs rather than errors, and a probe without a deadline hangs with it + it("gives up on a hanging query instead of waiting for it", async () => { + const health = await probe(client(new Promise(() => {})), 20); + expect(health.ok).toBe(false); + if (!health.ok) expect(health.error).toContain("timed out"); + }); + + // a query that rejects after losing the race must not escape as an unhandled + // rejection — that would crash the server the healthcheck is meant to watch + it("swallows a rejection that arrives after the deadline", async () => { + const late = new Promise((_, reject) => + setTimeout(() => reject(new Error("too late")), 40), + ); + const health = await probe(client(late), 10); + expect(health.ok).toBe(false); + await new Promise((resolve) => setTimeout(resolve, 60)); + }); +}); diff --git a/src/server/health.ts b/src/server/health.ts new file mode 100644 index 0000000..ccf5cb9 --- /dev/null +++ b/src/server/health.ts @@ -0,0 +1,58 @@ +// Liveness probe for the deployment healthcheck. `/` serves the SPA shell and +// answers 200 with Postgres on the floor, so it says nothing about whether this +// instance can do any work; `select 1` does. +// +// The client is a parameter rather than an import of `db/index.ts` for two +// reasons: that module throws at import time when DATABASE_URL is unset, which +// would make this file untestable in CI (no database there, by design), and a +// probe that cannot be pointed at a failing client cannot be tested failing. +// The route passes the real pool. +// +// The race against a timer lives here rather than on the shared Pool. `pg` has +// no timeout configured, so an unreachable database leaves the connection +// hanging and the healthcheck would stall instead of failing. Putting a +// deadline on every query the server makes is a different change, with +// different blast radius — decide it separately. + +/** The one thing a probe needs from a `pg` Pool. Structural, so a test can pass + * a hand-written stub and never touch a database. */ +export type Queryable = { query(sql: string): Promise }; + +export type Health = + { ok: true; latencyMs: number } | { ok: false; error: string }; + +const TIMEOUT_MS = 2_000; + +export async function probe( + client: Queryable, + timeoutMs: number = TIMEOUT_MS, +): Promise { + const started = performance.now(); + let timer: ReturnType | undefined; + + // The query keeps running after the timer wins the race. Attaching a handler + // here — not in the race — marks it handled, so a late rejection does not + // surface as an unhandled rejection and take the server down. + const query = client.query("select 1"); + query.catch(() => {}); + + try { + await Promise.race([ + query, + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`timed out after ${timeoutMs}ms`)), + timeoutMs, + ); + }), + ]); + return { ok: true, latencyMs: Math.round(performance.now() - started) }; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : String(error), + }; + } finally { + clearTimeout(timer); + } +} From b357bb4100c4530aa1dd9979d1552e0a52aa689c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Caruelle?= Date: Mon, 14 Sep 2026 00:18:43 +0200 Subject: [PATCH 11/11] chore(dev): aligner le major Postgres local sur la production MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docker-compose fixait postgres:17-alpine quand la production tourne postgres-ssl:18 — un major d'écart entre développement et production. Destructif pour le volume local : Postgres refuse de démarrer sur un répertoire de données d'un major précédent. `docker compose down -v` puis `bun run db:migrate`. AGENTS.md consigne la CI, le healthcheck et ce major. --- AGENTS.md | 34 ++++++++++++++++++++++++++++++---- docker-compose.yml | 5 ++++- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 95061a7..f5ef38a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -103,9 +103,14 @@ routing, and build were migrated. - `bun run auth:generate` — regenerate the Better Auth tables via the CLI (`bunx`, overwrites `src/db/schema.ts` — re-append our tables from git after: `dailyPuzzle`, `dailyScore`, `dailyView`, `levelScore`) -- `bun run lint` — oxlint +- `bun run lint` — oxlint, capped at `--max-warnings=12` (see "Key decisions") - `bun run generate-routes` — regenerate the route tree (`tsr generate`) +CI (`.github/workflows/ci.yml`) runs `lint` → `test` → `verify` → `build` on +every push to `main` and every pull request, on the bun version pinned by +`packageManager`. No Postgres service: no test imports `src/db/index.ts` or +reads `DATABASE_URL`, and the suite stays stateless on purpose. + ### Environment variables The pure game needs none, but the daily-level / accounts / leaderboard layer @@ -115,7 +120,10 @@ Copy `.env.example` → `.env` for local dev (bun auto-loads it); Railway inject the real values. - `DATABASE_URL` — Postgres connection. Local dev via `docker-compose.yml`; on - Railway use `${{Postgres.DATABASE_URL}}` (private host, no SSL). + Railway use `${{Postgres.DATABASE_URL}}` (private host, no SSL). The compose + image is `postgres:18-alpine`, matching production's major. It was 17: a data + directory written by 17 will not start under 18, so a checkout that predates + the bump needs `docker compose down -v` then `bun run db:migrate`. - `BETTER_AUTH_SECRET` — `openssl rand -base64 32`. - `BETTER_AUTH_URL` — the app's public origin (also added to `trustedOrigins`). Doubles as the origin for every absolute URL the server emits: `og:image` and @@ -220,7 +228,7 @@ NOT serve the client assets alongside the handler — that's why we use Nitro.) Topology in `.railway/railway.ts`: - **web** — `source: github(...)`, build `bun run build`, start `bun run start`, - `preDeployCommand: bun run db:migrate`, healthcheck `/`. Env: `DATABASE_URL` + `preDeployCommand: bun run db:migrate`, healthcheck `/api/health`. Env: `DATABASE_URL` from the Postgres ref; `BETTER_AUTH_SECRET` / `BETTER_AUTH_URL` set out of band (secrets, `preserve()`d in IaC). - **Postgres** — `postgres("Postgres")`; other services reference its @@ -230,6 +238,19 @@ Topology in `.railway/railway.ts`: `restartPolicyType: "NEVER"`. The generator closes the pool and `exit(0)` or the next run is skipped. +`/api/health` (`src/routes/api/health.ts` → `src/server/health.ts`) runs +`select 1` against the pool and answers 200 `{"status":"ok"}` or 503 +`{"status":"degraded"}`, `cache-control: no-store`. The old target, `/`, served +the SPA shell and answered 200 to an instance that could not reach its database. +`pg` has no timeout configured, so the probe races the query against a 2 s +deadline — that race is in `probe()`, not on the shared pool; putting a deadline +on every query is a separate decision. The failure reason goes to the logs, not +the body: the route is public and a driver error names the host and user. + +Railpack builds `web`; it reads `packageManager` from `package.json`, which is +why bun is pinned there (`bun@1.4.0`) and why CI's `setup-bun` repeats the same +literal. + Secrets to set once (dashboard/CLI, not in the file): `BETTER_AUTH_SECRET` (`openssl rand -base64 32`) and `BETTER_AUTH_URL` (the web service's public domain). @@ -246,7 +267,12 @@ Runtime network dependency: the Instrument Serif web font (Google Fonts). - The dozen `react/only-export-components` oxlint warnings are inherent to TanStack file-route modules (they export both `Route` and a component), so `bun run lint` is green with them; `__root.tsx` alone accounts for three (the - shell plus the two boundaries). A THIRTEENTH is a regression. + shell plus the two boundaries). A THIRTEENTH is a regression, and + `--max-warnings=12` now enforces that rather than leaving it to this + paragraph. The rule counts about one warning per route module, not one per + stray export: adding a non-component export to a file that already warns moves + the warning without adding one. What reaches 13 is a NEW route module — so + raise the cap deliberately when you add a route, never to get back to green. - `@better-auth/core` must resolve to the **same** version as `better-auth` (1.6.23). `@better-auth/cli` lags (1.4.21) and drags an old core, so it's NOT a dependency — `auth:generate` runs it via `bunx @better-auth/cli@latest`. diff --git a/docker-compose.yml b/docker-compose.yml index 6229864..9c5ce92 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,10 @@ # database to talk to locally. Data lives in a named volume. services: postgres: - image: postgres:17-alpine + # Same major as production (Railway runs postgres-ssl:18). Bumping this + # major invalidates the existing volume: `docker compose down -v` then + # `bun run db:migrate`. + image: postgres:18-alpine restart: unless-stopped environment: POSTGRES_USER: superposition