Skip to content

fix(web): un-combine the item tab strip below 560px + header slim groundwork (TASK-2329) - #1034

Draft
xarmian wants to merge 7 commits into
mainfrom
feat/item-header-slim
Draft

fix(web): un-combine the item tab strip below 560px + header slim groundwork (TASK-2329)#1034
xarmian wants to merge 7 commits into
mainfrom
feat/item-header-slim

Conversation

@xarmian

@xarmian xarmian commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

TASK-2329 of PLAN-2326. Five commits: the original header work, a revert of the part of it that was wrong, the actual mobile fix, a boundary re-derivation + Star gating, and a comment-accuracy pass.

Body rewritten. The first version of this description documented a 700px single-fold design that Dave's review of the running build rejected as unusable on phones. That design is reverted in 8c3e0c2b and replaced in e97d7425. Nothing below describes the old approach except where it is the "before" measurement.

What was wrong

Combining the tablist and the action group on one row is not survivable at phone widths, and no amount of folding fixes it. The four tab labels are a fixed 321px; even a hypothetical action group of nothing but (38px) needs 359px against the 342px a 390px phone has.

Measured on the widest common phone, 430px, on bd030124:

strip tabs need tabs shown clipped
430px phone 382px 321px 179px 2 of 4
390px phone 342px 321px 139px 3 of 4
360px phone 312px 321px 109px 3 of 4

…with scrollbar-width: none, so no hint the missing tabs existed.

8c3e0c2b — revert

  • Saved / Synced go back inside .meta-info, byte-identical to main. Relocating them into .strip-actions pushed 115px into a strip that was already overflowing and inflated the badges (39→48, 40→50) by escaping .meta-info's 0.8em.
  • .save-status:not(.visible) { display: none } reverted — measurably harmful. In a real 448px pane, toggling it moved the action group 87→143px and the tab scrollport 301→245px: a 56px reflow on every save cycle, under a hidden scrollbar.
  • .collab-state-label's sr-only fold, now dead — the query container is .tab-strip and the badge no longer lives inside it.
  • .meta-info { display: none } reverted for SEQUENCING, not because it was wrong. .collab-state-synced is SYNCED_BADGE_SELECTOR and five specs across four suites assert it visible. It returns once the badges have a new home.

Kept: the @media print { .title-row .item-ref { display: block } } reset — a latent-bug fix predating this PR, since main's print rule sets twelve properties and no display, so any screen-side display:none on the chip would silently drop the ref from the page-1 document header (BUG-626). Also the screen-side ref hide, the backlinks aria-label, the ⋯ Star row, and the capstone spec's strengthened assertions.

e97d7425 — the fix: un-combine below 560px

< 560       split — actions own a row, ABOVE the tabs
560 - 639   fold  — one row, badge icons → bare counts, star folds
<= 330      tab padding 11px → 8px (the last 24px needed at 312px)

Below 560 the star and the badge icons come back — the single <700 fold hid them on every phone and in the default-width docked pane, exactly the widths with the least need to economise once the actions have their own row.

Actions ABOVE the tabs (order: -1, visual only). .tab-strip owns the bottom divider and .pane-tab.on overlaps it by 1px via margin-bottom: -1px; keeping the tablist last preserves that — measured identical (offset 0) in both shapes. Actions-below would orphan the underline ~31px above the divider.

Two placement traps, both caught by measurement rather than reasoning

  1. flex-wrap must be in the base .tab-strip rule, not the @container block. A container query cannot style its own query container. Verified by mutation: moved inside, nowrap stays in force while flex: 0 0 100% still applies to .strip-actions, and .pane-tabs collapses to 0px with all four tabs clipped at every phone width. Both new tests go red on it.
  2. The <= 330 rule must sit after the base .pane-tab rule. Container queries add no specificity, so the selectors tie and source order decides. Written above it, the tabs kept scrollWidth: 321 inside a 312px strip and one tab stayed clipped.

Measured (worst case: both badges + quick actions + owner rights)

Desktop — must be unchanged, and is, to the pixel:

viewport rows header tabs underline
1440 / 1200 1 148.8px fit 0
1024 / 768 1 174.8px fit 0

Phone — tabs now fit where two to three were clipped:

width strip tabs (need / shown) before
360 312 297 / 297 ✓ 321 / 109, 3 clipped
390 342 321 / 321 ✓ 321 / 139, 3 clipped
412 364 321 / 321 ✓ 321 / 161, 3 clipped
430 382 321 / 321 ✓ 321 / 179, 2 clipped

Docked pane 360 / 448 / 600 → split; 720 (PANE_WIDTH_MAX, a 672px strip) → full, after the boundary re-derivation below.

