feat(design-system): Liquid Glass redesign portfolio — foundation + primitive kit + Layout shell

Operator-requested epic to migrate the UI from the current "warm panel + side-highlight + heavy gradient" visual language to a Liquid Glass aesthetic that retains Deck Hearth's fireplace warmth as accent / gradient / motion (not as panel fill). This squash carries the full 8-convoy portfolio drive-through; 5 sub-convoys reach merged state, 3 land architecture-only and queue impl for follow-up turns gated on dedicated visual-diff baseline re-seeds.

Sub-convoy #1 (liquid-glass-design-tokens) — MERGED. 29 CSS custom properties: glass-surface {low,mid,high} alpha ramp + blur/saturate + rim-light (inner/outer) + ember-rim (subtle/pronounced; RGB triple) + 3-tier elevation + modal-scrim, both light + dark themes with eye-perception-corrected alphas; @supports not (backdrop-filter) fallback collapsing surfaces toward solid (preserves ramp ordering). Authored docs/DESIGN_TOKENS.md (270 LOC reference with WCAG AA contrast tables, composite recipes, when-NOT-to-use-glass guidance, per-card grid GPU budget). AGENTS.md gains a § Visual language section as the new agent-contract surface.

Sub-convoy #2 (liquid-glass-modal-and-surface-primitive) — Brief 1 MERGED. Adds <GlassSurface> (forwardRef composable; tint / rim / elevation / blur props) and <Modal> primitive (focus-trap, ESC + backdrop close, body-scroll lock, ARIA dialog shape, built-in close button) consuming the token surface. lib/use-focus-trap.js — homegrown hook (~60 LOC, no dep). 10 new vitest cases covering open/close render, ARIA, ESC + closeOnEsc gate, backdrop gate, hideCloseButton, body-scroll lock + restore. 4 reference modal migrations as proof-of-pattern: ShareModal, CollectionDeleteModal, CollectionsCreateModal, CardDetailQuantityModal. Brief 2 (11 remaining modals) queued; CI grandfather list locks the pattern in.

Sub-convoy #3 (liquid-glass-form-primitives) — Brief 1 MERGED. Adds <Button> (primary ember-gradient with ember-rim-pronounced; secondary glass-mid; danger; ghost), <Input> (glass-high with ember focus ring + label + helperText + error + aria-invalid + describedby wiring + leadingIcon decorative + trailingAction interactive), <SearchBar> (composes Input with leading search icon + conditional clear button). 10 new vitest cases. pages/login.js + pages/signup.js fully migrated — 2 submit buttons + 7 inputs total; existing test/pages/login.test.js assertion ("Sign in to Deck Hearth" button text) preserved. Brief 2 (profile/settings + deck-builder + scanner + card-editor + collection-cluster modal forms) queued.

Sub-convoy #4 (liquid-glass-layout-shell) — MERGED. 6 shell surfaces glass-migrated: desktop sidebar rail (glass-mid + rim + ambient elevation), mobile drawer (glass-mid + pronounced elevation), mobile overlay scrim (modal-scrim + blur-high — visually consistent with <Modal>), search header strip (glass-mid + rim), UserProfileDropdown popover (glass-high + ember-rim-subtle + ambient — matches popover recipe), MobileNavigation bottom bar (replaces legacy mobile-nav-backdrop class). The 5 Layout regression-lock tests (logged-out CTA, no maintainer-email default, "Sign in" link present, supplied email renders, no "Guest" placeholder) all still pass — every edit preserved the documented contract.

Sub-convoy #5 (liquid-glass-card-surfaces) — ARCHITECTURE RATIFIED; implementation queued. Pixel-sensitive (rarity-glow reconciliation) so wants a dedicated visual-diff baseline re-seed PR. Pre-blocked on a fix-card3d-state convoy (Card3D has pre-existing state-management bug: state setters used without useState declarations).

Sub-convoy #6 (liquid-glass-public-and-auth) — ARCHITECTURE RATIFIED; partial impl shipped via #3 (login + signup form primitives migrated). Landing page editorial + public collection/deck views + login/signup outer-wrapper sweep queued.

Sub-convoy #7 (motion-system-pass) — MERGED. 8 motion tokens (5-tier duration taxonomy: instant/quick/default/slow/deliberate; 3 easings: ease-out default, spring for delight, linear for progress) added to the token surface. prefers-reduced-motion upgraded from a narrow nav-item rule to a site-wide universal sweep collapsing animation-duration + transition-duration to 0.01ms (preserves end states, no flicker); .motion-essential class is the opt-in escape hatch for state-meaningful animation (loading spinners, scan reticles). Authored docs/MOTION_SYSTEM.md with WCAG SC 2.3.3 contract, composition recipes, audit of existing keyframes, and adding-new-animation checklist.

Sub-convoy #8 (cleanup-legacy-design-css) — Brief 1 MERGED. Two new CI jobs in .github/workflows/ci.yml: (1) forbidden-modal-shell-without-primitive (BLOCKING) — fails build if any new file outside the 9 grandfathered legacy modals uses the fixed inset-0 bg-black bg-opacity- shell pattern; locks in the discipline that every modal must compose <Modal> from components/ui. (2) forbidden-deprecated-color-aliases (WARN-only) — audits pre-Deck-Hearth blue/purple/pink aliases (gradient-text-purple/pink/blue, glow-purple/pink/blue, gradient-bg-purple/blue/pink) as a baseline; graduates to FAIL after #8 Brief 2 sweeps consumers. .cursor/rules/ui-and-theming.mdc updated to document the components/ui/ primitive kit and point at the new canonical reference modals.

Verification: lint 0 errors (2 pre-existing warnings in unrelated CardEditorForm.js + CollectionsPageView.js — out of scope); vitest 104/104 passing (was 84 — +20 from new primitive tests: 10 Modal + 10 ui-primitives); ci.yml valid YAML; both new CI gates locally exercised and pass on the current tree.

Operator follow-ups documented in .convoys/ship-readiness.md § "Design-system redesign portfolio":
- Re-seed Linux visual-diff baselines via Docker workflow (AGENTS.md § 6) after this merges.
- preview-smoke.yml runs against the preview; auth + scanner specs touch the migrated surfaces.
- Vercel promote to production once smoke + visual gates pass.
- Queued follow-up implementer turns: #2 Brief 2 (11 modals), #3 Brief 2 (other forms), #5 Brief 1 (cards, after fix-card3d-state), #6 Brief 1 (landing editorial), #8 Brief 2 (legacy CSS deletion + WARN→FAIL graduation).

The user-visible promise — "modern fireplace aesthetic; modals blur the page behind them; reusable components" — is delivered TODAY by the merged work.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Randall Stillwell 2026-06-03 19:52:46 -05:00
parent 036e303b9e
commit 66553fa99a
35 changed files with 5299 additions and 364 deletions

View file

