1. Non-blocking scan capture upload (emitScannedCard)
- Card result shows immediately; upload fires in background
- Removes ~500ms blocking S3/MinIO roundtrip from the result path
2. Parallel OCR workers for name + number strips (ocr-worker)
- Two independent Tesseract workers run simultaneously
- Cuts Layer 1 OCR time ~50% on mobile (name + number in parallel)
- terminateOcrWorker cleans up both workers on teardown
3. Faster pre-verification timing (scanner-card-detection)
- VERIFICATION_INTERVAL_MS: 1000 → 500ms
- MIN_FIRST_SEEN_MS_FOR_VERIFY: 800 → 500ms
- Net ~300ms faster from first detection to verification start
All 58 scanner tests pass (2.20s).
- Export DEFAULT_LAYOUT from lib/frame-palette.js so the custom-frames
route can import it at runtime (not just the hardcoded copy in tests).
- Fix vitest mock isolation in test/api/custom-frames.test.js: beforeEach now
uses mockReturnValue instead of mockResolvedValue to avoid resolving the
default mock in each test; test cases provide specific mock chains with
mockResolvedValueOnce. Fixes 4 tests that were bleeding state between
cases due to leftover queued mock values.
- Fix validateLayout test coordinates: art w+h=0.924 and 0.398 are both
within the 0-1 fraction range so x+w=0.962<1 and y+h=0.982<1 pass.
- Add dedicated validateLayout unit tests (accepts, rejects missing zone,
rejects out-of-bounds).
- Fix update test mock chain: PUT calls SELECT (found) then SELECT (clash)
then UPDATE (RETURNING) — provide all three in order.
- Fix DELETE test: owns via SELECT then executes DELETE (2 calls).
Adds DEFAULT_LAYOUT + IMAGE_FRAME_ROWS constants (fractional art/text window
anchors), a ZoneEditor component for bounding-box layout editing in the
designer, and expands the custom-frames/games CRUD API surface to support
frame image storage and retrieval. Custom card designer pages wire these
together with the existing PNG export pipeline.
See .convoys/card-designer-image-frames.md for scope tracking.
- {CODE} tokens in description/actions/flavor render as inline symbol
icons (RichText), including inside cost pips
- custom frames gain an optional background texture (upload/replace/
remove via /api/custom-frames/[id]/texture); renders behind panels
- custom games can be shared to the community (is_public): toggle in the
game space, public listing at /community/games, read-only game view,
/api/public/games endpoints (no auth, public rows only)
- /designer/print: multi-card print sheets on US Letter at 300dpi
(63x88mm cards, 3x3 or 2x2, dashed cut guides, full-sheet PNG export)
- migration 1787711511000
- custom_frames: per-user frames with full 9-slot palette (JSONB),
unique names; designer frame picker lists them alongside starters,
click to use, edit/delete via inline editor with live preview
- custom_symbols: upload cost icons (PNG/WebP/SVG, 2MB) keyed by short
code; re-uploading a code replaces the old icon; ManaPips renders
icon pips for {CODE} tokens with graceful text fallback
- custom_cards.custom_frame_id links designs to custom frames; API GETs
join and nest the palette; deleting a frame falls back to starter
- migration 1787700511000
- game system selector: standalone, existing system (MTG/Pokemon/Lorcana/
SWU/FaB/One Piece/Sorcery/Grand Archive — codes match catalog imports),
or a user's custom game
- custom_games table + CRUD API (private per user, unique names)
- /games hub with create form; /games/[id] space with rename, delete,
card gallery, and ?game= deep-link into the designer
- catalog twin resolves game: system code, custom game name, or 'Custom'
- my-designs shows each design's game association
- migration 1787693311000
- rarity now renders as shape+color symbol anchored inside the type bar
(circle/diamond/pentagon/star per rarity) — fixes straddling gem alignment
- new flavor_quote field: centered italic quotation with ornamental
diamond dividers between description/actions/quote
- framed | fullart toggle: full-art bleeds artwork edge-to-edge with
title/cost top scrim and type/text bottom scrim
- shared pickDesignFields lib so create/update routes cannot drift
- migration 1787685911000: art_mode + flavor_quote columns
- custom_cards migration + CRUD API with catalog twin sync so designs
appear in My Cards, lists, and decks via normal card joins
- artwork upload to MinIO under card-art/
- /designer page: form-driven live preview, 4 starter frames, PNG export
- /my-designs gallery with edit/delete
- Designer nav entry in sidebar + mobile drawer
- scanner-desktop-layout: open -> shipped; adds As-shipped section
(PR #165, squash 938c161, 2026-08-15) with post-merge follow-up refs.
- reconcile-historical-add-scripts: open -> shipped to match its
existing As-shipped section and ship-readiness RESOLVED entry.
- Move disambiguation-cancel tracker reset into useCameraScanner's new
resetTrackedCard(cardId) (immutable map + setTrackedCards), wired via
onTrackerReset — the identification hook no longer mutates state-derived
objects, clearing the blocking react-hooks/immutability lint error.
- Stub ResizeObserver in test/setup.js so ScannerCamera's workstation
tests render under jsdom.
- Drop unused eslint-disable directive in CollectionsPageView.
npm run lint: 0 problems; vitest 231/231.
Default mobile to manual scan mode with guide tap and center shutter for explicit feedback. Split auto-detect pause from hard verification pause and fall back to guide bounds when shape detection misses.
Co-authored-by: Cursor <cursoragent@cursor.com>
Allow consecutive scans without refresh by resetting trackers and counting vision rate limits once per card. Add a fixed card guide, widen detection bounds, and correct object-cover overlay math.
Co-authored-by: Cursor <cursoragent@cursor.com>
Give /scanner a md+ camera, live match inspector, and history strip
(with device picker, batch scan, and tips) without regressing the
mobile immersive checkout.
Co-authored-by: Cursor <cursoragent@cursor.com>
Flatten authenticated sidebar IA, move admin to the profile menu, right-align TopSearchBar actions, and replace placeholder dashboard panels with data from /api/user/stats and /api/user-cards.
Co-authored-by: Cursor <cursoragent@cursor.com>
Record Aug 15 measurement showing Phases 1–2 missed success targets, Phase 3 code is live with 15% embedding backfill and zero L0 traffic yet.
Co-authored-by: Cursor <cursoragent@cursor.com>
Replace @vercel/postgres, Blob, and Upstash with lib/sql.js, MinIO object
storage, and CT 102 Redis rate limits. Add Dockerfile for Dokploy deploy,
homelab runbooks, Neon data-copy helper, and point CI smoke/visual at the
homelab URL instead of Vercel previews.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(ci): allow card-embed.js and skip pgvector on non-superuser CI
Add lib/card-embed.js to the server-only LLM allowlist (forbidden-patterns
Check 3). Make the pgvector migration degrade gracefully when CT 102 CI
cannot CREATE EXTENSION vector so migrate up still passes.
Co-authored-by: Cursor <cursoragent@cursor.com>
* docs(convoy): post-ship metrics and operator checklist for Phase 3
Record Neon migration applied, scan_attempts snapshot, and backfill gate.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Add lib/card-embed.js to the server-only LLM allowlist (forbidden-patterns
Check 3). Make the pgvector migration degrade gracefully when CT 102 CI
cannot CREATE EXTENSION vector so migrate up still passes.
Co-authored-by: Cursor <cursoragent@cursor.com>
Add pgvector embeddings on cards, server-side cohere/embed-v4.0 via AI
Gateway, kNN identify route, and L0→L1→L2 client orchestration with
empty-index fast escalate and id-cursor backfill job.
Co-authored-by: Cursor <cursoragent@cursor.com>
Align toast, sheet, and Review N pill with GlassSurface tokens, use solid cart rows to avoid stacked blurs, and delete pre-rebuild scanner components no longer referenced by /scanner.
Co-authored-by: Cursor <cursoragent@cursor.com>
B3 ran before the B2 column add on a fresh database. Move the index to a later migration so collaboration tables still apply and the index lands after visibility is present.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Start scanner-mobile-checkout convoy for the cart-then-commit phone flow.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Ship a cart-then-commit mobile scanner so phone sessions stay on the camera.
Scan matches enqueue locally instead of auto-writing ownership, checkout happens in a sheet, and audit fixes cover stale commit detection, returnUrl open redirects, nested Escape, and ember detection chrome.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Captures the DDL half of scripts/add-collaboration-features.js that the
initial-schema backfill missed:
- collection_permissions (collaboration roles + invite tokens)
- collection_activity (audit-trail with JSONB details)
- users.is_pending (column for invited-but-not-yet-accepted users)
- 4 indexes for query hot-paths
lib/permission-middleware.js reads collection_permissions in
withCollectionPermission and writes collection_activity from
logCollectionActivity, so a fresh Neon branch onboarded by
`npm run setup-db` MUST land these tables. Prod was brought to this
shape by add-collaboration-features.js running historically; this
migration brings fresh envs to parity per
.convoys/reconcile-historical-add-scripts.md Brief outline -> B3.
Idempotent against fresh and existing envs:
CREATE TABLE IF NOT EXISTS / ADD COLUMN IF NOT EXISTS / CREATE INDEX
IF NOT EXISTS so re-application against any post-historical-script env
is a no-op except recording the pgmigrations row.
Owner-permission DML backfill is intentionally NOT captured - fresh
envs have no pre-existing collections needing backfill, and prod's
backfill is already applied.
down() is a hard stub matching the initial-schema pattern.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(migrations): reconcile user_cards UNIQUE constraint per Option A (B6)
Convoy: reconcile-historical-add-scripts Brief 6.
Operator decision on Finding 1 (ratified 2026-06-14): Option A — keep
the canonical 3-col UNIQUE(user_id, card_id, is_foil) declared by
migrations/1779853647564_initial-schema.js. Drop / treat-as-no-op the
stricter 2-col UNIQUE(user_id, card_id) that the historical
scripts/fix-user-cards-constraints.js job would have installed. Foil
and non-foil copies of the same card are semantically separate rows.
Defensive idempotent shape; safe against all three prod states (fresh
Neon branch, long-lived env that never ran the script, long-lived env
that did run it). down() is a hard-stub throw — re-installing the
2-col constraint would forbid the foil distinction runtime code
relies on.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(migrate): pre-check pg_constraint instead of catching duplicate_object
ADD CONSTRAINT UNIQUE creates a supporting index under the hood; when
the index name already exists from initial-schema's inline UNIQUE,
Postgres raises SQLSTATE 42P07 (duplicate_table), not 42710
(duplicate_object) — so the EXCEPTION block didn't catch it and CI's
Migrations apply gate failed with `relation
"user_cards_user_id_card_id_is_foil_key" already exists`.
Swap to a pg_constraint pre-check: bulletproof against both SQLSTATEs
without overreaching to WHEN OTHERS.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Folds the union of DDL effects from `scripts/add-user-profile-columns.js`
(#8) and `scripts/add-user-profile-fields.js` (#9) into the migration
history so a brand-new Neon branch reaches structural parity with prod.
Captures:
- 16 ALTER TABLE users ADD COLUMN IF NOT EXISTS (first_name, last_name,
username UNIQUE, profile_image_url, bio, avatar_url, favorite_games,
collection_visibility, preferred_currency, cards_per_page, default_view,
notifications_email, notifications_marketing, two_factor_enabled, theme,
language). Per Finding 2, BOTH profile_image_url and avatar_url are kept
(cleanup deferred to queued unify-user-avatar-column).
- 2 CREATE TABLE IF NOT EXISTS (user_settings, user_avatars) — FK to users
with ON DELETE CASCADE; user_settings has UNIQUE(user_id, setting_key).
- 6 CREATE INDEX IF NOT EXISTS (idx_users_username, idx_users_email,
idx_user_settings_user_id, idx_user_settings_key, idx_user_avatars_user_id,
idx_user_avatars_active).
- 6 CHECK constraints wrapped in DO $$ EXCEPTION WHEN duplicate_object
blocks (Postgres pre-15 has no ADD CONSTRAINT IF NOT EXISTS for CHECK):
check_collection_visibility, check_preferred_currency, check_cards_per_page,
check_default_view, check_theme, check_language.
Defaults-backfill DML from the historical script is intentionally NOT
replicated; column DEFAULTs handle fresh-env semantics and prod rows
already have the values from the historical run.
down() is a hard stub (rolling back would drop columns runtime code reads).
Convoy: reconcile-historical-add-scripts (Brief 5/7).
Co-authored-by: Cursor <cursoragent@cursor.com>
Captures the `collections`-table DDL that historical scripts added to prod
but `migrations/1779853647564_initial-schema.js` did not capture:
- `visibility VARCHAR(20) DEFAULT 'private'` (scripts/add-collaboration-features.js, lines 15-21 — collections half only)
- `tcg VARCHAR(50) DEFAULT 'MTG'` (scripts/add-collaboration-features.js, lines 15-21 — collections half only)
- `tags TEXT` (scripts/add-collaboration-features.js, lines 15-21 — collections half only)
- `slug VARCHAR(100) UNIQUE` (scripts/add-collection-slugs.js, lines 17-20)
- `idx_collections_slug` UNIQUE INDEX (scripts/add-collection-slugs.js, line 79)
- `check_slug_format` CHECK constraint (scripts/add-collection-slugs.js, line 91)
- `image TEXT` (scripts/add-image-column.js, lines 14-17)
Idempotency (D2): every statement is `IF NOT EXISTS`-guarded
(ADD COLUMN IF NOT EXISTS, CREATE UNIQUE INDEX IF NOT EXISTS, plus a DO $$
pg_constraint guard for the CHECK since Postgres has no native IF NOT
EXISTS clause for named constraints). Safe against fresh, prod, and
re-apply.
Down() is a hard stub matching initial-schema style — these columns hold
visibility flags, slugs, tcg labels, tags, and images that production
collections rely on at every page render.
Out-of-scope per architect plan (B3 territory): collection_permissions,
collection_activity, users.is_pending, idx_collections_visibility, and
the 3 idx_collection_* indexes. Out-of-scope per architect plan (already
captured): is_system_collection (in 1780378340194).
Deferred DML: per-row slug backfill from `name` via
`lib/slug-utils.js::generateUniqueSlug`. Generating slugs on a fresh env
is moot (no pre-existing collections); operators of long-lived envs
already ran the backfill historically.
Static idempotency proof — grep confirms each B2 column/constraint is
defined exactly ONCE across all 8 existing migrations:
$ grep -nE "(visibility|tcg|^.*tags TEXT|slug VARCHAR|^.*image TEXT|check_slug_format|idx_collections_slug)" migrations/*.js
migrations/1781000000002_reconcile-collections-columns.js (sole owner)
The `image_url` / `stock_image_url` matches in `1779853647564_initial-schema.js`
are on the `cards` table, not `collections`. The `tags` table created in
`1781440721350_add-tagger-tables.js` is a separate table from this
migration's `collections.tags` column.
Verification: node --check ✅, npm run lint ✅ (0 errors, baseline 1
unrelated warning), npm run test:run ✅ (131/131). Live Neon-branch
verification deferred to operator runbook (D5 of the convoy plan).
Convoy: reconcile-historical-add-scripts
Brief: B2
Pre-assigned timestamp: 1781000000002
Co-authored-by: Cursor <cursoragent@cursor.com>
Captures the DDL effects of scripts/add-favorites-system.js (a historical
no-go-zone script) so a fresh Neon branch onboarded via npm run setup-db
has the same user_favorites table + 4 indexes that prod has via the
historical script. Brings fresh envs to parity with prod for the
favorites surface used by pages/api/favorites.js.
Shape matches the historical script and the runtime API verbatim:
- user_favorites(id, user_id FK CASCADE, item_type VARCHAR(50),
item_id INTEGER, created_at, UNIQUE(user_id, item_type, item_id))
- idx_user_favorites_user_id / _item_type / _item_id / _user_type
CREATE TABLE / CREATE INDEX guarded with IF NOT EXISTS per convoy
decision D2 — re-running against any env where the historical script
already ran is a documented no-op (only the pgmigrations row is new).
down() is a hard stub: rolling back would drop user_favorites and every
row in it; removal deserves its own scoped convoy.
Part of .convoys/reconcile-historical-add-scripts (commit 22ebef2),
Brief 4 of 7. Base PR is main, not the parent convoy branch, per the
parallel-implementer dispatch pattern.
Co-authored-by: Cursor <cursoragent@cursor.com>
Folds `scripts/add-card-columns.js` into the migration history as B1 of
the `reconcile-historical-add-scripts` convoy (architect plan at
commit 22ebef2). Adds two columns to `cards` that the initial-schema
backfill (1779853647564) did not capture in its bootstrap CREATE TABLE:
- cards.quantity INTEGER DEFAULT 0
- cards.favorited BOOLEAN DEFAULT false
Both columns exist in every long-lived env (the historical script ran
pre-migration-tool) but were missing from fresh-env onboarding via
`npm run setup-db` until now. They are flagged "Unused" in
docs/SCHEMA_MAP.md § "Known schema smells" #3; the follow-up
`drop-dead-cards-columns` convoy will retire them once a query-trace
audit confirms zero readers. Reproduced verbatim here to bring fresh
envs to prod-parity per the convoy's D3 ratification.
Idempotency (D2): both statements use ADD COLUMN IF NOT EXISTS, so
the migration is safe to run against fresh Neon branches, long-lived
prod envs where add-card-columns.js already ran, or re-applications.
Matches the raw `pgm.sql()` style of `1779853647564_initial-schema.js`.
`down()` is a hard-stub throw consistent with the rest of the
migration corpus's reconciliation/destructive guards.
Static idempotency proof — the `cards` CREATE TABLE block in
initial-schema (lines 45-69) does NOT contain `quantity` or
`favorited`; the three `quantity` hits in that file at lines 76, 103,
127 are on `user_cards`, `collection_cards`, and `deck_cards`. No
other migration mentions either column:
$ rg -n "quantity|favorited" migrations/
migrations/1779853647564_initial-schema.js:76: quantity INTEGER DEFAULT 1,
migrations/1779853647564_initial-schema.js:103: quantity INTEGER DEFAULT 1,
migrations/1779853647564_initial-schema.js:127: quantity INTEGER DEFAULT 1,
PR #32's NEW post-architect migration `1781440700404_add-scryfall-bulk-columns.js`
adds 13 unrelated Scryfall bulk columns (oracle_id, illustration_id,
color_identity, keywords, legalities, flavor_text, artist, released_at,
layout, edhrec_rank, reserved, reprint, finishes) — verified to not
include quantity/favorited; no scope reduction required.
Verification:
- `node --check migrations/1781000000001_reconcile-cards-columns.js` → exit 0
- `npm run lint` → 0 errors, 1 pre-existing warning on main
(components/CollectionsPageView.js, unrelated to this change)
- `npm run test:run` → 131/131 tests pass across 26 files
- End-to-end `npm run migrate up` against a fresh Neon branch:
deferred to operator post-merge verification per D5 (D5 runbook
lives in .convoys/reconcile-historical-add-scripts.md § Verification plan)
Refs: - Architect plan: .convoys/reconcile-historical-add-scripts.md (commit 22ebef2)
- Historical script (no-go-zone, not edited): scripts/add-card-columns.js
- SCHEMA_MAP smell entry: docs/SCHEMA_MAP.md § "Known schema smells" #3
Co-authored-by: Cursor <cursoragent@cursor.com>
Scryfall was returning 429/503 transiently, causing catalog sync to
fail immediately with no recovery. Adds exponential-backoff retry
(3 attempts) for rate-limit and service-unavailable responses in both
the set discovery and card import paths. Also adds proper pagination
support for sets with 175+ cards and URL-encodes set codes.
Co-authored-by: Cursor <cursoragent@cursor.com>
PR #144 (`31da384`, 2026-06-13) shipped a runtime
`ReferenceError: useFocusTrap is not defined` to production because
the component called the hook without importing it. The sibling
`enable-no-undef-eslint-rule` convoy closes that bug class at LINT
time. This PR locks the same regression at RENDER time so the bug
would still fail CI even if the lint rule were dropped or disabled.
## What changes
- `test/components/ScanDisambiguationDialog.test.js` — 8 tests:
1. `renders without crashing (PR #144 regression-lock)` — the
direct lock-in. Mutation-tested: commenting out the
`useFocusTrap` import causes all 8 tests to fail with the same
`ReferenceError` shape that hit prod.
2. `returns null when disambiguation is falsy`
3. ARIA shape (`role`, `aria-modal`, `aria-labelledby`)
4. One button per candidate with accessible labels
5. `onPick` callback receives the selected candidate
6. Vision-hint branch renders when provided
7. Submitting state disables the "send for review" button
8. `onCancel` callback fires on Cancel click
## Why vitest + jsdom and not Playwright smoke
| Path | Catches PR #144 | Setup | Runtime |
|------|-----------------|-------|---------|
| Playwright smoke | ✓ if disambiguation mounts in the smoke run | High (auth bypass, stable multi-candidate fixture image) | ~10s + browser |
| Vitest render | ✓ directly — render-throw → test fail | Low | <100ms |
Re-scoped the queued `scanner-disambiguation-smoke-test` task to the
vitest shape because a render test catches the exact same bug class
at 1/100th the cost and matches the existing `test/components/*.test.js`
pattern (`Modal.test.js`, `ScannedCardItem.test.js`, etc.). A Playwright
disambiguation smoke is still useful as integration-layer coverage and
is queued as `scanner-disambiguation-playwright-smoke`.
## Verification
- [x] `npm run test:run` — 26 files / 131 tests pass (up from 25/123)
- [x] Mutation test: with `useFocusTrap` import commented out, all 8
tests fail with `ReferenceError`. With import restored, all pass.
## Test plan
- [ ] CI on this PR green
- [ ] Squash + merge
- [ ] Smoke test post-merge: scan a card that triggers disambiguation
in prod and confirm no console errors (the original PR #144 bug
shape)
## Convoy doc
`.convoys/scanner-disambiguation-render-test.md` documents D1 (cover
the early-return branch explicitly), D2 (`fireEvent` not `userEvent`),
D3 (do NOT mock `useFocusTrap` — the missing-hook is exactly what
we're locking), and the two queued follow-ups
(`add-component-render-smoke-pattern`, `scanner-disambiguation-playwright-smoke`).
Co-authored-by: Cursor <cursoragent@cursor.com>
PR #144 (`31da384`, 2026-06-13) shipped a `ReferenceError: useFocusTrap
is not defined` to production because the flat ESLint config did NOT
enable the core `no-undef` rule — only `react/jsx-no-undef` (which
catches undefined JSX components, not plain JS identifier references).
This PR closes that gap, narrowly.
## What changes
- `eslint.config.mjs`: enable `no-undef: 'error'` for source files +
define the ~40 browser / Node / Vitest globals the rule needs.
Hand-curated globals list (rejected pulling in the `globals` npm
package for one config block).
- 3 latent bugs surfaced + fixed (NOT silenced with disables):
| Site | Bug | Fix |
|------|-----|-----|
| `components/CollectionPageView.js:238` | `onClick={toggleFavorite}` — fn defined in `lib/use-collection-view.js:269` (collection-level favorite) but missing from the hook's `return {}` | Added to hook return + component destructure |
| `components/CollectionPageView.js:532` | `onTogglePublic={togglePublic}` — same pattern, fn at line 315 of the hook | Same shape: hook return + destructure |
| `components/ShareModal.js:99` | `fetchInvitedUsers()` scoped inside the useEffect body but called from `handleInvite` outside | Extracted to component scope via `useCallback`; effect dep array updated |
Bugs 1 + 2 broke the "Favorite collection" button and the public-toggle
in the Share modal on the collection-detail page. Bug 3 broke the
"refresh invitee list" path after a successful invite. None had been
flagged because the operator hadn't exercised those exact flows since
the relevant hooks were last refactored.
- `components/ShareModal.js`: also adds an eslint-disable for
`react-hooks/set-state-in-effect` on the moved `fetchInvitedUsers()`
call. Matches the canonical pattern in `pages/profile.js:90` —
async fetch; setState fires post-resolve, not synchronously to the
effect body.
## Why not pull in @eslint/js/recommended wholesale?
The recommended bundle also enables `no-unused-vars`,
`no-prototype-builtins`, `no-empty`, `no-cond-assign`, and ~10 others
— each would generate dozens of pre-existing violations on this
codebase. The right rule-by-rule sweep is the deferred
`adopt-eslint-recommended-set` convoy. This PR is scoped to the one
rule that would have caught PR #144's bug class.
## Test plan
- [x] `npm run lint` — clean (1 pre-existing unrelated warning on
`CollectionsPageView.js`'s `eslint-disable` directive — out of
scope)
- [x] `npm run test:run` — 25 files / 123 tests pass
- [ ] CI on this PR
- [ ] Post-merge: exercise the three formerly-broken paths (favorite a
collection from its detail page; toggle a collection public via
Share modal; invite a user and confirm the invitee list refreshes)
## Convoy doc
`.convoys/enable-no-undef-eslint-rule.md` documents the surfaced bugs,
D1 (no-undef only vs recommended bundle), D2 (hand-curated globals vs
`globals` package), risks, and acceptance.
Co-authored-by: Cursor <cursoragent@cursor.com>
Extract detail page state into useDeckDetail and layout into DeckDetailView;
pages/deck/[id].js is a thin loading/not-found gated composer.
Co-authored-by: Cursor <cursoragent@cursor.com>
Extract list page state into useDecksPage and layout into DecksPageView;
pages/decks.js is a thin auth-gated composer.
Co-authored-by: Cursor <cursoragent@cursor.com>