Container-query proof: a 360px pane on a 1920px viewport splits, while the full page at that same viewport stays on one row. No media query can do that.

Cost: the strip goes 33px → 67px on phones, so the header grows ~34px there. That is the trade for four reachable tabs.

Also audited: .menu-anchor gained no overflow/contain/transform/filter/clip-path/will-change ancestor at any width (walked the computed styles); the desktop ⋯ panel and the 430/360 bottom sheets own all four corners by hit-test; the arrow-key roving walk still visits exactly the four tabs in a 360px pane. Chromium reports contain: none on .tab-strip despite container-type — expected, and deliberately not "fixed" with contain: layout, which does trap the sheet's position: fixed.

2c05297f — re-derive the boundary, gate the Star row

The 699 upper boundary was measuring the wrong thing. It was derived while Saved/Synced still sat in .strip-actions, worth 115px of the group. Re-measured post-salvage on the worst-case item:

action group width shared row needs
folded (icons off, star off) 191px 524px
full 274px 607px
full, counts inflated to an absurd 199/199 + 999 301px 634px

So 699 was folding the star and badge icons on the two widest surfaces that fold at all — a pane dragged to its 672px max, and a 1440px page with a pane docked at 678px — both with ~65px to spare. Boundary moves to 639 (full at 640+). The 560 split boundary is measurement-backed and unchanged: the folded row needs 524px, so it carries 36px of slack.

The Star row is now gated to exactly the fold band. It was unconditional, costing every user a permanent extra row. Now hidden by default and shown only by the same query that folds .star-btn — the affordance exists in exactly one place at every width, never zero and never two, and display: none keeps the hidden one out of the a11y tree. Verified at eight widths, both directions; the 390px case exercises the mobile BottomSheet, confirming the container query reaches into it.

Two more traps this turned up, neither visible by reading:

  1. .menu-anchor :global(.strip-star-row), not a bare :global(). MenuItem's own .mi { display: flex } carries that component's scope hash and is (0,2,0); a bare global class selector is (0,1,0) and loses. Measured: the row rendered display: flex at all eight widths — symmetry broken toward showing the star twice.
  2. A sub-pixel gap between the bands (Codex). Encoded as adjacent integers, a strip at 559.5px matched neither query and fell back to the full shared row. Container widths are not integers — the pane default is clamp(360px, 38%, 640px). Fixed by nesting the blocks (fold ⊃ split, with the split un-folding) and using .99 bounds. Verified by forcing the strip to 559.50 / 559.99 / 560.50 / 639.50 / 639.99 / 640.50: all six now correct, four were wrong before. Regression test added — it is the only one that produces fractional widths.

That is four specificity/order/precision traps in one small block of container queries. Container queries add no specificity, and their bounds are not integers; every rule here had to be checked against what it overrides.

Slow drag through the boundaries: 563 → 688px in 5px steps produced exactly three distinct states across 27 samples — split (strip 515, 67px tall, star shown) → fold at strip 560 (33px, star folded) → full at strip 640 (33px, star shown). Tabs fit in every sample. Deliberate, not glitchy.

Tests

pane-tab-strip-tiers.spec.ts rewritten — its old "strip stays ONE row" assertion contradicted this design, and its worst-case test asserted the mechanism ("every tab reachable via scroll") rather than the outcome, which stayed true and useless at a 94px scrollport with no scrollbar. Five tests now, all asserting outcomes: the container-driven split at a fixed viewport, the 360px worst case asserting tabs fit, the fold band, the sub-pixel boundary guard, and Star-affordance symmetry across four widths.

pane-full-page-capstone.spec.ts star assertions strengthened (DR-11): toBeEnabled() requires attached, not visible, so a CSS fold would leave them green while asserting nothing.

Gates

  • cd web && npm run check — 0 errors, 6 pre-existing warnings
  • make check — Go tests all ok, govulncheck "No vulnerabilities found", svelte-check 0 errors, 490/490 web unit tests
  • make install + Playwright measurement on every surface above, both themes
  • Tier spec 5/5, capstone 8/8; full e2e 81 passed with one cross-spec flake per runpane-collab-teardown:137 on one run, pane-content-link-anchors:238 on the next, a different test each time and both green in isolation. That is BUG-2334's shared-instance toast interference, not a regression.
  • Codex review: CLEAN

Currently draft only because it was requested mid-review; the reviewer has since said non-draft is fine. Flipping draft→ready is left to the reviewer. Refs TASK-2329, PLAN-2326.

Star affordance — verified at the accessibility-tree level

