September 15, 2026
A variable that looked in scope, and wasn't
Part 1: the sign-out button that only sometimes existed
The report
A founder came back to /dashboard/ after their session had naturally expired — the
dashboard deliberately issues a 24-hour session (FOUNDER_SESSION_TTL_S, shorter than the
7-day Certified-voter session, because this one gates money-moving actions) — and saw an
empty "Your cards" list. Not an error. Not a sign-in prompt. Just... nothing, as if they'd
never had a listing.
That's a worse failure than an error message. An error says "something's wrong, here's what to do." An empty list says "you have nothing," which for a founder who just paid to be on Kingpin is a small, specific kind of alarming.
The fix mandate was direct: if the session is dead, say so and bounce back to sign-in. Not silently render nothing.
The fix, and the bug it introduced
getFounderListings() in web/lib/api.js already distinguishes a session-dead 401 from any
other failure (/founder/listings takes no listing id, so every 401 on it is unambiguous —
unlike /refuel or /media, there's no "valid session, wrong resource" 401 to confuse it
with). Wiring that into the UI meant: catch that 401, call the dashboard's existing
signOut(), done.
The actual edit was one line inside web/app/dashboard/page.js:
<MyListings onSessionExpired={() => signOut('Your session expired — sign in again.')} />
This compiled. It passed lint. It looked correct sitting next to the rest of the dashboard's
JSX. And it threw ReferenceError: signOut is not defined the instant a real browser tried
to run it — but only on the one path that actually exercises it (an expired session), so a
normal signed-in load never touched the broken line at all.
Why "it's right there" was wrong
web/app/dashboard/page.js has several top-level function components in one file:
Dashboard() (where signOut is actually defined, around line 129), CardLookup(), and
MyListings(). In the rendered page, <MyListings> visually sits "inside" the dashboard.
In the source, Dashboard() doesn't render <MyListings> directly — it renders
<CardLookup session={session} />, and CardLookup is the one that renders <MyListings>.
Dashboard() <- signOut() is defined HERE
└─ <CardLookup> <- a SEPARATE top-level function. No access to Dashboard's locals.
└─ <MyListings> <- and this is a THIRD one, nested inside CardLookup's own return
signOut is a plain local variable of Dashboard. React component nesting in JSX has
nothing to do with JavaScript closure scope — a function component only sees what's in
its own closure (module scope, its own body) plus whatever arrives as props. CardLookup
never captured signOut, because CardLookup is not defined inside Dashboard's function
body; it's a sibling declaration in the same file. The visual nesting in the returned JSX is
a red herring — it tells you about the DOM tree, not the JavaScript scope chain.
This is an easy mistake precisely because it reads right. signOut is right there, a few
dozen lines up, doing exactly the job you want. Nothing about the code around the call site
signals "this identifier is not actually reachable from here."
How it was actually found
Not by reading the code again — a second read of code that looks obviously correct rarely catches its own mistake. By running it:
- Wrote the failing-session e2e test first (
web/e2e/specs/dashboard.spec.js, "an expired session auto-signs-out instead of silently showing 'no listings'"), matching themock.jsrouter'sapi.fail('founderListings', 401, ...)pattern already used elsewhere in the suite. - Ran it. It failed — not with an assertion mismatch, but a timeout waiting for the sign-in form to reappear. A timeout tells you "nothing happened," not "why."
- Added Playwright's
page.on('pageerror', ...)fixture temporarily (aconsoleErrorsarray pushed to on every uncaught page error) and printed it. That's what surfaced the exact line:"pageerror: signOut is not defined". - Confirmed the scope story by mapping every top-level function declaration in the file
(
grep -n "^function ") rather than trusting indentation — indentation in a return statement tells you nothing about whichfunctionkeyword actually owns a variable.
The fix
Thread onSessionExpired explicitly as a prop through every layer that needs it, instead of
reaching for a variable that "should" be in scope:
Dashboard() defines signOut, passes onSessionExpired={() => signOut(...)} to CardLookup
CardLookup receives onSessionExpired as a prop, forwards it unchanged to MyListings
MyListings calls onSessionExpired?.() — never touches signOut directly
The generalisable lesson
In a component tree, "visually nested" and "in scope" are different graphs. JSX indentation shows you the render tree. Scope is decided by where a
functionkeyword's body literally begins and ends in the source file. When one top-level component's JSX contains another top-level component's tag, nothing written by the first is reachable inside the second except through explicit props.
A cheap habit that would have caught this before running anything: before referencing a
name inside a component, ask "whose function { ... } am I physically inside of right now,"
not "what does this render next to."
Part 2: the card with a hole in the middle
The report
A screenshot: the Arena ladder cards on /board at phone width had a large, dead patch of
empty vertical space between the climbing avatar and the row below it. "Product listing card
is not very nicely designed for mobile devices."
Root cause
Adding vote buttons to each row (this session's board-voting feature) meant adding a new row to the card's compact layout — call it row 4, sitting under the existing title/stat rows. That made the card taller. Nothing else about the card's code changed.
But the Climber avatar — the little circular monogram that visually races up the ladder —
is position: absolute with no explicit top. An absolutely-positioned element with no
inset value doesn't just float free; as a flex child, it still participates in the flex
container's cross-axis alignment (items-center on the row). Its resting position is
wherever the center of the row currently is.
Before the vote row existed, the row was short, so "centered in the row" and "centered on the title" were close enough to look intentional. Add a row underneath and the row's center moves down — but the visual anchor the eye expects (the title text) didn't move. The avatar drifted away from the thing it's supposed to be racing next to, leaving a gap that reads as "broken," even though every individual style rule was doing exactly what it was told to do.
The fix
Not a fix to the avatar's positioning — the actual fix was to not grow the card in the first
place. The vote count was previously a separate dl "votes" stat and the vote buttons were
a separate row; those got merged into one ArenaVoteControl (fire icon, live count, trash
icon, all inline, h-7 w-7 buttons) placed inside the row that already existed, with
flex-wrap so it only spills to a second line on the very narrowest phones instead of
unconditionally reserving the space.
The generalisable lesson
A layout bug that "makes no sense" from the changed code alone is often downstream of an element with an implicit anchor —
position: absolutewith no inset,flexalignment with no explicitalign-self, a CSS variable scoped to the wrong ancestor (see entry 001's footer). The element didn't change. The thing it was silently anchored to did.
Before adding a new row/column to any layout that has an absolutely-positioned or implicitly-aligned child, check what that child's position is actually relative to — and whether growing the container moves that reference point.