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>
Move the grouped card list and empty-deck state into DeckDetailCardList;
page header + stats sidebar remain inline for Brief 3.
Co-authored-by: Cursor <cursoragent@cursor.com>
Move the deck list grid (empty state + cards) into DecksGrid and the
edit form into DecksEditModal to continue shrinking pages/decks.js.
Co-authored-by: Cursor <cursoragent@cursor.com>
Production runtime ReferenceError on the scan flow:
ReferenceError: useFocusTrap is not defined
at ScanDisambiguationDialog (...)
`components/ScanDisambiguationDialog.js` calls `useFocusTrap(...)` on
line 14 but never imports it. The file previously had `import { useRef
} from 'react'` (unused since the focus-trap refactor in PR #67
`071a3dc`); the `useFocusTrap` import was never added when the hook
was introduced. The bug only manifests at runtime when the scan flow
reaches a disambiguation result (i.e. when the OCR/match returns
multiple candidate cards), which is why neither smoke nor visual diff
caught it — the dialog never renders in the homepage smoke path.
Lint also missed it because the project's flat ESLint config does NOT
extend `eslint:recommended` / `@eslint/js`'s `no-undef` rule. The
`react/jsx-no-undef` rule catches undefined JSX components but not
plain JS identifier references. Hardening that gap is the queued
`enable-no-undef-eslint-rule` follow-up — out of scope for this hotfix.
Replaces the dead `useRef` import with the missing `useFocusTrap`
named import from `lib/use-focus-trap.js` (parallel to how
`components/ui/Modal.js` imports `useFocusTrapContainer` as the
default export — see Modal.js:3 + use-focus-trap.js:21 / :87 for the
two-export shape).
Test plan:
- [x] `npm run lint` — clean (1 pre-existing unrelated warning)
- [x] `npm run test:run` — 123 tests pass (24 files)
- [ ] CI on this PR
- [ ] Post-merge: re-trigger a scan that disambiguates (ambiguous OCR
hit) and confirm the modal renders without console errors.
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>
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>
Reason: Refresh post-glass-redesign — unblocks harden-visual-diff-gate brief 2
Triggered by workflow_dispatch run 27454132468 on
the axiom self-hosted runner (CT 111). Byte-equivalent
toolchain to visual-diff.yml — PNGs should match cleanly on
the follow-up Screenshot diff job.
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 1 of cleanup-card-item-list-and-share-modal-palette convoy.
Pure token sweep across lines ~181-295 of components/CardItem.js
(the list-mode branch only — grid-mode at L290+ stays untouched
per Brief 1 § Known constraints).
Migrations:
- Outer row container:
- `border-purple-500 bg-purple-50 shadow-md` (selected) →
`borderColor: 'var(--accent-ember)' + backgroundColor:
'rgba(255, 110, 0, 0.08)' + boxShadow: 'var(--rim-light-inner)'`.
- `border-gray-200 hover:border-gray-300 hover:shadow-sm` (default) →
`borderColor: 'var(--border)' + nav-item-hover` class for the
ember-tinted hover state from the unify convoy.
- `rounded-lg` → `rounded-xl` (convoy-wide rounding consistency).
- Image placeholder:
- `bg-gray-200` → `var(--bg-tertiary)`.
- `text-gray-400` on the "No Image" fallback → `var(--text-secondary)`.
- Card info text:
- `text-gray-900` → `var(--text-primary)` (card name).
- `text-gray-600` / `text-gray-500` → `var(--text-secondary)` (set/rarity/type).
- Game badge:
- `bg-blue-100 text-blue-800` → `var(--bg-tertiary)` + `var(--text-primary)`
+ `1px solid var(--border)` for visible delimiter in both themes.
- Price:
- `text-green-600` → `var(--accent-flame)`. Decision: chose brand-warm
over semantic-green because the rest of the row is on the ember
palette and a single warm-tone price tag reads as "primary value"
rather than "positive delta from baseline". Easy to revert to
`#16a34a` literal if dark-mode reviewers prefer the green.
- Favorite button:
- `text-red-500 hover:text-red-600` (favorited) →
`var(--accent-ember)`.
- `text-gray-400 hover:text-red-500` (default) →
`var(--text-secondary)` + nav-item-hover.
- Add-to-collection button:
- `text-blue-600 hover:text-blue-700 hover:bg-blue-50` →
`var(--accent-ember)` + nav-item-hover.
- Add-to-deck button:
- `text-green-600 hover:text-green-700 hover:bg-green-50` →
`var(--accent-flame)` + nav-item-hover. Distinguishes from
add-to-collection by warm-tier (flame vs ember).
- `rounded-lg` on all action buttons → `rounded-xl`.
Verification:
- `sed -n '181,295p' components/CardItem.js | grep -nE
'bg-(purple|blue|gray|red|green)-[0-9]|text-(...)|border-(...)'`
→ 0 matches. ✅
- `npm run lint` passes (1 pre-existing unrelated warning).
- `npm run test:run`: 118/118 tests pass.
Grid-mode (L290+) intentionally untouched per Brief 1 scope.
The 5 remaining palette hardcodes in CardItem.js are all in
grid-mode getRarityEffects() at L150/157/172/350/359 — out of
scope for this brief; the convoy didn't target those because the
grid render path has its own visual treatment.
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>
Standalone infrastructure PR (no convoy ceremony needed — single-file
scope). Triggered by the GitHub Actions billing block that gated
PRs #124 + #125 today.
Three layers of savings applied per the user's max-savings option:
1. paths-ignore on ci.yml + preview-smoke.yml
- Doc-only PRs (.convoys/**, **/*.md, docs/**, AGENTS.md,
.cursor/**, README.md) now trigger ZERO Actions jobs.
- Vercel still builds (it's not on the Actions billing).
- visual-diff.yml unchanged — it was already cost-conscious via a
positive paths: allowlist (pages/**, components/**, styles/**,
etc.).
2. Consolidate 6 grep-only forbidden-* jobs into 1
- Previously 6 independent jobs each ran their own
actions/checkout (~3s × 6 = 18s of redundant checkout).
- Merged into a single forbidden-patterns job with 6 sequential
::group:: sections, one FAIL flag at the bottom — preserves
"see all violations in one run" diagnostic behavior. Per-file
::error file=...::msg annotations work the same way.
- Removed jobs: forbidden-endpoints, forbidden-cors-headers,
forbidden-client-side-llm-keys,
forbidden-modal-shell-without-primitive,
forbidden-deprecated-color-aliases, forbidden-stale-strings.
- pr-health-rollup.yml only looks up "Lint" and "Schema map up to
date" by name — unaffected.
3. Cache node_modules + Playwright browsers
- actions/cache@v4 for node_modules keyed by package-lock.json
hash, applied to lint / test / migrate / preview-smoke /
visual-diff. Cuts npm ci from ~30-45s to ~3-5s on cache hit.
setup-node@v4's built-in cache: npm stays (caches ~/.npm) —
both layered.
- actions/cache@v4 for ~/.cache/ms-playwright keyed by the
resolved @playwright/test version. Cache invalidates on any
Playwright version bump. On cache hit, only system deps install
runs (npx playwright install-deps chromium) — saves ~15-25s/run.
AGENTS.md updates:
- § 6 Testing § CI behavior: appended "CI minute optimizations" subsection
documenting all three layers.
- § Product vocabulary table caption: updated "CI job
forbidden-stale-strings" reference to "CI check Forbidden patterns
(6 checks) → Check 6/6" with a historical pointer.
- Gotcha #5: updated the standalone forbidden-endpoints reference
similarly.
Estimated savings per typical convoy mix (~30% doc PRs based on
repo history):
- Doc-only PRs: 100% reduction (was ~6-7 min, now 0 Actions min).
- Code-touching PRs: ~30-50% reduction (cache hits for npm + Playwright +
no redundant 6× checkout).
- Weighted average: ~50-60% reduction.
This is short of the 70-80% I floated in chat — the real ceiling is
limited by lint / vitest / migrate / Playwright runtime itself, all of
which are kept on code-touching PRs (they're high-signal).
Verification:
- All 3 workflow YAMLs parse (python3 -c "yaml.safe_load(...)").
- npm run lint passes (1 pre-existing unrelated warning).
- npm run test:run: 118/118 tests pass.
- forbidden-patterns logic is byte-equivalent to the 6 original jobs'
bash bodies — the differences are: per-check ::group::/::endgroup::
framing, a shared FAIL flag instead of per-job exit 1, and renamed
local arrays (FOUND → LLM_FOUND / MODAL_FOUND / STRING_FOUND) to
avoid clobbering across the single job's scope.
Co-authored-by: Cursor <cursoragent@cursor.com>
Brief 4 of unify-glass-panel-surfaces convoy. The bulk-select toolbar
was a critical dark-mode bug (bg-white invisible against dark
backgrounds); migrating to .glass-panel-strong is both a unification
win and a correctness fix.
Outer toolbar:
- Replaces `bg-white rounded-xl shadow-2xl border border-gray-200`
with `glass-panel-strong rounded-2xl`.
- Inline boxShadow override: `var(--rim-light-inner),
var(--elevation-pronounced)` — keeps the toolbar reading as
elevated over arbitrary page content (matches the design audit's
"floating chrome" elevation tier).
More-actions dropdown:
- Replaces `absolute bottom-full right-0 mb-2 bg-white rounded-lg
shadow-xl border border-gray-200` with `glass-panel-strong
absolute bottom-full right-0 mb-2 rounded-xl`. Class's default
rim+ambient stack is the right weight here.
Interior token sweep (per Brief 4 acceptance criteria):
- text-gray-700 → style={{ color: 'var(--text-primary)' }}
- text-gray-{400,600} on the action triggers →
style={{ color: 'var(--text-secondary)' }}
- hover:text-gray-{600,800} + hover:bg-gray-{50,100} → nav-item-hover
- text-red-600 hover:bg-red-50 → style={{ color: 'rgb(239, 68, 68)' }}
+ nav-item-hover. (--accent-danger token does not exist yet; brief
pre-authorized this fallback. A future design-system PR can
introduce the token and swap the literal.)
- bg-gray-300 divider → style={{ backgroundColor: 'var(--border)' }}
- border-gray-200 menu separator → style={{ borderColor:
'var(--border)' }}
- rounded-lg → rounded-xl on action triggers (Brief 4 + convoy-wide
consistency).
UNCHANGED per brief scope:
- The 3 quick-action buttons (Collection / Deck / My Collection)
— they already use --accent-flame / --accent-gold / --accent-wood
and the onMouseEnter/Leave swap pattern. Touching them is scope
expansion.
Verification:
- rg "text-gray-|bg-gray-|hover:bg-gray-|text-red-|hover:bg-red-|
border-gray-" components/BulkSelectionToolbar.js → 0 matches.
- npm run lint passes (1 pre-existing unrelated warning).
- npm run test:run: 116/116 tests pass (no test changes needed for a
pure className/style swap per Brief 4 § Test Plan).
Acceptance criteria from
.convoys/unify-glass-panel-surfaces/brief-4-bulk-selection-toolbar.md
all met. No edits outside components/BulkSelectionToolbar.js.
Dark-mode QA reminder for reviewers: before this change, the toolbar
was bg-white — invisible against the dark theme. Verify in BOTH
themes before merge; before/after dark-mode screenshot attached to
PR description.
Co-authored-by: Cursor <cursoragent@cursor.com>
Brief 3 of unify-glass-panel-surfaces convoy. Migrates three floating
surfaces from inline var(--glass-surface-*) + backdropFilter to the
canonical .glass-panel-strong className, preserving their existing
box-shadow chains (ember rim for the dropdown panels; pronounced
elevation for the drawer + TopSearchBar UserMenu) via inline override.
Three popovers migrated:
1. components/Layout.js UserProfileDropdown panel (sidebar)
- boxShadow chain preserved: var(--rim-light-inner),
var(--ember-rim-subtle), var(--elevation-ambient).
2. components/Layout.js mobile drawer
- boxShadow chain preserved: var(--rim-light-inner),
var(--rim-light-outer), var(--elevation-pronounced).
3. components/ui/TopSearchBar.js UserMenu dropdown
- boxShadow chain preserved: var(--rim-light-inner),
var(--ember-rim-subtle), var(--elevation-pronounced) (note:
-pronounced, not -ambient — caught by architect boot-the-brief
recheck and documented in convoy's risk note).
The sidebar nav-chip / main content chrome block (Layout.js ~L853-863)
intentionally remains handrolled with full-intensity corner lights —
allowlisted by Brief 7's CI gate (D4 of the architect plan).
Tests (test/components/Layout.test.js, +2 new assertions):
- mobile drawer container queryable via .glass-panel-strong selector
and is wired with width/positioning classes (.w-64, .fixed, etc).
- mobile drawer inline style contains no var(--glass-surface-*) and
no backdrop-filter (both now provided by the class); does contain
var(--elevation-pronounced) (preserved override).
Verification:
- npm run lint passes (1 pre-existing unrelated warning).
- npm run test:run: 118/118 tests pass (was 116; +2 new).
Acceptance criteria from
.convoys/unify-glass-panel-surfaces/brief-3-floating-popovers.md
all met. No edits outside the 3 files in scope.
Co-authored-by: Cursor <cursoragent@cursor.com>
Brief 1 of unify-glass-panel-surfaces convoy. Adds a `cornerLights`
prop to the <GlassSurface> primitive so corner catch-lights compose
into every consumer (<Modal>, <StatCard>, landing feature cards) by
default — no per-consumer migration needed.
API:
cornerLights: 'subtle' (default) | 'chrome' | 'none'
'subtle' → 4-layer background using --corner-light-warm-subtle /
--corner-light-cool-subtle (matches .glass-panel-strong
post-PR #118; appropriate for most data surfaces).
'chrome' → same recipe with the full-intensity
--corner-light-warm / --corner-light-cool tokens
(matches the Layout sidebar nav-chip and TopSearchBar
header treatments).
'none' → today's pre-Brief-1 behavior. Single-layer background:
var(--glass-surface-{tint}); no transparent border, no
corner radials. Escape hatch for GPU-budget-constrained
tiles that legitimately must skip the gradient-border
treatment.
Composition recipe (verbatim mirror of styles/globals.css's
.glass-panel-strong block post-PR #118):
linear-gradient(<fill>, <fill>) padding-box,
radial-gradient(at 0% 100%, <warm> 0%, transparent 42%) border-box,
radial-gradient(at 100% 0%, <cool> 0%, transparent 42%) border-box,
var(--chip-border-base) border-box
Paired with `border: 1px solid transparent` so the border-box
gradients render through the border. For cornerLights='none', the
border declaration is omitted entirely — preserves today's box-model
exactly.
Other props (`tint`, `blur`, `rim`, `elevation`, `as`, `style`,
`className`) and the `...style` LAST-wins merge order are unchanged.
Test additions (test/components/ui-primitives.test.js, +49 lines):
- cornerLights='subtle' (default): asserts --corner-light-*-subtle
tokens, padding-box/border-box layers, --chip-border-base, and
`border: 1px solid transparent` all present in the rendered
inline style attribute.
- cornerLights='chrome': asserts the full-intensity tokens
(NOT the -subtle variants); same border declaration.
- cornerLights='none': asserts single-layer
`background: var(--glass-surface-mid)`, no corner-light tokens,
no padding-box, no --chip-border-base, no border declaration.
Verification:
- npm run lint passes (1 pre-existing unrelated warning).
- npm run test:run: 116/116 tests pass (was 113; +3 GlassSurface
assertions).
Ripple effect (intentional, per architect plan):
<Modal>, <StatCard>, and the landing-page feature cards all
delegate to <GlassSurface>. Defaulting to cornerLights='subtle'
means each of them now renders with corner catch-lights without
any per-consumer edit. The visual-diff baseline refresh is the
expected side effect; queue on Linux per AGENTS.md § Testing
before Brief 3 + Brief 4 dispatch.
Acceptance criteria from
.convoys/unify-glass-panel-surfaces/brief-1-upgrade-glass-surface-primitive.md
all met. No consumer migrations in this PR.
Co-authored-by: Cursor <cursoragent@cursor.com>
Brief 5 of unify-glass-panel-surfaces convoy. Deletes the legacy
.card class from styles/globals.css and migrates all consumers
(actual count: 7, not 8 as the brief had estimated — one of the
suspected sites was already on a different pattern) to
.glass-panel rounded-3xl p-{4|6}.
A single panel vocabulary across the app — .glass-panel for body
content, .glass-panel-strong for floating chrome/popovers,
.page-header-glass for full-bleed top strips — is the convoy's
success metric. .card predated the gradient-border system and was
the only remaining "opaque solid panel" pattern in user-facing pages.
Consumers migrated:
- pages/settings.js × 2 (p-4 and p-6 cards)
- pages/profile.js × 3 (avatar card, stats card, activity card)
- pages/community/collections.js × 1
- components/CollectionsPageView.js × 1
Each migration:
- Replaces `card` with `glass-panel rounded-3xl` in the className.
- Preserves sibling Tailwind tokens (p-4 / p-6 / text-center /
mt-6 / group / cursor-pointer).
- Adds `transition-all duration-{200|300}` explicitly where the
legacy class baked it in (5 of 7 sites needed this back).
- Drops hover:shadow-{lg,xl} Tailwind overrides on the 2 community
sites; the .glass-panel corner-light gradient is the new
affordance.
CSS change in styles/globals.css:
- Removed the `.card { @apply rounded-3xl shadow-lg p-6
transition-all duration-300; background-color:
var(--bg-primary); border: 1px solid var(--border); }` rule.
- Added a documentation comment in its place explaining the
retirement and pointing future consumers at the right alternative.
Verification:
- rg "className=[\"'\`]card\b" pages/ components/ --type js
returns 0 matches.
- rg "^\.card \{" styles/ returns 0 matches.
- npm run lint passes (1 pre-existing warning unrelated).
- npm run test:run: 113/113 tests pass.
Acceptance criteria from
.convoys/unify-glass-panel-surfaces/brief-5-retire-card-class.md
all met. No escape-hatch sites needed; all 7 migrations were clean.
Co-authored-by: Cursor <cursoragent@cursor.com>
Brief 2 of unify-glass-panel-surfaces convoy. Replaces the handrolled
`rgba(--bg-secondary-rgb, 0.85) + backdrop-blur-sm` glass imitation
on the login + signup form-card containers with the canonical
.glass-panel-strong className so the auth flow shares the rest of the
app's surface treatment (corner catch-lights, system blur tier,
gradient border).
Both swaps are pure className+style → className migration:
Before:
<div
className="p-8 rounded-2xl shadow-2xl backdrop-blur-sm border border-opacity-20"
style={{
backgroundColor: 'rgba(var(--bg-secondary-rgb), 0.85)',
borderColor: 'var(--border)',
}}
>
After:
<div className="glass-panel-strong rounded-2xl p-8">
All children (<Input>, <Button>, error banner, social-sign-in divider,
footer link) remain byte-identical. No new imports.
Acceptance criteria from
.convoys/unify-glass-panel-surfaces/brief-2-auth-form-cards.md all
met. npm run lint passes.
Co-authored-by: Cursor <cursoragent@cursor.com>
Brief 6 of unify-glass-panel-surfaces convoy. Replaces the handrolled
inline `var(--glass-surface-mid)` + `backdropFilter` + malformed
`boxShadow: 'inset 0 1px 0 var(--rim-light-inner)'` on the landing
<nav> with the canonical .page-header-glass class, which already
exists in styles/globals.css for exactly this "full-bleed top band"
use case.
The malformed boxShadow was a real bug: --rim-light-inner is already
a complete `inset 0 1px 0 <color>` declaration, so wrapping it in
another `inset 0 1px 0 ...` produced invalid CSS the browser silently
dropped. Documented in styles/globals.css L748-761. The class's own
`box-shadow: var(--rim-light-inner), 0 1px 0 var(--border)` does the
right thing.
Acceptance criteria from
.convoys/unify-glass-panel-surfaces/brief-6-landing-nav-bar.md:
- [x] <nav> uses className="page-header-glass border-b"
- [x] Inline style retains only borderColor: 'var(--border)'
- [x] Heading + Sign In / Get Started buttons inside the nav unchanged
- [x] npm run lint passes (1 pre-existing warning unrelated to this PR)
Co-authored-by: Cursor <cursoragent@cursor.com>