display: none removing the row from the a11y tree was an assertion worth proving rather than stating. ariaSnapshot() with the ⋯ open:

  • 390px phone (split, bottom sheet) — menuitems: Dependency graph, Move to collection…, Share…, Delete…. No Star. The row is in the sheet's DOM but display: none, so a querySelectorAll hit is not evidence it is announced.
  • 620px pane (fold, anchored panel) — menuitems include menuitem "Star", and clicking it really stars: chip.starred false → true, title Star → Unstar, row label ☆ Star → ★ Unstar.

Announced in exactly one tier.

Containment audit — corrected

An earlier claim of "zero risky ancestors" was wrong: that probe excluded auto and stopped walking at .item-page. Re-audited to <html>, there are five pre-existing overflow ancestors (.item-page, .item-page-host, .main-content, .app-shell, .app-layout). None is a problem — overflow forms no containing block for position: fixed.

container-type: inline-size reads as unsafe in the containment spec (layout containment is specified to form a fixed containing block). Chromium does not apply that: a position: fixed; inset: 0 probe injected as a real child of .tab-strip at a 390px viewport laid out 390×844 (full viewport), not 342×67 (the strip). contain: layout does collapse it — hence the standing warning not to add it.

xarmian added 3 commits July 26, 2026 06:12
…er-query strip (TASK-2329)

Task 3 of PLAN-2326, finishing IDEA-2299. Header measured 152px -> 127px on
the tagless full-page case (194px before TASK-2328 landed) — IDEA-2299's
target exactly. Three bands remain: breadcrumb, title, tab strip.

Header slimming
- Saved / Synced move out of `.meta-info` into `.strip-actions`, leading the
  group so the icon controls stay flush right. `.collab-state-synced` keeps
  its class and its visibility at every tier — it is SYNCED_BADGE_SELECTOR,
  asserted visible by four suites.
- `.meta-info` is screen-hidden WHOLESALE, not span-by-span: a spans-only
  hide would strand the `.meta-sep` "·" and the band's bottom margin.
- The `.item-ref` chip is screen-hidden; the breadcrumb tail carries identity
  on the full page and `.pane-header-ref-text` in the pane.

Print (BUG-626) is byte-for-byte preserved
- `.title-row .item-ref` had print styling but NO `display`, so the screen
  hide would have survived into print and dropped the ref from the page-1
  document header. Added the explicit reset. `.meta-info` already reset to
  `display: block`.
- Verified by rendering, not by reading CSS: `emulateMedia({media:'print'})`
  + PDF, before and after. Every element's print geometry is identical
  (display/order/w/h/x/y), and a pixel diff of the whole print rendering
  differs in exactly one 49x12 box — the relative-time string ("just now" ->
  "35m ago") that ticked over between the two runs. PDF page-1 text matches,
  ref included.

Two-tier rule — `@container item-tab-strip (max-width: 699px)`
- Repo's first container query. `@media` cannot see the docked pane: its
  width is dragged and persisted, decoupled from the viewport (DR-2).
- Threshold 700px, NOT the mock's 900px, and confirmed by measurement rather
  than arithmetic (DR-10). The strip caps at 912px on the full page at ANY
  monitor width, so 900 would have made compact the near-universal state.
  Measured: full page 892-912 -> full; full page with a pane docked 526-678,
  docked pane 312-672, mobile 342 -> compact.
- Compact drops the badge icons to bare counts, folds the star (`display:
  none`, node retained per DR-11), folds the collab label, and drops an idle
  save-status out of the flow — which triples the tab scrollport at the 360px
  pane floor (38px -> 94px) with no button movement.

Folded in (both found in review, both caused by this change)
- Star gets a row in the ⋯ menu. The compact tier folds the chip and the
  mobile overlay is ALWAYS compact, so without it starring became unreachable
  on phones. PLAN-2326's premise is zero affordances removed.
- The collab label folds sr-only, not `display: none`. The dot is
  `aria-hidden`, so the label is the badge's only textual state; hiding it
  outright left a colour-only status indicator.
- The backlinks badge gains an `aria-label`. Its accessible name was the
  computed "📎 4"; folding the icon would have degraded it to a bare "4".

Tests
- `pane-full-page-capstone.spec.ts` star assertions: `toBeEnabled()` requires
  attached, not visible, so a CSS fold would have left both green while
  asserting nothing — inside the test whose purpose is the BUG-2263
  invisible-freeze guarantee (DR-11). Pre-peek (892px, full tier) is now
  `toBeVisible()`. While peeking the master is 526px and genuinely compact,
  so that one asserts what the freeze actually owns — node retained and
  enabled — and the reachability half moves to `.pane-more-btn`, which never
  folds and is now asserted visible.
- New `pane-tab-strip-tiers.spec.ts`: the tier flips in place when a docked
  pane squeezes the full-page master at a FIXED viewport (a media query would
  sail through this), and the DR-9 worst case at the 360px pane floor — one
  row, all four tabs reachable through the scroll, ⋯ unclipped, and the
  folded star still operable from the menu. Mutation-tested: swapping
  `@container` for `@media` turns both red.

