fix(web): un-combine the item tab strip below 560px + header slim groundwork (TASK-2329) - #1034
Draft
xarmian wants to merge 7 commits into
Draft
fix(web): un-combine the item tab strip below 560px + header slim groundwork (TASK-2329)#1034xarmian wants to merge 7 commits into
xarmian wants to merge 7 commits into
Conversation
…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
marked this pull request as draft
July 26, 2026 06:46
…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
force-pushed
the
feat/item-header-slim
branch
from
July 26, 2026 11:48
13c5e2f to
fc7db1b
Compare
…-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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
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:…with
scrollbar-width: none, so no hint the missing tabs existed.8c3e0c2b— revert.meta-info, byte-identical to main. Relocating them into.strip-actionspushed 115px into a strip that was already overflowing and inflated the badges (39→48, 40→50) by escaping.meta-info's0.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-stripand the badge no longer lives inside it..meta-info { display: none }reverted for SEQUENCING, not because it was wrong..collab-state-syncedisSYNCED_BADGE_SELECTORand 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 nodisplay, so any screen-sidedisplay:noneon the chip would silently drop the ref from the page-1 document header (BUG-626). Also the screen-side ref hide, the backlinksaria-label, the ⋯ Star row, and the capstone spec's strengthened assertions.e97d7425— the fix: un-combine below 560pxBelow 560 the star and the badge icons come back — the single
<700fold 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-stripowns the bottom divider and.pane-tab.onoverlaps it by 1px viamargin-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
flex-wrapmust be in the base.tab-striprule, not the@containerblock. A container query cannot style its own query container. Verified by mutation: moved inside,nowrapstays in force whileflex: 0 0 100%still applies to.strip-actions, and.pane-tabscollapses to 0px with all four tabs clipped at every phone width. Both new tests go red on it.<= 330rule must sit after the base.pane-tabrule. Container queries add no specificity, so the selectors tie and source order decides. Written above it, the tabs keptscrollWidth: 321inside 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:
Phone — tabs now fit where two to three were 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-anchorgained nooverflow/contain/transform/filter/clip-path/will-changeancestor 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 reportscontain: noneon.tab-stripdespitecontainer-type— expected, and deliberately not "fixed" withcontain: layout, which does trap the sheet'sposition: fixed.2c05297f— re-derive the boundary, gate the Star rowThe 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:199/199+999So 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, anddisplay: nonekeeps 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:
.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 rendereddisplay: flexat all eight widths — symmetry broken toward showing the star twice.clamp(360px, 38%, 640px). Fixed by nesting the blocks (fold ⊃ split, with the split un-folding) and using.99bounds. 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.tsrewritten — 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.tsstar 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 warningsmake check— Go tests all ok, govulncheck "No vulnerabilities found", svelte-check 0 errors, 490/490 web unit testsmake install+ Playwright measurement on every surface above, both themespane-collab-teardown:137on one run,pane-content-link-anchors:238on the next, a different test each time and both green in isolation. That is BUG-2334's shared-instance toast interference, not a regression.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: noneremoving the row from the a11y tree was an assertion worth proving rather than stating.ariaSnapshot()with the ⋯ open:Dependency graph,Move to collection…,Share…,Delete…. NoStar. The row is in the sheet's DOM butdisplay: none, so aquerySelectorAllhit is not evidence it is announced.menuitem "Star", and clicking it really stars:chip.starred false → true, titleStar → 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
autoand 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 —overflowforms no containing block forposition: fixed.container-type: inline-sizereads as unsafe in the containment spec (layout containment is specified to form a fixed containing block). Chromium does not apply that: aposition: fixed; inset: 0probe injected as a real child of.tab-stripat a 390px viewport laid out 390×844 (full viewport), not 342×67 (the strip).contain: layoutdoes collapse it — hence the standing warning not to add it.