2025-08-01 19:18:21 -04:00
|
|
|
import { useRouter } from 'next/router';
|
|
|
|
|
import Link from 'next/link';
|
refactor(glass): migrate Button.secondary + Input + MobileNav off bespoke glass-surface (#131)
Closes the migrate-button-input-mobilenav-to-glass-primitive convoy
(seeded by PR #127). All 3 residual handrolled var(--glass-surface-*)
inline-style usages migrated to either purpose-built utility classes
or the <GlassSurface> primitive. CI allowlist reduced from 6 entries
to 3 (chrome only).
Architect decisions (D1-D3, ratified):
D1 — Button.secondary → new .btn-glass-secondary utility class.
NOT <GlassSurface>: the primitive sets `background` inline via
composedStyle, which CSS :hover rules can't override without
!important. The new class composes the same high-tint
gradient-border that .glass-panel-strong uses, plus a pure-CSS
:hover swap (high → mid fill on the padding-box layer).
Identical visual contract; the hover behavior is now driven by
CSS, not Tailwind's `hover:bg-[var(...)]` arbitrary class.
D2 — Input → new .glass-input utility class.
NOT <GlassSurface as="input"> and NOT <GlassSurface as="div"> wrap.
Reason: <GlassSurface>'s gradient-border trick requires
`border: 1px solid transparent` to expose the border-box layers,
which conflicts with <Input>'s conditional error-state
`1px solid #dc2626` red border. The new class adopts only the
tint + blur layer; the visible 1px border + focus ring stay in
JSX (class-controlled, not inline). Same visual contract as
before for both normal AND error states.
D3 — MobileNavigation → <GlassSurface as="div" tint="mid" blur="mid"
rim="subtle" elevation="flat" cornerLights="chrome">.
NOT .page-header-glass (the seed's first recommendation):
.page-header-glass uses var(--glass-surface-high) (wrong tint —
MobileNav uses mid) and sets a bottom-border separator (wrong
for a fixed-bottom-nav where the bottom edge is the viewport
edge). <GlassSurface> is the better fit AND brings the
chrome-tier corner-light bleed that the parent convoy is
unifying across all chrome surfaces.
Implementation choice — single PR (not 3 parallel briefs):
The seed recommended 3 small parallel-safe briefs (one per file).
D1 and D2 both need styles/globals.css to gain new utility
classes, so those 2 changes can't run truly in parallel without
merge conflicts. Single PR is faster, simpler to review
end-to-end, and the natural shape for a 2-3 hour convoy with
tightly-coupled artifacts.
Files changed (4):
styles/globals.css (+50 / -1):
- Adds .btn-glass-secondary (with :hover variant) — D1.
- Adds .glass-input — D2.
- Both classes documented inline with architect-decision references.
components/ui/Button.js (+2 / -10):
- Replaces inline variantStyle + Tailwind hover arbitrary class
for `variant === 'secondary'` with `variantClass =
'btn-glass-secondary font-medium'`. variantStyle now `{}`.
- Other variants (primary, danger, ghost) UNCHANGED.
components/ui/Input.js (+1 / -7):
- Adds `glass-input` to the className list.
- Removes inline `background` + `backdropFilter` +
`WebkitBackdropFilter` from the input's style block.
- Conditional `border: inputBorder` stays in JSX (error swap).
- All other props/behavior preserved.
components/MobileNavigation.js (+11 / -8):
- Adds `import { GlassSurface } from './ui'`.
- Replaces the inline-styled backdrop <div> with
<GlassSurface as="div" ...>. Same className ("absolute inset-0"),
same visible behavior, plus the chrome-tier corner-light bleed.
- Comment block updated to reference the convoy + decision.
.github/workflows/ci.yml (+8 / -22):
- forbidden-patterns Check 7/7 GLASS_ALLOWLIST reduced from 6
entries to 3 (chrome only). The TODO comments referencing this
convoy are deleted (work is done).
.convoys/migrate-button-input-mobilenav-to-glass-primitive.md
(+74 / -3):
- status: queued → closed, closed: 2026-06-05, prs: [131].
- Architect ratifications D1-D3 written into front-matter docs.
- Closeout checklist with all acceptance criteria checked.
- Note that parent convoy unify-glass-panel-surfaces is now
fully closed — no residual handrolled glass-surface usage
outside the 3 chrome blocks.
Verification:
- POSITIVE TEST: post-migration grep with the reduced 3-entry
allowlist returns 0 violations. ✅
- grep on raw files: only Layout.js + TopSearchBar.js still match
the literal regex (GlassSurface.js uses template literal which
doesn't match — intentional, allowlist is forward-compat).
- YAML parses (python3 yaml.safe_load).
- npm run lint passes (1 pre-existing unrelated warning).
- npm run test:run: 118/118 tests pass.
Visual diff to be verified by reviewer in light + dark mode for:
- <Button variant="secondary"> default + hover state.
- <Input> default + error state.
- Mobile bottom-nav backdrop.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 07:23:55 -04:00
|
|
|
import { GlassSurface } from './ui';
|
2026-08-15 17:47:59 -04:00
|
|
|
import { VOCAB } from '../lib/collection-vocabulary.js';
|
2025-08-01 19:18:21 -04:00
|
|
|
|
chore(components): remove dead user prop from MobileNavigation (#28)
components/MobileNavigation.js has accepted a `user` prop ever since
the mobile bottom-bar was extracted from Layout, but it has never read
any field of `user`. The bottom-bar items (Cards, Decks, Dashboard,
Community, More) are statically configured — none of them branch on
auth state, role, user id, or any other per-user attribute. The prop
is dead.
This was originally surfaced as R8 in the fix-layout-default-user
convoy (commit ca302a8) and deliberately deferred there to keep that
convoy focused on the Layout default-user fix. The follow-up was
queued as cleanup-mobile-nav-dead-props in .convoys/ship-readiness.md
§ Queued convoys. Pre-edit audit confirms the queue entry's premise:
`rg '\\buser\\b' components/MobileNavigation.js` returns 1 hit (the
destructure on line 5) before the change and 0 hits after. The
only active call site is components/Layout.js line 598; the
components/Layout.js.backup snapshot also calls it but is a
no-go-zone (per .cursor/rules/no-go-zones.mdc § "Append-only /
historical") and stays untouched — when that backup is eventually
deleted in a separate convoy, its stale call disappears with it.
Verification: npm run lint exit 1 with 128 problems (baseline
preserved, no regression introduced); npm run test:run 21/21 pass
(test/components/Layout.test.js still asserts the logged-out branch
contract from PR #15 — the dead-prop removal is invisible to that
suite since it does not inspect MobileNavigation's prop shape).
Convoy file .convoys/cleanup-mobile-nav-dead-props.md captures the
audit, fix, risks (R1: a future per-user bottom-bar feature would
need to re-add the prop — accepted; carrying dead state to hedge
hypothetical features is worse than paying the one-line re-add cost
when the feature actually lands), and acceptance criteria.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 23:53:28 -04:00
|
|
|
export default function MobileNavigation({ onMenuOpen }) {
|
2025-08-01 19:18:21 -04:00
|
|
|
const router = useRouter();
|
|
|
|
|
|
2026-08-15 17:47:59 -04:00
|
|
|
// Bottom bar — dashboard-home-realignment (2026-08-15). Primary hub
|
|
|
|
|
// is Dashboard only; Collection / Scanner / Decks are siblings.
|
|
|
|
|
// Lists + catalog + community live in the drawer via More.
|
2025-08-01 19:18:21 -04:00
|
|
|
const navigationItems = [
|
|
|
|
|
{
|
2026-08-15 17:47:59 -04:00
|
|
|
name: VOCAB.MY_COLLECTION,
|
|
|
|
|
href: '/my-cards',
|
|
|
|
|
icon: 'collection',
|
|
|
|
|
active: router.pathname === '/my-cards',
|
2025-08-01 19:18:21 -04:00
|
|
|
},
|
|
|
|
|
{
|
2026-08-15 17:47:59 -04:00
|
|
|
name: 'Scanner',
|
|
|
|
|
href: '/scanner',
|
|
|
|
|
icon: 'scanner',
|
|
|
|
|
active: router.pathname === '/scanner',
|
2025-08-01 19:18:21 -04:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: 'Dashboard',
|
|
|
|
|
href: '/dashboard',
|
|
|
|
|
icon: 'dashboard',
|
2026-08-15 17:47:59 -04:00
|
|
|
active: router.pathname === '/dashboard',
|
|
|
|
|
isPrimary: true,
|
2025-08-01 19:18:21 -04:00
|
|
|
},
|
|
|
|
|
{
|
2026-08-15 17:47:59 -04:00
|
|
|
name: 'Decks',
|
|
|
|
|
href: '/decks',
|
|
|
|
|
icon: 'decks',
|
|
|
|
|
active:
|
|
|
|
|
router.pathname === '/decks' ||
|
|
|
|
|
router.pathname.startsWith('/deck/'),
|
2025-08-01 19:18:21 -04:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: 'More',
|
|
|
|
|
href: '#',
|
|
|
|
|
icon: 'more',
|
|
|
|
|
onClick: onMenuOpen,
|
2026-08-15 17:47:59 -04:00
|
|
|
active: false,
|
|
|
|
|
},
|
2025-08-01 19:18:21 -04:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const getIcon = (iconName, isActive = false, isPrimary = false) => {
|
2026-08-15 17:47:59 -04:00
|
|
|
const iconClass = isPrimary ? 'h-7 w-7' : 'h-6 w-6';
|
2025-08-01 19:18:21 -04:00
|
|
|
const strokeWidth = isActive ? 2.5 : 2;
|
2026-08-15 17:47:59 -04:00
|
|
|
|
2025-08-01 19:18:21 -04:00
|
|
|
const icons = {
|
2026-08-15 17:47:59 -04:00
|
|
|
collection: (
|
|
|
|
|
<svg className={iconClass} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={strokeWidth} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
|
|
|
|
</svg>
|
|
|
|
|
),
|
|
|
|
|
scanner: (
|
|
|
|
|
<svg className={iconClass} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={strokeWidth} d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={strokeWidth} d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
|
|
|
</svg>
|
|
|
|
|
),
|
2025-08-01 19:18:21 -04:00
|
|
|
cards: (
|
|
|
|
|
<svg className={iconClass} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={strokeWidth} d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
|
|
|
|
|
</svg>
|
|
|
|
|
),
|
|
|
|
|
decks: (
|
|
|
|
|
<svg className={iconClass} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={strokeWidth} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
|
|
|
|
</svg>
|
|
|
|
|
),
|
|
|
|
|
dashboard: (
|
|
|
|
|
<svg className={iconClass} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={strokeWidth} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
|
|
|
|
|
</svg>
|
|
|
|
|
),
|
|
|
|
|
more: (
|
|
|
|
|
<svg className={iconClass} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={strokeWidth} d="M4 6h16M4 12h16M4 18h16" />
|
|
|
|
|
</svg>
|
2026-08-15 17:47:59 -04:00
|
|
|
),
|
2025-08-01 19:18:21 -04:00
|
|
|
};
|
2026-08-15 17:47:59 -04:00
|
|
|
|
2025-08-01 19:18:21 -04:00
|
|
|
return icons[iconName] || icons.dashboard;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="md:hidden fixed bottom-0 left-0 right-0 z-50">
|
refactor(glass): migrate Button.secondary + Input + MobileNav off bespoke glass-surface (#131)
Closes the migrate-button-input-mobilenav-to-glass-primitive convoy
(seeded by PR #127). All 3 residual handrolled var(--glass-surface-*)
inline-style usages migrated to either purpose-built utility classes
or the <GlassSurface> primitive. CI allowlist reduced from 6 entries
to 3 (chrome only).
Architect decisions (D1-D3, ratified):
D1 — Button.secondary → new .btn-glass-secondary utility class.
NOT <GlassSurface>: the primitive sets `background` inline via
composedStyle, which CSS :hover rules can't override without
!important. The new class composes the same high-tint
gradient-border that .glass-panel-strong uses, plus a pure-CSS
:hover swap (high → mid fill on the padding-box layer).
Identical visual contract; the hover behavior is now driven by
CSS, not Tailwind's `hover:bg-[var(...)]` arbitrary class.
D2 — Input → new .glass-input utility class.
NOT <GlassSurface as="input"> and NOT <GlassSurface as="div"> wrap.
Reason: <GlassSurface>'s gradient-border trick requires
`border: 1px solid transparent` to expose the border-box layers,
which conflicts with <Input>'s conditional error-state
`1px solid #dc2626` red border. The new class adopts only the
tint + blur layer; the visible 1px border + focus ring stay in
JSX (class-controlled, not inline). Same visual contract as
before for both normal AND error states.
D3 — MobileNavigation → <GlassSurface as="div" tint="mid" blur="mid"
rim="subtle" elevation="flat" cornerLights="chrome">.
NOT .page-header-glass (the seed's first recommendation):
.page-header-glass uses var(--glass-surface-high) (wrong tint —
MobileNav uses mid) and sets a bottom-border separator (wrong
for a fixed-bottom-nav where the bottom edge is the viewport
edge). <GlassSurface> is the better fit AND brings the
chrome-tier corner-light bleed that the parent convoy is
unifying across all chrome surfaces.
Implementation choice — single PR (not 3 parallel briefs):
The seed recommended 3 small parallel-safe briefs (one per file).
D1 and D2 both need styles/globals.css to gain new utility
classes, so those 2 changes can't run truly in parallel without
merge conflicts. Single PR is faster, simpler to review
end-to-end, and the natural shape for a 2-3 hour convoy with
tightly-coupled artifacts.
Files changed (4):
styles/globals.css (+50 / -1):
- Adds .btn-glass-secondary (with :hover variant) — D1.
- Adds .glass-input — D2.
- Both classes documented inline with architect-decision references.
components/ui/Button.js (+2 / -10):
- Replaces inline variantStyle + Tailwind hover arbitrary class
for `variant === 'secondary'` with `variantClass =
'btn-glass-secondary font-medium'`. variantStyle now `{}`.
- Other variants (primary, danger, ghost) UNCHANGED.
components/ui/Input.js (+1 / -7):
- Adds `glass-input` to the className list.
- Removes inline `background` + `backdropFilter` +
`WebkitBackdropFilter` from the input's style block.
- Conditional `border: inputBorder` stays in JSX (error swap).
- All other props/behavior preserved.
components/MobileNavigation.js (+11 / -8):
- Adds `import { GlassSurface } from './ui'`.
- Replaces the inline-styled backdrop <div> with
<GlassSurface as="div" ...>. Same className ("absolute inset-0"),
same visible behavior, plus the chrome-tier corner-light bleed.
- Comment block updated to reference the convoy + decision.
.github/workflows/ci.yml (+8 / -22):
- forbidden-patterns Check 7/7 GLASS_ALLOWLIST reduced from 6
entries to 3 (chrome only). The TODO comments referencing this
convoy are deleted (work is done).
.convoys/migrate-button-input-mobilenav-to-glass-primitive.md
(+74 / -3):
- status: queued → closed, closed: 2026-06-05, prs: [131].
- Architect ratifications D1-D3 written into front-matter docs.
- Closeout checklist with all acceptance criteria checked.
- Note that parent convoy unify-glass-panel-surfaces is now
fully closed — no residual handrolled glass-surface usage
outside the 3 chrome blocks.
Verification:
- POSITIVE TEST: post-migration grep with the reduced 3-entry
allowlist returns 0 violations. ✅
- grep on raw files: only Layout.js + TopSearchBar.js still match
the literal regex (GlassSurface.js uses template literal which
doesn't match — intentional, allowlist is forward-compat).
- YAML parses (python3 yaml.safe_load).
- npm run lint passes (1 pre-existing unrelated warning).
- npm run test:run: 118/118 tests pass.
Visual diff to be verified by reviewer in light + dark mode for:
- <Button variant="secondary"> default + hover state.
- <Input> default + error state.
- Mobile bottom-nav backdrop.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 07:23:55 -04:00
|
|
|
<GlassSurface
|
feat(design-system): Liquid Glass redesign portfolio — foundation + primitives + Layout (#95)
* feat(design-system): Liquid Glass redesign portfolio — foundation + primitive kit + Layout shell
Operator-requested epic to migrate the UI from the current "warm panel + side-highlight + heavy gradient" visual language to a Liquid Glass aesthetic that retains Deck Hearth's fireplace warmth as accent / gradient / motion (not as panel fill). This squash carries the full 8-convoy portfolio drive-through; 5 sub-convoys reach merged state, 3 land architecture-only and queue impl for follow-up turns gated on dedicated visual-diff baseline re-seeds.
Sub-convoy #1 (liquid-glass-design-tokens) — MERGED. 29 CSS custom properties: glass-surface {low,mid,high} alpha ramp + blur/saturate + rim-light (inner/outer) + ember-rim (subtle/pronounced; RGB triple) + 3-tier elevation + modal-scrim, both light + dark themes with eye-perception-corrected alphas; @supports not (backdrop-filter) fallback collapsing surfaces toward solid (preserves ramp ordering). Authored docs/DESIGN_TOKENS.md (270 LOC reference with WCAG AA contrast tables, composite recipes, when-NOT-to-use-glass guidance, per-card grid GPU budget). AGENTS.md gains a § Visual language section as the new agent-contract surface.
Sub-convoy #2 (liquid-glass-modal-and-surface-primitive) — Brief 1 MERGED. Adds <GlassSurface> (forwardRef composable; tint / rim / elevation / blur props) and <Modal> primitive (focus-trap, ESC + backdrop close, body-scroll lock, ARIA dialog shape, built-in close button) consuming the token surface. lib/use-focus-trap.js — homegrown hook (~60 LOC, no dep). 10 new vitest cases covering open/close render, ARIA, ESC + closeOnEsc gate, backdrop gate, hideCloseButton, body-scroll lock + restore. 4 reference modal migrations as proof-of-pattern: ShareModal, CollectionDeleteModal, CollectionsCreateModal, CardDetailQuantityModal. Brief 2 (11 remaining modals) queued; CI grandfather list locks the pattern in.
Sub-convoy #3 (liquid-glass-form-primitives) — Brief 1 MERGED. Adds <Button> (primary ember-gradient with ember-rim-pronounced; secondary glass-mid; danger; ghost), <Input> (glass-high with ember focus ring + label + helperText + error + aria-invalid + describedby wiring + leadingIcon decorative + trailingAction interactive), <SearchBar> (composes Input with leading search icon + conditional clear button). 10 new vitest cases. pages/login.js + pages/signup.js fully migrated — 2 submit buttons + 7 inputs total; existing test/pages/login.test.js assertion ("Sign in to Deck Hearth" button text) preserved. Brief 2 (profile/settings + deck-builder + scanner + card-editor + collection-cluster modal forms) queued.
Sub-convoy #4 (liquid-glass-layout-shell) — MERGED. 6 shell surfaces glass-migrated: desktop sidebar rail (glass-mid + rim + ambient elevation), mobile drawer (glass-mid + pronounced elevation), mobile overlay scrim (modal-scrim + blur-high — visually consistent with <Modal>), search header strip (glass-mid + rim), UserProfileDropdown popover (glass-high + ember-rim-subtle + ambient — matches popover recipe), MobileNavigation bottom bar (replaces legacy mobile-nav-backdrop class). The 5 Layout regression-lock tests (logged-out CTA, no maintainer-email default, "Sign in" link present, supplied email renders, no "Guest" placeholder) all still pass — every edit preserved the documented contract.
Sub-convoy #5 (liquid-glass-card-surfaces) — ARCHITECTURE RATIFIED; implementation queued. Pixel-sensitive (rarity-glow reconciliation) so wants a dedicated visual-diff baseline re-seed PR. Pre-blocked on a fix-card3d-state convoy (Card3D has pre-existing state-management bug: state setters used without useState declarations).
Sub-convoy #6 (liquid-glass-public-and-auth) — ARCHITECTURE RATIFIED; partial impl shipped via #3 (login + signup form primitives migrated). Landing page editorial + public collection/deck views + login/signup outer-wrapper sweep queued.
Sub-convoy #7 (motion-system-pass) — MERGED. 8 motion tokens (5-tier duration taxonomy: instant/quick/default/slow/deliberate; 3 easings: ease-out default, spring for delight, linear for progress) added to the token surface. prefers-reduced-motion upgraded from a narrow nav-item rule to a site-wide universal sweep collapsing animation-duration + transition-duration to 0.01ms (preserves end states, no flicker); .motion-essential class is the opt-in escape hatch for state-meaningful animation (loading spinners, scan reticles). Authored docs/MOTION_SYSTEM.md with WCAG SC 2.3.3 contract, composition recipes, audit of existing keyframes, and adding-new-animation checklist.
Sub-convoy #8 (cleanup-legacy-design-css) — Brief 1 MERGED. Two new CI jobs in .github/workflows/ci.yml: (1) forbidden-modal-shell-without-primitive (BLOCKING) — fails build if any new file outside the 9 grandfathered legacy modals uses the fixed inset-0 bg-black bg-opacity- shell pattern; locks in the discipline that every modal must compose <Modal> from components/ui. (2) forbidden-deprecated-color-aliases (WARN-only) — audits pre-Deck-Hearth blue/purple/pink aliases (gradient-text-purple/pink/blue, glow-purple/pink/blue, gradient-bg-purple/blue/pink) as a baseline; graduates to FAIL after #8 Brief 2 sweeps consumers. .cursor/rules/ui-and-theming.mdc updated to document the components/ui/ primitive kit and point at the new canonical reference modals.
Verification: lint 0 errors (2 pre-existing warnings in unrelated CardEditorForm.js + CollectionsPageView.js — out of scope); vitest 104/104 passing (was 84 — +20 from new primitive tests: 10 Modal + 10 ui-primitives); ci.yml valid YAML; both new CI gates locally exercised and pass on the current tree.
Operator follow-ups documented in .convoys/ship-readiness.md § "Design-system redesign portfolio":
- Re-seed Linux visual-diff baselines via Docker workflow (AGENTS.md § 6) after this merges.
- preview-smoke.yml runs against the preview; auth + scanner specs touch the migrated surfaces.
- Vercel promote to production once smoke + visual gates pass.
- Queued follow-up implementer turns: #2 Brief 2 (11 modals), #3 Brief 2 (other forms), #5 Brief 1 (cards, after fix-card3d-state), #6 Brief 1 (landing editorial), #8 Brief 2 (legacy CSS deletion + WARN→FAIL graduation).
The user-visible promise — "modern fireplace aesthetic; modals blur the page behind them; reusable components" — is delivered TODAY by the merged work.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(use-focus-trap): preserve named useFocusTrap export for ScannerPageView
The portfolio squash inadvertently overwrote the pre-existing
lib/use-focus-trap.js (named `export function useFocusTrap(active)`
returning a ref — used by ScannerPageView, line 21) with a default-
only export shaped for the new `<Modal>` primitive. Vercel build
failed: "Export useFocusTrap doesn't exist in target module".
Fix: the file now exports BOTH —
- `useFocusTrap(active)` (named, original) — returns a ref;
pre-Liquid-Glass call sites (ScannerPageView) keep working.
- `useFocusTrapContainer({ active, containerRef, ... })` (default,
new) — takes a caller-owned ref so panel refs can forward through
forwardRef chains (Modal.js consumes this shape).
Both hooks are commented to document which to use when. Modal.js
imports default already, so no change needed there.
Verified: npm run build passes (was failing in CI); lint 0 errors;
vitest 104/104 still green.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 21:12:33 -04:00
|
|
|
className="absolute inset-0"
|
refactor(glass): migrate Button.secondary + Input + MobileNav off bespoke glass-surface (#131)
Closes the migrate-button-input-mobilenav-to-glass-primitive convoy
(seeded by PR #127). All 3 residual handrolled var(--glass-surface-*)
inline-style usages migrated to either purpose-built utility classes
or the <GlassSurface> primitive. CI allowlist reduced from 6 entries
to 3 (chrome only).
Architect decisions (D1-D3, ratified):
D1 — Button.secondary → new .btn-glass-secondary utility class.
NOT <GlassSurface>: the primitive sets `background` inline via
composedStyle, which CSS :hover rules can't override without
!important. The new class composes the same high-tint
gradient-border that .glass-panel-strong uses, plus a pure-CSS
:hover swap (high → mid fill on the padding-box layer).
Identical visual contract; the hover behavior is now driven by
CSS, not Tailwind's `hover:bg-[var(...)]` arbitrary class.
D2 — Input → new .glass-input utility class.
NOT <GlassSurface as="input"> and NOT <GlassSurface as="div"> wrap.
Reason: <GlassSurface>'s gradient-border trick requires
`border: 1px solid transparent` to expose the border-box layers,
which conflicts with <Input>'s conditional error-state
`1px solid #dc2626` red border. The new class adopts only the
tint + blur layer; the visible 1px border + focus ring stay in
JSX (class-controlled, not inline). Same visual contract as
before for both normal AND error states.
D3 — MobileNavigation → <GlassSurface as="div" tint="mid" blur="mid"
rim="subtle" elevation="flat" cornerLights="chrome">.
NOT .page-header-glass (the seed's first recommendation):
.page-header-glass uses var(--glass-surface-high) (wrong tint —
MobileNav uses mid) and sets a bottom-border separator (wrong
for a fixed-bottom-nav where the bottom edge is the viewport
edge). <GlassSurface> is the better fit AND brings the
chrome-tier corner-light bleed that the parent convoy is
unifying across all chrome surfaces.
Implementation choice — single PR (not 3 parallel briefs):
The seed recommended 3 small parallel-safe briefs (one per file).
D1 and D2 both need styles/globals.css to gain new utility
classes, so those 2 changes can't run truly in parallel without
merge conflicts. Single PR is faster, simpler to review
end-to-end, and the natural shape for a 2-3 hour convoy with
tightly-coupled artifacts.
Files changed (4):
styles/globals.css (+50 / -1):
- Adds .btn-glass-secondary (with :hover variant) — D1.
- Adds .glass-input — D2.
- Both classes documented inline with architect-decision references.
components/ui/Button.js (+2 / -10):
- Replaces inline variantStyle + Tailwind hover arbitrary class
for `variant === 'secondary'` with `variantClass =
'btn-glass-secondary font-medium'`. variantStyle now `{}`.
- Other variants (primary, danger, ghost) UNCHANGED.
components/ui/Input.js (+1 / -7):
- Adds `glass-input` to the className list.
- Removes inline `background` + `backdropFilter` +
`WebkitBackdropFilter` from the input's style block.
- Conditional `border: inputBorder` stays in JSX (error swap).
- All other props/behavior preserved.
components/MobileNavigation.js (+11 / -8):
- Adds `import { GlassSurface } from './ui'`.
- Replaces the inline-styled backdrop <div> with
<GlassSurface as="div" ...>. Same className ("absolute inset-0"),
same visible behavior, plus the chrome-tier corner-light bleed.
- Comment block updated to reference the convoy + decision.
.github/workflows/ci.yml (+8 / -22):
- forbidden-patterns Check 7/7 GLASS_ALLOWLIST reduced from 6
entries to 3 (chrome only). The TODO comments referencing this
convoy are deleted (work is done).
.convoys/migrate-button-input-mobilenav-to-glass-primitive.md
(+74 / -3):
- status: queued → closed, closed: 2026-06-05, prs: [131].
- Architect ratifications D1-D3 written into front-matter docs.
- Closeout checklist with all acceptance criteria checked.
- Note that parent convoy unify-glass-panel-surfaces is now
fully closed — no residual handrolled glass-surface usage
outside the 3 chrome blocks.
Verification:
- POSITIVE TEST: post-migration grep with the reduced 3-entry
allowlist returns 0 violations. ✅
- grep on raw files: only Layout.js + TopSearchBar.js still match
the literal regex (GlassSurface.js uses template literal which
doesn't match — intentional, allowlist is forward-compat).
- YAML parses (python3 yaml.safe_load).
- npm run lint passes (1 pre-existing unrelated warning).
- npm run test:run: 118/118 tests pass.
Visual diff to be verified by reviewer in light + dark mode for:
- <Button variant="secondary"> default + hover state.
- <Input> default + error state.
- Mobile bottom-nav backdrop.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 07:23:55 -04:00
|
|
|
tint="mid"
|
|
|
|
|
blur="mid"
|
|
|
|
|
rim="subtle"
|
|
|
|
|
elevation="flat"
|
|
|
|
|
cornerLights="chrome"
|
2025-08-01 19:18:21 -04:00
|
|
|
/>
|
2026-08-15 17:47:59 -04:00
|
|
|
|
2025-08-01 19:18:21 -04:00
|
|
|
<div className="relative px-4 py-2">
|
|
|
|
|
<div className="flex items-center justify-around">
|
|
|
|
|
{navigationItems.map((item) => {
|
2026-08-15 17:47:59 -04:00
|
|
|
const label = item.name;
|
|
|
|
|
|
2025-08-01 19:18:21 -04:00
|
|
|
if (item.isPrimary) {
|
|
|
|
|
return (
|
|
|
|
|
<Link key={item.name} href={item.href}>
|
|
|
|
|
<div className="relative">
|
2026-08-15 17:47:59 -04:00
|
|
|
<div
|
2025-08-01 19:18:21 -04:00
|
|
|
className="absolute inset-0 rounded-full shadow-lg transform -translate-y-2"
|
2026-08-15 17:47:59 -04:00
|
|
|
style={{
|
|
|
|
|
background: item.active
|
|
|
|
|
? 'var(--gradient-primary)'
|
2025-08-01 19:18:21 -04:00
|
|
|
: 'var(--gradient-secondary)',
|
|
|
|
|
width: '56px',
|
2026-08-15 17:47:59 -04:00
|
|
|
height: '56px',
|
2025-08-01 19:18:21 -04:00
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
<button
|
2026-08-15 17:47:59 -04:00
|
|
|
type="button"
|
2025-08-01 19:18:21 -04:00
|
|
|
className="relative flex flex-col items-center justify-center w-14 h-14 transform -translate-y-2 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2"
|
|
|
|
|
style={{
|
|
|
|
|
color: 'white',
|
|
|
|
|
'--tw-ring-color': 'var(--accent-ember)',
|
2026-08-15 17:47:59 -04:00
|
|
|
'--tw-ring-offset-color': 'var(--bg-primary)',
|
2025-08-01 19:18:21 -04:00
|
|
|
}}
|
|
|
|
|
aria-label={item.name}
|
|
|
|
|
>
|
|
|
|
|
{getIcon(item.icon, item.active, true)}
|
|
|
|
|
</button>
|
2026-08-15 17:47:59 -04:00
|
|
|
<span
|
2025-08-01 19:18:21 -04:00
|
|
|
className="absolute top-12 left-1/2 transform -translate-x-1/2 text-xs font-medium whitespace-nowrap"
|
2026-08-15 17:47:59 -04:00
|
|
|
style={{
|
|
|
|
|
color: item.active
|
|
|
|
|
? 'var(--accent-ember)'
|
|
|
|
|
: 'var(--text-secondary)',
|
2025-08-01 19:18:21 -04:00
|
|
|
}}
|
|
|
|
|
>
|
2026-08-15 17:47:59 -04:00
|
|
|
{label}
|
2025-08-01 19:18:21 -04:00
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
</Link>
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-08-15 17:47:59 -04:00
|
|
|
|
|
|
|
|
const ButtonComponent = item.onClick ? 'button' : Link;
|
|
|
|
|
const buttonProps = item.onClick
|
|
|
|
|
? { onClick: item.onClick, type: 'button' }
|
|
|
|
|
: { href: item.href };
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<ButtonComponent key={item.name} {...buttonProps}>
|
|
|
|
|
<div
|
|
|
|
|
className="flex flex-col items-center justify-center py-2 px-3 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-lg"
|
|
|
|
|
style={{
|
|
|
|
|
color: item.active
|
|
|
|
|
? 'var(--accent-ember)'
|
|
|
|
|
: 'var(--text-secondary)',
|
|
|
|
|
'--tw-ring-color': 'var(--accent-ember)',
|
|
|
|
|
'--tw-ring-offset-color': 'var(--bg-secondary)',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<div className="mb-1">{getIcon(item.icon, item.active)}</div>
|
|
|
|
|
<span className="text-xs font-medium">{label}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</ButtonComponent>
|
|
|
|
|
);
|
2025-08-01 19:18:21 -04:00
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-08-15 17:47:59 -04:00
|
|
|
|
2025-08-01 19:18:21 -04:00
|
|
|
<div className="h-safe-area-inset-bottom" />
|
|
|
|
|
</div>
|
|
|
|
|
);
|
2026-08-15 17:47:59 -04:00
|
|
|
}
|