From 345e2829e4294aa2afe0295504541c798951aa92 Mon Sep 17 00:00:00 2001 From: Kyle Mitofsky Date: Wed, 21 Oct 2026 05:39:10 -0400 Subject: [PATCH 1/5] Back the selected segment until its thumb is measured Without a script, /design's segmented pickers had no selected backing: the thumb waits for its cell's measured frame, so the selected label sat on-brand white on the light track (about 1.21:1). The selected option now paints the thumb's face itself until the thumb is placed. Refs #3136 Co-Authored-By: Claude Opus 5.5 --- .../src/rsdDrawing/RsdSegmentedPicker.tsx | 6 +++++- tools/migration/tests/rsd-native-control-host.mjs | 5 +++-- .../tests/rsd-native-segmented-picker.test.mjs | 14 ++++++++++++++ 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/experiments/native-architecture/src/rsdDrawing/RsdSegmentedPicker.tsx b/experiments/native-architecture/src/rsdDrawing/RsdSegmentedPicker.tsx index e7c9132df..f69eaddc3 100644 --- a/experiments/native-architecture/src/rsdDrawing/RsdSegmentedPicker.tsx +++ b/experiments/native-architecture/src/rsdDrawing/RsdSegmentedPicker.tsx @@ -10,7 +10,7 @@ import { TextZoomRevision } from '../platform/textZoomRevision'; import { RsdIcon } from './RsdIcon'; import { WholeWordText } from './WholeWordText'; import { arrowDelta, nextEnabledIndex, rovingTabIndex } from './rovingRadio'; -import { brandColors, colors, dimensions, fonts } from './tokens.css'; +import { brandColors, colors, dimensions, fonts, shadows } from './tokens.css'; import { SegmentThumb } from './segmentThumb'; import { EASE_GLIDE, THUMB_TRAVEL_MS, type Cell } from './segmentThumbFace'; @@ -253,6 +253,7 @@ export function RsdSegmentedPicker(props: RadioProps | Togg styles.columnOption, variant === 'segment' && tight && !stacked && styles.tightOption, active && (marks ? styles.markActive : ACTIVE_OPTION[variant]), + index === thumbIndex && !thumbCell && styles.unplacedThumb, option.disabled && styles.disabled, ]} > @@ -450,6 +451,9 @@ const styles = css.create({ whiteSpace: 'nowrap', }, segmentActive: { color: { default: brandColors.onBrand, ':hover': brandColors.onBrand } }, + // The thumb waits for its cell's measured frame, which a prerendered page without a script never + // gets, so until then the selected option paints the thumb's face itself. + unplacedThumb: { backgroundColor: colors.brandSolid, boxShadow: shadows.shadowControl }, chipOption: { gap: CHIP_GAP_PX, paddingBlock: CHIP_PADDING_BLOCK_PX, diff --git a/tools/migration/tests/rsd-native-control-host.mjs b/tools/migration/tests/rsd-native-control-host.mjs index 4d6aa7168..1d2572e6f 100644 --- a/tools/migration/tests/rsd-native-control-host.mjs +++ b/tools/migration/tests/rsd-native-control-host.mjs @@ -2,7 +2,7 @@ import { vi } from 'vitest'; // The released RSD native renderer over a React Native stand-in for the shared controls: each host // view paints a DOM node carrying its accessibility props and resolved style, reports a layout the -// test chooses, and keeps its latest props on the node so a test can drive the responder. The +// test chooses (none when layoutOf answers null, as a prerendered page never lays out), and keeps its latest props on the node so a test can drive the responder. The // Reanimated stand-in records every shared-value write so a thumb's travel can be read. export const host = { windowWidth: 402, @@ -25,7 +25,8 @@ vi.mock('react-native', async () => { React.useImperativeHandle(ref, () => node.current); React.useLayoutEffect(() => { node.current.__rn = { props, style: flat(props.style) }; - props.onLayout?.({ nativeEvent: { layout: host.layoutOf(node.current) } }); + const layout = host.layoutOf(node.current); + if (layout) props.onLayout?.({ nativeEvent: { layout } }); }); const children = typeof props.children === 'function' diff --git a/tools/migration/tests/rsd-native-segmented-picker.test.mjs b/tools/migration/tests/rsd-native-segmented-picker.test.mjs index fa2977f24..a3cd55079 100644 --- a/tools/migration/tests/rsd-native-segmented-picker.test.mjs +++ b/tools/migration/tests/rsd-native-segmented-picker.test.mjs @@ -272,6 +272,20 @@ describe('segment thumb', () => { } }); + // Issue 3136: unmeasured, as on a prerendered page, the selected label sat white on the track. + it('backs the selected option with the thumb face until its cell is measured', async () => { + host.layoutOf = () => null; + await mount({ initial: 'light' }); + expect(thumb()).toBeNull(); + expect(native(option('Light')).style.backgroundColor).toBe(paint('segmentThumb', 'light')); + expect(native(option('Light')).style.boxShadow).toBe(rgba(scale.shadowControl)); + expect(native(option('Dark')).style.backgroundColor).toBe('transparent'); + host.layoutOf = cellLayout(CELL_WIDTH_PX); + await mount({ initial: 'light' }); + expect(thumb()).not.toBeNull(); + expect(native(option('Light')).style.backgroundColor).toBe('transparent'); + }); + it('draws a 48px option with a 16px icon inked on-brand only while selected', async () => { await mount({ initial: 'light' }); const light = native(option('Light')).style; From 225b4c4c48a76693f97535f82178ca52eab0392a Mon Sep 17 00:00:00 2001 From: Kyle Mitofsky Date: Wed, 21 Oct 2026 05:39:11 -0400 Subject: [PATCH 2/5] Drop the admin masthead's crayon strip below 390 px At 320 px the back link, the crayon strip and the Admin suffix overran the sheet by about 3.5 px and clipped the suffix. Shipping's PageShell drops the strip below its narrow-phone breakpoint; the console now does the same, and the admin check holds the masthead inside a 320 px window with and without a script. Refs #3136 Co-Authored-By: Claude Opus 5.5 --- .../src/pageDocument/admin/adminChrome.tsx | 7 +++- tools/migration/check-native-web-admin.mjs | 37 ++++++++++++++++++- .../native-admin-narrow-breakpoint.test.mjs | 19 ++++++++++ 3 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 tools/tests/native-admin-narrow-breakpoint.test.mjs diff --git a/experiments/native-architecture/src/pageDocument/admin/adminChrome.tsx b/experiments/native-architecture/src/pageDocument/admin/adminChrome.tsx index b17398999..00d4ceb2c 100644 --- a/experiments/native-architecture/src/pageDocument/admin/adminChrome.tsx +++ b/experiments/native-architecture/src/pageDocument/admin/adminChrome.tsx @@ -31,6 +31,10 @@ const DRAWING_HREF = '/'; // Exclusive bands, as StyleX does not order overlapping queries by width. const PHONE = '@media (max-width: 540px)'; const FRAMED = '@media (min-width: 540.02px) and (max-width: 920px)'; +// NARROW_PHONE_MIN_WIDTH_PX less BREAKPOINT_EPSILON_PX (web/src/lib/breakpoints.ts), where +// PageShell.svelte drops the crayon strip so the back link and the suffix share the row; +// tools/tests/native-admin-narrow-breakpoint.test.mjs holds the two equal. +const NARROW = '@media (max-width: 389.98px)'; const SHEET_MAX_WIDTH_PX = 760; const PAGE_PADDING = { top: '32px', side: '16px', bottom: '72px' } as const; const GUTTER = 'clamp(20px, 5vw, 34px)'; @@ -128,8 +132,9 @@ const styles = css.create({ gap: { default: dimensions.space2, [PHONE]: '6px' }, textDecoration: 'none', }, + // The strip is aria-hidden, so the link home keeps its whole accessible name. crayons: { - display: 'flex', + display: { default: 'flex', [NARROW]: 'none' }, flexDirection: 'row', gap: { default: CRAYON.gap, [PHONE]: PHONE_CRAYON.gap }, }, diff --git a/tools/migration/check-native-web-admin.mjs b/tools/migration/check-native-web-admin.mjs index 431aef9eb..fef74c10b 100644 --- a/tools/migration/check-native-web-admin.mjs +++ b/tools/migration/check-native-web-admin.mjs @@ -236,6 +236,40 @@ async function checkWithoutJavaScript(browser, { origin }, problems) { await context.close(); } +// The narrowest phone the page sheet serves: its masthead stays inside the window, with or +// without a script, and keeps the brand link's suffix whole beside the back link. +const NARROW_VIEWPORT = { width: 320, height: 740 }; + +async function checkNarrowMasthead(browser, { origin }, problems) { + for (const javaScriptEnabled of [false, true]) { + const context = await browser.newContext({ javaScriptEnabled, viewport: NARROW_VIEWPORT }); + const tab = await context.newPage(); + await tab.goto(`${origin}${ADMIN_PATH}`); + await tab.evaluate(() => document.fonts.ready); + const fit = await tab.evaluate(() => { + const brand = [...document.querySelectorAll('a[href="/"]')].find((link) => + link.textContent.includes('Admin') + ); + return { + scrollWidth: document.documentElement.scrollWidth, + brandRight: brand?.getBoundingClientRect().right ?? null, + }; + }); + const at = `${ADMIN_PATH} at ${NARROW_VIEWPORT.width} px (JavaScript ${javaScriptEnabled ? 'on' : 'off'})`; + check( + problems, + fit.scrollWidth <= NARROW_VIEWPORT.width, + `${at} scrolls ${fit.scrollWidth} px wide` + ); + check( + problems, + fit.brandRight !== null && fit.brandRight <= NARROW_VIEWPORT.width, + `${at} ends its brand link at ${fit.brandRight} px, past the window` + ); + await context.close(); + } +} + // One completed free generation in the dev function's in-memory grant ledger. async function seedFreeGrant(serverModule) { const grants = await serverModule(FREE_GRANTS_MODULE); @@ -400,6 +434,7 @@ export async function checkNativeWebAdmin({ const problems = []; try { await checkWithoutJavaScript(browser, dev, problems); + await checkNarrowMasthead(browser, dev, problems); await checkSessionAndCodes(browser, dev, problems); await checkDeployedGuards(browser, deployed, problems); } finally { @@ -409,7 +444,7 @@ export async function checkNativeWebAdmin({ } if (problems.length) throw new Error(problems.join('\n')); log( - `[native-web-admin] ${ADMIN_PATH} signs in on the local /api function, keeps its session across a reload, lists, adds and removes codes, reads its usage figures and free grants, forgets the session on a 401 and on sign-out; deployed, its same-origin bearer writes pass the CSRF guard; it reads without JavaScript and is noindex` + `[native-web-admin] ${ADMIN_PATH} signs in on the local /api function, keeps its session across a reload, lists, adds and removes codes, reads its usage figures and free grants, forgets the session on a 401 and on sign-out; deployed, its same-origin bearer writes pass the CSRF guard; it reads without JavaScript and is noindex, and its masthead fits ${NARROW_VIEWPORT.width} px` ); } diff --git a/tools/tests/native-admin-narrow-breakpoint.test.mjs b/tools/tests/native-admin-narrow-breakpoint.test.mjs new file mode 100644 index 000000000..d68f80892 --- /dev/null +++ b/tools/tests/native-admin-narrow-breakpoint.test.mjs @@ -0,0 +1,19 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { expect, it } from 'vitest'; +import { BREAKPOINT_EPSILON_PX, NARROW_PHONE_MIN_WIDTH_PX } from '../../web/src/lib/breakpoints.ts'; +import { ROOT } from '../lib/proc.mjs'; + +const ADMIN_CHROME = join( + ROOT, + 'experiments/native-architecture/src/pageDocument/admin/adminChrome.tsx' +); + +// The admin console's masthead drops its crayon strip where shipping's PageShell does; its media +// query is a literal because React Strict DOM compiles style keys statically. +it("drops the admin masthead's crayon strip at shipping's narrow-phone breakpoint", () => { + const source = readFileSync(ADMIN_CHROME, 'utf8'); + const narrow = /const NARROW = '@media \(max-width: ([\d.]+)px\)';/.exec(source); + expect(Number(narrow?.[1])).toBe(NARROW_PHONE_MIN_WIDTH_PX - BREAKPOINT_EPSILON_PX); + expect(source).toMatch(/crayons: \{\n\s+display: \{ default: 'flex', \[NARROW\]: 'none' \},/); +}); From 22cce92974b78201cf69d37a599afabacaad0b86 Mon Sep 17 00:00:00 2001 From: Kyle Mitofsky Date: Wed, 21 Oct 2026 05:39:11 -0400 Subject: [PATCH 3/5] Open canonical section URLs on their section without a script Each layout variant keys its section ids, so a canonical /privacy#contact or the footer's /changelog#release-1-6-0 named no element and opened at the top. A duplicate of the id inside each variant cannot help: a browser follows the first element with the id in tree order, shown or not. Without a script, a document with section links now lets the window scroll in place of the page's scroller and carries one canonical target per section in a