Skip to content

arm E report

Cindy Zhang edited this page Aug 26, 2026 · 2 revisions

PR #3938 — arm E vs arm D2 vs main (arm A)

Surface: route /packages/cli/assets/templates/pages/detail-page, template viewer, 1440×1000 @dpr 3, theme neutral, light dark, LTR. Container padding 16px (--container-padding-inline-*: 16px), stop padding 12px (--spacing-3).

Arm E commit: 0584db591b2 (branch vis-3938-e, bare repo {review-root}/astryx.git), built on arm D2 b747a345b03, which sits on main 58f95421362.

Sign convention: measured against the container's content inset. Negative = outside it (into the container's padding), positive = inside it.


1. The five measurements

# Measurement A (main) D2 E
1 first tab label vs content inset 0.00 0.00 0.00
2 first tab box / hover pill vs content inset −12.00 −16.00 −12.00
3 last stop (TabMenu) end padding / box width 12px / 78.41 12px / 78.41 12px / 78.41
4 ghost panel-toggle centre / tab-row centre (from header top) 133.98 / 133.48 (Δ0.50) 133.98 / 133.48 (Δ0.50) 133.98 / 133.48 (Δ0.50)
5 hasDivider underline span vs container content width n/a (no bleed) 1168 / 1136 1168 / 1136

Supporting numbers on the same frames:

A D2 E
strip box vs container box edge (0 = fully bled) 4 0 0
strip element inline padding 0 / 0 0 / 0 4 / 4
first stop padding-inline-start 12 16 12
first stop width 70.70 74.70 70.70
header height 152.98 152.98 152.98
strip bottom → header bottom 1 1 1

Metric 3, expanded. On this template the strip is layout="hug", so the TabMenu sits nowhere near the end edge and no arm moves it. But the reason it does not move in D2 is not that it does not need to — it is that D2's TabMenu :last-child rule never matches. TabMenu returns a fragment: <button data-tab-menu> followed by popover.render(...), which emits a <template> and a zero-size <div> as siblings inside the strip. The trigger is therefore never the strip's last child.

Where the end edge actually reaches the container — layout="fill", same 16px container:

layout="fill", 3 tabs + TabMenu D2 E
last stop box end vs inset −16.00 −12.00
last stop padding-inline-end 12px 12px
⇒ menu content (chevron) vs inset 4.00 outside 0.00
stop widths 363.86 / 359.88 / 359.86 / 78.41 358.53 / 358.53 / 358.53 / 78.41

D2 reproduces the exact 4px regression this PR exists to fix, at the end edge, on any strip whose last stop is a TabMenu — i.e. the common case. E does not, and needs no TabMenu change to get there. Verified, not assumed.

Metric 5, expanded. The template's TabList has no hasDivider (the header draws its own rule), so this was measured on a rig with hasDivider on: nav border-box 1168px against a 1136px container content width = content + 2×16px, i.e. the full container box, border-bottom-width: 1px, in both arms. The divider rides the nav, which both arms bleed identically; E's strip padding is inside it and does not shorten it.


2. Verdict

E is as correct as D2 on this template — pixel-identical at rest — and better on every axis where they differ, except containers whose inline padding is under 12px, where D2 is exact and E is off by (12 − padding).


3. How each one fails

E fails where D2 does not

Container padding below the stop's own 12px. E can only add padding to the strip; it can never take padding away from a stop. With <LayoutHeader padding={2}> (8px):

container padding A/main-style D2 first label E first label
8px (padding={2}) 0.00 +4.00 (inside the inset)
12px (padding={3}) 0.00 0.00
16px (padding={4}) 0.00 0.00 0.00
24px (padding={6}) 0.00 0.00

This is structural, not a tuning error: below 12px the fix requires shrinking the first stop's padding, which no rule on the strip can do. Note main has the same limitation — its hardcoded marginInline: -12 over-bleeds an 8px container — so E is no worse than today; D2 is strictly better there.

A restated constant. E puts --spacing-3 in TabList to subtract the stop's own padding, duplicating Tab/TabMenu's paddingInline (the import would be circular). If anyone changes a stop's inline padding, E's edge alignment drifts silently. D2 has no such coupling — it publishes, the stop consumes. Worth a unit test if E lands.

D2 fails where E does not

  1. Dead end-edge rule with a TabMenu (above): 4px outside the inset in layout="fill"; E lands 0.00.
  2. Widens stops that are not at an edge. :last-child fires on the last Tab when there is no menu, even in hug, where it is nowhere near the end: last tab 79.39 in D2 vs 75.39 in E — a 4px-wider hover pill on one tab for no reason. Same in fill, where the first stop grows to 363.86 against its siblings' 359.88; E's three growing stops are all 358.53.
  3. Regresses padding={0} containers. The property is published as 0px, so max(var(--container-padding-inline-start, 12px), --spacing-2) resolves to 8px — the first stop's padding shrinks 12→8 and its label moves 4px, in a container with no padding to bleed. Measured: D2 first stop 66.70 wide, label +8.00; E unchanged at 70.70 / +12.00 (identical to isFullBleed off).
  4. Single-tab strip. Both :first-child and :last-child fire, so D2's one tab is 8px wider (78.70 vs E's 70.70). Both land the label at 0.00.
  5. Three files and a private var protocol versus one rule in one file.

