Skip to content

Put the way back where it belongs, and keep it on screen - #43

Merged
takashi-matsuyama merged 1 commit into
mainfrom
fix/header-sticky
Aug 11, 2026
Merged

takashi-matsuyama merged 1 commit into
mainfrom
fix/header-sticky

Conversation

@takashi-matsuyama

Copy link
Copy Markdown
Member

An operator could not stow the database list. Three things were wrong, and each
one hid the next.

The word. The control said Databases, which is what the list holds, not
what the control moves - the databases are all still there afterwards, still
searched across, with one of them still selected. What folds is the list. The
README had this right and the button did not: "Databases in the header stows
the rail" names the object, and the object was never on the button. title
and aria-label now say "Hide the database list" / "Show the database list", and
the icon carries the rest.

The place. It sat beside Settings and + Add database, which act on data
and on configuration; this one only changes the layout, so a third noun in that
row read as a third way to open something. It now sits in the shell's own left
margin, level with All databases, against the window edge - beside the thing
it moves rather than beside things it has nothing to do with. left: -1.5rem is
main's own padding, so the row beside it keeps every pixel it had: measured at
1280, the map still goes 529 -> 751 when the list is stowed, exactly as before.

The reach. Nothing was pinned, and this layout accepts the page scrolling -
.split's floor comment documents it as the degradation rather than a failure.
Measured with the list showing, a 600px window scrolls 203px, a 520px one with
Settings open 394px, a 440px one 363px, and the control went to top -89, -169 and
-249: off screen, not hit-testable, and there is no second way to fold.

The pinning is where this got interesting, and three attempts were wrong first.

position: sticky cannot state it. A sticky box is clamped to its containing
block and every box in this shell is capped at 100dvh by the load-bearing
height on main, so it holds for about a viewport of spill and then rides away
with the page. That is not a tuning error: measured with the shell made its own
scroll container instead of the page, to see whether that was the cause, the
control lands at the same 38 / 30 / -10 / -34 either way. The app's own default
1280x840 window reproduces it after two presses of View > Zoom In, which Electron
ships by default.

Placing it from script in every state was worse. It put the resting position in
the hands of a dependency list, and the list was wrong immediately: measured, the
icon sat at 68 while its anchor was at 113, 225 and 192. A ResizeObserver added
to cover that earned nothing - identical numbers with and without it, because
main is a fixed height and document.body never resizes. Both are gone.

The third was capturing the escaped x. It was the one non-reactive quantity in an
otherwise reactive design: read once at the escape and never corrected, so a
horizontal scroll at that moment pinned the control off the window and it did not
come back. This shell does overflow sideways below about a 380px viewport and the
window has no minimum, so it is a drag away. Measured at 360x500: scroll right
21px, scroll down to escape, scroll back left, and the control stayed at x -21
with nothing reachable at its centre. There is no captured x now - fixed
positioning does not move with a scroll, and left: 0 is where that column
already is.

So the control rests in the flow, in the margin, and CSS aligns it with the row
in every state that does not scroll - Settings open, the add form open, an error
line up. Measured across those: 113/113, 225/225, 113/113, 192/192. Script decides
one thing only, the state CSS cannot express: when the row's own top passes above
the pinned header, the control leaves the flow and holds at that line. The header
measures itself rather than being a constant, because it wraps on a narrow window

  • 53px down to a 490px viewport, 69px from 480px down.

Checked by clicking, at the eight sizes that defeated the sticky version:
1280x840, 1280x600, 1280x520 with Settings, 1280x460 with Settings, 1280x440,
640x500 with Settings, and the default window at zoom 2x and 3x. Scrolled to the
end in each (203 to 997px of scroll), the control holds and a raw mouse click at
its centre folds the rail. With the escape disabled, seven of the eight go off
screen and the same click does nothing.

The guard runs at three sizes, one with Settings open and one with the list
stowed, and both halves of that were learned the hard way. The first version ran
at 600 alone - the one size where the sticky control still held - so it was green
while the bug it existed to prevent was live at 520 and 440. The second ran every
case with the list showing, which is the state where this control is one of
several ways to the same place; stowed, it is the only way back, and a change that
killed the escape only in that state passed all three sizes. The stowed case now
clicks, at the control's own centre from the scrolled position, and the list has
to return. It asserts reachability rather than visibility: toBeVisible() passes
for an element below the fold, which is the trap the map pane's guard 150 lines
above was written for.


Verification

typecheck / lint / test:unit (347) / test:e2e (9) green after pnpm build. macos-e2e runs only on push to main, so that surface is verified after merge, not here.

Adversarial review

One round before push (READ-ONLY, review-only, design-challenge list forced). Verdict needs-attention; it found a real bug, and everything it flagged as a defect is fixed in this commit:

  1. The captured escaped-x — non-reactive, unrecoverable after a horizontal scroll. Reproduced independently at 360x500 before fixing it. Gone: no captured coordinate, left: 0.
  2. The guard never exercised the stowed state, and a mutant that killed the escape only there passed all three sizes. Closed: one case is stowed and clicks. Verified with that mutant's shape (escaped = !railFolded && …) — the guard goes red on reachability.
  3. Three false numbers in my own comments. The header pair (53 at 460 / 68 at 400) was read with the add form open, which shortens its own button and takes a line out of the header; re-measured with it closed, 53px down to 490 and 69px from 480. The 472px spill bound is exceeded at zoom 1. "Held at the header's underside" is only true while the header is itself pinned. All three corrected.

