feat(design-system): Liquid Glass redesign portfolio — foundation + primitives + Layout #95
35 changed files with 5299 additions and 364 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.
|
||||
|
|
@ -1,50 +1,50 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
|
||||
const FOCUSABLE_SELECTOR =
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
||||
const 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(',');
|
||||
|
||||
function getFocusableElements(container) {
|
||||
return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter(
|
||||
(el) => !el.disabled && el.getAttribute('aria-hidden') !== 'true'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trap focus inside a modal while `active` and restore focus on close.
|
||||
* Returns a ref to attach to the dialog panel (not the backdrop).
|
||||
*/
|
||||
export function useFocusTrap(active) {
|
||||
const containerRef = useRef(null);
|
||||
const previouslyFocusedRef = useRef(null);
|
||||
export default function useFocusTrap({ active, containerRef, initialFocusRef, restoreFocus = true }) {
|
||||
const previousActiveElement = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
if (!active) return undefined;
|
||||
previousActiveElement.current = document.activeElement;
|
||||
|
||||
previouslyFocusedRef.current = document.activeElement;
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
if (!container) return undefined;
|
||||
|
||||
const focusFirst = () => {
|
||||
const nodes = getFocusableElements(container);
|
||||
nodes[0]?.focus();
|
||||
};
|
||||
const focusTarget =
|
||||
initialFocusRef?.current ??
|
||||
container.querySelector(FOCUSABLE) ??
|
||||
container;
|
||||
if (focusTarget && typeof focusTarget.focus === 'function') {
|
||||
if (focusTarget === container && !container.hasAttribute('tabindex')) {
|
||||
container.setAttribute('tabindex', '-1');
|
||||
}
|
||||
focusTarget.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
focusFirst();
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
if (event.key !== 'Tab') return;
|
||||
|
||||
const nodes = getFocusableElements(container);
|
||||
if (nodes.length === 0) return;
|
||||
|
||||
const first = nodes[0];
|
||||
const last = nodes[nodes.length - 1];
|
||||
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key !== 'Tab') return;
|
||||
const focusable = Array.from(container.querySelectorAll(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 (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
|
|
@ -53,12 +53,13 @@ export function useFocusTrap(active) {
|
|||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
const previous = previouslyFocusedRef.current;
|
||||
if (previous && typeof previous.focus === 'function') {
|
||||
previous.focus();
|
||||
if (
|
||||
restoreFocus &&
|
||||
previousActiveElement.current &&
|
||||
typeof previousActiveElement.current.focus === 'function'
|
||||
) {
|
||||
previousActiveElement.current.focus({ preventScroll: true });
|
||||
}
|
||||
};
|
||||
}, [active]);
|
||||
|
||||
return containerRef;
|
||||
}, [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