feat(design-system): Liquid Glass redesign portfolio — foundation + primitives + Layout (#95)
* 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> * fix(use-focus-trap): preserve named useFocusTrap export for ScannerPageView The portfolio squash inadvertently overwrote the pre-existing lib/use-focus-trap.js (named `export function useFocusTrap(active)` returning a ref — used by ScannerPageView, line 21) with a default- only export shaped for the new `<Modal>` primitive. Vercel build failed: "Export useFocusTrap doesn't exist in target module". Fix: the file now exports BOTH — - `useFocusTrap(active)` (named, original) — returns a ref; pre-Liquid-Glass call sites (ScannerPageView) keep working. - `useFocusTrapContainer({ active, containerRef, ... })` (default, new) — takes a caller-owned ref so panel refs can forward through forwardRef chains (Modal.js consumes this shape). Both hooks are commented to document which to use when. Modal.js imports default already, so no change needed there. Verified: npm run build passes (was failing in CI); lint 0 errors; vitest 104/104 still green. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
036e303b9e
commit
334612ad79
35 changed files with 5338 additions and 324 deletions
294
.convoys/cleanup-legacy-design-css.md
Normal file
294
.convoys/cleanup-legacy-design-css.md
Normal 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 ~304–310)
|
||||
- `.gradient-text-purple` (lines ~312–318)
|
||||
- `.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 ~115–118): `--accent-blue`, `--accent-purple`,
|
||||
`--accent-pink` — delete; no consumer should remain.
|
||||
- In `[data-theme="dark"]` (line ~146–149): 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 ~530–565) — 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.
|
||||
272
.convoys/liquid-glass-card-surfaces.md
Normal file
272
.convoys/liquid-glass-card-surfaces.md
Normal 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.
|
||||
359
.convoys/liquid-glass-design-tokens.md
Normal file
359
.convoys/liquid-glass-design-tokens.md
Normal 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 1–7 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.18–6.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 0–255, all alpha values 0.0–1.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 100–154. ✅ 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.
|
||||
506
.convoys/liquid-glass-design-tokens/brief-1-tokens-and-docs.md
Normal file
506
.convoys/liquid-glass-design-tokens/brief-1-tokens-and-docs.md
Normal 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 25–35 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 9–17 | `-webkit-backdrop-filter` prefix needed |
|
||||
| Chrome 17–75, 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 ~755–757) — 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 ~116–118, ~146–149) — 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.
|
||||
412
.convoys/liquid-glass-design-tokens/design-system-audit.md
Normal file
412
.convoys/liquid-glass-design-tokens/design-system-audit.md
Normal 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 30–40% 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 815–819 (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 9–17 | `-webkit-backdrop-filter` prefix needed | No (covered) |
|
||||
| Chrome 17–75, 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 |
|
||||
| --- | --- | --- | --- |
|
||||
| ~116–118 (light), ~146–149 (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 |
|
||||
| ~292–301 | `[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 |
|
||||
| ~304–318 | `.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 1–7 (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.
|
||||
239
.convoys/liquid-glass-form-primitives.md
Normal file
239
.convoys/liquid-glass-form-primitives.md
Normal 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 3–4
|
||||
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.
|
||||
256
.convoys/liquid-glass-layout-shell.md
Normal file
256
.convoys/liquid-glass-layout-shell.md
Normal 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 800–819). 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` — 1–3 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 9–10
|
||||
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 261–266).
|
||||
|
||||
## 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.
|
||||
288
.convoys/liquid-glass-modal-and-surface-primitive.md
Normal file
288
.convoys/liquid-glass-modal-and-surface-primitive.md
Normal 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 3–7 (Collections cluster)
|
||||
- [ ] Brief 3 — migrate modals 8–10 (CardDetail cluster + Upload)
|
||||
- [ ] Brief 4 — migrate modals 11–13 (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).
|
||||
255
.convoys/liquid-glass-public-and-auth.md
Normal file
255
.convoys/liquid-glass-public-and-auth.md
Normal 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.
|
||||
438
.convoys/liquid-glass-redesign.md
Normal file
438
.convoys/liquid-glass-redesign.md
Normal 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.55–0.75)` + `backdrop-filter: blur(20–32px) 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 815–819 — 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 568–697) 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 (~30–40% surface
|
||||
opacity), strong blur (~30px), tinted vibrancy.
|
||||
- **Linear / Vercel / Arc Browser** — less translucent (~70–85%),
|
||||
softer blur (~12–20px), 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 8–12px `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`.
|
||||
207
.convoys/motion-system-pass.md
Normal file
207
.convoys/motion-system-pass.md
Normal 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 261–266 — 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.
|
||||
|
|
@ -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`.
|
||||
|
||||
### 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
|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
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:
|
||||
|
||||
- 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` |
|
||||
| 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` |
|
||||
| Camera scanner | `components/CameraScanner.js` |
|
||||
| Auth-required wrapper | `components/ProtectedRoute.js` |
|
||||
|
|
|
|||
104
.github/workflows/ci.yml
vendored
104
.github/workflows/ci.yml
vendored
|
|
@ -197,6 +197,110 @@ jobs:
|
|||
fi
|
||||
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:
|
||||
name: No stale ownership/collection copy
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
23
AGENTS.md
23
AGENTS.md
|
|
@ -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).
|
||||
|
||||
## 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
|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { VOCAB } from '../lib/collection-vocabulary.js';
|
||||
import { Modal } from './ui';
|
||||
|
||||
export default function CardDetailQuantityModal({
|
||||
isOpen,
|
||||
|
|
@ -8,19 +9,13 @@ export default function CardDetailQuantityModal({
|
|||
onConfirm,
|
||||
onClose,
|
||||
}) {
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div
|
||||
className="bg-white rounded-2xl p-6 max-w-md w-full mx-4"
|
||||
style={{ backgroundColor: 'var(--bg-primary)' }}
|
||||
>
|
||||
<h3 className="text-xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}>
|
||||
{ownedQuantity > 0 ? 'Update Quantity' : VOCAB.ADD_TO_MY_COLLECTION}
|
||||
</h3>
|
||||
<Modal
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
title={ownedQuantity > 0 ? 'Update Quantity' : VOCAB.ADD_TO_MY_COLLECTION}
|
||||
size="md"
|
||||
>
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||
Quantity
|
||||
|
|
@ -75,7 +70,6 @@ export default function CardDetailQuantityModal({
|
|||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { collectionDisplayName } from '../lib/collection-vocabulary.js';
|
||||
import { Modal } from './ui';
|
||||
|
||||
export default function CollectionDeleteModal({
|
||||
isOpen,
|
||||
|
|
@ -6,41 +7,39 @@ export default function CollectionDeleteModal({
|
|||
onClose,
|
||||
onConfirm,
|
||||
}) {
|
||||
if (!isOpen || !collection) {
|
||||
return null;
|
||||
}
|
||||
if (!collection) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="card max-w-md w-full mx-4">
|
||||
<h2 className="text-2xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}>
|
||||
<Modal
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
title="Delete List"
|
||||
size="md"
|
||||
>
|
||||
<p className="mb-6" style={{ color: 'var(--text-secondary)' }}>
|
||||
Are you sure you want to delete "{collectionDisplayName(collection)}"? 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
|
||||
</h2>
|
||||
<p className="mb-6" style={{ color: 'var(--text-secondary)' }}>
|
||||
Are you sure you want to delete "{collectionDisplayName(collection)}"? 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>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import {
|
|||
handleCollectionTagKeyPress,
|
||||
removeCollectionTag,
|
||||
} from '../lib/collection-tag-input.js';
|
||||
import { Modal } from './ui';
|
||||
|
||||
export default function CollectionsCreateModal({
|
||||
isOpen,
|
||||
|
|
@ -13,16 +14,8 @@ export default function CollectionsCreateModal({
|
|||
onClose,
|
||||
onCreate,
|
||||
}) {
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<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>
|
||||
<Modal open={isOpen} onClose={onClose} title="Create New List" size="md">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||
|
|
@ -175,7 +168,6 @@ export default function CollectionsCreateModal({
|
|||
Create List
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -82,8 +82,16 @@ function UserProfileDropdown({ user, onMobileMenuClose }) {
|
|||
onClick={() => setIsDropdownOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Menu */}
|
||||
<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)' }}>
|
||||
{/* Menu — Liquid Glass popover, high-tint w/ ember-subtle rim. */}
|
||||
<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">
|
||||
{profileMenuItems.map((item) => (
|
||||
<Link key={item.name} href={item.href}>
|
||||
|
|
@ -605,19 +613,32 @@ export default function Layout({ children, user = null, showSearch = false }) {
|
|||
onMenuOpen={() => setIsMobileMenuOpen(true)}
|
||||
/>
|
||||
|
||||
{/* Mobile Overlay */}
|
||||
{/* Mobile Overlay — Liquid Glass scrim consistent with <Modal>. */}
|
||||
{isMobileMenuOpen && (
|
||||
<div
|
||||
className="md:hidden fixed inset-0 bg-black bg-opacity-50 z-40"
|
||||
<div
|
||||
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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile Menu Drawer - Slides in from left when "More" is tapped */}
|
||||
<div className={`
|
||||
md:hidden fixed inset-y-0 left-0 z-50 w-64 shadow-lg transform transition-transform duration-300 ease-in-out
|
||||
${isMobileMenuOpen ? 'translate-x-0' : '-translate-x-full'}
|
||||
`} style={{ backgroundColor: 'var(--bg-secondary)', borderRight: '1px solid var(--border)' }}>
|
||||
<div
|
||||
className={`
|
||||
md:hidden fixed inset-y-0 left-0 z-50 w-64 transform transition-transform duration-300 ease-in-out
|
||||
${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">
|
||||
{/* Mobile Header with Close Button */}
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
|
|
@ -710,8 +731,18 @@ export default function Layout({ children, user = null, showSearch = false }) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop Sidebar - Hidden on mobile */}
|
||||
<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)' }}>
|
||||
{/* Desktop Sidebar - Hidden on mobile. Liquid Glass mid-tint, ambient
|
||||
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">
|
||||
{/* Desktop Header */}
|
||||
<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">
|
||||
{/* Top Header - Only show search on dashboard */}
|
||||
{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-1 max-w-2xl">
|
||||
<div className="relative">
|
||||
|
|
|
|||
|
|
@ -78,12 +78,15 @@ export default function MobileNavigation({ onMenuOpen }) {
|
|||
|
||||
return (
|
||||
<div className="md:hidden fixed bottom-0 left-0 right-0 z-50">
|
||||
{/* Background with blur effect */}
|
||||
<div
|
||||
className="absolute inset-0 mobile-nav-backdrop border-t"
|
||||
style={{
|
||||
backgroundColor: `rgba(var(--bg-secondary-rgb), 0.95)`,
|
||||
borderColor: 'var(--border)'
|
||||
{/* Liquid Glass background — mid-tint surface, top rim-light to
|
||||
define the bar's edge against the page content above. */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
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)',
|
||||
}}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { Modal } from './ui';
|
||||
|
||||
export default function ShareModal({
|
||||
isOpen,
|
||||
|
|
@ -131,25 +132,9 @@ export default function ShareModal({
|
|||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<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>
|
||||
|
||||
<Modal open={isOpen} onClose={onClose} title="Share" size="md">
|
||||
<>
|
||||
{/* Public Access Toggle */}
|
||||
<div className="mb-6 p-4 border rounded-lg">
|
||||
<div className="flex items-start space-x-3">
|
||||
|
|
@ -353,8 +338,7 @@ export default function ShareModal({
|
|||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
106
components/ui/Button.js
Normal file
106
components/ui/Button.js
Normal 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;
|
||||
50
components/ui/GlassSurface.js
Normal file
50
components/ui/GlassSurface.js
Normal 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
114
components/ui/Input.js
Normal 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
152
components/ui/Modal.js
Normal 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>
|
||||
);
|
||||
}
|
||||
70
components/ui/SearchBar.js
Normal file
70
components/ui/SearchBar.js
Normal 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
5
components/ui/index.js
Normal 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
219
docs/DESIGN_TOKENS.md
Normal 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 9–17 | `-webkit-backdrop-filter` prefix needed |
|
||||
| Chrome 17–75, 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 ~755–757) — 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 ~116–118, ~146–149) — 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
115
docs/MOTION_SYSTEM.md
Normal 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.
|
||||
|
|
@ -10,19 +10,24 @@ function getFocusableElements(container) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Trap focus inside a modal while `active` and restore focus on close.
|
||||
* Trap focus inside a container while `active` and restore focus on close.
|
||||
* Returns a ref to attach to the dialog panel (not the backdrop).
|
||||
*
|
||||
* Used by pre-Liquid-Glass call sites (e.g. ScannerPageView) where the
|
||||
* caller wants the hook to own the container ref. New code that needs
|
||||
* to share / forward the panel ref should use the default export
|
||||
* `useFocusTrapContainer` instead — see Modal.js for the pattern.
|
||||
*/
|
||||
export function useFocusTrap(active) {
|
||||
const containerRef = useRef(null);
|
||||
const previouslyFocusedRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
if (!active) return undefined;
|
||||
|
||||
previouslyFocusedRef.current = document.activeElement;
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
if (!container) return undefined;
|
||||
|
||||
const focusFirst = () => {
|
||||
const nodes = getFocusableElements(container);
|
||||
|
|
@ -62,3 +67,78 @@ export function useFocusTrap(active) {
|
|||
|
||||
return containerRef;
|
||||
}
|
||||
|
||||
const NEW_FOCUSABLE = [
|
||||
'a[href]',
|
||||
'button:not([disabled])',
|
||||
'input:not([disabled]):not([type="hidden"])',
|
||||
'select:not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
'[contenteditable="true"]',
|
||||
].join(',');
|
||||
|
||||
/**
|
||||
* Focus-trap variant for the `<Modal>` primitive in components/ui/.
|
||||
* Takes a caller-owned containerRef + optional initialFocusRef, so the
|
||||
* panel ref can be forwarded through GlassSurface / forwardRef chains
|
||||
* without colliding with the hook's internal ref ownership.
|
||||
*/
|
||||
export default function useFocusTrapContainer({
|
||||
active,
|
||||
containerRef,
|
||||
initialFocusRef,
|
||||
restoreFocus = true,
|
||||
}) {
|
||||
const previousActiveElement = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return undefined;
|
||||
previousActiveElement.current = document.activeElement;
|
||||
|
||||
const container = containerRef.current;
|
||||
if (!container) return undefined;
|
||||
|
||||
const focusTarget =
|
||||
initialFocusRef?.current ??
|
||||
container.querySelector(NEW_FOCUSABLE) ??
|
||||
container;
|
||||
if (focusTarget && typeof focusTarget.focus === 'function') {
|
||||
if (focusTarget === container && !container.hasAttribute('tabindex')) {
|
||||
container.setAttribute('tabindex', '-1');
|
||||
}
|
||||
focusTarget.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key !== 'Tab') return;
|
||||
const focusable = Array.from(container.querySelectorAll(NEW_FOCUSABLE));
|
||||
if (focusable.length === 0) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
if (
|
||||
restoreFocus &&
|
||||
previousActiveElement.current &&
|
||||
typeof previousActiveElement.current.focus === 'function'
|
||||
) {
|
||||
previousActiveElement.current.focus({ preventScroll: true });
|
||||
}
|
||||
};
|
||||
}, [active, containerRef, initialFocusRef, restoreFocus]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useRouter } from 'next/router';
|
|||
import Link from 'next/link';
|
||||
import AuthLayout from '../components/AuthLayout';
|
||||
import AnimatedFireLogo from '../components/AnimatedFireLogo';
|
||||
import { Button, Input } from '../components/ui';
|
||||
import { VOCAB } from '../lib/collection-vocabulary.js';
|
||||
|
||||
export default function Login() {
|
||||
|
|
@ -92,70 +93,39 @@ export default function Login() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||
Email Address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
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>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
label="Email Address"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={(e) => handleInputChange('email', e.target.value)}
|
||||
placeholder="Enter your email"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
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>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
label="Password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={formData.password}
|
||||
onChange={(e) => handleInputChange('password', e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className={`w-full px-4 py-3 rounded-lg font-medium transition-all duration-200 ${
|
||||
loading
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: 'gradient-bg-ember text-white hover:shadow-xl transform hover:scale-105 hover:shadow-orange-500/20'
|
||||
}`}
|
||||
>
|
||||
{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>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
loading={loading}
|
||||
className="w-full"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign in to Deck Hearth'}
|
||||
</Button>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
|
|
|
|||
237
pages/signup.js
237
pages/signup.js
|
|
@ -4,6 +4,7 @@ import { useRouter } from 'next/router';
|
|||
import Link from 'next/link';
|
||||
import AuthLayout from '../components/AuthLayout';
|
||||
import AnimatedFireLogo from '../components/AnimatedFireLogo';
|
||||
import { Button, Input } from '../components/ui';
|
||||
import { VOCAB } from '../lib/collection-vocabulary.js';
|
||||
|
||||
export default function Signup() {
|
||||
|
|
@ -312,174 +313,90 @@ export default function Signup() {
|
|||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="firstName" className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||
First Name
|
||||
</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"
|
||||
<Input
|
||||
id="firstName"
|
||||
name="firstName"
|
||||
type="text"
|
||||
label="First Name"
|
||||
required
|
||||
value={formData.username}
|
||||
onChange={(e) => handleInputChange('username', 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.username ? '#ef4444' : 'var(--border)',
|
||||
color: 'var(--text-primary)'
|
||||
}}
|
||||
placeholder="johndoe123"
|
||||
value={formData.firstName}
|
||||
onChange={(e) => handleInputChange('firstName', e.target.value)}
|
||||
placeholder="John"
|
||||
error={validationErrors.firstName}
|
||||
/>
|
||||
<Input
|
||||
id="lastName"
|
||||
name="lastName"
|
||||
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>
|
||||
<label htmlFor="email" className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||
Email Address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
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>
|
||||
<Input
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
label="Username"
|
||||
required
|
||||
value={formData.username}
|
||||
onChange={(e) => handleInputChange('username', e.target.value)}
|
||||
placeholder="johndoe123"
|
||||
error={validationErrors.username}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
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: 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>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
label="Email Address"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={(e) => handleInputChange('email', e.target.value)}
|
||||
placeholder="john@example.com"
|
||||
error={validationErrors.email}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={formData.confirmPassword}
|
||||
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>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
label="Password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={formData.password}
|
||||
onChange={(e) => handleInputChange('password', e.target.value)}
|
||||
placeholder="At least 6 characters"
|
||||
error={validationErrors.password}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className={`w-full px-4 py-3 rounded-lg font-medium transition-all duration-200 ${
|
||||
loading
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: 'gradient-bg-ember text-white hover:shadow-xl transform hover:scale-105 hover:shadow-orange-500/20'
|
||||
}`}
|
||||
>
|
||||
{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>
|
||||
Creating Account...
|
||||
</div>
|
||||
) : (
|
||||
'Create Account'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
label="Confirm Password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={formData.confirmPassword}
|
||||
onChange={(e) => handleInputChange('confirmPassword', e.target.value)}
|
||||
placeholder="Confirm your password"
|
||||
error={validationErrors.confirmPassword}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
loading={loading}
|
||||
className="w-full"
|
||||
>
|
||||
{loading ? 'Creating Account...' : 'Create Account'}
|
||||
</Button>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
|
|
|
|||
|
|
@ -73,6 +73,121 @@ body {
|
|||
--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 */
|
||||
body {
|
||||
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) {
|
||||
*,
|
||||
*::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-bottom {
|
||||
transition: none;
|
||||
|
|
|
|||
133
test/components/Modal.test.js
Normal file
133
test/components/Modal.test.js
Normal 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('');
|
||||
});
|
||||
});
|
||||
118
test/components/ui-primitives.test.js
Normal file
118
test/components/ui-primitives.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue