fix(onboarding): let the confirm screen's sex select shrink at 390 px

The string-headroom sweep went red on CI for French at phone width by
two pixels the Mac never showed: the runner's fallback fonts render a
touch wider. The box was the sex select on the confirm screen — its
value is whitespace-nowrap, and a grid item's automatic minimum is its
min-content, so the track was pinned at the padded option's width and
the shell scrolled sideways. Both cells of that row and the trigger
carry min-w-0 now: the track takes the free space and the value
ellipsises, which is the select primitive's own design. Reproduced and
verified locally by narrowing the viewport to 372 px, which stands in
for the wider fonts (336 px of scroll before, none after).

The sweep now names what overflowed: the boxes past the shell's edge,
with tag, slot, classes, text prefix and edges, ride the failure
message and the outside list, so the next red run says what rather
than only that.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 23:50:30 +02:00
1 parent aba785f9f4
commit 677019b53b
2 files changed
+45 -8

No files matched your search

+35 -7
View File
@@ -118,6 +118,36 @@ async function padAndMeasure(page: Page) {
scrollWidth: el.scrollWidth,
clientWidth: el.clientWidth,
}));
/**
* A name for the box that overflowed, so a red run says WHAT rather than
* only that the page grew: tag, `data-slot`, the first classes, the text
* it starts with, and its edges. The CI runner's fallback fonts render a
* pixel or two wider than a Mac's, so the culprit is often invisible
* locally and the message is the whole diagnosis.
*/
const describe = (el: HTMLElement, r: DOMRect) => ({
tag: el.tagName.toLowerCase(),
slot: el.dataset.slot ?? null,
classes: el.className.toString().split(/\s+/).slice(0, 4).join(" "),
text: (el.textContent ?? "").trim().slice(0, 40),
left: Math.round(r.left),
right: Math.round(r.right),
width: Math.round(r.width),
});
const shellRight = shell.getBoundingClientRect().right;
// Every box that reaches past the shell's own right edge, widest first,
// deepest first among equals (the leaf is the culprit, its ancestors
// merely grew with it).
const widest = [...shell.querySelectorAll<HTMLElement>("*")]
.map((el) => ({ el, r: el.getBoundingClientRect() }))
.filter(({ r }) => r.width > 0 && r.right > shellRight + 1)
.sort(
(a, b) =>
b.r.right - a.r.right || b.el.children.length - a.el.children.length,
)
.reverse()
.slice(0, 6)
.map(({ el, r }) => describe(el, r));
const outside = [
...shell.querySelectorAll<HTMLElement>(
"button, input, select, textarea, a[href], [tabindex]:not([tabindex='-1'])",
@@ -126,11 +156,7 @@ async function padAndMeasure(page: Page) {
.map((el) => ({ el, r: el.getBoundingClientRect() }))
.filter(({ r }) => r.width > 0 && r.height > 0)
.filter(({ r }) => r.right > window.innerWidth + 1 || r.left < -1)
.map(({ el, r }) => ({
slot: el.dataset.slot ?? el.tagName.toLowerCase(),
left: Math.round(r.left),
right: Math.round(r.right),
}));
.map(({ el, r }) => describe(el, r));
return {
padded,
@@ -138,6 +164,7 @@ async function padAndMeasure(page: Page) {
pageClientWidth: doc.clientWidth,
shellScrollWidth: shell.scrollWidth,
shellClientWidth: shell.clientWidth,
widest,
clippers,
outside,
};
@@ -191,13 +218,14 @@ test.describe("setup flow — every locale at 30% longer strings", () => {
result!.padded,
`${label}: nothing was padded, so this measured nothing`,
).toBeGreaterThan(3);
const culprits = JSON.stringify(result!.widest);
expect(
result!.pageScrollWidth,
`${label}: the page scrolls sideways`,
`${label}: the page scrolls sideways; past the shell's edge: ${culprits}`,
).toBeLessThanOrEqual(result!.pageClientWidth + 1);
expect(
result!.shellScrollWidth,
`${label}: the shell scrolls sideways`,
`${label}: the shell scrolls sideways; past the shell's edge: ${culprits}`,
).toBeLessThanOrEqual(result!.shellClientWidth + 1);
expect(result!.clippers, `${label}: text is clipped`).toEqual([]);
expect(
+10 -1
View File
@@ -95,8 +95,16 @@ export function BaselineFields({
/>
</FieldGroup>
{/* `min-w-0` on both cells: a grid item's automatic minimum is its
min-content, and the sex select's value is `whitespace-nowrap` — a
locale whose longest option is a third longer, or a runner whose
fallback fonts render two pixels wider, then pinned the track past
the card at 390 px (the string-headroom sweep caught it on CI). With
the minimum released the track takes the free space and the value
ellipsises, which is the select primitive's own design. */}
<div className="grid gap-4 sm:grid-cols-2">
<FieldGroup
className="min-w-0"
htmlFor={IDS.heightCm}
label={
heightAdapter.usesFeetInches
@@ -116,6 +124,7 @@ export function BaselineFields({
/>
</FieldGroup>
<FieldGroup
className="min-w-0"
htmlFor={IDS.gender}
label={t("onboarding.baseline.genderLabel")}
error={errors.gender}
@@ -130,7 +139,7 @@ export function BaselineFields({
>
<SelectTrigger
id={IDS.gender}
className="w-full"
className="w-full min-w-0"
data-slot="onboarding-baseline-gender"
aria-invalid={errors.gender ? true : undefined}
aria-describedby={