Where they are the same

  • RTL — symmetric in both, same numbers as LTR (a 1px rounding appears in both arms in the narrow scrolling case, not arm-specific).
  • Container that publishes nothing — both are exact no-ops (E's max(…, 0px) clamps; D2's fallback resolves to 12px). Strip box unmoved at 24/24, first label +12.00, identical to isFullBleed off, in both.
  • layout="fill" distributable area — E's is 8px narrower than D2's (the strip's 4+4 padding), which spreads to ~1.3px per growing stop (358.53 vs 359.87). Not visible; labels are centred in a fill stop anyway.
  • Scrolling strip (viewport 300px) — both scroll, both fade, both show one arrow; scroll extent 324 in E vs 320 in D2 vs 316 unbled. Rest-state label still 0.00 in E.
  • overflow="none" (strip is not a scroll container, so --_tab-strip-bleed is undefined) — E's var(--_tab-strip-bleed, 0px) fallback holds: strip padding 4/4, label 0.00, box −12.00.
  • Block axis — untouched by both; header height, strip-to-divider gap and both centres are identical to main to 0.01px.

4. Evidence

Frames (all captureWithSensors, all receipts passing)

Frame Receipt row
{review-root}/shots-3938-arme/E-hover-row.png E-hover tab row sha=0584db591b28c78e5d1e6e8710da59fa77be55e6 route=/packages/cli/assets/templates/pages/detail-page theme=neutral mode=light dark dir=ltr vp=1440x1000@3 targets=1 state={"tabs":4,"active":"Details","ghost":"present"}
{review-root}/shots-3938-arme/D2-hover-row.png D2-hover tab row sha=b747a345b03f5a577c23f7d1d78580ea2b72887c route=… theme=neutral mode=light dark dir=ltr vp=1440x1000@3 targets=1 state={"tabs":4,"active":"Details","ghost":"present"}
{review-root}/shots-3938-arme/A-row.png (control) A tab row sha=58f95421362527e586c078b9377b1fcab2274910 route=… theme=neutral mode=light dark dir=ltr vp=1440x1000@3 targets=1 state={"tabs":4,"active":"Details","ghost":"present"}
{review-root}/shots-3938-arme/E-row.png, D2-row.png rest-state pair, shot first; 0 differing pixels between them, which is why the pair was re-shot hovered

Sensor JSON beside each PNG (*.png.sensors.json), receipt summaries in *-receipt-summary.txt.

Pixel diffs (device px, dpr 3):

D2-hover vs E-hover  diffPx=1350  0.3342%  maxChannelDelta=13  box(css)=x24.0-37.0 y94.0-129.7
D2       vs E        diffPx=0                                  identical
A        vs E        diffPx=1218  0.0996%  maxChannelDelta=232  box(css)=x886.7-1206.3 (the ghost
                                                                button, which main's row-level
                                                                marginInline shifts 12px)

The only place D2 and E differ on screen is the 13px-wide band at the first stop's pill edge, and only while it is hovered or focused.

Numbers

  • {review-root}/shots-3938-arme/{A,D2,E,E-hover,D2-hover}.json — the template route.
  • {review-root}/shots-3938-arme/matrix-{E,D2}.json — the 14-rig edge-case matrix (fill, no-menu, single tab, paddings 0/2/3/4/6, unpadded, prop off, RTL, overflow="none"), plus -w300 (scrolling) variants.

Harnesses

  • {review-root}/probe-kit/tpl-3938-arme.cjs — the route probe (extends tpl-3938-armd.cjs with the edge-stop metrics; HOVER=1 paints the pill).
  • {review-root}/probe-kit/matrix-3938-arme.cjs — the DOM-only edge-case matrix.
  • apps/storybook/stories/ZzBleed3938ArmE.stories.tsx in the worktree — scratch rig, deliberately left untracked.

The arm diff

D2-to-E.diff. Reverts Tab.tsx and TabMenu.tsx to main, and in TabList.tsx:

const STOP_INLINE_PADDING = spacingVars['--spacing-3'];
const EDGE_PAD_START = `max(var(--container-padding-inline-start, 0px) - ${STOP_INLINE_PADDING}, 0px)`;
const EDGE_PAD_END   = `max(var(--container-padding-inline-end,   0px) - ${STOP_INLINE_PADDING}, 0px)`;

fullBleedStrip: {
  paddingInlineStart: `calc(var(${BLEED_VAR}, 0px) + ${EDGE_PAD_START})`,
  paddingInlineEnd:   `calc(var(${BLEED_VAR}, 0px) + ${EDGE_PAD_END})`,
},

applied to the strip after stripScroll (both write the strip's inline padding) and carrying --_tab-strip-bleed through, so stripScroll's negative margin still cancels the focus-ring share. Compiled output verified in packages/core/dist/astryx.css:

padding-inline-start:calc(var(--_tab-strip-bleed,0px) + max(var(--container-padding-inline-start,0px) - var(--spacing-3),0px))

main-to-E-core.diff is the same arm against main, packages/core only.

Nothing was pushed and nothing was posted to GitHub.

Clone this wiki locally