Gates: `npm run check` 0 errors; `make check` exit 0 (Go tests, govulncheck clean,
490 web unit tests); 25/25 on the pane suites (tiers, capstone, host, link-anchors)
against `make install`; full e2e 78 passed with one BUG-2334 toast-overlay flake
(passes in isolation). Codex CLEAN.

Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT
… (TASK-2329)

Salvage commit. Dave's review of the running build found the combined strip
unusable on phones, and part of the cause was this PR's own DR-5 instruction:
relocating Saved/Synced into `.strip-actions` pushed 115px into a strip that
was already overflowing, and inflated the two badges (39->48, 40->50) by
escaping `.meta-info`'s 0.8em. Reverted verbatim — the markup and the CSS
block are now byte-identical to main.

Reverted with it:
- `.strip-actions .save-status/.collab-state { margin-left: 0 }`, which
  existed only to de-double the relocation's spacing.
- `.save-status:not(.visible) { display: none }`. Measurably harmful in a
  real 448px pane: toggling it moved the action group 87->143px and the tab
  scrollport 301->245px — a 56px reflow on every save cycle, under a
  scrollbar that is `scrollbar-width: none`.
- `.collab-state-label`'s sr-only fold, now dead: the query container IS
  `.tab-strip`, and the badge no longer lives inside it.

`.meta-info { display: none }` is also reverted, but for SEQUENCING, not
because it was wrong. `.collab-state-synced` is SYNCED_BADGE_SELECTOR
(web/e2e/lib/collab-helpers.ts:69) and five specs across four suites assert
it visible; hiding the band while the badges live inside it turns them red.
It comes back once the badges have a new home.

KEPT from the original work:
- The `@media print { .title-row .item-ref { display: block } }` reset. This
  is a latent-bug fix that predates this PR — main's print rule sets twelve
  properties and no `display`, so ANY screen-side `display:none` on the chip
  would silently drop the ref from the page-1 document header (BUG-626).
- `.title-row .item-ref { display: none }` (screen).
- The backlinks badge's `aria-label`: folding `.badge-icon` strips "📎" from
  the computed accessible name, degrading it to a bare "4".
- The Star row in the ⋯ menu, and the capstone spec's strengthened star
  assertions.

The strip's own tier rule is untouched here and is re-keyed in the next
commit; `pane-tab-strip-tiers.spec.ts` drops the two assertions that
described the reverted behaviour so the branch stays green at this commit.

Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT
…phone (TASK-2329)

Combining the tablist and the action group on one row is not survivable at
phone widths, and folding does not fix it. The four tab labels are a fixed
321px; even a hypothetical action group of nothing but `⋯` (38px) needs 359px
against the 342px a 390px phone has. Measured on the WIDEST common phone,
430px, before this change: 382px of strip, 179px of tab scrollport, TWO of
four tabs clipped — under `scrollbar-width: none`, so with no hint they
existed at all.

Below 560px of strip, `.strip-actions` now takes a row of its own and the
tablist gets the full width. Re-keyed from one `<700` fold to three rules:

    < 560       split — actions own a row, ABOVE the tabs
    560 - 699   fold  — one row, badge icons -> bare counts, star folds
    <= 330      tab padding 11px -> 8px (the last 24px needed at 312px)

Consequence worth stating: below 560 the star and the badge icons come BACK.
The previous single `<700` fold hid them on every phone and in the
default-width docked pane — the widths with the LEAST need to economise once
the actions have their own row.

