From ea9fbf52332d2ba302870dd922577bab1fccb6e8 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Fri, 30 Oct 2026 15:41:36 -0700 Subject: [PATCH] test(core): one internal definition pins core's touch style keys The touch keys (the coarse-pointer, touch-callout and compact-size media keys core's styles read) get one internal definition, and a scan pins every quoted at-rule key in core to its reference spelling: a key that names the pointer, the callout or a compact size, or that is a few characters off a reference, must be that spelling exactly. A compiled-CSS check keeps the press paint above the coarse-pointer :active reset, and a browser proof shows a Tab's pressed overlay differing from its hover under a fine pointer and, under a coarse pointer, a finger's press painting as an image while the colour layer stays transparent in :active. #8331's change, rebased onto main as one commit. Core declares the interaction harness as a dev dependency, which its browser proof imports (the harness is on main; this is its first core user). --- .changeset/touch-style-keys.md | 7 + packages/core/package.json | 1 + ...abPressPointer.interaction.browser.spec.ts | 163 ++++++++++++++++++ .../core/src/utils/pressPaintPriority.test.ts | 80 +++++++++ .../src/utils/touchPresentation.stylex.ts | 43 +++++ .../core/src/utils/touchPresentation.test.ts | 141 ++++++++++++++- packages/core/src/utils/touchPresentation.ts | 8 +- pnpm-lock.yaml | 3 + 8 files changed, 442 insertions(+), 4 deletions(-) create mode 100644 .changeset/touch-style-keys.md create mode 100644 packages/core/src/TabList/__tests__/TabPressPointer.interaction.browser.spec.ts create mode 100644 packages/core/src/utils/pressPaintPriority.test.ts create mode 100644 packages/core/src/utils/touchPresentation.stylex.ts diff --git a/.changeset/touch-style-keys.md b/.changeset/touch-style-keys.md new file mode 100644 index 0000000000000..26e5754d78501 --- /dev/null +++ b/.changeset/touch-style-keys.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: core's touch style keys stay literal, and a test pins every one to the touch presentation resolver's constants (`touchKeys`, internal to core). An imported `defineConsts` key compiles to a different StyleX priority than the same literal. No public export and no style changes. + +@vjeux diff --git a/packages/core/package.json b/packages/core/package.json index 3375b3a134f2f..e6c8651750d5f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -864,6 +864,7 @@ "devDependencies": { "@astryxdesign/a11y-spec": "workspace:*", "@astryxdesign/cli": "workspace:*", + "@astryxdesign/interaction-harness": "workspace:*", "@babel/cli": "catalog:", "@babel/core": "catalog:", "@babel/preset-react": "catalog:", diff --git a/packages/core/src/TabList/__tests__/TabPressPointer.interaction.browser.spec.ts b/packages/core/src/TabList/__tests__/TabPressPointer.interaction.browser.spec.ts new file mode 100644 index 0000000000000..ebeff056275a8 --- /dev/null +++ b/packages/core/src/TabList/__tests__/TabPressPointer.interaction.browser.spec.ts @@ -0,0 +1,163 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file TabPressPointer.interaction.browser.spec.ts + * @input Uses @playwright/test and the interaction harness (a built + * Storybook) + * @output Proves a Tab's hover and press layer under a fine and a coarse + * primary pointer + * @position Interaction proof for packages/core/src/TabList/Tab.tsx + * + * Tab's press layer has a hover branch and a coarse-pointer rule, which + * StyleX makes exclusive. The outcome on every pointer: a fine pointer + * paints the hover, then a pressed overlay that differs from it; under a + * coarse pointer a finger's press paints as an image (`data-astryx-press`), + * and the colour layer stays transparent while the tab is `:active`. + * Chromium only: it holds a finger, and forces `:active`, through the + * DevTools protocol. + */ + +import {expect, test, type CDPSession, type Page} from '@playwright/test'; +import {openStory, withStorybook} from '@astryxdesign/interaction-harness'; + +const storybook = withStorybook(test); + +const TRANSPARENT = 'rgba(0, 0, 0, 0)'; + +/** The "Projects" tab: not the story's current one. */ +function projectsTab(page: Page) { + return page.locator('button', {hasText: 'Projects'}).first(); +} + +/** The tab's colour layer (its first aria-hidden span) and the pointer. */ +async function tabState(page: Page): Promise<{ + colour: string; + image: string; + press: string | null; + coarse: boolean; + hover: boolean; +}> { + return projectsTab(page).evaluate(element => { + const layer = element.querySelector('span[aria-hidden="true"]'); + const style = layer == null ? null : getComputedStyle(layer); + return { + colour: style?.backgroundColor ?? '', + image: style?.backgroundImage ?? '', + press: element.getAttribute('data-astryx-press'), + coarse: matchMedia('(pointer: coarse)').matches, + hover: matchMedia('(hover: hover)').matches, + }; + }); +} + +/** The layer's colour once its transition has landed (three equal reads). */ +async function settledColour(page: Page): Promise { + let last = ''; + let same = 0; + for (let read = 0; read < 60 && same < 3; read++) { + const colour = (await tabState(page)).colour; + same = colour === last ? same + 1 : 1; + last = colour; + await page.waitForTimeout(50); + } + return last; +} + +/** The tab's centre, in page coordinates. */ +async function tabCentre(page: Page): Promise<{x: number; y: number}> { + const box = await projectsTab(page).boundingBox(); + if (box == null) { + throw new Error('the Projects tab has no box'); + } + return {x: box.x + box.width / 2, y: box.y + box.height / 2}; +} + +/** A finger held on the tab's centre; returns the lift. */ +async function holdFinger( + page: Page, + cdp: CDPSession, +): Promise<() => Promise> { + await cdp.send('Input.dispatchTouchEvent', { + type: 'touchStart', + touchPoints: [await tabCentre(page)], + }); + return async () => { + await cdp.send('Input.dispatchTouchEvent', { + type: 'touchEnd', + touchPoints: [], + }); + }; +} + +/** Puts the tab in `:active` (or out of it) through the DevTools protocol. */ +async function forceActive(cdp: CDPSession, isActive: boolean): Promise { + await cdp.send('DOM.enable'); + await cdp.send('CSS.enable'); + const {root} = await cdp.send('DOM.getDocument', {depth: -1}); + const {nodeIds} = await cdp.send('DOM.querySelectorAll', { + nodeId: root.nodeId, + selector: 'button', + }); + for (const nodeId of nodeIds) { + const {outerHTML} = await cdp.send('DOM.getOuterHTML', {nodeId}); + if (outerHTML.includes('Projects')) { + await cdp.send('CSS.forcePseudoState', { + nodeId, + forcedPseudoClasses: isActive ? ['active'] : [], + }); + return; + } + } + throw new Error('the Projects tab has no node'); +} + +test.describe('Tab press layer by pointer', () => { + test.skip( + ({browserName}) => browserName !== 'chromium', + 'Chromium holds a finger and forces :active through the DevTools protocol', + ); + + test.describe('fine pointer with hover', () => { + test.use({isMobile: false, hasTouch: false}); + test('paints the hover, then a pressed overlay that differs from it', async ({ + page, + }) => { + await openStory(page, storybook(), 'core-tablist--default'); + await projectsTab(page).hover(); + const state = await tabState(page); + expect(state.coarse).toBe(false); + expect(state.hover).toBe(true); + await expect + .poll(async () => (await tabState(page)).colour) + .not.toBe(TRANSPARENT); + const hovered = await settledColour(page); + await page.mouse.down(); + // The pressed overlay is its own colour, not the hover paint held on. + await expect + .poll(async () => (await tabState(page)).colour) + .not.toBe(hovered); + expect(await settledColour(page)).not.toBe(hovered); + await page.mouse.up(); + }); + }); + + test("coarse pointer: a finger's press paints as an image, and the colour layer stays transparent while the tab is :active", async ({ + page, + }) => { + await openStory(page, storybook(), 'core-tablist--default'); + const cdp = await page.context().newCDPSession(page); + expect((await tabState(page)).coarse).toBe(true); + // The finger's press: the press model marks the tab, and the layer + // paints the pressed overlay as an image. + const lift = await holdFinger(page, cdp); + await expect.poll(async () => (await tabState(page)).press).toBe('on'); + expect((await tabState(page)).image).not.toBe('none'); + await lift(); + await expect.poll(async () => (await tabState(page)).press).not.toBe('on'); + // The tab in :active under the coarse pointer: the reset keeps the + // colour layer transparent, past any transition. + await forceActive(cdp, true); + expect(await settledColour(page)).toBe(TRANSPARENT); + await forceActive(cdp, false); + }); +}); diff --git a/packages/core/src/utils/pressPaintPriority.test.ts b/packages/core/src/utils/pressPaintPriority.test.ts new file mode 100644 index 0000000000000..e39035dd39785 --- /dev/null +++ b/packages/core/src/utils/pressPaintPriority.test.ts @@ -0,0 +1,80 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file pressPaintPriority.test.ts + * @input Uses vitest, Babel with the StyleX plugin (the repository's shared + * module resolution) and interactionOverlay.stylex.ts + * @output Checks, in the compiled CSS, that the press paint outranks the + * coarse-pointer `:active` reset of the overlay + * @position Regression test for the cascade order of the press overlay + * + * WebKit applies `:active` during a touch. Under a coarse pointer the overlay + * turns the bare `:active` image off, and the press paint + * (`[data-astryx-press="on"]`) must still win: its rule has to sit in a + * higher StyleX priority than the reset. The order is a property of how + * each key is spelled: an imported `defineConsts` key compiles to a higher + * priority than the same literal, which put the reset above the paint and + * erased the touch-down frame. + */ + +import fs from 'node:fs'; +import {createRequire} from 'node:module'; +import path from 'node:path'; +import {describe, expect, it} from 'vitest'; +import stylexResolution from '../../../../scripts/stylex-module-resolution.cjs'; + +type Rule = [string, {ltr: string}, number]; + +// Babel's types are not part of core's typecheck; type the one call used. +const {transformSync} = createRequire(import.meta.url)('@babel/core') as { + transformSync: ( + code: string, + options: Record, + ) => {metadata?: unknown} | null; +}; + +function compiledRules(file: string): Rule[] { + const output = transformSync(fs.readFileSync(file, 'utf8'), { + filename: file, + babelrc: false, + configFile: false, + parserOpts: {plugins: ['typescript', 'jsx']}, + plugins: [ + [ + '@stylexjs/babel-plugin', + { + dev: false, + runtimeInjection: false, + genConditionalClasses: true, + treeshakeCompensation: true, + unstable_moduleResolution: stylexResolution.stylexModuleResolution, + }, + ], + ], + }); + return (output?.metadata as {stylex?: Rule[]}).stylex ?? []; +} + +describe('press overlay cascade', () => { + it('ranks the press paint above the coarse-pointer :active reset', () => { + const rules = compiledRules( + path.resolve(__dirname, 'interactionOverlay.stylex.ts'), + ); + const resets = rules.filter( + // Matched without its condition's text: an imported key compiles to a + // placeholder until the bundle step. + ([, {ltr}]) => + ltr.includes(':active') && ltr.includes('background-image:none'), + ); + const paints = rules.filter( + ([, {ltr}]) => + ltr.includes('[data-astryx-press="on"]') && + ltr.includes('background-image:'), + ); + expect(resets.length).toBeGreaterThan(0); + expect(paints.length).toBeGreaterThan(0); + const highestReset = Math.max(...resets.map(([, , priority]) => priority)); + const lowestPaint = Math.min(...paints.map(([, , priority]) => priority)); + expect(highestReset).toBeLessThan(lowestPaint); + }); +}); diff --git a/packages/core/src/utils/touchPresentation.stylex.ts b/packages/core/src/utils/touchPresentation.stylex.ts new file mode 100644 index 0000000000000..f571303739d19 --- /dev/null +++ b/packages/core/src/utils/touchPresentation.stylex.ts @@ -0,0 +1,43 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file touchPresentation.stylex.ts + * @input Uses StyleX defineConsts + * @output Exports touchKeys: the touch presentation resolver's queries as + * StyleX at-rule keys + * @position The reference spellings of the touch, compact and iOS-gate + * conditions. Core styles write these as literal keys wherever they need + * them (`spec:AST-094` FR13, `spec:AST-012` FR9), and a test pins each + * literal to these values; utils/touchPresentation.ts holds the same + * queries for script (matchMedia) + * + * These are the spellings to match. A style that changes with these + * conditions writes the same literal key (`'@media (pointer: coarse)'`, the + * iOS gate as that media key outside `'@supports (-webkit-touch-callout: + * none)'`), and a test pins every literal in core to these values. Do not use + * an imported key as a computed StyleX key: it compiles to a different + * priority than the literal (6000 against 3200) and reorders the cascade. + * The iOS supports gate is only for a style iOS WebKit itself requires (the + * 16px input floor), never a behaviour. + * + * SYNC: When modified, update touchPresentation.ts and + * touchPresentation.test.ts. + */ + +import * as stylex from '@stylexjs/stylex'; + +export const touchKeys = stylex.defineConsts({ + /** The primary pointer is coarse (TOUCH_QUERY). */ + touch: '@media (pointer: coarse)', + /** The viewport is compact (COMPACT_QUERY, `spec:AST-012` FR9). */ + compact: '@media (width < 768px), (height < 500px)', + /** Both: a presentation such as a sheet in place of a popover. */ + compactTouch: + '@media (width < 768px) and (pointer: coarse), (height < 500px) and (pointer: coarse)', + /** + * The iOS WebKit gate, inside `touch` (IOS_SUPPORTS_QUERY): only for a + * style iOS WebKit itself requires (the 16px input floor), never a + * behaviour; touch behaviour is one model on every touch device. + */ + iosSupports: '@supports (-webkit-touch-callout: none)', +}); diff --git a/packages/core/src/utils/touchPresentation.test.ts b/packages/core/src/utils/touchPresentation.test.ts index 9d62a1379b88c..90b9507645f79 100644 --- a/packages/core/src/utils/touchPresentation.test.ts +++ b/packages/core/src/utils/touchPresentation.test.ts @@ -22,6 +22,7 @@ import { COMPACT_TOUCH_PRESENTATION_QUERY, useAdaptivePresentation, } from '../hooks/useAdaptivePresentation'; +import {reducedMotionKeys} from '../motion/reducedMotion.stylex'; import {DEFAULT_COMPACT_DEFINITION} from '../theme/themeAdaptations'; import {Theme, defineTheme} from '../theme'; import { @@ -33,6 +34,7 @@ import { readTouchPresentation, useTouchPresentation, } from './touchPresentation'; +import {touchKeys} from './touchPresentation.stylex'; /** * A device: what its primary pointer is, how big its viewport is, and @@ -90,6 +92,32 @@ afterEach(() => { vi.unstubAllGlobals(); }); +/** + * The Levenshtein distance between two strings, or more than `limit` as soon + * as it must exceed it: how many characters a spelling is off another. + */ +function editDistance(a: string, b: string, limit: number): number { + if (Math.abs(a.length - b.length) > limit) { + return limit + 1; + } + let previous = Array.from({length: b.length + 1}, (_, j) => j); + for (let i = 1; i <= a.length; i++) { + const current = [i]; + for (let j = 1; j <= b.length; j++) { + current[j] = Math.min( + previous[j] + 1, + current[j - 1] + 1, + previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1), + ); + } + if (Math.min(...current) > limit) { + return limit + 1; + } + previous = current; + } + return previous[b.length]; +} + describe('touch presentation', () => { it('keys touch on the primary pointer and compact on the width', () => { const {result} = renderHook(() => useTouchPresentation()); @@ -181,6 +209,116 @@ describe('touch presentation', () => { }); }); + it('holds the same keys the resolver defines', () => { + expect(touchKeys.touch).toBe(`@media ${TOUCH_QUERY}`); + expect(touchKeys.compact).toBe(`@media ${COMPACT_QUERY}`); + expect(touchKeys.compactTouch).toBe(`@media ${COMPACT_TOUCH_QUERY}`); + expect(touchKeys.iosSupports).toBe(`@supports ${IOS_SUPPORTS_QUERY}`); + expect(IOS_TOUCH_STYLE_KEYS).toEqual([ + touchKeys.touch, + touchKeys.iosSupports, + ]); + }); + + it('keeps every touch and Reduce Motion key literal in core: no computed StyleX key', () => { + // An imported defineConsts at-rule key compiles to a different StyleX + // priority than the same literal (6000 vs 3200), which reorders the + // cascade; core spells the keys and matches them to the constants. + const root = path.resolve(__dirname, '..'); + const computed: string[] = []; + const walk = (dir: string): void => { + for (const entry of fs.readdirSync(dir, {withFileTypes: true})) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name !== '__tests__' && entry.name !== 'node_modules') { + walk(full); + } + } else if ( + /\.(ts|tsx)$/.test(entry.name) && + !/\.test\.|\.spec\./.test(entry.name) + ) { + fs.readFileSync(full, 'utf8') + .split('\n') + .forEach((line, index) => { + if ( + !/^\s*(\*|\/\/)/.test(line) && + /\[(touchKeys|reducedMotionKeys)\.[a-zA-Z]+\]\s*:/.test(line) + ) { + computed.push(`${path.relative(root, full)}:${index + 1}`); + } + }); + } + } + }; + walk(root); + expect(computed).toEqual([]); + }); + + it('keeps every quoted at-rule key in core that names the pointer, the touch callout or a compact size, or that is a few characters off a reference spelling, one of the reference spellings', () => { + // Every quoted string that starts with `@` is read, whatever its at-rule + // is spelled. Two rules, so no drift depends on which character moved: + // - one that names the pointer or the touch callout (any word of either + // query, any case), or a compact size with its breakpoint, is one of + // the resolver's own spellings or a query the presentation does not + // decide (a second pointer, a fine pointer, Reduce Motion split by + // pointer); + // - one within three characters of any of those spellings, case aside, + // is that spelling exactly: a capital, a digit or a typo anywhere, in + // the at-rule, a feature or a value, fails. + const pointerKeys = new Set([ + touchKeys.touch, + touchKeys.compactTouch, + touchKeys.iosSupports, + '@media (any-pointer: coarse)', + '@media (pointer: fine)', + reducedMotionKeys.fade, + reducedMotionKeys.instant, + ]); + const references = new Set([...pointerKeys, touchKeys.compact]); + const lowered = [...references].map(reference => reference.toLowerCase()); + const root = path.resolve(__dirname, '..'); + const drifted: string[] = []; + let pointerLiterals = 0; + const walk = (dir: string): void => { + for (const entry of fs.readdirSync(dir, {withFileTypes: true})) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name !== '__tests__' && entry.name !== 'node_modules') { + walk(full); + } + } else if ( + /\.(ts|tsx)$/.test(entry.name) && + !/\.(test|spec|stories)\./.test(entry.name) + ) { + const source = fs.readFileSync(full, 'utf8'); + for (const match of source.matchAll(/'(@[^'\n]*)'/g)) { + const key = match[1]; + if (references.has(key)) { + if (pointerKeys.has(key)) { + pointerLiterals++; + } + continue; + } + const names = + /pointer|coarse|fine|touch|callout/i.test(key) || + (/\b(width|height)\b/i.test(key) && /\b(768|500)px\b/.test(key)); + const lower = key.toLowerCase(); + const isNear = lowered.some( + reference => editDistance(lower, reference, 3) <= 3, + ); + if (names || isNear) { + drifted.push(`${path.relative(root, full)}: ${key}`); + } + } + } + } + }; + walk(root); + expect(drifted).toEqual([]); + // Core writes the touch key as a literal in many places. + expect(pointerLiterals).toBeGreaterThan(50); + }); + it('keeps every iOS gate in core spelled and nested as the resolver', () => { // The gate is the media query outside and the supports query inside; a // single supports query that names the pointer never matches anywhere. @@ -201,7 +339,8 @@ describe('touch presentation', () => { } else if ( /\.(ts|tsx)$/.test(entry.name) && !/\.test\.|\.spec\./.test(entry.name) && - entry.name !== 'touchPresentation.ts' + entry.name !== 'touchPresentation.ts' && + entry.name !== 'touchPresentation.stylex.ts' ) { const lines = fs.readFileSync(full, 'utf8').split('\n'); lines.forEach((line, index) => { diff --git a/packages/core/src/utils/touchPresentation.ts b/packages/core/src/utils/touchPresentation.ts index 4093bee836010..9cf79f845a0f9 100644 --- a/packages/core/src/utils/touchPresentation.ts +++ b/packages/core/src/utils/touchPresentation.ts @@ -46,9 +46,11 @@ * test keeps core's compact-touch keys identical to COMPACT_TOUCH_QUERY and * every touch-callout key spelled and nested as above. The iOS style gate * is only for a style iOS WebKit itself requires (core's 16px input floor); - * it never selects a behaviour. The keys are literal because the Astryx - * StyleX build does not resolve a key shared across files through - * `defineConsts`. The hook is for behaviour. It answers the server default + * it never selects a behaviour. Core spells the keys literally: an imported + * `defineConsts` key compiles to a different StyleX priority than the same + * literal, which would reorder the cascade. `touchKeys` + * (touchPresentation.stylex.ts, internal to core) holds the same keys, and a + * test keeps every literal equal to them. The hook is for behaviour. It answers the server default * (not touch, not compact) until hydrated unless the caller passes a server * hint, and follows the pointer and size when they change (a tablet docking * a mouse). diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c93bc6ef3838a..ad5e966730d45 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -882,6 +882,9 @@ importers: '@astryxdesign/cli': specifier: workspace:* version: link:../cli + '@astryxdesign/interaction-harness': + specifier: workspace:* + version: link:../../internal/interaction-harness '@babel/cli': specifier: 'catalog:' version: 7.29.7(@babel/core@7.29.7)