* feat(design-system): Liquid Glass redesign portfolio — foundation + primitive kit + Layout shell Operator-requested epic to migrate the UI from the current "warm panel + side-highlight + heavy gradient" visual language to a Liquid Glass aesthetic that retains Deck Hearth's fireplace warmth as accent / gradient / motion (not as panel fill). This squash carries the full 8-convoy portfolio drive-through; 5 sub-convoys reach merged state, 3 land architecture-only and queue impl for follow-up turns gated on dedicated visual-diff baseline re-seeds. Sub-convoy #1 (liquid-glass-design-tokens) — MERGED. 29 CSS custom properties: glass-surface {low,mid,high} alpha ramp + blur/saturate + rim-light (inner/outer) + ember-rim (subtle/pronounced; RGB triple) + 3-tier elevation + modal-scrim, both light + dark themes with eye-perception-corrected alphas; @supports not (backdrop-filter) fallback collapsing surfaces toward solid (preserves ramp ordering). Authored docs/DESIGN_TOKENS.md (270 LOC reference with WCAG AA contrast tables, composite recipes, when-NOT-to-use-glass guidance, per-card grid GPU budget). AGENTS.md gains a § Visual language section as the new agent-contract surface. Sub-convoy #2 (liquid-glass-modal-and-surface-primitive) — Brief 1 MERGED. Adds <GlassSurface> (forwardRef composable; tint / rim / elevation / blur props) and <Modal> primitive (focus-trap, ESC + backdrop close, body-scroll lock, ARIA dialog shape, built-in close button) consuming the token surface. lib/use-focus-trap.js — homegrown hook (~60 LOC, no dep). 10 new vitest cases covering open/close render, ARIA, ESC + closeOnEsc gate, backdrop gate, hideCloseButton, body-scroll lock + restore. 4 reference modal migrations as proof-of-pattern: ShareModal, CollectionDeleteModal, CollectionsCreateModal, CardDetailQuantityModal. Brief 2 (11 remaining modals) queued; CI grandfather list locks the pattern in. Sub-convoy #3 (liquid-glass-form-primitives) — Brief 1 MERGED. Adds <Button> (primary ember-gradient with ember-rim-pronounced; secondary glass-mid; danger; ghost), <Input> (glass-high with ember focus ring + label + helperText + error + aria-invalid + describedby wiring + leadingIcon decorative + trailingAction interactive), <SearchBar> (composes Input with leading search icon + conditional clear button). 10 new vitest cases. pages/login.js + pages/signup.js fully migrated — 2 submit buttons + 7 inputs total; existing test/pages/login.test.js assertion ("Sign in to Deck Hearth" button text) preserved. Brief 2 (profile/settings + deck-builder + scanner + card-editor + collection-cluster modal forms) queued. Sub-convoy #4 (liquid-glass-layout-shell) — MERGED. 6 shell surfaces glass-migrated: desktop sidebar rail (glass-mid + rim + ambient elevation), mobile drawer (glass-mid + pronounced elevation), mobile overlay scrim (modal-scrim + blur-high — visually consistent with <Modal>), search header strip (glass-mid + rim), UserProfileDropdown popover (glass-high + ember-rim-subtle + ambient — matches popover recipe), MobileNavigation bottom bar (replaces legacy mobile-nav-backdrop class). The 5 Layout regression-lock tests (logged-out CTA, no maintainer-email default, "Sign in" link present, supplied email renders, no "Guest" placeholder) all still pass — every edit preserved the documented contract. Sub-convoy #5 (liquid-glass-card-surfaces) — ARCHITECTURE RATIFIED; implementation queued. Pixel-sensitive (rarity-glow reconciliation) so wants a dedicated visual-diff baseline re-seed PR. Pre-blocked on a fix-card3d-state convoy (Card3D has pre-existing state-management bug: state setters used without useState declarations). Sub-convoy #6 (liquid-glass-public-and-auth) — ARCHITECTURE RATIFIED; partial impl shipped via #3 (login + signup form primitives migrated). Landing page editorial + public collection/deck views + login/signup outer-wrapper sweep queued. Sub-convoy #7 (motion-system-pass) — MERGED. 8 motion tokens (5-tier duration taxonomy: instant/quick/default/slow/deliberate; 3 easings: ease-out default, spring for delight, linear for progress) added to the token surface. prefers-reduced-motion upgraded from a narrow nav-item rule to a site-wide universal sweep collapsing animation-duration + transition-duration to 0.01ms (preserves end states, no flicker); .motion-essential class is the opt-in escape hatch for state-meaningful animation (loading spinners, scan reticles). Authored docs/MOTION_SYSTEM.md with WCAG SC 2.3.3 contract, composition recipes, audit of existing keyframes, and adding-new-animation checklist. Sub-convoy #8 (cleanup-legacy-design-css) — Brief 1 MERGED. Two new CI jobs in .github/workflows/ci.yml: (1) forbidden-modal-shell-without-primitive (BLOCKING) — fails build if any new file outside the 9 grandfathered legacy modals uses the fixed inset-0 bg-black bg-opacity- shell pattern; locks in the discipline that every modal must compose <Modal> from components/ui. (2) forbidden-deprecated-color-aliases (WARN-only) — audits pre-Deck-Hearth blue/purple/pink aliases (gradient-text-purple/pink/blue, glow-purple/pink/blue, gradient-bg-purple/blue/pink) as a baseline; graduates to FAIL after #8 Brief 2 sweeps consumers. .cursor/rules/ui-and-theming.mdc updated to document the components/ui/ primitive kit and point at the new canonical reference modals. Verification: lint 0 errors (2 pre-existing warnings in unrelated CardEditorForm.js + CollectionsPageView.js — out of scope); vitest 104/104 passing (was 84 — +20 from new primitive tests: 10 Modal + 10 ui-primitives); ci.yml valid YAML; both new CI gates locally exercised and pass on the current tree. Operator follow-ups documented in .convoys/ship-readiness.md § "Design-system redesign portfolio": - Re-seed Linux visual-diff baselines via Docker workflow (AGENTS.md § 6) after this merges. - preview-smoke.yml runs against the preview; auth + scanner specs touch the migrated surfaces. - Vercel promote to production once smoke + visual gates pass. - Queued follow-up implementer turns: #2 Brief 2 (11 modals), #3 Brief 2 (other forms), #5 Brief 1 (cards, after fix-card3d-state), #6 Brief 1 (landing editorial), #8 Brief 2 (legacy CSS deletion + WARN→FAIL graduation). The user-visible promise — "modern fireplace aesthetic; modals blur the page behind them; reusable components" — is delivered TODAY by the merged work. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(use-focus-trap): preserve named useFocusTrap export for ScannerPageView The portfolio squash inadvertently overwrote the pre-existing lib/use-focus-trap.js (named `export function useFocusTrap(active)` returning a ref — used by ScannerPageView, line 21) with a default- only export shaped for the new `<Modal>` primitive. Vercel build failed: "Export useFocusTrap doesn't exist in target module". Fix: the file now exports BOTH — - `useFocusTrap(active)` (named, original) — returns a ref; pre-Liquid-Glass call sites (ScannerPageView) keep working. - `useFocusTrapContainer({ active, containerRef, ... })` (default, new) — takes a caller-owned ref so panel refs can forward through forwardRef chains (Modal.js consumes this shape). Both hooks are commented to document which to use when. Modal.js imports default already, so no change needed there. Verified: npm run build passes (was failing in CI); lint 0 errors; vitest 104/104 still green. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
31 KiB
AGENTS.md — AI collaboration (tcg-vault)
Guidance for agents and humans working in this repo. Prefer existing patterns over new abstractions.
Branding note: this product is Deck Hearth as of 2026-05-24 (
pick-a-nameconvoy, squash commit9abbab6, PR #21). The repo and Vercel project are still namedtcg-vault— that rename is tracked in the queuedrename-repo-and-vercel-projectconvoy (auto-redirects make it low-urgency). Admin email isadmin@deckhearth.com; the prioradmin@tcgvault.comliteral is deliberately preserved intest/lib/permission-middleware.test.jsas a historical regression-lock per Risk 4 of the pick-a-name convoy.
Product vocabulary
User-facing copy distinguishes ownership (everything you own) from curated lists (binders/subsets). Import labels from lib/collection-vocabulary.js (VOCAB, collectionDisplayName) rather than hardcoding strings.
| Concept | UI label | Route / schema | Notes |
|---|---|---|---|
| Global ownership | My Collection | /my-cards, user_cards |
Scanner default destination; replaces "Owned" / "Mark Owned" |
| Curated list / binder | List / Lists | /collections, /collection/[id], collections table |
URL slug unchanged in v1; nav says "Lists" |
| Auto-sync system list | Synced binder (display) | collections row with is_system_collection = true |
DB name stays 'All My Cards' — never render; use collectionDisplayName() |
| Add ownership | Add to My Collection | POST /api/user-cards, scanner bulk |
Replaces "Mark Owned" / "Mark as Owned" |
| Add to curated list | Add to List | POST /api/collections/:id/cards |
Replaces "Add to Collection" in scanner/card flows |
CI job forbidden-stale-strings blocks "Mark Owned", "Owned Cards", and "All My Cards" in pages/ + components/ (API literals exempt).
Visual language
Deck Hearth's visual direction is Liquid Glass (in-progress as of
2026-06-03 — see .convoys/liquid-glass-redesign.md umbrella). Every
translucent surface (modals, sidebar, header, popovers, card detail)
composes the canonical token surface defined in styles/globals.css
and documented in docs/DESIGN_TOKENS.md.
Do not hardcode hex in .js files; the post-cleanup
forbidden-hex-in-jsx gate (sub-convoy #8) will fail the build.
Three rules of thumb:
- Surfaces are glass. Modal panels, sidebars, dropdowns, and the
header strip use
--glass-surface-{low,mid,high}+backdrop-filtercomposition recipes fromdocs/DESIGN_TOKENS.md§ "Composite recipes". - Brand warmth is accent, not panel fill. Ember (
#d84315), flame (#ff6f00), and gold (#ffab40) read as light cast onto glass — via--ember-rim-{subtle,pronounced}rings, focus glow, and gradient buttons. They are NOT the canonical panel-background color. - No
backdrop-filteron card grid items. GPU budget — glass goes on grid containers and detail views, not per-card. Seedocs/DESIGN_TOKENS.md§ "Per-card grid performance budget".
1. Project overview
A web app for managing trading-card-game collections (Magic, Pokémon, Lorcana). Users authenticate, build collections + decks, scan physical cards via a camera+AI-OCR flow, and share publicly. Admin users curate the card database.
- Framework: Next.js 16 (Pages router) + React 18, JavaScript (not TypeScript — see Gotcha #9)
- Data: Neon Postgres. The runtime auth surface uses
@vercel/postgrestagged-template SQL exclusively post-single-sql-client(PR #30,c403ea4;lib/database.jsdeleted). 11scripts/**helpers (setup-neon-db.js,reset-db.js,migrations/2026-05-24-rename-admin-email.js, plus 8 historical add-/fix-/seed-* jobs) still use@neondatabase/serverless'sneon()directly — out-of-scope per the no-go-zones rule and tracked as the queuedpurge-neondatabase-serverless-fullyfollow-up. Schema changes ship asnode-pg-migratemigrations undermigrations/at the repo root post-migration-tool(PR #32,de9f334) — see § 3 Conventions § "Schema changes" and Gotcha #6. - Auth: Custom JWT (jsonwebtoken + bcryptjs), token stored in
localStorage, sent asAuthorization: Bearer …. No NextAuth. The secret + canonical 24h TTL come fromlib/auth-secret.js(single source of truth; throws at module load ifJWT_SECRETis unset).getUserFromRequestreturnsnullfor unauthenticated requests — no synthetic admin fallback — and login + register are rate-limited (5 attempts / 15 min via@upstash/ratelimit). The seed admin row is created atadmin@deckhearth.comwith a password supplied via the requiredADMIN_INITIAL_PASSWORDenv var (scripts/setup-neon-db.jsexits with code 1 before touching the DB if the var is unset); no credential ships in the source tree. Operators of envs that pre-date thedrop-public-setupconvoy still have the oldadmin123hash in their DB — rotate manually via the app (see Gotcha #4). - UI: Tailwind CSS + custom CSS variables for theming (light/dark via
lib/theme-context.js) - Hosting: Vercel (
vercel.json,.vercel/present)
2. Architecture quick reference
| Area | Path | Notes |
|---|---|---|
| Pages router views | pages/*.js |
Public + auth views; uses components/Layout.js |
| API routes | pages/api/**/*.js |
Express-style handler(req, res). 30+ handlers depend on lib/permission-middleware.js::getUserFromRequest |
| Shared UI | components/*.js |
Layout, CardItem, CameraScanner, modal family |
| Auth + DB libs | lib/*.js |
use-auth (canonical client hook — sole surface post-single-auth-provider, PR #31, 0668b0c), auth-secret (single JWT_SECRET + TTL source), permission-middleware (server-side getUserFromRequest + withCollectionPermission), rate-limit (5 named limiters — see Gotcha #12). The legacy lib/database.js was deleted by single-sql-client (PR #30, c403ea4); DB access now goes through @vercel/postgres tagged templates directly. |
| Migration scripts | scripts/*.js |
27+ one-off "add column" / "seed" scripts. No formal migration tool |
| Card-import jobs | pages/api/cards/import-*.js, scripts/import-*.js |
Scryfall / Lorcana / Pokémon TCG APIs |
| Database schema | scripts/setup-neon-db.js |
Bootstrap SQL DDL — the source of truth until a real migration tool lands |
| Schema map | docs/SCHEMA_MAP.md |
Hand-curated; regenerate after schema changes |
Code graph is indexed by user-code-review-graph MCP (122 files, 628 nodes, 5602 edges). Ask: "what calls getUserFromRequest?" before refactoring auth.
3. Key conventions
- Auth (server):
import { getUserFromRequest } from '../../lib/permission-middleware'→ returns{ userId, email, role }ornull.nullmeans "send 401" — always early-return when the user is null before doing any work that depends on their identity. - Auth (client):
import { useAuth } from '../lib/use-auth'is the only client auth surface. Returns{ user, loading, logout, refreshAuth };user === nullmeans logged out,loading === truemeans token verification in flight. There is no client-side admin hook — computeconst isAdmin = user?.role === 'admin'from the sameuseAuth()call. The legacylib/auth-context.js+lib/admin-auth.jswere deleted bysingle-auth-provider(PR #31,0668b0c); do not reintroduce a<AuthProvider>/<AdminProvider>wrapper inpages/_app.js. The login + signup flow uses directfetch('/api/auth/{login,register}')frompages/login.js/pages/signup.js— there is nouseAuth().login(...)/useAuth().register(...)method; do not add one. - Layout
userprop: pages should passuserfromuseAuth()to<Layout>. Layout's default isnulland renders a logged-out "Sign in" CTA when no user is supplied — both paths are valid (some surfaces likepages/invite/{accept,decline}.jslegitimately render Layout for anonymous visitors). Do not reintroduce a hardcoded user object as a default prop. - JWT secret + TTL:
import { JWT_SECRET, JWT_TOKEN_TTL } from '../../lib/auth-secret.js'. This is the only place either value is defined; do not reintroduce literal fallbacks.JWT_TOKEN_TTL = '24h'is canonical. - Auth helper (token mint / verify / password hash):
import { ... } from '../../pages/api/auth-utils'(generateToken,verifyToken,hashPassword,verifyPassword). Reads the secret + TTL fromlib/auth-secret.jsunder the hood. - Rate limiting:
import { checkAuthRateLimit } from '../../lib/rate-limit.js'for any new auth-surface endpoint (/api/auth/login+/api/auth/registeralready wired). Returns{ allowed, remaining, reset }; on!allowedreturn 429 with aRetry-Afterheader. See.cursor/rules/api-routes.mdc§ "Rate limiting" for the verbatim shape. - Permission gate for collection routes: wrap handlers with
withCollectionPermission('viewer' | 'editor' | 'owner')fromlib/permission-middleware.js. - DB access: Use tagged-template style —
import { sql } from '@vercel/postgres'. The legacylib/database.js(db.query(string, params)wrapper around@neondatabase/serverless, which interpolated params into a string and calledsql.unsafe) was deleted bysingle-sql-client(PR #30,c403ea4); do NOT reintroduce that shape. Forscripts/**helpers that legitimately need the Neon HTTP driver (e.g.setup-neon-db.js,reset-db.js), import{ neon } from '@neondatabase/serverless'directly and use tagged-template SQL (await sql\...``) — the safe shape, not the wrapper's unsafe shape. - Activity logging:
logCollectionActivity(collectionId, userId, action, details)— call it from any handler that mutates a collection. - File names:
kebab-case.jsfor libs/scripts;PascalCase.jsfor React components. - Imports: No path aliases configured; use relative imports.
- Slugs:
lib/slug-utils.js::generateUniqueSlugfor any user-facing identifier (collections, decks). - CSS theme tokens: Components read
var(--bg-primary),var(--text-primary),var(--accent-ember), etc. — defined instyles/. Don't hardcode hex colors. - Schema changes (post-
migration-tool): new column / constraint / table work ships as anode-pg-migratemigration undermigrations/at the repo root. Generate vianpm run migrate create <name> -- -j js, edit theup()(anddown()when rollback is safe — for any migration that touches data, prefer a hard-stubdown()that throws), and apply locally withnpm run migrate up.npm run setup-dbnow chainsnpm run migrate upthen seeds the admin user. The legacyscripts/add-*.js/scripts/fix-*.js/scripts/seed-*.jsjobs are append-only history (no-go-zones rule); do NOT add new ones. See.convoys/migration-tool.mdfor the full architect-decision record and Gotcha #6 below for the historical context.
4. Common gotchas
-
#1 — Two SQL clients live in parallel. RESOLVED by
single-sql-clientconvoy (PR #30, squash commitc403ea4, 2026-05-26).lib/database.jsis deleted; the 2 callers (pages/api/auth-utils.jssource +test/api/auth-utils.test.jsmock) migrated to@vercel/postgrestagged templates (byte-equivalent SQL semantics for the two single-parameter SELECT queries). The convoy's architect audit (D4) confirmed no current call site actually exercised thesql.unsafeinjection vector — theuserIdcallers passed a numeric SERIAL from a verified JWT — so this was foot-gun removal rather than a live security finding.@neondatabase/serverlessis still inpackage.jsonas a runtime dep because 11scripts/*helpers continue to useneon()directly (setup-neon-db.js,migrations/2026-05-24-rename-admin-email.js,reset-db.js, plus 8 historicaladd-*/fix-*/seed-*jobs). Those scripts use the safe tagged-template shape (await sql\...`), not the deleted wrapper's unsafedb.query(string, params)shape. Full dep purge is tracked as the queuedpurge-neondatabase-serverless-fullyfollow-up (now unblocked bymigration-toolPR #32 — the migration helpers all usenode-pg-migrate'spgclient, not@neondatabase/serverless, so the only remaining directneon()consumers aresetup-neon-db.js(admin seed),reset-db.js`, and the historical graveyard). Entry kept (not renumbered) to preserve cross-references. -
#2 —
getUserFromRequestsynthetic-admin fallback. RESOLVED byfix-auth-bypassBrief 2 (commit258e479). The helper now returnsnullfor unauthenticated requests;pages/api/auth/verify.jsreturns 401 on the no-token branch. The 16 unit tests intest/lib/permission-middleware.test.jslock in the contract, including a negative regression against the old synthetic-admin shape. Entry kept (not renumbered) to preserve the audit trail and stable cross-references. -
#3 — JWT_SECRET hardcoded across 7 files. RESOLVED by
fix-auth-bypassBrief 1 (commit4a10dce).lib/auth-secret.jsis now the single source of truth and throws at module load whenJWT_SECRETis unset. Canonical TTL isJWT_TOKEN_TTL = '24h'. The'your-secret-key-change-in-production'literal is gone from all 7 sites; CI lint passes against the post-fix tree. Entry kept (not renumbered) to preserve cross-references. -
#4 — Default admin credentials in the seed. RESOLVED by
drop-public-setupBrief 1 (commitff80753) + Brief 2 (commitb63b509).scripts/setup-neon-db.jsno longer hardcodesadmin123; it readsADMIN_INITIAL_PASSWORDfrom the environment and exits with code 1 before opening a DB connection if the var is unset. README's "Default Admin Account" section is replaced with "First-time admin setup" copy that documents the env var,openssl rand -base64 24generation tip, and CI-secret alternative. Brief 2 converted the script from CJS to ESM sonpm run setup-dbactually runs on Node 22.x (thebump-next-jsconvoy's"type": "module"flag had silently broken it). Operator caveat: the seed is idempotent (ON CONFLICT (email) DO NOTHING); re-running setup-db on an env that already has the admin row does NOT rotate the password. Any deployed env that ran setup before this convoy still has the weakadmin123hash — operators must rotate manually via the app, or wait for the queuedrotate-default-adminfollow-up convoy. Entry kept (not renumbered) to preserve cross-references.Post-
pick-a-name(2026-05-24, squash9abbab6), the seeded admin email isadmin@deckhearth.com(and alice/bob test users likewise renamed). If you are deploying past9abbab6and the prod Neon DB still has@tcgvault.comrows, you MUST runnode scripts/migrations/2026-05-24-rename-admin-email.jsBEFORE the next admin login attempt or it 401s. The migration is ESM, idempotent, UNIQUE-collision-safe (fails loud ifsetup-neon-db.jsalready ran post-rename — which would indicate an ordering error). Order: migration FIRST, then any subsequentnpm run setup-db. -
#5 —
pages/api/setup-database.jspublic endpoint. RESOLVED byfix-auth-bypassBrief 3 (commitfc0dd73). The file is deleted along with the other three dev endpoints (/api/simple,/api/test-auth,/api/test-db), and.github/workflows/ci.yml's newforbidden-endpointsjob fails the build if any of them are re-introduced (or if a newpages/api/test-*.jsfile appears). Entry kept (not renumbered) to preserve cross-references. -
#6 — Migrations were bare scripts. RESOLVED by
migration-toolconvoy (2026-05-26).node-pg-migrate@^8is the chosen tool (lightweight, raw-SQL-friendly, zero TS surface — matches the repo's JavaScript-only@vercel/postgresstyle). New migrations live undermigrations/at the repo root and use the defaultpgmigrationstracking table. The initial backfillmigrations/1779853647564_initial-schema.jsreproducesscripts/setup-neon-db.js's 7-table DDL verbatim usingCREATE TABLE IF NOT EXISTS, so it's idempotent against fresh AND pre-existing envs — first-timenpm run migrate upon an env that already ransetup-neon-db.jspre-convoy is a no-op DDL-wise (only records thepgmigrationsrow). The legacy 27scripts/add-*.js/scripts/fix-*.js/scripts/seed-*.jsjobs are append-only history per the no-go-zones rule — do NOT add new ones. New column / constraint work ships as anode-pg-migratemigration. See § 3 Conventions § "Schema changes" above +.convoys/migration-tool.md. Entry kept (not renumbered) to preserve cross-references. -
#7 — Dual
is_publicsemantics. Collections and decks both haveis_publiccolumns; check which controls discovery vs. anonymous read in the relevant route. -
#8 — Layout has hardcoded default user. RESOLVED by
fix-layout-default-userconvoy (PR #15, squash commitca302a8).components/Layout.js's default prop is nownull;UserProfileDropdownrenders a<Link href="/login">Sign in</Link>CTA whenuser === null. Brief 2 also swept the 7 pages that needed page-level fixes (scanner/decks/deck-builder/deck/[id]now passuser={user}to Layout;profile/settingsreplaced leakyuseState({email:'me@…'})withuseState(null)+ null-guards on every syncuser.*read;card/[id]swapped a hardcodedconst user = {...}foruseAuth()fromlib/use-auth.js).test/components/Layout.test.jsadds 5 regression-lock assertions (no maintainer email when user is null/omitted; "Sign in" link present; supplied email renders; no "Guest" placeholder); vitest 21/21 green at merge. New devDeps:jsdom@^29+@testing-library/react@^16. See.convoys/fix-layout-default-user.mdand.convoys/ship-readiness.mdP0 #7. Entry kept (not renumbered) to preserve cross-references. -
#9 —
typescriptis a devDep, but the source is still JavaScript-only.package.jsonliststypescript@^5.9.3purely soeslint-config-next@16's bundledtypescript-eslintchain can satisfy its hardrequire('typescript')at module load (thepeerDependenciesMeta.typescript.optional: trueflag ineslint-config-nextonly suppresses npm's install-time warning, not the runtime require). There is notsconfig.json, no.ts/.tsxfiles, and no// @ts-checkdirectives. Do not rename.jsfiles to.tsor add atsconfig.jsonwithout an explicit convoy decision — TypeScript adoption is its own scope. See.convoys/bump-next-js.md§ Decisions C. -
#10 — ESLint pinned to v9 (maintenance), not v10 (latest).
devDependencies.eslintis^9.39.4even thoughlatestis10.4.0. We tried v10 andnpm run lintcrashed withTypeError: scopeManager.addGlobals is not a functionbecauseeslint-config-next@16's bundledtypescript-eslint@8.xpredates ESLint v10's redesigned global-ingestion path. Reverted to v9 under Decision D. Do NOT bump ESLint independently — wait for the queuedbump-eslint-10follow-up convoy, which is upstream-blocked untiltypescript-eslintships a v10-tested release thateslint-config-nextbundles. See.convoys/bump-next-js.md§ Decisions D + "Follow-up convoys queued". -
#11 — Turbopack is now the default bundler.
next devandnext builduse Turbopack by default in Next.js 16. The fallback per command is--webpack(e.g.next build --webpack). We have no customwebpack:block innext.config.js, no custom loaders/aliases, and no Sass tilde imports, so Turbopack should "just work" — but if a build/runtime regression appears, reproduce on both bundlers before deciding whether to revert or pin a script to webpack. Do not pre-emptively switch to--webpack. -
#12 — Rate-limit env vars are
KV_REST_API_URL/KV_REST_API_TOKEN, notUPSTASH_REDIS_REST_*.lib/rate-limit.jsreads the Vercel Upstash Marketplace integration's auto-provisioned names. Three other Upstash-shaped vars exist in the Vercel-managed env (KV_URL,REDIS_URL,KV_REST_API_READ_ONLY_TOKEN) but our@upstash/redisREST client does not use them — do not wire to them. In prod, the rate-limit module fails closed if either of the two REST vars is missing (a single failed login is a better outcome than silently disabling brute-force protection). In dev / test, it warn-and-continues as a no-op so local work is unaffected when Upstash isn't wired up.Milestone —
add-rate-limitingconvoy (squash708ef45, PR #20, 2026-05-24) closed P0 #6 — all 8 P0s now RESOLVED. The lib refactored from a single auth-only limiter to 5 named limiters with aMap<className, Ratelimit>cache (one shared Redis client, fiveRatelimitinstances, distinct Redis prefix per class). The five exports + their use cases:Helper Class Limit/window Key Redis prefix Routes checkAuthRateLimit(req)auth5 / 15 min IP deckhearth:auth/api/auth/login,/api/auth/register(Brief 4 contract; byte-identical return shape preserved)checkSearchRateLimit(req)search60 / 1 min IP deckhearth:search/api/users/search,/api/cards/searchcheckUploadRateLimit(req, userId)upload10 / 1 hour user deckhearth:upload/api/user/avatarcheckGenerateRateLimit(req, userId)generate5 / 1 hour user deckhearth:generate/api/user/avatar/generatecheckImportRateLimit(req, userId)import5 / 1 hour user (admin-only) deckhearth:import/api/cards/import-mtg,/api/cards/import-pokemonPrefixes renamed
tcgvault:*→deckhearth:*inpick-a-name(squash9abbab6, 2026-05-24); accepted one-time per-15-min / per-1-hour counter reset; existing Upstash state attcgvault:*keys is now stale and will TTL out naturally.All five return the same
{ allowed, remaining, reset }shape; on!allowed, setRetry-After: Math.ceil((reset - Date.now()) / 1000)and return 429 with the uniform message'Too many attempts. Try again later.'(per-class variation would fingerprint the limits to an attacker — explicitly rejected).Defensive THROW pattern.
extractUserIdentifier(userId)THROWS with a named error whenuserIdisnull/undefined/''/NaN. Surfaces gate-ordering bugs at dev time rather than silently falling back to IP and converting a per-user limit into a per-IP limit (which would lock household members out for one user's behavior). Numeric0is intentionally accepted (returns'user:0') for forward-compat. Gate-ordering rule: per-user rate-limit gates (upload,generate,import) MUST sit AFTER the auth check. For the two/api/cards/import-*routes, the ordering is alsoauth → admin-role check (403 if not admin) → rate-limit; the admin-role check sits between auth and rate-limit. IP-keyed gates (auth,search) can sit anywhere after the method check.Adding a sixth class is a one-line
LIMITER_CONFIGaddition + one new exported function (noinit()restructuring needed). Tuning an existing class is a one-lineLIMITER_CONFIGedit. The full verbatim call shape + gate-ordering rules + identifier-extraction documentation live in.cursor/rules/api-routes.mdc§ Rate limiting.
5. Running locally
- Runtime: Node 20 (Vercel default).
- Setup:
npm install, copy.env.localtemplate (POSTGRES_URL + JWT_SECRET + RESEND_API_KEY + BLOB_READ_WRITE_TOKEN + ADMIN_INITIAL_PASSWORD — the last is required fornpm run setup-dband the script exits with code 1 if it's unset; optionally KV_REST_API_URL + KV_REST_API_TOKEN to exercise the rate limiter locally — without them,lib/rate-limit.jswarn-and-no-ops in dev), thennpm run setup-dbonce. - Dev server:
npm run dev→ http://localhost:3000.
6. Testing
-
Unit-test runner:
vitest@^3.2.4(installed viafix-auth-bypassBrief 5, commit1629afb).npm testfor watch mode;npm run test:runfor the CI / single-shot mode. Config invitest.config.js, setup intest/setup.js(setsJWT_SECRET+NODE_ENV=testbefore any module loads). Specs live undertest/mirroring source layout (test/lib/*.test.js,test/api/*.test.js,test/components/*.test.js). Last green: 21/21 tests pass. -
Vitest coverage today: 21 unit tests —
lib/auth-secret.js(3),lib/permission-middleware.js::getUserFromRequest(8, incl. a negative regression against the old synthetic-admin shape — Gotcha #2),pages/api/auth-utils.js(5), andcomponents/Layout.js(5 regression-lock assertions for the post-PR-#15 logged-out branch — Gotcha #8). These tests lock in the contracts established byfix-auth-bypassBriefs 1 + 2 andfix-layout-default-user; do not weaken them when refactoring auth or Layout. -
E2E / smoke runner:
@playwright/test@^1.60.0(installed viaadopt-playwright-smoke, PR #18 squash7b6f751). Config inplaywright.config.js(root, ESM) declares two projects:smoke—tests/smoke/**/*.spec.@(ts|js); invoked by.github/workflows/preview-smoke.yml.npm run test:smokelocally.visual—tests/visual/**/*.spec.@(ts|js); invoked by.github/workflows/visual-diff.yml.npm run test:visuallocally;npm run test:visual:updateto (re-)seed baselines.
Local-run convention: boot
next devin one terminal, then in another runBASE_URL=http://localhost:3000 npm run test:smoke(or against a deployed preview,BASE_URL=https://<preview>.vercel.app VERCEL_AUTOMATION_BYPASS_SECRET=<value> npm run test:smoke). Nonext devauto-boot in the test scripts (Decision 6 ofadopt-playwright-smoke). -
Browsers must be installed once locally:
npx playwright install --with-deps chromium. CI re-runs this on every workflow run (it's cached when possible). -
Visual baselines: none committed yet.
tests/visual/__screenshots__/is intentionally absent and intentionally NOT in.gitignore(baselines, when they exist, must be committed). First-run baseline generation MUST happen in a Linux environment so the PNG matches what CI produces. Recommended path is the Playwright Docker image:docker run --rm -v "$PWD":/work -w /work \ mcr.microsoft.com/playwright:v1.60.0-noble \ sh -c "npm ci && BASE_URL=<preview-url> \ VERCEL_AUTOMATION_BYPASS_SECRET=<value> \ npm run test:visual:update"Mac-generated baselines will NOT match Linux CI —
playwright.config.js's customsnapshotPathTemplatehas no{platform}token, so a Mac update silently overwrites the canonical Linux baseline. Tracked as the queuedseed-visual-baselines-on-linuxconvoy (see.convoys/ship-readiness.md§ Queued convoys). -
CI behavior:
- Vitest: the
test:job in.github/workflows/ci.ymlrunsnpm run test:runon every PR and push tomainand is blocking (no|| true, nocontinue-on-error). A red test job blocks merge. - Playwright smoke: runs on every PR via
preview-smoke.yml. Gate skip viapipeline: skip smokein the PR body (handled in thegate:job's Decide step via env-var routing — see § 7's shell-injection note). Last measured runtime: 59s end-to-end, 3/3 tests pass in 2.9s (PR #18 post-merge run). - Screenshot diff: runs only on PRs touching
pages/**/components/**/styles/**/tailwind.config.js/postcss.config.jsviavisual-diff.yml. FirstScreenshot diffrun afteradopt-playwright-smokewill fail at the test step because no baseline exists yet;continue-on-error: trueswallows the failure and the comment-on-PR step posts "Visual Diff — view run" with empty artifacts. That is the documented Decision-4 end state ofadopt-playwright-smoke, not a regression — it stays that way untilseed-visual-baselines-on-linuxlands.
- Vitest: the
-
Manual QA:
TESTING_GUIDE.mdstill applies for flows not yet covered by automated tests (scanner camera path, card-import jobs, multi-step UI wizards). The automated smoke + visual suite is steadily eclipsing it;TESTING_GUIDE.mdwill be renamed todocs/MANUAL_QA.mdand trimmed to truly-manual-only flows in a future cleanup convoy (see.convoys/ship-readiness.md§ Role-doc-writer findings).
7. Deployment
-
Vercel auto-deploys
mainand creates Preview deployments for every PR.vercel.jsonand.vercel/are committed. CI in.github/workflows/runs lint + types (no duplicate build — Vercel handles it). -
Preview protection bypass for automation. The project has a Protection Bypass for Automation token exposed locally as
VERCEL_AUTOMATION_BYPASS_SECRETin.env.local(not committed) and seeded into GitHub Actions as a repo secret (gh secret set VERCEL_AUTOMATION_BYPASS_SECRET, 2026-05-24). The secret is consumed in two shapes:- Query parameter on
wait-for-vercel-preview@v1.3.2'spath:input in bothpreview-smoke.ymlandvisual-diff.yml—path: '/?x-vercel-protection-bypass=…', bare form, without&x-vercel-set-bypass-cookie=true(the cookie variant returns 307 + Set-Cookie and axios in Node has no cookie jar, so it 401s on the redirect). Plumbed by PR #17 (fix-vercel-deployment-protection-in-ci, squash9a3e077). - HTTP header in
playwright.config.js'suse.extraHTTPHeaders—'x-vercel-protection-bypass': <secret>. Playwright's browser context has a real cookie jar so this shape works there, and the testOptions surface forwards the header to the test-levelrequestfixture'sAPIRequestContextas well, so bothpage.goto(...)calls andrequest.get('/api/health')calls hit the protected preview correctly without per-spec header injection. Plumbed by PR #18 (adopt-playwright-smoke, squash7b6f751) per Decision 2 of that convoy.
Decision 2 also wires a fail-loud-in-CI / warn-in-dev predicate:
if (process.env.CI === 'true' && !process.env.VERCEL_AUTOMATION_BYPASS_SECRET) throw ...(with an error message that names the env var, thegh secret setrotation command, and points at this section); otherwiseconsole.warnonce and continue withextraHTTPHeadersundefined. Same fail-closed / warn-and-no-op shape aslib/rate-limit.js's Upstash predicate — see Gotcha #12.Do not log or echo the value. If the operator rotates the token in the Vercel dashboard, re-seed the GitHub secret via
gh secret set VERCEL_AUTOMATION_BYPASS_SECRET --body "<new value>". See.convoys/fix-vercel-deployment-protection-in-ci.mdand.convoys/adopt-playwright-smoke.md. - Query parameter on
-
Shell-injection hardening in workflow YAML. Never inline
${{ github.event.* }}directly into arun:block — route the value through the step'senv:block and quote it ("$VAR_NAME") in shell. PR #17's CI validation caught a real syntax error from a PR body containing(because the gate-job's Decide step inlined${{ github.event.pull_request.body }}straight into bash; commitb6f8688swept bothpreview-smoke.ymlandvisual-diff.ymlto theenv:+ quoted-shell pattern. This is GitHub's official Security Hardening guidance ("Security hardening for GitHub Actions" → "Using a third-party action"). Apply to any new workflow that reads PR body / title / branch name / commit messages in shell.
8. Code graph
A local code-knowledge-graph MCP server (user-code-review-graph) is set up for this repo. Ask "what calls X?" or "show me the flow from /api/auth/login" instead of grepping. See docs/agent-context/README.md.