fix(shell): give the top bar a measured fit at every width

The bar was 611px inside a 600px viewport at the bottom of the Compact
band, and 862px while a scan with a real library's title ran, because
`job-indicator` is `hidden` when idle and 235px wide when it is not.
`body` is `overflow-x: auto`, so a user got a horizontal scrollbar on a
shell #24 promised would not need one — and the band is 600 to 899 with
work in flight, not the 600 to 610 the idle measurement suggested.

`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. Measured rather than breakpointed
because three of the five children are as wide as their content — the
library filter 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.

What yields is decided by #24's own sentence, which rules out the two
cheapest candidates in the Direction. Hiding the library filter takes
away an action, since it is the only control in the app that selects a
library (filed as #148, which is the phone already doing it), and
collapsing search to an icon is #57's, which is blocked behind #62. So
the wordmark yields first — a brand the window title bar repeats, and
visually-hidden rather than `display: none` because that h1 is the
document's heading — and then the indicator's label, leaving the ring,
which the component already does below 600px and whose live region
announces the state either way.

"Fits" is the children against the content box, not `scrollWidth`
against `clientWidth`: `scrollWidth` counts the left padding and not
the right, so the first version read 700/700 with the indicator sitting
in the whole right gutter. And the bar does not resize when a job
starts, which is the case this is for, so every child is observed too.

Pinned before it was fixed, as the issue asks. On the unfixed build the
new spec fails at 600 idle and at 600, 800 and 900 with a job, and
passes at 390, 899 and 1440; `layout-overflow.spec.ts` gains 600x600
and failed there. That spec asserts on the *shell*, so it was green
throughout this defect — the per-child measurement is #69's lesson, and
it is what caught the gutter case above.

Closes #143
This commit is contained in:
2026-08-19 21:35:03 -04:00
parent 6e7e349e63
commit ae85df0dad
6 changed files with 499 additions and 10 deletions
+33 -9
View File
@@ -33,6 +33,14 @@ const VIEWPORTS = [
// was missing its own worst case.
{ name: '900×600 (the widest sidebar, so the narrowest content)', width: 900, height: 600 },
{ name: `the minimum (${MIN_VIEWPORT.width}×${MIN_VIEWPORT.height})`, ...MIN_VIEWPORT },
// Below the enforced minimum on purpose, and for the reason 700×480
// is below it further down: a scaled display or a large system font
// lands the layout here without the window ever being dragged there,
// and 600 is the last width before the phone layout takes over. The
// *narrowest header* is a different question from the narrowest
// content area and has a different answer — this one (#143), where
// the bar was 611px inside 600 sitting still.
{ name: '600×600 (the bottom of the Compact band)', width: 600, height: 600 },
];
/**
@@ -145,6 +153,12 @@ test.describe('the app fits in its own window', () => {
test.describe('the title block fits its bar', () => {
test('the hgroup stays inside the 4em top bar', async ({ app }) => {
// Stated rather than inherited from whatever ran last. Since #143
// the wordmark is visually hidden at widths where the bar cannot
// afford it, so a test about its *vertical* fit has to say which
// width it is asking about.
await app.setViewportSize({ width: 1440, height: 900 });
// The state a11y.29 landed in. The pair is flex-centred and a UA
// gives an `h1` a 0.67em top margin, so the block measured 67px
// inside 64 — pre-existing, and invisible until dropping the h3's
@@ -246,16 +260,26 @@ test.describe('the shell reflows rather than hiding what does not fit', () => {
test(`no scrollbar appears at ${vp.name}`, async ({ app }) => {
await app.setViewportSize({ width: vp.width, height: vp.height });
const excess = await app.evaluate(() => {
const de = document.documentElement;
// Polled, for the reason the track-row test above is: since #143
// the top bar's fit is *measured* — a ResizeObserver decides what
// it can afford at this width — so a single read taken straight
// after the resize races the observer and reports the frame
// before it. Read once, this passed alone and failed in the full
// suite, which is the shape of a timing assumption rather than of
// a defect.
//
// The other half of the assertion: at every size this app
// promises, the fix costs nothing. A scrollbar that is always
// there is a worse answer than the clipping it replaced.
await expect
.poll(() =>
app.evaluate(() => {
const de = document.documentElement;
return de.scrollWidth - de.clientWidth;
});
// The other half: at every size this app promises, the fix costs
// nothing. A scrollbar that is always there is a worse answer
// than the clipping it replaced.
expect(excess).toBe(0);
return de.scrollWidth - de.clientWidth;
}),
)
.toBe(0);
});
}
});