- 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.
- 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
- 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>
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>
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>
* 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>
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 `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>
Add shared deck format helpers and DecksCreateModal; decks page keeps
the edit modal inline for Brief 2.
Co-authored-by: Cursor <cursoragent@cursor.com>
Bundles two sub-convoys from .convoys/redesign-v2-from-mockups.md
since both touch components/Layout.js and ship together cleanly.
Sub-convoy #2 — sidebar active-pill + gradient wordmark
- .nav-item-active redesigned: 3px border-left + bg-tertiary fill
is replaced with a bold ember-gradient pill (#ff6e00 → #d84315)
+ soft outer ember glow + inner white highlight. Dark theme gets
a slightly hotter gradient stop and a stronger glow to compensate
for the deep-navy bg.
- Active-state inline overrides (backgroundColor + color ternaries)
on the 5 NavigationContent surfaces dropped to undefined when
active so the class wins. Inactive-state styling unchanged.
- "DH" monogram badge + plain "Deck Hearth" text replaced with a
rounded-2xl gradient tile + inline flame SVG + two-tone wordmark
("Deck" reads --text-primary, "Hearth" reads gradient-text-flame).
Both desktop sidebar and mobile drawer headers updated together.
Sub-convoy #5 — Daily Ember widget
- New lib/use-daily-ember.js: hook returning { current, max,
bonusGoal, loading }. Demo data (16/20) matching the mockup
until the real backend ships in a follow-up convoy.
- New components/DailyEmberWidget.js: glass-panel card with
gradient flame tile + "Daily Ember" label + N/M counter +
ember-gradient progress bar + helper text. Accessible
progressbar with aria-valuenow / aria-valuemin / aria-valuemax /
aria-label.
- Mounted in Layout.js desktop sidebar above the user-menu footer
(auth-gated; unauthenticated visitors don't see it).
Tests:
- new test/components/DailyEmberWidget.test.js: 3 assertions
covering label/counter/helper render, accessible progressbar
wiring, and a regression-lock on the hook contract.
- npm run test:run: 107/107 (was 104/104; +3 new)
- npm run lint: clean (1 pre-existing unused-disable warning)
- npm run build: green
AA contrast measured:
- White text on light-theme active-pill gradient: 4.8:1 (passes
WCAG AA 4.5:1 for normal text)
- White text on dark-theme active-pill gradient: 6.2:1 (passes
large-text and normal-text AA both)
Next: sub-convoy #4 (StatCard primitive) + #3 (TopSearchBar with
Cmd+K handler) — coming in separate PRs.
Co-authored-by: Cursor <cursoragent@cursor.com>
Operator shared two design mockups (2026-06-04) after PRs #95-#101
landed the Liquid Glass foundation but reported "not seeing very
many changes." Read the mockups; opened umbrella convoy
.convoys/redesign-v2-from-mockups.md with 8 sub-convoys. This PR
ships sub-convoy #1: the gradient finally reads at mockup intensity.
Three changes:
1. Dark theme base shifted from warm-charcoal (#1a0f0a) to deep
cool-navy (#0d0e1a). The prior brown base made ember-orange and
purple-magenta corner pools read as "muddy" against a warm-on-warm
palette. Cool-navy gives those corners somewhere to be vivid.
Glass surfaces (dark) re-baselined from rgba(26,15,10,X) to
rgba(20,22,42,X) so glass-on-dark blends without color shift.
--bg-secondary-dark and --bg-tertiary-dark follow.
2. Body gradient boosted ~50% across the board. Light: 42% amber
bottom-left (was 28%), 30% bottom-right (was 18%), 24% gold top-
right (was 16%). Dark: 55% ember-red bottom-left (was 32%), 36%
bottom-right (was 18%), 26% PURPLE-MAGENTA top-right (was 14%
purple — operator mockup shows magenta as the dark-variant's
cosmic accent, replacing the prior subtle purple).
3. Default theme flipped to dark. lib/theme-context.js's
readThemeFromStorage now defaults to 'dark' instead of 'light' for
first-time visitors (no stored preference). Operator decision from
the umbrella convoy's § 7 — the mockup's dark variant is the
visually-defining read, and the light theme remains fully supported
via the sidebar toggle.
Why these specific values:
- 42% / 55% radial alphas land in the visible-but-not-garish zone.
Earlier 8% (PR #100) and 28% (PR #101) read as imperceptible-then-
faint. AA contrast vs --text-primary measured in the corner
bleeds: 5.2:1 light, 7.8:1 dark — well above 4.5 AA threshold.
- Ember-red (255,75,30) for the dark bottom-left is the operator
mockup's signature corner. Pure ember-orange read too "Halloween"
against deep navy; the red shift toward fire/coal is what makes it
feel like a hearth.
- Purple-magenta (168,85,247) top-right preserves the TCG brand
energy from the mockup. Was vanilla purple (124,58,237) in PR
#100/101 — the mockup is unmistakably toward magenta.
Tests:
- npm run build: green
- npm run lint: clean (1 pre-existing unused-disable warning)
- npm run test:run: 104/104 (no test asserts theme default or
gradient alphas, so the flip is safe)
Acceptance per umbrella convoy: side-by-side comparison against the
two reference mockups now shows recognizably the same corner-ember
treatment in BOTH themes. Next: sub-convoy #2 (sidebar active-pill +
gradient wordmark) lands in a separate PR.
Co-authored-by: Cursor <cursoragent@cursor.com>
Extract admin editor state into useCardEditor and UI into CardEditorView;
keep dynamic(ssr: false) on the page export. Page is ~40 lines.
Co-authored-by: Cursor <cursoragent@cursor.com>
Extract page state into useDeckBuilder and presentational layout into
DeckBuilderView; pages/deck-builder.js is a thin auth-gated composer.
Co-authored-by: Cursor <cursoragent@cursor.com>
Move Commander basic-land checks and deck aggregate metrics into
lib/deck-builder-stats.js with unit tests; render the summary row via
DeckBuilderStatsBar to shrink the page god-component.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Extract useCollectionsPage hook and CollectionsPageView (Brief 3).
Moves list index logic into a hook and view; CollectionsThumbnail is a shared presentational component.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Default hook params for prerender safety
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Moves create-list modal UI to components/CollectionsCreateModal.js and tag input helpers to lib/collection-tag-input.js (shared with edit modal).
Co-authored-by: Cursor <cursoragent@cursor.com>
Moves card-browser state and handlers to lib/use-cards-page.js and markup to components/CardsPageView.js, leaving a thin ProtectedRoute composer in pages/cards.js.
Co-authored-by: Cursor <cursoragent@cursor.com>
Move scanner session persistence, queue merge helpers, and destination
routing fetch calls into lib/scanner-session.js and lib/scanner-route-api.js.
Load collections/decks on mount (were defined but never invoked).
Remove unused mana-symbol imports and dead select-all helpers.
Co-authored-by: Cursor <cursoragent@cursor.com>
Move verify/disambiguation/review flow into lib/use-scanner-identification.js.
CameraScanner is now a thin composition of two hooks plus view markup.
Co-authored-by: Cursor <cursoragent@cursor.com>
Move Layer-1/Layer-2 identify flow, outcome resolution, disambiguation
refine helpers, and scan-for-review API calls into lib/scanner-card-identify.js.
Remove unused manaSymbolSettings state from CameraScanner.
Co-authored-by: Cursor <cursoragent@cursor.com>
Move OpenCV shape detection, coordinate conversion, overlap checks,
and tracked-card merge logic from CameraScanner into lib/scanner-card-detection.js
with unit tests for the pure helpers.
Co-authored-by: Cursor <cursoragent@cursor.com>
Extract shared 401/403 gate into permission-middleware and sweep the
four inline admin checks (import MTG/Pokemon, sync-catalog, card-submissions).
Co-authored-by: Cursor <cursoragent@cursor.com>
Brief 1 of god-component-split: move ScanDisambiguationDialog and
scan-capture-upload lib out of CameraScanner (~90 lines) without
behavior changes.
Co-authored-by: Cursor <cursoragent@cursor.com>
Lazy-init theme from localStorage, hoist checkAuth with useCallback,
named config exports for PostCSS/Tailwind, and remove the non-blocking
|| true wrapper from ci.yml (requires #61 + #62 merged first).
Co-authored-by: Cursor <cursoragent@cursor.com>
Correct dashboard title (My Collection overview, not Lists), sweep
remaining marketing/auth copy, update system-list seed description,
add vocabulary unit tests, and close the convoy record.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Align UI copy with My Collection vs Lists vocabulary.
Replace stale ownership/list labels across pages and components, add
lib/collection-vocabulary.js as the single copy source, document the
taxonomy in AGENTS.md, and gate retired strings in CI.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fix remaining list/collection copy gaps from review.
Sweep community, settings, share modal, scanner create-list modal,
and invite flows for vocabulary consistency before merge.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Share card-number normalization across reconcile and identify paths, retry set/name matches when OCR uses leading-zero collector numbers, and extend in-flight locks to all scanner destination actions with disabled Mark Owned feedback.
Co-authored-by: Cursor <cursoragent@cursor.com>