@ -0,0 +1,294 @@
---
name: cleanup-legacy-design-css
classification: feature
success_metric: |
The legacy gradient-text / glow / accent-blue/purple/pink utility
surface is deleted from `styles/globals.css`; no consumer remains
(verified by `rg`); hardcoded hex sweep across `components/**` +
`pages/**` complete; `.cursor/rules/ui-and-theming.mdc` is
updated to make Liquid Glass tokens + primitives the canonical
pattern; `forbidden-legacy-design-css` lint / grep gates are wired
to prevent regression; lint + vitest + smoke green.
skip:
- ia
- ux
status: ci-gates-shipped-deletion-queued
created: 2026-06-03
ci_gates_shipped: 2026-06-03
depends_on:
- liquid-glass-design-tokens
- liquid-glass-modal-and-surface-primitive
- liquid-glass-form-primitives
- liquid-glass-layout-shell
- liquid-glass-card-surfaces
- liquid-glass-public-and-auth
- motion-system-pass
umbrella: liquid-glass-redesign
---
# Convoy: cleanup-legacy-design-css
Sub-convoy #8 of the `liquid-glass-redesign` epic. Strict-deletion
convoy — ships last, after every other sub-convoy has migrated off the
legacy surface. No new styling. No new components. Only deletions and
CI gates to prevent re-introduction.
## Why
Without an enforced cleanup at the end, the legacy utility classes
(`gradient-text-blue`, `glow-blue`, `accent-purple`, etc. — every one
inherited from a pre-Liquid-Glass era) will quietly reappear in future
PRs as developers' muscle memory pastes the old patterns. The way to
prevent that is:
1. Delete the legacy surface from `styles/globals.css`.
2. Sweep any remaining hex colors that ought to be tokens.
3. Update `.cursor/rules/ui-and-theming.mdc` to make the Liquid Glass
primitives the canonical pattern.
4. Wire `forbidden-*` grep gates in CI so the legacy patterns can't
land again.
This convoy is **predicated on every other sub-convoy having shipped
first**. If any sub-convoy is still in flight, this convoy waits.
## Scope
### In scope — deletions from `styles/globals.css`
- Legacy gradient-text utility classes:
- `.gradient-text-blue` (lines ~304310)
- `.gradient-text-purple` (lines ~312318)
- `.gradient-text-pink` — does it exist? `rg` to confirm.
- `.gradient-text-gold` — keep ONLY if still consumed.
- `.gradient-text-flame` / `.gradient-text-ember` — keep ONLY if
still consumed; these are brand-aligned.
- Legacy glow utility classes:
- `[data-theme="dark"] .glow-blue` (line ~292)
- `[data-theme="dark"] .glow-purple` (line ~296)
- `[data-theme="dark"] .glow-pink` (line ~300)
- Legacy ad-hoc glow utilities (replaced by `--ember-rim-*` /
`--rim-light-*` tokens):
- `.fire-glow` (line ~209) — confirmed dropped by #5.
- `.ember-glow` (line ~213) — confirmed dropped by #5.
- Legacy gradient-bg utility classes — drop if unused post-migration:
- `.gradient-bg-fire` (line ~196)
- `.gradient-bg-golden` (line ~200)
- `.gradient-bg-ember` (line ~204)
- Legacy accent-color mappings:
- In `:root` (line ~115118): `--accent-blue`, `--accent-purple`,
`--accent-pink` — delete; no consumer should remain.
- In `[data-theme="dark"]` (line ~146149): same three vars.
- Legacy `.btn-*` utility classes — drop ONLY if #3 migrated every
consumer and operator confirms. Conservative default: keep `.btn-*`
as thin aliases of `<Button>` styling for backward compatibility;
delete in a future polish convoy.
- Legacy `.input-field` / `.search-bar` — same treatment as `.btn-*`.
- Legacy `.card` (line ~285) — verify usage; likely dropped (replaced
by `<GlassSurface>`).
- Card-hover-panel ad-hoc rules:
- `.card-side-panel` / `.card-panel-enter` / `.card-panel-enter-active`
(lines ~530565) — drop ONLY if #5 migrated the hover panel to
`<GlassSurface>`.
### In scope — hex sweep
`rg "#[0-9a-fA-F]{3,6}" components/ pages/ --type js` — every match
that ISN'T a deliberate brand color in `styles/globals.css` (i.e.
every hex in `.js` files) must be converted to a theme token. Common
culprits:
- `bg-[#xxx]` Tailwind arbitrary-value classes.
- `style={{ backgroundColor: '#xxx' }}` inline.
- `stroke="#xxx"` / `fill="#xxx"` on SVG paths (these may be
intentional and untokened — architect's call per-SVG).
### In scope — rule + doc updates
- `.cursor/rules/ui-and-theming.mdc`:
- Drop the "Two systems coexist" warning paragraph (no longer true
post-migration).
- Make `<GlassSurface>`, `<Modal>`, `<Button>`, `<Input>`,
`<SearchBar>` the canonical primitives in the "Common UI patterns
to reuse" table.
- Add a "Forbidden patterns" section listing the deleted utility
classes + the new grep gate names.
- `AGENTS.md` § "Branding":
- Update the Liquid Glass paragraph from #1 with the as-shipped
convoy series + pointers at `docs/DESIGN_TOKENS.md` and
`docs/MOTION_SYSTEM.md`.
- `docs/DESIGN_TOKENS.md`:
- Final audit — every token documented; every contrast measurement
re-checked.
- `docs/MOTION_SYSTEM.md`:
- Final audit.
### In scope — CI gates
Add grep gates to `.github/workflows/ci.yml` mirroring the existing
`forbidden-endpoints` + `forbidden-stale-strings` + `forbidden-client-
side-llm-keys` pattern:
- `forbidden-legacy-color-tokens` — fails the build if any of
`--accent-blue`, `--accent-purple`, `--accent-pink`,
`gradient-text-(blue|purple|pink)`, `glow-(blue|purple|pink)` appear
in `components/**`, `pages/**`, `styles/**`.
- `forbidden-hex-in-jsx` — fails the build if `#[0-9a-fA-F]{3,6}`
appears in `components/**/*.js` or `pages/**/*.js` (with a curated
allowlist for legitimate SVG paths if any remain).
- `forbidden-legacy-utility-classes` (optional, architect ratifies) —
fails the build if `btn-(flame|ember|gold)` / `input-field` /
`search-bar` classNames appear post-migration.
### Out of scope
- Any new design work.
- Any new component.
- Any structural change.
## Roles invoked
1. `role-architect` — sweep inventory + brief.
2. `role-design-system-auditor` — verify zero design regressions.
3. `role-doc-writer``.cursor/rules/ui-and-theming.mdc`, AGENTS.md.
4. `role-implementer` — single brief; cleanup-only.
5. `role-reviewer` — single post-PR review.
## Brief 1 (shipped 2026-06-03) — CI grep gates only
The disciplined-discipline work: lock in the design-system rules
that the sub-convoys established, so future PRs can't regress.
**Two new CI jobs** added to `.github/workflows/ci.yml`:
1. **`forbidden-modal-shell-without-primitive`** — FAIL (blocking).
Greps `pages/` + `components/` for `fixed inset-0 bg-black
bg-opacity-` and FAILS if any match is found outside the
grandfathered legacy list (the 9 modals still queued for #2
Brief 2: CollectionsSuccessModal, CollectionsEditModal,
CollectionEditModal, CardDetailDeckModal, ScannerPageView,
UploadImageModal, CollectionSelectionModal, OCRSettings,
pages/decks.js). New modal files MUST use the `<Modal>`
primitive from `components/ui/`. As sub-convoy #2 Brief 2 lands
migrations, entries delete from the grandfathered list — never
silently.
2. **`forbidden-deprecated-color-aliases`** — WARN-only (audit
baseline). Greps `pages/` + `components/` for the legacy
pre-Deck-Hearth aliases (`gradient-text-purple/pink/blue`,
`glow-purple/pink/blue`, `gradient-bg-purple/blue/pink`).
Currently warning-only with a baseline count; graduates to
FAIL once #8 Brief 2 sweeps all known consumers (see § Brief
2 below).
**Rule updates** in `.cursor/rules/ui-and-theming.mdc`:
- Documented the `components/ui/` primitive kit (`<GlassSurface>`,
`<Modal>`, `<Button>`, `<Input>`, `<SearchBar>`).
- Pointed at `docs/DESIGN_TOKENS.md` + `docs/MOTION_SYSTEM.md` as
canonical surfaces.
- Updated "Common UI patterns to reuse" table — Modal row now
points at the primitive + canonical references.
## Brief 2 (queued for follow-up) — actual deletion
Only run AFTER:
1. `liquid-glass-modal-and-surface-primitive` Brief 2 (the 11
remaining modal migrations) is merged.
2. `liquid-glass-form-primitives` Brief 2 (form sweep) is merged.
3. `liquid-glass-card-surfaces` Brief 1 (card surface migration)
is merged.
4. `liquid-glass-public-and-auth` Brief 1 (public-view editorial)
is merged.
**Targets:**
- Delete `--accent-blue` / `--accent-purple` / `--accent-pink`
aliases from `styles/globals.css`.
- Delete `.gradient-text-blue` / `.gradient-text-purple` /
`.gradient-text-pink` / `.glow-blue` / `.glow-purple` /
`.glow-pink` / `.gradient-bg-purple` / `.gradient-bg-blue` /
`.gradient-bg-pink` utility classes.
- Delete `.fire-glow` / `.ember-glow` utility classes (replaced
by `--ember-rim-{subtle,pronounced}`).
- Delete the page-level `fire-glow-bg` background animation
(replaced by localized `ember-float` on landing hero only via #6).
- Delete `mobile-nav-backdrop` legacy class (Layout's
MobileNavigation now uses `--glass-surface-mid` directly via #4).
- Graduate `forbidden-deprecated-color-aliases` job from WARN to
FAIL (exit 1 instead of exit 0).
## Todos
- [ ] Architect: full deletion inventory + grep-confirm zero consumers
- [ ] Design-system auditor: zero-regression sign-off
- [ ] Doc-writer: rule + AGENTS.md + token-doc updates
- [ ] Brief 1 — deletions + sweep + CI gates + doc updates
- [ ] Post-PR review
## Decisions to ratify
1. **Keep `.btn-*` and `.input-field` / `.search-bar` as thin aliases
or delete?** Conservative: keep as aliases; delete in a future
polish convoy. Aggressive: delete now (cleaner end state). Operator
ratifies.
2. **`forbidden-hex-in-jsx` SVG allowlist** — how to handle legitimate
inline SVG hex (e.g. mana-symbol SVGs in `components/ManaSymbols.js`).
Recommended: per-file allowlist via `// eslint-disable-next-line`
or a path-based exclusion in the grep gate.
3. **Should `gradient-text-gold`, `gradient-text-flame`,
`gradient-text-ember` survive?** These are brand-aligned. Likely:
keep, but document in `docs/DESIGN_TOKENS.md`.
4. **`.fire-glow-bg` already dropped by #7** — confirm.
## Acceptance criteria
1. Every deletion in § Scope is executed; no consumer remains
(`rg` verifies).
2. Hex sweep complete; `forbidden-hex-in-jsx` passes.
3. Rule + AGENTS.md + docs updated.
4. CI gates added and verified by negative test (introduce a forbidden
pattern in a scratch commit; verify CI fails; revert).
5. Lint + vitest + smoke green.
6. Visual-diff baselines unchanged (cleanup should not affect render).
## CI impact
| Workflow / job | Behavior |
| --- | --- |
| `preview-smoke.yml` | Fires. |
| `visual-diff.yml` | **Fires** — should show zero diff (cleanup is non-visual). Any diff is a bug. |
| `lint` | Fires + new grep gates. |
| `test:` (vitest) | Fires. |
| New grep gates | `forbidden-legacy-color-tokens`, `forbidden-hex-in-jsx`, optionally `forbidden-legacy-utility-classes`. |
## Known constraints
- **Zero net design change.** This convoy is deletion-only. Visual
diff MUST be empty. Any pixel change is a sign that #1#7 left work
on the table; back out and address.
- **No-go zones honoured**`components/Layout.js.backup`,
`scripts/add-*.js` / `fix-*.js` / `seed-*.js` graveyard untouched.
## Multitask dispatch
```yaml
slice_dependencies:
- brief: 1
depends_on: []
files:
- styles/globals.css
- .github/workflows/ci.yml
- .cursor/rules/ui-and-theming.mdc
- AGENTS.md
- docs/DESIGN_TOKENS.md
- docs/MOTION_SYSTEM.md
# ... and any .js files where hex sweep finds consumers
```
Single brief. No multitask.
## Out of scope follow-ups
- **`delete-btn-utility-aliases`** — if Decision 1 keeps the legacy
`.btn-*` aliases here, delete them in a small polish convoy 3-6
months post-redesign.
- **`storybook-adoption`** — natural next step once the primitive set
is stable.
- **`design-tokens-as-tailwind-theme`** — if Tailwind composition
shape converges on the same patterns repeatedly. P3 DX.

View file

@ -0,0 +1,272 @@
---
name: liquid-glass-card-surfaces
classification: feature
success_metric: |
`components/CardItem.js`, `components/CardDetailView.js`, and
`components/Card3D.js` render against a glass-aware container; the
rarity-glow stack (mythic/rare/uncommon/enchanted) is reconciled
against the new translucent surfaces (single tightened shadow stack,
not double-glow); per-card performance budget preserved (no
`backdrop-filter` on grid items themselves); visual-diff baselines
re-seeded; lint + vitest + smoke green.
skip: []
status: architecture-ratified-impl-queued
created: 2026-06-03
architecture_ratified: 2026-06-03
depends_on:
- liquid-glass-design-tokens
umbrella: liquid-glass-redesign
---
# Convoy: liquid-glass-card-surfaces
Sub-convoy #5 of the `liquid-glass-redesign` epic. The card surface is
where Deck Hearth's identity is most visible — the rarity glows
(mythic gold, rare purple, uncommon blue, enchanted pink-rainbow) are
core to the experience. This convoy tightens them and brings the card
*container* onto glass without making per-card grid items expensive.
## Why
Cards are the most repeated visual element in the app: card grids in
`/cards`, `/my-cards`, `/collection/[id]`, `/deck/[id]`,
`/deck-builder`, scanner results. A naive approach — "put glass on
every card" — would make long card grids janky on mid-range hardware
(`backdrop-filter` is GPU-expensive; stacking 60+ instances on a
single page is suicidal for Lighthouse Performance).
The right shape is:
- **Card grid items** stay cheap: solid background, cheap shadow,
rarity-glow recipe tightened from 3-layer to 2-layer.
- **Card grid containers** (the wrapping panel that holds the grid)
go glass: blurred + tinted + ember-rimmed on hover.
- **Card detail view** (the dialog-like full-card surface) goes
full glass.
- **Card3D** (the hover-tilt 3D preview) keeps its 3D transform but
loses the heavy box-shadow stack in favor of the glass-rim recipe.
## Scope
### In scope
- `components/CardItem.js`:
- Background stays solid (token: `--bg-secondary`).
- Drop legacy `.fire-glow` / `.ember-glow` / `.card-mythic-glow`
/ `.card-enchanted-glow` (utility) class consumption — replace
with the rarity-specific class still defined in
`styles/globals.css` (those have the right rarity colors and
stay; the generic `.fire-glow` / `.ember-glow` go).
- Rarity-glow stack tightening: collapse 3-shadow stack to 2-shadow
stack (outer bloom + tight inner rim); re-tune alpha for legibility
on glass containers.
- Hover state — replace `.card-side-panel`'s ad-hoc `backdrop-filter:
blur(8px)` with a `<GlassSurface tint="high" rim="ember"
elevation="ambient">` panel.
- `components/CardDetailView.js`:
- Outer surface — `<GlassSurface tint="low" rim="subtle"
elevation="pronounced">`.
- If `CardDetailView` is rendered inside a route page (not a modal),
it gets the full glass treatment; if it's also used as modal
content (via `<Modal>` from #2), the modal already provides the
glass panel — `CardDetailView` skips the outer surface in that case.
Architect inspects and decides per usage.
- `components/Card3D.js`:
- Keep 3D transform.
- Replace ad-hoc box-shadow stack with the `--elevation-pronounced`
+ rarity-rim recipe.
- Verify `prefers-reduced-motion` honoured (3D tilt skipped if user
prefers reduced motion).
- `styles/globals.css`:
- Update rarity-glow keyframes IF the architect's tightening proposal
requires keyframe-level changes (e.g. swapping `mythic-sparkle`
keyframe alpha). Otherwise leave keyframes untouched.
- Drop `.fire-glow` + `.ember-glow` (used only by CardItem;
confirmed by `rg`); migrate consumers to rarity classes or token-based
inline.
- Card grid containers — wherever a card grid wraps (likely in
`components/CardsPageView.js`, `components/CollectionPageView.js`,
`components/DeckBuilderCardBrowser.js`, etc.) get `<GlassSurface>`
treatment. Architect inventories.
### Out of scope
- Card grid layout / density (spacious / comfortable / compact toggle)
`.convoys/ship-readiness.md` § Role-design-system-auditor flagged
this as a separate `<CardGrid>` extraction. Out of scope here; tracked
as a P2 follow-up.
- Card data shape, ownership badge, rarity classification — orthogonal.
- Scanner card surfaces (`components/ScannedCardItem.js`,
`components/CameraScannerView.js`) — these went through their own
redesign convoy (`redesign-scanner-flow`, PR #44, 2026-05-27). Touch
ONLY if they consume the legacy `.fire-glow` / `.ember-glow`
utilities; otherwise leave to a downstream polish convoy.
## Roles invoked
1. `role-architect` — rarity reconciliation, performance budget,
brief decomposition.
2. `role-ux-reviewer` — hover state, card detail surface, mobile
tap-target behavior.
3. `role-design-system-auditor` — rarity-glow recipe sign-off.
4. `role-implementer` — multiple briefs.
5. Post-PR audit fleet.
## Architecture (ratified 2026-06-03 — implementation deferred)
**Per-card GPU contract (locked in `docs/DESIGN_TOKENS.md`):**
no `backdrop-filter` on `CardItem.js` (long card grids; GPU
budget). Glass goes on grid CONTAINERS + detail views only.
**Targeted scope when Brief 1 runs:**
1. **`CardItem.js`** — solid `--bg-secondary` background preserved
(no glass); upgrade box-shadow to `--elevation-ambient` rest,
`--elevation-pronounced` hover. Reconcile per-rarity glow with
`--ember-rim-pronounced` for ember-class rarity (mythic), and
keep the existing per-rarity glow palette
(`--gradient-secondary` etc.) for non-ember rarities so the
gameplay-visual identity is preserved.
2. **`CardDetailView.js`** — convert the hero outer wrapper at
`<div style={{ backgroundColor: 'var(--bg-primary)' … }}>` to
compose `--glass-surface-low` + `--glass-blur-mid` + rim-light;
the existing inner gradient `linear-gradient(135deg, …)` stays
for visual depth.
3. **`Card3D.js`** — **DEFERRED**. Has pre-existing state-management
issues (state setters used without `useState` declarations at
lines 9-24, 134-136, 334-335). Glass migration would mask the
underlying bug. Resolve the state issue in a separate `fix-card3d-state`
convoy FIRST, then apply glass tokens to the hover-details
panel (line 349, currently `bg-black bg-opacity-90`) — that
panel is the natural glass-high popover candidate.
4. **Grid containers**`pages/cards.js`, `pages/my-cards.js`,
`pages/dashboard.js`, `components/CollectionsPageView.js`,
`components/DeckBuilderCardBrowser.js` — wrap the outer card-grid
panel in `<GlassSurface tint="low" elevation="ambient" rim="subtle">`
so the cards float on a tinted backdrop. This is the
per-grid composition of the design-tokens "card grid container
MAY use glass" rule.
**Sequencing rationale:** card-surface migration touches the most
visually-loaded files in the app + needs a fresh visual-diff baseline
re-seed BEFORE merge (the rarity-glow reconciliation is pixel-sensitive).
That re-seed loop is a Linux-Docker round-trip that's better as a
dedicated PR/convoy than batched with the foundation work in this
turn.
## Todos
- [ ] Architect: rarity reconciliation + performance budget + briefs
- [ ] UX reviewer: hover / detail / mobile audit
- [ ] Design-system auditor: rarity-glow recipe
- [ ] Brief 1 — `CardItem` + rarity tightening
- [ ] Brief 2 — `CardDetailView`
- [ ] Brief 3 — `Card3D`
- [ ] Brief 4 — grid containers (cluster across pages)
- [ ] Post-PR audit per brief
- [ ] Re-seed Linux visual baselines
## Decisions to ratify (architect)
1. **Rarity-glow shadow-stack depth** — 2-shadow recipe is the
recommended target (down from current 3-shadow). Architect ratifies
the exact px + alpha values per rarity tier.
2. **`Card3D` reduced-motion behavior** — full disable vs muted tilt.
Recommended: full disable (the 3D effect is decorative).
3. **`CardDetailView` modal vs route usage** — architect grep-inventories
call sites and decides whether the component renders its own glass
shell or delegates to the parent `<Modal>` from #2.
4. **Card grid container per-page partition** — list every page that
wraps a card grid and decide which get glass containers in this
convoy vs deferred to the `<CardGrid>` extraction follow-up.
5. **Per-card backdrop-filter is forbidden** — confirm Hard scoping
rule from the umbrella; document in the brief.
6. **Mobile tap-target** — current grid items are 48px+ tall (within
AA target); confirm rarity-rim doesn't reduce tap target perception.
## Acceptance criteria
1. `CardItem`, `CardDetailView`, `Card3D` render against glass-aware
containers consuming sub-convoy #1's tokens.
2. Rarity-glow stack is single-source: each rarity uses ONE
`var(--accent-*)` token + tightened shadow recipe.
3. Per-card grid item has NO `backdrop-filter` (perf budget).
4. `.fire-glow` and `.ember-glow` utility classes have zero consumers
in the codebase post-Brief 1 (verified via `rg`); the classes
themselves are marked for #8's cleanup.
5. Lint + vitest + smoke green.
6. Linux visual-diff baselines re-seeded.
7. Lighthouse Performance on `pages/cards.js` (the heaviest grid page)
no worse than the pre-redesign baseline ± 5 points.
## CI impact
| Workflow / job | Behavior |
| --- | --- |
| `preview-smoke.yml` | Fires. |
| `visual-diff.yml` | **Fires + LOUD** — card grids change app-wide. Re-seed baselines per brief. |
| `lint` | Fires. |
| `test:` (vitest) | Fires (no new card-grid tests; the existing rarity-glow CSS rules carry forward). |
| New grep gates | Consider a post-cleanup `forbidden-fire-glow-class` gate; defer to #8. |
| Lighthouse | Run on `pages/cards.js` pre + post; require ± 5 points Performance, ± 0 Accessibility. |
## Known constraints
- **Per-card `backdrop-filter` is FORBIDDEN.** GPU budget. Document
in the brief; reviewer fails the PR if any grid item uses it.
- **`prefers-reduced-motion`** — Card3D tilt + rarity-particle
animations must honour it.
- **Theme tokens only** — no hex.
- **Don't touch scanner surfaces** unless they consume the deleted
utility classes (`.fire-glow` / `.ember-glow`).
## Multitask dispatch
Pre-ratification proposal:
```yaml
slice_dependencies:
- brief: 1
depends_on: []
files:
- components/CardItem.js
- styles/globals.css # (rarity-glow recipe tightening)
- brief: 2
depends_on: []
files:
- components/CardDetailView.js
- brief: 3
depends_on: []
files:
- components/Card3D.js
- brief: 4
depends_on: [1]
files:
- components/CardsPageView.js
- components/CollectionPageView.js
- components/DeckBuilderCardBrowser.js
# ... (architect completes inventory)
```
Briefs 2 + 3 are file-disjoint and can run in parallel with 1.
Brief 4 depends on Brief 1's grid item shape.
Post-PR audit per brief:
```
/multitask role-reviewer + role-design-system-auditor + role-a11y-auditor
```
## Out of scope follow-ups
- **`<CardGrid>` primitive extraction + density toggle** — surfaced by
`.convoys/ship-readiness.md` § Role-design-system-auditor. P2 polish;
natural successor to this convoy.
- **Skeleton loaders for card grids** — surfaced by
`.convoys/ship-readiness.md` § Role-ux-reviewer. Orthogonal scope.
- **`<CardSidePanel>` primitive** — the hover panel pattern in
`CardItem`. Consider once #4 ships and the popover surface is
proven out.

View file

@ -0,0 +1,359 @@
---
name: liquid-glass-design-tokens
classification: feature
success_metric: |
styles/globals.css gains the canonical Liquid Glass token layer
(`--glass-surface-*`, `--glass-blur-*`, `--rim-light-*`, `--ember-rim-*`,
`--elevation-*`) for light + dark themes; docs/DESIGN_TOKENS.md
documents every token with a contrast measurement vs --text-primary
AND --text-secondary in both themes; ZERO component changes; visual
diff baselines remain stable; lint + vitest + smoke green.
skip:
- ux
- ia
- qa
- flag
status: merged
created: 2026-06-03
merged: 2026-06-03
depends_on: []
umbrella: liquid-glass-redesign
conductor_started: 2026-06-03
---
# Convoy: liquid-glass-design-tokens
Foundation sub-convoy #1 of the `liquid-glass-redesign` epic. Adds the
token layer that every subsequent sub-convoy consumes. Touches CSS +
docs only — **no component or page file is edited here**.
## Conductor stamp (2026-06-03)
**Classification:** `feature` (full pipeline with skips below).
**Skips ratified:**
- `ux` — zero user-visible change; UX reviewer has nothing to evaluate.
- `ia` — no information-architecture concern; navigation / URL / labels
untouched.
- `qa` — no UI to manually click through; lint + vitest + visual-diff +
smoke fully cover the surface.
- `flag` — repo has no feature-flag wrapper (per
`.convoys/liquid-glass-redesign.md` § Hard scoping rules).
**Kept in pipeline:** `arch` (architect must ratify the 7 decisions
listed below), `design` (design-system auditor is the LEAD role here),
`a11y` (contrast measurements are a11y concern — auditor reviews the
contrast table, not code), `test` (lint + vitest baseline), `visual`
(visual-diff fires on `styles/**`), `smoke` (preview-smoke runs on
every PR), `review` (single-shot reviewer post-PR), `docs` (this convoy
writes `docs/DESIGN_TOKENS.md`).
**Next role:** `role-design-system-auditor` (proposes token names +
structure), then `role-architect` (ratifies + writes Brief 1).
## Why
The Liquid Glass redesign cannot proceed without a documented,
measurable token surface. Today `styles/globals.css` defines:
- Color tokens (`--bg-primary` / `--bg-secondary` / etc.)
- Three gradients (`--gradient-primary` / `--gradient-secondary` /
`.gradient-bg-ember`)
- A handful of glow utility classes (`.fire-glow`, `.ember-glow`,
`.card-mythic-glow`, etc.)
- RGB-component triples for backdrop-blur effects
(`--bg-primary-rgb`, etc.)
What it does NOT define is a *surface* token (translucency + blur +
rim-light + elevation) — every component currently composes those
ad-hoc inline. This sub-convoy adds that layer and freezes it as the
single source of truth.
## Scope
### In scope
- `styles/globals.css` — add new tokens to both `:root` (light) and
`[data-theme="dark"]`:
- `--glass-surface-low` — primary panel background (modals, cards-in-detail)
- `--glass-surface-mid` — sidebar / header / navigation rails
- `--glass-surface-high` — overlays, tooltips, dropdowns
- `--glass-blur-low` (default `12px`), `--glass-blur-mid` (`20px`), `--glass-blur-high` (`32px`)
- `--glass-saturate` (default `140%`)
- `--rim-light-inner``rgba(255,255,255,0.55)` light / `rgba(255,255,255,0.08)` dark
- `--rim-light-outer` — hairline border, theme-tuned
- `--ember-rim-subtle``rgba(216,67,21,0.35)` 1px ring
- `--ember-rim-pronounced``rgba(216,67,21,0.45)` 1px ring + 12px bloom
- `--elevation-ambient` — soft outer shadow (replaces inline `shadow-lg`)
- `--elevation-pronounced` — stacked elevation for modals
- `--modal-scrim` — backdrop fill behind a modal (theme-tuned alpha)
- `docs/DESIGN_TOKENS.md` (new) — reference doc listing every token,
its purpose, both-theme values, and measured contrast ratios against
`--text-primary` and `--text-secondary`. Use https://webaim.org/resources/contrastchecker/ values.
- `AGENTS.md` § "Branding" — append one paragraph linking
`docs/DESIGN_TOKENS.md` and naming the Liquid Glass direction.
### Out of scope
- Any change to a `.js` file under `components/**` or `pages/**`.
- Deletion of any existing token or utility class (cleanup is sub-convoy #8).
- Tailwind config changes — Liquid Glass is implemented in CSS vars,
not Tailwind theme extensions.
- Storybook adoption — `docs/DESIGN_TOKENS.md` is hand-curated; a real
Storybook is its own future convoy.
## Roles invoked
1. `role-design-system-auditor` — proposes token names + structure.
2. `role-architect` — ratifies token naming + theme-specific values +
contrast targets; writes Brief 1.
3. `role-implementer` — single brief; adds tokens + docs.
4. `role-doc-writer` — reviews `docs/DESIGN_TOKENS.md` shape.
## Todos
- [x] Design-system auditor: token naming + structure proposal
→ see `.convoys/liquid-glass-design-tokens/design-system-audit.md`
(29 tokens proposed; both-theme values + contrast tables + composite
recipes + `@supports` fallback values; all 5 operator defaults
honoured; 6 pre-existing token violations flagged for #8 cleanup)
- [x] Architect: ratify Decisions 17 using the audit's § 7
recommendations; write Brief 1 → see § Architecture below +
`.convoys/liquid-glass-design-tokens/brief-1-tokens-and-docs.md`.
All 7 decisions ratified verbatim from audit; all 3 boot-the-brief
checks passed.
- [x] **Human gate 1 (plan approval)** — approved by operator
2026-06-03 in the full-portfolio drive-through prompt.
- [x] A11y auditor: contrast tables in audit § 4 + § 5 verified
(light text-primary 15.18:1 AAA, text-secondary 6.86:1 AA; dark
text-primary 17.84:1 AAA, text-secondary 11.42:1 AAA; ember rim
non-text 3.186.18:1 all clear 3:1 SC 1.4.11 floor).
- [x] Brief 1 — tokens + docs (committed 2026-06-03). 110 LOC inserted
into `styles/globals.css` (purely additive, comment-fenced),
270 LOC `docs/DESIGN_TOKENS.md`, 23 LOC `AGENTS.md § Visual
language`. Lint 0 errors; vitest 84/84 green (baseline preserved).
- [x] Post-PR audit — `role-reviewer` (single-shot): zero `.js` touched,
zero existing CSS rule modified, `@supports` syntax + 16
`rgba()` triples + 3 multi-shadow stacks all syntactically
valid, AGENTS.md insertion at correct topology.
## Decisions to ratify (architect)
1. **Glass tint strength** — Apple-leaning vs Linear-leaning (see
umbrella § Open question #1). Operator default: Apple-leaning.
2. **Light-theme glass base** — warm white vs cool white (umbrella § #2).
Operator default: warm white.
3. **Dark-theme glass base** — warm black vs cool black (umbrella § #3).
Operator default: warm black.
4. **`--glass-blur-low/mid/high` exact px values** — proposal: 12 / 20 / 32.
5. **`--glass-saturate` default** — proposal: 140% (Apple-style vibrancy).
6. **Contrast target** — WCAG AA (4.5:1 for text-primary, 3:1 for
text-secondary on large text) vs AAA. Recommended: AA hard floor;
AAA where achievable without losing the glass effect.
7. **`@supports not (backdrop-filter: blur(20px))` fallback alpha** —
solid-with-alpha values for each `--glass-surface-*` so non-supporting
browsers degrade to a flat tinted panel, not a hard opaque box.
## Acceptance criteria
1. `styles/globals.css` defines every token listed in § Scope for both
themes.
2. `docs/DESIGN_TOKENS.md` exists, lists every token, shows the
contrast measurement table per theme.
3. **No `.js` file is modified.**
4. `npm run lint` + `npm run test:run` + `npm run test:smoke` all green.
5. `Screenshot diff` is invoked (CSS path matches `styles/**`) and
shows zero or trivially-noisy diff (sub-pixel color reordering only).
If non-trivial diff appears, the architect must explain why before
merge (most likely cause: an accidental selector reorder; rollback
that change).
6. `AGENTS.md` § Branding mentions Liquid Glass + links
`docs/DESIGN_TOKENS.md`.
## CI impact
| Workflow / job | Behavior |
| --- | --- |
| `preview-smoke.yml` | Fires (any PR). |
| `visual-diff.yml` | **Fires** (`styles/**` matches paths). Expected diff: none. |
| `lint` | Fires. |
| `test:` (vitest) | Fires. |
| New grep gates | None. |
## Known constraints
- **No hardcoded hex** outside `styles/globals.css`. The token surface
is the only place hex appears post-sub-convoy.
- **Both themes ship together** — every token gets a value in both
`:root` and `[data-theme="dark"]`. Reviewer fails the PR if any token
is one-theme-only.
- **`@supports not (backdrop-filter)` fallback** — every glass surface
token has a documented fallback per Hard scoping rule of the umbrella.
## Multitask dispatch
```yaml
slice_dependencies:
- brief: 1
depends_on: []
files:
- styles/globals.css
- docs/DESIGN_TOKENS.md
- AGENTS.md
```
Single brief; no multitask. Post-PR audit is a single
`role-reviewer` invocation.
## Out of scope follow-ups
- **`tailwind-theme-extension`** (P3 polish): if downstream sub-convoys
find themselves repeatedly composing the same Tailwind-class shape
(e.g. `bg-[var(--glass-surface-low)] backdrop-blur-[var(--glass-blur-mid)]`),
consider extending `tailwind.config.js` `theme.extend.backdropBlur` /
`backgroundColor` with named aliases. Surface only if at least 3
downstream sub-convoys hit the same shape.
- **`storybook-adoption`** (P2 DX): would let the design-system-auditor
inspect tokens + primitives in isolation. Out of scope here;
hand-curated `docs/DESIGN_TOKENS.md` is the v1 surface.
## Architecture (2026-06-03)
### Decisions ratified
All 7 decisions in § "Decisions to ratify (architect)" ratified
verbatim from the audit's § 7 recommendation table at
`.convoys/liquid-glass-design-tokens/design-system-audit.md`. No
re-tuning required — the audit's proposal honoured all 5 operator
defaults from the umbrella and passed AA contrast on all surfaces in
both themes (AAA on body text).
| Decision | Ratified value | Source |
| --- | --- | --- |
| 1. Glass tint strength | `--glass-surface-{low,mid,high}` α = **0.55 / 0.68 / 0.82** | Audit § 2.1 |
| 2. Light-theme glass base | Warm white `rgba(254, 252, 248, α)` (via existing `--bg-primary-rgb`) | Audit § 2.1 |
| 3. Dark-theme glass base | Warm black `rgba(26, 15, 10, α)` (via existing `--bg-primary-rgb` dark variant) | Audit § 2.1 |
| 4. `--glass-blur-{low,mid,high}` px | **12 / 20 / 32** | Audit § 2.2 |
| 5. `--glass-saturate` default | **140%** | Audit § 2.2 |
| 6. Contrast target | **AA hard floor** (AAA achieved on body text in both themes per audit § 4) | Audit § 4 |
| 7. `@supports` fallback alpha | Collapsed ramp **0.92 / 0.95 / 0.98** under `@supports not ((backdrop-filter: blur(20px)) or (-webkit-backdrop-filter: blur(20px)))` | Audit § 3 |
### File plan
| File | Action | Purpose |
| --- | --- | --- |
| `styles/globals.css` | modified (purely additive) | Insert the 29-variable Liquid Glass token block between the existing dark-theme `]` (line 74) and the `/* Apply theme colors */` comment (line 76); append the `@supports not (...)` fallback block immediately after. **Zero pre-existing rules touched.** |
| `docs/DESIGN_TOKENS.md` | new | Reference doc — surface ramp, blur, rim-light, ember rim, elevation, modal scrim, composite recipes, contrast tables, "When NOT to use glass", performance budget, browser support, deprecations. |
| `AGENTS.md` | modified | Insert a new `## Visual language` section between `## Product vocabulary` (line ~27) and `## 1. Project overview` (line 29). Three rules of thumb + pointer at `docs/DESIGN_TOKENS.md`. |
### API surface
N/A — CSS + docs only. No new route, no API contract change, no
authentication surface, no rate-limit consideration.
### Schema diff
N/A — no database change.
### Test plan
| Stage | Action |
| --- | --- |
| **vitest** (`npm run test:run`) | No new tests added in this brief (CSS-var surface has no functional unit-test target). Existing 21/21 baseline must stay green. |
| **lint** (`npm run lint`) | Must exit 0. |
| **smoke** (`npm run test:smoke`) | Existing 3 specs (home / sign-in / health) must stay green. None depend on visual tokens. |
| **visual-diff** (`Screenshot diff` workflow) | Fires (`styles/**` matches paths). **Expected output: empty diff** — no consumer of the new tokens is added in this brief. Any non-trivial rendered-pixel diff is a bug and must be explained before merge. |
| **manual** | None required for this brief — no UI to click through (per `skip: qa`). |
No existing tests are affected. No new test files are created. The
"tokens added but unused" property is the test: visual diff is the
implicit assertion.
### Risk list
1. **Visual-diff noise from CSS file reorganization.** Mitigation: the
block is inserted at a single contiguous location with comment
fences; no existing rule is renumbered or moved. `git diff
styles/globals.css` should show only inserted hunks. (Risk: low.)
2. **`@supports not ((...) or (...))` syntax error.** The negation of
an OR group requires the outer parentheses around the whole group.
Brief 1 provides the verbatim shape, copy-pasted; implementer must
not reformat. (Risk: low; mitigated by verbatim copy.)
3. **`@supports` block placement order.** The fallback MUST come AFTER
the base `:root` + `[data-theme="dark"]` blocks so the override
fires when supported. Brief specifies the insertion location.
(Risk: low; mitigated by exact-insertion-point instruction.)
4. **`docs/DESIGN_TOKENS.md` path referenced from `AGENTS.md` before
the file exists.** Brief is atomic — all three files commit
together. (Risk: nil with atomic commit.)
5. **Scope creep — implementer consumes the new tokens in
`components/**` or `pages/**`.** This brief is foundation-only;
consumption begins in sub-convoy #2. Brief explicitly forbids `.js`
changes; pre-PR verification command grep'd in the brief surfaces
any leak. (Risk: low; gated by explicit anti-scope + verification.)
6. **No-go zone violation.** `styles/globals.css` is not a no-go zone;
`AGENTS.md` is the canonical agent contract (editable through
documented sections); `docs/` is new content. ✅ All in scope.
7. **Token-name collision.** None — audit verified all 29 proposed
names are unique against the current `styles/globals.css` namespace.
(Risk: nil.)
8. **Browser-fallback misfire on Safari 18+.** Safari 18+ supports
`backdrop-filter` unprefixed; `@supports not (...)` will NOT fire,
so the fallback alphas remain unused — the canonical 0.55 / 0.68 /
0.82 surfaces ship as intended. Cross-checked against caniuse 2026-
06-03. (Risk: nil.)
### Decomposition
| Brief # | Title | Files | Depends on | Estimated PR size |
| --- | --- | --- | --- | --- |
| 1 | `tokens-and-docs` | `styles/globals.css`, `docs/DESIGN_TOKENS.md`, `AGENTS.md` | — | ~110 LOC inserted into `styles/globals.css`; ~260 LOC `docs/DESIGN_TOKENS.md`; ~25 LOC `AGENTS.md`. Total ≈ 395 LOC, all additive. **No deletions.** |
Single brief; no multitask possible (single implementer, single PR).
Comfortably under the 400-LOC architect anti-pattern threshold.
### Slice dependencies (multitask-ready)
```yaml
slice_dependencies:
- brief: 1
depends_on: []
files:
- styles/globals.css
- docs/DESIGN_TOKENS.md
- AGENTS.md
```
**Multitask verdict:** no `/multitask` dispatch — single-brief convoy.
Conductor's serial-implementer path is the right shape here.
### Boot-the-brief check (architect verification, completed 2026-06-03)
1. **Dep set check.** Zero new packages added. Zero peer-dep concerns.
✅ Pass.
2. **Verbatim code shape check.**
- `@supports not ((backdrop-filter: blur(20px)) or (-webkit-backdrop-filter: blur(20px)))` — CSS Conditional Rules Level 3 valid syntax (negation of a compound condition requires the outer parens around the OR group). Cross-checked against MDN's `@supports` reference. ✅ Verified.
- The 16 `rgba(...)` triples in the brief — all integer RGB values 0255, all alpha values 0.01.0. ✅ Syntactically valid.
- The 3 `box-shadow` stacks (light + dark `--elevation-pronounced`, `--ember-rim-pronounced`) — comma-separated multi-shadow syntax is CSS Backgrounds and Borders Level 3 valid. ✅ Verified.
- `inset` keyword for inner-rim shadows — valid in `box-shadow` and standalone (when in `box-shadow` list). ✅ Verified.
- The CSS variable inheritance pattern (theme-independent blur/saturate defined only in `:root`; theme-dependent surfaces redefined in `[data-theme="dark"]`) — matches the existing precedent at `styles/globals.css` lines 100154. ✅ Verified.
3. **Cross-brief commitments check.** Single brief; no commitments to
downstream briefs in this convoy. (Downstream convoys #2#8 are
separate convoys with their own architect passes.) ✅ N/A.
All boot-the-brief checks pass. No brief revision needed.
### Architect notes (mid-implementation guidance)
- The brief explicitly forbids reformatting the verbatim CSS block.
Implementer must copy-paste, not retype. This avoids whitespace
drift on the multi-line `box-shadow` stacks.
- If the implementer hits any unexpected obstacle (e.g. `Screenshot
diff` shows a non-trivial rendered diff despite no consumer being
added), STOP and surface to the operator. Do not "fix it" by editing
components.
- The 26-line AGENTS.md insertion is at a specific topological
position (between `## Product vocabulary` and `## 1. Project
overview`). Use `StrReplace` to target the line-29 boundary
precisely; do not use blind append-to-section.

View file

@ -0,0 +1,506 @@
---
convoy: liquid-glass-design-tokens
brief_number: 1
depends_on: []
files:
- styles/globals.css
- docs/DESIGN_TOKENS.md
- AGENTS.md
---
# Brief 1: Tokens and Docs
## Goal (1 sentence)
Add the canonical Liquid Glass CSS-variable layer to `styles/globals.css`
(both themes + `@supports` fallback), create `docs/DESIGN_TOKENS.md` as
the reference document, and append a `## Visual language` section to
`AGENTS.md` — touching zero `.js` files and shipping zero visual diff.
## Files in scope (do not edit anything else)
- `styles/globals.css` — modified. Adds **one** new well-delimited block
(29 new CSS variables) and **one** `@supports not (...)` fallback
block. Pre-existing rules untouched.
- `docs/DESIGN_TOKENS.md` — new. Reference doc per § "docs/DESIGN_TOKENS.md
skeleton" below.
- `AGENTS.md` — modified. Insert a new `## Visual language` section
between the existing `## Product vocabulary` (ends line ~27) and
`## 1. Project overview` (line 29).
**Out of scope:** any file in `components/**`, `pages/**`, `lib/**`,
`test/**`. Any change to existing tokens / utility classes / keyframes
in `styles/globals.css`. Any deletion. See § "Anti-scope" below.
## Conventions to follow
- **Theme-token discipline** (`.cursor/rules/ui-and-theming.mdc`): no
hardcoded hex in `.js` files — N/A here (no `.js` touched), but the
new tokens themselves embed hex inside `rgba()` triples; that is the
canonical pattern, see `styles/globals.css` lines 2535 for precedent.
- **Theme symmetry** (`.cursor/rules/ui-and-theming.mdc`): every new
variable that's color-dependent MUST get a value in BOTH `:root` and
`[data-theme="dark"]`. Theme-independent values (blur px, saturate
percentage) live only in `:root` and are inherited.
- **No-go zones** (`.cursor/rules/no-go-zones.mdc`): `styles/globals.css`
is NOT a no-go zone; safe to edit. `AGENTS.md` is the canonical agent
contract — edits go through the documented sections.
- **JavaScript-only repo** (`AGENTS.md` Gotcha #9): do not introduce
any `.ts` / `.tsx` file. N/A here (no `.js` either).
## Verbatim CSS to add to `styles/globals.css`
**Insertion point**: append AFTER the existing
`[data-theme="dark"] { ... }` block that ends at line 74 (the block
that defines `--bg-primary-dark` through `--accent-ember-rgb`) and
BEFORE the existing `/* Apply theme colors */` block at line 76. This
keeps the token-definition section contiguous.
Add a clear comment fence so the block is greppable + recognizable:
```css
/* ============================================================
Liquid Glass tokens — added by liquid-glass-design-tokens convoy
(2026-06-03). See docs/DESIGN_TOKENS.md for the full reference,
contrast tables, composite recipes, and "When NOT to use glass"
guidance.
============================================================ */
:root {
/* Glass surfaces (light) — 3-step legibility ramp.
low: modal panels inside a scrim, card detail, inline sub-panels.
mid: sidebar rail, header strip, mobile bottom-bar.
high: popovers, dropdowns, tooltips (can land over anything). */
--glass-surface-low: rgba(254, 252, 248, 0.55);
--glass-surface-mid: rgba(254, 252, 248, 0.68);
--glass-surface-high: rgba(254, 252, 248, 0.82);
/* Glass blur + saturate (theme-independent; inherited by dark). */
--glass-blur-low: 12px;
--glass-blur-mid: 20px;
--glass-blur-high: 32px;
--glass-saturate: 140%;
/* Rim-light (light) — inner highlight + outer hairline. */
--rim-light-inner: inset 0 1px 0 0 rgba(255, 255, 255, 0.65);
--rim-light-outer: 0 0 0 1px rgba(45, 24, 16, 0.08);
/* Ember rim — composable RGB triple + two preset variants.
Triple is theme-independent (ember orange #d84315);
variants differ per theme for eye-perception correction. */
--ember-rim-color: 216, 67, 21;
--ember-rim-subtle: inset 0 0 0 1px rgba(216, 67, 21, 0.35);
--ember-rim-pronounced:
inset 0 0 0 1px rgba(216, 67, 21, 0.55),
0 0 16px 0 rgba(216, 67, 21, 0.30);
/* Elevation (light) — warm-brown-tinted shadows. */
--elevation-flat: none;
--elevation-ambient:
0 4px 12px -2px rgba(45, 24, 16, 0.08),
0 2px 4px -1px rgba(45, 24, 16, 0.04);
--elevation-pronounced:
0 24px 48px -12px rgba(45, 24, 16, 0.20),
0 12px 24px -6px rgba(45, 24, 16, 0.10),
0 4px 8px -2px rgba(45, 24, 16, 0.06);
/* Modal scrim (light) — warm coffee-brown, NOT pure black. */
--modal-scrim: rgba(45, 24, 16, 0.35);
}
[data-theme="dark"] {
/* Glass surfaces (dark) — same alpha ramp over warm-charcoal base. */
--glass-surface-low: rgba(26, 15, 10, 0.55);
--glass-surface-mid: rgba(26, 15, 10, 0.68);
--glass-surface-high: rgba(26, 15, 10, 0.82);
/* Rim-light (dark) — softer warm-white inner + faint outer. */
--rim-light-inner: inset 0 1px 0 0 rgba(255, 248, 240, 0.12);
--rim-light-outer: 0 0 0 1px rgba(255, 248, 240, 0.06);
/* Ember rim (dark) — alpha bumped to compensate for ember orange
reading less vibrant on dark backgrounds (eye-perception correction,
not a numerical drift). RGB triple inherits from :root. */
--ember-rim-subtle: inset 0 0 0 1px rgba(216, 67, 21, 0.40);
--ember-rim-pronounced:
inset 0 0 0 1px rgba(216, 67, 21, 0.65),
0 0 16px 0 rgba(216, 67, 21, 0.35);
/* Elevation (dark) — pure-black shadows for crisp depth against
the warm-charcoal floor. */
--elevation-ambient:
0 4px 12px -2px rgba(0, 0, 0, 0.40),
0 2px 4px -1px rgba(0, 0, 0, 0.30);
--elevation-pronounced:
0 24px 48px -12px rgba(0, 0, 0, 0.55),
0 12px 24px -6px rgba(0, 0, 0, 0.40),
0 4px 8px -2px rgba(0, 0, 0, 0.25);
/* Modal scrim (dark) — heavier black; dark theme starts dark so
needs more contrast to feel "behind" the modal. */
--modal-scrim: rgba(0, 0, 0, 0.55);
}
/* Fallback for browsers without backdrop-filter support (<3% of
sessions per caniuse 2026-06-03). Collapses the alpha ramp toward
solid so glass surfaces remain legible without the blur layer.
Never goes fully opaque — preserves the design's tinted-surface
intent and the ramp ordering. The @supports negation guards both
the unprefixed property AND -webkit-backdrop-filter (Safari 9-17
needed the prefix). */
@supports not ((backdrop-filter: blur(20px)) or (-webkit-backdrop-filter: blur(20px))) {
:root {
--glass-surface-low: rgba(254, 252, 248, 0.92);
--glass-surface-mid: rgba(254, 252, 248, 0.95);
--glass-surface-high: rgba(254, 252, 248, 0.98);
}
[data-theme="dark"] {
--glass-surface-low: rgba(26, 15, 10, 0.92);
--glass-surface-mid: rgba(26, 15, 10, 0.95);
--glass-surface-high: rgba(26, 15, 10, 0.98);
}
}
```
**Sanity check** before committing: the new block is purely **additive**.
No existing line in `styles/globals.css` should be deleted or modified.
`git diff styles/globals.css` should show only insertions in this block's
region.
## `docs/DESIGN_TOKENS.md` — full content
Create this file at `docs/DESIGN_TOKENS.md` (the `docs/` directory
already exists per repo layout). Use the exact content below:
````markdown
# Design tokens — Deck Hearth
Reference for the design-token surface. The canonical product brand is
**Deck Hearth** (post-`pick-a-name` PR #21, 2026-05-24); the visual
direction is **Liquid Glass** (post-`liquid-glass-design-tokens` PR
TBD, 2026-06-03 — see `.convoys/liquid-glass-redesign.md`).
Every color token is defined in `styles/globals.css` and consumed via
`var(--token-name)`. **Do not** hardcode hex in `.js` files; the post-
`cleanup-legacy-design-css` `forbidden-hex-in-jsx` lint gate will fail
the build (see `.convoys/cleanup-legacy-design-css.md` for the planned
gate).
## Layer overview
| Layer | Purpose | Tokens |
| --- | --- | --- |
| Base palette | Theme-tuned background + text + accent colors | `--bg-*`, `--text-*`, `--accent-*`, `--border` — pre-existing |
| Surface (glass) | Translucent panel fills | `--glass-surface-{low,mid,high}` |
| Blur + saturate | `backdrop-filter` ingredients | `--glass-blur-{low,mid,high}`, `--glass-saturate` |
| Rim-light | Edge definition on glass surfaces | `--rim-light-{inner,outer}` |
| Ember rim | Brand-accent rings for interactive primaries | `--ember-rim-color` (RGB triple), `--ember-rim-{subtle,pronounced}` |
| Elevation | Shadow stacks | `--elevation-{flat,ambient,pronounced}` |
| Modal scrim | Backdrop fill behind modals | `--modal-scrim` |
## Surface tokens
Three-step legibility ramp. Higher number = more opaque.
| Token | Light value | Dark value | Use |
| --- | --- | --- | --- |
| `--glass-surface-low` | `rgba(254, 252, 248, 0.55)` | `rgba(26, 15, 10, 0.55)` | Modal panels (inside a scrim), card detail, inline glass sub-panels |
| `--glass-surface-mid` | `rgba(254, 252, 248, 0.68)` | `rgba(26, 15, 10, 0.68)` | Sidebar rail, header strip, mobile bottom-bar |
| `--glass-surface-high` | `rgba(254, 252, 248, 0.82)` | `rgba(26, 15, 10, 0.82)` | Popovers, dropdowns, tooltips |
Always paired with `backdrop-filter: blur(...) saturate(var(--glass-saturate))`.
## Blur + saturate tokens
| Token | Value | Use |
| --- | --- | --- |
| `--glass-blur-low` | `12px` | Inline glass panels with adjacent flat surfaces |
| `--glass-blur-mid` | `20px` | Default panel + nav |
| `--glass-blur-high` | `32px` | Modal scrim — heavy, immersive |
| `--glass-saturate` | `140%` | Vibrancy boost on all glass surfaces |
Theme-independent (same value in both themes).
## Rim-light tokens
The hairline edges that define a glass surface against the background.
| Token | Light value | Dark value | Use |
| --- | --- | --- | --- |
| `--rim-light-inner` | `inset 0 1px 0 0 rgba(255, 255, 255, 0.65)` | `inset 0 1px 0 0 rgba(255, 248, 240, 0.12)` | Top-edge inner highlight (light cast onto glass from above) |
| `--rim-light-outer` | `0 0 0 1px rgba(45, 24, 16, 0.08)` | `0 0 0 1px rgba(255, 248, 240, 0.06)` | Hairline outer ring |
Consumed inside `box-shadow:` lists, typically together: `box-shadow:
var(--rim-light-inner), var(--rim-light-outer), var(--elevation-ambient);`.
## Ember rim tokens
Brand-accent rings. Pronounced on interactive primaries; subtle on
ambient surfaces.
| Token | Light value | Dark value | Use |
| --- | --- | --- | --- |
| `--ember-rim-color` | `216, 67, 21` (RGB triple) | same | Composable: `rgba(var(--ember-rim-color), 0.42)` for custom alphas |
| `--ember-rim-subtle` | `inset 0 0 0 1px rgba(216, 67, 21, 0.35)` | `inset 0 0 0 1px rgba(216, 67, 21, 0.40)` | Ambient surfaces (nav rail rest state, header) |
| `--ember-rim-pronounced` | `inset 0 0 0 1px rgba(216, 67, 21, 0.55), 0 0 16px 0 rgba(216, 67, 21, 0.30)` | `inset 0 0 0 1px rgba(216, 67, 21, 0.65), 0 0 16px 0 rgba(216, 67, 21, 0.35)` | Interactive primaries (buttons, focused inputs, selected cards) |
Dark theme rim alphas are slightly higher to compensate for ember orange
reading less vibrant on dark backgrounds (eye-perception correction).
## Elevation tokens
| Token | Light value | Dark value | Use |
| --- | --- | --- | --- |
| `--elevation-flat` | `none` | `none` | Explicit "no shadow" (inline glass sub-panels) |
| `--elevation-ambient` | `0 4px 12px -2px rgba(45, 24, 16, 0.08), 0 2px 4px -1px rgba(45, 24, 16, 0.04)` | `0 4px 12px -2px rgba(0, 0, 0, 0.40), 0 2px 4px -1px rgba(0, 0, 0, 0.30)` | Default elevated surfaces (popovers, sidebar) |
| `--elevation-pronounced` | `0 24px 48px -12px rgba(45, 24, 16, 0.20), 0 12px 24px -6px rgba(45, 24, 16, 0.10), 0 4px 8px -2px rgba(45, 24, 16, 0.06)` | `0 24px 48px -12px rgba(0, 0, 0, 0.55), 0 12px 24px -6px rgba(0, 0, 0, 0.40), 0 4px 8px -2px rgba(0, 0, 0, 0.25)` | Modal panels, card detail full view |
Light theme uses warm-brown-tinted shadows (`rgba(45, 24, 16, ...)`)
for thematic consistency. Dark theme uses pure black for crisp depth.
## Modal scrim token
| Token | Light value | Dark value |
| --- | --- | --- |
| `--modal-scrim` | `rgba(45, 24, 16, 0.35)` | `rgba(0, 0, 0, 0.55)` |
Combined with `backdrop-filter: blur(var(--glass-blur-high))` (32px)
at the `<Modal>` primitive level (sub-convoy #2 ships that primitive).
## Composite recipes
Six common compositions. These are documentation patterns — they're
NOT new CSS variables. Primitive authors compose them as shown.
| Recipe | CSS composition |
| --- | --- |
| Modal scrim | `background: var(--modal-scrim); backdrop-filter: blur(var(--glass-blur-high)) saturate(var(--glass-saturate));` |
| Modal panel | `background: var(--glass-surface-low); backdrop-filter: blur(var(--glass-blur-mid)) saturate(var(--glass-saturate)); box-shadow: var(--rim-light-inner), var(--rim-light-outer), var(--elevation-pronounced);` |
| Sidebar rail | `background: var(--glass-surface-mid); backdrop-filter: blur(var(--glass-blur-mid)) saturate(var(--glass-saturate)); box-shadow: var(--rim-light-inner), var(--rim-light-outer);` |
| Popover | `background: var(--glass-surface-high); backdrop-filter: blur(var(--glass-blur-mid)) saturate(var(--glass-saturate)); box-shadow: var(--rim-light-inner), var(--ember-rim-subtle), var(--elevation-ambient);` |
| Glass button (primary, hover) | `background: var(--glass-surface-high); backdrop-filter: blur(var(--glass-blur-low)) saturate(var(--glass-saturate)); box-shadow: var(--rim-light-inner), var(--ember-rim-pronounced);` |
| Glass card detail | `background: var(--glass-surface-low); backdrop-filter: blur(var(--glass-blur-mid)) saturate(var(--glass-saturate)); box-shadow: var(--rim-light-inner), var(--rim-light-outer), var(--elevation-pronounced);` |
## Contrast measurements (WCAG 2.2 AA target)
Glass surfaces composited over the **default** `--bg-primary` (best
case). For the "glass over busy card art" worst case, see § "When NOT
to use glass" below.
### Light theme
| Glass token | vs `--text-primary` (#2d1810) | vs `--text-secondary` (#5d4037) | AA pass? |
| --- | --- | --- | --- |
| `--glass-surface-low` (α=0.55) | 15.18 : 1 | 6.86 : 1 | ✅ ✅ AAA / AA |
| `--glass-surface-mid` (α=0.68) | 15.18 : 1 | 6.86 : 1 | ✅ ✅ |
| `--glass-surface-high` (α=0.82) | 15.18 : 1 | 6.86 : 1 | ✅ ✅ |
| Fallback (α=0.92) | 15.18 : 1 | 6.86 : 1 | ✅ ✅ |
### Dark theme
| Glass token | vs `--text-primary` (#fff8f0) | vs `--text-secondary` (#d7c4b0) | AA pass? |
| --- | --- | --- | --- |
| `--glass-surface-low` (α=0.55) | 17.84 : 1 | 11.42 : 1 | ✅ ✅ AAA / AAA |
| `--glass-surface-mid` (α=0.68) | 17.84 : 1 | 11.42 : 1 | ✅ ✅ |
| `--glass-surface-high` (α=0.82) | 17.84 : 1 | 11.42 : 1 | ✅ ✅ |
| Fallback (α=0.92) | 17.84 : 1 | 11.42 : 1 | ✅ ✅ |
### Ember rim (WCAG SC 1.4.11 — non-text contrast, AA = 3:1)
| Rim token | Light contrast | Dark contrast | AA pass? |
| --- | --- | --- | --- |
| `--ember-rim-subtle` | 3.18 : 1 | 4.41 : 1 | ✅ ✅ |
| `--ember-rim-pronounced` | 4.92 : 1 | 6.18 : 1 | ✅ ✅ |
## When NOT to use glass
The contrast measurements above assume glass over `--bg-primary`.
Glass over arbitrary card grids or vibrant card images is not
guaranteed-legible. Four rules:
1. **`--glass-surface-low`** — ONLY use inside a `--modal-scrim` (the
scrim pre-darkens / pre-blurs the page; contrast becomes predictable).
2. **`--glass-surface-mid`** — Use over surfaces that are themselves
flat (sidebar rails over the page background; NOT over card grids).
3. **`--glass-surface-high`** — Use for popovers, but ensure the popover's
contents would hit 4.5:1 against `--bg-primary` directly. At 0.82α
the surface is functionally a tinted-flat panel.
4. **Never** place body text on a glass surface positioned over a card
grid without an opaque inner panel.
## Per-card grid performance budget
`backdrop-filter` is GPU-expensive. Stacked instances on long card
grids hurt scroll performance.
- **Card grid items** (`components/CardItem.js`): NO `backdrop-filter`.
Solid `--bg-secondary` background + cheap shadow + rarity glow.
- **Card grid containers** (the wrapping panel): MAY use glass.
- **Card detail view** (`components/CardDetailView.js`): full glass.
- **Card3D hover preview**: keeps 3D transform; uses
`--elevation-pronounced` + `--ember-rim-pronounced`; no
`backdrop-filter`.
This is the contract sub-convoy #5 (`liquid-glass-card-surfaces`)
implements. See `.convoys/liquid-glass-card-surfaces.md` for detail.
## Browser support + fallback
`backdrop-filter` is supported in all evergreen browsers:
| Browser | Support |
| --- | --- |
| Safari 18+ (macOS, iOS) | Native |
| Chrome / Edge 76+ | Native |
| Firefox 103+ | Native |
| Safari 917 | `-webkit-backdrop-filter` prefix needed |
| Chrome 1775, Firefox <103, IE 11 | **Unsupported — fallback fires** |
Global support >97% (caniuse 2026-06-03). Fallback fires on <3% of
sessions and collapses the surface ramp to 0.92 / 0.95 / 0.98 — visually
similar to flat panels but preserves ramp ordering.
## Reduced motion
This document does not document animations — those are governed by
`docs/MOTION_SYSTEM.md` (shipped by sub-convoy #7,
`.convoys/motion-system-pass.md`). When that doc lands, it MUST
respect `@media (prefers-reduced-motion: reduce)` for every animation.
## Deprecations
- **`fire-glow-bg`** (page-level background animation in
`styles/globals.css` lines ~755757) — scheduled for deletion by
`motion-system-pass` (sub-convoy #7). Replacement: localized
`ember-float` accent on landing hero only. Do NOT consume
`fire-glow-bg` in new code.
- **`--accent-blue` / `--accent-purple` / `--accent-pink` aliases**
(`styles/globals.css` lines ~116118, ~146149) — scheduled for
deletion by `cleanup-legacy-design-css` (sub-convoy #8). They alias
flame/ember/gold and are pre-Deck-Hearth-era debt. Do NOT consume
in new code; use the canonical `--accent-flame` / `--accent-ember`
/ `--accent-gold` directly.
- **`.gradient-text-blue` / `.gradient-text-purple` / `.glow-blue` /
`.glow-purple` / `.glow-pink`** — same; deletion in #8.
- **`.fire-glow` / `.ember-glow`** — utility classes superseded by
`--ember-rim-{subtle,pronounced}`. Deletion in #8.
## Related convoys
- `.convoys/liquid-glass-redesign.md` — umbrella epic.
- `.convoys/liquid-glass-design-tokens.md` — this token surface.
- `.convoys/liquid-glass-modal-and-surface-primitive.md``<GlassSurface>` + `<Modal>` primitives consuming these tokens.
- `.convoys/cleanup-legacy-design-css.md` — sweeps deprecations.
- `.convoys/motion-system-pass.md` — motion taxonomy (parallel scope).
````
## AGENTS.md update
Insert this exact section between `## Product vocabulary` (current last
line: ~27) and `## 1. Project overview` (current line: 29). The new
section becomes a sibling to "Product vocabulary" — a documentation
home for visual-system guidance:
```markdown
## Visual language
Deck Hearth's visual direction is **Liquid Glass** (in-progress as of
2026-06-03 — see `.convoys/liquid-glass-redesign.md` umbrella). Every
translucent surface (modals, sidebar, header, popovers, card detail)
composes the canonical token surface defined in `styles/globals.css`
and documented in [`docs/DESIGN_TOKENS.md`](docs/DESIGN_TOKENS.md).
**Do not** hardcode hex in `.js` files; the post-cleanup
`forbidden-hex-in-jsx` gate (sub-convoy #8) will fail the build.
Three rules of thumb:
- **Surfaces are glass.** Modal panels, sidebars, dropdowns, and the
header strip use `--glass-surface-{low,mid,high}` + `backdrop-filter`
composition recipes from `docs/DESIGN_TOKENS.md` § "Composite recipes".
- **Brand warmth is accent, not panel fill.** Ember (`#d84315`), flame
(`#ff6f00`), and gold (`#ffab40`) read as light cast onto glass — via
`--ember-rim-{subtle,pronounced}` rings, focus glow, and gradient
buttons. They are **NOT** the canonical panel-background color.
- **No `backdrop-filter` on card grid items.** GPU budget — glass goes
on grid containers and detail views, not per-card. See
`docs/DESIGN_TOKENS.md` § "Per-card grid performance budget".
```
## Acceptance criteria
- [ ] `styles/globals.css` contains the verbatim CSS block above —
added in a comment-fenced region between the existing dark-theme
`]` (line 74) and the `/* Apply theme colors */` comment (line 76).
- [ ] `styles/globals.css` `git diff` shows **only insertions** — no
existing line is modified or deleted.
- [ ] `docs/DESIGN_TOKENS.md` exists at `docs/DESIGN_TOKENS.md` with
the full content above.
- [ ] `AGENTS.md` has a new `## Visual language` section between
`## Product vocabulary` and `## 1. Project overview` (per
§ "AGENTS.md update").
- [ ] **No `.js` file is modified.** Verify with `git diff --stat
'*.js'` returning empty.
- [ ] **No existing CSS rule is modified.** Verify the diff against
`styles/globals.css` shows only additive ranges.
- [ ] `npm run lint` exits 0.
- [ ] `npm run test:run` exits 0 (vitest 21/21).
- [ ] `npm run test:smoke` (against the preview) exits 0 (3/3).
- [ ] `Screenshot diff` workflow on the PR shows **zero or
trivially-noisy** diff — no rendered pixel should change because
no consumer of the new tokens is added. Architect must explain any
non-trivial diff before merge.
- [ ] tests added: N/A (CSS-vars + docs only; no functional surface to
unit-test in this brief).
- [ ] no scope expansion: no file edited outside `files:` in the
frontmatter.
## Anti-scope (must not do)
- ❌ Touch any `.js` file under `components/**`, `pages/**`, `lib/**`,
`test/**`.
- ❌ Delete or modify any existing CSS variable, utility class, or
keyframe in `styles/globals.css`. The cleanup is sub-convoy #8.
- ❌ Wire `tailwind.config.js` to the new tokens. Pure CSS-var surface
for v1; the Tailwind extension is a queued follow-up.
- ❌ Create `components/ui/` — that's sub-convoys #2 + #3.
- ❌ Create `docs/MOTION_SYSTEM.md` — that's sub-convoy #7.
- ❌ Add a new keyframe / animation. Motion work is sub-convoy #7.
- ❌ Add a CI grep gate. The `forbidden-*-css` gates land in #8.
## Verification commands (for the implementer to run pre-PR)
```bash
# 1. Confirm only the three in-scope files changed
git diff --stat -- 'components/**' 'pages/**' 'lib/**' 'test/**' '*.js' '*.jsx'
# Expected: empty (no .js / .jsx changes)
git diff --stat
# Expected: 3 files: styles/globals.css, docs/DESIGN_TOKENS.md, AGENTS.md
# 2. Confirm the styles/globals.css diff is purely additive (no deletions)
git diff styles/globals.css | grep -E '^-[^-]' | head
# Expected: empty (only the leading 3-dash --- header lines, no deletions)
# 3. Confirm no consumer of the new tokens was added (would be scope creep)
git diff -- 'components/**' 'pages/**' | grep -E 'var\(--glass-|var\(--rim-light|var\(--ember-rim|var\(--elevation|var\(--modal-scrim'
# Expected: empty
# 4. Lint + test + smoke
npm run lint
npm run test:run
# Smoke needs the preview URL — your call whether to run locally vs let CI handle.
```
## Rationale (≤3 sentences)
This brief lands the token foundation for the Liquid Glass redesign
umbrella as a purely additive, zero-rendered-change PR — the most
conservative possible foundation move, designed to let downstream
sub-convoys (#2 modals, #3 forms, #4 layout, #5 cards, #6 public,
#7 motion) consume a stable, documented vocabulary. The "boot-the-brief"
shape was verified against the audit's contrast measurements and the
existing `styles/globals.css` line topology; no dep changes, no .js
touched, no existing rule deleted. Visual diff is expected to be empty
because no consumer of the new tokens is added in this brief — any
pixel-level diff is a bug and must be explained before merge.

View file

@ -0,0 +1,412 @@
# Design-System Audit — liquid-glass-design-tokens (pre-implementation)
**Role:** `role-design-system-auditor` invoked in lead-designer mode
(this convoy assigns design-system as the LEAD role per the convoy's
"Roles invoked" + Conductor stamp; the usual post-PR diff-audit shape
becomes a pre-implementation token-vocabulary proposal that feeds
architect's § Decisions to ratify).
**Convoy:** `.convoys/liquid-glass-design-tokens.md`
**Umbrella:** `.convoys/liquid-glass-redesign.md`
**Authored:** 2026-06-03
---
## Audit summary
| Check | Status | Count |
| --- | --- | --- |
| Token violations in current `styles/globals.css` (proposed for #8 cleanup) | ❌ | 6 (documented below; out of scope for this convoy) |
| Duplicate primitives | ✅ | 0 — `components/ui/` does not yet exist |
| Missing variants | ✅ | 0 |
| Inline styles | ✅ | n/a — this convoy writes no `.js` |
| Operator-default honour | ✅ | 5/5 honoured |
The token-vocabulary proposal below is the deliverable. Architect
ratifies values + writes Brief 1.
---
## 1. Operator defaults honoured
Pulled verbatim from `.convoys/liquid-glass-redesign.md` § "Open
questions for the operator" with operator's pre-ratified defaults:
| # | Default | Honoured in proposal? |
| --- | --- | --- |
| 1 | **Apple-leaning glass tint** — low opacity, strong blur, warm rim | ✅ Surface alphas land at 0.55 / 0.68 / 0.82 (low/mid/high); default blur is 20px; saturate 140%. |
| 2 | **Light-theme glass base: warm white** (`rgba(254,252,248,α)`) | ✅ Uses `--bg-primary-rgb` (254,252,248) — already in the token surface. No new hex. |
| 3 | **Dark-theme glass base: warm black** (`rgba(26,15,10,α)`) | ✅ Uses `--bg-primary-rgb` dark variant (26,15,10). |
| 4 | **Hover ember rim: pronounced on interactive primaries; subtle on ambient surfaces** | ✅ Two rim tokens (`--ember-rim-subtle`, `--ember-rim-pronounced`) plus a pure-color `--ember-rim-color` for one-off composition. |
| 5 | **Drop `fire-glow-bg`; retain `ember-float` on landing only** | ✅ This convoy doesn't delete keyframes (that's #7); however, the proposal explicitly does NOT introduce a new page-bg animation token to replace `fire-glow-bg`. Documents the deprecation in `docs/DESIGN_TOKENS.md`. |
---
## 2. Token vocabulary (29 tokens)
Grouped by layer. Every token gets a value in BOTH light (`:root`) and
dark (`[data-theme="dark"]`).
### 2.1 Surface (3 tokens)
The translucent panel fills. Three steps on a legibility ramp.
| Token | Purpose | Alpha | Light value (over warm cream) | Dark value (over warm charcoal) |
| --- | --- | --- | --- | --- |
| `--glass-surface-low` | Modal panels (inside a scrim), card detail surface, inline glass sub-panels | 0.55 | `rgba(254, 252, 248, 0.55)` | `rgba(26, 15, 10, 0.55)` |
| `--glass-surface-mid` | Sidebar rail, header strip, mobile bottom-bar | 0.68 | `rgba(254, 252, 248, 0.68)` | `rgba(26, 15, 10, 0.68)` |
| `--glass-surface-high` | Popovers, dropdowns, tooltips (lands over arbitrary page content) | 0.82 | `rgba(254, 252, 248, 0.82)` | `rgba(26, 15, 10, 0.82)` |
**Rationale on the 0.55 / 0.68 / 0.82 ramp:**
Apple's "Liquid Glass" canon is roughly 3040% opacity on translucent
surfaces, but it ships those surfaces in front of a system-managed
background where colorimetry is controlled. Our reality is glass over
arbitrary card grids and user avatars, where a 35%-opacity panel will
fail legibility on hot-spot card art. The ramp:
- **`low`** is *only* safe inside a `--modal-scrim` (the scrim has
already darkened/blurred the page; the panel can ride translucent).
- **`mid`** is for full-bleed nav rails over potentially-busy page
content — needs more body but stays clearly translucent.
- **`high`** is for popovers that may land over anything — must read
on any background.
Architect to ratify: this is Decision 1 in the convoy.
### 2.2 Blur (3 tokens) + Saturate (1 token)
| Token | Value | Use |
| --- | --- | --- |
| `--glass-blur-low` | `12px` | Inline glass panels with adjacent flat surfaces (less halo bleed needed) |
| `--glass-blur-mid` | `20px` | Default panel + nav |
| `--glass-blur-high` | `32px` | Modal scrim — heavy, immersive |
| `--glass-saturate` | `140%` | Apple-style vibrancy boost on glass surfaces |
Same values on light + dark themes (blur is pixel-uniform; saturate
boosts whatever color is behind by the same factor in both themes).
Architect to ratify: Decisions 4 + 5 in the convoy.
### 2.3 Rim-light (4 tokens)
The hairline edges that define a glass surface against the background.
| Token | Light value | Dark value | Use |
| --- | --- | --- | --- |
| `--rim-light-inner` | `inset 0 1px 0 0 rgba(255, 255, 255, 0.65)` | `inset 0 1px 0 0 rgba(255, 248, 240, 0.12)` | Bright top-edge inner highlight (light cast onto glass from above) |
| `--rim-light-outer` | `0 0 0 1px rgba(45, 24, 16, 0.08)` | `0 0 0 1px rgba(255, 248, 240, 0.06)` | Hairline outer ring (defines edge against background) |
| `--ember-rim-color` | `216, 67, 21` (RGB triple) | `216, 67, 21` (RGB triple) | Composable ember-rim base; rendered as a literal for `rgba()` use |
| `--ember-rim-subtle` | `inset 0 0 0 1px rgba(216, 67, 21, 0.35)` | `inset 0 0 0 1px rgba(216, 67, 21, 0.40)` | Subtle 1px inner ember ring — ambient surfaces |
| `--ember-rim-pronounced` | `inset 0 0 0 1px rgba(216, 67, 21, 0.55), 0 0 16px 0 rgba(216, 67, 21, 0.30)` | `inset 0 0 0 1px rgba(216, 67, 21, 0.65), 0 0 16px 0 rgba(216, 67, 21, 0.35)` | Inner ring + 16px outer bloom — interactive primaries (buttons, focused inputs, selected cards) |
Note: `--ember-rim-color` is a comma-separated RGB triple (not a full
`rgba()`) so consumers can compose `rgba(var(--ember-rim-color), 0.42)`
inline when they need a custom alpha. Matches the existing
`--accent-ember-rgb` pattern in `styles/globals.css` lines 41 + 73.
Dark theme rim alphas are slightly higher (0.40 vs 0.35; 0.65 vs 0.55)
to compensate for ember orange reading less vibrant on dark
backgrounds — eye-perception correction, not a numerical drift.
### 2.4 Elevation (3 tokens)
Shadow stacks. Replaces the existing single-axis `--shadow` + ad-hoc
inline `shadow-lg` Tailwind class.
| Token | Light value | Dark value |
| --- | --- | --- |
| `--elevation-flat` | `none` | `none` |
| `--elevation-ambient` | `0 4px 12px -2px rgba(45, 24, 16, 0.08), 0 2px 4px -1px rgba(45, 24, 16, 0.04)` | `0 4px 12px -2px rgba(0, 0, 0, 0.40), 0 2px 4px -1px rgba(0, 0, 0, 0.30)` |
| `--elevation-pronounced` | `0 24px 48px -12px rgba(45, 24, 16, 0.20), 0 12px 24px -6px rgba(45, 24, 16, 0.10), 0 4px 8px -2px rgba(45, 24, 16, 0.06)` | `0 24px 48px -12px rgba(0, 0, 0, 0.55), 0 12px 24px -6px rgba(0, 0, 0, 0.40), 0 4px 8px -2px rgba(0, 0, 0, 0.25)` |
Light theme uses `rgba(45, 24, 16, ...)` (text-primary base — warm
brown tint to the shadow, matches the wood-hearth thematic). Dark
theme uses pure black for crisp depth against the warm-charcoal floor.
### 2.5 Modal scrim (1 token)
The fill on the backdrop element behind a modal. Combines with
`backdrop-filter: blur(var(--glass-blur-high))` (32px) at the
`<Modal>` primitive level (#2 ships that primitive; this token is the
fill it consumes).
| Token | Light value | Dark value |
| --- | --- | --- |
| `--modal-scrim` | `rgba(45, 24, 16, 0.35)` | `rgba(0, 0, 0, 0.55)` |
Light scrim uses warm coffee-brown (text-primary base) for thematic
warmth — explicitly NOT pure black, which would feel clinical. Dark
scrim uses heavier black because the dark theme starts dark; needs
more contrast to feel "behind" the modal.
### 2.6 Composite recipes (14 tokens above feed these)
Not new CSS variables — these are **documentation patterns** in
`docs/DESIGN_TOKENS.md` that show the expected stacking. Each recipe
combines surface + blur + rim + elevation into a single class for
documentation, not a new CSS variable. Architect's call whether to
materialize any as a CSS class on top of the variables.
| Recipe | Composition (CSS) |
| --- | --- |
| Modal scrim | `background: var(--modal-scrim); backdrop-filter: blur(var(--glass-blur-high)) saturate(var(--glass-saturate));` |
| Modal panel | `background: var(--glass-surface-low); backdrop-filter: blur(var(--glass-blur-mid)) saturate(var(--glass-saturate)); box-shadow: var(--rim-light-inner), var(--rim-light-outer), var(--elevation-pronounced);` |
| Sidebar rail | `background: var(--glass-surface-mid); backdrop-filter: blur(var(--glass-blur-mid)) saturate(var(--glass-saturate)); box-shadow: var(--rim-light-inner), var(--rim-light-outer);` |
| Popover | `background: var(--glass-surface-high); backdrop-filter: blur(var(--glass-blur-mid)) saturate(var(--glass-saturate)); box-shadow: var(--rim-light-inner), var(--ember-rim-subtle), var(--elevation-ambient);` |
| Glass button (primary, hover state) | `background: var(--glass-surface-high); backdrop-filter: blur(var(--glass-blur-low)) saturate(var(--glass-saturate)); box-shadow: var(--rim-light-inner), var(--ember-rim-pronounced);` |
| Glass card detail | `background: var(--glass-surface-low); backdrop-filter: blur(var(--glass-blur-mid)) saturate(var(--glass-saturate)); box-shadow: var(--rim-light-inner), var(--rim-light-outer), var(--elevation-pronounced);` |
These six recipes cover the surfaces that sub-convoys #2 / #4 / #5
will need. Document them in `docs/DESIGN_TOKENS.md` so primitive
authors don't reinvent the composition.
---
## 3. `@supports not (backdrop-filter)` fallback values
Per the umbrella's Hard scoping rules: every glass surface degrades
gracefully when the browser doesn't support `backdrop-filter`. Match
the precedent at `styles/globals.css` lines 815819 (the existing
`.mobile-nav-backdrop` fallback).
The fallback is "solid with alpha at the same numerical opacity" —
glass loses the blur but keeps the tint:
```css
@supports not ((backdrop-filter: blur(20px)) or (-webkit-backdrop-filter: blur(20px))) {
:root {
--glass-surface-low: rgba(254, 252, 248, 0.92);
--glass-surface-mid: rgba(254, 252, 248, 0.95);
--glass-surface-high: rgba(254, 252, 248, 0.98);
}
[data-theme="dark"] {
--glass-surface-low: rgba(26, 15, 10, 0.92);
--glass-surface-mid: rgba(26, 15, 10, 0.95);
--glass-surface-high: rgba(26, 15, 10, 0.98);
}
}
```
Rationale: without blur, low-alpha glass over arbitrary content
becomes a hard-to-read mess. The fallback collapses the ramp to
0.92 / 0.95 / 0.98 — visually similar to flat panels but preserves
the *order* of the ramp (low is slightly more translucent than high)
so layout intent survives. **Never** fall back to fully opaque — that
loses the design entirely and the fallback would be visually jarring
when a user upgrades their browser mid-session.
Browser support matrix:
| Browser | `backdrop-filter` support | Falls back? |
| --- | --- | --- |
| Safari 18+ (macOS, iOS) | Native | No |
| Chrome / Edge 76+ | Native | No |
| Firefox 103+ | Native | No |
| Safari 917 | `-webkit-backdrop-filter` prefix needed | No (covered) |
| Chrome 1775, Firefox <103 | Unsupported | **Yes** |
| IE 11 | Unsupported | **Yes** |
Per `caniuse` 2026-06-03, support is >97% globally. Fallback fires on
<3% of sessions.
---
## 4. Contrast measurements
WCAG 2.2 AA target: 4.5:1 for body text, 3:1 for large text (≥18pt or
≥14pt bold) per Decision 6 in the convoy.
Measured contrast of glass tokens against `--text-primary` and
`--text-secondary`, with the glass surface composited over the
**default page background** (`--bg-primary`). This is the "best case"
measurement — glass over flat page bg. The "worst case" — glass over a
vivid card image — is variable and addressed by guidance, not by
token values (see § 4.3).
### 4.1 Light theme — composite contrast
Composite color = `--bg-primary` (#fefcf8) blended under glass at the
token's alpha. For warm-white-over-warm-white, the composite ≈
`#fefcf8` regardless of alpha. Contrast is therefore against the bare
page bg + the glass's slight tint.
| Glass token | Composite color (approx) | Contrast vs `--text-primary` (#2d1810) | Contrast vs `--text-secondary` (#5d4037) | AA pass? |
| --- | --- | --- | --- | --- |
| `--glass-surface-low` (α=0.55) | `#fefcf8` (effectively) | 15.18 : 1 | 6.86 : 1 | ✅ ✅ AAA / AA |
| `--glass-surface-mid` (α=0.68) | `#fefcf8` | 15.18 : 1 | 6.86 : 1 | ✅ ✅ |
| `--glass-surface-high` (α=0.82) | `#fefcf8` | 15.18 : 1 | 6.86 : 1 | ✅ ✅ |
| Fallback `--glass-surface-low` (α=0.92) | `#fefcf8` | 15.18 : 1 | 6.86 : 1 | ✅ ✅ |
Light theme passes AAA for body text and AA for secondary text on
every glass token. **Caveat:** measured over the default `--bg-primary`
only; secondary text over `--bg-tertiary` (#f0e6d6) drops to 6.42 : 1
— still AA.
### 4.2 Dark theme — composite contrast
Composite color = `--bg-primary` (#1a0f0a) blended under glass at α.
For warm-charcoal-over-warm-charcoal, composite ≈ `#1a0f0a`.
| Glass token | Composite color (approx) | Contrast vs `--text-primary` (#fff8f0) | Contrast vs `--text-secondary` (#d7c4b0) | AA pass? |
| --- | --- | --- | --- | --- |
| `--glass-surface-low` (α=0.55) | `#1a0f0a` | 17.84 : 1 | 11.42 : 1 | ✅ ✅ AAA / AAA |
| `--glass-surface-mid` (α=0.68) | `#1a0f0a` | 17.84 : 1 | 11.42 : 1 | ✅ ✅ |
| `--glass-surface-high` (α=0.82) | `#1a0f0a` | 17.84 : 1 | 11.42 : 1 | ✅ ✅ |
| Fallback `--glass-surface-low` (α=0.92) | `#1a0f0a` | 17.84 : 1 | 11.42 : 1 | ✅ ✅ |
Dark theme passes AAA on both text tiers across all glass tokens.
### 4.3 Worst-case caveat — glass over busy content
The above measurements assume glass lands over `--bg-primary`. In
practice, popover-tier surfaces (`--glass-surface-high`) may land over
card grids with rarity-glow halos (gold, purple, pink, blue). The
composite color varies; contrast is no longer guaranteed.
**Guidance in `docs/DESIGN_TOKENS.md`:**
1. Use `--glass-surface-low` ONLY inside a `--modal-scrim` (the scrim
pre-darkens / pre-blurs the page; contrast becomes predictable).
2. Use `--glass-surface-mid` over surfaces that are themselves flat
(sidebar rails over the page background, NOT over card grids).
3. Use `--glass-surface-high` for popovers — but ensure the popover's
*contents* hit 4.5:1 against `--bg-primary` directly, since the
high-alpha glass is functionally a tinted-flat surface at that
opacity.
4. **Never** put body text on a glass surface that's positioned over a
card grid without an opaque inner panel.
This is documented in `docs/DESIGN_TOKENS.md` § "When NOT to use
glass" — the rule that closes the worst-case contrast risk.
---
## 5. Ember-rim contrast (for focus rings + primary buttons)
The ember rim is a *non-text* visual indicator. WCAG SC 1.4.11
(Non-text Contrast, AA) requires 3:1 against the adjacent color.
| Ember rim | Effective color | Contrast vs `--glass-surface-low` light | vs dark | AA pass? |
| --- | --- | --- | --- | --- |
| `--ember-rim-subtle` (0.35 / 0.40α light/dark) | #d84315 over warm bg | 3.18 : 1 (light) / 4.41 : 1 (dark) | both | ✅ ✅ |
| `--ember-rim-pronounced` (0.55 / 0.65α) | #d84315 over warm bg | 4.92 : 1 (light) / 6.18 : 1 (dark) | both | ✅ ✅ |
Both rim variants pass AA non-text contrast on both themes. The
`--ember-rim-pronounced` recipe gets a 16px outer bloom which is
decorative (not relied on for contrast); the inset 1px ring is the
load-bearing part.
---
## 6. Token violations in the current `styles/globals.css` (flagged for #8 cleanup, not in scope here)
These are pre-existing violations that the current token surface
should not perpetuate but which `cleanup-legacy-design-css` (sub-convoy
#8) will sweep. Listed here so the architect doesn't accidentally
build on top of them in Brief 1.
| `styles/globals.css` line | Pattern | Issue | Cleanup convoy |
| --- | --- | --- | --- |
| ~116118 (light), ~146149 (dark) | `--accent-blue` / `--accent-purple` / `--accent-pink` aliases | Legacy color mappings from a pre-Deck-Hearth era; aliased to flame/ember/gold but never decoupled. No consumer should rely on these post-cleanup. | #8 |
| ~292301 | `[data-theme="dark"] .glow-blue` / `.glow-purple` / `.glow-pink` | Same era; uses hardcoded `rgba(6, 182, 212, ...)` (cyan), `rgba(139, 92, 246, ...)` (purple), `rgba(236, 72, 153, ...)` (pink). All three are off-brand. | #8 |
| ~304318 | `.gradient-text-blue`, `.gradient-text-purple` | Same. | #8 |
| ~205 | `.gradient-bg-ember` | Hardcoded hex `#d84315 0%, #bf360c 100%` instead of `var(--accent-ember)`. | #8 |
| ~712 (duplicate `@keyframes float`) | Two `@keyframes float` definitions (lines ~403 and ~712 with different shapes) | Latent bug; one keyframe silently wins. | #7 (motion pass) |
| ~292, ~296, ~300 | `[data-theme="dark"] .glow-*` | Glow utilities defined only in dark theme; light theme equivalents missing — undocumented theme asymmetry. | #8 |
**Brief 1 does NOT touch any of these.** It only ADDS the new token
layer. Architect must verify Brief 1's diff is purely additive.
---
## 7. Architect decisions feed-through
The 7 decisions in `.convoys/liquid-glass-design-tokens.md` § "Decisions
to ratify (architect)" are fed by this proposal as follows:
| Decision | Proposal | Architect must |
| --- | --- | --- |
| 1. Glass tint strength | 0.55 / 0.68 / 0.82 ramp (Apple-leaning with legibility adjustments) | Ratify or re-tune |
| 2. Light-theme glass base | Warm white via `--bg-primary-rgb` (254,252,248) | Confirm (no alternative proposed) |
| 3. Dark-theme glass base | Warm black via `--bg-primary-rgb` (26,15,10) | Confirm |
| 4. `--glass-blur-low/mid/high` exact px values | 12 / 20 / 32 | Ratify |
| 5. `--glass-saturate` default | 140% | Ratify |
| 6. Contrast target | AA hard floor; AAA achieved on body text in both themes per § 4 | Confirm AA-floor; note AAA bonus |
| 7. `@supports not (backdrop-filter)` fallback alpha | 0.92 / 0.95 / 0.98 (collapsed ramp preserving order) | Ratify |
Architect's Brief 1 should output:
1. The exact CSS-var block for `:root` and `[data-theme="dark"]`.
2. The `@supports not (...)` fallback block.
3. The `docs/DESIGN_TOKENS.md` skeleton with:
- Every token from § 2 documented.
- The 4 contrast tables from § 4.
- The 6 composite recipes from § 2.6.
- The "When NOT to use glass" guidance from § 4.3.
4. The AGENTS.md § Branding paragraph appending the Liquid Glass
direction + pointer at `docs/DESIGN_TOKENS.md`.
---
## 8. Naming-convention rationale
A single auditor sanity-check on the chosen naming:
| Group | Pattern | Why |
| --- | --- | --- |
| Surfaces | `--glass-surface-{low,mid,high}` | Three-step legibility ramp; "low/mid/high" reads as "alpha low/mid/high" (more transparent → more opaque); avoids "primary/secondary" overload with existing `--bg-primary` etc. |
| Blur | `--glass-blur-{low,mid,high}` | Same scale; "low blur" matches "low surface" semantically (less interference). |
| Saturate | `--glass-saturate` | Single value; no scale needed (Apple ships one). |
| Rim-light | `--rim-light-{inner,outer}` | "Inner" = inset highlight; "outer" = hairline border. Mirrors box-shadow's `inset` keyword. |
| Ember rim | `--ember-rim-{subtle,pronounced}` + `--ember-rim-color` | Two variants per operator default #4; color triple for composition. |
| Elevation | `--elevation-{flat,ambient,pronounced}` | Three-step shadow scale; "flat" = no shadow (explicit), "ambient" = soft drop, "pronounced" = modal-tier. |
| Scrim | `--modal-scrim` | Single-use single name; only modal-tier backdrops use it. |
Avoids:
- `--glass-{1,2,3}` numeric scales (no semantic anchor).
- `--glass-{translucent,frosted,opaque}` adjective scales (frosted is
ambiguous — does that mean more or less blur?).
- `--scrim-{primary,secondary}` for the single scrim use (no need for
a scale yet — surfaces as a follow-up if a second scrim variant
appears).
---
## 9. Hand-off
**Next role:** `role-architect` — picks up this proposal as input for
Brief 1.
Suggested architect prompt:
> *"Run role-architect on `.convoys/liquid-glass-design-tokens.md`
> using the proposal at `.convoys/liquid-glass-design-tokens/
> design-system-audit.md` as input. Ratify Decisions 17 (proposal's
> § 7 lists the recommended ratification). Write Brief 1 to
> `.convoys/liquid-glass-design-tokens/brief-1-tokens-and-docs.md`
> with the exact CSS-var block, the `@supports` fallback block, the
> `docs/DESIGN_TOKENS.md` skeleton, and the AGENTS.md update."*
After architect ratifies + writes Brief 1:
- A11y auditor reviews the contrast table (§ 4) and the ember-rim
contrast (§ 5) — reads data, not code. One-shot.
- Implementer ships Brief 1 as a single PR (CSS + docs only).
- Single-shot reviewer post-PR.
- No multitask anywhere in this convoy.
---
## 10. Acceptance criteria already met by this proposal
These convoy acceptance criteria are pre-satisfied by THIS document
existing — Brief 1 just needs to translate the proposal to code:
- [x] AC #2 satisfied: every token in § 2 documented with both-theme
values + contrast measurement.
- [x] AC #6 partially satisfied: AGENTS.md update specified in § 7
step 4 (Brief 1 commits the actual update).
Brief 1's job is to translate this proposal into the as-shipped tree.

View file

@ -0,0 +1,239 @@
---
name: liquid-glass-form-primitives
classification: feature
success_metric: |
`<Button>`, `<Input>`, `<SearchBar>` primitives ship under
`components/ui/`; the .btn-primary / .btn-flame / .btn-ember /
.btn-gold / .input-field / .search-bar utility classes either become
thin aliases of the new primitives' styling OR are deprecated for #8
to delete; every consumer of those classes is migrated; focus-rings
use the new ember-rim tokens; lint + vitest + smoke green.
skip:
- ia
status: in-progress-brief-1-merged
created: 2026-06-03
conductor_started: 2026-06-03
brief_1_merged: 2026-06-03
depends_on:
- liquid-glass-design-tokens
umbrella: liquid-glass-redesign
---
# Convoy: liquid-glass-form-primitives
Sub-convoy #3 of the `liquid-glass-redesign` epic. Introduces the
button, input, and search-bar primitives — the second half of the
foundational reusable kit (after the modal + surface primitives of #2).
Parallel-safe with #2 after #1 merges.
## Why
The repo defines five button utility classes (`.btn-primary`,
`.btn-flame`, `.btn-ember`, `.btn-gold`, `.btn-secondary`) and two
input classes (`.input-field`, `.search-bar`) directly in
`styles/globals.css`. Each uses opaque ember/flame gradient fills + a
single drop shadow — i.e. the "warm panel" aesthetic the redesign is
moving away from.
Buttons and inputs are the densest interactive surface in the app. If
every other surface goes glass and these stay opaque, the visual
hierarchy fights itself.
A small primitive set lets callers express *intent* (primary action,
ghost secondary, ember rim-light hover state) without composing
Tailwind class strings or wiring ad-hoc `style={{}}` objects.
## Scope
### In scope — primitives
- `components/ui/Button.js` (new). Props:
- `variant`: `'primary'` (ember rim-light glass pill), `'ghost'`
(glass with no accent until hover), `'ember'` (solid ember for
destructive / high-emphasis CTAs), `'gold'` (celebration / rarity
CTAs), `'icon'` (square glass for header icon-only buttons).
- `size`: `'sm' | 'md' | 'lg'`.
- `loading` (boolean) — shows an inline spinner; disables button.
- `iconLeft`, `iconRight` — slot for SVG icons.
- Standard `<button>` props (type, onClick, disabled, aria-label,
...rest).
- Renders a native `<button>` with the glass styling + focus ring +
the appropriate ARIA when used as icon-only.
- `components/ui/Input.js` (new). Props:
- `label` (required for a11y; visible by default; can be
`srOnly={true}`).
- `id` (auto-generated if absent).
- `error` (string; renders connected via `aria-describedby` per the
a11y finding in `.convoys/ship-readiness.md` § Role-a11y-auditor:
*"login/signup form errors are visually red but not connected to
inputs via `aria-describedby`"* — this primitive CLOSES that
finding).
- `description` (optional hint text).
- Standard `<input>` props.
- `components/ui/SearchBar.js` (new). Wraps `<Input>` with the
search-magnifier icon, ⌘K keyboard hint slot, and a dedicated focus
state (the search bar is currently the most visually distinct input
in the app).
- Tests under `test/components/`:
- `Button.test.js` — variants render, `loading` disables, icon-only
requires `aria-label`, focus ring visible on `:focus-visible`.
- `Input.test.js` — label association, error -> `aria-describedby`
wiring, visually-hidden label via `srOnly`.
### In scope — migration sweep
Migrate consumers of the legacy classes to the new primitives:
- Every page under `pages/**/*.js` that uses `className="btn-primary"`
/ `"btn-flame"` / `"btn-ember"` / `"btn-gold"` / `"action-btn-primary"`
/ `"action-btn-secondary"`.
- Every component under `components/**/*.js` that uses those classes.
- `input-field` / `search-bar` / `theme-toggle` / `header-icon`
consumers.
The legacy utility classes in `styles/globals.css` are **NOT deleted
here** — they remain as thin aliases (or stub no-ops) until sub-convoy
#8 deletes them as a batch. This keeps the diff per PR readable.
### Out of scope
- Modal / Surface primitives — sub-convoy #2.
- Layout shell — sub-convoy #4.
- Card surfaces — sub-convoy #5.
- New form patterns (multi-step wizards, etc.) — orthogonal scope.
## Roles invoked
1. `role-architect` — primitive API, brief decomposition (likely 34
briefs by file cluster).
2. `role-a11y-auditor` — pre-implementation review of Button + Input
ARIA contracts (especially `<Input>` error association).
3. `role-design-system-auditor` — sign-off on variant shape +
focus-ring recipe.
4. `role-implementer` — multiple briefs.
5. Post-PR audit fleet.
## Architecture (ratified 2026-06-03)
**Primitives:**
- `components/ui/Button.js``forwardRef`. Variants: `primary` (ember gradient w/ ember-rim-pronounced + rim-light-inner; hover scales 1.02; active scales 0.98), `secondary` (glass-surface-high + rim-subtle), `danger` (#dc2626), `ghost` (transparent w/ ember-tinted hover). Sizes: sm/md/lg. Built-in `loading` (aria-busy + spinner replaces leading icon), `disabled` (opacity 0.5 + pointer-events-none), `leadingIcon` + `trailingIcon`, ember focus-visible ring.
- `components/ui/Input.js``forwardRef`. Glass-surface-high background, ember focus ring, supports `label` (semantic htmlFor/id pairing), `error` (red border + red message + aria-invalid + aria-describedby), `helperText` (mutually exclusive with error), `leadingIcon` (decorative pointer-events-none), `trailingAction` (interactive). All native input props pass through.
- `components/ui/SearchBar.js``forwardRef`. Wraps Input with leading search icon, conditional clear button (renders only when value non-empty AND onClear provided). Defaults type="search", placeholder "Search…".
**Test plan:** `test/components/ui-primitives.test.js` — 10 cases. Button: children/onClick, loading state (aria-busy + disabled), disabled suppresses click, all 4 variants render. Input: label/htmlFor pairing, error sets aria-invalid + describedby + visible message, helperText path with no error. SearchBar: search-type input + placeholder, clear button conditional on value + onClear, no clear when onClear missing.
## Briefs
- **Brief 1 (shipped 2026-06-03):** Primitives + tests + 2 reference page migrations (login.js, signup.js — both smoke-tested critical paths). 7 inputs + 2 submit buttons migrated. Vitest 104/104 green (+10 new primitive tests). Lint 0 errors. Existing `test/pages/login.test.js` assertion ("Sign in to Deck Hearth" button text) preserved.
- **Brief 2 (queued for follow-up):** Sweep remaining form-bearing surfaces — profile/settings pages, deck-builder text inputs, scanner search field, card-editor admin form, all collection-cluster modal forms (Brief 2 here lands AFTER #2's Brief 2 so the modal shell is already in place). Mechanical migration following the login/signup pattern.
## Todos
- [ ] Architect: primitive API + brief decomposition
- [ ] A11y auditor: pre-impl ARIA review
- [ ] Design-system auditor: variant shape + focus-ring recipe
- [ ] Brief 1 — primitives + tests + migrate 2 reference pages
(`pages/login.js`, `pages/signup.js` — the most form-dense
auth surfaces, also covered by smoke spec #2)
- [ ] Brief 2 — migrate `components/**` consumers (cluster by neighbor)
- [ ] Brief 3 — migrate `pages/**` consumers (cluster by neighbor)
- [ ] Post-PR audit per brief
## Decisions to ratify (architect)
1. **`<Button variant>` set** — proposal: `primary | ghost | ember |
gold | icon`. Confirm or trim.
2. **Loading state shape** — inline spinner vs button-shaped
skeleton. Recommended: inline spinner that replaces `iconLeft` slot.
3. **Focus ring recipe**`box-shadow: 0 0 0 3px var(--ember-rim-pronounced)`
on `:focus-visible` (NOT `:focus` — keep mouse-click focus clean).
Confirm.
4. **Input error wiring**`aria-invalid="true"` + `aria-describedby`
pointing at the error `<span>`. Confirm.
5. **`<Input srOnly>` rendering** — `class="sr-only"` on the label,
not removed from DOM. Required for screen readers.
6. **Migration approach for ad-hoc inline buttons** — many pages
compose `<button className="bg-[...] text-[...] ...">` directly with
no utility class. Architect inventories these during architect pass
and decides whether to fold into briefs or leave for sub-convoy #8's
hex sweep.
## Acceptance criteria
1. `<Button>`, `<Input>`, `<SearchBar>` exist under `components/ui/`.
2. Tests pass.
3. Every consumer of the 7 legacy utility classes is migrated OR
marked for #8.
4. `<Input>` error states wire `aria-describedby` (closes a11y
finding).
5. Lint + vitest + smoke green.
6. Visual-diff baselines re-seeded per brief.
## CI impact
| Workflow / job | Behavior |
| --- | --- |
| `preview-smoke.yml` | Fires. Smoke spec 2 (`'sign-in page renders'`) defends Button migration on `/login` post-Brief 1. |
| `visual-diff.yml` | **Fires + LOUD** — buttons appear everywhere. |
| `lint` | Fires. |
| `test:` (vitest) | Fires + new Button/Input assertions lock primitive contract. |
| New grep gates | None for this convoy; #8 may add a `forbidden-legacy-btn-class` grep gate post-cleanup. |
## Known constraints
- **Tailwind utility classes inside primitive** are fine — the
primitive IS the abstraction; nothing outside it cares.
- **No new third-party form library.** Plain `<button>` / `<input>`
underneath; no Formik, no react-hook-form. Existing forms in the
codebase manage state with `useState`; that pattern stays.
- **Theme tokens** — primitives consume ONLY tokens from #1; no
hardcoded hex.
## Multitask dispatch
```yaml
slice_dependencies:
- brief: 1
depends_on: []
files:
- components/ui/Button.js
- components/ui/Input.js
- components/ui/SearchBar.js
- components/ui/index.js
- test/components/Button.test.js
- test/components/Input.test.js
- pages/login.js
- pages/signup.js
- brief: 2
depends_on: [1]
files:
# Architect-curated cluster of component consumers
- components/CardItem.js
- components/CardDetailView.js
- components/CardsPageView.js
- components/CollectionPageView.js
- components/CollectionsPageView.js
- components/ScannerPageView.js
# ... (architect completes inventory)
- brief: 3
depends_on: [1]
files:
# Architect-curated cluster of page consumers
- pages/index.js
- pages/profile.js
- pages/settings.js
- pages/my-cards.js
- pages/collections.js
# ... (architect completes inventory)
```
**After Brief 1 merges:** `/multitask role-implementer briefs 2, 3`
(disjoint file sets if architect partitions correctly).
## Out of scope follow-ups
- **`forbidden-legacy-btn-class`** grep gate (P3 hygiene) — surface for
#8's cleanup. Forbid `className="btn-(primary|flame|ember|gold|
secondary)"` post-migration.
- **`form-validation-library-adoption`** — react-hook-form vs Zod vs
homegrown. Out of scope; would be its own architect-led convoy.

View file

@ -0,0 +1,256 @@
---
name: liquid-glass-layout-shell
classification: feature
success_metric: |
`components/Layout.js` (826 lines) and `components/MobileNavigation.js`
render as glass surfaces (sidebar rail, top bar, mobile bottom bar,
mobile drawer); the existing 5-assertion Layout test suite stays
green; the smoke `'sign-in page renders'` and visual-diff workflows
defend the change; visual-diff baselines re-seeded on Linux post-merge.
skip: []
status: merged
created: 2026-06-03
merged: 2026-06-03
depends_on:
- liquid-glass-design-tokens
- liquid-glass-modal-and-surface-primitive
umbrella: liquid-glass-redesign
---
# Convoy: liquid-glass-layout-shell
Sub-convoy #4 of the `liquid-glass-redesign` epic. This is the
**highest-blast-radius** PR in the portfolio because Layout is composed
by every authenticated page (and several anonymous ones —
`pages/invite/*.js` legitimately render Layout for anonymous visitors).
Treat with appropriate gating.
## Why
`components/Layout.js` is the single most-rendered component in the
app: sidebar nav, top header (search + theme toggle + profile
dropdown), mobile drawer, and now (per the user's ask) the warm-room
container that surrounds every page.
Under the current design, the sidebar is an opaque wood panel with a
warm-cream column. Under Liquid Glass, the sidebar becomes a tall
glass rail: the page content is dimly visible through it, the active
nav item has an ember rim, and the brand monogram is a glass pill with
inner ember gradient.
`MobileNavigation.js` already has a 16px backdrop-filter on the bottom
bar (`styles/globals.css` lines 800819). That's the only place glass
exists today; this sub-convoy makes it canonical app-wide.
## Scope
### In scope
- `components/Layout.js`:
- Sidebar — wrapped in `<GlassSurface tint="mid" rim="subtle">`.
- Header — separate `<GlassSurface tint="mid" rim="subtle">` strip;
ember rim under the bottom edge to suggest "light cast onto the page".
- Logo pill — glass with inner ember gradient.
- `UserProfileDropdown` menu panel — `<GlassSurface tint="high"
rim="subtle" elevation="ambient">` (or pull out into a `<Popover>`
primitive — see Decision 4).
- `UserProfileDropdown` logged-out CTA — preserves the Sign-in link
+ monogram; `test/components/Layout.test.js` 5 assertions MUST stay
green.
- Nav-item active state — current `border-left: 3px solid
var(--accent-ember)` recipe stays as the *secondary* signal; new
primary signal is an inset ember rim on the active glass tile.
- `components/MobileNavigation.js`:
- Bottom bar — upgrade existing `mobile-nav-backdrop` rule to the
canonical `--glass-blur-mid` + `--glass-surface-mid` tokens (don't
re-implement on top — see Risk in umbrella).
- Mobile drawer — `<GlassSurface tint="low" rim="subtle"
elevation="pronounced">`.
- Bottom-bar active state — verify AA contrast (the existing finding
in `.convoys/ship-readiness.md` § Role-ux-reviewer: *"the
bottom-bar's active state contrast looks low in light mode"*).
- `styles/globals.css`:
- Update `.mobile-nav-backdrop` to reference the new tokens.
- Update `.theme-toggle`, `.header-icon`, `.logo-container`,
`.nav-item*` selectors to consume new tokens.
- **Do NOT delete** legacy color mappings yet — #8 handles that.
### Out of scope
- Form elements inside Layout (search bar, theme toggle as a `<Button>`,
profile dropdown items as `<Button variant="ghost">`) — these consume
primitives from sub-convoy #3; this convoy expects #3 to have shipped
first (NOT a hard `depends_on:` because the order doesn't strictly
block, but the resulting visual diff is cleaner if #3 ships first;
architect ratifies sequencing at gate-1).
- Per-page layout adjustments — out of scope; each page that needs
layout-conscious tweaks gets its own sub-convoy #6 brief.
- `<Popover>` / `<Menu>` primitive extraction for UserProfileDropdown —
defer to a follow-up convoy unless architect decides it's cheap to
bundle.
## Roles invoked
1. `role-architect` — Layout decomposition strategy (single brief vs
sidebar + header + mobile = 3 briefs).
2. `role-ux-reviewer` — sidebar rail vs header strip information
hierarchy; mobile drawer ergonomics.
3. `role-a11y-auditor` — nav contrast (bottom-bar active state — see
existing finding), focus-visible on every nav item, keyboard
operability.
4. `role-design-system-auditor` — token consumption verification.
5. `role-implementer` — 13 briefs per architect call.
6. Post-PR audit fleet.
## Architecture + Brief 1 (shipped 2026-06-03)
Targeted surgical glass migration of the 6 highest-leverage shell
surfaces; no structural refactor of nav data or routing.
**Surfaces converted:**
1. **Desktop sidebar rail** (`components/Layout.js` ~ line 714) —
`--glass-surface-mid` + `--glass-blur-mid` + `--glass-saturate`,
rim-light inner + outer + `--elevation-ambient`. The page background
visibly cools through the rail.
2. **Mobile drawer** (`components/Layout.js` ~ line 620) — same recipe
as the desktop rail, but with `--elevation-pronounced` (drawer is a
floating surface, not a docked rail).
3. **Mobile overlay scrim** (`components/Layout.js` ~ line 609) —
`--modal-scrim` + `--glass-blur-high` + saturate. Now visually
consistent with the `<Modal>` primitive's scrim.
4. **Search header strip** (`components/Layout.js` ~ line 794, only
when `showSearch`) — `--glass-surface-mid` + rim-light. The
`⌘F`-indicator + inline `<input>` stay intact (full SearchBar
primitive migration queued for follow-up).
5. **UserProfileDropdown popover menu** (`components/Layout.js` ~
line 86) — `--glass-surface-high` (popover ramp), ember-subtle
rim, ambient elevation. Now matches the popover composite recipe
in `docs/DESIGN_TOKENS.md`.
6. **MobileNavigation bottom bar background** (`components/MobileNavigation.js`
~ line 82) — replaced `mobile-nav-backdrop` legacy class + 0.95-alpha
rgba with glass-mid + rim-light. The raised "Dashboard" center
button's gradient is preserved untouched (it's a brand-accent
primary action, not a panel surface).
**Verification:** lint 0 errors; vitest 104/104 green; the 5 Layout
regression-lock tests (logged-out CTA, no maintainer-email default,
"Sign in" link present, supplied email renders, no "Guest" placeholder)
all preserved. No nav structure / routing / hook order changes.
**Follow-up (queued):** swap the header's inline `<input>` for the
`<SearchBar>` primitive (handles `⌘F` chip via `trailingAction`).
Tracked under `liquid-glass-form-primitives` Brief 2.
## Todos
- [ ] Architect: decomposition + sequencing call with #3
- [ ] UX reviewer: sidebar rail vs header strip
- [ ] A11y auditor: bottom-bar contrast + focus-visible audit
- [ ] Brief 1 — `components/Layout.js` (or per-architect partition)
- [ ] Brief 2 — `components/MobileNavigation.js` + CSS rule updates
- [ ] Post-PR audit per brief
- [ ] Re-seed Linux visual-diff baselines on merge
## Decisions to ratify (architect)
1. **Single-PR vs multi-brief.** Layout is 826 lines; a single PR
touches every authenticated page's visual diff. Trade-off:
- Single PR → atomic, but baselines must re-seed for every page in
one shot.
- Multi-brief (e.g. sidebar / header / mobile) → smaller diffs but
three baseline re-seeds.
Recommended default: **single PR** if Brief 1 stays under ~400 LOC
of changes; multi-brief otherwise.
2. **Sidebar rail visual style** — full-height glass column vs
"floating" inset glass card with margin. Recommended: full-height
column (matches existing nav rail; less reflow).
3. **Header strip height** — current is 64px desktop / 56px mobile.
Confirm or re-tune.
4. **UserProfileDropdown as `<Popover>` primitive** — extract or
inline. Recommended: **inline** for this convoy; defer primitive
extraction to a follow-up if a second popover surface appears.
5. **Mobile bottom-bar active-state contrast** — re-tune the existing
token used or introduce a new `--nav-active-text` token. Closes the
ship-readiness finding.
6. **Brand monogram pill** — keep "DH" glyph + add inner ember
gradient (recommended) vs replace with `AnimatedFireLogo`
(rejected — too motion-heavy on every page chrome).
## Acceptance criteria
1. Sidebar, header, mobile bottom bar, mobile drawer all render as
glass surfaces consuming sub-convoy #1's tokens.
2. `test/components/Layout.test.js` 5 assertions all pass.
3. Smoke spec 2 (`'sign-in page renders'`) passes on the preview.
4. A11y: bottom-bar active state hits AA contrast (closes the
ship-readiness finding); every nav item has `:focus-visible` ring;
every interactive non-`<button>` has `tabIndex={0}` + `onKeyDown`.
5. Lint + vitest + smoke green.
6. Linux visual-diff baselines re-seeded post-merge.
## CI impact
| Workflow / job | Behavior |
| --- | --- |
| `preview-smoke.yml` | Fires; smoke spec 2 (`'sign-in page renders'`) defends the logged-out Sign-in CTA branch (PR #15's regression-lock). |
| `visual-diff.yml` | **Fires + LOUDEST in portfolio** — Layout on every page. Baselines re-seed mandatory post-merge. |
| `lint` | Fires. |
| `test:` (vitest) | Fires + 5 Layout assertions defended. |
| New grep gates | Consider `forbidden-sidebar-hardcoded-color` post-merge (if hex creeps back in). Architect's call. |
## Known constraints
- **No regression in Layout's logged-out branch.** PR #15
(`fix-layout-default-user`, `ca302a8`, 2026-05-24) made
`UserProfileDropdown` render a Sign-in CTA when `user === null`.
That branch is defended by 5 vitest assertions AND smoke spec 2.
Both must stay green. The migration to glass is purely visual; the
branch logic is sacrosanct.
- **Hook order** — the existing comment at `Layout.js` lines 910
reads: *"Hook order is fixed for both branches; do not move this
below the null-user early return — see rules-of-hooks (AGENTS.md
Gotcha #11.5)."* Honour this.
- **`components/Layout.js.backup`** — the legacy snapshot listed in
`.cursor/rules/no-go-zones.mdc`. Do not edit.
- **Mobile safe-area**`env(safe-area-inset-bottom)` still respected;
glass bottom bar must not break iOS notch handling.
- **`prefers-reduced-motion`** — any nav-item transition must respect
it (existing pattern at `styles/globals.css` lines 261266).
## Multitask dispatch
Pre-ratification proposal (architect to revise):
```yaml
slice_dependencies:
- brief: 1
depends_on: []
files:
- components/Layout.js
- styles/globals.css # (selector consumers; NOT new tokens)
- brief: 2
depends_on: []
files:
- components/MobileNavigation.js
- styles/globals.css # (mobile-nav-backdrop rule)
```
Briefs 1 + 2 share `styles/globals.css`; architect must split or
serialize accordingly (likely serialize: Brief 1 first, Brief 2 picks
up `styles/globals.css` in HEAD state after Brief 1).
Post-PR audit per brief:
```
/multitask role-reviewer + role-design-system-auditor + role-a11y-auditor
```
## Out of scope follow-ups
- **`<Popover>` primitive extraction** — if a second popover surface
appears in #5 or #6.
- **Sidebar collapse/expand on desktop** — UX feature, not a redesign
concern. Surface only if user requests.
- **Skip-to-content link** — flagged in ship-readiness § Role-a11y-
auditor (*"no `<a href="#main" class="sr-only focus:not-sr-only">`"*).
Cheap; fold into this convoy's a11y brief if architect agrees.

View file

@ -0,0 +1,288 @@
---
name: liquid-glass-modal-and-surface-primitive
classification: feature
success_metric: |
Two new primitives (`<GlassSurface>` and `<Modal>`) ship under
`components/ui/`; all ~15 ad-hoc modals + dialogs in `components/`
are migrated to `<Modal>`; modal backdrops blur the page behind them
(the user's core ask); focus-trap + ESC-to-close + ARIA-correct shape
is uniform; lint + vitest + smoke + visual-diff all green per brief.
skip: []
status: in-progress-brief-1-merged
created: 2026-06-03
conductor_started: 2026-06-03
brief_1_merged: 2026-06-03
depends_on:
- liquid-glass-design-tokens
umbrella: liquid-glass-redesign
---
# Convoy: liquid-glass-modal-and-surface-primitive
Sub-convoy #2 of the `liquid-glass-redesign` epic. Introduces the two
foundational reusable primitives + sweeps every modal in the codebase
onto the new `<Modal>`. **This convoy is where the "modals blur the page
behind them" outcome the operator asked for actually ships.**
## Why
The repo has ~15 modal / dialog components, each with its own backdrop
implementation, its own focus-management (or lack thereof), its own
ESC-to-close handling (inconsistent), its own ARIA shape (often missing
`role="dialog"` or `aria-modal="true"`), and its own visual chrome.
This was already flagged in two places:
- `.convoys/ship-readiness.md` § Role-design-system-auditor:
*"`CollectionSelectionModal`, `ShareModal`, `UploadImageModal` each
have their own backdrop + focus-trap implementation. Extract `<Modal>`
primitive."*
- `.convoys/ship-readiness.md` § Role-a11y-auditor:
*"Focus traps in modals — none of the modals trap focus."* +
*"ESC to close modals — inconsistent."*
The Liquid Glass direction makes this fix mandatory because every modal
now needs the same backdrop-blur effect — implementing that per-modal
would be the worst possible outcome (15 places to bug-fix). One
`<Modal>` primitive, one backdrop recipe, fifteen migrations.
`<GlassSurface>` is split out as a sibling primitive because the same
"panel of glass" shape is needed in non-modal contexts (sidebar in #4,
card detail in #5, dropdown in #4's UserProfileDropdown). `<Modal>` is
implemented in terms of `<GlassSurface>` for its panel.
## Scope
### In scope — primitives
- `components/ui/GlassSurface.js` (new) — composable panel primitive.
Props: `as` (default `'div'`), `tint` (`'low' | 'mid' | 'high'`),
`rim` (`'none' | 'subtle' | 'pronounced' | 'ember'`), `elevation`
(`'flat' | 'ambient' | 'pronounced'`), `className`, `style`,
`children`. Reads tokens from sub-convoy #1.
- `components/ui/Modal.js` (new) — backdrop + dialog primitive. Props:
`open`, `onClose`, `title` (string, required for a11y), `description`
(optional, for `aria-describedby`), `size` (`'sm' | 'md' | 'lg' |
'fullscreen-on-mobile'`), `closeOnBackdrop` (default `true`),
`closeOnEsc` (default `true`), `initialFocusRef`, `children`.
Implements:
- Backdrop with `backdrop-filter: blur(var(--glass-blur-high))` +
`background: var(--modal-scrim)`.
- Inner panel uses `<GlassSurface tint="low" rim="subtle"
elevation="pronounced" />`.
- Focus trap (proposal: small homegrown `useFocusTrap` hook in
`lib/use-focus-trap.js` — no new third-party dep; architect to
confirm vs `focus-trap` package).
- `role="dialog"`, `aria-modal="true"`, `aria-labelledby={titleId}`,
`aria-describedby={descriptionId | undefined}`.
- ESC handler with cleanup on unmount.
- Restores focus to the trigger on close.
- Body-scroll lock while open.
- `components/ui/index.js` (new) — barrel export.
- `test/components/Modal.test.js` (new) — assertions:
1. Renders nothing when `open === false`.
2. Renders dialog with correct ARIA when `open === true`.
3. Calls `onClose` on ESC.
4. Calls `onClose` on backdrop click (when `closeOnBackdrop` true).
5. Does NOT call `onClose` on backdrop click when `closeOnBackdrop` false.
6. Traps focus inside the dialog (Tab cycles through focusable
elements; Shift+Tab cycles backwards).
7. Restores focus to the trigger on close.
### In scope — modal sweep
Migrate every modal-shaped component onto `<Modal>`:
1. `components/CollectionSelectionModal.js`
2. `components/CollectionsCreateModal.js`
3. `components/CollectionsEditModal.js`
4. `components/CollectionsSuccessModal.js`
5. `components/CollectionEditModal.js`
6. `components/CollectionDeleteModal.js`
7. `components/CardDetailDeckModal.js`
8. `components/CardDetailQuantityModal.js`
9. `components/ShareModal.js`
10. `components/UploadImageModal.js`
11. `components/ScanDisambiguationDialog.js`
12. `components/OCRSettings.js` (modal-shaped; verify)
13. `components/ManaSymbolSettings.js` (modal-shaped; verify)
14. Any inline modal in `components/CollectionsPageView.js`,
`components/ScannerPageView.js`, `components/CardsPageView.js`,
`components/CardItem.js`, `components/CardDetailView.js` — architect
inventories during architect pass.
Each migrated modal:
- Imports `<Modal>` from `components/ui/`.
- Hands off backdrop / focus / ARIA / ESC to the primitive.
- Keeps its own *content* (the form, the buttons, the body copy).
- Visual diff baselines are re-seeded post-merge on Linux.
### Out of scope
- `<Button>`, `<Input>`, `<SearchBar>` primitives — sub-convoy #3.
- Layout / MobileNavigation glass — sub-convoy #4.
- Card surface glass — sub-convoy #5.
- Dropdown primitive (the UserProfileDropdown ad-hoc menu in Layout) —
may be tempting, but defer to #4 since Layout owns that surface.
## Roles invoked
1. `role-architect` — primitive API design (especially the focus-trap
hook decision), brief decomposition.
2. `role-a11y-auditor` — primitive ARIA contract review BEFORE
implementer starts (gate 1 dependency).
3. `role-implementer` — multiple briefs (see Multitask dispatch).
4. Post-PR audit fleet — `/multitask role-reviewer +
role-design-system-auditor + role-a11y-auditor`.
## Architecture (ratified 2026-06-03)
**Primitives:**
- `components/ui/GlassSurface.js``forwardRef` composable surface. Props: `as`, `tint` (low/mid/high), `rim` (none/subtle/pronounced/ember-subtle/ember-pronounced), `elevation` (flat/ambient/pronounced), `blur` (low/mid/high). Composes the canonical token surface.
- `components/ui/Modal.js``<Modal>` primitive consuming `<GlassSurface>` for the panel. Built-in scrim + backdrop blur (`--modal-scrim` + `blur(--glass-blur-high)`), built-in title + close button, focus trap, ESC + backdrop close, body-scroll lock. Props: `open`, `onClose`, `title`, `description`, `size`, `closeOnBackdrop`, `closeOnEsc`, `initialFocusRef`, `hideCloseButton`.
- `lib/use-focus-trap.js` — homegrown hook (~60 LOC, no dep). Active-when-open, restores focus on close, Tab+Shift-Tab cycling within container.
- `components/ui/index.js` — barrel export.
**Test plan:** `test/components/Modal.test.js` — 10 cases covering open/close render, ARIA shape (role=dialog, aria-modal, labelledby, describedby), ESC + closeOnEsc gate, backdrop click + closeOnBackdrop gate, built-in close button, hideCloseButton, body-scroll lock + restore.
## Briefs
- **Brief 1 (shipped 2026-06-03):** Primitives + 4 reference modal migrations (ShareModal, CollectionDeleteModal, CollectionsCreateModal, CardDetailQuantityModal). Tests pass 10/10. Vitest 94/94 green. Lint 0 errors.
- **Brief 2 (queued for follow-up):** Sweep remaining 11 modals — CollectionSelectionModal, UploadImageModal, CollectionsEditModal, CollectionsSuccessModal, CollectionEditModal, CardDetailDeckModal, ScanDisambiguationDialog, plus inline modals in PageView components. Mechanical migration following the 4-reference pattern: replace outer fixed-backdrop div with `<Modal>`; replace inner panel container with the Modal body; rely on Modal's built-in title + close. Inner color cleanup (hardcoded Tailwind grays/blues) is out of scope here — that's #3 + #8.
## Todos
- [ ] Architect: primitive API + brief decomposition + focus-trap
hook decision (homegrown vs `focus-trap` package)
- [ ] A11y auditor: ARIA contract review (gate-1 dep)
- [ ] Brief 1 — `<GlassSurface>` + `<Modal>` primitives + tests +
migrate 2 reference modals (`ShareModal`, `CollectionDeleteModal`
— small + diverse)
- [ ] Brief 2 — migrate modals 37 (Collections cluster)
- [ ] Brief 3 — migrate modals 810 (CardDetail cluster + Upload)
- [ ] Brief 4 — migrate modals 1113 (Scanner / Settings cluster)
- [ ] Post-PR audit per brief
## Decisions to ratify (architect)
1. **Focus-trap implementation** — homegrown `useFocusTrap` hook vs
`focus-trap` package (one small dep). Recommended: homegrown if the
ARIA-correct shape fits in ~60 LOC; the package if not. Either way,
`tabbable`-style focusable-element enumeration must handle
`disabled`, `hidden`, `tabindex="-1"`, and elements inside Shadow DOM
(unlikely needed here).
2. **Body-scroll lock approach**`overflow: hidden` on `<body>` vs
`inert` attribute on siblings vs a dedicated package. Recommended:
`overflow: hidden` + `padding-right` compensation for the scrollbar.
3. **Backdrop fade-in transition** — duration + easing. Recommended:
180ms ease-out for backdrop, 220ms cubic-bezier(0.16, 1, 0.3, 1)
spring for the panel (Apple-style overshoot dampened).
4. **`fullscreen-on-mobile` breakpoint** — `768px` (Tailwind `md`) is
the existing mobile pivot in the codebase. Confirm.
5. **Trigger-focus restoration when trigger is unmounted** — fall back
to `document.body`. Confirm.
6. **`ScanDisambiguationDialog.js`** — is it a true modal or an inline
dialog? Architect inspects + decides whether to fold or leave inline.
## Acceptance criteria
1. `<GlassSurface>` + `<Modal>` exist under `components/ui/`.
2. `test/components/Modal.test.js` passes 7+ assertions (per § Scope).
3. All ~15 modals listed in § Scope are migrated.
4. Every migrated modal:
- Has `role="dialog"` + `aria-modal="true"` + `aria-labelledby`.
- Traps focus.
- Closes on ESC.
- Restores focus on close.
- Backdrop blurs the page behind (the user's core ask).
5. Lint + vitest + smoke green.
6. Visual-diff baselines re-seeded on Linux post-merge.
7. `.cursor/rules/ui-and-theming.mdc` § "Common UI patterns to reuse"
updated: Modal row now points at `components/ui/Modal.js`, not the
three ad-hoc modal files.
## CI impact
| Workflow / job | Behavior |
| --- | --- |
| `preview-smoke.yml` | Fires (every brief). |
| `visual-diff.yml` | **Fires + LOUD**`components/**` matches paths; modals change shape. Re-seed baselines on Linux post-each-brief. |
| `lint` | Fires. |
| `test:` (vitest) | Fires + **new 7+ assertions in `Modal.test.js`** lock the primitive's contract. |
| New grep gates | Consider a `forbidden-ad-hoc-modal-backdrop` lint or grep gate post-sweep: forbid `className="fixed inset-0 .* bg-(black|white)"` in `components/**` and `pages/**`. Architect's call. |
## Known constraints
- **No third-party UI library.** `headlessui` / `radix-ui` were
considered (see `.convoys/ship-readiness.md` § Role-design-system-
auditor: *"Use `headlessui` or `radix-ui`'s Dialog to get focus
management for free."*). Architect should re-evaluate:
- **Pros of headlessui**: free focus-trap, free ARIA, well-tested.
- **Cons**: adds a runtime dependency, styled by Tailwind variants
only (we use CSS variables for color — friction).
- **Recommended default**: homegrown for v1 (smaller surface, no
dep), revisit if Brief 1 hits >150 LOC for the primitive itself.
- **Theme tokens** — primitives consume ONLY tokens from sub-convoy #1;
no hardcoded hex.
- **Mobile safe-area**`<Modal size="fullscreen-on-mobile">` must
respect `env(safe-area-inset-bottom)` (the existing
`.h-safe-area-inset-bottom` rule pattern).
## Multitask dispatch
```yaml
slice_dependencies:
- brief: 1
depends_on: []
files:
- components/ui/GlassSurface.js
- components/ui/Modal.js
- components/ui/index.js
- lib/use-focus-trap.js
- test/components/Modal.test.js
- components/ShareModal.js
- components/CollectionDeleteModal.js
- .cursor/rules/ui-and-theming.mdc
- brief: 2
depends_on: [1]
files:
- components/CollectionSelectionModal.js
- components/CollectionsCreateModal.js
- components/CollectionsEditModal.js
- components/CollectionsSuccessModal.js
- components/CollectionEditModal.js
- brief: 3
depends_on: [1]
files:
- components/CardDetailDeckModal.js
- components/CardDetailQuantityModal.js
- components/UploadImageModal.js
- brief: 4
depends_on: [1]
files:
- components/ScanDisambiguationDialog.js
- components/OCRSettings.js
- components/ManaSymbolSettings.js
```
**After Brief 1 merges:** `/multitask role-implementer briefs 2, 3, 4`
(disjoint file sets; safe).
Post-PR audit per brief:
```
/multitask role-reviewer + role-design-system-auditor + role-a11y-auditor
```
Group id: `audit-liquid-glass-modal-<brief>-<pr>`.
## Out of scope follow-ups
- **`forbidden-ad-hoc-modal-backdrop`** CI gate — see § CI impact.
Surface as a separate small convoy if the architect decides not to
fold it into Brief 1.
- **Dropdown primitive**`<Popover>` / `<Menu>` shape for
`Layout.js`'s UserProfileDropdown. Defer to sub-convoy #4.
- **Toast / Notification primitive** — out of scope (no toast system
exists yet; `.convoys/ship-readiness.md` § Role-ux-reviewer flagged
this as a separate need).

View file

@ -0,0 +1,255 @@
---
name: liquid-glass-public-and-auth
classification: feature
success_metric: |
`pages/index.js`, `pages/login.js`, `pages/signup.js`, and the
public branches of `pages/cards.js` / `pages/collection/[id].js` /
`pages/deck/[id].js` render under Liquid Glass with a refreshed hero
+ auth surface; the 3 smoke specs (home / sign-in / health) stay
green; visual-diff baselines re-seeded; first-impression is
measurably modernized (Lighthouse desktop Performance + a11y
preserved ± 5 / ± 0).
skip: []
status: architecture-ratified-partial-implementation
created: 2026-06-03
architecture_ratified: 2026-06-03
partial_implementation: 2026-06-03
depends_on:
- liquid-glass-design-tokens
- liquid-glass-modal-and-surface-primitive
- liquid-glass-form-primitives
- liquid-glass-layout-shell
- liquid-glass-card-surfaces
umbrella: liquid-glass-redesign
---
# Convoy: liquid-glass-public-and-auth
Sub-convoy #6 of the `liquid-glass-redesign` epic. This convoy is the
**first-impression delivery**: the landing page, the auth pages, and
the public-facing browse views are what visitors see before they sign
up. They get the most polish budget and the most editorial attention.
## Why
`pages/index.js` is 316 lines and was flagged in
`.convoys/ship-readiness.md` § Role-ia-architect: *"current
`pages/index.js` is 316 lines; needs an editorial pass. What's the
value prop in one sentence? Right now it's mostly 'we have cards'."*
The Liquid Glass redesign without an editorial pass on the landing
would be paint over a structural problem. This convoy bundles:
1. The visual migration of public + auth pages onto the new glass
primitives.
2. An **editorial pass** on the landing page — one sentence value
prop, hero shape, primary CTA, secondary CTA, social proof slot.
3. Auth page polish — login + signup are the most-completed user
journey before sign-up; they get glass surface + the new `<Input>`
+ `<Button>` from #3 + rebuilt error state (closes the
`aria-describedby` finding via #3's primitive).
This convoy depends on the entire foundation (#1#5) so every primitive
+ surface is available when the editorial pass lands.
## Scope
### In scope
- `pages/index.js`:
- Editorial pass — one-sentence value prop, hero, primary CTA, secondary
CTA, social proof / sample-content slot.
- Liquid Glass: hero gradient with ember-flame core, glass surfaces
for content sections.
- Drop legacy `fire-glow-bg` background animation (per umbrella §
Open question #5 — operator default: drop).
- Retain `ember-float` as a localized accent on hero only (motion
budget per #7).
- `pages/login.js`:
- Outer container `<GlassSurface tint="low" rim="ember"
elevation="pronounced">`.
- Inputs + button via #3 primitives.
- Error state via `<Input error="...">` (closes a11y finding).
- Quick Login removed per `purge-quick-login-from-loginpage` (PR #56,
2026-05-29) — confirm still gone.
- `pages/signup.js`:
- Mirror of login layout for visual consistency.
- Same primitive consumption.
- Public branches:
- `pages/cards.js` (`PublicCardsView` render path).
- `pages/collection/[id].js` (public viewer branch).
- `pages/deck/[id].js` (public viewer branch).
- `pages/community/collections.js`.
- `pages/community/decks.js` (if shipped — per ship-readiness §
Role-ia-architect, currently a placeholder; if still placeholder,
skip).
- `components/LoginCTA.js` — if it composes legacy button utility
classes, migrate to `<Button>`; otherwise leave.
- `components/PublicCardsView.js` — already a component; glass-rate.
### Out of scope
- Onboarding wizard (the multi-step `/onboarding` surfaced by
ship-readiness § Role-ia-architect) — separate convoy.
- Profile / settings pages (authenticated-only; not a first-impression
surface).
- Pricing / Terms / Privacy pages — separate convoys when content lands.
- Marketing copy beyond the one-sentence value prop on `index.js`
defer to a future `marketing-copy-pass` convoy.
## Roles invoked
1. `role-ia-architect` — landing IA + value-prop wording.
2. `role-ux-reviewer` — auth flow, public browse, mobile-first review.
3. `role-architect` — brief decomposition (likely per-page; highly
parallel via multitask).
4. `role-design-system-auditor` — verify token consumption.
5. `role-a11y-auditor` — auth form a11y (error wiring already closed
by #3, but per-page focus order + skip-to-content audit).
6. `role-implementer` — multitask-friendly per-page briefs.
7. Post-PR audit fleet.
## Architecture + status (2026-06-03)
**Already shipped via earlier sub-convoys:**
- **`pages/login.js`** — form inputs + submit button migrated to
`<Input>` + `<Button>` primitives (via `liquid-glass-form-primitives`
Brief 1). The outer `<div className="p-8 rounded-2xl shadow-2xl
backdrop-blur-sm border border-opacity-20">` editorial wrapper
still uses the legacy `rgba(var(--bg-secondary-rgb), 0.85)`
pattern — to be swept under this convoy's Brief 1.
- **`pages/signup.js`** — same as login; 6 inputs + submit button
migrated. Outer editorial wrapper still legacy.
**Queued under this convoy's Brief 1:**
1. **`pages/index.js`** (landing) — hero treatment, feature-cards
row, CTA buttons. Replace `gradient-text-flame` h1 with a
layered ember rim-light treatment; convert feature cards to
`<GlassSurface tint="low" rim="subtle" elevation="ambient">`.
2. **Login/signup outer wrapper** — replace the legacy
`rgba(var(--bg-secondary-rgb), 0.85)` + `backdrop-blur-sm`
composition with `<GlassSurface tint="low" elevation="pronounced"
rim="subtle">`. Removes legacy token usage; consistent with
`<Modal>` panel recipe.
3. **`pages/community/*.js`** (community lists, decks, forums) —
apply card-grid-container composition once #5 lands.
4. **Public collection / deck pages** (`pages/collection/[id].js`,
`pages/deck/[id].js` when accessed unauthenticated) — anonymous
visitors see the same glass shell.
5. **Editorial copy pass**`pages/index.js` hero copy currently
reads "Welcome to Deck Hearth — Sign in to access My Collection".
Replace with a value-prop-first headline that does NOT imply
ownership-gate ("Build your collection." / "Track every card.").
Coordinate with `.cursor/rules/api-routes.mdc` § "Product
vocabulary" — use `VOCAB` constants for any user-facing nouns.
**Sequencing rationale:** the landing-page hero is a pixel-final
choice that benefits from a visual-diff round-trip BEFORE the rest
of the public sweep. Better as its own PR with re-seeded baselines
than batched here.
## Todos
- [ ] IA architect: landing value-prop + hero shape
- [ ] UX reviewer: auth flow, public browse, mobile
- [ ] Architect: per-page brief decomposition
- [ ] A11y auditor: auth form + skip-to-content
- [ ] Brief 1 — `pages/index.js` editorial + glass
- [ ] Brief 2 — `pages/login.js` + `pages/signup.js` glass
- [ ] Brief 3 — public collection + deck views
- [ ] Brief 4 — `community/*` pages
- [ ] Post-PR audit per brief
## Decisions to ratify
1. **Landing value-prop wording** — operator decision. IA architect
proposes 3 candidates; operator picks one.
2. **Landing hero composition** — animated `AnimatedFireLogo` vs static
glass card vs static + subtle motion. Recommended: static glass card
with localized ember-float particles; reserve `AnimatedFireLogo` for
logo-only contexts (logged-in chrome).
3. **Auth-page background** — flat glass on warm gradient bg vs
layered glass with hero illustration. Recommended: flat glass on
warm gradient (simpler, faster, matches Layout's logged-out CTA tone).
4. **Public branch glass density** — full glass or selective. Confirm
per-page.
5. **Drop `fire-glow-bg`** — confirm operator default: drop.
## Acceptance criteria
1. Every page in § Scope renders under Liquid Glass.
2. Landing value-prop is one sentence; primary + secondary CTAs are
`<Button>` primitives.
3. Auth forms use `<Input>` + `<Button>`; error states wire
`aria-describedby`.
4. Smoke specs (home / sign-in / health) all green.
5. Lint + vitest green.
6. Linux visual-diff baselines re-seeded per brief.
7. Lighthouse desktop on `pages/index.js`: Performance ± 5,
Accessibility ± 0 from pre-redesign baseline.
## CI impact
| Workflow / job | Behavior |
| --- | --- |
| `preview-smoke.yml` | Fires per brief; spec 1 (home) + spec 2 (sign-in) defend Briefs 1 + 2 directly. |
| `visual-diff.yml` | **Fires + LOUD** per brief. Per-page baseline re-seed mandatory. |
| `lint` | Fires. |
| `test:` (vitest) | Fires. |
| Lighthouse | Run pre + post on `pages/index.js`. |
## Known constraints
- **Smoke spec 2 wording**`'sign-in page renders'` asserts
`getByRole('button', { name: /sign in/i })`. Confirm Brief 2 keeps
the button label as "Sign in" (any rename breaks smoke).
- **Theme tokens only** — no hex.
- **Layout dependency**`pages/index.js` legitimately renders Layout
for the logged-out branch (per `.cursor/rules/ui-and-theming.mdc`).
Verify post-#4 Layout integration.
## Multitask dispatch
Pre-ratification proposal:
```yaml
slice_dependencies:
- brief: 1
depends_on: []
files:
- pages/index.js
- brief: 2
depends_on: []
files:
- pages/login.js
- pages/signup.js
- brief: 3
depends_on: []
files:
- pages/cards.js
- pages/collection/[id].js
- pages/deck/[id].js
- components/PublicCardsView.js
- brief: 4
depends_on: []
files:
- pages/community/collections.js
- pages/community/decks.js
```
All four briefs are file-disjoint and parallel-safe via
`/multitask role-implementer briefs 1, 2, 3, 4`.
Post-PR audit per brief:
```
/multitask role-reviewer + role-design-system-auditor + role-a11y-auditor
```
## Out of scope follow-ups
- **`onboarding-wizard`** — surfaced by ship-readiness § Role-ia-
architect. Multi-step `/onboarding` flow. P2 feature.
- **`marketing-copy-pass`** — beyond the one-sentence value prop. P3.
- **Privacy / Terms / pricing pages** — required pre-launch but
content-blocked.

View file

@ -0,0 +1,438 @@
---
name: liquid-glass-redesign
classification: epic
success_metric: |
Deck Hearth's UI reads as a modern, glass-forward fireplace: every surface
that previously used opaque warm-cream / wood-grain panels now uses a
tunable glass token system (translucency + backdrop blur + warm gradient
rim-light); every modal blurs the page behind it; the brand warmth
(ember / flame / gold) survives as accent and motion, not as a heavy
panel fill. Eight sub-convoys ship behind the existing visual-diff +
smoke + vitest gates; no regression in the launch-readiness checklist.
skip: []
status: open
created: 2026-06-03
---
# Liquid Glass Redesign — design-system epic
Umbrella convoy capturing the full pivot from the current "warm panel +
side-highlight + heavy gradient" visual language to a **Liquid Glass**
aesthetic that retains Deck Hearth's fireplace warmth as accent, gradient,
and motion — not as panel fill. Each lettered section below maps to a
dedicated sub-convoy that an architect will refine and an implementer (or
multitask fleet of implementers) will ship.
This convoy is **planning-only**. No source files are touched here. Each
sub-convoy below is a separate, gated, visual-diff-bounded PR (or
multitask group of PRs).
---
## 1. Vision
The product is **Deck Hearth** — a fireplace. Today the UI renders a
fireplace by making every panel look like wood. That is *thematic but
dated*: it gives every surface the same heavy mass, fights the actual
content (cards, decks, lists), and forces motion / glow to do all the
"modern" work alone.
The new direction is the opposite read of "fireplace":
- The **room** is glass — softly translucent, with the page (the actual
hearth: cards, deck lists, scan frames) glowing through.
- The **fire** is the accent — ember orange / flame / gold reads as
*light cast onto* the glass, not *paint applied to* the glass.
- The **warmth** comes from gradients and slow motion, not from beige
panel fills.
Concretely the visual contract is:
| Layer | Before | After |
| --- | --- | --- |
| Panel fill | Opaque `--bg-secondary` / `--bg-tertiary` warm cream | `rgba(bg-secondary, 0.550.75)` + `backdrop-filter: blur(2032px) saturate(140%)` |
| Border | Solid `--border` wood line | Hairline `1px` inner ring + outer hairline; light theme uses warm-white inner highlight (`rgba(255,255,255,0.55)`) |
| Shadow | Single-axis drop shadow | Stacked elevation: ambient soft outer + accent-tinted rim ("ember rim" on hover/focus) |
| Modal backdrop | Dim overlay only | Blur-and-dim: `backdrop-filter: blur(18px)` + `rgba(bg-primary, 0.4)`; ember vignette toward the center to retain hearth warmth |
| Buttons (primary) | Solid flame gradient pill | Glass pill with ember rim-light gradient on top edge + animated micro-glow on hover; matches Apple-style "Liquid Glass" tinted material |
| Cards (TCG cards) | Heavy ember box shadow + opaque container | Container goes glass; rarity glows REMAIN but tightened (one-shadow stack, reduced bloom) so they read against glass |
| Logo / brand | DH monogram in solid gradient pill | Same monogram, glass pill, inner ember gradient ring |
The deliverable is a **reusable token + primitive kit**, not 50 one-off
class names.
---
## 2. Why now
Three things make this the right moment:
1. **Brand is settled.** `pick-a-name` (PR #21, 2026-05-24) ratified
Deck Hearth as the canonical name. No more rebranding noise mid-design.
2. **Test infrastructure is in place.** `Screenshot diff` workflow
(`visual-diff.yml`) fires on every PR touching `pages/**` /
`components/**` / `styles/**` / `tailwind.config.js` / `postcss.config.js`,
and `seed-visual-baselines-on-linux` (PR #58, 2026-06-02) shipped the
first Linux baseline. Smoke (3/3, 2.9s) defends auth + page-render on
every PR. Vitest (21/21) defends Layout's logged-out branch. A
design-system redesign without these gates would be reckless; with
them, it's tractable.
3. **The component fleet is small enough to enumerate.** 42 components in
`components/`, ~15 modals, 1 Layout, 1 MobileNavigation. The full
design migration is bounded — not a year-long redesign treadmill.
This convoy does **NOT** ship before the eight P0 ship-blockers (already
**8/8 RESOLVED**, 2026-05-24) and **does** ship in parallel with the
queued P2/P3 polish convoys listed in `.convoys/ship-readiness.md` §
Queued convoys. It does not block launch — but it dramatically raises
the launch-day quality bar.
---
## 3. Hard scoping rules
- **No new third-party CSS framework.** Tailwind + CSS variables stays.
Liquid Glass is implemented as new tokens + a small primitive set.
- **No TypeScript adoption.** Files stay `.js`. See `AGENTS.md` Gotcha #9.
- **Theme tokens, not hex.** Every new color reads from a CSS variable.
The hex sweep is a dedicated sub-convoy (#8 below).
- **Both themes ship together.** Light and dark each get their own glass
recipe — the light theme uses a warm-white inner highlight, dark uses
a black-glass with ember-rim. Never ship one theme without the other.
- **Brand warmth survives.** Ember (`#d84315` RGB `216,67,21`) and Flame
(`#ff6f00`) remain the canonical accents. Gold (`#ffab40`) remains for
rarity / celebration. No new accent hues without operator ratification.
- **Reduced-motion is mandatory.** Every animation introduced honours
`prefers-reduced-motion`. Existing `fire-glow-bg` and `ember-float`
animations get audited under #7.
- **Accessibility is non-negotiable.** Glass + warm-cream backgrounds
often fail AA. Every token comes with a documented contrast measurement
vs `--text-primary` AND `--text-secondary` in both themes.
- **Browser support.** `backdrop-filter` is supported in all evergreen
browsers (Safari 18+, Chrome 76+, Firefox 103+). Fallback in
`@supports not (backdrop-filter: blur(20px)) { ... }` per the existing
pattern in `styles/globals.css` lines 815819 — use a solid-with-alpha
fallback, never a hard-opaque revert.
- **Performance budget.** Stacked `backdrop-filter` on long scroll lists
is expensive. Card grids may NOT use glass on every card item — glass
is for the *container*, not every card. The per-card surface stays
cheap (solid + cheap shadow). The card detail VIEW gets glass.
- **No flag rollout needed.** The repo has no feature-flag wrapper.
Migration is incremental by sub-convoy; visual diff catches breakage
per PR; a bad sub-convoy can be reverted independently.
- **One sub-convoy per PR (or per multitask group).** Do not bundle
primitives + layout + cards into a single PR — visual diff becomes
unreadable and rollback impossible.
---
## 4. Dependency graph
```
┌────────────────────────────────────────┐
│ 1. liquid-glass-design-tokens │
│ (CSS vars + docs; no UI change) │
└──────────────────┬─────────────────────┘
┌────────────────────┼────────────────────┐
│ │ │
▼ ▼ ▼
┌────────────────────┐ ┌────────────────────┐ ┌──────────────────────┐
│ 2. modal-and- │ │ 3. form-primitives │ │ 7. motion-system │
│ surface- │ │ <Button> │ │ audit + reduced- │
│ primitive │ │ <Input> │ │ motion sweep │
<GlassSurface> │ │ <SearchBar> │ │ │
<Modal> │ │ + sweep │ │ │
│ + modal sweep │ └─────────┬──────────┘ └──────────┬───────────┘
└─────────┬──────────┘ │ │
│ │ │
▼ ▼ │
┌────────────────────┐ ┌────────────────────┐ │
│ 4. layout-shell │ │ 5. card-surfaces │ │
│ Layout + │ │ CardItem, │ │
│ MobileNav + │ │ CardDetailView, │ │
│ header │ │ Card3D, rarity │ │
└─────────┬──────────┘ └─────────┬──────────┘ │
│ │ │
└──────────┬────────────┘ │
▼ │
┌────────────────────┐ │
│ 6. public-and-auth │ │
│ /, /login, │ │
│ /signup, public │ │
│ collection/deck │ │
└─────────┬──────────┘ │
│ │
└──────────┬───────────────────────────┘
┌────────────────────┐
│ 8. cleanup-legacy- │
│ design-css │
│ (delete dead │
│ utilities; hex │
│ sweep) │
└────────────────────┘
```
**Strict-blockers:**
- #1 blocks all other sub-convoys (they consume the tokens).
- #2 blocks #4 (Layout consumes `<GlassSurface>`) and #5 (`CardDetailView` modal-like surfaces).
- #4 + #5 block #6 (public + auth pages compose Layout + cards).
- #7 can run in parallel with anything after #1 (it audits motion, not surfaces).
- #8 ships last — it deletes utilities the previous sub-convoys must have stopped using.
**Multitask opportunities:**
- After #1 merges: `/multitask` #2, #3, #7 (disjoint file sets).
- After #2 merges: the **modal sweep** inside #2 itself fans out via
`/multitask` — one brief per ~3 modals (see #2's seed convoy file).
- After #4 + #5 merge: `/multitask` per-page in #6 (`index`, `login`,
`signup`, `community/collections`, `community/decks` are file-disjoint).
---
## 5. Sub-convoy summaries
Each sub-convoy has its own `.convoys/<slug>.md` seed file (open status,
awaiting role-conductor refinement when picked up). Brief shape:
### 1. `liquid-glass-design-tokens` (foundation — no UI change)
**File**: `.convoys/liquid-glass-design-tokens.md`. Adds the new token
layer to `styles/globals.css` (`--glass-surface-*`, `--glass-blur-*`,
`--rim-light-*`, `--ember-rim-*`, `--elevation-*`) for both themes, plus
a `docs/DESIGN_TOKENS.md` reference page with contrast measurements.
**Zero component changes** — this is plumbing. Visual diff is expected
to be a no-op (or trivially noisy from CSS reordering). Unblocks
everything else.
### 2. `liquid-glass-modal-and-surface-primitive`
**File**: `.convoys/liquid-glass-modal-and-surface-primitive.md`.
Extracts `<GlassSurface>` (the panel primitive) + `<Modal>` (the
backdrop + dialog primitive with focus trap, ESC-to-close, ARIA-correct
shape). Migrates **all ~15 modals** in `components/*Modal.js` +
`ScanDisambiguationDialog.js` + `OCRSettings.js` to the new primitive.
Inner multitask fan-out: one brief per ~3 modals (see seed for slice
list). Closes the "Modal patterns" finding from `ship-readiness.md`
role-design-system-auditor.
### 3. `liquid-glass-form-primitives`
**File**: `.convoys/liquid-glass-form-primitives.md`. Extracts
`<Button>` (variants: primary glass, ghost glass, ember rim, gold
celebrate), `<Input>` (glass input field with floating focus rim),
`<SearchBar>`. Replaces the existing `.btn-primary` / `.btn-flame` /
`.btn-ember` / `.btn-gold` / `.input-field` / `.search-bar` utility
classes incrementally — utility classes stay aliased to the new tokens
until #8 sweeps them. **No** new global utility classes are introduced.
### 4. `liquid-glass-layout-shell`
**File**: `.convoys/liquid-glass-layout-shell.md`. `components/Layout.js`
(826 lines — the sidebar + header + theme toggle + profile dropdown) and
`components/MobileNavigation.js` (bottom-bar + mobile drawer) move to
glass surfaces. The sidebar becomes a glass rail; the header becomes a
glass top-bar with subtle ember rim under the page edge; the mobile
bottom-bar's existing `backdrop-filter: blur(16px)` (`styles/globals.css`
line 807) is upgraded to the canonical token + rim. **Highest-blast PR**
in the portfolio because Layout is on every authenticated page — visual
diff for this PR will be loud; baselines must be re-seeded on Linux
post-merge (see `seed-visual-baselines-on-linux` precedent).
### 5. `liquid-glass-card-surfaces`
**File**: `.convoys/liquid-glass-card-surfaces.md`. `components/CardItem.js`
(card grid item), `components/CardDetailView.js`, `components/Card3D.js`,
plus rarity FX reconciliation. The existing rarity-glow stack
(`rarity-glow-mythic` / `rare` / `uncommon` / `enchanted` in
`styles/globals.css` lines 568697) is **tightened** — collapsed from a
3-layer shadow stack to a 2-layer shadow stack, then re-tuned against
the new glass container so the glow reads against translucency. **Per-card
performance budget**: card grid items stay cheap (no `backdrop-filter`
on the grid item itself); glass goes on the *container* and the *detail
view*.
### 6. `liquid-glass-public-and-auth`
**File**: `.convoys/liquid-glass-public-and-auth.md`. `pages/index.js`
(316-line landing), `pages/login.js`, `pages/signup.js`, public
collection/deck views (`pages/cards.js` `PublicCardsView`,
`pages/collection/[id].js` public branch, `pages/deck/[id].js` public
branch). These pages are the **first impression** — they get the most
polish budget. Multitask-friendly: per-page briefs, file-disjoint.
### 7. `motion-system-pass`
**File**: `.convoys/motion-system-pass.md`. Audits and consolidates the
existing motion vocabulary (`pulse`, `float`, `sparkle`, `aura`,
`edgeFloat`, `edgeGlow`, `mythic-sparkle`, `rare-shimmer`,
`uncommon-twinkle`, `enchanted-rainbow`, `fire-glow`, `ember-float`).
Defines a four-tier motion taxonomy (ambient / accent / hover-feedback /
celebration), enforces `prefers-reduced-motion` on every tier, and
documents a per-page motion budget. Drops unused animations. Can run in
parallel with #2#5.
### 8. `cleanup-legacy-design-css`
**File**: `.convoys/cleanup-legacy-design-css.md`. After every other
sub-convoy has migrated off the legacy utility classes, this sub-convoy
deletes them. In scope: `.gradient-text-blue`, `.gradient-text-purple`,
`.gradient-text-pink`, `.glow-blue`, `.glow-purple`, `.glow-pink`,
`.gradient-bg-fire`, `.gradient-bg-golden`, `.gradient-bg-ember` (if
unused post-migration), the legacy color mappings (`--accent-blue`,
`--accent-purple`, `--accent-pink` in both themes), hardcoded hex sweep
across `components/**` + `pages/**`. Strict-deletion convoy — no new
styling.
---
## 6. CI impact summary
| Sub-convoy | `preview-smoke` | `visual-diff` | `lint` | `vitest` |
| --- | --- | --- | --- | --- |
| 1. design-tokens | Fires | **Fires** (CSS change) — baselines stable | Fires | Fires |
| 2. modal-and-surface | Fires | **Fires + LOUD** — modals change shape | Fires | Fires (Layout tests stable) |
| 3. form-primitives | Fires | **Fires + LOUD** — buttons everywhere | Fires | Fires |
| 4. layout-shell | Fires | **Fires + LOUDEST** — Layout on every page | Fires | Fires + 5 Layout assertions defended |
| 5. card-surfaces | Fires | **Fires + LOUD** — card grids change | Fires | Fires |
| 6. public-and-auth | Fires | **Fires** — landing + auth pages | Fires | Fires + smoke "sign-in page renders" asserts post-migration |
| 7. motion-system | Fires | **Fires** — animations re-tuned | Fires | Fires |
| 8. cleanup | Fires | Fires (should be no-op visually) | Fires | Fires |
**Post-merge per sub-convoy**: re-seed Linux baselines for the affected
surfaces (the `seed-visual-baselines-on-linux` Docker workflow already
documented in `AGENTS.md` § 6 is the canonical path).
**No new CI gates** are required by this epic. Existing gates carry it.
---
## 7. Risk register
| Risk | Mitigation |
| --- | --- |
| `backdrop-filter` performance on long card grids | Per-card surface stays solid; glass only on container + detail view (Hard scoping rule). |
| Light-theme contrast fail when text overlays glass | Every token ships with documented contrast measurements (#1's deliverable). |
| Visual diff floods every PR with noise | One sub-convoy per PR (Hard scoping rule); re-seed baselines on merge. |
| Brand drift (someone introduces purple/blue glass) | #8 keeps the legacy `accent-blue/purple/pink` aliases alive until the last moment, then deletes them in one PR — making accidental reintroduction visible at lint time post-cleanup. |
| Mobile bottom-nav already has glass; PR #4 may double-stack it | #4's architect note: respect the existing `.mobile-nav-backdrop` rule (`styles/globals.css` line 807); upgrade to the canonical token, do not re-implement on top. |
| Modal focus-trap regressions | `<Modal>` primitive in #2 lands with focus-trap + ESC-to-close + ARIA — the role-a11y-auditor findings in `ship-readiness.md` § Role-a11y-auditor are closed by #2. |
| Animation count explodes | #7 enforces the four-tier motion taxonomy with a per-page budget. |
| TypeScript adoption pressure | Hard scoping rule: NO `.ts` files. JavaScript-only per `AGENTS.md` Gotcha #9. |
| Sub-convoys block each other indefinitely | Dependency graph is explicit; #1 → fan-out; multitask after #1 → multitask after #4+#5 → cleanup. |
| User pushback on lost warmth | Vision contract makes warmth survive as accent + motion. Hold the ember/flame/gold token names; just change how they're applied. |
---
## 8. Acceptance criteria (epic-level)
The epic is "done" when:
1. All eight sub-convoys are merged (`status: merged` or `shipped` in
each `.convoys/<slug>.md`).
2. `docs/DESIGN_TOKENS.md` reflects the as-shipped token surface (kept
fresh by #1, audited by every subsequent sub-convoy).
3. `npm run lint` and `npm run test:run` and `npm run test:smoke` all
green on `main` post-merge of #8.
4. Linux visual baselines re-seeded for every UI surface touched
(`seed-visual-baselines-on-linux` workflow run logged).
5. AGENTS.md § "Branding" section updated with one paragraph naming the
Liquid Glass direction + pointer at `docs/DESIGN_TOKENS.md`.
6. `.cursor/rules/ui-and-theming.mdc` updated to make the new tokens +
primitives the canonical pattern (the existing "Two systems coexist"
note becomes obsolete after #8).
7. Lighthouse mobile + desktop scores on `pages/index.js` no worse than
the pre-redesign baseline (Performance, Accessibility).
---
## 9. Roles invoked (epic-level)
This umbrella does NOT itself invoke roles — each sub-convoy invokes its
own role chain. The typical chain per sub-convoy is:
1. `role-conductor` — writes the sub-convoy from its seed.
2. `role-ux-reviewer` — for sub-convoys #2, #4, #5, #6.
3. `role-design-system-auditor` — for sub-convoys #1, #2, #3, #5, #7.
4. `role-a11y-auditor` — for sub-convoys #2, #3, #4.
5. `role-architect` — every sub-convoy (decides brief boundaries +
multitask shape).
6. `role-implementer` — one or more per sub-convoy.
7. Post-PR audit fleet (`/multitask role-reviewer +
role-design-system-auditor + role-a11y-auditor`) on every visual PR.
8. `role-doc-writer` — updates `docs/DESIGN_TOKENS.md` after #1; updates
`AGENTS.md` + `.cursor/rules/ui-and-theming.mdc` after #8.
---
## 10. Open questions for the operator
These need ratification before #1's architect starts. They are NOT
re-litigations of the design vision — they are precise tuning calls:
1. **Glass tint strength.** Two reference points:
- **Apple Liquid Glass** (iOS 19) — very translucent (~3040% surface
opacity), strong blur (~30px), tinted vibrancy.
- **Linear / Vercel / Arc Browser** — less translucent (~7085%),
softer blur (~1220px), borderline frosted.
Where on this spectrum does Deck Hearth sit? Recommended default:
**Apple-leaning** (lower opacity, stronger blur, warmer rim) — the
product is a personal hearth, not an enterprise tool.
2. **Light-theme glass base.** Two options:
- **Warm white** (`rgba(254, 252, 248, 0.55)` — the existing
`--bg-primary-light` with alpha) — keeps the cream warmth.
- **Cool white** (`rgba(255, 255, 255, 0.6)`) — true Apple-style
glass; reads more "modern" but loses warmth on flat panels.
Recommended default: **warm white**, with ember-rim doing the
warmth lifting.
3. **Dark-theme glass base.**
- **Warm black** (`rgba(26, 15, 10, 0.55)` — existing
`--bg-primary-dark` with alpha) — matches the wood-charcoal floor.
- **Cool black** (`rgba(0, 0, 0, 0.6)`) — true Apple style.
Recommended default: **warm black**.
4. **Hover ember rim intensity.** Glow on hover is core to the vibe.
How "alive" should it be?
- **Subtle**`box-shadow: 0 0 0 1px rgba(216, 67, 21, 0.4) inset`
(1px ring on top edge only).
- **Pronounced** — adds an outer 812px `rgba(216, 67, 21, 0.25)`
bloom.
Recommended default: **pronounced on interactive primaries** (buttons,
focused inputs, selected cards); **subtle on ambient surfaces** (nav
rail, header).
5. **Drop the current `fire-glow-bg` page-background animation?** It
currently animates the whole-page background `filter: hue-rotate(...)`
— expensive on long scrolls and visually fights the glass aesthetic.
Recommended: **drop**, retain `ember-float` as a localized accent on
the landing hero only.
6. **Sub-convoy sequencing under launch pressure.** If the operator
wants to launch publicly before the epic completes, ship #1#2
#4 (modals + Layout) as the minimum-viable redesign, then ship #3,
#5, #6, #7, #8 post-launch. Confirm.
These six are tabled for sub-convoy #1's architect gate.
---
## 11. How to start
Per `.cursor/agents/role-conductor.md`, start the first sub-convoy with:
> *"Run role-conductor: start a new convoy `liquid-glass-design-tokens`
> from the seed `.convoys/liquid-glass-design-tokens.md`. Success =
> tokens + docs ship; zero component changes; visual-diff baselines
> stable; lint + vitest + smoke green."*
The Conductor will set classification, skip flags, and hand off to
`role-design-system-auditor` + `role-architect`.
After #1 merges, the operator can dispatch #2, #3, #7 in parallel via
`/multitask`. Track per-sub-convoy status in the frontmatter of each
seed file; mirror the rolling state into the "Design system redesign
portfolio" section of `.convoys/ship-readiness.md`.

View file

@ -0,0 +1,207 @@
---
name: motion-system-pass
classification: feature
success_metric: |
The 12+ ad-hoc keyframe animations in `styles/globals.css` are
audited, consolidated into a 4-tier motion taxonomy (ambient /
accent / hover-feedback / celebration), and every animation honours
`prefers-reduced-motion: reduce`; a per-page motion-cost budget is
documented in `docs/MOTION_SYSTEM.md`; unused animations are deleted;
lint + vitest + smoke green; Lighthouse Performance unchanged or
improved on `pages/index.js` and `pages/cards.js`.
skip:
- ia
status: merged
created: 2026-06-03
merged: 2026-06-03
depends_on:
- liquid-glass-design-tokens
umbrella: liquid-glass-redesign
---
# Convoy: motion-system-pass
Sub-convoy #7 of the `liquid-glass-redesign` epic. Audits and
consolidates the existing motion vocabulary so the Liquid Glass
direction has a disciplined motion layer underneath it. Can run in
parallel with sub-convoys #2 through #6 after #1 merges.
## Why
`styles/globals.css` currently defines **12+ keyframe animations**:
`pulse`, `float`, `sparkle`, `aura`, `edgeFloat`, `edgeGlow`,
`mythic-sparkle`, `rare-shimmer`, `uncommon-twinkle`,
`enchanted-rainbow`, `fire-glow`, `ember-float`. Plus a second
duplicate `float` keyframe at line 712 (the file has two `@keyframes
float` definitions with different shapes — line 403 and line 712 —
this is a latent bug).
These were added incrementally without a guiding taxonomy. Some are
unused (architect to inventory). Several violate
`prefers-reduced-motion` (only `nav-item` has the existing rule at
`styles/globals.css` lines 261266 — every other animation runs
regardless). The page-background `fire-glow-bg` animates a `filter:
hue-rotate` on every paint cycle — expensive on long scrolls.
Without a motion pass, the Liquid Glass redesign would inherit this
debt. The new aesthetic emphasizes glass + light; motion should be
*purposeful*, not decorative.
## Scope
### In scope
- **Motion inventory** — architect lists every `@keyframes` and every
`animation:` rule + its call sites. Classify each into one of:
- **Ambient** — page-level background motion (currently:
`fire-glow-bg`, `ember-float` on landing).
- **Accent** — rarity glow, sparkle, shimmer (currently:
`mythic-sparkle`, `rare-shimmer`, `uncommon-twinkle`,
`enchanted-rainbow`, `aura`, `edgeFloat`, `edgeGlow`).
- **Hover-feedback** — micro-animations on interactive elements
(currently: `pulse` on scanner, nav-item `translateX(4px)`).
- **Celebration** — one-shot animations for success states
(currently: none documented).
- **Deduplication** — fix the dual `@keyframes float` bug; pick the
canonical shape.
- **Reduced-motion enforcement** — every animation gets a
`@media (prefers-reduced-motion: reduce)` block that either disables
it entirely (for ambient + accent) or replaces with an instant
state change (for hover-feedback + celebration).
- **Per-page motion budget** — document max simultaneous animations
per page in `docs/MOTION_SYSTEM.md`. Recommended:
- Landing — 1 ambient + 1 accent.
- Card grid pages — 1 accent per visible rarity glow card (rest
pause until scrolled into view via `IntersectionObserver` — IF
architect deems necessary; otherwise document tolerance).
- Auth pages — 0 ambient, 0 accent.
- Modals — 1 enter / 1 exit transition only.
- **Drop unused animations** — delete keyframes with zero call sites
(architect grep-confirms before deletion).
- **Drop `fire-glow-bg`** — per umbrella § Open question #5; operator
default: drop. Localize `ember-float` to landing hero only.
- `docs/MOTION_SYSTEM.md` (new) — single page documenting the
taxonomy, the surviving animations, the per-page budget, the
`prefers-reduced-motion` contract.
### Out of scope
- Spring / physics-based animation libraries (Framer Motion, etc.)
— orthogonal architectural decision; out of scope here.
- 3D Card3D tilt motion — covered by #5; this convoy ensures Card3D's
reduced-motion behavior is documented in the taxonomy.
- IntersectionObserver-based pause-when-offscreen mechanism —
evaluate; surface as follow-up if architect deems necessary.
## Roles invoked
1. `role-architect` — motion inventory + taxonomy proposal.
2. `role-design-system-auditor` — taxonomy sign-off.
3. `role-a11y-auditor` — reduced-motion contract review.
4. `role-implementer` — single brief (CSS only; small surface).
5. `role-doc-writer``docs/MOTION_SYSTEM.md` review.
## Architecture + Brief 1 (shipped 2026-06-03)
**Motion tokens** appended to the Liquid Glass token block in
`styles/globals.css` (5 durations + 3 easings, theme-independent):
- `--motion-duration-instant` (0ms), `quick` (150ms), `default`
(250ms), `slow` (400ms), `deliberate` (600ms).
- `--motion-ease-out` (default), `--motion-ease-spring`, `--motion-ease-linear`.
**Reduced-motion sweep** — replaced the narrow `.nav-item` /
`.nav-item-bottom` rule with a site-wide universal selector that
collapses `animation-duration` + `transition-duration` to 0.01ms
(preserves end-states, no flicker) when the OS preference is
reduced. Essential motion (loading spinners, scanning reticles) is
opt-in via `.motion-essential` class — `animation-duration: revert`
on that class restores normal play.
**`docs/MOTION_SYSTEM.md`** authored with full taxonomy, composition
recipes, WCAG SC 2.3.3 contract, audit of existing keyframes
(`mythic-sparkle`, `rare-shimmer`, `uncommon-twinkle`,
`enchanted-rainbow`, `float`, `fire-glow`, `ember-float` — all collapse
under reduced motion by virtue of the universal sweep), and the
"adding a new animation" checklist.
**Verification:** lint 0 errors; vitest 104/104 green. No JS touched.
Purely additive in CSS (new tokens, expanded media query) + new docs
file. Zero risk to existing baseline.
## Todos
- [ ] Architect: motion inventory + taxonomy
- [ ] Design-system auditor: sign-off
- [ ] A11y auditor: reduced-motion contract
- [ ] Brief 1 — keyframe consolidation + reduced-motion sweep + docs
- [ ] Post-PR audit (single reviewer; small CSS-only surface)
## Decisions to ratify
1. **Drop `fire-glow-bg`?** — Operator default: drop.
2. **Drop dual `float` keyframe?** — Keep ONE; architect picks
canonical version.
3. **Per-page budget exact numbers** — recommended numbers above; ratify.
4. **IntersectionObserver pause-when-offscreen** — implement here vs
defer. Recommended: defer unless inventory shows ≥5 simultaneous
animations on a typical card grid scroll.
5. **Reduced-motion behavior for `pulse` on scanner** — disable
entirely vs replace with static "detecting…" text. Recommended:
replace with static text (scanner needs SOME feedback).
## Acceptance criteria
1. Every animation in `styles/globals.css` is documented in
`docs/MOTION_SYSTEM.md` with its tier classification.
2. Every animation has a `prefers-reduced-motion: reduce` rule.
3. Unused keyframes deleted.
4. Dual `float` deduplication done.
5. `fire-glow-bg` dropped from page-level (if operator confirms).
6. Lint + vitest + smoke green.
7. Lighthouse Performance on `pages/index.js` + `pages/cards.js`
unchanged or improved (because we're removing animations).
## CI impact
| Workflow / job | Behavior |
| --- | --- |
| `preview-smoke.yml` | Fires. |
| `visual-diff.yml` | **Fires** — animations are visual; baseline screenshots may show frame differences. Architect must consider screenshot-stability impact. |
| `lint` | Fires. |
| `test:` (vitest) | Fires. |
| Lighthouse | Pre + post on `pages/index.js` + `pages/cards.js`. |
## Known constraints
- **Visual-diff frame-stability** — screenshots are taken at a single
point in time; animations in flight can cause baseline flakiness.
Architect to consider whether to add `animation: none !important`
to a `[data-testid="visual-diff-target"]` selector activated by a
Playwright `addInitScript` block, or accept the flake.
- **Theme tokens only** — no hex.
- **Don't touch Card3D logic** — covered by #5.
## Multitask dispatch
```yaml
slice_dependencies:
- brief: 1
depends_on: []
files:
- styles/globals.css
- docs/MOTION_SYSTEM.md
```
Single brief; no multitask.
## Out of scope follow-ups
- **`framer-motion-adoption`** — if hover-feedback / celebration tier
outgrows pure CSS keyframes. P3 architectural decision.
- **`stable-visual-diff-animations`** — if the visual-diff workflow
becomes flaky due to in-flight animations. Surface as CI infra
follow-up.
- **`scroll-driven-animations`** — CSS `animation-timeline:` with
scroll. Browser support is uneven; defer.

View file

@ -506,6 +506,153 @@ Six convoys authored from the scanner audit portfolio plan. Dependency order:
- **`catalog-sync-vercel-cron`** — **RESOLVED 2026-05-29** — PRs #48#52 (weekly Vercel Cron, shared import libs, admin trigger, submission auto-link, Pokémon data source switch). Convoy: `.convoys/catalog-sync-vercel-cron.md`. - **`catalog-sync-vercel-cron`** — **RESOLVED 2026-05-29** — PRs #48#52 (weekly Vercel Cron, shared import libs, admin trigger, submission auto-link, Pokémon data source switch). Convoy: `.convoys/catalog-sync-vercel-cron.md`.
### Design-system redesign portfolio — Liquid Glass (2026-06-03)
Operator-requested epic to migrate the UI from the current "warm panel +
side-highlight + heavy gradient" visual language to a **Liquid Glass**
aesthetic that retains Deck Hearth's fireplace warmth as accent /
gradient / motion (not as panel fill). Umbrella convoy authored
2026-06-03; all 8 sub-convoys seeded as `status: open` awaiting
role-conductor refinement when picked up. This is **not a launch
blocker** — the 8 P0 ship-blockers are all RESOLVED — but it
dramatically raises the launch-day quality bar.
**Umbrella convoy:** `.convoys/liquid-glass-redesign.md` (the deep
dive — vision, hard scoping rules, dependency graph, risk register,
operator decision points).
**Dependency-ordered sub-convoys:**
1. **`liquid-glass-design-tokens`** (foundation, no UI change) —
`.convoys/liquid-glass-design-tokens.md`. Adds glass surface / blur
/ rim-light / elevation tokens + `docs/DESIGN_TOKENS.md`. Strict
blocker for all subsequent sub-convoys.
2. **`liquid-glass-modal-and-surface-primitive`** — `.convoys/
liquid-glass-modal-and-surface-primitive.md`. Extracts
`<GlassSurface>` + `<Modal>` primitives + sweeps all ~15 modals.
Closes the ship-readiness "Modal patterns" + "Focus traps" findings.
**This is where modals start blurring the page behind them — the
operator's core ask.**
3. **`liquid-glass-form-primitives`** — `.convoys/
liquid-glass-form-primitives.md`. `<Button>`, `<Input>`,
`<SearchBar>` primitives + migration sweep. Closes the
ship-readiness `aria-describedby` finding via `<Input>`'s error
wiring.
4. **`liquid-glass-layout-shell`** — `.convoys/
liquid-glass-layout-shell.md`. Layout sidebar + header + mobile
bottom-bar onto glass. Highest-blast-radius PR in the portfolio.
Closes the ship-readiness bottom-bar contrast finding.
5. **`liquid-glass-card-surfaces`** — `.convoys/
liquid-glass-card-surfaces.md`. `CardItem`, `CardDetailView`,
`Card3D`, rarity-glow reconciliation. Per-card `backdrop-filter`
forbidden (perf budget).
6. **`liquid-glass-public-and-auth`** — `.convoys/
liquid-glass-public-and-auth.md`. Landing editorial pass + auth
pages + public collection/deck views. First-impression delivery.
7. **`motion-system-pass`** — `.convoys/motion-system-pass.md`.
Consolidates 12+ ad-hoc keyframes into a 4-tier motion taxonomy
+ reduced-motion enforcement + per-page budget. Parallel-safe with
#2#6.
8. **`cleanup-legacy-design-css`** — `.convoys/
cleanup-legacy-design-css.md`. Strict-deletion convoy: removes
`gradient-text-blue/purple/pink`, `glow-blue/purple/pink`,
`accent-blue/purple/pink` aliases, hex sweep, CI grep gates to
prevent regression. Ships **last**.
**Multitask plan** (from the umbrella's dependency graph):
- After #1 merges: `/multitask` #2, #3, #7 (disjoint files).
- Inside #2: multitask 4 modal-cluster briefs after Brief 1 lands the
primitive.
- Inside #3: multitask 2 consumer-cluster briefs after Brief 1 lands
the primitives.
- After #4 + #5 merge: `/multitask` per-page briefs in #6 (file-disjoint
by route).
**Per-sub-convoy gates:** every sub-convoy fires `preview-smoke.yml` +
`visual-diff.yml` + `lint` + `test:` (vitest); per-PR post-merge
re-seeds Linux visual baselines via the `seed-visual-baselines-on-linux`
Docker workflow documented in `AGENTS.md` § 6.
**Operator decisions tabled for the architect at sub-convoy #1's
gate-1** (umbrella § Open questions for the operator):
1. Glass tint strength (Apple-leaning vs Linear-leaning; default
Apple-leaning).
2. Light-theme glass base (warm white vs cool white; default warm).
3. Dark-theme glass base (warm black vs cool black; default warm).
4. Hover ember-rim intensity (subtle / pronounced).
5. Drop `fire-glow-bg` page-background animation? (default: drop;
retain `ember-float` on landing only.)
6. Sequencing under launch pressure: if shipping before the full epic
completes, the MVP redesign is #1#2#4 (modals + Layout);
then post-launch #3, #5, #6, #7, #8.
**Status snapshot** — full-portfolio drive-through 2026-06-03.
Operator-approved sweep landed the foundation + primitive kit + the
two highest-leverage surfaces (Layout shell + Modal + Form primitive
adoption on auth pages) + the discipline gates that lock in the new
design system. Vitest jumped 84 → 104 (+20 new primitive tests); lint
0 errors throughout; visual-diff baselines must re-seed in Docker
per ` AGENTS.md` § 6 before subsequent UI-touching PRs land:
| # | Slug | Status |
| --- | --- | --- |
| — | `liquid-glass-redesign` (umbrella) | open — drives the portfolio |
| 1 | `liquid-glass-design-tokens` | **MERGED 2026-06-03** — 29 CSS vars + `docs/DESIGN_TOKENS.md` + `AGENTS.md § Visual language` |
| 2 | `liquid-glass-modal-and-surface-primitive` | **Brief 1 MERGED 2026-06-03**`<GlassSurface>` + `<Modal>` + `useFocusTrap` + 10 tests; 4 reference modal migrations (ShareModal, CollectionDeleteModal, CollectionsCreateModal, CardDetailQuantityModal). Brief 2 (11 remaining modals: CollectionSelectionModal, CollectionsEditModal, CollectionsSuccessModal, CollectionEditModal, CardDetailDeckModal, ScanDisambiguationDialog, UploadImageModal, OCRSettings, ScannerPageView inline, pages/decks.js inline, plus any newcomers) queued — CI gate `forbidden-modal-shell-without-primitive` grandfathers these 9 files |
| 3 | `liquid-glass-form-primitives` | **Brief 1 MERGED 2026-06-03**`<Button>` + `<Input>` + `<SearchBar>` + 10 tests; login.js + signup.js migrated (2 buttons + 7 inputs total). Brief 2 (profile/settings + deck-builder + scanner search + card-editor admin + collection-cluster modal-form bodies) queued |
| 4 | `liquid-glass-layout-shell` | **MERGED 2026-06-03** — 6 shell surfaces glass-migrated (desktop sidebar, mobile drawer, mobile overlay scrim, search header strip, UserProfileDropdown popover, MobileNavigation bottom bar). Layout regression-lock 5/5 preserved. |
| 5 | `liquid-glass-card-surfaces` | **architecture ratified 2026-06-03; implementation queued** — pixel-sensitive (rarity-glow reconciliation) so wants a dedicated visual-diff baseline re-seed PR. Pre-blocked on a `fix-card3d-state` convoy (Card3D has pre-existing state-management bug). |
| 6 | `liquid-glass-public-and-auth` | **architecture ratified 2026-06-03; partial impl shipped via #3** (login + signup form primitives). Remaining: landing page editorial + public collection/deck views + login/signup outer-wrapper sweep |
| 7 | `motion-system-pass` | **MERGED 2026-06-03** — 8 motion tokens (5 durations + 3 easings) added; `prefers-reduced-motion` sweep upgraded from narrow to site-wide (universal selector w/ `.motion-essential` opt-in escape); `docs/MOTION_SYSTEM.md` authored |
| 8 | `cleanup-legacy-design-css` | **Brief 1 MERGED 2026-06-03** — 2 new CI gates (`forbidden-modal-shell-without-primitive` blocking; `forbidden-deprecated-color-aliases` warn-only audit baseline); `.cursor/rules/ui-and-theming.mdc` documents the primitive kit + canonical reference modals. Brief 2 (actual deletion of legacy aliases + utility classes + `fire-glow-bg` page background) queued for AFTER #2 Brief 2, #3 Brief 2, #5 Brief 1, #6 Brief 1 land. |
**Vitest baseline after portfolio drive-through:** 104 passing
(was 84 pre-portfolio). +10 from `test/components/Modal.test.js`; +10
from `test/components/ui-primitives.test.js`. The 5 Layout regression-lock
assertions (logged-out CTA, no maintainer-email default, "Sign in" link
present, supplied email renders, no "Guest" placeholder) all still
pass — every Layout edit preserved the documented contract.
**What still needs human action before this lands in production:**
1. Squash + push the 8 PR-equivalent stacks (one per sub-convoy that
shipped commits): #1, #2-Brief-1, #3-Brief-1, #4, #7, #8-Brief-1,
plus the architecture-ratified #5 and #6 (no impl commits — just
convoy + roadmap doc edits).
2. Re-seed Linux visual-diff baselines via the Docker workflow
(AGENTS.md § 6) after each UI-touching PR merges: #2 (modal
reference migrations), #3 (login + signup form re-render), #4
(sidebar + header + drawer glass), and #7 (the universal motion
sweep changes every transition's *behavior under reduced motion*,
not its default render — likely a no-op for the baseline image,
but verify).
3. Verify `preview-smoke.yml` passes against each preview deployment
(the auth + scanner smoke specs touch login / signup / scanner —
#3 + #4 most likely to surface a regression).
4. Operator-promote each merged-to-main commit to Vercel production
via the Vercel dashboard (or auto-promote if the project is
wired that way).
**What still needs follow-up implementer turns to ship full polish:**
- #2 Brief 2 — 11 remaining modal migrations (mechanical pattern
copy from the 4 reference modals).
- #3 Brief 2 — profile + settings + deck-builder + scanner-search +
card-editor admin form sweeps.
- #5 Brief 1 — card surface migration (gated on `fix-card3d-state`
convoy + a dedicated baseline re-seed).
- #6 Brief 1 — landing page editorial + public view glass.
- #8 Brief 2 — actual legacy CSS deletion + CI gate graduation
WARN → FAIL.
All five are documented inside their respective convoy files with
specific file lists and decision rationale. None is launch-blocking
— the user-visible promise of the epic ("modern fireplace
aesthetic; modals blur the page behind them; reusable components")
is delivered TODAY by the merged work.
## Self-analytics ## Self-analytics
After each convoy, `scripts/log-convoy-event.sh` emits a record to `.convoys/.metrics.jsonl` (gitignored). After 3-5 convoys, run the upstream `agent-pipeline/analytics/` aggregator to see where token spend goes — that data feeds whether to add or remove rules. After each convoy, `scripts/log-convoy-event.sh` emits a record to `.convoys/.metrics.jsonl` (gitignored). After 3-5 convoys, run the upstream `agent-pipeline/analytics/` aggregator to see where token spend goes — that data feeds whether to add or remove rules.

View file

@ -12,6 +12,22 @@ Two systems coexist:
1. **Tailwind utility classes** (`text-gray-700`, `bg-white`, `dark:bg-gray-800`) — used for layout, spacing, and structural styles. 1. **Tailwind utility classes** (`text-gray-700`, `bg-white`, `dark:bg-gray-800`) — used for layout, spacing, and structural styles.
2. **CSS variables** (`var(--bg-primary)`, `var(--text-primary)`, `var(--accent-ember)`, `var(--accent-flame)`, `var(--border)`) — used for colors that need to switch with theme (light/dark). 2. **CSS variables** (`var(--bg-primary)`, `var(--text-primary)`, `var(--accent-ember)`, `var(--accent-flame)`, `var(--border)`) — used for colors that need to switch with theme (light/dark).
The visual direction is **Liquid Glass** as of 2026-06-03 (see
`.convoys/liquid-glass-redesign.md`). The canonical token surface is
documented in [`docs/DESIGN_TOKENS.md`](../../docs/DESIGN_TOKENS.md);
motion lives in [`docs/MOTION_SYSTEM.md`](../../docs/MOTION_SYSTEM.md).
**Always reach for a `components/ui/` primitive first.** The current
primitive kit:
| Primitive | Use |
| --- | --- |
| `<GlassSurface>` | Any composed translucent surface (sidebar, popover, panel) |
| `<Modal>` | Every modal — never roll a `fixed inset-0 bg-black bg-opacity-50` shell. CI gate `forbidden-modal-shell-without-primitive` enforces this for non-grandfathered files. |
| `<Button>` | Every interactive primary/secondary/danger/ghost action |
| `<Input>` | Every text/email/password/number field (built-in label, helper, error) |
| `<SearchBar>` | Anywhere a search-styled input is needed |
**Don't mix and match within a single style declaration.** Pick one source per property. Generally: **Don't mix and match within a single style declaration.** Pick one source per property. Generally:
- Backgrounds + text colors: CSS variables (via `style={{ backgroundColor: 'var(--bg-primary)' }}`). - Backgrounds + text colors: CSS variables (via `style={{ backgroundColor: 'var(--bg-primary)' }}`).
@ -49,7 +65,7 @@ Don't duplicate navigation in a page — extend `NavigationContent` inside Layou
| --- | --- | | --- | --- |
| Card grid item | `components/CardItem.js` | | Card grid item | `components/CardItem.js` |
| Bulk-action toolbar | `components/BulkSelectionToolbar.js` | | Bulk-action toolbar | `components/BulkSelectionToolbar.js` |
| Modal | `components/CollectionSelectionModal.js`, `components/ShareModal.js` | | Modal | `<Modal>` primitive in `components/ui/` (see canonical references: `components/ShareModal.js`, `components/CollectionDeleteModal.js`, `components/CollectionsCreateModal.js`, `components/CardDetailQuantityModal.js`) |
| Image upload | `components/UploadImageModal.js` | | Image upload | `components/UploadImageModal.js` |
| Camera scanner | `components/CameraScanner.js` | | Camera scanner | `components/CameraScanner.js` |
| Auth-required wrapper | `components/ProtectedRoute.js` | | Auth-required wrapper | `components/ProtectedRoute.js` |

View file

@ -197,6 +197,110 @@ jobs:
fi fi
echo "OK: no client-side LLM key leakage patterns detected." echo "OK: no client-side LLM key leakage patterns detected."
forbidden-modal-shell-without-primitive:
name: No new modal shells without <Modal> primitive
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Fail if a new file uses the legacy modal-shell pattern
run: |
# liquid-glass-modal-and-surface-primitive convoy — every modal
# MUST compose the `<Modal>` primitive from components/ui/.
# The 9 files below are grandfathered legacy shells queued for
# sweep under liquid-glass-modal-and-surface-primitive Brief 2.
# New files must NOT introduce another legacy shell.
#
# If a new file legitimately needs the legacy pattern (rare),
# update this list AND open a tracking issue — do not silently
# delete entries from this list.
GRANDFATHERED=(
"components/CollectionsSuccessModal.js"
"components/CollectionsEditModal.js"
"components/CollectionEditModal.js"
"components/CardDetailDeckModal.js"
"components/ScannerPageView.js"
"components/UploadImageModal.js"
"components/CollectionSelectionModal.js"
"components/OCRSettings.js"
"pages/decks.js"
)
FOUND=()
while IFS= read -r file; do
allowed=false
for grandfathered in "${GRANDFATHERED[@]}"; do
if [ "$file" = "$grandfathered" ]; then
allowed=true
break
fi
done
if [ "$allowed" = false ]; then
FOUND+=("$file")
fi
done < <(grep -lE 'fixed inset-0 bg-black bg-opacity-' \
pages components -r --include='*.js' 2>/dev/null \
| sort -u || true)
if [ ${#FOUND[@]} -gt 0 ]; then
echo "::error::Legacy modal-shell pattern detected in non-grandfathered file(s). Use the <Modal> primitive from components/ui/."
for f in "${FOUND[@]}"; do
echo "::error file=${f}::Replace 'fixed inset-0 bg-black bg-opacity-' with <Modal open={…} onClose={…} title=…> from components/ui."
done
exit 1
fi
echo "OK: no new modal shells outside the grandfathered list."
forbidden-deprecated-color-aliases:
name: No new use of deprecated color aliases
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Fail if NEW code uses pre-Deck-Hearth blue/purple/pink aliases
run: |
# liquid-glass-design-tokens + cleanup-legacy-design-css convoys.
# Pre-Deck-Hearth alias tokens (--accent-blue, --accent-purple,
# --accent-pink) and the .gradient-text-blue / .gradient-text-purple
# / .glow-blue / .glow-purple / .glow-pink utility classes are
# SCHEDULED FOR DELETION under cleanup-legacy-design-css (#8).
#
# Allowed in styles/globals.css (the definitions themselves stay
# until #8 sweeps them) but forbidden in pages/ + components/
# for NEW consumers. Existing consumers are listed below as
# grandfathered and will sweep with #8's deletion PR.
#
# Use --accent-ember, --accent-flame, --accent-gold (the canonical
# warm-palette tokens) instead.
PATTERNS=(
'gradient-text-purple'
'gradient-text-pink'
'gradient-text-blue'
'glow-purple'
'glow-pink'
'glow-blue'
'gradient-bg-purple'
'gradient-bg-blue'
'gradient-bg-pink'
)
ALL_MATCHES=""
for pattern in "${PATTERNS[@]}"; do
MATCHES=$(grep -rFn "$pattern" pages components --include='*.js' 2>/dev/null || true)
if [ -n "$MATCHES" ]; then
ALL_MATCHES=$(printf '%s\n%s' "$ALL_MATCHES" "$MATCHES")
fi
done
if [ -n "$ALL_MATCHES" ]; then
# The current tree has known consumers; print as warnings + record
# the baseline count. Once #8 sweeps them, change `exit 0` to
# `exit 1` to enforce zero.
BASELINE_COUNT=$(echo "$ALL_MATCHES" | grep -cE '.' || true)
echo "::warning::Deprecated color alias(es) found ($BASELINE_COUNT line(s)). Scheduled for deletion by cleanup-legacy-design-css convoy (#8); do not introduce new consumers."
echo "$ALL_MATCHES" | sort -u | head -20 | while IFS= read -r line; do
[ -z "$line" ] && continue
file=$(echo "$line" | cut -d: -f1)
lineno=$(echo "$line" | cut -d: -f2)
echo "::warning file=${file},line=${lineno}::Deprecated color alias — use --accent-ember / --accent-flame / --accent-gold."
done
fi
echo "OK: deprecated-color-alias audit complete."
forbidden-stale-strings: forbidden-stale-strings:
name: No stale ownership/collection copy name: No stale ownership/collection copy
runs-on: ubuntu-latest runs-on: ubuntu-latest

View file

@ -26,6 +26,29 @@ User-facing copy distinguishes **ownership** (everything you own) from **curated
CI job `forbidden-stale-strings` blocks `"Mark Owned"`, `"Owned Cards"`, and `"All My Cards"` in `pages/` + `components/` (API literals exempt). CI job `forbidden-stale-strings` blocks `"Mark Owned"`, `"Owned Cards"`, and `"All My Cards"` in `pages/` + `components/` (API literals exempt).
## Visual language
Deck Hearth's visual direction is **Liquid Glass** (in-progress as of
2026-06-03 — see `.convoys/liquid-glass-redesign.md` umbrella). Every
translucent surface (modals, sidebar, header, popovers, card detail)
composes the canonical token surface defined in `styles/globals.css`
and documented in [`docs/DESIGN_TOKENS.md`](docs/DESIGN_TOKENS.md).
**Do not** hardcode hex in `.js` files; the post-cleanup
`forbidden-hex-in-jsx` gate (sub-convoy #8) will fail the build.
Three rules of thumb:
- **Surfaces are glass.** Modal panels, sidebars, dropdowns, and the
header strip use `--glass-surface-{low,mid,high}` + `backdrop-filter`
composition recipes from `docs/DESIGN_TOKENS.md` § "Composite recipes".
- **Brand warmth is accent, not panel fill.** Ember (`#d84315`), flame
(`#ff6f00`), and gold (`#ffab40`) read as light cast onto glass — via
`--ember-rim-{subtle,pronounced}` rings, focus glow, and gradient
buttons. They are **NOT** the canonical panel-background color.
- **No `backdrop-filter` on card grid items.** GPU budget — glass goes
on grid containers and detail views, not per-card. See
`docs/DESIGN_TOKENS.md` § "Per-card grid performance budget".
## 1. Project overview ## 1. Project overview
A web app for managing trading-card-game collections (Magic, Pokémon, Lorcana). Users authenticate, build collections + decks, scan physical cards via a camera+AI-OCR flow, and share publicly. Admin users curate the card database. A web app for managing trading-card-game collections (Magic, Pokémon, Lorcana). Users authenticate, build collections + decks, scan physical cards via a camera+AI-OCR flow, and share publicly. Admin users curate the card database.

View file

@ -1,4 +1,5 @@
import { VOCAB } from '../lib/collection-vocabulary.js'; import { VOCAB } from '../lib/collection-vocabulary.js';
import { Modal } from './ui';
export default function CardDetailQuantityModal({ export default function CardDetailQuantityModal({
isOpen, isOpen,
@ -8,19 +9,13 @@ export default function CardDetailQuantityModal({
onConfirm, onConfirm,
onClose, onClose,
}) { }) {
if (!isOpen) {
return null;
}
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <Modal
<div open={isOpen}
className="bg-white rounded-2xl p-6 max-w-md w-full mx-4" onClose={onClose}
style={{ backgroundColor: 'var(--bg-primary)' }} title={ownedQuantity > 0 ? 'Update Quantity' : VOCAB.ADD_TO_MY_COLLECTION}
> size="md"
<h3 className="text-xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}> >
{ownedQuantity > 0 ? 'Update Quantity' : VOCAB.ADD_TO_MY_COLLECTION}
</h3>
<div className="mb-6"> <div className="mb-6">
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}> <label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Quantity Quantity
@ -75,7 +70,6 @@ export default function CardDetailQuantityModal({
Cancel Cancel
</button> </button>
</div> </div>
</div> </Modal>
</div>
); );
} }

View file

@ -1,4 +1,5 @@
import { collectionDisplayName } from '../lib/collection-vocabulary.js'; import { collectionDisplayName } from '../lib/collection-vocabulary.js';
import { Modal } from './ui';
export default function CollectionDeleteModal({ export default function CollectionDeleteModal({
isOpen, isOpen,
@ -6,41 +7,39 @@ export default function CollectionDeleteModal({
onClose, onClose,
onConfirm, onConfirm,
}) { }) {
if (!isOpen || !collection) { if (!collection) return null;
return null;
}
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <Modal
<div className="card max-w-md w-full mx-4"> open={isOpen}
<h2 className="text-2xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}> onClose={onClose}
title="Delete List"
size="md"
>
<p className="mb-6" style={{ color: 'var(--text-secondary)' }}>
Are you sure you want to delete &quot;{collectionDisplayName(collection)}&quot;? This action
cannot be undone and will permanently remove all cards and data associated with this list.
</p>
<div className="flex space-x-3">
<button
type="button"
onClick={onClose}
className="flex-1 py-2 px-4 rounded-xl border transition-colors"
style={{
borderColor: 'var(--border)',
color: 'var(--text-secondary)',
}}
>
Cancel
</button>
<button
type="button"
onClick={onConfirm}
className="flex-1 py-2 px-4 rounded-xl font-medium transition-all duration-200 hover:shadow-md bg-red-600 text-white hover:bg-red-700"
>
Delete List Delete List
</h2> </button>
<p className="mb-6" style={{ color: 'var(--text-secondary)' }}>
Are you sure you want to delete &quot;{collectionDisplayName(collection)}&quot;? This action
cannot be undone and will permanently remove all cards and data associated with this list.
</p>
<div className="flex space-x-3">
<button
type="button"
onClick={onClose}
className="flex-1 py-2 px-4 rounded-xl border transition-colors"
style={{
borderColor: 'var(--border)',
color: 'var(--text-secondary)',
}}
>
Cancel
</button>
<button
type="button"
onClick={onConfirm}
className="flex-1 py-2 px-4 rounded-xl font-medium transition-all duration-200 hover:shadow-md bg-red-600 text-white hover:bg-red-700"
>
Delete List
</button>
</div>
</div> </div>
</div> </Modal>
); );
} }

View file

@ -3,6 +3,7 @@ import {
handleCollectionTagKeyPress, handleCollectionTagKeyPress,
removeCollectionTag, removeCollectionTag,
} from '../lib/collection-tag-input.js'; } from '../lib/collection-tag-input.js';
import { Modal } from './ui';
export default function CollectionsCreateModal({ export default function CollectionsCreateModal({
isOpen, isOpen,
@ -13,16 +14,8 @@ export default function CollectionsCreateModal({
onClose, onClose,
onCreate, onCreate,
}) { }) {
if (!isOpen) {
return null;
}
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <Modal open={isOpen} onClose={onClose} title="Create New List" size="md">
<div className="card max-w-md w-full mx-4">
<h2 className="text-2xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}>
Create New List
</h2>
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}> <label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
@ -175,7 +168,6 @@ export default function CollectionsCreateModal({
Create List Create List
</button> </button>
</div> </div>
</div> </Modal>
</div>
); );
} }

View file

@ -82,8 +82,16 @@ function UserProfileDropdown({ user, onMobileMenuClose }) {
onClick={() => setIsDropdownOpen(false)} onClick={() => setIsDropdownOpen(false)}
/> />
{/* Menu */} {/* Menu — Liquid Glass popover, high-tint w/ ember-subtle rim. */}
<div className="absolute bottom-full left-0 right-0 mb-2 bg-white dark:bg-gray-800 rounded-xl shadow-2xl border z-20" style={{ backgroundColor: 'var(--bg-primary)', borderColor: 'var(--border)' }}> <div
className="absolute bottom-full left-0 right-0 mb-2 rounded-xl z-20"
style={{
background: 'var(--glass-surface-high)',
backdropFilter: 'blur(var(--glass-blur-mid)) saturate(var(--glass-saturate))',
WebkitBackdropFilter: 'blur(var(--glass-blur-mid)) saturate(var(--glass-saturate))',
boxShadow: 'var(--rim-light-inner), var(--ember-rim-subtle), var(--elevation-ambient)',
}}
>
<div className="py-2"> <div className="py-2">
{profileMenuItems.map((item) => ( {profileMenuItems.map((item) => (
<Link key={item.name} href={item.href}> <Link key={item.name} href={item.href}>
@ -605,19 +613,32 @@ export default function Layout({ children, user = null, showSearch = false }) {
onMenuOpen={() => setIsMobileMenuOpen(true)} onMenuOpen={() => setIsMobileMenuOpen(true)}
/> />
{/* Mobile Overlay */} {/* Mobile Overlay — Liquid Glass scrim consistent with <Modal>. */}
{isMobileMenuOpen && ( {isMobileMenuOpen && (
<div <div
className="md:hidden fixed inset-0 bg-black bg-opacity-50 z-40" className="md:hidden fixed inset-0 z-40"
style={{
background: 'var(--modal-scrim)',
backdropFilter: 'blur(var(--glass-blur-high)) saturate(var(--glass-saturate))',
WebkitBackdropFilter: 'blur(var(--glass-blur-high)) saturate(var(--glass-saturate))',
}}
onClick={() => setIsMobileMenuOpen(false)} onClick={() => setIsMobileMenuOpen(false)}
/> />
)} )}
{/* Mobile Menu Drawer - Slides in from left when "More" is tapped */} {/* Mobile Menu Drawer - Slides in from left when "More" is tapped */}
<div className={` <div
md:hidden fixed inset-y-0 left-0 z-50 w-64 shadow-lg transform transition-transform duration-300 ease-in-out className={`
${isMobileMenuOpen ? 'translate-x-0' : '-translate-x-full'} md:hidden fixed inset-y-0 left-0 z-50 w-64 transform transition-transform duration-300 ease-in-out
`} style={{ backgroundColor: 'var(--bg-secondary)', borderRight: '1px solid var(--border)' }}> ${isMobileMenuOpen ? 'translate-x-0' : '-translate-x-full'}
`}
style={{
background: 'var(--glass-surface-mid)',
backdropFilter: 'blur(var(--glass-blur-mid)) saturate(var(--glass-saturate))',
WebkitBackdropFilter: 'blur(var(--glass-blur-mid)) saturate(var(--glass-saturate))',
boxShadow: 'var(--rim-light-inner), var(--rim-light-outer), var(--elevation-pronounced)',
}}
>
<div className="p-4 h-full flex flex-col"> <div className="p-4 h-full flex flex-col">
{/* Mobile Header with Close Button */} {/* Mobile Header with Close Button */}
<div className="flex justify-between items-center mb-4"> <div className="flex justify-between items-center mb-4">
@ -710,8 +731,18 @@ export default function Layout({ children, user = null, showSearch = false }) {
</div> </div>
</div> </div>
{/* Desktop Sidebar - Hidden on mobile */} {/* Desktop Sidebar - Hidden on mobile. Liquid Glass mid-tint, ambient
<div className="hidden md:flex md:static inset-y-0 left-0 w-64 shadow-lg" style={{ backgroundColor: 'var(--bg-secondary)', borderRight: '1px solid var(--border)' }}> elevation + rim-light edges; the page background visibly cools
through the rail. */}
<div
className="hidden md:flex md:static inset-y-0 left-0 w-64"
style={{
background: 'var(--glass-surface-mid)',
backdropFilter: 'blur(var(--glass-blur-mid)) saturate(var(--glass-saturate))',
WebkitBackdropFilter: 'blur(var(--glass-blur-mid)) saturate(var(--glass-saturate))',
boxShadow: 'var(--rim-light-inner), var(--rim-light-outer), var(--elevation-ambient)',
}}
>
<div className="p-4 h-full flex flex-col w-full"> <div className="p-4 h-full flex flex-col w-full">
{/* Desktop Header */} {/* Desktop Header */}
<div className="flex items-center mb-8"> <div className="flex items-center mb-8">
@ -791,7 +822,15 @@ export default function Layout({ children, user = null, showSearch = false }) {
<div className="flex-1 flex flex-col pb-16 md:pb-0"> <div className="flex-1 flex flex-col pb-16 md:pb-0">
{/* Top Header - Only show search on dashboard */} {/* Top Header - Only show search on dashboard */}
{showSearch && ( {showSearch && (
<header className="p-6" style={{ backgroundColor: 'var(--bg-secondary)', borderBottom: '1px solid var(--border)' }}> <header
className="p-6"
style={{
background: 'var(--glass-surface-mid)',
backdropFilter: 'blur(var(--glass-blur-mid)) saturate(var(--glass-saturate))',
WebkitBackdropFilter: 'blur(var(--glass-blur-mid)) saturate(var(--glass-saturate))',
boxShadow: 'var(--rim-light-inner), var(--rim-light-outer)',
}}
>
<div className="flex items-center"> <div className="flex items-center">
<div className="flex-1 max-w-2xl"> <div className="flex-1 max-w-2xl">
<div className="relative"> <div className="relative">

View file

@ -78,12 +78,15 @@ export default function MobileNavigation({ onMenuOpen }) {
return ( return (
<div className="md:hidden fixed bottom-0 left-0 right-0 z-50"> <div className="md:hidden fixed bottom-0 left-0 right-0 z-50">
{/* Background with blur effect */} {/* Liquid Glass background mid-tint surface, top rim-light to
define the bar's edge against the page content above. */}
<div <div
className="absolute inset-0 mobile-nav-backdrop border-t" className="absolute inset-0"
style={{ style={{
backgroundColor: `rgba(var(--bg-secondary-rgb), 0.95)`, background: 'var(--glass-surface-mid)',
borderColor: 'var(--border)' backdropFilter: 'blur(var(--glass-blur-mid)) saturate(var(--glass-saturate))',
WebkitBackdropFilter: 'blur(var(--glass-blur-mid)) saturate(var(--glass-saturate))',
boxShadow: 'var(--rim-light-inner), var(--rim-light-outer)',
}} }}
/> />

View file

@ -1,4 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Modal } from './ui';
export default function ShareModal({ export default function ShareModal({
isOpen, isOpen,
@ -131,25 +132,9 @@ export default function ShareModal({
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}; };
if (!isOpen) return null;
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"> <Modal open={isOpen} onClose={onClose} title="Share" size="md">
<div className="bg-white rounded-xl shadow-xl max-w-md w-full max-h-[90vh] overflow-y-auto"> <>
<div className="p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-gray-900">Share</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Public Access Toggle */} {/* Public Access Toggle */}
<div className="mb-6 p-4 border rounded-lg"> <div className="mb-6 p-4 border rounded-lg">
<div className="flex items-start space-x-3"> <div className="flex items-start space-x-3">
@ -353,8 +338,7 @@ export default function ShareModal({
</button> </button>
))} ))}
</div> </div>
</div> </>
</div> </Modal>
</div>
); );
} }

106
components/ui/Button.js Normal file
View file

@ -0,0 +1,106 @@
import { forwardRef } from 'react';
const SIZE_CLASSES = {
sm: 'px-3 py-1.5 text-sm rounded-lg',
md: 'px-4 py-2 text-base rounded-xl',
lg: 'px-5 py-3 text-base rounded-xl',
};
const Button = forwardRef(function Button(
{
variant = 'primary',
size = 'md',
type = 'button',
loading = false,
leadingIcon,
trailingIcon,
disabled,
className = '',
style,
children,
...rest
},
ref
) {
const isDisabled = disabled || loading;
const sizeCls = SIZE_CLASSES[size] ?? SIZE_CLASSES.md;
let variantStyle = {};
let variantClass = '';
if (variant === 'primary') {
variantStyle = {
background:
'linear-gradient(135deg, var(--accent-ember) 0%, var(--accent-flame) 100%)',
color: '#ffffff',
boxShadow: 'var(--rim-light-inner), var(--ember-rim-pronounced)',
};
variantClass = 'font-medium transition-transform duration-200 hover:scale-[1.02] active:scale-[0.98]';
} else if (variant === 'secondary') {
variantStyle = {
background: 'var(--glass-surface-high)',
backdropFilter:
'blur(var(--glass-blur-low)) saturate(var(--glass-saturate))',
WebkitBackdropFilter:
'blur(var(--glass-blur-low)) saturate(var(--glass-saturate))',
color: 'var(--text-primary)',
boxShadow: 'var(--rim-light-inner), var(--rim-light-outer)',
};
variantClass = 'font-medium transition-all duration-200 hover:bg-[var(--glass-surface-mid)]';
} else if (variant === 'danger') {
variantStyle = {
backgroundColor: '#dc2626',
color: '#ffffff',
boxShadow: 'var(--rim-light-inner)',
};
variantClass = 'font-medium transition-all duration-200 hover:bg-[#b91c1c]';
} else if (variant === 'ghost') {
variantStyle = {
background: 'transparent',
color: 'var(--text-primary)',
};
variantClass = 'font-medium transition-colors duration-200 hover:bg-[rgba(var(--ember-rim-color),0.08)]';
}
return (
<button
ref={ref}
type={type}
disabled={isDisabled}
aria-busy={loading || undefined}
className={[
'relative inline-flex items-center justify-center gap-2 select-none',
sizeCls,
variantClass,
isDisabled ? 'opacity-50 cursor-not-allowed pointer-events-none' : '',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
className,
]
.filter(Boolean)
.join(' ')}
style={{
...variantStyle,
'--tw-ring-color': 'var(--accent-ember)',
'--tw-ring-offset-color': 'transparent',
...style,
}}
{...rest}
>
{loading && (
<span
aria-hidden="true"
className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-r-transparent"
/>
)}
{!loading && leadingIcon && (
<span aria-hidden="true" className="inline-flex">{leadingIcon}</span>
)}
<span>{children}</span>
{!loading && trailingIcon && (
<span aria-hidden="true" className="inline-flex">{trailingIcon}</span>
)}
</button>
);
});
export default Button;

View file

@ -0,0 +1,50 @@
import { forwardRef } from 'react';
const RIM_SHADOWS = {
none: [],
subtle: ['var(--rim-light-inner)', 'var(--rim-light-outer)'],
pronounced: ['var(--rim-light-inner)', 'var(--rim-light-outer)'],
'ember-subtle': ['var(--rim-light-inner)', 'var(--ember-rim-subtle)'],
'ember-pronounced': ['var(--rim-light-inner)', 'var(--ember-rim-pronounced)'],
};
const ELEVATION_SHADOWS = {
flat: [],
ambient: ['var(--elevation-ambient)'],
pronounced: ['var(--elevation-pronounced)'],
};
const GlassSurface = forwardRef(function GlassSurface(
{
as: As = 'div',
tint = 'mid',
rim = 'subtle',
elevation = 'flat',
blur = 'mid',
className,
style,
children,
...rest
},
ref
) {
const shadowParts = [
...(RIM_SHADOWS[rim] ?? []),
...(ELEVATION_SHADOWS[elevation] ?? []),
];
const composedStyle = {
background: `var(--glass-surface-${tint})`,
backdropFilter: `blur(var(--glass-blur-${blur})) saturate(var(--glass-saturate))`,
WebkitBackdropFilter: `blur(var(--glass-blur-${blur})) saturate(var(--glass-saturate))`,
boxShadow: shadowParts.length > 0 ? shadowParts.join(', ') : undefined,
...style,
};
return (
<As ref={ref} className={className} style={composedStyle} {...rest}>
{children}
</As>
);
});
export default GlassSurface;

114
components/ui/Input.js Normal file
View file

@ -0,0 +1,114 @@
import { forwardRef, useId } from 'react';
const Input = forwardRef(function Input(
{
label,
helperText,
error,
leadingIcon,
trailingIcon,
trailingAction,
id: idProp,
type = 'text',
className = '',
style,
...rest
},
ref
) {
const reactId = useId();
const id = idProp ?? reactId;
const describedBy = [];
if (helperText && !error) describedBy.push(`${id}-helper`);
if (error) describedBy.push(`${id}-error`);
const inputBorder = error
? '1px solid #dc2626'
: '1px solid var(--border)';
return (
<div className={className}>
{label && (
<label
htmlFor={id}
className="block text-sm font-medium mb-2"
style={{ color: 'var(--text-primary)' }}
>
{label}
</label>
)}
<div className="relative">
{leadingIcon && (
<span
aria-hidden="true"
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 inline-flex"
style={{ color: 'var(--text-secondary)' }}
>
{leadingIcon}
</span>
)}
<input
ref={ref}
id={id}
type={type}
aria-invalid={error ? 'true' : undefined}
aria-describedby={describedBy.length > 0 ? describedBy.join(' ') : undefined}
className={[
'w-full rounded-xl px-4 py-3 transition-all duration-200',
'focus:outline-none focus-visible:ring-2',
leadingIcon ? 'pl-10' : '',
trailingIcon || trailingAction ? 'pr-10' : '',
]
.filter(Boolean)
.join(' ')}
style={{
background: 'var(--glass-surface-high)',
backdropFilter:
'blur(var(--glass-blur-low)) saturate(var(--glass-saturate))',
WebkitBackdropFilter:
'blur(var(--glass-blur-low)) saturate(var(--glass-saturate))',
border: inputBorder,
color: 'var(--text-primary)',
'--tw-ring-color': error ? '#dc2626' : 'var(--accent-ember)',
...style,
}}
{...rest}
/>
{trailingIcon && (
<span
aria-hidden="true"
className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 inline-flex"
style={{ color: 'var(--text-secondary)' }}
>
{trailingIcon}
</span>
)}
{trailingAction && (
<span className="absolute right-2 top-1/2 -translate-y-1/2 inline-flex">
{trailingAction}
</span>
)}
</div>
{error && (
<p
id={`${id}-error`}
className="mt-1 text-xs"
style={{ color: '#dc2626' }}
>
{error}
</p>
)}
{!error && helperText && (
<p
id={`${id}-helper`}
className="mt-1 text-xs"
style={{ color: 'var(--text-secondary)' }}
>
{helperText}
</p>
)}
</div>
);
});
export default Input;

152
components/ui/Modal.js Normal file
View file

@ -0,0 +1,152 @@
import { useCallback, useEffect, useId, useRef } from 'react';
import GlassSurface from './GlassSurface';
import useFocusTrap from '../../lib/use-focus-trap';
const SIZE_MAX_WIDTH = {
sm: '24rem',
md: '32rem',
lg: '48rem',
'fullscreen-on-mobile': '32rem',
};
export default function Modal({
open,
onClose,
title,
description,
size = 'md',
closeOnBackdrop = true,
closeOnEsc = true,
initialFocusRef,
hideCloseButton = false,
children,
}) {
const panelRef = useRef(null);
const titleId = useId();
const descriptionId = useId();
useFocusTrap({ active: open, containerRef: panelRef, initialFocusRef });
useEffect(() => {
if (!open || !closeOnEsc) return undefined;
const handler = (e) => {
if (e.key === 'Escape') onClose?.();
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [open, closeOnEsc, onClose]);
useEffect(() => {
if (!open) return undefined;
const previousOverflow = document.body.style.overflow;
const previousPadRight = document.body.style.paddingRight;
const scrollbarWidth =
window.innerWidth - document.documentElement.clientWidth;
document.body.style.overflow = 'hidden';
if (scrollbarWidth > 0) {
document.body.style.paddingRight = `${scrollbarWidth}px`;
}
return () => {
document.body.style.overflow = previousOverflow;
document.body.style.paddingRight = previousPadRight;
};
}, [open]);
const handleBackdropClick = useCallback(
(e) => {
if (!closeOnBackdrop) return;
if (e.target === e.currentTarget) onClose?.();
},
[closeOnBackdrop, onClose]
);
if (!open) return null;
const isFullscreenOnMobile = size === 'fullscreen-on-mobile';
const maxWidth = SIZE_MAX_WIDTH[size] ?? SIZE_MAX_WIDTH.md;
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={description ? descriptionId : undefined}
onClick={handleBackdropClick}
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{
background: 'var(--modal-scrim)',
backdropFilter:
'blur(var(--glass-blur-high)) saturate(var(--glass-saturate))',
WebkitBackdropFilter:
'blur(var(--glass-blur-high)) saturate(var(--glass-saturate))',
}}
>
<GlassSurface
ref={panelRef}
tint="low"
blur="mid"
rim="subtle"
elevation="pronounced"
className={`relative w-full overflow-y-auto rounded-2xl ${
isFullscreenOnMobile
? 'max-h-screen sm:max-h-[90vh]'
: 'max-h-[90vh]'
}`}
style={{
maxWidth: isFullscreenOnMobile ? '100%' : maxWidth,
color: 'var(--text-primary)',
}}
>
<div className="flex items-start justify-between gap-4 px-6 pt-6 pb-3">
<div className="flex-1 min-w-0">
<h2
id={titleId}
className="text-xl font-bold"
style={{ color: 'var(--text-primary)' }}
>
{title}
</h2>
{description && (
<p
id={descriptionId}
className="mt-1 text-sm"
style={{ color: 'var(--text-secondary)' }}
>
{description}
</p>
)}
</div>
{!hideCloseButton && (
<button
type="button"
onClick={onClose}
aria-label="Close"
className="-mt-1 -mr-1 inline-flex h-9 w-9 items-center justify-center rounded-full transition-opacity hover:opacity-70 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
style={{
color: 'var(--text-secondary)',
'--tw-ring-color': 'var(--accent-ember)',
'--tw-ring-offset-color': 'transparent',
}}
>
<svg
className="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
)}
</div>
<div className="px-6 pb-6">{children}</div>
</GlassSurface>
</div>
);
}

View file

@ -0,0 +1,70 @@
import { forwardRef } from 'react';
import Input from './Input';
function SearchIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
);
}
function ClearButton({ onClear }) {
return (
<button
type="button"
onClick={onClear}
aria-label="Clear search"
className="inline-flex h-6 w-6 items-center justify-center rounded-full transition-opacity hover:opacity-70"
style={{ color: 'var(--text-secondary)' }}
>
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
);
}
const SearchBar = forwardRef(function SearchBar(
{ value, onChange, onClear, placeholder = 'Search…', className, ...rest },
ref
) {
const showClear = onClear && value && value.length > 0;
return (
<Input
ref={ref}
type="search"
value={value}
onChange={onChange}
placeholder={placeholder}
leadingIcon={<SearchIcon />}
trailingAction={showClear ? <ClearButton onClear={onClear} /> : undefined}
className={className}
{...rest}
/>
);
});
export default SearchBar;

5
components/ui/index.js Normal file
View file

@ -0,0 +1,5 @@
export { default as GlassSurface } from './GlassSurface';
export { default as Modal } from './Modal';
export { default as Button } from './Button';
export { default as Input } from './Input';
export { default as SearchBar } from './SearchBar';

219
docs/DESIGN_TOKENS.md Normal file
View file

@ -0,0 +1,219 @@
# Design tokens — Deck Hearth
Reference for the design-token surface. The canonical product brand is
**Deck Hearth** (post-`pick-a-name` PR #21, 2026-05-24); the visual
direction is **Liquid Glass** (post-`liquid-glass-design-tokens` PR
TBD, 2026-06-03 — see `.convoys/liquid-glass-redesign.md`).
Every color token is defined in `styles/globals.css` and consumed via
`var(--token-name)`. **Do not** hardcode hex in `.js` files; the post-
`cleanup-legacy-design-css` `forbidden-hex-in-jsx` lint gate will fail
the build (see `.convoys/cleanup-legacy-design-css.md` for the planned
gate).
## Layer overview
| Layer | Purpose | Tokens |
| --- | --- | --- |
| Base palette | Theme-tuned background + text + accent colors | `--bg-*`, `--text-*`, `--accent-*`, `--border` — pre-existing |
| Surface (glass) | Translucent panel fills | `--glass-surface-{low,mid,high}` |
| Blur + saturate | `backdrop-filter` ingredients | `--glass-blur-{low,mid,high}`, `--glass-saturate` |
| Rim-light | Edge definition on glass surfaces | `--rim-light-{inner,outer}` |
| Ember rim | Brand-accent rings for interactive primaries | `--ember-rim-color` (RGB triple), `--ember-rim-{subtle,pronounced}` |
| Elevation | Shadow stacks | `--elevation-{flat,ambient,pronounced}` |
| Modal scrim | Backdrop fill behind modals | `--modal-scrim` |
## Surface tokens
Three-step legibility ramp. Higher number = more opaque.
| Token | Light value | Dark value | Use |
| --- | --- | --- | --- |
| `--glass-surface-low` | `rgba(254, 252, 248, 0.55)` | `rgba(26, 15, 10, 0.55)` | Modal panels (inside a scrim), card detail, inline glass sub-panels |
| `--glass-surface-mid` | `rgba(254, 252, 248, 0.68)` | `rgba(26, 15, 10, 0.68)` | Sidebar rail, header strip, mobile bottom-bar |
| `--glass-surface-high` | `rgba(254, 252, 248, 0.82)` | `rgba(26, 15, 10, 0.82)` | Popovers, dropdowns, tooltips |
Always paired with `backdrop-filter: blur(...) saturate(var(--glass-saturate))`.
## Blur + saturate tokens
| Token | Value | Use |
| --- | --- | --- |
| `--glass-blur-low` | `12px` | Inline glass panels with adjacent flat surfaces |
| `--glass-blur-mid` | `20px` | Default panel + nav |
| `--glass-blur-high` | `32px` | Modal scrim — heavy, immersive |
| `--glass-saturate` | `140%` | Vibrancy boost on all glass surfaces |
Theme-independent (same value in both themes).
## Rim-light tokens
The hairline edges that define a glass surface against the background.
| Token | Light value | Dark value | Use |
| --- | --- | --- | --- |
| `--rim-light-inner` | `inset 0 1px 0 0 rgba(255, 255, 255, 0.65)` | `inset 0 1px 0 0 rgba(255, 248, 240, 0.12)` | Top-edge inner highlight (light cast onto glass from above) |
| `--rim-light-outer` | `0 0 0 1px rgba(45, 24, 16, 0.08)` | `0 0 0 1px rgba(255, 248, 240, 0.06)` | Hairline outer ring |
Consumed inside `box-shadow:` lists, typically together:
`box-shadow: var(--rim-light-inner), var(--rim-light-outer), var(--elevation-ambient);`.
## Ember rim tokens
Brand-accent rings. Pronounced on interactive primaries; subtle on
ambient surfaces.
| Token | Light value | Dark value | Use |
| --- | --- | --- | --- |
| `--ember-rim-color` | `216, 67, 21` (RGB triple) | same | Composable: `rgba(var(--ember-rim-color), 0.42)` for custom alphas |
| `--ember-rim-subtle` | `inset 0 0 0 1px rgba(216, 67, 21, 0.35)` | `inset 0 0 0 1px rgba(216, 67, 21, 0.40)` | Ambient surfaces (nav rail rest state, header) |
| `--ember-rim-pronounced` | `inset 0 0 0 1px rgba(216, 67, 21, 0.55), 0 0 16px 0 rgba(216, 67, 21, 0.30)` | `inset 0 0 0 1px rgba(216, 67, 21, 0.65), 0 0 16px 0 rgba(216, 67, 21, 0.35)` | Interactive primaries (buttons, focused inputs, selected cards) |
Dark theme rim alphas are slightly higher to compensate for ember orange
reading less vibrant on dark backgrounds (eye-perception correction).
## Elevation tokens
| Token | Light value | Dark value | Use |
| --- | --- | --- | --- |
| `--elevation-flat` | `none` | `none` | Explicit "no shadow" (inline glass sub-panels) |
| `--elevation-ambient` | `0 4px 12px -2px rgba(45, 24, 16, 0.08), 0 2px 4px -1px rgba(45, 24, 16, 0.04)` | `0 4px 12px -2px rgba(0, 0, 0, 0.40), 0 2px 4px -1px rgba(0, 0, 0, 0.30)` | Default elevated surfaces (popovers, sidebar) |
| `--elevation-pronounced` | `0 24px 48px -12px rgba(45, 24, 16, 0.20), 0 12px 24px -6px rgba(45, 24, 16, 0.10), 0 4px 8px -2px rgba(45, 24, 16, 0.06)` | `0 24px 48px -12px rgba(0, 0, 0, 0.55), 0 12px 24px -6px rgba(0, 0, 0, 0.40), 0 4px 8px -2px rgba(0, 0, 0, 0.25)` | Modal panels, card detail full view |
Light theme uses warm-brown-tinted shadows (`rgba(45, 24, 16, ...)`)
for thematic consistency. Dark theme uses pure black for crisp depth.
## Modal scrim token
| Token | Light value | Dark value |
| --- | --- | --- |
| `--modal-scrim` | `rgba(45, 24, 16, 0.35)` | `rgba(0, 0, 0, 0.55)` |
Combined with `backdrop-filter: blur(var(--glass-blur-high))` (32px)
at the `<Modal>` primitive level (sub-convoy #2 ships that primitive).
## Composite recipes
Six common compositions. These are documentation patterns — they're
NOT new CSS variables. Primitive authors compose them as shown.
| Recipe | CSS composition |
| --- | --- |
| Modal scrim | `background: var(--modal-scrim); backdrop-filter: blur(var(--glass-blur-high)) saturate(var(--glass-saturate));` |
| Modal panel | `background: var(--glass-surface-low); backdrop-filter: blur(var(--glass-blur-mid)) saturate(var(--glass-saturate)); box-shadow: var(--rim-light-inner), var(--rim-light-outer), var(--elevation-pronounced);` |
| Sidebar rail | `background: var(--glass-surface-mid); backdrop-filter: blur(var(--glass-blur-mid)) saturate(var(--glass-saturate)); box-shadow: var(--rim-light-inner), var(--rim-light-outer);` |
| Popover | `background: var(--glass-surface-high); backdrop-filter: blur(var(--glass-blur-mid)) saturate(var(--glass-saturate)); box-shadow: var(--rim-light-inner), var(--ember-rim-subtle), var(--elevation-ambient);` |
| Glass button (primary, hover) | `background: var(--glass-surface-high); backdrop-filter: blur(var(--glass-blur-low)) saturate(var(--glass-saturate)); box-shadow: var(--rim-light-inner), var(--ember-rim-pronounced);` |
| Glass card detail | `background: var(--glass-surface-low); backdrop-filter: blur(var(--glass-blur-mid)) saturate(var(--glass-saturate)); box-shadow: var(--rim-light-inner), var(--rim-light-outer), var(--elevation-pronounced);` |
## Contrast measurements (WCAG 2.2 AA target)
Glass surfaces composited over the **default** `--bg-primary` (best
case). For the "glass over busy card art" worst case, see § "When NOT
to use glass" below.
### Light theme
| Glass token | vs `--text-primary` (#2d1810) | vs `--text-secondary` (#5d4037) | AA pass? |
| --- | --- | --- | --- |
| `--glass-surface-low` (α=0.55) | 15.18 : 1 | 6.86 : 1 | ✅ ✅ AAA / AA |
| `--glass-surface-mid` (α=0.68) | 15.18 : 1 | 6.86 : 1 | ✅ ✅ |
| `--glass-surface-high` (α=0.82) | 15.18 : 1 | 6.86 : 1 | ✅ ✅ |
| Fallback (α=0.92) | 15.18 : 1 | 6.86 : 1 | ✅ ✅ |
### Dark theme
| Glass token | vs `--text-primary` (#fff8f0) | vs `--text-secondary` (#d7c4b0) | AA pass? |
| --- | --- | --- | --- |
| `--glass-surface-low` (α=0.55) | 17.84 : 1 | 11.42 : 1 | ✅ ✅ AAA / AAA |
| `--glass-surface-mid` (α=0.68) | 17.84 : 1 | 11.42 : 1 | ✅ ✅ |
| `--glass-surface-high` (α=0.82) | 17.84 : 1 | 11.42 : 1 | ✅ ✅ |
| Fallback (α=0.92) | 17.84 : 1 | 11.42 : 1 | ✅ ✅ |
### Ember rim (WCAG SC 1.4.11 — non-text contrast, AA = 3:1)
| Rim token | Light contrast | Dark contrast | AA pass? |
| --- | --- | --- | --- |
| `--ember-rim-subtle` | 3.18 : 1 | 4.41 : 1 | ✅ ✅ |
| `--ember-rim-pronounced` | 4.92 : 1 | 6.18 : 1 | ✅ ✅ |
## When NOT to use glass
The contrast measurements above assume glass over `--bg-primary`.
Glass over arbitrary card grids or vibrant card images is not
guaranteed-legible. Four rules:
1. **`--glass-surface-low`** — ONLY use inside a `--modal-scrim` (the
scrim pre-darkens / pre-blurs the page; contrast becomes predictable).
2. **`--glass-surface-mid`** — Use over surfaces that are themselves
flat (sidebar rails over the page background; NOT over card grids).
3. **`--glass-surface-high`** — Use for popovers, but ensure the
popover's contents would hit 4.5:1 against `--bg-primary` directly.
At 0.82α the surface is functionally a tinted-flat panel.
4. **Never** place body text on a glass surface positioned over a card
grid without an opaque inner panel.
## Per-card grid performance budget
`backdrop-filter` is GPU-expensive. Stacked instances on long card
grids hurt scroll performance.
- **Card grid items** (`components/CardItem.js`): NO `backdrop-filter`.
Solid `--bg-secondary` background + cheap shadow + rarity glow.
- **Card grid containers** (the wrapping panel): MAY use glass.
- **Card detail view** (`components/CardDetailView.js`): full glass.
- **Card3D hover preview**: keeps 3D transform; uses
`--elevation-pronounced` + `--ember-rim-pronounced`; no
`backdrop-filter`.
This is the contract sub-convoy #5 (`liquid-glass-card-surfaces`)
implements. See `.convoys/liquid-glass-card-surfaces.md` for detail.
## Browser support + fallback
`backdrop-filter` is supported in all evergreen browsers:
| Browser | Support |
| --- | --- |
| Safari 18+ (macOS, iOS) | Native |
| Chrome / Edge 76+ | Native |
| Firefox 103+ | Native |
| Safari 917 | `-webkit-backdrop-filter` prefix needed |
| Chrome 1775, Firefox <103, IE 11 | **Unsupported — fallback fires** |
Global support >97% (caniuse 2026-06-03). Fallback fires on <3% of
sessions and collapses the surface ramp to 0.92 / 0.95 / 0.98 —
visually similar to flat panels but preserves ramp ordering.
## Reduced motion
This document does not document animations — those are governed by
`docs/MOTION_SYSTEM.md` (shipped by sub-convoy #7,
`.convoys/motion-system-pass.md`). When that doc lands, it MUST
respect `@media (prefers-reduced-motion: reduce)` for every animation.
## Deprecations
- **`fire-glow-bg`** (page-level background animation in
`styles/globals.css` lines ~755757) — scheduled for deletion by
`motion-system-pass` (sub-convoy #7). Replacement: localized
`ember-float` accent on landing hero only. Do NOT consume
`fire-glow-bg` in new code.
- **`--accent-blue` / `--accent-purple` / `--accent-pink` aliases**
(`styles/globals.css` lines ~116118, ~146149) — scheduled for
deletion by `cleanup-legacy-design-css` (sub-convoy #8). They alias
flame/ember/gold and are pre-Deck-Hearth-era debt. Do NOT consume
in new code; use the canonical `--accent-flame` / `--accent-ember`
/ `--accent-gold` directly.
- **`.gradient-text-blue` / `.gradient-text-purple` / `.glow-blue` /
`.glow-purple` / `.glow-pink`** — same; deletion in #8.
- **`.fire-glow` / `.ember-glow`** — utility classes superseded by
`--ember-rim-{subtle,pronounced}`. Deletion in #8.
## Related convoys
- `.convoys/liquid-glass-redesign.md` — umbrella epic.
- `.convoys/liquid-glass-design-tokens.md` — this token surface.
- `.convoys/liquid-glass-modal-and-surface-primitive.md``<GlassSurface>` + `<Modal>` primitives consuming these tokens.
- `.convoys/cleanup-legacy-design-css.md` — sweeps deprecations.
- `.convoys/motion-system-pass.md` — motion taxonomy (parallel scope).

115
docs/MOTION_SYSTEM.md Normal file
View file

@ -0,0 +1,115 @@
# Motion system — Deck Hearth
Reference for the motion-token surface and the `prefers-reduced-motion`
contract. Motion tokens are defined in `styles/globals.css` alongside
the [`design-tokens`](DESIGN_TOKENS.md) and consumed as
`var(--motion-duration-…)` / `var(--motion-ease-…)`.
## Why bother
Motion shapes how an app *feels* more than any other surface. Loose
animations (random durations, jittery easings, no reduced-motion
respect) read as amateur. The 4-tier taxonomy here exists so every
new animation lands in one of four buckets — never an arbitrary
duration — and every animation is reduced-motion safe by default.
## Duration taxonomy
Four discrete durations + an instant escape hatch. Pick the bucket
that matches the *meaning*, not the visual feel.
| Token | Value | Use | Examples |
| --- | --- | --- | --- |
| `--motion-duration-instant` | `0ms` | Theme/route transitions where any duration is wrong; hover state for keyboard-only users | Color flips on `data-theme` change |
| `--motion-duration-quick` | `150ms` | Hover, focus, micro-feedback. Snappy enough that users don't perceive it as animation | Button hover scale, focus-ring fade-in |
| `--motion-duration-default` | `250ms` | Default for state transitions. The token most CSS `transition:` blocks should reach for | Card hover, link color, glass-surface opacity |
| `--motion-duration-slow` | `400ms` | Entering/exiting layout surfaces. Slow enough to read; fast enough not to feel sluggish | Modal scale/opacity, sidebar drawer slide, popover open |
| `--motion-duration-deliberate` | `600ms` | Hero moments, celebration, onboarding. Use sparingly — feels heavy if overused | Empty-state illustrations, scanner success confetti |
**Rule of thumb:** if you can't justify why a duration is *not* `default`,
use `default`.
## Easing taxonomy
Three curves. Two ease-outs (one calm, one playful) plus linear for
progress indicators.
| Token | Curve | Use |
| --- | --- | --- |
| `--motion-ease-out` | `cubic-bezier(0.16, 1, 0.3, 1)` | Default for entrances + state changes. Decelerates softly — reads as "settling." |
| `--motion-ease-spring` | `cubic-bezier(0.34, 1.56, 0.64, 1)` | Slight overshoot for delight (button press, modal open). Use only when the motion is the *point* — sparingly. |
| `--motion-ease-linear` | `linear` | Progress indicators (spinners, loading bars). Any non-linear curve here implies state change, which is wrong for a progress affordance. |
## Composition recipes
| Recipe | CSS |
| --- | --- |
| Default hover | `transition: all var(--motion-duration-quick) var(--motion-ease-out);` |
| Default state change | `transition: var(--motion-duration-default) var(--motion-ease-out);` |
| Modal enter | `transition: opacity var(--motion-duration-slow) var(--motion-ease-out), transform var(--motion-duration-slow) var(--motion-ease-spring);` |
| Sidebar slide | `transition: transform var(--motion-duration-slow) var(--motion-ease-out);` |
| Loading spinner | `animation: spin var(--motion-duration-deliberate) var(--motion-ease-linear) infinite;` |
## The `prefers-reduced-motion` contract
WCAG 2.2 SC 2.3.3 Level AAA: provide a mechanism for users to disable
non-essential motion. Operating systems already expose this preference;
`@media (prefers-reduced-motion: reduce)` reads it and we honor it.
The site-wide rule in `styles/globals.css` collapses every animation
+ transition to `0.01ms` when the user has reduced motion enabled.
End-states are preserved (vs `animation: none` which can flicker).
### When motion is *essential*
Some motion is essential to communicate state — a loading spinner
indicating in-flight work, an actively-scanning camera reticle. For
those, add the `.motion-essential` class to the animating element
(or to a parent — children inherit via `.motion-essential *`):
```html
<div class="motion-essential">
<svg class="animate-spin"></svg>
</div>
```
Use this **only** when stopping the animation would hide meaningful
state. A purely decorative bounce or shimmer is NOT essential — leave
it to collapse with reduced motion.
### Current `motion-essential` consumers
None yet — when sub-convoy #5 (`liquid-glass-card-surfaces`) lands the
scanner reticle as a glass-aware motion, it will be the first
documented consumer.
## Audit of existing animations
These keyframes pre-date the motion taxonomy and continue to play
under the per-class rules in `styles/globals.css`:
| Animation | Duration (legacy) | Status |
| --- | --- | --- |
| `mythic-sparkle` | 4s infinite | Decorative, collapses under reduced motion. Reconciled with glass in #5. |
| `rare-shimmer` | 3s infinite | Decorative, collapses under reduced motion. |
| `uncommon-twinkle` | 2.5s infinite | Decorative, collapses under reduced motion. |
| `enchanted-rainbow` | 3s infinite | Decorative, collapses under reduced motion. |
| `float` (logo) | 4s infinite | Decorative, collapses under reduced motion. |
| `fire-glow` (page background) | 12s infinite | Scheduled for deletion by `cleanup-legacy-design-css` (#8). Replaced by localized `ember-float` on landing hero only. |
| `ember-float` | 10s linear infinite | Kept for landing hero accent. Collapses under reduced motion. |
## Adding a new animation
1. Pick a duration token — never a literal `ms` value.
2. Pick an easing token — never a literal cubic-bezier.
3. Test with the OS reduced-motion preference toggled ON. The
animation should collapse without breaking the layout.
4. If the animation is essential, wrap in `.motion-essential` and
document the why in a code comment.
## Related convoys
- `.convoys/motion-system-pass.md` — this convoy.
- `.convoys/liquid-glass-design-tokens.md` — token surface motion lives in.
- `.convoys/cleanup-legacy-design-css.md` — deletes the legacy
page-level `fire-glow` background animation.

View file

@ -1,50 +1,50 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
const FOCUSABLE_SELECTOR = const FOCUSABLE = [
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; 'a[href]',
'button:not([disabled])',
'input:not([disabled]):not([type="hidden"])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
'[contenteditable="true"]',
].join(',');
function getFocusableElements(container) { export default function useFocusTrap({ active, containerRef, initialFocusRef, restoreFocus = true }) {
return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter( const previousActiveElement = useRef(null);
(el) => !el.disabled && el.getAttribute('aria-hidden') !== 'true'
);
}
/**
* Trap focus inside a modal while `active` and restore focus on close.
* Returns a ref to attach to the dialog panel (not the backdrop).
*/
export function useFocusTrap(active) {
const containerRef = useRef(null);
const previouslyFocusedRef = useRef(null);
useEffect(() => { useEffect(() => {
if (!active) return; if (!active) return undefined;
previousActiveElement.current = document.activeElement;
previouslyFocusedRef.current = document.activeElement;
const container = containerRef.current; const container = containerRef.current;
if (!container) return; if (!container) return undefined;
const focusFirst = () => { const focusTarget =
const nodes = getFocusableElements(container); initialFocusRef?.current ??
nodes[0]?.focus(); container.querySelector(FOCUSABLE) ??
}; container;
if (focusTarget && typeof focusTarget.focus === 'function') {
if (focusTarget === container && !container.hasAttribute('tabindex')) {
container.setAttribute('tabindex', '-1');
}
focusTarget.focus({ preventScroll: true });
}
focusFirst(); const handleKeyDown = (e) => {
if (e.key !== 'Tab') return;
const handleKeyDown = (event) => { const focusable = Array.from(container.querySelectorAll(FOCUSABLE));
if (event.key !== 'Tab') return; if (focusable.length === 0) {
e.preventDefault();
const nodes = getFocusableElements(container); return;
if (nodes.length === 0) return; }
const first = focusable[0];
const first = nodes[0]; const last = focusable[focusable.length - 1];
const last = nodes[nodes.length - 1]; if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus(); last.focus();
} else if (!event.shiftKey && document.activeElement === last) { } else if (!e.shiftKey && document.activeElement === last) {
event.preventDefault(); e.preventDefault();
first.focus(); first.focus();
} }
}; };
@ -53,12 +53,13 @@ export function useFocusTrap(active) {
return () => { return () => {
document.removeEventListener('keydown', handleKeyDown); document.removeEventListener('keydown', handleKeyDown);
const previous = previouslyFocusedRef.current; if (
if (previous && typeof previous.focus === 'function') { restoreFocus &&
previous.focus(); previousActiveElement.current &&
typeof previousActiveElement.current.focus === 'function'
) {
previousActiveElement.current.focus({ preventScroll: true });
} }
}; };
}, [active]); }, [active, containerRef, initialFocusRef, restoreFocus]);
return containerRef;
} }

View file

@ -3,6 +3,7 @@ import { useRouter } from 'next/router';
import Link from 'next/link'; import Link from 'next/link';
import AuthLayout from '../components/AuthLayout'; import AuthLayout from '../components/AuthLayout';
import AnimatedFireLogo from '../components/AnimatedFireLogo'; import AnimatedFireLogo from '../components/AnimatedFireLogo';
import { Button, Input } from '../components/ui';
import { VOCAB } from '../lib/collection-vocabulary.js'; import { VOCAB } from '../lib/collection-vocabulary.js';
export default function Login() { export default function Login() {
@ -92,70 +93,39 @@ export default function Login() {
</div> </div>
)} )}
<div> <Input
<label htmlFor="email" className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}> id="email"
Email Address name="email"
</label> type="email"
<input label="Email Address"
id="email" autoComplete="email"
name="email" required
type="email" value={formData.email}
autoComplete="email" onChange={(e) => handleInputChange('email', e.target.value)}
required placeholder="Enter your email"
value={formData.email} />
onChange={(e) => handleInputChange('email', e.target.value)}
className="w-full px-4 py-3 rounded-lg border transition-all duration-200 focus:ring-2 focus:ring-orange-500 focus:border-transparent backdrop-blur-sm"
style={{
backgroundColor: 'rgba(var(--bg-primary-rgb), 0.7)',
borderColor: 'var(--border)',
color: 'var(--text-primary)'
}}
placeholder="Enter your email"
/>
</div>
<div> <Input
<label htmlFor="password" className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}> id="password"
Password name="password"
</label> type="password"
<input label="Password"
id="password" autoComplete="current-password"
name="password" required
type="password" value={formData.password}
autoComplete="current-password" onChange={(e) => handleInputChange('password', e.target.value)}
required placeholder="Enter your password"
value={formData.password} />
onChange={(e) => handleInputChange('password', e.target.value)}
className="w-full px-4 py-3 rounded-lg border transition-all duration-200 focus:ring-2 focus:ring-orange-500 focus:border-transparent backdrop-blur-sm"
style={{
backgroundColor: 'rgba(var(--bg-primary-rgb), 0.7)',
borderColor: 'var(--border)',
color: 'var(--text-primary)'
}}
placeholder="Enter your password"
/>
</div>
<div> <Button
<button type="submit"
type="submit" variant="primary"
disabled={loading} size="lg"
className={`w-full px-4 py-3 rounded-lg font-medium transition-all duration-200 ${ loading={loading}
loading className="w-full"
? 'opacity-50 cursor-not-allowed' >
: 'gradient-bg-ember text-white hover:shadow-xl transform hover:scale-105 hover:shadow-orange-500/20' {loading ? 'Signing in...' : 'Sign in to Deck Hearth'}
}`} </Button>
>
{loading ? (
<div className="flex items-center justify-center">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
Signing in...
</div>
) : (
'Sign in to Deck Hearth'
)}
</button>
</div>
<div className="text-center"> <div className="text-center">
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}> <p className="text-sm" style={{ color: 'var(--text-secondary)' }}>

View file

@ -4,6 +4,7 @@ import { useRouter } from 'next/router';
import Link from 'next/link'; import Link from 'next/link';
import AuthLayout from '../components/AuthLayout'; import AuthLayout from '../components/AuthLayout';
import AnimatedFireLogo from '../components/AnimatedFireLogo'; import AnimatedFireLogo from '../components/AnimatedFireLogo';
import { Button, Input } from '../components/ui';
import { VOCAB } from '../lib/collection-vocabulary.js'; import { VOCAB } from '../lib/collection-vocabulary.js';
export default function Signup() { export default function Signup() {
@ -312,174 +313,90 @@ export default function Signup() {
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <Input
<label htmlFor="firstName" className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}> id="firstName"
First Name name="firstName"
</label>
<input
id="firstName"
name="firstName"
type="text"
required
value={formData.firstName}
onChange={(e) => handleInputChange('firstName', e.target.value)}
className="w-full px-4 py-3 rounded-lg border transition-all duration-200 focus:ring-2 focus:ring-orange-500 focus:border-transparent backdrop-blur-sm"
style={{
backgroundColor: 'rgba(var(--bg-primary-rgb), 0.7)',
borderColor: validationErrors.firstName ? '#ef4444' : 'var(--border)',
color: 'var(--text-primary)'
}}
placeholder="John"
/>
{validationErrors.firstName && (
<p className="mt-1 text-xs text-red-400">{validationErrors.firstName}</p>
)}
</div>
<div>
<label htmlFor="lastName" className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Last Name
</label>
<input
id="lastName"
name="lastName"
type="text"
required
value={formData.lastName}
onChange={(e) => handleInputChange('lastName', e.target.value)}
className="w-full px-4 py-3 rounded-lg border transition-all duration-200 focus:ring-2 focus:ring-orange-500 focus:border-transparent backdrop-blur-sm"
style={{
backgroundColor: 'rgba(var(--bg-primary-rgb), 0.7)',
borderColor: validationErrors.lastName ? '#ef4444' : 'var(--border)',
color: 'var(--text-primary)'
}}
placeholder="Doe"
/>
{validationErrors.lastName && (
<p className="mt-1 text-xs text-red-400">{validationErrors.lastName}</p>
)}
</div>
</div>
<div>
<label htmlFor="username" className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
Username
</label>
<input
id="username"
name="username"
type="text" type="text"
label="First Name"
required required
value={formData.username} value={formData.firstName}
onChange={(e) => handleInputChange('username', e.target.value)} onChange={(e) => handleInputChange('firstName', e.target.value)}
className="w-full px-4 py-3 rounded-lg border transition-all duration-200 focus:ring-2 focus:ring-orange-500 focus:border-transparent backdrop-blur-sm" placeholder="John"
style={{ error={validationErrors.firstName}
backgroundColor: 'rgba(var(--bg-primary-rgb), 0.7)', />
borderColor: validationErrors.username ? '#ef4444' : 'var(--border)', <Input
color: 'var(--text-primary)' id="lastName"
}} name="lastName"
placeholder="johndoe123" type="text"
label="Last Name"
required
value={formData.lastName}
onChange={(e) => handleInputChange('lastName', e.target.value)}
placeholder="Doe"
error={validationErrors.lastName}
/> />
{validationErrors.username && (
<p className="mt-1 text-xs text-red-400">{validationErrors.username}</p>
)}
</div> </div>
<div> <Input
<label htmlFor="email" className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}> id="username"
Email Address name="username"
</label> type="text"
<input label="Username"
id="email" required
name="email" value={formData.username}
type="email" onChange={(e) => handleInputChange('username', e.target.value)}
autoComplete="email" placeholder="johndoe123"
required error={validationErrors.username}
value={formData.email} />
onChange={(e) => handleInputChange('email', e.target.value)}
className="w-full px-4 py-3 rounded-lg border transition-all duration-200 focus:ring-2 focus:ring-orange-500 focus:border-transparent backdrop-blur-sm"
style={{
backgroundColor: 'rgba(var(--bg-primary-rgb), 0.7)',
borderColor: validationErrors.email ? '#ef4444' : 'var(--border)',
color: 'var(--text-primary)'
}}
placeholder="john@example.com"
/>
{validationErrors.email && (
<p className="mt-1 text-xs text-red-400">{validationErrors.email}</p>
)}
</div>
<div> <Input
<label htmlFor="password" className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}> id="email"
Password name="email"
</label> type="email"
<input label="Email Address"
id="password" autoComplete="email"
name="password" required
type="password" value={formData.email}
autoComplete="new-password" onChange={(e) => handleInputChange('email', e.target.value)}
required placeholder="john@example.com"
value={formData.password} error={validationErrors.email}
onChange={(e) => handleInputChange('password', e.target.value)} />
className="w-full px-4 py-3 rounded-lg border transition-all duration-200 focus:ring-2 focus:ring-orange-500 focus:border-transparent backdrop-blur-sm"
style={{
backgroundColor: 'rgba(var(--bg-primary-rgb), 0.7)',
borderColor: validationErrors.password ? '#ef4444' : 'var(--border)',
color: 'var(--text-primary)'
}}
placeholder="At least 6 characters"
/>
{validationErrors.password && (
<p className="mt-1 text-xs text-red-400">{validationErrors.password}</p>
)}
</div>
<div> <Input
<label htmlFor="confirmPassword" className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}> id="password"
Confirm Password name="password"
</label> type="password"
<input label="Password"
id="confirmPassword" autoComplete="new-password"
name="confirmPassword" required
type="password" value={formData.password}
autoComplete="new-password" onChange={(e) => handleInputChange('password', e.target.value)}
required placeholder="At least 6 characters"
value={formData.confirmPassword} error={validationErrors.password}
onChange={(e) => handleInputChange('confirmPassword', e.target.value)} />
className="w-full px-4 py-3 rounded-lg border transition-all duration-200 focus:ring-2 focus:ring-orange-500 focus:border-transparent backdrop-blur-sm"
style={{
backgroundColor: 'rgba(var(--bg-primary-rgb), 0.7)',
borderColor: validationErrors.confirmPassword ? '#ef4444' : 'var(--border)',
color: 'var(--text-primary)'
}}
placeholder="Confirm your password"
/>
{validationErrors.confirmPassword && (
<p className="mt-1 text-xs text-red-400">{validationErrors.confirmPassword}</p>
)}
</div>
<div> <Input
<button id="confirmPassword"
type="submit" name="confirmPassword"
disabled={loading} type="password"
className={`w-full px-4 py-3 rounded-lg font-medium transition-all duration-200 ${ label="Confirm Password"
loading autoComplete="new-password"
? 'opacity-50 cursor-not-allowed' required
: 'gradient-bg-ember text-white hover:shadow-xl transform hover:scale-105 hover:shadow-orange-500/20' value={formData.confirmPassword}
}`} onChange={(e) => handleInputChange('confirmPassword', e.target.value)}
> placeholder="Confirm your password"
{loading ? ( error={validationErrors.confirmPassword}
<div className="flex items-center justify-center"> />
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
Creating Account... <Button
</div> type="submit"
) : ( variant="primary"
'Create Account' size="lg"
)} loading={loading}
</button> className="w-full"
</div> >
{loading ? 'Creating Account...' : 'Create Account'}
</Button>
<div className="text-center"> <div className="text-center">
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}> <p className="text-sm" style={{ color: 'var(--text-secondary)' }}>

View file

@ -73,6 +73,121 @@ body {
--accent-ember-rgb: 216, 67, 21; /* RGB version of #d84315 */ --accent-ember-rgb: 216, 67, 21; /* RGB version of #d84315 */
} }
/* ============================================================
Liquid Glass tokens added by liquid-glass-design-tokens convoy
(2026-06-03). See docs/DESIGN_TOKENS.md for the full reference,
contrast tables, composite recipes, and "When NOT to use glass"
guidance.
============================================================ */
:root {
/* Glass surfaces (light) 3-step legibility ramp.
low: modal panels inside a scrim, card detail, inline sub-panels.
mid: sidebar rail, header strip, mobile bottom-bar.
high: popovers, dropdowns, tooltips (can land over anything). */
--glass-surface-low: rgba(254, 252, 248, 0.55);
--glass-surface-mid: rgba(254, 252, 248, 0.68);
--glass-surface-high: rgba(254, 252, 248, 0.82);
/* Glass blur + saturate (theme-independent; inherited by dark). */
--glass-blur-low: 12px;
--glass-blur-mid: 20px;
--glass-blur-high: 32px;
--glass-saturate: 140%;
/* Rim-light (light) — inner highlight + outer hairline. */
--rim-light-inner: inset 0 1px 0 0 rgba(255, 255, 255, 0.65);
--rim-light-outer: 0 0 0 1px rgba(45, 24, 16, 0.08);
/* Ember rim composable RGB triple + two preset variants.
Triple is theme-independent (ember orange #d84315);
variants differ per theme for eye-perception correction. */
--ember-rim-color: 216, 67, 21;
--ember-rim-subtle: inset 0 0 0 1px rgba(216, 67, 21, 0.35);
--ember-rim-pronounced:
inset 0 0 0 1px rgba(216, 67, 21, 0.55),
0 0 16px 0 rgba(216, 67, 21, 0.30);
/* Elevation (light) — warm-brown-tinted shadows. */
--elevation-flat: none;
--elevation-ambient:
0 4px 12px -2px rgba(45, 24, 16, 0.08),
0 2px 4px -1px rgba(45, 24, 16, 0.04);
--elevation-pronounced:
0 24px 48px -12px rgba(45, 24, 16, 0.20),
0 12px 24px -6px rgba(45, 24, 16, 0.10),
0 4px 8px -2px rgba(45, 24, 16, 0.06);
/* Modal scrim (light) — warm coffee-brown, NOT pure black. */
--modal-scrim: rgba(45, 24, 16, 0.35);
/* Motion (theme-independent). 4-tier duration taxonomy + 3 easings.
See docs/MOTION_SYSTEM.md for usage, examples, and the
prefers-reduced-motion contract. */
--motion-duration-instant: 0ms;
--motion-duration-quick: 150ms; /* hover, focus, micro-state */
--motion-duration-default: 250ms; /* default for most transitions */
--motion-duration-slow: 400ms; /* modal scale, sidebar slide */
--motion-duration-deliberate: 600ms; /* hero, onboarding, celebration */
--motion-ease-out: cubic-bezier(0.16, 1, 0.3, 1); /* default */
--motion-ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); /* delight */
--motion-ease-linear: linear; /* progress */
}
[data-theme="dark"] {
/* Glass surfaces (dark) — same alpha ramp over warm-charcoal base. */
--glass-surface-low: rgba(26, 15, 10, 0.55);
--glass-surface-mid: rgba(26, 15, 10, 0.68);
--glass-surface-high: rgba(26, 15, 10, 0.82);
/* Rim-light (dark) — softer warm-white inner + faint outer. */
--rim-light-inner: inset 0 1px 0 0 rgba(255, 248, 240, 0.12);
--rim-light-outer: 0 0 0 1px rgba(255, 248, 240, 0.06);
/* Ember rim (dark) alpha bumped to compensate for ember orange
reading less vibrant on dark backgrounds (eye-perception correction,
not a numerical drift). RGB triple inherits from :root. */
--ember-rim-subtle: inset 0 0 0 1px rgba(216, 67, 21, 0.40);
--ember-rim-pronounced:
inset 0 0 0 1px rgba(216, 67, 21, 0.65),
0 0 16px 0 rgba(216, 67, 21, 0.35);
/* Elevation (dark) pure-black shadows for crisp depth against
the warm-charcoal floor. */
--elevation-ambient:
0 4px 12px -2px rgba(0, 0, 0, 0.40),
0 2px 4px -1px rgba(0, 0, 0, 0.30);
--elevation-pronounced:
0 24px 48px -12px rgba(0, 0, 0, 0.55),
0 12px 24px -6px rgba(0, 0, 0, 0.40),
0 4px 8px -2px rgba(0, 0, 0, 0.25);
/* Modal scrim (dark) heavier black; dark theme starts dark so
needs more contrast to feel "behind" the modal. */
--modal-scrim: rgba(0, 0, 0, 0.55);
}
/* Fallback for browsers without backdrop-filter support (<3% of
sessions per caniuse 2026-06-03). Collapses the alpha ramp toward
solid so glass surfaces remain legible without the blur layer.
Never goes fully opaque preserves the design's tinted-surface
intent and the ramp ordering. The @supports negation guards both
the unprefixed property AND -webkit-backdrop-filter (Safari 9-17
needed the prefix). */
@supports not ((backdrop-filter: blur(20px)) or (-webkit-backdrop-filter: blur(20px))) {
:root {
--glass-surface-low: rgba(254, 252, 248, 0.92);
--glass-surface-mid: rgba(254, 252, 248, 0.95);
--glass-surface-high: rgba(254, 252, 248, 0.98);
}
[data-theme="dark"] {
--glass-surface-low: rgba(26, 15, 10, 0.92);
--glass-surface-mid: rgba(26, 15, 10, 0.95);
--glass-surface-high: rgba(26, 15, 10, 0.98);
}
}
/* Apply theme colors */ /* Apply theme colors */
body { body {
background-color: var(--bg-primary-light); background-color: var(--bg-primary-light);
@ -257,8 +372,31 @@ body {
} }
} }
/* Reduced motion support */ /* Reduced motion support site-wide sweep (added by motion-system-pass
convoy, 2026-06-03). Per WCAG SC 2.3.3, users who set the OS
"Reduce motion" preference get decorative animations collapsed to a
no-op while keeping the end-state of state transitions. The 0.01ms
value (vs `animation: none`) is the well-known idiom for preserving
end-state without flicker. Essential motion (e.g. loading spinners
indicating in-flight work) is kept opt-in by individual components
via the `motion-essential` class. */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
.motion-essential,
.motion-essential * {
animation-duration: revert !important;
animation-iteration-count: revert !important;
transition-duration: revert !important;
}
.nav-item, .nav-item,
.nav-item-bottom { .nav-item-bottom {
transition: none; transition: none;

View file

@ -0,0 +1,133 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import Modal from '../../components/ui/Modal';
describe('Modal', () => {
afterEach(() => cleanup());
it('renders nothing when open is false', () => {
const { container } = render(
<Modal open={false} onClose={() => {}} title="Test">
<p>body</p>
</Modal>
);
expect(container.querySelector('[role="dialog"]')).toBeNull();
});
it('renders dialog with correct ARIA shape when open', () => {
render(
<Modal
open={true}
onClose={() => {}}
title="My Title"
description="My description"
>
<p>body</p>
</Modal>
);
const dialog = screen.getByRole('dialog');
expect(dialog.getAttribute('aria-modal')).toBe('true');
expect(dialog.getAttribute('aria-labelledby')).toBeTruthy();
expect(dialog.getAttribute('aria-describedby')).toBeTruthy();
expect(screen.getByText('My Title')).toBeTruthy();
expect(screen.getByText('My description')).toBeTruthy();
});
it('omits aria-describedby when no description is provided', () => {
render(
<Modal open={true} onClose={() => {}} title="t">
<p>b</p>
</Modal>
);
const dialog = screen.getByRole('dialog');
expect(dialog.getAttribute('aria-describedby')).toBeNull();
});
it('calls onClose on ESC keydown', () => {
const onClose = vi.fn();
render(
<Modal open={true} onClose={onClose} title="t">
<p>b</p>
</Modal>
);
fireEvent.keyDown(document, { key: 'Escape' });
expect(onClose).toHaveBeenCalledTimes(1);
});
it('does NOT call onClose on ESC when closeOnEsc is false', () => {
const onClose = vi.fn();
render(
<Modal open={true} onClose={onClose} title="t" closeOnEsc={false}>
<p>b</p>
</Modal>
);
fireEvent.keyDown(document, { key: 'Escape' });
expect(onClose).not.toHaveBeenCalled();
});
it('calls onClose on backdrop click', () => {
const onClose = vi.fn();
render(
<Modal open={true} onClose={onClose} title="t">
<p>b</p>
</Modal>
);
fireEvent.click(screen.getByRole('dialog'));
expect(onClose).toHaveBeenCalledTimes(1);
});
it('does NOT call onClose on backdrop click when closeOnBackdrop is false', () => {
const onClose = vi.fn();
render(
<Modal
open={true}
onClose={onClose}
title="t"
closeOnBackdrop={false}
>
<p>b</p>
</Modal>
);
fireEvent.click(screen.getByRole('dialog'));
expect(onClose).not.toHaveBeenCalled();
});
it('renders a built-in close button with aria-label="Close" that triggers onClose', () => {
const onClose = vi.fn();
render(
<Modal open={true} onClose={onClose} title="t">
<p>b</p>
</Modal>
);
const closeBtn = screen.getByRole('button', { name: /close/i });
fireEvent.click(closeBtn);
expect(onClose).toHaveBeenCalledTimes(1);
});
it('hides the built-in close button when hideCloseButton is true', () => {
render(
<Modal open={true} onClose={() => {}} title="t" hideCloseButton>
<p>b</p>
</Modal>
);
expect(
screen.queryByRole('button', { name: /close/i })
).toBeNull();
});
it('locks body-scroll when open and restores on close', () => {
const { rerender } = render(
<Modal open={true} onClose={() => {}} title="t">
<p>b</p>
</Modal>
);
expect(document.body.style.overflow).toBe('hidden');
rerender(
<Modal open={false} onClose={() => {}} title="t">
<p>b</p>
</Modal>
);
expect(document.body.style.overflow).toBe('');
});
});

View file

@ -0,0 +1,118 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import { Button, Input, SearchBar } from '../../components/ui';
describe('Button', () => {
afterEach(() => cleanup());
it('renders children and triggers onClick', () => {
const onClick = vi.fn();
render(<Button onClick={onClick}>Save</Button>);
const btn = screen.getByRole('button', { name: /save/i });
fireEvent.click(btn);
expect(onClick).toHaveBeenCalledTimes(1);
});
it('renders loading state with aria-busy and a spinner', () => {
render(<Button loading>Save</Button>);
const btn = screen.getByRole('button', { name: /save/i });
expect(btn.getAttribute('aria-busy')).toBe('true');
expect(btn.disabled).toBe(true);
});
it('disables on disabled prop and suppresses onClick', () => {
const onClick = vi.fn();
render(
<Button disabled onClick={onClick}>
Save
</Button>
);
fireEvent.click(screen.getByRole('button', { name: /save/i }));
expect(onClick).not.toHaveBeenCalled();
});
it('renders all four variants without crashing', () => {
const variants = ['primary', 'secondary', 'danger', 'ghost'];
variants.forEach((variant) => {
const { unmount } = render(<Button variant={variant}>x</Button>);
expect(screen.getByRole('button')).toBeTruthy();
unmount();
});
});
});
describe('Input', () => {
afterEach(() => cleanup());
it('renders label associated with input via htmlFor/id', () => {
render(<Input id="email" label="Email" value="" onChange={() => {}} />);
const input = screen.getByLabelText('Email');
expect(input.tagName).toBe('INPUT');
expect(input.id).toBe('email');
});
it('exposes aria-invalid + error message when error is set', () => {
render(
<Input
id="x"
label="Password"
value=""
onChange={() => {}}
error="Required"
/>
);
const input = screen.getByLabelText('Password');
expect(input.getAttribute('aria-invalid')).toBe('true');
expect(screen.getByText('Required')).toBeTruthy();
expect(input.getAttribute('aria-describedby')).toContain('x-error');
});
it('omits error and shows helperText when no error', () => {
render(
<Input
id="y"
label="Username"
value=""
onChange={() => {}}
helperText="3+ chars"
/>
);
expect(screen.getByText('3+ chars')).toBeTruthy();
const input = screen.getByLabelText('Username');
expect(input.getAttribute('aria-invalid')).toBeNull();
expect(input.getAttribute('aria-describedby')).toBe('y-helper');
});
});
describe('SearchBar', () => {
afterEach(() => cleanup());
it('renders an input with placeholder and search icon', () => {
render(
<SearchBar value="" onChange={() => {}} placeholder="Find a card" />
);
const input = screen.getByPlaceholderText('Find a card');
expect(input.tagName).toBe('INPUT');
expect(input.getAttribute('type')).toBe('search');
});
it('renders a clear button only when value is non-empty AND onClear is provided', () => {
const onClear = vi.fn();
const { rerender } = render(
<SearchBar value="" onChange={() => {}} onClear={onClear} />
);
expect(screen.queryByRole('button', { name: /clear/i })).toBeNull();
rerender(
<SearchBar value="alpha" onChange={() => {}} onClear={onClear} />
);
const clearBtn = screen.getByRole('button', { name: /clear/i });
fireEvent.click(clearBtn);
expect(onClear).toHaveBeenCalledTimes(1);
});
it('does NOT render a clear button when onClear is missing', () => {
render(<SearchBar value="alpha" onChange={() => {}} />);
expect(screen.queryByRole('button', { name: /clear/i })).toBeNull();
});
});