- 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.
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>
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>
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>
Closes the operator caveat from the `drop-public-setup` convoy: deployed
envs that ran `npm run setup-db` BEFORE `ff80753` (2026-05-22) still
carry the historical `admin123` bcrypt hash. The seed is idempotent
(`ON CONFLICT (email) DO NOTHING`), so re-running setup-db is a no-op
on existing rows.
## Design — D1: which option from the 3-option menu?
| Option | Picked? | Why |
|---|---|---|
| A. Close as no-op (defer rotation to manual app login) | No | Leaves a real-world residue if any pre-drop-public-setup env still exists — and an audit is harder than just shipping the script. |
| B. One-shot parameterized rotation script | **Yes** | Tightly scoped (~120 lines). Audit-trail-preserving (`updated_at` bump). Reusable for future rotations. No new auth surface in the app. |
| C. First-login forced password reset flow in the app | No | Right product answer, but heavier scope (new route, new flag column, UI work). Deferred as the queued `force-admin-password-reset-flow` convoy. |
## Script shape
`scripts/rotate-admin-password.js`:
- Reads `POSTGRES_URL` + `ADMIN_NEW_PASSWORD` from env (or `.env.local`).
- Optional `ADMIN_EMAIL` override; defaults to `admin@deckhearth.com`.
Pass `admin@tcgvault.com` for envs that pre-date `pick-a-name`
(squash `9abbab6`, 2026-05-24).
- Fail-loud-exits BEFORE opening any DB connection if:
- `POSTGRES_URL` is unset
- `ADMIN_NEW_PASSWORD` is unset or empty
- `ADMIN_NEW_PASSWORD` is shorter than 12 chars
- Validates the target row EXISTS AND has `role = 'admin'` before
touching it. Refuses to rotate non-admin rows even if `ADMIN_EMAIL`
points at one. Refuses to rotate when multiple rows match (impossible
given the UNIQUE(email) constraint, but checked anyway).
- Hashes with bcryptjs at 12 rounds — same as `setup-neon-db.js`.
- After UPDATE, re-fetches the row and runs `bcrypt.compare(newPassword,
row.password_hash)`; exits non-zero if the compare fails (extremely
unlikely, but catches silent UPDATE failures).
- NEVER echoes the password to stdout / stderr / shell history. The
only output is the row id, email, role, and updated_at.
Same import shape as the existing `scripts/migrations/2026-05-24-rename-admin-email.js`
(ESM, `dotenv.config({ path: '.env.local' })`, `import { neon } from
'@neondatabase/serverless'`, tagged-template SQL) — keeps the "11
scripts/* using neon() directly" graveyard from gaining new patterns;
fits the `purge-neondatabase-serverless-fully` follow-up convoy's
existing audit shape.
## Out of scope
- Sibling test users (alice / bob in `scripts/create-test-users.js`) —
dev fixtures, not real auth surfaces. Documented inline + in
AGENTS.md Gotcha #4.
- First-login forced password reset flow — deferred as the queued
`force-admin-password-reset-flow` convoy (it's the right product
answer, but heavier scope than this hygiene PR).
- Email rotation (already handled by
`scripts/migrations/2026-05-24-rename-admin-email.js`).
## Test plan
- [x] `node --check scripts/rotate-admin-password.js` — syntax OK
- [x] `npm run lint` — clean (1 pre-existing unrelated warning)
- [x] `npm run test:run` — 118 tests pass
- [ ] CI on this PR
- [ ] Operator-side smoke test (NOT covered by CI):
- Set `ADMIN_NEW_PASSWORD=test-rotation-12chars` against a throwaway
Neon branch DB, run the script, log in via the app with the new
password, run the script again with a different password, log in
again. Skip if there's no convenient throwaway DB.
Co-authored-by: Cursor <cursoragent@cursor.com>
Removes `continue-on-error: true` from `.github/workflows/visual-diff.yml`'s
`Capture screenshots (PR)` step. Visual drift is now a real merge gate
on UI-touching PRs.
Brief 2/2 of the `harden-visual-diff-gate` convoy. PR #138 shipped the
seed workflow (Brief 1); PR #139 (`54495fe`) landed the fresh Linux
baseline regenerated against post-glass-redesign main on CT 111. With a
known-good baseline committed, the gate can flip without false-failing
every UI-touching PR.
## What changes
- `.github/workflows/visual-diff.yml` — drop the
`continue-on-error: true` flag; add an inline rationale block linking
to the convoy + the operator runbook for both intentional changes
(dispatch seed workflow → manually open PR → merge → re-run) and
unintentional regressions (inspect artifact diff → fix → push).
- `.github/workflows/ci.yml` — add 9th `forbidden-patterns` check that
greps `visual-diff.yml` for `^\s*continue-on-error:\s*true` and fails
the build if it returns. Risk #3 of the convoy made concrete: prevents
silent re-introduction via template revert. Scoped narrowly to that
one file; other workflows (`seed-visual-baselines.yml`'s PR-open
step, etc.) legitimately use the flag. Job name bumped from
"Forbidden patterns (8 checks)" → "(9 checks)". All `Check N/8`
group labels renumbered to `N/9`.
- `AGENTS.md` — § Testing § Visual baselines rewritten to drop the
"Known staleness as of 2026-06-12" callout (resolved by PR #139);
§ Testing § Screenshot diff rewritten to lead with "hard merge gate",
document the intentional-change runbook, reference the new ci.yml
check, and explicitly mention the org-setting caveat for the seed
workflow's auto-PR step.
- `tests/visual/homepage.spec.ts` — module docblock rewritten to match
the AGENTS.md runbook: drops the "advisory, not gating" language;
promotes the seed-visual-baselines workflow as the primary
re-seeding path; demotes the Playwright Docker image to the offline
fallback.
- `.github/workflows/seed-visual-baselines.yml` — patches the
`peter-evans/create-pull-request@v6` PR-open failure case discovered
during Brief 1's first dispatch (run 27454132468). The PR-open step
is now `continue-on-error: true` (narrowly scoped, with an inline
rationale callout distinguishing it from the just-removed
`visual-diff.yml` flag — that one silently hid real UI regressions;
this one fronts a known org-level "Allow GitHub Actions to create
and approve pull requests" limitation with a loud failure notice).
New steps disambiguate the three possible outcomes (no-changes /
pr-opened / branch-pushed-pr-blocked) via a `git ls-remote` check on
the bot branch and exit non-zero on the blocked-PR case so the
workflow run shows red and the operator gets the exact `gh pr create`
command in the run logs.
- `.convoys/harden-visual-diff-gate.md` — status: shipping; Step 2
marked SHIPPED; Decision D4 ratified (chose option C: accept org
setting, document manual `gh pr create` fallback). Inline links to
PR #139 + PR #140.
## Test plan
- [x] `npm run lint` — clean (1 pre-existing unrelated warning)
- [x] `npm run test:run` — 24 files / 118 tests pass
- [ ] CI on this PR: 9th forbidden-patterns check passes; visual-diff
job passes against the fresh baseline; convoy-metrics-gate passes
(2 new rows added by this commit)
- [ ] After merge: smoke test the 9th check by opening a throwaway PR
that re-adds `continue-on-error: true` to `visual-diff.yml`; confirm
it red-X's. (Skip if confident in the grep.)
## Convoy state
- Brief 1: SHIPPED (PR #138, `c100c5f`, 2026-06-13)
- Baseline refresh: SHIPPED (PR #139, `54495fe`, 2026-06-13)
- Brief 2 (this PR): shipping
- Convoy closeout: this PR's merge
Co-authored-by: Cursor <cursoragent@cursor.com>
Adds a workflow_dispatch-triggered job on the self-hosted axiom runner
that captures fresh `tests/visual/__screenshots__/*.png` against a
caller-provided URL and opens a `chore(visual): refresh baselines from
<url>` PR via peter-evans/create-pull-request@v6.
This is brief 1 of 2 of the harden-visual-diff-gate convoy. The
workflow exists but is not invoked by this PR — operator dispatches via
the GitHub UI or `gh workflow run seed-visual-baselines.yml` once they
want a fresh baseline against post-glass-redesign main.
Brief 2 (flip continue-on-error: true off visual-diff.yml, add 9th
forbidden-patterns check) is unblocked once a fresh baseline lands via
this workflow's auto-PR.
Convoy decision D1 ratified: Option B (workflow_dispatch + auto-PR)
chosen over Option A (ad-hoc SSH-into-CT-111 + manual commit). The
workflow gives the baseline regeneration a reviewable Git-native shape;
the auto-PR carries a checklist for visual sanity-check before merge.
Workflow shape:
- Inputs: base_url (required, no default to avoid wrong-target
accidents), reason (optional, used in PR body).
- Permissions: contents: write + pull-requests: write — sufficient for
default GITHUB_TOKEN; no PAT needed.
- Caches: shares the node_modules + Playwright browser caches with
visual-diff.yml so the byte-equivalence guarantee holds without
cache miss overhead.
- Idempotent: peter-evans/create-pull-request short-circuits to a
::notice:: annotation if the captures match the existing committed
baselines (no PR opened).
Metrics: logged role-conductor + role-architect + role-implementer
events for this convoy in .convoys/.metrics.jsonl. Satisfies the
convoy-metrics-gate (PR #134, 9eef8d9) that fires on convoy:-titled
PRs requiring at least one new metrics row.
Co-authored-by: Cursor <cursoragent@cursor.com>
PR #58 (83a358b, 2026-06-02) committed the first Linux visual baseline,
resolving the seed-visual-baselines-on-linux convoy. But the cleanup
sweep across docs that referenced the convoy as "queued / not yet done"
never landed. Three files still describe the world as if PR #58 hadn't
happened, which confuses any agent reading the codebase to understand
the visual-diff pipeline:
1. tests/visual/homepage.spec.ts module docblock — described "FIRST
RUN (no committed baseline yet)" and "SEEDING THE BASELINE
(post-merge follow-up)" as the active state.
2. playwright.config.js snapshotPathTemplate comment — said
"Per Decision 4, we don't commit baselines this convoy" and
pointed at the queued seed convoy as future work.
3. AGENTS.md § Testing § Visual baselines + § CI behavior §
Screenshot diff — claimed "none committed yet" and that the first
visual-diff run "will fail at the test step because no baseline
exists yet."
Sweeping all three to describe the current reality. The Mac-vs-Linux
platform footgun (snapshotPathTemplate has no {platform} token) is
still live, so that warning stays — just rephrased from "don't have
baselines yet" to "don't regenerate them on a Mac."
Also surfaces a separate finding the seed work left behind:
visual-diff.yml's screenshot capture step still carries
`continue-on-error: true`, making the diff advisory rather than gating.
Flipping it requires re-seeding the baseline against post-glass-redesign
main first (the PR #58 baseline predates unify-glass-panel-surfaces +
cleanup-card-item-list-and-share-modal-palette +
migrate-button-input-mobilenav-to-glass-primitive). New convoy seed at
.convoys/harden-visual-diff-gate.md captures the two-step shape (re-seed
baseline, then flip the gate) plus the recommended workflow_dispatch
approach for repeatable re-seeding on CT 111.
No code behavior changes. Documentation + .md convoy seed only.
Co-authored-by: Cursor <cursoragent@cursor.com>
The pipeline metrics shim worked correctly through Jun 4 (65 role events,
25 convoys recorded) but silently stopped capturing thereafter. 8 convoy
PRs merged Jun 5-11 (#126-#133) with zero rows logged to
.convoys/.metrics.jsonl. The roles' Metrics sections clearly instruct the
agents to call scripts/log-convoy-event.sh after every hand-off, but the
instruction was skipped during multitask audit fan-outs and longer
sessions where the Metrics section fell out of working context.
Two changes to prevent the silent gap from recurring:
1. .gitignore: drop the `.convoys/.metrics.jsonl` ignore line. Convoy
telemetry now committed in git so gaps surface in PR review. The
script comment was already clear that events contain metadata only —
no code, no prompts.
2. .github/workflows/convoy-metrics-gate.yml: new CI gate that fails any
PR titled `convoy:` if no rows were added to .convoys/.metrics.jsonl
between base and head. Bypass with the `skip-metrics` label + a
documented reason. Non-convoy PRs are no-op.
Also: commits the existing 65-event history to git so future analysis
(and the §10 measurement protocol in agent-pipeline's v0.4 plan) has a
stable baseline to compare against.
Runs on self-hosted axiom runner to inherit the lower GH minutes cost
the Jun 5-11 work already migrated to (PR #132).
Co-authored-by: Cursor <cursoragent@cursor.com>
* convoy: forbidden-pattern gate + docs (briefs 3+4)
Closes out the migrate-ci-to-self-hosted convoy with the two
defensive follow-ups Brief 1+2 (PR #132) intentionally deferred.
Brief 3 — Check 8 of `forbidden-patterns` in ci.yml. Greps
`.github/workflows/` for `runs-on: ubuntu-latest` and fails unless
the match is in the documented allowlist (currently
`agent-context-drift.yml` only, per Decision D4). Self-tested
locally against the post-migration tree: 0 violations. Renames the
job from "Forbidden patterns (7 checks)" → "(8 checks)" and
normalizes the older "Check N/6" labels to "N/8" for consistency
(the inherited mix of `/6` and `/7` was a known cosmetic from the
unify-glass-panel-surfaces convoy).
Brief 4 — AGENTS.md § 6 and § 7 updates:
- § 6 "CI behavior": Playwright smoke runtime range updated to
cover post-migration cold vs. warm cache (was a stale 59s figure
from pre-migration ubuntu-latest).
- § 6 new top-level bullet "Self-hosted runner pool" alongside
"CI minute optimizations" — covers where runners live, where
caches are bind-mounted on CT 111, the Postgres rewire on
CT 102, and the agent-context-drift.yml exemption + how Check 8
enforces it.
- § 7 new bullet for the operational story: PAT rotation cadence
+ the D5 one-line `sed` revert path for when axiom is offline
mid-PR-storm. Cross-references the axiom-server CT 111 README
and the Beszel down alert.
Convoy doc — status flipped queued → shipped, shipped_in lists
both PRs, and follow_ups makes the cleanup-stale-ci-runs-cron +
seed-visual-baselines-on-linux items machine-greppable.
Co-authored-by: Cursor <cursoragent@cursor.com>
* chore: drop placeholder comment now PR #133 number is known
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* convoy: migrate CI to self-hosted axiom runners (briefs 1+2)
Moves 4 of 5 GitHub Actions workflows from `ubuntu-latest` to the new
`stwl-labs` org-level self-hosted pool (CT 111 axiom-runner-1..4) and
rewires the `migrate` job to use CT 102's shared Postgres via per-run
databases.
Changes:
- ci.yml: lint, schema-map-fresh, forbidden-patterns, migrate, test ->
`[self-hosted, axiom]`. migrate job drops `services.postgres` (saved
~30s/run of image pull) and switches to `HOMELAB_CI_POSTGRES_BASE_URL`
secret + per-run DB (`ci_run_<run_id>_<run_attempt>`) with `always()`
cleanup so failed migrations don't leak DBs.
- preview-smoke.yml: gate + smoke -> self-hosted. Playwright browser
cache lives under /opt/appdata/gha-runner/shared-cache/playwright on
the host bind mount; first PR primes it, subsequent runs reuse.
- visual-diff.yml: gate + visual -> self-hosted (same Playwright cache).
- pr-health-rollup.yml: rollup -> self-hosted.
- agent-context-drift.yml: deliberately LEFT on ubuntu-latest (D4 in
convoy doc). Weekly cron stays GitHub-hosted so it runs even when
axiom is down.
Why on this side and not the runner side:
- migrate adds an explicit `sudo apt-get install -y postgresql-client`
step (~10s, amortized via apt-cache survival). The runner image
doesn't ship psql; baking it in would require a custom image and
doesn't earn its keep for one job.
Repo prereqs (set before this PR opens):
- `HOMELAB_CI_POSTGRES_BASE_URL` repo secret set (value pattern:
`postgres://deckhearth_ci:<pw>@192.168.68.102:5432`)
- `deckhearth_ci` Postgres user created on CT 102 with CREATEDB,
no superuser
- stwl-labs org Actions settings: "Require approval for all outside
collaborators" + runner group rejects public repos
- 4 runners online: `axiom-runner-1..4`, status Idle
Follow-ups (per convoy):
- Brief 3: forbidden-pattern gate to catch `runs-on: ubuntu-latest`
re-introduction outside the agent-context-drift allowlist
- Brief 4: AGENTS.md updates + 1-line revert path (D5)
- Weekly cron on CT 102 to GC any `ci_run_*` DBs older than 7d
(Risk #4 mitigation)
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(migrate): use PGHOST/PGUSER/PGPASSWORD instead of URL secret
First Brief 1+2 validation run failed on the migrate job with
`psql: invalid option -- '/'` despite the secret being set correctly
and a direct CT-111 → CT-102 psql connection working fine. The
URL-parse path in `psql "$PGBASE/postgres"` was the fragile bit.
Splitting the connection into discrete `PG*` env vars (which psql
picks up automatically) sidesteps URL parsing entirely. The
`HOMELAB_CI_POSTGRES_BASE_URL` repo secret is now
`HOMELAB_CI_POSTGRES_PASSWORD` — password only — and the workflow
hardcodes the (non-sensitive) host/port/user. `node-pg-migrate`
still reads `POSTGRES_URL` from `.env.local`, so we assemble that
URL inline for it; the runner is ephemeral so the leaked-to-disk
password is bounded to one job.
Convoy doc updated to reflect the shipped approach + lesson learned
in prerequisites.
Co-authored-by: Cursor <cursoragent@cursor.com>
* ci: trigger vercel preview after stwl-labs reauth
Co-authored-by: Cursor <cursoragent@cursor.com>
* ci: re-test playwright after vercel project rebind
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Closes the migrate-button-input-mobilenav-to-glass-primitive convoy
(seeded by PR #127). All 3 residual handrolled var(--glass-surface-*)
inline-style usages migrated to either purpose-built utility classes
or the <GlassSurface> primitive. CI allowlist reduced from 6 entries
to 3 (chrome only).
Architect decisions (D1-D3, ratified):
D1 — Button.secondary → new .btn-glass-secondary utility class.
NOT <GlassSurface>: the primitive sets `background` inline via
composedStyle, which CSS :hover rules can't override without
!important. The new class composes the same high-tint
gradient-border that .glass-panel-strong uses, plus a pure-CSS
:hover swap (high → mid fill on the padding-box layer).
Identical visual contract; the hover behavior is now driven by
CSS, not Tailwind's `hover:bg-[var(...)]` arbitrary class.
D2 — Input → new .glass-input utility class.
NOT <GlassSurface as="input"> and NOT <GlassSurface as="div"> wrap.
Reason: <GlassSurface>'s gradient-border trick requires
`border: 1px solid transparent` to expose the border-box layers,
which conflicts with <Input>'s conditional error-state
`1px solid #dc2626` red border. The new class adopts only the
tint + blur layer; the visible 1px border + focus ring stay in
JSX (class-controlled, not inline). Same visual contract as
before for both normal AND error states.
D3 — MobileNavigation → <GlassSurface as="div" tint="mid" blur="mid"
rim="subtle" elevation="flat" cornerLights="chrome">.
NOT .page-header-glass (the seed's first recommendation):
.page-header-glass uses var(--glass-surface-high) (wrong tint —
MobileNav uses mid) and sets a bottom-border separator (wrong
for a fixed-bottom-nav where the bottom edge is the viewport
edge). <GlassSurface> is the better fit AND brings the
chrome-tier corner-light bleed that the parent convoy is
unifying across all chrome surfaces.
Implementation choice — single PR (not 3 parallel briefs):
The seed recommended 3 small parallel-safe briefs (one per file).
D1 and D2 both need styles/globals.css to gain new utility
classes, so those 2 changes can't run truly in parallel without
merge conflicts. Single PR is faster, simpler to review
end-to-end, and the natural shape for a 2-3 hour convoy with
tightly-coupled artifacts.
Files changed (4):
styles/globals.css (+50 / -1):
- Adds .btn-glass-secondary (with :hover variant) — D1.
- Adds .glass-input — D2.
- Both classes documented inline with architect-decision references.
components/ui/Button.js (+2 / -10):
- Replaces inline variantStyle + Tailwind hover arbitrary class
for `variant === 'secondary'` with `variantClass =
'btn-glass-secondary font-medium'`. variantStyle now `{}`.
- Other variants (primary, danger, ghost) UNCHANGED.
components/ui/Input.js (+1 / -7):
- Adds `glass-input` to the className list.
- Removes inline `background` + `backdropFilter` +
`WebkitBackdropFilter` from the input's style block.
- Conditional `border: inputBorder` stays in JSX (error swap).
- All other props/behavior preserved.
components/MobileNavigation.js (+11 / -8):
- Adds `import { GlassSurface } from './ui'`.
- Replaces the inline-styled backdrop <div> with
<GlassSurface as="div" ...>. Same className ("absolute inset-0"),
same visible behavior, plus the chrome-tier corner-light bleed.
- Comment block updated to reference the convoy + decision.
.github/workflows/ci.yml (+8 / -22):
- forbidden-patterns Check 7/7 GLASS_ALLOWLIST reduced from 6
entries to 3 (chrome only). The TODO comments referencing this
convoy are deleted (work is done).
.convoys/migrate-button-input-mobilenav-to-glass-primitive.md
(+74 / -3):
- status: queued → closed, closed: 2026-06-05, prs: [131].
- Architect ratifications D1-D3 written into front-matter docs.
- Closeout checklist with all acceptance criteria checked.
- Note that parent convoy unify-glass-panel-surfaces is now
fully closed — no residual handrolled glass-surface usage
outside the 3 chrome blocks.
Verification:
- POSITIVE TEST: post-migration grep with the reduced 3-entry
allowlist returns 0 violations. ✅
- grep on raw files: only Layout.js + TopSearchBar.js still match
the literal regex (GlassSurface.js uses template literal which
doesn't match — intentional, allowlist is forward-compat).
- YAML parses (python3 yaml.safe_load).
- npm run lint passes (1 pre-existing unrelated warning).
- npm run test:run: 118/118 tests pass.
Visual diff to be verified by reviewer in light + dark mode for:
- <Button variant="secondary"> default + hover state.
- <Input> default + error state.
- Mobile bottom-nav backdrop.
Co-authored-by: Cursor <cursoragent@cursor.com>
Bookkeeping. Both convoys completed yesterday but the seed-file
front-matter still reads `status: open` / `closing`. Flip to
`closed` and record the PR list for posterity:
- unify-glass-panel-surfaces — PRs #120, #121, #122, #123, #124,
#125, #127. queued_followup field preserved.
- cleanup-card-item-list-and-share-modal-palette — PRs #128, #129.
Doc-only PR — paths-ignore in ci.yml + preview-smoke.yml means
this triggers ZERO GitHub Actions jobs (per the slash-ci-minutes
convoy, PR #126).
Co-authored-by: Cursor <cursoragent@cursor.com>
Brief 7 (final brief) of unify-glass-panel-surfaces convoy. Adds the
regression gate that prevents reintroduction of bespoke
var(--glass-surface-low|mid|high) inline styles outside the
documented allowlist.
IMPLEMENTATION DEVIATION FROM BRIEF (DOCUMENTED):
Brief 7 was authored before PR #126 (slash-ci-minutes convoy)
consolidated the 6 grep-only forbidden-* jobs into a single
forbidden-patterns job with sequential ::group:: sections. Adding
Brief 7 as a standalone forbidden-bespoke-glass-surface job (the
brief's verbatim shape) would partially undo PR #126's checkout
amortization win. Instead, this PR adds the check as Check 7/7
inside the existing forbidden-patterns job — semantics, allowlist,
and grep pattern are exactly as Brief 7 specifies; only the wrapper
changes. Job display name updated: "Forbidden patterns (6 checks)"
→ "Forbidden patterns (7 checks)".
ALLOWLIST EXPANSION (DOCUMENTED):
Brief 7's planned 3-entry allowlist (the 3 chrome blocks) turned out
to undercount the residual surface area. Three additional files
still handroll their own var(--glass-surface-*) inline styles:
- components/ui/Button.js (secondary variant)
- components/ui/Input.js (input wrapper)
- components/MobileNavigation.js (bottom-nav background)
Per Brief 7's own note ("If you need to add a fourth allowlist
entry, that's a design-system decision — open a new convoy"), the
right call is to ship the gate NOW with a 6-entry allowlist
(3 chrome + 3 pending-migration) and track the cleanup in a
follow-up. This PR therefore also seeds
`.convoys/migrate-button-input-mobilenav-to-glass-primitive.md`
with the migration plan, open questions for the architect, and
acceptance criteria. The 3 pending entries are tagged with inline
`# TODO:` comments in ci.yml referencing the follow-up convoy.
Local verification (per Brief 7's pre-merge negative test):
- Positive test: grep on clean main → 0 violations outside the
6-entry allowlist. ✅
- Negative test: injected a scratch
`const scratch = { background: 'var(--glass-surface-low)' }` line
at EOF of pages/profile.js; grep correctly flagged it. ✅
- Revert verified: removing the scratch line returns the grep to
0 violations. ✅
The scratch change was NOT committed (per Brief 7's instructions).
Convoy closeout:
- `.convoys/unify-glass-panel-surfaces.md` status moved from
`open` to `closing`; queued_followup field names the new convoy.
The convoy lands fully when this PR merges.
Verification:
- npm run lint passes (1 pre-existing unrelated warning).
- npm run test:run: 118/118 tests pass.
- YAML parses (python3 yaml.safe_load).
Files:
- .github/workflows/ci.yml: +~70 lines (Check 7/7 step + final-exit
copy edit).
- .convoys/migrate-button-input-mobilenav-to-glass-primitive.md:
new file, 113 lines.
- .convoys/unify-glass-panel-surfaces.md: +2 lines (status +
queued_followup fields).
Co-authored-by: Cursor <cursoragent@cursor.com>
* docs(convoys): seed unify-glass-panel-surfaces + cleanup-card-item-list-and-share-modal-palette
Two convoy seeds opened as follow-ups to the 2026-06-04 design pass
(#116 corner-border-light → #117 site-wide sweep → #118 card vibrancy
reduction). Both were called out in #117's PR body as deferred and are
now formally tracked.
## unify-glass-panel-surfaces
Migrates remaining panel-shaped surfaces to the gradient-border
corner-light treatment so the app shares one surface vocabulary.
The audit's key insight: `<GlassSurface>` (`components/ui/GlassSurface.js`)
predates the corner-light pattern. Because `<Modal>`, `<StatCard>`,
and the landing-page feature/collection cards all delegate to it,
upgrading the primitive cascades to ~10 visible surfaces at once.
7 briefs, multitask-parallel after Brief 1 lands:
1. `<GlassSurface>` primitive upgrade — BLOCKING for 3, 4
2. Auth form cards (login.js, signup.js)
3. Floating popovers (mobile drawer, sidebar profile dropdown,
UserMenu dropdown)
4. BulkSelectionToolbar (currently `bg-white border-gray-200` —
invisible in dark mode)
5. `.card`-class consumers (4 pages); decision to ratify whether
to retire `.card` entirely or keep as documented opaque fallback
6. Landing nav bar — wrong pattern; should use existing
`.page-header-glass` class
7. `forbidden-bespoke-glass-surface` CI grep gate — prevents
regression after the migration ships
## cleanup-card-item-list-and-share-modal-palette
Targeted palette cleanup for two files whose interiors weren't
addressed in #117:
1. `CardItem.js` list-mode (L183–284) — entirely hardcoded
Tailwind palette (`bg-purple-50`, `border-gray-200`, `text-gray-{500-900}`,
`bg-blue-100 text-blue-800` etc.); unreadable / off-brand in dark mode.
2. `ShareModal.js` interior rows — purple avatar circles, gray-50
permission row (invisible in dark mode), blue-600 Copy-link button,
gray text labels.
Token-only swap. 2 parallel briefs, no architect / IA / UX needed
(no design decisions — palette to design tokens).
## Sequencing note
The two convoys are independent and can run in parallel. The audit
agent's recommended sequencing (Brief 1 of `unify-glass-panel-surfaces`
first) is encoded in the multitask `slice_dependencies` blocks.
Co-authored-by: Cursor <cursoragent@cursor.com>
* docs(convoys): architect plan + 7 briefs for unify-glass-panel-surfaces
Appends the ## Architecture section to .convoys/unify-glass-panel-surfaces.md
ratifying the 4 open Decisions, and writes 7 implementer-brief files under
.convoys/unify-glass-panel-surfaces/.
Decisions ratified:
D1. <GlassSurface> grows a cornerLights='subtle'|'chrome'|'none' prop,
default 'subtle' (4-layer gradient-border per PR #118 recipe).
D2. Retire .card entirely; migrate all 8 consumers to .glass-panel.
D3. Mobile drawer uses subtle tier (.glass-panel-strong).
D4. CI gate scopes to JSX inline-style usage only; 3-entry allowlist
for documented chrome exceptions.
Briefs (1-7, all <100 LOC each, disjoint files):
1. Upgrade <GlassSurface> primitive (no deps, blocks 3+4)
2. Migrate auth form cards (parallel-safe)
3. Migrate floating popovers (sidebar dropdown, mobile drawer,
TopSearchBar UserMenu)
4. Migrate BulkSelectionToolbar + interior token sweep
5. Retire .card class; migrate 8 consumers
6. Migrate landing nav bar to .page-header-glass
7. forbidden-bespoke-glass-surface CI gate (runs LAST)
Boot-the-brief check: all 4-layer recipes verified against post-PR-#118
styles/globals.css; all box-shadow preservation chains verified against
current Layout.js + TopSearchBar.js; .card deletion verified clean
(single rule at L729-733); CI gate shape modeled on existing
forbidden-modal-shell-without-primitive job.
Architecture is read-only output — no application code touched.
Awaiting human gate 1 (plan approval) before implementers run.
Co-authored-by: Cursor <cursoragent@cursor.com>
* docs(convoys): fix Brief 3 TopSearchBar UserMenu elevation token
Self-review recheck against current TopSearchBar.js (L211-222) found
that the UserMenu dropdown uses var(--elevation-pronounced), not
var(--elevation-ambient) as Brief 3's initial sample showed. Brief 3
now documents the verbatim shape (pronounced + role="menu" +
aria-label + overflow-hidden) and the convoy's risk-list note is
updated to reflect all three popovers' verbatim chains.
No semantic change to the plan; just an accuracy fix the
implementer of Brief 3 needs to ship the right code on the first try.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
All 8 sub-convoys from the redesign-v2 epic landed across 5 PRs
(#102 → #107). Updates the umbrella convoy with:
- frontmatter status: open → shipped, shipped: 2026-06-04
- § 9: table mapping each sub-convoy to its PR + squash commit
+ brief one-liner so the next agent reading .convoys/ has a
complete audit trail
- § 9: 9 queued follow-up convoys explicitly captured (Daily
Ember backend, rarity aggregation, wishlist feature, user
activity feed, market data + chart library decision, featured
collection filters, federated command palette, page-header-
glass CSS cleanup, top-bar user dropdown). Each names the
upstream call site (component / hook / TODO comment) so the
follow-up author has a starting line.
Zero code changes. Lint clean. No tests touched (docs-only).
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>
Follow-up PR to #95 (Liquid Glass foundation + primitives + Layout shell)
that closes out the remaining sub-convoy briefs in a single sweep.
Operator-instructed scope: "finish off the design changes."
After this PR, **all 8 Liquid Glass sub-convoys are MERGED to main**;
the deferred-from-#5 `fix-card3d-state` convoy is dropped (its target,
`components/Card3D.js`, turned out to be dead code).
## #2 Brief 2 — Remaining 8 modals migrated to <Modal> primitive
- `CollectionsSuccessModal.js` — wrap in <Modal hideCloseButton>; 2 Buttons.
- `CollectionsEditModal.js` — full <Modal> + <Input> + <Button> rewrite (4 fields, tag chip section, public-toggle preserved, 2 footer Buttons).
- `CollectionEditModal.js` — same pattern as above (4 fields + public-toggle + 2 Buttons).
- `CardDetailDeckModal.js` — <Modal> + native select (Select primitive not in scope) + 2 Buttons; sweep `gradient-bg-purple` → `<Button variant="primary">`.
- `UploadImageModal.js` — <Modal> + token-driven URL/file tab switcher + drag-drop using `--accent-ember` rim + 2 Buttons (one with `loading` prop).
- `CollectionSelectionModal.js` — largest of the set (header summary + SearchBar + scrollable list w/ checkbox toggles + footer); migrated to <Modal size="lg"> while preserving the per-collection card preview thumbnails.
- `OCRSettings.js` — trivial <Modal> wrap + single primary <Button>.
- `pages/decks.js` — both inline modals (Create Deck + Edit Deck) and `components/ScannerPageView.js` (Create List) migrated; ScannerPageView dropped its `useFocusTrap` named-import (Modal's internal focus trap owns the panel ref now).
- **`.github/workflows/ci.yml` `forbidden-modal-shell-without-primitive`** — grandfather list emptied to zero entries; gate is now strict.
## #3 Brief 2 — Forms migrated to <Button> / <SearchBar>
- `pages/dashboard.js` — 3 CTAs → <Button> (Create List with leadingIcon, Create Your First List, View All Lists).
- `pages/my-cards.js` — empty-state CTA → <Button variant="primary" size="lg">. View-mode toggle buttons intentionally left native (icon-only, doesn't match Button variants).
- `pages/community/collections.js` — Go to My Lists CTA → <Button>.
- `components/CollectionsPageView.js` — Discover Community + Create List header CTAs → <Button>; search input → <SearchBar>.
- Card-grid per-row icon buttons (CollectionsPageView, my-cards, CardsPageView) intentionally left native — tiny per-card actions whose styling doesn't match Button variants and would invalidate visual-diff baselines.
## #5 — scope revised + landed
`components/Card3D.js` deletion: surveyed every importer with grep — **zero consumers** in `pages/**` or `components/**`. Only references were in convoy docs. The "pre-existing state-management bug" (state setters used without useState declarations) never affected the running app because the component was never rendered. -505 LOC. The `fix-card3d-state` convoy is dropped from the roadmap as a result.
The actual card-grid component (`components/CardItem.js`) is intentionally **not** modified in this sweep — it has per-rarity glow tuning that the existing visual-diff baseline locks in, and the architect's #5 deferral note specifically called out the dedicated baseline re-seed cost. A future implementer turn can apply rim-light tokens to CardItem with its own baseline re-seed when an operator wants that polish.
## #6 Brief 1 — Landing + invite pages glass-migrated
- `pages/index.js` — top nav: `var(--glass-surface-mid)` + `--glass-blur-mid` + rim-light. 3 feature cards: `<GlassSurface tint="mid" rim="subtle" elevation="ambient">`. Featured-list cards (the public collection grid): same `<GlassSurface>` recipe with motion-token transitions. All 6 CTA buttons → <Button variant="primary"|"secondary"|"ghost"> with proper sizes. Pulse-loading placeholders tagged `.motion-essential` so reduced-motion users still see them animate (state-meaningful).
- `pages/invite/accept.js` + `pages/invite/decline.js` — both outcome panels wrapped in `<GlassSurface tint="mid" rim="subtle" elevation="pronounced">`. Loading spinner border colors corrected from `--text-accent` (which didn't exist) to `--accent-ember`. All 8 buttons → <Button>. `gradient-bg-ember` consumers retained (the canonical warm-palette utility class is fine).
## #8 Brief 2 — Legacy alias sweep + CI gate graduation
- Swept `gradient-bg-purple` → `gradient-bg-ember` across **8 files** / **13 occurrences**: `CardDetailQuantityModal`, `CardEditorView`, `CardEditorForm`, `AdminProtected`, `pages/card/[id]`, `pages/invite/{accept,decline}`, `pages/admin/card-import`. `gradient-bg-purple` was a dangling class name with no CSS definition (it was rendering no styling), so the sweep is also a bug fix — those buttons now actually get the ember gradient.
- Deleted the 5 dead CSS classes from `styles/globals.css`: `.gradient-text-blue`, `.gradient-text-purple`, `[data-theme="dark"] .glow-blue`, `[data-theme="dark"] .glow-purple`, `[data-theme="dark"] .glow-pink`. Each was zero-consumer post-sweep.
- **Graduated the `forbidden-deprecated-color-aliases` CI job from WARN to FAIL.** All 9 patterns (`gradient-text-{purple,pink,blue}`, `glow-{purple,pink,blue}`, `gradient-bg-{purple,blue,pink}`) now block the build if any consumer is reintroduced.
## Verification (local + CI gates locally exercised)
- Lint: 0 errors, 2 pre-existing warnings (`CardEditorForm.js` + `CollectionsPageView.js` carry-overs from before #95; out of scope).
- Vitest: 104/104 passing — unchanged from #95.
- Build: clean (Turbopack default; passes both light + dark theme prerender).
- `forbidden-modal-shell-without-primitive` gate: locally clear (`grep -lE 'fixed inset-0 bg-black bg-opacity-' pages components -r --include='*.js'` returns no matches).
- `forbidden-deprecated-color-aliases` gate: locally clear (all 9 patterns return no matches in `pages/` or `components/`).
## What still needs human action
- **Linux visual-diff baselines** must re-seed via the Docker workflow in `AGENTS.md` § 6. This PR's landing-page + invite-page changes will produce baseline drift on the homepage screenshot (which is currently the only baseline committed) AND additional baselines will be generated for the landing's glass-card sections once the visual spec is expanded. Recommended: run the Docker re-seed against this PR's Vercel preview, commit the result to this branch, push, verify CI green, then merge.
- Vercel auto-promotes the merge to production.
## Closes / supersedes
- Closes `.convoys/liquid-glass-modal-and-surface-primitive.md` Brief 2 (status → merged).
- Closes `.convoys/liquid-glass-form-primitives.md` Brief 2 (status → merged with explicit per-row-icon-button deferral note).
- Closes `.convoys/liquid-glass-public-and-auth.md` Brief 1 (status → merged).
- Closes `.convoys/cleanup-legacy-design-css.md` Brief 2 (status → merged + CI gate FAIL).
- Drops `.convoys/liquid-glass-card-surfaces.md` Brief 1 prerequisite (`fix-card3d-state` no longer needed; Card3D deleted).
- Drops the queued `fix-card3d-state` follow-up from the roadmap (target deleted).
- Updates `.convoys/ship-readiness.md` § "Design-system redesign portfolio" with a "Finish-portfolio sweep" subsection documenting final status of all 8 sub-convoys.
Co-authored-by: Cursor <cursoragent@cursor.com>
Move scanner page markup into ScannerPageView.js. pages/scanner.js is now
a thin composer of session state, useScannerQueue, and the view.
Mark pages/scanner.js resolved in ship-readiness god-component-split table.
Co-authored-by: Cursor <cursoragent@cursor.com>
Move camera feed markup, overlays, and disambiguation dialog wiring into
CameraScannerView.js. CameraScanner is now a ~45-line hook composer.
Update ship-readiness god-component-split CameraScanner slice as resolved.
Co-authored-by: Cursor <cursoragent@cursor.com>
Close stale convoy frontmatter for merged scanner, lint, and hygiene work;
record P1 #11.5 and queued follow-ups as RESOLVED with PR references.
Co-authored-by: Cursor <cursoragent@cursor.com>
Drop alice/bob password prefill from the login page, add a regression
test, and improve bulk-toolbar and disambiguation accessible names.
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>
Add focus traps for modals, accessible names for icon/select controls,
ownership badge role=status, list semantics for the scan queue, and
aria-live updates for the card count.
Co-authored-by: Cursor <cursoragent@cursor.com>
Record audit-redesign-scanner-flow-44 rollup and queue follow-up convoys
from reviewer, design-system, and a11y fan-out on PRs #42–#44.
Co-authored-by: Cursor <cursoragent@cursor.com>
When vision reads a set+number missing from the catalog, route to
card_submissions rather than sibling disambiguation. Adds a not-listed
modal action, background vision refine, foil-friendly prompt, and
submit-for-review API. Queues catalog-sync-vercel-cron convoy for later.
Co-authored-by: Cursor <cursoragent@cursor.com>
Delete the public /api/config/gemini endpoint and remove client auto-load
paths so GEMINI_AI_API_KEY stays server-side only. Add a scan rate-limit
class for the upcoming server-side identify route and a CI gate that blocks
reintroducing config key leaks or new browser LLM URLs.
Co-authored-by: Cursor <cursoragent@cursor.com>
Closes P1 #11 of .convoys/ship-readiness.md (launch sequence step 7) —
"No migration tool — scripts/add-*.js graveyard". Schema changes
post-this-convoy ship as node-pg-migrate migrations under migrations/
at the repo root; the legacy 27 scripts/add-*.js / scripts/fix-*.js /
scripts/seed-*.js jobs remain append-only history per the no-go-zones
rule.
Decisions (full record in .convoys/migration-tool.md § Decisions):
D1 — Tool: node-pg-migrate@^8. Rejected drizzle-kit / prisma migrate /
kysely because each forces broader TypeScript surface than AGENTS.md
Gotcha #9 allows (TS is a devDep only). node-pg-migrate is
JavaScript-native, raw-SQL-friendly via pgm.sql(), and ESM-clean for
the post-bump-next-js "type": "module" repo. Brings pg@^8.21.0 as a
peer dep (dev-only; never loaded in the Next.js bundle).
D2 — Migrations directory: migrations/ at the repo root. Separates
the tool-wrapped artifacts from the historical scripts/migrations/
placeholder folder (which housed the lone pre-tool
2026-05-24-rename-admin-email.js migration and remains preserved for
the audit trail). Matches node-pg-migrate's default flag.
D3 — Tracking table: default pgmigrations (no name collision with
the existing 7-table bootstrap; zero CLI noise).
D4 — Backfill strategy: hand-translate scripts/setup-neon-db.js's
DDL into the initial migration verbatim. Each await sql`...` block
becomes one pgm.sql(`...`) call. Each CREATE uses IF NOT EXISTS, so
the migration is idempotent against fresh AND pre-existing envs —
re-running setup-db on an env that already has the schema is a no-op
DDL-wise (only records the pgmigrations row). Documented assumption:
prod has drifted via the 27 historical add-*.js scripts; reconciling
those into the migration history is the queued
reconcile-historical-add-scripts follow-up convoy.
D5 — Bootstrap reconciliation: split. setup-neon-db.js now (1)
validates ADMIN_INITIAL_PASSWORD + POSTGRES_URL, (2) spawns
`npm run migrate up` via child_process with stdio inherited, (3)
seeds the admin row with ON CONFLICT (email) DO NOTHING. The seven
DDL blocks are deleted from setup-neon-db.js; success/error message
copy is updated to mention the migration step explicitly.
D6 — CI integration: defer. Wiring a CI job that runs migrate up
against a test DB needs either a dedicated Neon branch + secret OR a
Postgres service container; both are real work. Surface as
wire-migrate-into-ci follow-up. Risk acknowledged in
.convoys/migration-tool.md § R3.
D7 — Down-migration on the initial backfill: hard stub. Rolling back
the initial schema would drop every user / card / collection / deck
row in the DB. The stub throws with a long-form error pointing at
the recommended alternative (branch the Neon database + forward-apply).
Future migrations that touch one of the seven bootstrap tables write
their own dated migration with a real down().
Verification (pre-PR):
- npm run lint → 128 problems (baseline preserved, zero regression;
migration file is lint-clean, no new ignore patterns)
- npm run test:run → 21/21 pass
- node --check on migrations/1779853647564_initial-schema.js + on
scripts/setup-neon-db.js → exit 0
- Module load + down() throw verified via dynamic import
- npm run migrate -- --help reaches the node-pg-migrate CLI through
the wrapper
Live verification against a Neon branch is deferred (no throwaway
branch available); the operator's optional post-merge sequence is
documented in .convoys/migration-tool.md § Operator runbook.
See .convoys/migration-tool.md § Follow-ups for the queued
wire-migrate-into-ci / reconcile-historical-add-scripts /
retire-graveyard-scripts-after-audit / audit-node-pg-migrate-transitive-deps
/ add-migration-template follow-up convoys.
Co-authored-by: Cursor <cursoragent@cursor.com>
`lib/use-auth.js` is now the sole client-side auth surface (P1 §9 of
`.convoys/ship-readiness.md`). The legacy `lib/auth-context.js`
(`AuthProvider` + `useAuth`) and `lib/admin-auth.js` (`AdminProvider` +
`useAdmin` + `useIsAdmin`) are deleted; every importer is migrated to
the canonical hook. Pre-convoy a worst-case page mount issued THREE
identical `GET /api/auth/verify` requests (one per provider/hook); the
post-convoy floor is one verify per page mount (3 → 1 on
`pages/card/[id].js`, 2 → 1 elsewhere).
Importer inventory swept (7 source files):
- `pages/_app.js` — removed `<AuthProvider>` wrapper; `<ThemeProvider>`
is now the only top-level provider. `lib/use-auth.js` is hook-only,
no replacement provider needed.
- `pages/index.js`, `pages/scanner.js`, `pages/decks.js`,
`pages/deck/[id].js`, `pages/deck-builder.js` — `import { useAuth }`
path swap from `../lib/auth-context` to `../lib/use-auth`. All five
pages destructured only `{ user }` or `{ user, loading }`; verified
no consumer reads `login` / `register` from useAuth (those flows are
in `pages/login.js` / `pages/signup.js` which call the API directly),
so no shape-parity gap on `lib/use-auth.js`.
- `pages/card/[id].js` — replaced `useIsAdmin()` (the only consumer of
`lib/admin-auth.js` anywhere in the tree) with synchronous
`user?.role === 'admin'` derived from the existing `useAuth()` call.
Render condition at line 524 stays byte-identical.
Decisions documented in `.convoys/single-auth-provider.md`:
- D1: no extension to `lib/use-auth.js` (zero call sites for `login` /
`register` from useAuth — those flows are direct fetches in
`login.js` / `signup.js`).
- D2: `useIsAdmin()` collapses onto `useAuth()`; no separate hook.
- D3: provider tree `<ThemeProvider><AuthProvider>{children}</AuthProvider></ThemeProvider>`
→ `<ThemeProvider>{children}</ThemeProvider>`.
- D4: 3 → 1 verify roundtrip on `card/[id].js`; 2 → 1 on every other
page-load.
- D5: zero test files modified; the 21-test vitest suite is server-
side or prop-driven (`Layout.test.js` passes `user` as a prop, never
imports the legacy hooks).
Doc / config updates so the deletion lands cleanly:
- `.github/CODEOWNERS` — drop the two CODEOWNERS lines for the deleted
files.
- `AGENTS.md` § 2 architecture row + § 3 "Auth (client)" bullet —
rewritten for the post-convoy single-surface state.
- `.cursor/rules/auth-and-permissions.mdc` — § "Legacy" reframed to
"deleted by this convoy"; § "Authentication state on the client"
updated to the post-convoy `useAuth()` shape and the direct-fetch
login flow used by `login.js` / `signup.js`.
- `.cursor/rules/no-go-zones.mdc` — auth-refactors bullet drops the
deleted files from the canonical list.
- `.cursor/skills/add-page/SKILL.md` — checklist + anti-pattern row
refer to the deletion.
Verification:
- `rg "lib/auth-context|lib/admin-auth" --type js` → 0 hits in source.
- `npm run lint` → 128 → 125 problems (3 fewer errors from the deleted
unused-import lines; no regression).
- `npm run test:run` → 21/21 pass (including the 5 Layout regression
locks from `fix-layout-default-user`, which are prop-driven and
unaffected).
- `npm run build` → all 26 pages compile end-to-end; no SSR / static-
generation breakage that would have surfaced if a page tried to use
the legacy context hook unwrapped.
- Manual smoke deferred to operator post-merge per convoy doc.
Risks (full discussion in convoy file):
- R1 shape parity gap — verified zero consumers of legacy-only
surface; mitigated.
- R2 SSR mismatch from removing `<AuthProvider>` — `useEffect`-
guarded `localStorage` read; identical SSR shape pre/post; build
passes.
- R3 missed importer — post-delete grep + build pass would surface
any miss.
- R5 stale `useAuth` cache across components — pre-existing
pattern, called out as follow-up rather than addressed here.
Out of scope: any change to `lib/permission-middleware.js` (server-
side; resolved P0 #1), `lib/auth-secret.js` (resolved P0 #2),
`pages/api/**` route handlers, login / register API contracts, or
the seeded admin account flow.
Co-authored-by: Cursor <cursoragent@cursor.com>
Convoy: single-sql-client (P1 quality, launch sequence step 8)
Addresses: AGENTS.md Gotcha #1, .convoys/ship-readiness.md P1 #8
## Decisions
- D1: Caller inventory = 2 files (1 source + 1 test), not "~3 based on graph".
Only pages/api/auth-utils.js imports `db`; test/api/auth-utils.test.js mocks
it purely to satisfy the import graph (the 5 tests exercise
generateToken/verifyToken, not isAdmin/getUserById).
- D2: Migrate both call sites (isAdmin, getUserById) to @vercel/postgres
tagged-template SQL. Queries are SELECT-only, single-table,
single-numeric-parameter — byte-equivalent translation; same result shape
({rows, rowCount}); no transaction or pool semantics differ.
- D3: KEEP @neondatabase/serverless as a dep. 11 scripts/* files still use
`neon()` directly (setup-neon-db.js, migrations/, reset-db.js, 8 historical
add-*/fix-*/seed-* jobs). They are out of scope per the no-go-zones rule
and the convoy spec; purging the dep entirely would be its own convoy
(queued as `purge-neondatabase-serverless-fully`, blocked on migration-tool).
- D4: sql.unsafe audit — NOT a real injection vector with current callers
(userId comes from a verified JWT, is a numeric SERIAL id). Security
finding: NO. Pure refactor + foot-gun removal that prevents the FUTURE
caller that would have been the incident.
- D5: Test mock cleanup — drop the now-unneeded `vi.mock('../../lib/database.js')`
call + unused `vi` import. Test count + assertions unchanged (5/5).
## Per-file changes
- pages/api/auth-utils.js: swap `import { db } from '../../lib/database.js'`
for `import { sql } from '@vercel/postgres'`; rewrite isAdmin's
`db.query(SELECT … WHERE id = $1, [userId])` and getUserById's same shape
to `sql\`SELECT … WHERE id = ${userId}\``. Same try/catch, same
result.rows[0] access, same error returns.
- test/api/auth-utils.test.js: drop vi.mock for lib/database.js + the unused
`vi` import. 5/5 tests still pass.
- lib/database.js: DELETED (47 lines removed; manual-interpolation + sql.unsafe
wrapper is gone).
- .convoys/single-sql-client.md: NEW (the convoy file documenting all
decisions + caller inventory + verification + risks + follow-ups).
## Verification
- npm run lint → 128 problems (baseline preserved, no regression)
- npm run test:run → 21/21 pass (vitest)
- Grep "lib/database" --type js -l → 0 hits anywhere
- Grep "@neondatabase/serverless" --type js -l → still matches the 11
scripts/* sites (expected; out of scope per D3)
- node --check pages/api/auth-utils.js → exit 0
## Scope note
This convoy collapses the lib/database.js abstraction onto the canonical
@vercel/postgres surface for pages/api/**. It does NOT eliminate
@neondatabase/serverless from the dependency tree — that would require
migrating the scripts/* helpers, which is out of scope here (no-go-zones
rule + convoy spec). Queued as a follow-up.
## Live smoke
Deferred. The two migrated functions (isAdmin, getUserById) are only
reachable via pages/api/admin/index.js which requires an admin Bearer
token and a populated users table in prod Neon. Byte-equivalent SQL +
identical result shape gives high confidence; rollback is a single-commit
revert if a post-merge admin action 500s.
Co-authored-by: Cursor <cursoragent@cursor.com>