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>
18 KiB
| convoy | brief_number | depends_on | files | cross_brief_commitments | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| fix-layout-default-user | 1 |
|
|
Brief 1: Default Layout's user to null + render a logged-out branch + lock the contract with a vitest test
Goal (1 sentence)
Change components/Layout.js's user default from { email: 'me@randallstillwell.com', role: 'user' } to null, replace UserProfileDropdown's avatar+email+menu with a "Sign in" link to /login when user === null, and add a vitest test under test/components/Layout.test.js that locks in the contract by asserting the rendered tree never contains me@randallstillwell.com for the no-user / null-user branches.
Files in scope (do not edit anything else)
components/Layout.js— modified (default-prop fix + logged-outUserProfileDropdownbranch).test/components/Layout.test.js— new (regression test).package.json— modified (addjsdomand@testing-library/reacttodevDependencies).package-lock.json— regenerated bynpm install.
Conventions to follow
.cursor/rules/ui-and-theming.mdc§ Component conventions. Already documents the intent: "Avoid hardcoded default values foruserprops. … New components must default tonulland render a logged-out state." This brief is the first concrete application of that rule..cursor/rules/auth-and-permissions.mdc§ Authentication state on the client.useAuth()returns{ user, loading, … }whereuser === nullmeans logged out. Layout's null-user rendering must be safe for that case; do not add new logic that throws onuser === null..cursor/rules/no-go-zones.mdc. Do not editcomponents/Layout.js.backup. Do not edit anything underlib/**or.github/**. Do not touch.cursor/rules/**.- Brief size discipline. This brief is one component change, one new test, two devDeps. Do NOT:
- Split the Layout god-component (
god-component-splitconvoy owns that). - Migrate Layout off
lib/auth-context.js/lib/admin-auth.js(Layout doesn't import either today; both legacy providers are queued forsingle-auth-provider). - Rename "Deck Hearth" or "DH" to "TCG Vault" (
pick-a-nameconvoy). - Remove the dead
userprop onMobileNavigation(deferred follow-up, see convoy file § "Anything flagged but not acted on"). - Touch
components/MobileNavigation.jsat all.
- Split the Layout god-component (
- Vitest test patterns. Match
test/lib/permission-middleware.test.js's shape:describeblock per behavior cluster,vi.mock(...)for module dependencies, plainexpect()matchers (no jest-dom required). The new test uses a per-file// @vitest-environment jsdomdirective at the top (vitest v3 supports this) sovitest.config.js's globalenvironment: 'node'does not need to change.
Acceptance criteria
components/Layout.js
- Change the default-prop on line 562. Before:
export default function Layout({ children, user = { email: 'me@randallstillwell.com', role: 'user' }, showSearch = false }) {
After:
export default function Layout({ children, user = null, showSearch = false }) {
No other change to that line.
- Add a logged-out branch to
UserProfileDropdown. The branch MUST be placed after the existinguseState(false)call (rules of hooks: hooks must be called in the same order every render — moving the early return aboveuseStatewould throw "Rendered more hooks than during the previous render" the momentuserflips fromnullto an object on a subsequent render). Verbatim shape — the implementer MAY adjust class names to match neighboring sidebar items, but every prop / behavior must be present:
function UserProfileDropdown({ user, onMobileMenuClose }) {
// 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).
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
// 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>
);
}
// Existing dropdown body (profileMenuItems, getProfileIcon, JSX) —
// unchanged below this line.
}
- The
useStatestays where it is today (currently line 9); the early return is inserted betweenuseStateand the existingprofileMenuItemsdeclaration.setIsDropdownOpenis unused on the null branch; that's fine — React does not warn on unused state setters, and the variable is declared because the hook MUST run. - The
<Link href="/login">is non-negotiable — the test intest/components/Layout.test.jsqueriesgetByRole('link', { name: /sign in/i })and asserts every match hashref="/login". onClick={onMobileMenuClose}keeps the mobile drawer behavior consistent with the existing items (the dropdown's existing items also call this on click — see line 73-74 of the current file).- The SVG is a "log-in" / "arrow-into-box" glyph (mirror of the existing logout SVG at line 39-41). Implementer MAY substitute another inline SVG so long as it is wrapped in
aria-hidden="true"and the visible label is exactly "Sign in" (the test does a case-insensitive/sign in/imatch — "Sign In" / "Sign in" / "SIGN IN" all work, but "Login" / "Log in" would fail the assertion). - Lint check. Run
npm run lint -- components/Layout.jsafter the change — confirm zero newreact-hooks/rules-of-hooksviolations. Pre-existing lint baseline issues (perAGENTS.mdGotcha #11.5) may be present elsewhere in the file butreact-hooks/rules-of-hooksshould not regress inUserProfileDropdown. - Do not modify
NavigationContent(lines 127-560). ItsauthenticatedNavigation,myCollectionNavigation,adminNavigationalready gate onusercorrectly; the public + community sections render unconditionally and are correct for both logged-out and logged-in states. - Do not modify the Layout body (lines 568-793) — the desktop sidebar, mobile drawer, mobile overlay, and main-content wrapper are all unchanged. They pass
user(now potentiallynull) toMobileNavigation,NavigationContent, andUserProfileDropdown; each of those handlesnullcorrectly after this brief. - Do not touch the
'me@randallstillwell.com'literal anywhere except the line 562 default — there are no other references incomponents/Layout.js(verified byrg "me@randallstillwell" components/Layout.jsreturning a single hit before this brief). - Branding. Lines 593-596 ("DH" / "Deck Hearth" mobile drawer header) and 686-689 (desktop sidebar header) stay unchanged.
pick-a-nameowns branding.
test/components/Layout.test.js (new)
- Create the directory
test/components/if it does not exist (it does not as of this brief). The vitest globinclude: ['test/**/*.test.js'](seevitest.config.jsline 8) automatically picks the new file up. - Set the per-file environment with
// @vitest-environment jsdomas the first line. Do NOT modifyvitest.config.js's globalenvironment: 'node'— other tests (auth, permission-middleware) run in node and changing the default would force every test through jsdom unnecessarily. - Verbatim shape — the implementer MAY tighten queries, but every assertion must be present and the file must run green:
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, cleanup, screen } from '@testing-library/react';
// Mock next/router so useRouter() does not crash without a RouterContext.
// Layout reads `router.pathname` only; the rest of the surface (`prefetch`,
// `events`, `push`) is for next/link's internals — provide stubs so prefetch
// does not throw when <Link> mounts.
vi.mock('next/router', () => ({
useRouter: () => ({
pathname: '/',
asPath: '/',
query: {},
push: vi.fn(),
replace: vi.fn(),
prefetch: vi.fn().mockResolvedValue(undefined),
events: { on: vi.fn(), off: vi.fn(), emit: vi.fn() },
}),
}));
// Mock next/link to a plain <a>. The real next/link triggers prefetch on
// mount via the router; bypassing it removes a class of jsdom flake without
// changing the rendered DOM that the assertions inspect.
vi.mock('next/link', () => ({
__esModule: true,
default: ({ href, children, ...rest }) => {
// children may be a single element (e.g. a <div>) or text; wrap in <a>.
return (
<a href={typeof href === 'string' ? href : ''} {...rest}>
{children}
</a>
);
},
}));
// Mock the theme context so useTheme() does not require a ThemeProvider.
vi.mock('../../lib/theme-context', () => ({
useTheme: () => ({ theme: 'light', toggleTheme: vi.fn() }),
}));
import Layout from '../../components/Layout';
describe('Layout — logged-out rendering (regression: P0 #7)', () => {
afterEach(() => cleanup());
it('does NOT render the maintainer email when no user prop is passed', () => {
const { container } = render(<Layout>page body</Layout>);
expect(container.textContent).not.toContain('me@randallstillwell.com');
});
it('does NOT render the maintainer email when user is null', () => {
const { container } = render(<Layout user={null}>page body</Layout>);
expect(container.textContent).not.toContain('me@randallstillwell.com');
});
it('renders a Sign-in link to /login when user is null', () => {
render(<Layout user={null}>page body</Layout>);
const links = screen.getAllByRole('link', { name: /sign in/i });
expect(links.length).toBeGreaterThanOrEqual(1);
// Both desktop sidebar + mobile drawer render UserProfileDropdown,
// so we expect TWO Sign-in links (one per copy).
for (const link of links) {
expect(link.getAttribute('href')).toBe('/login');
}
});
it('renders the supplied user email when user is an object', () => {
const { container } = render(
<Layout user={{ email: 'foo@bar.com', role: 'user' }}>page body</Layout>
);
expect(container.textContent).toContain('foo@bar.com');
expect(container.textContent).not.toContain('me@randallstillwell.com');
});
it('does NOT render a "Guest" placeholder when logged out', () => {
// Decision A says the logged-out copy is "Sign in", not "Guest".
// This test prevents a future revert that ships "Guest" as the default
// (which would still hide the maintainer email but skip the CTA).
const { container } = render(<Layout user={null}>page body</Layout>);
expect(container.textContent).not.toContain('Guest');
});
});
- All five tests pass under
npm run test:run. - The pre-existing 24 tests (16 in
permission-middleware.test.js, 3 inauth-secret.test.js, 5 inauth-utils.test.js) remain green. This brief does not touch any file they cover, so the only failure mode is a CI environment regression — investigate and fix before merging.
package.json + package-lock.json
- Add to
devDependencies— single command:npm install --save-dev jsdom @testing-library/react. Resolved versions at architect time (2026-05-23):jsdom@29.1.1,@testing-library/react@16.3.2. Caret ranges are fine (matches the existing devDep style —vitest@^3.2.4). - Verify
@testing-library/domis present.@testing-library/react@16declares@testing-library/dom@^10.0.0as a peer dependency (verified bynpm view @testing-library/react peerDependencies). npm 7+ auto-installs peers, so a singlenpm install --save-dev jsdom @testing-library/reactshould resolve it transitively. After running install, runnpm ls @testing-library/domand confirm a single^10.xentry appears. If it does NOT (npm version too old or peer-install was disabled), explicitly add it:npm install --save-dev @testing-library/dom@^10.0.0. - Do NOT add
@testing-library/jest-dom. The test uses plainexpect().toContain()/toBeGreaterThanOrEqual()matchers — no jest-dom matchers needed. Adding it would expand the dep surface for no acceptance-criteria benefit. - Do NOT add
@types/react. The repo is JavaScript-only (AGENTS.mdGotcha #9); the React 18/19 type peer on@testing-library/reactis irrelevant when there is notsconfig.json. npm will warn-and-skip the type peers, which is the documented behavior — leave the warning alone. - Do NOT bump any other dep while you have
package.jsonopen. Nonextbump, novitestbump, no react bump. If npm hoists a transitive minor that flips a lockfile entry, that's fine; if it tries to bump a top-level dep, stop and ask. - Verify
package-lock.jsonwas regenerated and committed (thenpm installrun produces the lockfile diff; commit it as part of the same change).
vitest.config.js
- No change. The new test file uses
// @vitest-environment jsdomper-file. The globalenvironment: 'node'(line 5) stays so the existing 24 tests do not slow down by going through jsdom unnecessarily. - (Sanity check, not a code change.) Confirm the existing
include: ['test/**/*.test.js']glob (line 8) catchestest/components/Layout.test.js. It does — the test file matches the recursive glob.
Smoke (manual, in addition to the vitest run)
Run these in order; paste the relevant output / screenshots into the PR description:
- Logged-out smoke. With no
auth_tokeninlocalStorage(DevTools → Application → Local Storage → clearauth_token),npm run devand visithttp://localhost:3000/dashboard(or any page that renders Layout). Expect:- The desktop sidebar bottom shows "Sign in" with a small icon, in place of the avatar + email + dropdown chevron.
- Clicking "Sign in" routes to
/login. - The string
me@randallstillwell.comdoes NOT appear anywhere on the page. (Open DevTools → Console → rundocument.body.innerText.includes('me@randallstillwell.com')→ expectfalse.) - The "My Collection", admin, and authenticated-only nav items are hidden.
- Public + community nav items (Cards, Scanner, Deck Builder placeholder, Community section) are still visible.
- Logged-in smoke. Log in as a non-admin (
admin@tcgvault.comworks since the seed admin hasrole='admin'— use a non-admin signup or temporarily flip the row). Visit/dashboard. Expect:- The desktop sidebar bottom shows the real user's email + role.
- Clicking the avatar opens the dropdown with Profile / Settings / Logout (no Admin Panel for non-admin).
- Mobile drawer smoke. Resize Chrome DevTools to a phone preset (iPhone 13). Open the mobile drawer ("More" tap). Expect: the same Sign-in / authenticated states render in the drawer's bottom section as in the desktop sidebar.
- Lint.
npm run lintexits 0 (or matches the existing pre-PR baseline — pre-existing lint errors are fine; do not introduce new ones). - Tests.
npm run test:runis green: 5 new + 24 pre-existing = 29 tests pass.
Out of scope (do not do these)
- No edits to any of the 17 pages — that's Brief 2.
- No edits to
components/MobileNavigation.js— its deaduserprop is a deferred cleanup. - No edits to
lib/use-auth.js,lib/auth-context.js,lib/admin-auth.js,lib/permission-middleware.js, orlib/auth-secret.js. The auth surface is downstream of this brief. - No edits to
vitest.config.js(the per-file// @vitest-environment jsdomdirective is the entire mechanism). - No new vitest tests beyond
test/components/Layout.test.js. Brief 2 does not add tests either (Decision D = D2 covers the contract at the component boundary). - No
.cursor/rules/*.mdcupdates. The rule (ui-and-theming.mdc§ Component conventions) already documents the intent. - No
AGENTS.mdGotcha #8 update — that's the post-convoy doc-writer pass. - No
README.mdupdate. - No
CHANGELOG.md(none exists yet —adopt-keep-a-changelogis a separate convoy).
Rationale (≤3 sentences)
The Layout default is the actual P0 #7 bug; this brief fixes it at the source and locks in the contract with a regression test that asserts the maintainer email never reappears for the null-user branches. Splitting the page audit into Brief 2 keeps the conceptually interesting change (Layout + logged-out branch + test harness) reviewable on its own; the page edits are mechanical and benefit from being grouped separately. Adding jsdom + @testing-library/react as devDeps is the smallest harness that lets vitest exercise React rendering — the tools are common, the surface is two packages, and the value (preventing a recurrence of a default-prop email leak) is high.