Actions go ABOVE the tabs (`order: -1`, visual only — DOM order and the
tablist's roving tabindex are untouched). `.tab-strip` owns the bottom
divider and `.pane-tab.on` overlaps it by 1px via `margin-bottom: -1px`;
keeping the tablist as the LAST row preserves that. Measured identical
(offset 0) in both the shared-row and split shapes. Actions-below would
orphan the active-tab underline ~31px above the divider.

`flex-wrap` / `column-gap` / `row-gap` are in the BASE `.tab-strip` rule, not
the `@container` block. A container query cannot style its own query
container and `.tab-strip` IS `item-tab-strip`, so a `flex-wrap: wrap`
written in there is silently dropped. Verified by mutation rather than
asserted: moving it inside leaves `nowrap` in force while `flex: 0 0 100%`
still applies to `.strip-actions`, and `.pane-tabs` collapses to **0px** with
all four tabs clipped at every phone width. Both new tests go red on it.

The `<= 330` rule likewise MUST sit after the base `.pane-tab` rule — a
container query adds no specificity, so the selectors tie and source order
decides. Caught by measurement: written above the base rule the tabs kept
`scrollWidth: 321` inside a 312px strip and one tab stayed clipped.

MEASURED (worst-case item: both badges + quick actions + owner)

  desktop, must be unchanged — and is, to the pixel:
    1440 / 1200   one row, header 148.8px, tabs fit, underline offset 0
    1024 /  768   one row, header 174.8px, tabs fit, underline offset 0
  phone full page + overlay pane, tabs FIT where two were clipped before:
    360   strip 312, tabs 297/297   (was 321 needed / 109 shown, 3 clipped)
    390   strip 342, tabs 321/321   (was 321 / 139, 3 clipped)
    412   strip 364, tabs 321/321   (was 321 / 161, 3 clipped)
    430   strip 382, tabs 321/321   (was 321 / 179, 2 clipped)
  docked pane 360 / 448 / 600 -> split;  720 (PANE_WIDTH_MAX) -> fold band
  container proof: a 360px pane on a 1920px viewport splits while the full
    page at that same viewport stays on one row — no media query can do that

Cost: the strip is 33px -> 67px on phones, so the header grows ~34px there.
That is the trade for four reachable tabs.

Also verified: `.menu-anchor` gained no `overflow`/`contain`/`transform`/
`filter`/`clip-path`/`will-change` ancestor at any width (audited by walking
the computed styles), the desktop ⋯ panel and the 430/360 bottom sheets own
all four of their corners by hit-test, and the arrow-key roving walk still
visits exactly the four tabs in a 360px pane.

pane-tab-strip-tiers.spec.ts rewritten: its old "strip stays ONE row"
assertion contradicted this design. Now three tests — the container-driven
split at a fixed viewport, the 360px worst case asserting tabs FIT rather
than "reachable by scrolling", and the 560-699 fold band.

Gates: `npm run check` 0 errors; `make check` exit 0 (Go tests, govulncheck
clean, 490 web unit tests); tier spec 3/3; full e2e 79 passed with one
cross-spec flake per run (pane-collab-teardown:137 then
pane-content-link-anchors:238 — different test each run, both green in
isolation; BUG-2334's shared-instance interference).

Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT
@xarmian xarmian changed the title feat(web): slim the item header to three bands + the two-tier container-query strip (TASK-2329) fix(web): un-combine the item tab strip below 560px + header slim groundwork (TASK-2329) Jul 26, 2026
@xarmian
xarmian marked this pull request as draft July 26, 2026 06:46
xarmian added 2 commits July 26, 2026 07:11
…menu row to it (TASK-2329)

A third commit rather than an amend: commits 1 and 2 are already pushed, and
rewriting pushed history — even on a draft PR — is not mine to do unasked.

── The upper boundary was measuring the wrong thing ──

699 was derived while Saved/Synced still lived in `.strip-actions`, and they
were worth 115px of the group. Re-measured on the worst-case item (children
badge AND backlinks badge AND quick actions AND owner rights) now that the
salvage has put them back in `.meta-info`:

    action group FOLDED (icons off, star off)    191px -> shared row needs 524px
    action group FULL                            274px -> shared row needs 607px
    action group FULL, counts inflated to an
      absurd "199/199" + "999"                   301px -> shared row needs 634px

So the shared row needs 607px, not the 722px the old figure implied. A 699
boundary was folding the star and the badge icons on the two WIDEST surfaces
that fold at all — a pane dragged to its 672px maximum, and a 1440px full
page with a pane docked at 678px — both of which have ~65px to spare.

Boundary moves 699 -> 639 (full tier at 640+): 6px over the absurd case, 33px
over the real one. The split boundary stays at 560, which measurement
supports — the folded row needs 524px, so 560 carries 36px of slack.

Net effect: a max-drag pane and a pane-squeezed 1440 master now show the star
and both badge icons on one row instead of folding them.

── Star menu row is now gated to exactly the fold band ──

It was unconditional, which cost every user a permanent extra row in the ⋯
menu even where the chip beside it was visible. Now `.strip-star-row` is
hidden by default and shown ONLY by the same query that folds `.star-btn`, so
the affordance exists in exactly one place at every width — never zero, never
two, and `display: none` keeps the hidden one out of the accessibility tree so
it is never announced twice.

Two things this needed that reading the CSS would not have caught:

1. `.menu-anchor :global(.strip-star-row)`, not a bare `:global(...)`.
   MenuItem's own `.mi { display: flex }` carries that component's scoping
   hash and is (0,2,0); a bare global class selector is (0,1,0) and LOSES.
   Measured: the row rendered `display: flex` at all eight probe widths —
   symmetry broken in the direction that shows the star twice. Prefixing with
   this component's own `.menu-anchor` makes both rules (0,3,0).

   That is the THIRD specificity/order trap in this strip's container rules,
   after `flex-wrap` in the base rule and the ≤330 `.pane-tab` rule needing to
   follow its base. Container queries add no specificity; every rule in here
   has to be checked against what it is overriding.

2. `MenuItem` gained an optional `class` prop so the row can be addressed at
   all. Additive; no existing caller passes it.

── Verified in the browser, both directions, eight widths ──

  phone 390 (strip 342, split)   chip visible, menu row display:none
  pane 360 (strip 312, split)    chip visible, row none
  pane 448 (strip 400, split)    chip visible, row none
  pane 620 (strip 572, FOLD)     chip none,    row visible
  pane 680 (strip 632, FOLD)     chip none,    row visible
  pane 720 (strip 672, full)     chip visible, row none
  full page 1024 (strip 716)     chip visible, row none
  full page 1440 (strip 912)     chip visible, row none

Exactly one of the two at every width. The phone case is the mobile
BottomSheet rather than the anchored panel, confirming the container query
reaches into both — neither portals out of `.tab-strip`.

── Slow drag through the boundaries ──

Dragging the pane 563 -> 688px in 5px steps produced THREE distinct states
across 27 samples and nothing in between: split (strip 515, 67px tall, star
shown) -> fold at strip 560 (33px, star folded) -> full at strip 640 (33px,
star shown). Tabs fit in every single sample. The transitions land exactly on
the declared boundaries and read as deliberate, not glitchy.

Desktop is still unchanged: 1440/1200 one row, header 148.8px; 1024/768 one
row, 174.8px; underline offset 0 everywhere.

── Codex round 2: the boundaries had a sub-pixel gap ──

Encoded as three adjacent integer bands (max-width 559 / 560..639 / 640+),
a strip measuring 559.5px matched NEITHER the split nor the fold query and
fell back to the full shared row that needs 607px. Container widths are not
integers — the pane default is `clamp(360px, 38%, 640px)` and PaneHost's JS
floors are `usable * 0.4` — so that width is reachable, and every test here
used integers so none of them saw it.

Fixed two ways at once. The blocks now NEST rather than abut: FOLD covers
everything at or below the full threshold and SPLIT is a strict subset that
also UN-folds what the outer block hid, which bounds the damage on its own
(measured at 559.5 pre-fix: folded instead of split, and the folded row needs
only 524px so the tabs still fit). And the bounds are .99 rather than
integers, leaving a [559.99, 560) window no engine can land in — layout
precision is 1/64px in Chromium and WebKit, 1/60px in Gecko.

Range syntax `(560px <= width < 640px)` would be exact and read better, but a
browser that cannot parse it drops the whole block, and on a phone that means
the broken shared row returns. `max-width` is supported everywhere
`@container` is.

Verified by forcing the strip to 559.50 / 559.99 / 560.50 / 639.50 / 639.99 /
640.50 and checking the tier and tab fit at each — all six correct, where four
were wrong before. Added as a regression test; it is the only one here that
produces fractional widths.

Gates: `npm run check` 0 errors; `make check` exit 0 (Go tests, govulncheck
clean, 490 web unit tests); tier spec 5/5 and capstone 8/8; full e2e 81 passed
with one BUG-2334 toast-overlay flake in pane-content-link-anchors (green in
isolation, and a clean 81/0 run earlier in this same commit).

Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT
…ise (TASK-2329)

Comment-only. Two corrections requested in review, plus one the review itself
got wrong.

── 1. WHY the <=330 rule must follow its base rule ──

The note said the ordering was load-bearing but not why that matters to a
future reader. The trap is that the failure is silent AND partial. A container
query adds no specificity, so `.pane-tab` inside the block and `.pane-tab` in
the base rule tie at (0,2,0) once Svelte's hash is applied, and source order
alone decides. Placed above the base rule the whole block is discarded — but
the tabs still render, still scroll, and only the last one clips at 360px,
which reads as "the 330 threshold is slightly off" rather than "these
declarations never applied". Invisible to code review; the only thing that
surfaces it is measuring `scrollWidth`, which stays at 321px inside a 312px
strip instead of dropping to 297px. Adds an explicit "do not group this with
the other @container blocks for tidiness" warning, since that is exactly the
edit that would re-break it.

── 2. The containment note listed `overflow` as dangerous. It is not. ──

And the audit backing that claim was itself wrong: it excluded `auto` from the
risky values and stopped walking at `.item-page`, so it reported zero hits.

Re-audited by walking the full computed-style chain to `<html>`. There are
FIVE overflow ancestors above the strip — `.item-page`, `.item-page-host`,
`.main-content`, `.app-shell`, `.app-layout` — every one pre-existing, and the
panels are fine: `overflow` alone forms no containing block for
`position: fixed`, and the anchored panel is `position: absolute` sized to fit.
Corner hit-tests on the open panel at 1440 and the sheet at 430 and 360 report
four corners owned and inside the viewport every time.

The note now separates the mechanisms instead of lumping them, because they
are genuinely different: transform/filter/perspective/will-change form the
fixed containing block but add no paint containment; `contain: layout` does the
same and `contain: paint` additionally clips; `clip-path` forms no containing
block but visually clips fixed descendants anyway; `overflow` does none of it.

── 3. `container-type` is safe, and the spec-reading that says otherwise is
      wrong in practice ──

Review pushed back that `container-type: inline-size` implies layout
containment, and layout containment is specified to make an element a
containing block for fixed descendants — so the "safe" claim contradicted
itself. Settled by measurement rather than argument: injected a
`position: fixed; inset: 0` probe as a real child of `.tab-strip` at a 390px
viewport. The probe laid out 390x844 — the full viewport — not 342x67, the
strip. Chromium does not apply that behaviour for `container-type`, and also
reports `contain: none` on the element. The comment now records the probe and
its result so this is not re-litigated, and keeps the warning that
`contain: layout` DOES collapse a fixed child into the strip.

── Verification in the same round (no code change needed — the Star gate
   shipped in 2c05297 already behaved correctly at all of these) ──

  400px pane  (strip 352, SPLIT)  chip display:block, menu row display:none
  620px pane  (strip 572, FOLD)   chip display:none, menu row present, and
                                  clicking it really stars: chip.starred
                                  false -> true, title "Star" -> "Unstar",
                                  row label "☆ Star" -> "★ Unstar"
  390px phone (strip 342, SPLIT)  chip visible; the row IS in the bottom
                                  sheet's DOM but display:none

Confirmed at the ACCESSIBILITY-TREE level rather than by inference, since
"display:none keeps it out of the a11y tree" was an assertion worth proving:
`ariaSnapshot()` at 390 lists menuitems Dependency graph / Move to collection /
Share / Delete and NO "Star"; at 620 it lists `menuitem "Star"` alongside them.
The row is announced in exactly one tier, and a DOM query finding it inside the
sheet at 390 is not evidence to the contrary.

Gates: `npm run check` 0 errors; `make check` exit 0 (Go tests, govulncheck
clean, 490 web unit tests); tier spec 5/5; full e2e 81 passed with one
cross-spec flake (pane-content-link-anchors:238 this run — BUG-2334's
shared-instance toast interference, green in isolation, a different test each
run).

Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT
@xarmian
xarmian force-pushed the feat/item-header-slim branch from 13c5e2f to fc7db1b Compare July 26, 2026 11:48
xarmian added 2 commits July 26, 2026 11:53
…-2329)

pane-tab-strip-tiers.spec.ts:230 passes locally and failed twice on CI with
only `true !== false` to go on. Dump the real numbers from whichever
environment runs it — scrollWidth/clientWidth, computed slack, per-tab box and
text-only widths, resolved font family/size/weight, scroll-container scrollbar
width, DPR, UA — plus a failure message that prints them inline.

Local run reports slackPx: 0. The fit derivation rests on the four English
labels measuring 321px (297px after the <=330 padding trim) in whatever
`--font-ui` resolves to, and that stack is `-apple-system,
BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif` — every entry misses on
Linux, so `system-ui` goes through fontconfig and can differ between a dev box
and ubuntu-latest. Confirming that with CI numbers before designing the fix
rather than tuning a pixel until it goes green.

Reverted once the slack fix lands.

Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT
…+ a visible overflow cue (TASK-2329)

CI was red on this branch's own core test, twice, deterministically. Root cause
confirmed with numbers from the runner rather than inferred, then fixed at the
level of the design instead of the assertion. Reverts the temporary diagnostic
added in the previous commit, replacing it with a permanent slack assertion.

── ROOT CAUSE: font metrics ──

                          dev box    CI      delta
    strip width             312      312       —
    tab content (scrollW)   297      337     +40
    scrollport              297      312
    OVERFLOW                  0       25     <-- the failure
    scrollbar in container    0        0     <-- ruled out
    devicePixelRatio          1        1     <-- ruled out
    padding per tab         8/8      8/8     <-- the <=330 rule DID fire

Per-tab glyph advance, local -> CI: Details 42.4->50.8, Relationships
84.4->98.5, Activity 46.4->55.5, Versions 53.9->62.1. Total 227.0->266.8,
+17.5%. The +40px content delta is ENTIRELY the +39.8px text delta.

`--font-ui` is `-apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui,
sans-serif` — every entry misses on Linux, so `system-ui` resolves through
fontconfig to different font FILES on the two machines. Same declaration,
wider glyphs. Ten candidate families forced locally spanned 273-297px, so CI
at 337px is outside the entire local envelope: this was not findable by
guessing at fonts, it needed the runner.

The deeper fault was mine: the <=330 padding rule had been tuned until 297 met
297. Zero slack. A pixel measured in one environment treated as universal —
the same mistake that produced the original mobile failure.

── FIX 1: shorten the longest label at narrow widths ──

`Relationships` -> `Links` below 560px of strip, VISIBLE TEXT ONLY. Worth 51px
measured, which is most of the row's margin in one move.

The accessible name stays `Relationships` at every width, pinned by
`aria-label` on the tab, because ~5 specs address these tabs by
`getByRole('tab', { name: 'Relationships' })`. Verified at four widths:
`name: 'Relationships'` resolves to exactly 1 everywhere, `name: 'Links'` to 0
everywhere, and `ariaSnapshot()` reads `tab "Relationships": Links` — name
unchanged, visible content swapped. A screen reader never hears "Links".

Slack now, measured, including a simulation of CI's wider glyphs:

    font scale   phone 360   phone 390   pane 360   pane 448
    1.000 (local)   +66         +72        +66        +130
    1.175 (== CI)   +35         +41        +35         +99
    1.300           +13         +19        +13         +77

CI's -25px becomes +35px. It still fits at 1.30, i.e. 30% wider than CI.

── FIX 2: overflow is no longer silent ──

`scrollbar-width: none` hid the only native signal that content was cut off —
which IS the defect this task exists to fix (two of four tabs invisible at
430px, unhinted). Slack makes overflow unlikely, not impossible:
translations, user zoom, a fifth tab.

`.pane-tabs` now takes a soft mask fade on an edge ONLY when there is more
content past that edge. Verified in all four states: fitting -> no mask class
at all; at scroll start -> right edge only; at end -> left only; mid-scroll ->
both. Paint-only, so the active tab's 1px divider overlap still measures 0.

Scripted, not a container query, and that is deliberate: CSS cannot ask "is
this element scrolling". That is the OPPOSITE of the tier rules, where CSS
answers natively and DR-2 rightly rejected JS — this reads
scrollWidth/clientWidth and writes two booleans, and touches no tier logic. It
writes without reading, so there is no effect-self-write loop (CONVE-1688),
and mask-image cannot resize the element so it cannot re-trigger its own
observer.

A pure-CSS attempt came first and was rejected ON MEASUREMENT: the two-layer
`background-attachment: local/scroll` scroll-shadow idiom is conditional by
construction, but backgrounds paint BEHIND the tab text and sampling the
rendered edge pixels showed no legible signal in either theme. Recorded in the
CSS so nobody re-tries it.

── FIX 3: the test can no longer pass on a coincidence ──

It asserted only `tabsFit` and passed with 0px to spare. Now it asserts
`slackPx >= 20` as well, where slackPx is `stripWidth - tabs.scrollWidth`.

The diagnostic's own `clientWidth - scrollWidth` was useless and is corrected:
it is ALWAYS 0 or negative, because `.pane-tabs` has no `flex-grow` so while
the content fits its clientWidth IS its content width. It could not
distinguish "just fits" from "loads of room".

Both assertions print the measured widths, the resolved font family/size and
the per-tab boxes, so the next failure on a machine nobody can attach to hands
over the numbers instead of `true !== false`. Roving-walk identity switched
from `textContent` (now "RelationshipsLinks", both spans) to accessible name.
New test covers the four overflow-cue states.

── MATRIX RE-VERIFIED ──

Desktop byte-identical: 1440/1200 one row header 148.8px; 1024/768 one row
174.8px; underline offset 0; full `Relationships` label; content 321 fits
716+. Phones 360/390/412/430 and panes 360/448/600 split and fit with 66-130px
slack. Pane 720 (672px strip) full tier, one row. Container proof intact: a
360px pane on a 1920px viewport splits while the full page at that viewport
does not. Drag sweep 563->688px still three clean states, tabs fit in all 27
samples. Star chip/menu-row symmetry holds at all eight widths.

── BUG-2334: answered with a control, not an assumption ──

Built `main` and ran the full local suite against it: 2 failures,
`pane-collab-teardown:60` and `pane-content-link-anchors:238` — both in the
set that fails on this branch, with none of this branch's specs or seeding
present. So the cross-spec toast interference is pre-existing locally and not
something this PR introduced or made materially likelier. Main's CI is green
at 10a5ae2, so CI is simply timing-luckier than local. All affected tests
pass in isolation.

Gates: `npm run check` 0 errors; `make check` exit 0 (Go tests, govulncheck
clean, 490 web unit tests); tier spec 6/6.

Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT
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