Commit graph

368 commits

Author SHA1 Message Date
varutasu
73424aae59
Mobile scanner checkout: scan first, commit later (#157)
* 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>
2026-08-14 20:20:43 -05:00
varutasu
c6c1364dd6
chore(agent-pipeline): sync 0.6.0/0.7.0 artifacts (#155)
* Sync agent pipeline artifacts to 0.6.0.

Add model routing defaults, L2 role updates, convoy telemetry, and manifest tracking without touching unrelated in-progress work.

* Record tcg-vault interactive sync (kept local L1/L3 customizations).

Update last_synced_at after reviewing behind/conflict artifacts; no overwrites applied.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(agent-pipeline): sync 0.6.0 — implementer Mode 2 fix pass + UI designer

- role-implementer Mode 2 (fix pass) and role-reviewer hand-off
- role-ui-designer + model-routing updates from upstream 783e2a3
- manifest last_synced_at bumped

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(agent-pipeline): sync v0.7.0 model routing

Composer Standard for architect, Grok for audit fan-out, Sonnet 5 escalation path.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(agent-pipeline): finish 0.6.0/0.7.0 sync on branch

Install security baseline, convoy planning, security-audit and ui-ux-pro-max
skills; align PR template and metrics docs with convoy-metrics-gate; refresh
manifest hashes while keeping tcg-vault-local L1/L3 customizations.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 18:56:01 -05:00
varutasu
15e02ee01b
feat(migrations): reconcile collaboration tables missed by initial-schema backfill (B3) (#151)
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>
2026-07-06 11:27:46 -05:00
varutasu
71757faa90
chore: sync agent pipeline v0.6.0 (keep local L1/L3) (#154)
* Sync agent pipeline artifacts to 0.6.0.

Add model routing defaults, L2 role updates, convoy telemetry, and manifest tracking without touching unrelated in-progress work.

* Record tcg-vault interactive sync (kept local L1/L3 customizations).

Update last_synced_at after reviewing behind/conflict artifacts; no overwrites applied.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 11:27:33 -05:00
Randall Stillwell
8ee5e7bf05 chore(metrics): capture role event from multi-game-bulk-sync brief 2
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 15:34:04 -05:00
Randall Stillwell
ec9bb2b93e feat(catalog): unified multi-game bulk sync + schema map update
Ship Pokemon and Lorcana bulk import libs/scripts, unified weekly cron
sync across MTG/Pokemon/Lorcana with per-game error isolation and
catalog_sync_log telemetry. Admin UI adds Unified/Incremental/Bulk MTG modes.

- Rename reconcile migrations to 1781442330* timestamps so they apply
  after bulk-data migrations without node-pg-migrate ordering conflicts
- Add Lorcana set-code normalization + orphan cleanup migrations
- Drop stricter user_cards_user_card_unique (keep 3-column foil unique)
- Update docs/SCHEMA_MAP.md for tags, card_tags, catalog_sync_log, bulk columns

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 08:22:19 -05:00
varutasu
40402eb287
feat(migrations): reconcile user_cards UNIQUE constraint per Option A (B6) (#149)
* 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>
2026-06-14 08:18:22 -05:00
varutasu
ee7da9ac3a
feat(migrations): reconcile user profile missed by initial-schema backfill (B5) (#153)
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>
2026-06-14 08:18:20 -05:00
varutasu
e8619c5836
feat(migrations): reconcile collections columns missed by initial-schema backfill (B2) (#150)
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>
2026-06-14 08:18:05 -05:00
varutasu
ef2cfb8547
feat(migrations): reconcile favorites system missed by initial-schema backfill (B4) (#152)
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>
2026-06-14 08:11:26 -05:00
varutasu
a35ce01ba0
feat(migrations): reconcile cards columns missed by initial-schema backfill (B1) (#148)
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>
2026-06-14 08:11:08 -05:00
Randall Stillwell
67073aab7f feat(catalog): Scryfall bulk data import + Tagger community tags
Add full Scryfall bulk data pipeline:

- Migration: 13 new columns on `cards` (oracle_id, illustration_id,
  color_identity, keywords, legalities, flavor_text, artist, released_at,
  layout, edhrec_rank, reserved, reprint, finishes) with GIN indexes
  for JSONB search.
- Migration: `tags` + `card_tags` tables for Tagger community data.
- Script: `bulk-import-scryfall.js` — downloads Oracle Cards bulk file
  (168 MB) and upserts all 36k+ MTG cards with rich metadata.
- Script: `import-scryfall-tags.js` — imports oracle tags (4.5k tags,
  227k taggings) and art tags (11k tags, 458k taggings).
- Lib: `bulk-sync.js` — runtime bulk sync callable from the admin API.
- Admin UI: mode toggle (incremental vs bulk) on catalog sync panel.

Enables Commander deck validation (color_identity), format legality
checks, keyword search, EDHREC popularity ranking, and functional
card tagging ("removal", "ramp", "draw") for deck building assistance.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 07:51:39 -05:00
Randall Stillwell
59e2ca4ce4 fix(catalog-sync): add retry logic and pagination for Scryfall API
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>
2026-06-13 10:49:50 -05:00
Randall Stillwell
cf9fea0726 feat(scanner): rebuild as mobile-first three-phase flow
Replace the desktop-first, everything-at-once scanner layout with a
phased mobile-optimized experience: Setup → Scanning → Review.

Phase 1 (Setup): destination picker, game filter, deck mode toggle,
scan history (last 5 sessions).

Phase 2 (Scanning): full-screen camera with auto-start, haptic + sound
feedback on card detection, torch/flash toggle, count pill, bottom-sheet
disambiguation (replaces full-screen modal).

Phase 3 (Review): card list with inline condition/foil/qty edits,
batch confirm, 30-second undo, deck progress indicator.

New features:
- Deck mode (progress toward 40/60/99 card target)
- Scan history (persisted to localStorage)
- Sound feedback (Web Audio oscillator, configurable)
- Offline queue (localStorage persistence + auto-retry on reconnect)
- Camera flash/torch toggle
- Batch ownership API (replaces N+1 per-card fetches)
- Visibility pause (detection loop stops when tab is backgrounded)

Convoy: scanner-rebuild
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-13 08:42:58 -05:00
Randall Stillwell
237870c17e convoy: render-test regression-lock for ScanDisambiguationDialog (PR #144)
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>
2026-06-13 01:20:21 -05:00
Randall Stillwell
2e68574393 convoy: enable no-undef ESLint rule + fix 3 latent bugs it surfaced
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>
2026-06-13 01:17:18 -05:00
varutasu
b2ea950a3c
refactor(deck): useDeckDetail + DeckDetailView (Brief 3) (#146)
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>
2026-06-13 01:14:46 -05:00
varutasu
32d64d86f0
refactor(decks): useDecksPage + DecksPageView (Brief 3) (#145)
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>
2026-06-13 01:14:43 -05:00
varutasu
7e0e6bffc1
refactor(deck): extract grouped card list panel (Brief 2) (#143)
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>
2026-06-13 01:11:50 -05:00
varutasu
bce62c3716
refactor(decks): extract edit modal and deck grid (Brief 2) (#142)
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>
2026-06-13 01:11:47 -05:00
varutasu
31da384a9d
fix(scanner): import useFocusTrap in ScanDisambiguationDialog (#144)
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>
2026-06-13 01:00:47 -05:00
varutasu
4f6467f077
refactor(deck): extract grouping lib and stats sidebar (Brief 1) (#137)
Reuse computeDeckStats from deck-builder-stats; add groupDeckCards lib,
DeckDetailStatsSidebar component, and vitest coverage. Fixes stray semicolon
after useEffect. Page drops ~205 lines.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-13 00:44:16 -05:00
varutasu
290d79ccf8
refactor(decks): extract format utils and create modal (Brief 1) (#136)
Add shared deck format helpers and DecksCreateModal; decks page keeps
the edit modal inline for Brief 2.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-13 00:44:13 -05:00
varutasu
f83d71abe0
convoy: scripts/rotate-admin-password.js — one-shot admin rotation (rotate-default-admin) (#141)
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>
2026-06-12 23:23:44 -05:00
varutasu
c2ebd18cf0
convoy: flip visual-diff to a hard merge gate (harden-visual-diff-gate brief 2/2) (#140)
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>
2026-06-12 22:03:17 -05:00
varutasu
54495fea28
chore(visual): refresh baselines from https://tcg-vault-4mwg6pitb-randall-stillwells-projects.vercel.app (#139)
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.
2026-06-12 21:54:10 -05:00
varutasu
c100c5f192
convoy: add seed-visual-baselines workflow (harden-visual-diff-gate brief 1) (#138)
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>
2026-06-12 21:37:50 -05:00
varutasu
e3b4b0e213
chore(docs): sweep stale visual-baseline references + queue gate-hardening convoy (#135)
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>
2026-06-12 20:30:34 -05:00
varutasu
9eef8d9575
feat(convoy-metrics): un-gitignore .metrics.jsonl + add CI gate on convoy PRs (#134)
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>
2026-06-12 17:46:28 -05:00
varutasu
e933140560
convoy: forbidden-pattern gate + AGENTS.md docs (briefs 3+4) (#133)
* 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>
2026-06-06 00:18:21 -05:00
varutasu
f228c096fd
convoy: migrate CI to self-hosted axiom runners (briefs 1+2) (#132)
* 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>
2026-06-05 23:46:31 -05:00
varutasu
0d14278021
refactor(glass): migrate Button.secondary + Input + MobileNav off bespoke glass-surface (#131)
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>
2026-06-05 06:23:55 -05:00
varutasu
f72d308a1e
docs(convoys): close unify-glass-panel-surfaces + cleanup-card-item-list-and-share-modal-palette (#130)
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>
2026-06-05 06:18:30 -05:00
varutasu
3cb7bfdb48
refactor(share-modal): tokenize interior palette (Brief 2) (#129)
Brief 2 of cleanup-card-item-list-and-share-modal-palette convoy.
Pure palette token sweep across the interior of
components/ShareModal.js. The modal SURFACE was already correct
(delegates to <Modal> → <GlassSurface>) and the
user-search-dropdown + email-invite-card areas were updated in
PR #117 — this brief addresses the residual interior rows.

Migrations:

Public Access section (L139-167):
- Outer container: `border` (default Tailwind 1px) → tokenized border:
  '1px solid var(--border)' + backgroundColor: 'var(--bg-secondary)'.
  rounded-lg → rounded-xl.
- Link icon: `text-gray-400` → `var(--text-secondary)`.
- Heading: `text-gray-900` → `var(--text-primary)`.
- Sub-text: `text-gray-500` → `var(--text-secondary)`.
- Toggle: `bg-blue-600` (on) / `bg-gray-200` (off) →
  `var(--accent-ember)` / `var(--bg-tertiary)` + 1px border for
  visible delta when off.
- Inner span (handle): `bg-white` → kept literal `rgb(255, 255, 255)`
  for theme-independent white contrast on the ember toggle.

Add People input (L172-183):
- `border-gray-300` + `focus:ring-blue-500` → `var(--input-border)`
  + `var(--input-bg)` + `--tw-ring-color: var(--accent-ember)`.
- Search icon: `text-gray-400` → `var(--text-secondary)`.
- rounded-lg → rounded-xl.

Current Permissions section (L253-340):
- Section paragraph: `text-gray-700` → `var(--text-secondary)`.
- Current-user row: `bg-gray-50 rounded-lg` → `var(--bg-secondary)` +
  `var(--border)` + `rounded-xl`.
- Current-user avatar circle: `bg-purple-600` →
  `linear-gradient(135deg, var(--accent-ember), var(--accent-flame))`
  (matches the UserMenu avatar gradient from TopSearchBar).
- Owner badge: `bg-white rounded border` →
  `var(--bg-tertiary)` + `var(--text-secondary)` + 1px ember border +
  rounded-xl.
- Invited-user row: `border rounded-lg` → `var(--border)` +
  `var(--bg-secondary)` + rounded-xl.
- Invited-user avatar: `bg-gray-400` → `var(--bg-tertiary)` +
  1px ember border + `var(--text-primary)` for the letter.
- Role badge: `bg-gray-100 rounded` → `var(--bg-tertiary)` +
  `var(--text-secondary)` + rounded-xl.
- All `text-gray-{500,700,900}` → `var(--text-primary)` /
  `var(--text-secondary)` per role.

Share Link section (L305-340):
- Readonly input: `bg-gray-50 border-gray-300 text-gray-600
  rounded-lg` → `var(--input-bg)` + `var(--input-border)` +
  `var(--text-primary)` + rounded-xl.
- Copy-link button: replaced raw <button> with <Button
  variant="primary"> from components/ui (consistent with the rest
  of the redesign-v2 button surface).
- Copied! success state: `bg-green-100 text-green-800 border
  border-green-200` → `var(--bg-secondary)` + `var(--accent-flame)`
  + `var(--accent-ember)` border. Renders as a non-interactive
  badge instead of a styled button — same UX (you can't re-click
  "Copied!" anyway, the original was disabled-by-state).
- New Button import added at top: `import { Modal, Button } from
  './ui'`.

Social Share section (L327-365):
- Icon container: `text-gray-600` → `var(--text-secondary)`.
- Label: `text-xs text-gray-600` → `var(--text-secondary)`.

Verification:
- `grep -nE "bg-(palette)-[0-9]|text-(...)|border-(...)" components/ShareModal.js`
  → 0 matches. 
- `npm run lint` passes (1 pre-existing unrelated warning).
- `npm run test:run`: 118/118 tests pass.

Acceptance criteria from
.convoys/cleanup-card-item-list-and-share-modal-palette.md
all met for the in-scope sites. No edits outside ShareModal.js.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 16:55:39 -05:00
varutasu
4eca7560e5
refactor(card-item): tokenize list-mode palette (Brief 1) (#128)
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>
2026-06-04 16:55:36 -05:00
varutasu
b82fd6cb6a
ci(forbidden-patterns): add Check 7/7 — no bespoke var(--glass-surface-*) (#127)
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>
2026-06-04 16:47:26 -05:00
varutasu
a4dca47642
ci: slash GitHub Actions minutes via paths-ignore + consolidation + caching (#126)
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>
2026-06-04 16:40:36 -05:00
varutasu
8bb174c7cc
refactor(bulk-toolbar): migrate to .glass-panel-strong + token sweep (#125)
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>
2026-06-04 16:25:17 -05:00
varutasu
e8eba34d59
refactor(layout): migrate 3 floating popovers to .glass-panel-strong (#124)
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>
2026-06-04 16:25:14 -05:00
varutasu
3248468a33
feat(GlassSurface): add cornerLights prop (subtle | chrome | none) (#123)
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>
2026-06-04 15:30:32 -05:00
varutasu
87a71f2068
refactor(styles): retire .card; migrate 7 consumers to .glass-panel (#122)
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>
2026-06-04 15:30:29 -05:00
varutasu
78f954f652
refactor(auth): migrate login + signup form cards to .glass-panel-strong (#121)
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>
2026-06-04 15:30:26 -05:00
varutasu
2efe82eff8
refactor(landing): use .page-header-glass for the nav bar (#120)
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>
2026-06-04 15:30:22 -05:00
varutasu
66a4d7b721
docs(convoys): unify-glass-panel-surfaces + cleanup palette — conductor seed + architect plan (#119)
* 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>
2026-06-04 14:09:40 -05:00
varutasu
2986171e6d
refactor(design): tone down card corner-light gradients to ~55% intensity (#118)
Operator feedback after the design-sweep PR went out: the corner-light
gradients on data cards (the `.glass-panel` / `.glass-panel-strong`
class) read too vibrant once the pattern was applied broadly to deck
cards, stat cards, list cards, modals, dropdowns, etc.

The same intensity worked fine on the floating chrome chips (sidebar
nav, top header) because there are only two of those per viewport and
they SHOULD anchor the page visually as the surfaces "responding" to
the background light sources. Data cards tile densely; at full
intensity they compete with the chrome and the catch-lights become
visual noise instead of an environmental cue.

Two intensity tiers now live in `styles/globals.css`:

  1. `--corner-light-{warm,cool}` (full intensity, unchanged) — used
     by the floating chrome chips via inline `boxShadow` in
     `components/Layout.js` and `components/ui/TopSearchBar.js`.

  2. `--corner-light-{warm,cool}-subtle` (NEW, ~55% of full alpha) —
     used by `.glass-panel` and `.glass-panel-strong` for data cards.

Alpha values:

| Token                            | Light theme | Dark theme |
|----------------------------------|-------------|------------|
| `--corner-light-warm`            | 0.50        | 0.68       |
| `--corner-light-cool`            | 0.38        | 0.58       |
| `--corner-light-warm-subtle`     | 0.28        | 0.38       |
| `--corner-light-cool-subtle`     | 0.20        | 0.32       |

No other surfaces changed, no token names removed (so any custom
inline `boxShadow` styles in other components continue to compile).
The hearth body gradient + `--chip-border-base` faint white tint are
untouched.

Verified: build + lint + 113/113 vitest tests pass.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 14:06:25 -05:00
varutasu
22364de8c9
refactor(design): site-wide sweep — broken Tailwind tokens, rounded corners, SearchBar primitive (#117) (#117)
Comprehensive design sweep across the rest of the app following the
shipped Liquid Glass + corner-border-light system (#116).

## Three classes of finding

### 1. Broken Tailwind token classes (HIGH — pages were unstyled)

The decks / deck-builder / deck-detail cluster relied on Tailwind
classes that don't exist in `tailwind.config.js` (no `bg-bg-*`,
`text-text-*`, `border-border`, `bg-accent-ember`,
`focus:ring-accent-ember`, `hover:bg-accent-ember-dark`). Those classes
produced ZERO CSS — backgrounds were transparent, borders invisible,
hover states absent.

Rewrote with inline `style={{ ... CSS vars ... }}` + the `<Button>` /
`<SearchBar>` primitives + `glass-panel` surfaces:

- `pages/decks.js` (full page)
- `pages/deck/[id].js` (header, stats sidebar, group-by controls,
  card list)
- `pages/deck-builder.js` (loading spinner)
- `components/DeckBuilderView.js` (toolbar + main panel)
- `components/DeckBuilderCardBrowser.js` (full rewrite; integrated
  `<SearchBar>` for the card-picker input)
- `components/DeckBuilderDeckList.js` (full rewrite)
- `components/DeckBuilderStatsBar.js`
- `components/ManaSymbolSettings.js`
- `components/ManaSymbols.js` (single `text-text-secondary`)
- `pages/admin/card-editor.js` cluster was already clean

### 2. Duplicative / stale page searches

Replaced raw `<input>` search controls with the `<SearchBar>` primitive
(adds clear button, ember focus ring, system-consistent rounded
corners). Kept page-specific filter searches (they filter the visible
list — distinct from the global TopSearchBar command palette):

- `pages/my-cards.js`
- `pages/community/collections.js`
- `components/CardsPageView.js`
- `components/CollectionPageView.js`
- `components/DeckBuilderCardBrowser.js`

`pages/my-cards.js` filter wrapper also lifted into a `glass-panel`
chip instead of a solid `var(--bg-primary)` band.

### 3. Square corners + stale palette in shared views

- `components/CollectionPageView.js`: 10 action buttons (`rounded-lg`
  + `hover:bg-gray-50`) → `rounded-xl` + `nav-item-hover`; 4 filter
  selects (`focus:ring-purple-500 rounded-lg`) → `.input-field`;
  view-mode toggle (`bg-white text-gray-900` — invisible in dark mode)
  → tokenised; SYSTEM badge gradient (`from-blue-500 to-purple-600`)
  → ember↔flame; tooltip (`bg-gray-900`) → `glass-panel-strong`;
  search-results dropdown (`bg-white border-gray-200` — invisible in
  dark mode) → `glass-panel-strong`; Activity / game-count /
  TCG-game badges palette-aligned.
- `components/CardsPageView.js`: "Load More Cards" button
  (`bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg`) →
  `<Button variant="primary" size="lg">`.
- `components/CollectionsPageView.js`: matching SYSTEM badge +
  tooltip cleanup.
- `components/ShareModal.js`: user-search dropdown
  (`border-gray-200 hover:bg-gray-50`) and email-invite card moved
  onto `glass-panel` + `nav-item-hover`; social-share buttons
  `rounded-lg hover:bg-gray-50` → `rounded-xl nav-item-hover`.
- `components/Layout.js`: profile-menu dropdown row
  (`hover:bg-gray-50 dark:hover:bg-gray-700`) → `nav-item-hover`.
- `components/CardItem.js`: bulk-select checkbox
  `focus:ring-purple-500` → ember.

### 4. `dark:` modifier classes (broken with `[data-theme]` theming)

This app uses `[data-theme="dark"]` CSS selector theming, not
Tailwind's `class` strategy, so `dark:bg-green-900/20` etc. produced
no CSS in dark mode. Affected alerts on `pages/settings.js` and
`pages/profile.js` — replaced with `glass-panel` + semantic border
colour (flame for success, #dc2626 for error).

`pages/settings.js` sidebar nav also moved off its hardcoded full-ember
fill onto the system `nav-item` / `nav-item-active` / `nav-item-hover`
pattern for consistency with the global sidebar.

## Verification

- `npm run build` — green (Next 16 + Turbopack)
- `npm run lint` — 0 errors, 1 unrelated pre-existing warning
- `npm run test:run` — 113/113 pass (no test changes needed)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 14:06:22 -05:00
varutasu
c57b5d0406
refactor(design): move light response onto chip borders; cut directional drop shadows (#116)
Operator feedback 2026-06-04 (after PR #115's two-light-source pass):
"A lot of gradients happening, though I want the gradient in the
background to be the focus, and then the border gets a highlight,
perhaps, in a corner that's closest to the background gradients...
the borders are reflecting the color, not necessarily the drop
shadows behind the cards. In fact, you could probably reduce those
box shadows quite a bit. At least they shouldn't span the entire
edge of the card. Just a subtle bit in the corner."

PR #115's outer directional drop-shadows (`--edge-light-warm` /
`--edge-light-cool`) DID make the chips respond to the two light
sources but spanned the entire bottom-left + top-right edges, which
competed visually with the body gradient. This PR moves the
warm/cool response onto the chip's 1px BORDER, concentrated in the
two corners closest to the matching light sources, and cuts the
directional drop-shadows entirely (just rim-light-inner + a single
neutral elevation-ambient remain).

Technique: the modern gradient-border pattern.
  background:
    /* surface fill, clipped to padding-box */
    linear-gradient(surface, surface) padding-box,
    /* warm catch-light at bottom-left, border-box-clipped */
    radial-gradient(at 0% 100%, warm 0%, transparent 42%) border-box,
    /* cool catch-light at top-right, border-box-clipped */
    radial-gradient(at 100% 0%, cool 0%, transparent 42%) border-box,
    /* faint diagonal white tint for the rest of the rim */
    chip-border-base border-box;
  border: 1px solid transparent;

The transparent border is what exposes the border-box-clipped
gradient layers as the visible rim.

Token changes (styles/globals.css):
- Retired: --edge-light-warm, --edge-light-cool (both themes).
  These were complete box-shadow declarations with offsets spanning
  the full bottom-left + top-right edges of every chip.
- Added: --corner-light-warm, --corner-light-cool — plain RGBA
  colors for radial-gradient consumption.
    Light: 0.50 warm / 0.38 cool.
    Dark:  0.68 warm / 0.58 cool (bumped so catch-lights read
           against the deep substrate).
- Added: --chip-border-base — faint 135deg white tint so the rest
  of the rim isn't invisible (sensible chip edge far from either
  light source). Lower alpha on dark.

Application sites:
- styles/globals.css `.glass-panel` and `.glass-panel-strong` —
  updated to multi-layer background + transparent border. Picks
  up DailyEmberWidget, CommandPaletteModal popover, UserMenu
  dropdown, the logged-out sidebar Support/Theme tray, etc.
- components/Layout.js nav-chip inline style — same treatment.
- components/ui/TopSearchBar.js inline style — same treatment.

Shadow stacks across all four sites are now just:
  var(--rim-light-inner), var(--elevation-ambient)
(or elevation-pronounced for .glass-panel-strong). The body
gradient stays unchanged from PR #115 — it remains the dominant
gradient story per operator's "I want the gradient in the
background to be the focus" framing.

Tests:
- npm run test:run: 113/113
- npm run lint: 0 errors (1 pre-existing warning)
- npm run build: green

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 12:31:53 -05:00
varutasu
c5c31ba813
feat(design): warm↔cool gradient + edge-light response on chips (#115)
Operator feedback 2026-06-04: "increase the gradient in the
background that goes from our nice warm red to a nice purpley
blue. And then let's have the corners or the edges of our sections
respond as if those two are light sources."

Two coordinated changes that together create the "lit by two light
sources" effect.

1) Body gradient pushed harder + diagonal warm↔cool story
   (styles/globals.css body { ... } + [data-theme="dark"] body)

   - Dark theme:
     * Warm ember pool bottom-left: alpha 0.55 → 0.72, ellipse
       enlarged from 75%/55% to 95%/70%.
     * Top-right corner: switched from secondary ember + dim
       purple-magenta (0.26) to a unified bright purpley-blue pool
       (alpha 0.58, indigo→violet ramp, 90%/70% ellipse).
     * Base linear-gradient: 180deg vertical wash → 45deg diagonal
       (warm aubergine bottom-left → indigo top-right) so the whole
       page reads as one continuous warm↔cool dialogue, not just
       two corner pools.
   - Light theme:
     * Top-right gold accent replaced with purpley-blue (indigo
       0.42 → violet 0.20 → light violet 0.08) so both themes share
       the same diagonal story.
     * Bottom-left ember boosted (0.42 → 0.55, ellipse enlarged).
     * Base linear-gradient: 180deg → 45deg diagonal (warm-cream
       → cool-violet).

2) Edge-light tokens on every floating chip's shadow stack
   (styles/globals.css + components/Layout.js + components/ui/TopSearchBar.js)

   - New design tokens in :root and [data-theme="dark"]:
       --edge-light-warm  (offset -X +Y → glow on the chip's
                           bottom-left edge, matching the warm
                           ember light source)
       --edge-light-cool  (offset +X -Y → glow on the chip's
                           top-right edge, matching the cool
                           purpley-blue light source)
     Light: 0.16 warm / 0.12 cool. Dark: 0.34 warm / 0.28 cool.
   - .glass-panel and .glass-panel-strong shadow stacks updated to
     prepend the two edge-light tokens. Every glass surface in the
     app now picks up the directional rim glows from the body
     gradient without per-component changes (DailyEmberWidget,
     CommandPaletteModal popover, the logged-out sidebar Support/
     Theme tray, UserMenu dropdown, etc).
   - components/Layout.js nav-chip and components/ui/TopSearchBar.js
     inline boxShadow strings prepended with the same two tokens
     (they use inline styles rather than the .glass-panel class so
     they need the explicit shadow stack).

Result: the body now visibly transitions from warm ember (bottom-
left) through a neutral midpoint to purpley-blue (top-right). Every
floating chip's bottom-left and top-right edges pick up subtle
directional glow from the matching corner, reading as light wrapping
around the chip's rim from the two off-screen-ish light sources.

Tests:
- npm run test:run: 113/113
- npm run lint: 0 errors (1 pre-existing warning)
- npm run build: green

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 12:23:22 -05:00
varutasu
f3faeab788
refactor(layout): split sidebar into nav+badge chips; move help/theme to topbar dropdown (#114)
Operator feedback 2026-06-04:
- "make the navigation its own section, separated from the badges
  and progress"
- "move the help and theme toggle into the profile drop-down"

Changes:

components/Layout.js
- Desktop sidebar restructured: the single full-height chip is gone.
  The sidebar column is now <aside className="md:flex md:flex-col
  md:gap-4"> holding two independent floating glass chips:
    1. nav-chip (flex-1, rounded-3xl, glass-surface-mid) — logo,
       wordmark, NavigationContent, and the Sign-in CTA for logged-
       out visitors only.
    2. bottom-chip — for authenticated users this is the
       <DailyEmberWidget /> (already a self-contained glass-panel).
       For logged-out, it's a small glass-panel containing the
       Support + Theme icon row so those affordances stay reachable
       when there's no TopSearchBar dropdown.
  The visible body-gradient gap between the two chips is the new
  separation the operator asked for.
- Authenticated path no longer renders Support / Theme icons in the
  sidebar bottom — they live in the TopSearchBar <UserMenu> dropdown.
- Mobile drawer: bottom Support+Theme row is now gated behind
  `!user`. Authenticated mobile users access the same affordances
  via the TopSearchBar avatar chip (visible on all viewports; the
  name/chevron portion is hidden on < md but the chip itself stays).

components/ui/TopSearchBar.js
- The user-menu chip (previously a <Link href="/settings">) is now
  a real <UserMenu> dropdown sub-component:
    - Profile, Settings, Admin Panel (admins only), Help & Support,
      Theme toggle (button — toggles theme + closes menu),
      separator, Logout.
    - role="menu" + menuitem children; aria-haspopup + aria-expanded
      on the trigger; Escape key closes; click-outside scrim closes.
    - Uses useTheme() so the menu label flips between "Dark mode" /
      "Light mode" with the matching sun/moon icon.
    - Glass-surface-high background + rim-light + ember-rim-subtle
      + elevation-pronounced shadow stack so it reads as a popover
      above the floating header chip.

Tests:
- npm run test:run: 113/113
- npm run lint: 0 errors (1 pre-existing warning, not from this PR)
- npm run build: green

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 12:16:09 -05:00
varutasu
06534e5fba
refactor(layout): floating sidebar + floating top header (#113)
Operator feedback 2026-06-04:
- "pull out the left nav to make it look like it is its own floating
  section versus attached to the top-left"
- "this is also true for the header for the pages"
- "the divider that runs between the right side and the sidebar.
  let's make that divider go away so it all feels like one large
  page"

Changes:

components/Layout.js
- Outer flex shell: `md:p-4 md:gap-4` pulls the sidebar AND the main
  column in from the viewport edges on desktop. Mobile (< 768px) is
  unaffected because the sidebar is `hidden md:flex` already and the
  TopSearchBar / page content still run flush there.
- Desktop sidebar: `md:rounded-3xl md:overflow-hidden` so the rail
  reads as a floating chip. Dropped `var(--rim-light-outer)` from
  its box-shadow stack — that 1px outer ring was literally the
  "vertical divider between the sidebar and the right side" the
  operator wanted gone. Chip edges are now defined by
  `var(--elevation-ambient)` (drop shadow) + the glass-surface-mid
  tint against the body gradient that shows through `md:gap-4`.
- Main column: added `md:gap-4 min-w-0`. The gap separates the
  floating TopSearchBar from the page content vertically so they
  read as two distinct floating chrome elements; min-w-0 prevents
  long card titles / table cells from blowing past the column width.

components/ui/TopSearchBar.js
- `md:rounded-2xl` so the header reads as a floating chip on
  desktop. Mobile stays square (header is edge-to-edge there).
- Box-shadow: `rim-light-inner` + `elevation-ambient`. Deliberately
  no `rim-light-outer` — the prior "remove the divider" feedback
  applies to any visible 1px edge, and a hairline outer ring would
  re-introduce one on mobile where the bar abuts content directly.

Tests:
- npm run test:run: 113/113
- npm run lint: clean
- npm run build: green

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 12:07:17 -05:00