Design-challenge candidates: 6, all blocked, none applied — they are the operator's calls, recorded here rather than acted on:

  • Set a minWidth on the window and retire the sideways-overflow class entirely.
  • Anchor the escape to the header for real (an unbounded header pin) instead of to a viewport line.
  • Restore the colour treatment the stowed state used to carry, now that the word is gone too.
  • Widen the guard's shape further: mid-scroll offsets as well as the extreme.
  • a11y wiring: aria-controls alongside aria-expanded, and drop one of title/aria-label (some readers announce both).
  • The 24px control in a 24px margin leaves no room for a focus ring or hover slop.

The review also attacked and cleared: no $effect loop, exact non-oscillating escape boundary (1px either side, both directions), correct behaviour through resize and zoom while escaped, no occlusion at any width or zoom, and elementFromPoint faithfully predicting real clicks. It confirmed 529→751, the resting-alignment invariant, −89/−169/−249, 203/394/363, and the two-press zoom repro.

An operator could not stow the database list. Three things were wrong, and each
one hid the next.

**The word.** The control said `Databases`, which is what the list holds, not
what the control moves - the databases are all still there afterwards, still
searched across, with one of them still selected. What folds is the list. The
README had this right and the button did not: "**Databases** in the header stows
**the rail**" names the object, and the object was never on the button. `title`
and `aria-label` now say "Hide the database list" / "Show the database list", and
the icon carries the rest.

**The place.** It sat beside `Settings` and `+ Add database`, which act on data
and on configuration; this one only changes the layout, so a third noun in that
row read as a third way to open something. It now sits in the shell's own left
margin, level with **All databases**, against the window edge - beside the thing
it moves rather than beside things it has nothing to do with. `left: -1.5rem` is
`main`'s own padding, so the row beside it keeps every pixel it had: measured at
1280, the map still goes 529 -> 751 when the list is stowed, exactly as before.

**The reach.** Nothing was pinned, and this layout accepts the page scrolling -
`.split`'s floor comment documents it as the degradation rather than a failure.
Measured with the list showing, a 600px window scrolls 203px, a 520px one with
Settings open 394px, a 440px one 363px, and the control went to top -89, -169 and
-249: off screen, not hit-testable, and there is no second way to fold.

The pinning is where this got interesting, and three attempts were wrong first.

`position: sticky` cannot state it. A sticky box is clamped to its containing
block and every box in this shell is capped at `100dvh` by the load-bearing
`height` on `main`, so it holds for about a viewport of spill and then rides away
with the page. That is not a tuning error: measured with the shell made its own
scroll container instead of the page, to see whether that was the cause, the
control lands at the same 38 / 30 / -10 / -34 either way. The app's own default
1280x840 window reproduces it after two presses of View > Zoom In, which Electron
ships by default.

Placing it from script in every state was worse. It put the resting position in
the hands of a dependency list, and the list was wrong immediately: measured, the
icon sat at 68 while its anchor was at 113, 225 and 192. A `ResizeObserver` added
to cover that earned nothing - identical numbers with and without it, because
`main` is a fixed height and `document.body` never resizes. Both are gone.

The third was capturing the escaped x. It was the one non-reactive quantity in an
otherwise reactive design: read once at the escape and never corrected, so a
horizontal scroll at that moment pinned the control off the window and it did not
come back. This shell does overflow sideways below about a 380px viewport and the
window has no minimum, so it is a drag away. Measured at 360x500: scroll right
21px, scroll down to escape, scroll back left, and the control stayed at x -21
with nothing reachable at its centre. There is no captured x now - fixed
positioning does not move with a scroll, and `left: 0` is where that column
already is.

So the control rests in the flow, in the margin, and CSS aligns it with the row
in every state that does not scroll - Settings open, the add form open, an error
line up. Measured across those: 113/113, 225/225, 113/113, 192/192. Script decides
one thing only, the state CSS cannot express: when the row's own top passes above
the pinned header, the control leaves the flow and holds at that line. The header
measures itself rather than being a constant, because it wraps on a narrow window
- 53px down to a 490px viewport, 69px from 480px down.

Checked by clicking, at the eight sizes that defeated the sticky version:
1280x840, 1280x600, 1280x520 with Settings, 1280x460 with Settings, 1280x440,
640x500 with Settings, and the default window at zoom 2x and 3x. Scrolled to the
end in each (203 to 997px of scroll), the control holds and a raw mouse click at
its centre folds the rail. With the escape disabled, seven of the eight go off
screen and the same click does nothing.

The guard runs at three sizes, one with Settings open and one with the list
stowed, and both halves of that were learned the hard way. The first version ran
at 600 alone - the one size where the sticky control still held - so it was green
while the bug it existed to prevent was live at 520 and 440. The second ran every
case with the list showing, which is the state where this control is one of
several ways to the same place; stowed, it is the only way back, and a change that
killed the escape only in that state passed all three sizes. The stowed case now
clicks, at the control's own centre from the scrolled position, and the list has
to return. It asserts reachability rather than visibility: `toBeVisible()` passes
for an element below the fold, which is the trap the map pane's guard 150 lines
above was written for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@takashi-matsuyama
takashi-matsuyama merged commit dfb6b43 into main Aug 11, 2026
2 checks passed
@takashi-matsuyama
takashi-matsuyama deleted the fix/header-sticky branch August 11, 2026 09:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant