2025-07-27 14:22:42 -04:00
|
|
|
import { useState, useEffect } from 'react';
|
2025-07-23 22:26:54 -04:00
|
|
|
import { useRouter } from 'next/router';
|
|
|
|
|
import Link from 'next/link';
|
|
|
|
|
import { useTheme } from '../lib/theme-context';
|
2025-08-01 19:18:21 -04:00
|
|
|
import MobileNavigation from './MobileNavigation';
|
2025-07-23 22:26:54 -04:00
|
|
|
|
2025-07-26 10:51:40 -04:00
|
|
|
// User Profile Dropdown Component
|
|
|
|
|
function UserProfileDropdown({ user, onMobileMenuClose }) {
|
fix(layout+pages): default user=null + page audit sweep (P0 #7) (#15)
* convoy: scope fix-layout-default-user (P0 #7 — Layout maintainer-email leak)
The last remaining P0 ship-blocker from .convoys/ship-readiness.md.
components/Layout.js line 562 defaults the user prop to a real email
address (me@randallstillwell.com); any page that renders Layout without
passing user explicitly impersonates the maintainer.
Scope: components/Layout.js + audit of 17 pages that import Layout
(grep-confirmed list in convoy file). Single PR likely. Auditor cohort
skipped (no design-system, IA, or browser-smoke surface).
Architect to address:
- Q1: logged-out rendering branch design (navbar, mobile-nav,
auth-only items treatment)
- Q2: page audit triage into always-auth / public-or-auth /
anonymous-allowed buckets
- Q3: brief decomposition (single brief / 2 briefs in 1 PR / fan-out)
- Q4: whether to add vitest coverage for the logged-out branch
(recommend yes — small surface, high regression protection)
Hard out-of-scope: branding (pick-a-name), auth-provider collapse
(single-auth-provider), Layout god-component split (god-component-split).
depends_on: bump-next-js (shipped), fix-auth-bypass (shipped),
drop-public-setup (shipped)
addresses: P0 #7 from .convoys/ship-readiness.md
parent: ship-readiness
Co-authored-by: Cursor <cursoragent@cursor.com>
* architect(fix-layout-default-user): plan + briefs 1-2 (Layout fix + page audit)
2 briefs, single PR. ~12 files net (down from the 18 in the original scope —
10 of the 17 Layout-importing pages already pass user explicitly).
Brief 1: components/Layout.js default user=null + Sign-in CTA branch in
UserProfileDropdown when logged out. Adds first jsdom test in the repo
at test/components/Layout.test.js (Decision D2) with 5 regression-lock
assertions. devDeps: jsdom@^29, @testing-library/react@^16.
Brief 2: page audit sweep — 7 pages need code changes:
- Pass user={user} to Layout: scanner.js, deck-builder.js (×4),
deck/[id].js (×3), decks.js (×3)
- Replace page-level useState({email: 'me@...'}) → useState(null) +
null-guards: profile.js, settings.js
- Replace hardcoded const user = {email: 'me@...'} with useAuth():
card/[id].js
Discovered second anti-pattern: profile.js, settings.js, card/[id].js
seed page-level state with the maintainer email. Folded into Brief 2 since
success metric "no real email address remains in any component default-prop"
reads naturally to include page-level seed values.
Decisions:
A1 — Sign-in CTA replaces avatar+email+dropdown when user===null;
hides auth-only dropdown (Profile/Settings/Logout/Admin);
keeps public + community nav visible
B — Per-page bucket assignment (10 already correct, 7 need fix);
full per-page table with justification in convoy file
C2 — Two briefs in one PR (Brief 1 = Layout + test; Brief 2 = page
sweep depends on Brief 1). C1 buries the conceptual change under
mechanical edits; C3 is over-orchestrated for this scope
D2 — vitest lock-in; first jsdom test in repo; same negative-regression
style as test/lib/permission-middleware.test.js (synthetic-admin
shape). devDeps jsdom + @testing-library/react
Risks tracked R1-R8. Biggest: R2 (useState(null) null-deref in 3 leaky
pages — mitigated by audit-pass mandate + manual smoke).
MobileNavigation deliberately NOT folded in: its user prop is dead code
(never reads user.*); different bug class; cleanup queued separately to
avoid scope expansion.
Flagged-but-deferred:
- 4 pages still import useAuth from lib/auth-context.js
→ single-auth-provider (queued P1 #9)
- Layout headers still render "Deck Hearth" / "DH" branding
→ pick-a-name (queued P1 #12)
- MobileNavigation dead user prop → cleanup-mobile-nav-dead-props
or fold into god-component-split
addresses: P0 #7 from .convoys/ship-readiness.md (last P0 ship-blocker)
parent: ship-readiness
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(layout): default user=null + Sign-in CTA when logged out (Brief 1 of fix-layout-default-user)
Closes the source-side half of P0 #7 from .convoys/ship-readiness.md.
The page-side sweep (Brief 2) follows in a separate commit.
components/Layout.js:
- Default user prop is now null (was hardcoded to
{ email: 'me@randallstillwell.com', role: 'user' })
- UserProfileDropdown renders a "Sign in" link to /login when
user === null instead of the maintainer's email + auth-only menu
items (Decision A1)
- All user.* accesses guarded with optional chaining or null checks
- useState hook stays above the new null-user early return to satisfy
rules-of-hooks (boot-the-brief caught this on the first try;
see AGENTS.md Gotcha #11.5)
test/components/Layout.test.js (new):
- First jsdom test in the repo (Decision D2)
- 5 regression-lock assertions: no maintainer email ever rendered
(prop omitted, prop=null), Sign-in link exists with href=/login,
supplied email renders when prop is set, no "Guest" placeholder
(locks A1 copy choice)
- Mocks next/link, next/router (prefetch, replace, events, query),
and theme-context.useTheme for jsdom safety under Next 16
package.json + package-lock.json:
- Add jsdom@^29 and @testing-library/react@^16 to devDependencies
- @testing-library/dom@^10 added explicitly (peer auto-install
skipped it under npm 11; brief anticipated this fallback)
vitest.config.js (deviation from brief — see PR description):
- Add esbuild { loader: 'jsx', jsx: 'automatic' } so vitest can
parse JSX in .js files. Required to import any React component
written in the repo's Next.js pages-router .js convention
(AGENTS.md Gotcha #9). The brief said "no change" to this file,
but JSX-in-.js parsing is a hard prerequisite for the new test
to import components/Layout.js — the alternatives (rename test
to .test.jsx; rewrite test in React.createElement) either break
the test glob or still hit the same Layout.js parse failure.
Other tests are unaffected (they import non-JSX modules).
Smoke output: see PR description.
addresses: P0 #7 from .convoys/ship-readiness.md (last P0 ship-blocker)
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(pages): pass user explicitly + null-guard leaky page seeds (Brief 2 of fix-layout-default-user)
Closes the page-side half of P0 #7 from .convoys/ship-readiness.md.
Brief 1 (commit ddf8fd2) handled the Layout-side fix.
Per the architect's per-page bucket table (Decision B in
.convoys/fix-layout-default-user.md), 7 pages needed code changes;
the other 10 of 17 Layout-importing pages already pass `user` correctly.
Pass user={user} to Layout (4 pages, 11 call sites):
- pages/scanner.js (1 call)
- pages/decks.js (3 calls)
- pages/deck-builder.js (4 calls)
- pages/deck/[id].js (3 calls)
(All four still import useAuth from lib/auth-context.js — that's
intentional and stays as-is until the single-auth-provider convoy
collapses the three parallel auth surfaces.)
Replace leaky page-level seed values with useState(null) + null guards
(2 pages, R2 mitigation):
- pages/profile.js: useState({email: 'me@...', role: 'user', ...})
→ useState(null) + ?. on every sync user.* read
+ early-return guards in getDisplayName/getInitials
+ conditional render around the "Member since" block
so formatDate(undefined) never runs
- pages/settings.js: same pattern (single user.email reader guarded)
Replace hardcoded const with useAuth from lib/use-auth.js (1 page):
- pages/card/[id].js: const user = {email: 'me@...'}
→ const { user } = useAuth() (called unconditionally
at the top of the component; rules-of-hooks safe)
Verification:
- grep 'me@randallstillwell.com' pages/ → 0 hits
- 21/21 vitest tests pass (16 pre-existing + 5 from Brief 1)
- npm run lint matches baseline (128 problems pre, 128 post; verified
via git stash before/after)
- Manual static read-through of every diff; ReadLints clean on the 7
files
- Dev-server smoke: /cards anonymous returned HTTP 200 with 0
'me@randallstillwell' matches before the user's shared dev server
became unresponsive mid-session (same dev-server-shared-by-user
constraint flagged in Brief 1); interactive logged-in smoke is
parent/operator gated
Flagged-but-deferred (untouched per scope):
- 4 pages still import useAuth from lib/auth-context.js
→ single-auth-provider (queued P1 #9)
- components/MobileNavigation.js still receives dead user prop
→ cleanup-mobile-nav-dead-props (or fold into god-component-split)
addresses: P0 #7 from .convoys/ship-readiness.md (last P0 ship-blocker)
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 15:31:37 -04:00
|
|
|
// 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).
|
2025-07-26 10:51:40 -04:00
|
|
|
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
|
|
|
|
|
fix(layout+pages): default user=null + page audit sweep (P0 #7) (#15)
* convoy: scope fix-layout-default-user (P0 #7 — Layout maintainer-email leak)
The last remaining P0 ship-blocker from .convoys/ship-readiness.md.
components/Layout.js line 562 defaults the user prop to a real email
address (me@randallstillwell.com); any page that renders Layout without
passing user explicitly impersonates the maintainer.
Scope: components/Layout.js + audit of 17 pages that import Layout
(grep-confirmed list in convoy file). Single PR likely. Auditor cohort
skipped (no design-system, IA, or browser-smoke surface).
Architect to address:
- Q1: logged-out rendering branch design (navbar, mobile-nav,
auth-only items treatment)
- Q2: page audit triage into always-auth / public-or-auth /
anonymous-allowed buckets
- Q3: brief decomposition (single brief / 2 briefs in 1 PR / fan-out)
- Q4: whether to add vitest coverage for the logged-out branch
(recommend yes — small surface, high regression protection)
Hard out-of-scope: branding (pick-a-name), auth-provider collapse
(single-auth-provider), Layout god-component split (god-component-split).
depends_on: bump-next-js (shipped), fix-auth-bypass (shipped),
drop-public-setup (shipped)
addresses: P0 #7 from .convoys/ship-readiness.md
parent: ship-readiness
Co-authored-by: Cursor <cursoragent@cursor.com>
* architect(fix-layout-default-user): plan + briefs 1-2 (Layout fix + page audit)
2 briefs, single PR. ~12 files net (down from the 18 in the original scope —
10 of the 17 Layout-importing pages already pass user explicitly).
Brief 1: components/Layout.js default user=null + Sign-in CTA branch in
UserProfileDropdown when logged out. Adds first jsdom test in the repo
at test/components/Layout.test.js (Decision D2) with 5 regression-lock
assertions. devDeps: jsdom@^29, @testing-library/react@^16.
Brief 2: page audit sweep — 7 pages need code changes:
- Pass user={user} to Layout: scanner.js, deck-builder.js (×4),
deck/[id].js (×3), decks.js (×3)
- Replace page-level useState({email: 'me@...'}) → useState(null) +
null-guards: profile.js, settings.js
- Replace hardcoded const user = {email: 'me@...'} with useAuth():
card/[id].js
Discovered second anti-pattern: profile.js, settings.js, card/[id].js
seed page-level state with the maintainer email. Folded into Brief 2 since
success metric "no real email address remains in any component default-prop"
reads naturally to include page-level seed values.
Decisions:
A1 — Sign-in CTA replaces avatar+email+dropdown when user===null;
hides auth-only dropdown (Profile/Settings/Logout/Admin);
keeps public + community nav visible
B — Per-page bucket assignment (10 already correct, 7 need fix);
full per-page table with justification in convoy file
C2 — Two briefs in one PR (Brief 1 = Layout + test; Brief 2 = page
sweep depends on Brief 1). C1 buries the conceptual change under
mechanical edits; C3 is over-orchestrated for this scope
D2 — vitest lock-in; first jsdom test in repo; same negative-regression
style as test/lib/permission-middleware.test.js (synthetic-admin
shape). devDeps jsdom + @testing-library/react
Risks tracked R1-R8. Biggest: R2 (useState(null) null-deref in 3 leaky
pages — mitigated by audit-pass mandate + manual smoke).
MobileNavigation deliberately NOT folded in: its user prop is dead code
(never reads user.*); different bug class; cleanup queued separately to
avoid scope expansion.
Flagged-but-deferred:
- 4 pages still import useAuth from lib/auth-context.js
→ single-auth-provider (queued P1 #9)
- Layout headers still render "Deck Hearth" / "DH" branding
→ pick-a-name (queued P1 #12)
- MobileNavigation dead user prop → cleanup-mobile-nav-dead-props
or fold into god-component-split
addresses: P0 #7 from .convoys/ship-readiness.md (last P0 ship-blocker)
parent: ship-readiness
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(layout): default user=null + Sign-in CTA when logged out (Brief 1 of fix-layout-default-user)
Closes the source-side half of P0 #7 from .convoys/ship-readiness.md.
The page-side sweep (Brief 2) follows in a separate commit.
components/Layout.js:
- Default user prop is now null (was hardcoded to
{ email: 'me@randallstillwell.com', role: 'user' })
- UserProfileDropdown renders a "Sign in" link to /login when
user === null instead of the maintainer's email + auth-only menu
items (Decision A1)
- All user.* accesses guarded with optional chaining or null checks
- useState hook stays above the new null-user early return to satisfy
rules-of-hooks (boot-the-brief caught this on the first try;
see AGENTS.md Gotcha #11.5)
test/components/Layout.test.js (new):
- First jsdom test in the repo (Decision D2)
- 5 regression-lock assertions: no maintainer email ever rendered
(prop omitted, prop=null), Sign-in link exists with href=/login,
supplied email renders when prop is set, no "Guest" placeholder
(locks A1 copy choice)
- Mocks next/link, next/router (prefetch, replace, events, query),
and theme-context.useTheme for jsdom safety under Next 16
package.json + package-lock.json:
- Add jsdom@^29 and @testing-library/react@^16 to devDependencies
- @testing-library/dom@^10 added explicitly (peer auto-install
skipped it under npm 11; brief anticipated this fallback)
vitest.config.js (deviation from brief — see PR description):
- Add esbuild { loader: 'jsx', jsx: 'automatic' } so vitest can
parse JSX in .js files. Required to import any React component
written in the repo's Next.js pages-router .js convention
(AGENTS.md Gotcha #9). The brief said "no change" to this file,
but JSX-in-.js parsing is a hard prerequisite for the new test
to import components/Layout.js — the alternatives (rename test
to .test.jsx; rewrite test in React.createElement) either break
the test glob or still hit the same Layout.js parse failure.
Other tests are unaffected (they import non-JSX modules).
Smoke output: see PR description.
addresses: P0 #7 from .convoys/ship-readiness.md (last P0 ship-blocker)
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(pages): pass user explicitly + null-guard leaky page seeds (Brief 2 of fix-layout-default-user)
Closes the page-side half of P0 #7 from .convoys/ship-readiness.md.
Brief 1 (commit ddf8fd2) handled the Layout-side fix.
Per the architect's per-page bucket table (Decision B in
.convoys/fix-layout-default-user.md), 7 pages needed code changes;
the other 10 of 17 Layout-importing pages already pass `user` correctly.
Pass user={user} to Layout (4 pages, 11 call sites):
- pages/scanner.js (1 call)
- pages/decks.js (3 calls)
- pages/deck-builder.js (4 calls)
- pages/deck/[id].js (3 calls)
(All four still import useAuth from lib/auth-context.js — that's
intentional and stays as-is until the single-auth-provider convoy
collapses the three parallel auth surfaces.)
Replace leaky page-level seed values with useState(null) + null guards
(2 pages, R2 mitigation):
- pages/profile.js: useState({email: 'me@...', role: 'user', ...})
→ useState(null) + ?. on every sync user.* read
+ early-return guards in getDisplayName/getInitials
+ conditional render around the "Member since" block
so formatDate(undefined) never runs
- pages/settings.js: same pattern (single user.email reader guarded)
Replace hardcoded const with useAuth from lib/use-auth.js (1 page):
- pages/card/[id].js: const user = {email: 'me@...'}
→ const { user } = useAuth() (called unconditionally
at the top of the component; rules-of-hooks safe)
Verification:
- grep 'me@randallstillwell.com' pages/ → 0 hits
- 21/21 vitest tests pass (16 pre-existing + 5 from Brief 1)
- npm run lint matches baseline (128 problems pre, 128 post; verified
via git stash before/after)
- Manual static read-through of every diff; ReadLints clean on the 7
files
- Dev-server smoke: /cards anonymous returned HTTP 200 with 0
'me@randallstillwell' matches before the user's shared dev server
became unresponsive mid-session (same dev-server-shared-by-user
constraint flagged in Brief 1); interactive logged-in smoke is
parent/operator gated
Flagged-but-deferred (untouched per scope):
- 4 pages still import useAuth from lib/auth-context.js
→ single-auth-provider (queued P1 #9)
- components/MobileNavigation.js still receives dead user prop
→ cleanup-mobile-nav-dead-props (or fold into god-component-split)
addresses: P0 #7 from .convoys/ship-readiness.md (last P0 ship-blocker)
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 15:31:37 -04:00
|
|
|
// Logged-out: replace avatar + email + dropdown with a Sign-in CTA.
|
|
|
|
|
if (!user) {
|
|
|
|
|
return (
|
|
|
|
|
<Link href="/login">
|
|
|
|
|
<div
|
|
|
|
|
className="w-full flex items-center px-4 py-3 rounded-2xl transition-all duration-200 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 nav-item-hover cursor-pointer"
|
|
|
|
|
style={{
|
|
|
|
|
backgroundColor: 'transparent',
|
|
|
|
|
color: 'var(--text-primary)',
|
|
|
|
|
'--tw-ring-color': 'var(--accent-ember)',
|
|
|
|
|
'--tw-ring-offset-color': 'var(--bg-secondary)'
|
|
|
|
|
}}
|
|
|
|
|
onClick={onMobileMenuClose}
|
|
|
|
|
>
|
|
|
|
|
<div className="h-8 w-8 logo-container mr-3 flex items-center justify-center">
|
|
|
|
|
<svg className="h-4 w-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
|
|
|
|
</svg>
|
|
|
|
|
</div>
|
|
|
|
|
<span className="font-medium text-sm">Sign in</span>
|
|
|
|
|
</div>
|
|
|
|
|
</Link>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-07-26 10:51:40 -04:00
|
|
|
const profileMenuItems = [
|
|
|
|
|
{ name: 'Profile', href: '/profile', icon: 'user' },
|
|
|
|
|
{ name: 'Settings', href: '/settings', icon: 'settings' },
|
|
|
|
|
...(user?.role === 'admin' ? [
|
|
|
|
|
{ name: 'Admin Panel', href: '/admin', icon: 'admin' }
|
|
|
|
|
] : []),
|
|
|
|
|
{ name: 'Logout', href: '/logout', icon: 'logout', isLogout: true }
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const getProfileIcon = (iconName) => {
|
|
|
|
|
const icons = {
|
|
|
|
|
user: (
|
|
|
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
|
|
|
|
</svg>
|
|
|
|
|
),
|
|
|
|
|
settings: (
|
|
|
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
|
|
|
</svg>
|
|
|
|
|
),
|
|
|
|
|
admin: (
|
|
|
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
|
|
|
|
</svg>
|
|
|
|
|
),
|
|
|
|
|
logout: (
|
|
|
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
|
|
|
|
</svg>
|
|
|
|
|
)
|
|
|
|
|
};
|
|
|
|
|
return icons[iconName] || icons.user;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="relative">
|
|
|
|
|
{/* Dropdown Menu */}
|
|
|
|
|
{isDropdownOpen && (
|
|
|
|
|
<>
|
|
|
|
|
{/* Backdrop */}
|
|
|
|
|
<div
|
|
|
|
|
className="fixed inset-0 z-10"
|
|
|
|
|
onClick={() => setIsDropdownOpen(false)}
|
|
|
|
|
/>
|
|
|
|
|
|
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
|
|
|
{/* 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)',
|
|
|
|
|
}}
|
|
|
|
|
>
|
2025-07-26 10:51:40 -04:00
|
|
|
<div className="py-2">
|
|
|
|
|
{profileMenuItems.map((item) => (
|
|
|
|
|
<Link key={item.name} href={item.href}>
|
|
|
|
|
<div
|
|
|
|
|
className={`
|
|
|
|
|
flex items-center px-4 py-3 text-sm transition-all duration-200 cursor-pointer
|
|
|
|
|
hover:bg-gray-50 dark:hover:bg-gray-700
|
|
|
|
|
${item.isLogout ? 'text-red-600 dark:text-red-400' : ''}
|
|
|
|
|
`}
|
|
|
|
|
style={{
|
|
|
|
|
color: item.isLogout ? 'var(--accent-ember)' : 'var(--text-primary)'
|
|
|
|
|
}}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setIsDropdownOpen(false);
|
|
|
|
|
onMobileMenuClose();
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<span className="mr-3" aria-hidden="true">{getProfileIcon(item.icon)}</span>
|
|
|
|
|
<span className="font-medium">{item.name}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</Link>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Profile Button */}
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
|
|
|
|
className="w-full flex items-center px-4 py-3 rounded-2xl transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 nav-item-hover"
|
|
|
|
|
style={{
|
|
|
|
|
backgroundColor: 'transparent',
|
|
|
|
|
color: 'var(--text-secondary)',
|
|
|
|
|
'--tw-ring-color': 'var(--accent-ember)',
|
|
|
|
|
'--tw-ring-offset-color': 'var(--bg-secondary)'
|
|
|
|
|
}}
|
|
|
|
|
aria-expanded={isDropdownOpen}
|
|
|
|
|
aria-haspopup="menu"
|
|
|
|
|
>
|
|
|
|
|
<div className="h-8 w-8 logo-container mr-3">
|
|
|
|
|
<span className="text-white text-xs font-medium">
|
|
|
|
|
{user?.email?.charAt(0).toUpperCase() || 'G'}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex-1 text-left">
|
|
|
|
|
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>
|
|
|
|
|
{user?.email || 'Guest'}
|
|
|
|
|
</p>
|
|
|
|
|
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
|
|
|
|
{user?.role || 'visitor'}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<svg
|
|
|
|
|
className={`w-4 h-4 transition-transform duration-200 ${isDropdownOpen ? 'rotate-180' : ''}`}
|
|
|
|
|
fill="none"
|
|
|
|
|
stroke="currentColor"
|
|
|
|
|
viewBox="0 0 24 24"
|
|
|
|
|
>
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
|
|
|
</svg>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-01 19:18:21 -04:00
|
|
|
// Navigation Content Component - Shared between desktop and mobile
|
|
|
|
|
function NavigationContent({ user, router, onItemClick }) {
|
2025-07-27 14:22:42 -04:00
|
|
|
const [isCommunityExpanded, setIsCommunityExpanded] = useState(
|
|
|
|
|
router.pathname.startsWith('/community')
|
|
|
|
|
);
|
2026-06-02 02:03:36 -04:00
|
|
|
const [lastCommunityPath, setLastCommunityPath] = useState(router.pathname);
|
2025-07-27 14:22:42 -04:00
|
|
|
|
2026-06-02 02:03:36 -04:00
|
|
|
if (
|
|
|
|
|
router.pathname.startsWith('/community') &&
|
|
|
|
|
router.pathname !== lastCommunityPath
|
|
|
|
|
) {
|
|
|
|
|
setLastCommunityPath(router.pathname);
|
|
|
|
|
if (!isCommunityExpanded) {
|
2025-07-27 14:22:42 -04:00
|
|
|
setIsCommunityExpanded(true);
|
|
|
|
|
}
|
2026-06-02 02:03:36 -04:00
|
|
|
} else if (router.pathname !== lastCommunityPath) {
|
|
|
|
|
setLastCommunityPath(router.pathname);
|
|
|
|
|
}
|
2025-07-23 22:26:54 -04:00
|
|
|
|
2025-07-29 15:19:48 -04:00
|
|
|
// Navigation structure for authenticated users
|
|
|
|
|
const authenticatedNavigation = user ? [
|
|
|
|
|
{ name: 'Activity', href: '/activity', icon: 'activity', active: router.pathname === '/activity', isPlaceholder: true }
|
|
|
|
|
] : [];
|
|
|
|
|
|
|
|
|
|
// My Collection section (only for authenticated users)
|
|
|
|
|
const myCollectionNavigation = user ? {
|
|
|
|
|
name: 'My Collection',
|
2025-08-01 19:18:21 -04:00
|
|
|
href: '/dashboard',
|
2025-07-29 15:19:48 -04:00
|
|
|
icon: 'collection',
|
|
|
|
|
active: router.pathname === '/dashboard' || router.pathname === '/collections' || router.pathname === '/my-cards' || router.pathname === '/decks' || router.pathname === '/analytics',
|
2025-08-01 19:18:21 -04:00
|
|
|
expanded: true,
|
2025-07-29 15:19:48 -04:00
|
|
|
items: [
|
2026-05-29 10:53:40 -04:00
|
|
|
{ name: 'Lists', href: '/collections', active: router.pathname === '/collections' },
|
2025-07-29 15:19:48 -04:00
|
|
|
{ name: 'Cards', href: '/my-cards', active: router.pathname === '/my-cards' },
|
|
|
|
|
{ name: 'Decks', href: '/decks', active: router.pathname === '/decks' },
|
|
|
|
|
{ name: 'Analytics', href: '/analytics', active: router.pathname === '/analytics', isPlaceholder: true }
|
|
|
|
|
]
|
|
|
|
|
} : null;
|
|
|
|
|
|
|
|
|
|
// Always visible navigation (public + authenticated)
|
|
|
|
|
const publicNavigation = [
|
2025-07-23 22:26:54 -04:00
|
|
|
{ name: 'Cards', href: '/cards', icon: 'card', active: router.pathname === '/cards' },
|
2025-07-29 15:19:48 -04:00
|
|
|
{ name: 'Scanner', href: '/scanner', icon: 'scanner', active: router.pathname === '/scanner' },
|
|
|
|
|
{ name: 'Deck Builder', href: '/deck-builder', icon: 'deck', active: router.pathname === '/deck-builder', isPlaceholder: true }
|
2025-07-25 12:28:04 -04:00
|
|
|
];
|
|
|
|
|
|
2025-07-29 15:19:48 -04:00
|
|
|
// Admin navigation (only for admin users)
|
|
|
|
|
const adminNavigation = (user?.role === 'admin') ? [
|
|
|
|
|
{ name: 'Admin Tools', href: '/admin/card-editor', icon: 'admin', active: router.pathname.startsWith('/admin'), badge: 'ADMIN' }
|
|
|
|
|
] : [];
|
|
|
|
|
|
2025-07-27 14:22:42 -04:00
|
|
|
const communityNavigation = {
|
|
|
|
|
name: 'Community',
|
|
|
|
|
icon: 'community',
|
|
|
|
|
active: router.pathname.startsWith('/community'),
|
|
|
|
|
expanded: isCommunityExpanded,
|
|
|
|
|
items: [
|
2026-05-29 10:53:40 -04:00
|
|
|
{ name: 'Lists', href: '/community/collections', active: router.pathname === '/community/collections' },
|
2025-07-27 14:22:42 -04:00
|
|
|
{ name: 'Decks', href: '/community/decks', active: router.pathname === '/community/decks' },
|
|
|
|
|
{ name: 'Forums', href: '/community/forums', active: router.pathname === '/community/forums' }
|
|
|
|
|
]
|
|
|
|
|
};
|
|
|
|
|
|
2025-07-23 22:26:54 -04:00
|
|
|
const getIcon = (iconName) => {
|
|
|
|
|
const icons = {
|
|
|
|
|
grid: (
|
|
|
|
|
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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>
|
|
|
|
|
),
|
|
|
|
|
collection: (
|
|
|
|
|
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
|
|
|
|
</svg>
|
|
|
|
|
),
|
|
|
|
|
card: (
|
|
|
|
|
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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>
|
|
|
|
|
),
|
|
|
|
|
deck: (
|
|
|
|
|
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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>
|
|
|
|
|
),
|
|
|
|
|
analytics: (
|
|
|
|
|
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
|
|
|
|
</svg>
|
|
|
|
|
),
|
|
|
|
|
community: (
|
|
|
|
|
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" />
|
|
|
|
|
</svg>
|
|
|
|
|
),
|
|
|
|
|
settings: (
|
|
|
|
|
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
|
|
|
</svg>
|
|
|
|
|
),
|
|
|
|
|
logout: (
|
|
|
|
|
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
|
|
|
|
</svg>
|
2025-07-24 17:31:29 -04:00
|
|
|
),
|
|
|
|
|
admin: (
|
|
|
|
|
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
|
|
|
</svg>
|
2025-07-25 12:28:04 -04:00
|
|
|
),
|
|
|
|
|
notifications: (
|
|
|
|
|
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-5 5v-5zM4.5 19.5L9 15m0 0l-4.5-4.5M9 15v5" />
|
|
|
|
|
</svg>
|
2025-07-29 15:19:48 -04:00
|
|
|
),
|
|
|
|
|
activity: (
|
|
|
|
|
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
|
|
|
|
</svg>
|
|
|
|
|
),
|
|
|
|
|
scanner: (
|
|
|
|
|
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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={2} d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
|
|
|
</svg>
|
2025-07-23 22:26:54 -04:00
|
|
|
)
|
|
|
|
|
};
|
|
|
|
|
return icons[iconName] || icons.grid;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
2025-08-01 19:18:21 -04:00
|
|
|
<>
|
|
|
|
|
{/* Authenticated User Navigation - Activity */}
|
|
|
|
|
{authenticatedNavigation.map((item) => (
|
|
|
|
|
<div key={item.name}>
|
|
|
|
|
{item.isPlaceholder ? (
|
|
|
|
|
<div
|
|
|
|
|
className="nav-item flex items-center justify-between px-4 py-3 rounded-2xl opacity-50 cursor-not-allowed"
|
|
|
|
|
style={{
|
|
|
|
|
backgroundColor: 'transparent',
|
|
|
|
|
color: 'var(--text-secondary)'
|
2025-07-26 10:38:18 -04:00
|
|
|
}}
|
2025-07-26 01:49:42 -04:00
|
|
|
>
|
2025-08-01 19:18:21 -04:00
|
|
|
<div className="flex items-center">
|
|
|
|
|
<span className="mr-3" aria-hidden="true">{getIcon(item.icon)}</span>
|
|
|
|
|
<span className="font-medium">{item.name}</span>
|
2025-07-29 15:19:48 -04:00
|
|
|
</div>
|
2025-08-01 19:18:21 -04:00
|
|
|
<span className="text-xs px-2 py-1 rounded-full" style={{ backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-secondary)' }}>
|
|
|
|
|
Coming Soon
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<Link href={item.href}>
|
2025-07-27 14:22:42 -04:00
|
|
|
<div
|
|
|
|
|
className={`
|
|
|
|
|
nav-item flex items-center justify-between px-4 py-3 rounded-2xl
|
|
|
|
|
transition-all duration-200 cursor-pointer
|
|
|
|
|
focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2
|
2025-08-01 19:18:21 -04:00
|
|
|
${item.active
|
2025-07-27 14:22:42 -04:00
|
|
|
? 'shadow-lg nav-item-active'
|
|
|
|
|
: 'hover:shadow-md nav-item-hover'
|
|
|
|
|
}
|
|
|
|
|
`}
|
|
|
|
|
style={{
|
2025-08-01 19:18:21 -04:00
|
|
|
backgroundColor: item.active ? 'var(--bg-tertiary)' : 'transparent',
|
|
|
|
|
color: item.active ? 'var(--text-primary)' : 'var(--text-secondary)',
|
2025-07-27 14:22:42 -04:00
|
|
|
'--tw-ring-color': 'var(--accent-ember)',
|
|
|
|
|
'--tw-ring-offset-color': 'var(--bg-secondary)'
|
|
|
|
|
}}
|
2025-08-01 19:18:21 -04:00
|
|
|
onClick={onItemClick}
|
2025-07-27 14:22:42 -04:00
|
|
|
>
|
|
|
|
|
<div className="flex items-center">
|
2025-08-01 19:18:21 -04:00
|
|
|
<span className="mr-3" aria-hidden="true">{getIcon(item.icon)}</span>
|
|
|
|
|
<span className="font-medium">{item.name}</span>
|
2025-07-27 14:22:42 -04:00
|
|
|
</div>
|
|
|
|
|
</div>
|
2025-08-01 19:18:21 -04:00
|
|
|
</Link>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
|
|
|
|
|
{/* My Collection Section (only for authenticated users) */}
|
|
|
|
|
{myCollectionNavigation && (
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
{/* My Collection Header - Clickable */}
|
|
|
|
|
<Link href={myCollectionNavigation.href}>
|
|
|
|
|
<div
|
|
|
|
|
className={`
|
|
|
|
|
nav-item flex items-center justify-between px-4 py-3 rounded-2xl
|
|
|
|
|
transition-all duration-200 cursor-pointer
|
|
|
|
|
focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2
|
|
|
|
|
${router.pathname === '/dashboard'
|
|
|
|
|
? 'shadow-lg nav-item-active'
|
|
|
|
|
: 'hover:shadow-md nav-item-hover'
|
|
|
|
|
}
|
|
|
|
|
`}
|
|
|
|
|
style={{
|
|
|
|
|
backgroundColor: router.pathname === '/dashboard' ? 'var(--bg-tertiary)' : 'transparent',
|
|
|
|
|
color: router.pathname === '/dashboard' ? 'var(--text-primary)' : 'var(--text-secondary)',
|
|
|
|
|
'--tw-ring-color': 'var(--accent-ember)',
|
|
|
|
|
'--tw-ring-offset-color': 'var(--bg-secondary)'
|
|
|
|
|
}}
|
|
|
|
|
onClick={onItemClick}
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-center">
|
|
|
|
|
<span className="mr-3" aria-hidden="true">{getIcon(myCollectionNavigation.icon)}</span>
|
|
|
|
|
<span className="font-medium">{myCollectionNavigation.name}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</Link>
|
2025-07-27 14:22:42 -04:00
|
|
|
|
2025-08-01 19:18:21 -04:00
|
|
|
{/* My Collection Sub-items */}
|
|
|
|
|
{myCollectionNavigation.expanded && (
|
|
|
|
|
<div className="ml-4 space-y-1 border-l-2 pl-4" style={{ borderColor: 'var(--border)' }}>
|
|
|
|
|
{myCollectionNavigation.items.map((subItem) => (
|
|
|
|
|
<div key={subItem.name}>
|
|
|
|
|
{subItem.isPlaceholder ? (
|
|
|
|
|
<div
|
|
|
|
|
className="nav-item flex items-center justify-between px-4 py-2 rounded-xl opacity-50 cursor-not-allowed text-sm"
|
|
|
|
|
style={{
|
|
|
|
|
backgroundColor: 'transparent',
|
|
|
|
|
color: 'var(--text-secondary)'
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<span className="font-medium">{subItem.name}</span>
|
|
|
|
|
<span className="text-xs px-2 py-1 rounded-full" style={{ backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-secondary)' }}>
|
|
|
|
|
Soon
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<Link href={subItem.href}>
|
2025-07-27 14:22:42 -04:00
|
|
|
<div
|
|
|
|
|
className={`
|
|
|
|
|
nav-item flex items-center px-4 py-2 rounded-xl
|
|
|
|
|
transition-all duration-200 cursor-pointer text-sm
|
|
|
|
|
focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2
|
|
|
|
|
${subItem.active
|
|
|
|
|
? 'shadow-md nav-item-active'
|
|
|
|
|
: 'hover:shadow-sm nav-item-hover'
|
|
|
|
|
}
|
|
|
|
|
`}
|
|
|
|
|
style={{
|
|
|
|
|
backgroundColor: subItem.active ? 'var(--bg-tertiary)' : 'transparent',
|
|
|
|
|
color: subItem.active ? 'var(--text-primary)' : 'var(--text-secondary)',
|
|
|
|
|
'--tw-ring-color': 'var(--accent-ember)',
|
|
|
|
|
'--tw-ring-offset-color': 'var(--bg-secondary)'
|
|
|
|
|
}}
|
2025-08-01 19:18:21 -04:00
|
|
|
onClick={onItemClick}
|
2025-07-27 14:22:42 -04:00
|
|
|
>
|
|
|
|
|
<span className="font-medium">{subItem.name}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</Link>
|
2025-08-01 19:18:21 -04:00
|
|
|
)}
|
2025-07-27 14:22:42 -04:00
|
|
|
</div>
|
2025-08-01 19:18:21 -04:00
|
|
|
))}
|
2025-07-27 14:22:42 -04:00
|
|
|
</div>
|
2025-08-01 19:18:21 -04:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2025-07-23 22:26:54 -04:00
|
|
|
|
2025-08-01 19:18:21 -04:00
|
|
|
{/* Separator */}
|
|
|
|
|
<div className="border-t pt-2 mt-4" style={{ borderColor: 'var(--border)' }}></div>
|
|
|
|
|
|
|
|
|
|
{/* Public Navigation (always visible) */}
|
|
|
|
|
{publicNavigation.map((item) => (
|
|
|
|
|
<div key={item.name}>
|
|
|
|
|
{item.isPlaceholder ? (
|
|
|
|
|
<div
|
|
|
|
|
className="nav-item flex items-center justify-between px-4 py-3 rounded-2xl opacity-50 cursor-not-allowed"
|
|
|
|
|
style={{
|
|
|
|
|
backgroundColor: 'transparent',
|
|
|
|
|
color: 'var(--text-secondary)'
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-center">
|
|
|
|
|
<span className="mr-3" aria-hidden="true">{getIcon(item.icon)}</span>
|
|
|
|
|
<span className="font-medium">{item.name}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<span className="text-xs px-2 py-1 rounded-full" style={{ backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-secondary)' }}>
|
|
|
|
|
Coming Soon
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<Link href={item.href}>
|
|
|
|
|
<div
|
|
|
|
|
className={`
|
|
|
|
|
nav-item flex items-center justify-between px-4 py-3 rounded-2xl
|
|
|
|
|
transition-all duration-200 cursor-pointer
|
|
|
|
|
focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2
|
|
|
|
|
${item.active
|
|
|
|
|
? 'shadow-lg nav-item-active'
|
|
|
|
|
: 'hover:shadow-md nav-item-hover'
|
|
|
|
|
}
|
|
|
|
|
`}
|
|
|
|
|
style={{
|
|
|
|
|
backgroundColor: item.active ? 'var(--bg-tertiary)' : 'transparent',
|
|
|
|
|
color: item.active ? 'var(--text-primary)' : 'var(--text-secondary)',
|
|
|
|
|
'--tw-ring-color': 'var(--accent-ember)',
|
|
|
|
|
'--tw-ring-offset-color': 'var(--bg-secondary)'
|
|
|
|
|
}}
|
|
|
|
|
onClick={onItemClick}
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-center">
|
|
|
|
|
<span className="mr-3" aria-hidden="true">{getIcon(item.icon)}</span>
|
|
|
|
|
<span className="font-medium">{item.name}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</Link>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
|
|
|
|
|
{/* Community Section with Sub-items */}
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
<div
|
|
|
|
|
className={`
|
|
|
|
|
nav-item flex items-center justify-between px-4 py-3 rounded-2xl
|
|
|
|
|
transition-all duration-200 cursor-pointer
|
|
|
|
|
focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2
|
|
|
|
|
${communityNavigation.active
|
|
|
|
|
? 'shadow-lg nav-item-active'
|
|
|
|
|
: 'hover:shadow-md nav-item-hover'
|
|
|
|
|
}
|
|
|
|
|
`}
|
|
|
|
|
style={{
|
|
|
|
|
backgroundColor: communityNavigation.active ? 'var(--bg-tertiary)' : 'transparent',
|
|
|
|
|
color: communityNavigation.active ? 'var(--text-primary)' : 'var(--text-secondary)',
|
|
|
|
|
'--tw-ring-color': 'var(--accent-ember)',
|
|
|
|
|
'--tw-ring-offset-color': 'var(--bg-secondary)'
|
|
|
|
|
}}
|
|
|
|
|
onClick={() => setIsCommunityExpanded(!isCommunityExpanded)}
|
|
|
|
|
role="menuitem"
|
|
|
|
|
aria-expanded={isCommunityExpanded}
|
|
|
|
|
aria-haspopup="menu"
|
|
|
|
|
tabIndex={0}
|
|
|
|
|
onKeyDown={(e) => {
|
|
|
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
setIsCommunityExpanded(!isCommunityExpanded);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-center">
|
|
|
|
|
<span className="mr-3" aria-hidden="true">{getIcon(communityNavigation.icon)}</span>
|
|
|
|
|
<span className="font-medium">{communityNavigation.name}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<svg
|
|
|
|
|
className={`w-4 h-4 transition-transform duration-200 ${isCommunityExpanded ? 'rotate-90' : ''}`}
|
|
|
|
|
fill="none"
|
|
|
|
|
stroke="currentColor"
|
|
|
|
|
viewBox="0 0 24 24"
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
>
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
|
|
|
</svg>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Community Sub-items */}
|
|
|
|
|
{isCommunityExpanded && (
|
|
|
|
|
<div className="ml-4 space-y-1 border-l-2 pl-4" style={{ borderColor: 'var(--border)' }}>
|
|
|
|
|
{communityNavigation.items.map((subItem) => (
|
|
|
|
|
<Link key={subItem.name} href={subItem.href}>
|
2025-07-29 15:19:48 -04:00
|
|
|
<div
|
|
|
|
|
className={`
|
2025-08-01 19:18:21 -04:00
|
|
|
nav-item flex items-center px-4 py-2 rounded-xl
|
|
|
|
|
transition-all duration-200 cursor-pointer text-sm
|
2025-07-29 15:19:48 -04:00
|
|
|
focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2
|
2025-08-01 19:18:21 -04:00
|
|
|
${subItem.active
|
|
|
|
|
? 'shadow-md nav-item-active'
|
|
|
|
|
: 'hover:shadow-sm nav-item-hover'
|
2025-07-29 15:19:48 -04:00
|
|
|
}
|
|
|
|
|
`}
|
|
|
|
|
style={{
|
2025-08-01 19:18:21 -04:00
|
|
|
backgroundColor: subItem.active ? 'var(--bg-tertiary)' : 'transparent',
|
|
|
|
|
color: subItem.active ? 'var(--text-primary)' : 'var(--text-secondary)',
|
2025-07-29 15:19:48 -04:00
|
|
|
'--tw-ring-color': 'var(--accent-ember)',
|
|
|
|
|
'--tw-ring-offset-color': 'var(--bg-secondary)'
|
|
|
|
|
}}
|
2025-08-01 19:18:21 -04:00
|
|
|
onClick={onItemClick}
|
|
|
|
|
role="menuitem"
|
|
|
|
|
aria-current={subItem.active ? 'page' : undefined}
|
|
|
|
|
tabIndex={0}
|
|
|
|
|
onKeyDown={(e) => {
|
|
|
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
onItemClick();
|
|
|
|
|
}
|
|
|
|
|
}}
|
2025-07-29 15:19:48 -04:00
|
|
|
>
|
2025-08-01 19:18:21 -04:00
|
|
|
<span className="font-medium">{subItem.name}</span>
|
2025-07-29 15:19:48 -04:00
|
|
|
</div>
|
|
|
|
|
</Link>
|
|
|
|
|
))}
|
2025-08-01 19:18:21 -04:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Admin Navigation (if admin user) */}
|
|
|
|
|
{adminNavigation.map((item) => (
|
|
|
|
|
<Link key={item.name} href={item.href}>
|
|
|
|
|
<div
|
|
|
|
|
className={`
|
|
|
|
|
nav-item flex items-center justify-between px-4 py-3 rounded-2xl
|
|
|
|
|
transition-all duration-200 cursor-pointer
|
|
|
|
|
focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2
|
|
|
|
|
${item.active
|
|
|
|
|
? 'shadow-lg nav-item-active'
|
|
|
|
|
: 'hover:shadow-md nav-item-hover'
|
|
|
|
|
}
|
|
|
|
|
`}
|
|
|
|
|
style={{
|
|
|
|
|
backgroundColor: item.active ? 'var(--bg-tertiary)' : 'transparent',
|
|
|
|
|
color: item.active ? 'var(--text-primary)' : 'var(--text-secondary)',
|
|
|
|
|
'--tw-ring-color': 'var(--accent-ember)',
|
|
|
|
|
'--tw-ring-offset-color': 'var(--bg-secondary)'
|
|
|
|
|
}}
|
|
|
|
|
onClick={onItemClick}
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-center">
|
|
|
|
|
<span className="mr-3" aria-hidden="true">{getIcon(item.icon)}</span>
|
|
|
|
|
<span className="font-medium">{item.name}</span>
|
|
|
|
|
</div>
|
|
|
|
|
{item.badge && (
|
|
|
|
|
<span
|
|
|
|
|
className="px-2 py-1 text-xs rounded-full font-medium"
|
|
|
|
|
style={{
|
|
|
|
|
backgroundColor: item.badge === 'ADMIN' ? 'var(--accent-ember)' : 'var(--accent-flame)',
|
|
|
|
|
color: 'white'
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{item.badge}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</Link>
|
|
|
|
|
))}
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
fix(layout+pages): default user=null + page audit sweep (P0 #7) (#15)
* convoy: scope fix-layout-default-user (P0 #7 — Layout maintainer-email leak)
The last remaining P0 ship-blocker from .convoys/ship-readiness.md.
components/Layout.js line 562 defaults the user prop to a real email
address (me@randallstillwell.com); any page that renders Layout without
passing user explicitly impersonates the maintainer.
Scope: components/Layout.js + audit of 17 pages that import Layout
(grep-confirmed list in convoy file). Single PR likely. Auditor cohort
skipped (no design-system, IA, or browser-smoke surface).
Architect to address:
- Q1: logged-out rendering branch design (navbar, mobile-nav,
auth-only items treatment)
- Q2: page audit triage into always-auth / public-or-auth /
anonymous-allowed buckets
- Q3: brief decomposition (single brief / 2 briefs in 1 PR / fan-out)
- Q4: whether to add vitest coverage for the logged-out branch
(recommend yes — small surface, high regression protection)
Hard out-of-scope: branding (pick-a-name), auth-provider collapse
(single-auth-provider), Layout god-component split (god-component-split).
depends_on: bump-next-js (shipped), fix-auth-bypass (shipped),
drop-public-setup (shipped)
addresses: P0 #7 from .convoys/ship-readiness.md
parent: ship-readiness
Co-authored-by: Cursor <cursoragent@cursor.com>
* architect(fix-layout-default-user): plan + briefs 1-2 (Layout fix + page audit)
2 briefs, single PR. ~12 files net (down from the 18 in the original scope —
10 of the 17 Layout-importing pages already pass user explicitly).
Brief 1: components/Layout.js default user=null + Sign-in CTA branch in
UserProfileDropdown when logged out. Adds first jsdom test in the repo
at test/components/Layout.test.js (Decision D2) with 5 regression-lock
assertions. devDeps: jsdom@^29, @testing-library/react@^16.
Brief 2: page audit sweep — 7 pages need code changes:
- Pass user={user} to Layout: scanner.js, deck-builder.js (×4),
deck/[id].js (×3), decks.js (×3)
- Replace page-level useState({email: 'me@...'}) → useState(null) +
null-guards: profile.js, settings.js
- Replace hardcoded const user = {email: 'me@...'} with useAuth():
card/[id].js
Discovered second anti-pattern: profile.js, settings.js, card/[id].js
seed page-level state with the maintainer email. Folded into Brief 2 since
success metric "no real email address remains in any component default-prop"
reads naturally to include page-level seed values.
Decisions:
A1 — Sign-in CTA replaces avatar+email+dropdown when user===null;
hides auth-only dropdown (Profile/Settings/Logout/Admin);
keeps public + community nav visible
B — Per-page bucket assignment (10 already correct, 7 need fix);
full per-page table with justification in convoy file
C2 — Two briefs in one PR (Brief 1 = Layout + test; Brief 2 = page
sweep depends on Brief 1). C1 buries the conceptual change under
mechanical edits; C3 is over-orchestrated for this scope
D2 — vitest lock-in; first jsdom test in repo; same negative-regression
style as test/lib/permission-middleware.test.js (synthetic-admin
shape). devDeps jsdom + @testing-library/react
Risks tracked R1-R8. Biggest: R2 (useState(null) null-deref in 3 leaky
pages — mitigated by audit-pass mandate + manual smoke).
MobileNavigation deliberately NOT folded in: its user prop is dead code
(never reads user.*); different bug class; cleanup queued separately to
avoid scope expansion.
Flagged-but-deferred:
- 4 pages still import useAuth from lib/auth-context.js
→ single-auth-provider (queued P1 #9)
- Layout headers still render "Deck Hearth" / "DH" branding
→ pick-a-name (queued P1 #12)
- MobileNavigation dead user prop → cleanup-mobile-nav-dead-props
or fold into god-component-split
addresses: P0 #7 from .convoys/ship-readiness.md (last P0 ship-blocker)
parent: ship-readiness
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(layout): default user=null + Sign-in CTA when logged out (Brief 1 of fix-layout-default-user)
Closes the source-side half of P0 #7 from .convoys/ship-readiness.md.
The page-side sweep (Brief 2) follows in a separate commit.
components/Layout.js:
- Default user prop is now null (was hardcoded to
{ email: 'me@randallstillwell.com', role: 'user' })
- UserProfileDropdown renders a "Sign in" link to /login when
user === null instead of the maintainer's email + auth-only menu
items (Decision A1)
- All user.* accesses guarded with optional chaining or null checks
- useState hook stays above the new null-user early return to satisfy
rules-of-hooks (boot-the-brief caught this on the first try;
see AGENTS.md Gotcha #11.5)
test/components/Layout.test.js (new):
- First jsdom test in the repo (Decision D2)
- 5 regression-lock assertions: no maintainer email ever rendered
(prop omitted, prop=null), Sign-in link exists with href=/login,
supplied email renders when prop is set, no "Guest" placeholder
(locks A1 copy choice)
- Mocks next/link, next/router (prefetch, replace, events, query),
and theme-context.useTheme for jsdom safety under Next 16
package.json + package-lock.json:
- Add jsdom@^29 and @testing-library/react@^16 to devDependencies
- @testing-library/dom@^10 added explicitly (peer auto-install
skipped it under npm 11; brief anticipated this fallback)
vitest.config.js (deviation from brief — see PR description):
- Add esbuild { loader: 'jsx', jsx: 'automatic' } so vitest can
parse JSX in .js files. Required to import any React component
written in the repo's Next.js pages-router .js convention
(AGENTS.md Gotcha #9). The brief said "no change" to this file,
but JSX-in-.js parsing is a hard prerequisite for the new test
to import components/Layout.js — the alternatives (rename test
to .test.jsx; rewrite test in React.createElement) either break
the test glob or still hit the same Layout.js parse failure.
Other tests are unaffected (they import non-JSX modules).
Smoke output: see PR description.
addresses: P0 #7 from .convoys/ship-readiness.md (last P0 ship-blocker)
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(pages): pass user explicitly + null-guard leaky page seeds (Brief 2 of fix-layout-default-user)
Closes the page-side half of P0 #7 from .convoys/ship-readiness.md.
Brief 1 (commit ddf8fd2) handled the Layout-side fix.
Per the architect's per-page bucket table (Decision B in
.convoys/fix-layout-default-user.md), 7 pages needed code changes;
the other 10 of 17 Layout-importing pages already pass `user` correctly.
Pass user={user} to Layout (4 pages, 11 call sites):
- pages/scanner.js (1 call)
- pages/decks.js (3 calls)
- pages/deck-builder.js (4 calls)
- pages/deck/[id].js (3 calls)
(All four still import useAuth from lib/auth-context.js — that's
intentional and stays as-is until the single-auth-provider convoy
collapses the three parallel auth surfaces.)
Replace leaky page-level seed values with useState(null) + null guards
(2 pages, R2 mitigation):
- pages/profile.js: useState({email: 'me@...', role: 'user', ...})
→ useState(null) + ?. on every sync user.* read
+ early-return guards in getDisplayName/getInitials
+ conditional render around the "Member since" block
so formatDate(undefined) never runs
- pages/settings.js: same pattern (single user.email reader guarded)
Replace hardcoded const with useAuth from lib/use-auth.js (1 page):
- pages/card/[id].js: const user = {email: 'me@...'}
→ const { user } = useAuth() (called unconditionally
at the top of the component; rules-of-hooks safe)
Verification:
- grep 'me@randallstillwell.com' pages/ → 0 hits
- 21/21 vitest tests pass (16 pre-existing + 5 from Brief 1)
- npm run lint matches baseline (128 problems pre, 128 post; verified
via git stash before/after)
- Manual static read-through of every diff; ReadLints clean on the 7
files
- Dev-server smoke: /cards anonymous returned HTTP 200 with 0
'me@randallstillwell' matches before the user's shared dev server
became unresponsive mid-session (same dev-server-shared-by-user
constraint flagged in Brief 1); interactive logged-in smoke is
parent/operator gated
Flagged-but-deferred (untouched per scope):
- 4 pages still import useAuth from lib/auth-context.js
→ single-auth-provider (queued P1 #9)
- components/MobileNavigation.js still receives dead user prop
→ cleanup-mobile-nav-dead-props (or fold into god-component-split)
addresses: P0 #7 from .convoys/ship-readiness.md (last P0 ship-blocker)
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 15:31:37 -04:00
|
|
|
export default function Layout({ children, user = null, showSearch = false }) {
|
2025-08-01 19:18:21 -04:00
|
|
|
const router = useRouter();
|
|
|
|
|
const { theme, toggleTheme } = useTheme();
|
|
|
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
|
|
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
|
|
|
|
|
|
|
|
|
return (
|
2026-06-04 10:39:29 -04:00
|
|
|
<div className="flex h-screen">
|
2025-08-01 19:18:21 -04:00
|
|
|
{/* Mobile Navigation - Bottom bar for mobile */}
|
|
|
|
|
<MobileNavigation
|
|
|
|
|
onMenuOpen={() => setIsMobileMenuOpen(true)}
|
|
|
|
|
/>
|
|
|
|
|
|
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
|
|
|
{/* Mobile Overlay — Liquid Glass scrim consistent with <Modal>. */}
|
2025-08-01 19:18:21 -04:00
|
|
|
{isMobileMenuOpen && (
|
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
|
|
|
<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))',
|
|
|
|
|
}}
|
2025-08-01 19:18:21 -04:00
|
|
|
onClick={() => setIsMobileMenuOpen(false)}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Mobile Menu Drawer - Slides in from left when "More" is tapped */}
|
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
|
|
|
<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)',
|
|
|
|
|
}}
|
|
|
|
|
>
|
2025-08-01 19:18:21 -04:00
|
|
|
<div className="p-4 h-full flex flex-col">
|
|
|
|
|
{/* Mobile Header with Close Button */}
|
|
|
|
|
<div className="flex justify-between items-center mb-4">
|
|
|
|
|
<div className="flex items-center">
|
|
|
|
|
<div className="w-10 h-10 logo-container mr-3">
|
|
|
|
|
<span className="text-white font-bold text-sm">DH</span>
|
|
|
|
|
</div>
|
|
|
|
|
<h1 className="text-xl font-bold" style={{ color: 'var(--text-primary)' }}>Deck Hearth</h1>
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setIsMobileMenuOpen(false)}
|
|
|
|
|
className="p-2 rounded-lg focus:outline-none focus:ring-2 focus:ring-offset-2"
|
|
|
|
|
style={{
|
|
|
|
|
color: 'var(--text-secondary)',
|
|
|
|
|
'--tw-ring-color': 'var(--accent-ember)',
|
|
|
|
|
'--tw-ring-offset-color': 'var(--bg-secondary)'
|
|
|
|
|
}}
|
|
|
|
|
aria-label="Close navigation menu"
|
|
|
|
|
>
|
|
|
|
|
<svg className="h-6 w-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>
|
|
|
|
|
|
|
|
|
|
{/* Mobile Navigation Content */}
|
|
|
|
|
<nav className="space-y-2 flex-1" role="navigation" aria-label="Mobile navigation">
|
|
|
|
|
<NavigationContent
|
|
|
|
|
user={user}
|
|
|
|
|
router={router}
|
|
|
|
|
onItemClick={() => setIsMobileMenuOpen(false)}
|
|
|
|
|
/>
|
|
|
|
|
</nav>
|
2025-07-29 15:19:48 -04:00
|
|
|
|
2025-08-01 19:18:21 -04:00
|
|
|
{/* Mobile Bottom Section */}
|
|
|
|
|
<div className="mt-auto space-y-2">
|
2025-07-29 15:19:48 -04:00
|
|
|
{/* User Profile Dropdown */}
|
|
|
|
|
<UserProfileDropdown
|
|
|
|
|
user={user}
|
|
|
|
|
onMobileMenuClose={() => setIsMobileMenuOpen(false)}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{/* Icon Buttons Row - Support and Dark Mode */}
|
|
|
|
|
<div className="flex justify-center space-x-4 pt-2">
|
|
|
|
|
{/* Support Icon Button */}
|
|
|
|
|
<Link href="/support">
|
|
|
|
|
<button
|
|
|
|
|
className="p-3 rounded-xl transition-all duration-200 hover:shadow-md cursor-pointer nav-item-hover focus:outline-none focus:ring-2 focus:ring-offset-2"
|
|
|
|
|
style={{
|
|
|
|
|
backgroundColor: 'transparent',
|
|
|
|
|
color: 'var(--text-secondary)',
|
|
|
|
|
'--tw-ring-color': 'var(--accent-ember)',
|
|
|
|
|
'--tw-ring-offset-color': 'var(--bg-secondary)'
|
|
|
|
|
}}
|
|
|
|
|
onClick={() => setIsMobileMenuOpen(false)}
|
|
|
|
|
aria-label="Support"
|
|
|
|
|
title="Support"
|
|
|
|
|
>
|
|
|
|
|
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
|
|
|
</svg>
|
|
|
|
|
</button>
|
|
|
|
|
</Link>
|
|
|
|
|
|
|
|
|
|
{/* Theme Toggle Icon Button */}
|
|
|
|
|
<button
|
|
|
|
|
onClick={toggleTheme}
|
|
|
|
|
className="p-3 rounded-xl transition-all duration-200 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 nav-item-hover"
|
|
|
|
|
style={{
|
|
|
|
|
backgroundColor: 'transparent',
|
|
|
|
|
color: 'var(--text-secondary)',
|
|
|
|
|
'--tw-ring-color': 'var(--accent-ember)',
|
|
|
|
|
'--tw-ring-offset-color': 'var(--bg-secondary)'
|
|
|
|
|
}}
|
|
|
|
|
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
|
|
|
|
|
title={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
|
|
|
|
|
>
|
2025-07-26 10:51:40 -04:00
|
|
|
{theme === 'light' ? (
|
2025-07-29 15:19:48 -04:00
|
|
|
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
2025-07-26 10:51:40 -04:00
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
|
|
|
|
</svg>
|
|
|
|
|
) : (
|
2025-07-29 15:19:48 -04:00
|
|
|
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
2025-07-26 10:51:40 -04:00
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
|
|
|
|
</svg>
|
|
|
|
|
)}
|
2025-07-29 15:19:48 -04:00
|
|
|
</button>
|
|
|
|
|
</div>
|
2025-07-23 22:26:54 -04:00
|
|
|
</div>
|
2025-07-25 12:28:04 -04:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
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
|
|
|
{/* 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)',
|
|
|
|
|
}}
|
|
|
|
|
>
|
2025-08-01 19:18:21 -04:00
|
|
|
<div className="p-4 h-full flex flex-col w-full">
|
|
|
|
|
{/* Desktop Header */}
|
|
|
|
|
<div className="flex items-center mb-8">
|
|
|
|
|
<div className="w-10 h-10 logo-container mr-3">
|
|
|
|
|
<span className="text-white font-bold text-sm">DH</span>
|
|
|
|
|
</div>
|
|
|
|
|
<h1 className="text-xl font-bold" style={{ color: 'var(--text-primary)' }}>Deck Hearth</h1>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Desktop Navigation Content */}
|
|
|
|
|
<nav className="space-y-2 flex-1" role="navigation" aria-label="Main navigation">
|
|
|
|
|
<NavigationContent
|
|
|
|
|
user={user}
|
|
|
|
|
router={router}
|
|
|
|
|
onItemClick={() => {}}
|
|
|
|
|
/>
|
|
|
|
|
</nav>
|
|
|
|
|
|
|
|
|
|
{/* Desktop Bottom Section */}
|
|
|
|
|
<div className="mt-auto space-y-2">
|
|
|
|
|
{/* User Profile Dropdown */}
|
|
|
|
|
<UserProfileDropdown
|
|
|
|
|
user={user}
|
|
|
|
|
onMobileMenuClose={() => {}}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{/* Icon Buttons Row - Support and Dark Mode */}
|
|
|
|
|
<div className="flex justify-center space-x-4 pt-2">
|
|
|
|
|
{/* Support Icon Button */}
|
|
|
|
|
<Link href="/support">
|
|
|
|
|
<button
|
|
|
|
|
className="p-3 rounded-xl transition-all duration-200 hover:shadow-md cursor-pointer nav-item-hover focus:outline-none focus:ring-2 focus:ring-offset-2"
|
|
|
|
|
style={{
|
|
|
|
|
backgroundColor: 'transparent',
|
|
|
|
|
color: 'var(--text-secondary)',
|
|
|
|
|
'--tw-ring-color': 'var(--accent-ember)',
|
|
|
|
|
'--tw-ring-offset-color': 'var(--bg-secondary)'
|
|
|
|
|
}}
|
|
|
|
|
aria-label="Support"
|
|
|
|
|
title="Support"
|
|
|
|
|
>
|
|
|
|
|
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
|
|
|
</svg>
|
|
|
|
|
</button>
|
|
|
|
|
</Link>
|
|
|
|
|
|
|
|
|
|
{/* Theme Toggle Icon Button */}
|
|
|
|
|
<button
|
|
|
|
|
onClick={toggleTheme}
|
|
|
|
|
className="p-3 rounded-xl transition-all duration-200 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 nav-item-hover"
|
|
|
|
|
style={{
|
|
|
|
|
backgroundColor: 'transparent',
|
|
|
|
|
color: 'var(--text-secondary)',
|
|
|
|
|
'--tw-ring-color': 'var(--accent-ember)',
|
|
|
|
|
'--tw-ring-offset-color': 'var(--bg-secondary)'
|
|
|
|
|
}}
|
|
|
|
|
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
|
|
|
|
|
title={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
|
|
|
|
|
>
|
|
|
|
|
{theme === 'light' ? (
|
|
|
|
|
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
|
|
|
|
</svg>
|
|
|
|
|
) : (
|
|
|
|
|
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
|
|
|
|
</svg>
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2025-07-25 12:28:04 -04:00
|
|
|
{/* Main Content */}
|
2025-08-01 19:18:21 -04:00
|
|
|
<div className="flex-1 flex flex-col pb-16 md:pb-0">
|
2025-07-25 12:28:04 -04:00
|
|
|
{/* Top Header - Only show search on dashboard */}
|
|
|
|
|
{showSearch && (
|
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
|
|
|
<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)',
|
|
|
|
|
}}
|
|
|
|
|
>
|
2025-07-25 12:28:04 -04:00
|
|
|
<div className="flex items-center">
|
|
|
|
|
<div className="flex-1 max-w-2xl">
|
|
|
|
|
<div className="relative">
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
2026-05-29 10:53:40 -04:00
|
|
|
placeholder="Search cards, decks, or lists..."
|
2025-07-25 12:28:04 -04:00
|
|
|
className="search-bar pr-12"
|
|
|
|
|
value={searchQuery}
|
|
|
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
|
|
|
/>
|
|
|
|
|
<div className="absolute inset-y-0 right-0 flex items-center pr-4">
|
|
|
|
|
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}>⌘F</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="absolute inset-y-0 left-0 flex items-center pl-4">
|
|
|
|
|
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" style={{ color: 'var(--text-secondary)' }}>
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
|
|
|
</svg>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</header>
|
|
|
|
|
)}
|
2025-07-23 22:26:54 -04:00
|
|
|
|
|
|
|
|
{/* Page Content */}
|
|
|
|
|
<main className="flex-1 overflow-auto">
|
|
|
|
|
{children}
|
|
|
|
|
</main>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
2025-08-01 19:18:21 -04:00
|
|
|
}
|