docs: record how the top bar decides what to drop
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m35s
CI / e2e (pull_request) Successful in 8m38s

The shell section already states the three size bands and the promise
that no action is unreachable at any of them; how the header chooses
what to give up belongs beside them, because the promise is what
decides it.

Two measured facts go to NOTES.md rather than here. `scrollWidth`
counts a box's left padding and not its right, so the obvious fit
predicate under-reports by a gutter and passed on a bar with a control
jammed against the window edge. And the overflow is 11px idle and 262px
while working, which is why the issue was filed twice with different
numbers — a seeded app that has finished scanning is idle by the time
you resize it.

Closes #143
This commit is contained in:
2026-08-19 21:35:12 -04:00
parent ae85df0dad
commit ead1354e4d
2 changed files with 115 additions and 0 deletions
+63
View File
@@ -3761,3 +3761,66 @@ the same run. Reproducing it locally is running the suite twice against
one `make dev-headless` — which is worth doing for any change that
leaves state behind, since it is the only place a cross-engine order
dependency shows up.
## `scrollWidth` counts the left padding and not the right (measured 2026-08-20)
The obvious predicate for "does this flex row fit" is
`el.scrollWidth <= el.clientWidth`, and on a box with symmetric gutters
it **under-reports by one gutter**. `scrollWidth` is the extent of the
scrollable content area, which includes `padding-left` and excludes
`padding-right`; `clientWidth` includes both. So a child may end up to
`padding-right` past where content is allowed to go while the box
reports a perfect fit.
Measured on the top bar (`padding: 0 2em`) at 700x600 with a long-titled
scan staged: `clientWidth 700`, `scrollWidth 700` — and
`job-indicator`'s right edge at 700 against a content edge of 668, i.e.
sitting in the whole right gutter. `#143`'s first fix passed its own
measurement and left the indicator visibly jammed against the window
edge.
The predicate `services/top-bar-fit.ts` uses instead is the one its
spec asserts: no in-flow child's rect outside the parent's *content*
box, both edges, with half a pixel of slack for fractional flex widths.
This is the same family as #69's title trap — the measurement easiest to
reach for is the one that cannot see the failure — and it is worth
knowing before writing the next one of these: **the fit test and the
assertion that proves it should be the same test.** It was found only
because `top-bar-fit.spec.ts` measures per child rather than asserting
on the container, which is exactly why #69 needed
`header-action-overflow.spec.ts`.
## The top bar's overflow is 11px idle and 262px while working (measured 2026-08-20)
#143 was filed as "11px at 600x600" and re-measured as 171. Both are the
same defect seen with different jobs running: `job-indicator` is
`hidden` when idle, ~144px wide showing "Scanning Music", and **235px**
showing a real library's scan title ("Scanning Music from the external
drive"), because the label is capped at 12rem and gets there.
Swept against the running app with that job staged, `header.top-bar`
client vs scroll:
| width | idle | with the long-titled scan |
|---|---|---|
| 320, 390, 599 | fits | fits (the phone rules drop the filter and the label) |
| 600 | 611 | **862** |
| 700 | fits | 862 |
| 800 | fits | 862 |
| 899 | fits | 899 (fits) |
| 900 | fits | 946 |
| 1100, 1440 | fits | fits |
Two things worth keeping. The band is **600610 idle and 600900 while
working**, so "a narrow corner" and "the header is crowded from 900
down" are both true and the difference is entirely what is in flight —
which is the case a seeded, settled app can never show you. And 899
fits while 900 does not, because `nav-history` appears at 900: the worst
width for the header is not the narrowest one, the same way 900 rather
than 800 is the worst width for the content area.
Staging it is `/__test/emit` with a `JobsChanged` snapshot; a job with
`state: "running"` never completes, so it stays up until an empty
snapshot is emitted, which is what makes an idle re-measurement look
like the fix not working.
+52
View File
@@ -1520,6 +1520,58 @@ three, *no action is ever unreachable at any supported size*. The bands
themselves already existed; what was new is that they are a promise and
that the queue panel is inside it.
**The top bar decides what it can afford, and what it gives up is never
an action.** Its five children do not fit at the bottom of the Compact
band: the bar was 611px inside a 600px viewport idle and **862px while
a scan ran**, because `job-indicator` is `hidden` when idle and 235px
wide showing a real library's scan title (#143). So `services/
top-bar-fit.ts` is `page-header`'s treatment one bar up — a
ResizeObserver, every pass starting from all-visible, hiding the
lowest-priority child until it fits.
Five things about it are load-bearing.
**It is measured rather than breakpointed for a reason specific to this
bar**: three of its five children are as wide as their *content* — the
library filter is a `<select>` sized by the longest library name, the
indicator by the running job's title, the search box by its view-scoped
placeholder — so any width picked is right for one library, one job and
one view. Swept with a long-titled scan staged, the bar overflowed at
**every** width from 600 to 899 *and* at 900 where `nav-history`
appears, while 899 fits; a breakpoint fixing "600 to 610" would have
fixed whichever case happened to be idle when it was measured.
**What yields is decided by the promise above, which rules out the two
cheapest answers.** Hiding the library filter takes away an action —
`library-filter` is the only control in the app that calls
`setSelectedLibrary` — so it trades this promise for the same promise
(#148 is the phone already doing that). Collapsing the search box to an
icon is what #57 wants and #57 is blocked behind #62, so building it
here is building it without the thing that blocks it. The two that
yield are the two that are **not** actions: the wordmark, which the
window's own title bar repeats and which #48 wants down to "YJ" at
every width anyway, and then the job indicator's *label*, leaving the
ring — which is not a new judgement, since the component already drops
it below 600px and its `sr-only` live region is what announces the
state either way.
**The wordmark yields its width, not its existence.** The collapsed
rule is visually-hidden rather than `display: none`, because that `h1`
is the document's top-level heading as well as the brand.
**"Fits" is the children against the content box, and `scrollWidth`
cannot express it.** `scrollWidth` counts a box's left padding and not
its right, so with 2em gutters it under-reports by 32px: the first fix
read `700/700` — a perfect fit — with the indicator sitting in the
whole right gutter. Same family as #69's title trap, and found only
because `top-bar-fit.spec.ts` measures **per child**, which is what
`layout-overflow.spec.ts` cannot do and why that spec was green
throughout the defect.
And **the bar does not resize when a job starts**, which is the case the
whole thing is for — a ResizeObserver on the header alone never fires,
so every element child is observed too.
**900 is the worst desktop width, not the 800×600 minimum.** The
sidebar collapses to icons *below* 900, so the main panel is 843px at
899 and 700px at 900 — the narrowest content area any desktop width