From 82dd7099db0ef53465fbfd4bd5f3fcab709c3d03 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Wed, 4 Nov 2026 11:55:36 -0800 Subject: [PATCH 1/3] fix(core): a press's release fades on the theme's exit motion tokens Every press release that fades now plays `--duration-exit` and `--ease-exit` instead of 200 ms, scaled by the theme's tempo, on a curve of its own: the shared overlay's touch release, the controller's release clock (which action groups read too), Switch's press layers and the list row layer. A desktop pen press, which the controller fades, moves to the tokens too. Under Reduce Motion a finger's release is no longer than 200 ms, as before. A mouse press on a desktop is unchanged. --- .changeset/list-row-press-compositor.md | 2 +- .changeset/press-release-exit-tokens.md | 7 + .../specs/AST-094-touch-presentations/spec.md | 4 + docs/specs/AST-108-theme-motion/spec.md | 2 +- docs/specs/components/Button/Button.spec.md | 2 +- docs/specs/components/List/List.spec.md | 2 +- .../ButtonPress.interaction.browser.spec.ts | 23 ++- .../ListRowPress.interaction.browser.spec.ts | 61 +++++-- ...tRowPressPaths.interaction.browser.spec.ts | 20 ++- packages/core/src/Switch/Switch.test.tsx | 6 +- packages/core/src/Switch/Switch.tsx | 13 +- .../Switch.interaction.browser.spec.ts | 73 +++++--- packages/core/src/__tests__/pressState.ts | 44 ++++- .../core/src/hooks/actionGroupTracker.test.ts | 21 ++- packages/core/src/motion/uikit/timing.ts | 12 +- packages/core/src/motion/uikit/uikit.test.ts | 2 - packages/core/src/motion/uikit/values.test.ts | 32 +--- .../src/privateAttributes/press.stylex.ts | 19 +- packages/core/src/theme/themeMotion.ts | 5 +- .../src/utils/interactionOverlay.cell.test.ts | 69 +++---- .../utils/interactionOverlay.exports.test.ts | 9 +- .../src/utils/interactionOverlay.stylex.ts | 42 ++--- packages/core/src/utils/pressFeedback.test.ts | 170 +++++++++--------- packages/core/src/utils/pressFeedback.ts | 23 ++- packages/core/src/utils/pressGesture.test.ts | 5 +- packages/core/src/utils/pressGesture.ts | 19 +- .../core/src/utils/rowPressLayer.stylex.ts | 27 --- scripts/build-css.test.mjs | 101 ++++------- 28 files changed, 417 insertions(+), 398 deletions(-) create mode 100644 .changeset/press-release-exit-tokens.md delete mode 100644 packages/core/src/utils/rowPressLayer.stylex.ts diff --git a/.changeset/list-row-press-compositor.md b/.changeset/list-row-press-compositor.md index 75ad1a8430311..3d4b3c619ddcb 100644 --- a/.changeset/list-row-press-compositor.md +++ b/.changeset/list-row-press-compositor.md @@ -2,6 +2,6 @@ '@astryxdesign/core': patch --- -[fix] A list row's touch press release keeps fading while the page runs script, once the release has started. `ListItem`, `Item` and `TreeList` rows used to freeze mid-fade until the main thread was free, and every frame of the fade repainted the row; the release now runs on the compositor, with the same color, clock and curve as before. Script that runs in the tap's own handlers, before the next frame, still holds the row pressed until it ends. Under a finger, while a row is pressed and through its release, the press paints on the row's `::before` and the row is its own stacking context; at rest nothing of it applies, so a row's own `::before` decoration and its children's stacking are as they were. During a press the layer is the row's `::before`, and any `::before` property a caller sets, from `xstyle` (which `Item` composes after the layer) or from a stylesheet, wins over the layer's: a decoration with its own size and background jumps to the row's corner, and the row shows no pressed fill until the release ends. Decoration that should stay through a press belongs on `::after` or a child element. With a mouse or trackpad the row is unchanged: no layer and no new stacking context. +[fix] A list row's touch press release keeps fading while the page runs script, once the release has started. `ListItem`, `Item` and `TreeList` rows used to freeze mid-fade until the main thread was free, and every frame of the fade repainted the row; the release now runs on the compositor, with the same color as before. Script that runs in the tap's own handlers, before the next frame, still holds the row pressed until it ends. Under a finger, while a row is pressed and through its release, the press paints on the row's `::before` and the row is its own stacking context; at rest nothing of it applies, so a row's own `::before` decoration and its children's stacking are as they were. During a press the layer is the row's `::before`, and any `::before` property a caller sets, from `xstyle` (which `Item` composes after the layer) or from a stylesheet, wins over the layer's: a decoration with its own size and background jumps to the row's corner, and the row shows no pressed fill until the release ends. Decoration that should stay through a press belongs on `::after` or a child element. With a mouse or trackpad the row is unchanged: no layer and no new stacking context. @vjeux diff --git a/.changeset/press-release-exit-tokens.md b/.changeset/press-release-exit-tokens.md new file mode 100644 index 0000000000000..475fa26a0ea6b --- /dev/null +++ b/.changeset/press-release-exit-tokens.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] A press's release now fades on the theme's exit motion tokens, `--duration-exit` and `--ease-exit`, instead of 200 ms, scaled by the theme's `tempo`, on a curve of its own. This covers every release that fades: a finger, mouse, pen or key press on a phone or tablet, and a pen press on a desktop, on buttons, cards, switches, checkboxes, list and tree rows, tabs, menu items and action groups. With the default tokens the release is 175 ms and holds its colour a little longer before it goes. A theme that changes `--duration-exit` or `--ease-exit` now changes the release too; a theme's `tempo` still does, through `--duration-exit`. Under Reduce Motion a finger's release still fades for no more than 200 ms. A mouse press on a desktop is unchanged. + +@cixzhang diff --git a/docs/specs/AST-094-touch-presentations/spec.md b/docs/specs/AST-094-touch-presentations/spec.md index f01a260ee831e..0d65f89e670ab 100644 --- a/docs/specs/AST-094-touch-presentations/spec.md +++ b/docs/specs/AST-094-touch-presentations/spec.md @@ -229,6 +229,10 @@ a measured, sourced difference exists. and MUST keep advancing at display rate through a main-thread block. It MUST NOT animate layout properties, and MUST NOT step a frame from script when the compositor can run the curve. + A press shows at once. Every press release that fades does so on the + theme's exit motion tokens (`--duration-exit`, `--ease-exit`), on every + device and pointer, by the owner's decision (2026-11-03); under Reduce + Motion, `design:reduced-motion` DR2 applies. - **FR6 — Motion is interruptible.** A finger MUST be able to catch any animation mid-flight at the value on screen. That value and its velocity MUST come from the curve, not from a layout read. A new target MUST keep the diff --git a/docs/specs/AST-108-theme-motion/spec.md b/docs/specs/AST-108-theme-motion/spec.md index cffa239ea7f82..377cecdc12f03 100644 --- a/docs/specs/AST-108-theme-motion/spec.md +++ b/docs/specs/AST-108-theme-motion/spec.md @@ -173,7 +173,7 @@ defineTheme({ `spec:AST-012` FR4 (`reducedMotion` stays root-owned), `architecture:theme-application` INV1 (`'always'` reaches nested providers), `architecture:theme-authoring-contract` (the internal motion resolver), - `component:Button` FR15 (its release fade follows `tempo`), the draft `design:motion` DR3, and `spec:AST-090` are amended to + `component:Button` FR15 (its release fade follows `tempo` through `--duration-exit`, which references a tempo-scaled duration), the draft `design:motion` DR3, and `spec:AST-090` are amended to match. ## Verification diff --git a/docs/specs/components/Button/Button.spec.md b/docs/specs/components/Button/Button.spec.md index abd2f106d4776..1fa08e68802f5 100644 --- a/docs/specs/components/Button/Button.spec.md +++ b/docs/specs/components/Button/Button.spec.md @@ -131,7 +131,7 @@ Consumer migration instructions belong in consumer docs and release notes. | FR12 | With `tooltip`, the tooltip attaches to the Button element itself (no wrapper element), opens above it, and its content is added to `aria-describedby` after any caller value, unless its trimmed text equals the Button's accessible name as its props decide it (the derived or caller `aria-label`, or `label` when `icon` and `endContent` render nothing or a decorative Icon), as Avatar AR5 does, so a tooltip that repeats the name is not read twice. When the name comes from elsewhere (`aria-labelledby`, or other content in `icon` or `endContent`), the tooltip still describes the Button. IconButton inherits this. | Current source; real-browser accessibility test | | FR13 | A standalone Button paints the shadow token for its `elevation`. Inside a ButtonGroup it paints no shadow and reflects `data-elevation="none"`, whatever `elevation` says. | `family:buttons` FR8–FR10; current source and tests | | FR14 | The root carries `astryx-button` and reflects the effective `data-variant`, `data-size`, and `data-elevation`. | `family:buttons` FR10; `architecture:component-theming-surface`; tests | -| FR15 | Hover paints the shared interaction overlay on hover-capable pointers, and a press paints the shared pressed overlay through the press controller: at once under a mouse or a pen (`spec:AST-094` FR11's fine-pointer profile) and on FR11's control profile under a finger, fading out over 200 ms on release at `tempo` 1, scaled by the theme's `tempo` (`spec:AST-108` FR4). Without a reduced-motion preference, a standalone Button also scales to 0.98 on a mouse press, or while the touch press reads on; under reduced motion it MUST NOT scale (`design:reduced-motion` DR1). All of this paint follows the element's exposed disabled state: a Button that is natively disabled or exposes `aria-disabled="true"` (busy-only, disabled with a tooltip, or a caller's own `aria-disabled`) shows no overlay and no press scale, even when it stays operable. Inside a ButtonGroup the press scale does not apply. Button's hover and press transitions are fast, and instant under reduced motion. Implementation gap: the 0.98 press scale also applies under reduced motion; only its transition becomes instant. | Current source and tests; `spec:AST-094` FR11 and DEC-21 for the mouse and pen press | +| FR15 | Hover paints the shared interaction overlay on hover-capable pointers, and a press paints the shared pressed overlay through the press controller: at once under a mouse or a pen (`spec:AST-094` FR11's fine-pointer profile) and on FR11's control profile under a finger, fading out on release on the theme's exit motion tokens (`--duration-exit`, `--ease-exit`; `spec:AST-094` FR5). Without a reduced-motion preference, a standalone Button also scales to 0.98 on a mouse press, or while the touch press reads on; under reduced motion it MUST NOT scale (`design:reduced-motion` DR1). All of this paint follows the element's exposed disabled state: a Button that is natively disabled or exposes `aria-disabled="true"` (busy-only, disabled with a tooltip, or a caller's own `aria-disabled`) shows no overlay and no press scale, even when it stays operable. Inside a ButtonGroup the press scale does not apply. Button's hover and press transitions are fast, and instant under reduced motion. Implementation gap: the 0.98 press scale also applies under reduced motion; only its transition becomes instant. | Current source and tests; `spec:AST-094` FR11 and DEC-21 for the mouse and pen press | | FR16 | The ghost variant carries the edge-compensation marker so an enclosing container can align it flush with its edge; other variants do not. | Current source; edge-compensation tests | | FR17 | `ref`, `className`, `style`, `xstyle`, and other DOM props reach the root element. A caller's own `aria-busy` and `aria-disabled` are kept beside Button's own (either one sets the attribute); a caller `aria-disabled` changes exposure and paint only and does not make the Button inert. The tooltip trigger shares the caller's `ref`, which stays attached across rerenders. | Current source and tests | diff --git a/docs/specs/components/List/List.spec.md b/docs/specs/components/List/List.spec.md index 5dec5d8576136..ca0af676dd7c2 100644 --- a/docs/specs/components/List/List.spec.md +++ b/docs/specs/components/List/List.spec.md @@ -130,7 +130,7 @@ Consumer migration instructions belong in consumer docs and release notes. | FR8 | `outset` and `bleed` are valid only under a host state whose published inline inset matches its applied padding: a visually neutral host, such as a Layout region with explicit padding or a padded ScrollableArea, or the content area of a Card, Section (any variant), Dialog, or BottomSheet. Those hosts bound their padding with a border, background, or clip; FR6 keeps row paint inside the padding edge, so it never covers the border or changes the host's outline. Automatic LayoutContent/LayoutPanel states with a known publication mismatch are not eligible until that gap is fixed, and Drawer's content area is not admitted until it has its own evidence. | `architecture:container-padding/INV8–INV11`; real-browser evidence in each admitted host | Accepted contract | | FR9 | The list element containing the rows MUST clip in the inline axis with `overflow-inline: clip`, never `hidden`, except for static Markdown document lists that expose no swipe actions (DEC-5). Those Markdown lists, ordered or unordered, MAY use visible inline overflow so their own or descendant number markers remain whole while retaining AST-061 FR11's text starts and nested insets. General Lists and any collection with swipe-capable rows MUST retain the clip. Under `outset` and `bleed` its inline edges MUST reach the host's padding edge, so the clip never cuts resting row paint and a swiped row paints no further than that edge. | `spec:AST-057/FR9`; `component:List/DEC-5`; `spec:AST-061/FR11` | Verified by `List.test.tsx` and real-browser geometry | | FR10 | Under a coarse primary pointer (`spec:AST-094` FR13), every ListItem row MUST offer a hit target at least 44 px tall, through its padding or an invisible hit area, with no visual change, where its neighbors leave room: the hit area never overlaps a neighboring row's or control's target, and between close neighbors it is clamped to half the gap; row height and geometry stay as they are at every density. A fine pointer keeps today's hit target. | Owner decision, 2026-10-26: no row-height change, a 44 px touch target | Accepted contract | -| FR11 | Under a coarse primary pointer, an interactive row's press MUST follow the cell profile (`spec:AST-094` FR11): it paints the `--color-overlay-pressed` token above the row's background and below its content without adding an element, and its release fades over the press model's release duration and curve on the compositor (`spec:AST-094` FR5). The row's dividers stay drawn while it is pressed, in every theme. At rest the row's paint and stacking are as they were. Under the touch presentation, an interactive Item, ListItem or TreeList row uses its `::before` as the press layer, and a pressed row's divider keeps its resting colour. A caller's own `::before` on such a row merges into the press layer only while it is pressed; use `::after` or a child for decoration. | `spec:AST-094` FR5, FR11; `component:List/DEC-6` | Accepted contract | +| FR11 | Under a coarse primary pointer, an interactive row's press MUST follow the cell profile (`spec:AST-094` FR11): it paints the `--color-overlay-pressed` token above the row's background and below its content without adding an element, and its release fades on the theme's exit motion tokens (`--duration-exit`, `--ease-exit`) on the compositor (`spec:AST-094` FR5). The row's dividers stay drawn while it is pressed, in every theme. At rest the row's paint and stacking are as they were. Under the touch presentation, an interactive Item, ListItem or TreeList row uses its `::before` as the press layer, and a pressed row's divider keeps its resting colour. A caller's own `::before` on such a row merges into the press layer only while it is pressed; use `::after` or a child for decoration. | `spec:AST-094` FR5, FR11; `component:List/DEC-6` | Accepted contract | | FR12 | List MUST take an `animatedLayout` handle (`spec:AST-105` FR1) and report each row inserted, removed, or moved between renders. With a handle, an inserted row enters, a removed row collapses in place, inert and hidden from assistive technology until it is gone, and a row crossing rows moving the other way rides above them on the list's surface, while the rows after a change glide to their new places (`spec:AST-105` FR3), all starting on one frame on the shared motion role (`spec:AST-105` FR4). Without a handle the list changes at once (`spec:AST-105` FR2). A row's identity is its `itemKey` (`spec:AST-103` DEC-4); a row without one changes at once. With a handle, a row also reports a change to its start or end content: content that appears enters, content that leaves exits (a slot that sets the row's height hides at once instead, and the row's other content and the rows after it take their final places at once, as `spec:AST-105`'s Coverage states), content replaced in the same slot swaps in place (`spec:AST-105` FR4, FR8), and the row's other content glides within the row; a slot whose content changes without changing size does not animate. The reorder handle's appearance (`spec:AST-103`) is such a change. A slot control that leaves is inert and hidden from assistive technology as its exit starts; if it held focus, focus moves to the row's focus target (or to the row, which takes focus programmatically for the handoff) without scrolling. | `spec:AST-105` FR1–FR5; `spec:AST-103` DEC-4; `spec:AST-105` FR8; `spec:AST-103` FR2 (the handle renders only with `reorderable`) | Accepted contract | | FR13 | A row menu on a ListItem attaches to the row's own `
  • `, keeping `ul > li` with no wrapper element: the caller passes the ListItem's ref to `useContextMenu` (`spec:AST-107` FR8), with no ListItem prop of its own; the whole row is its hit area, it opens on the context request (a long press, a right click, or Shift+F10), under a finger, the row takes the press paint (FR11; on a plain row, part of OQ2) and lifts as the menu grows from it, `useContextMenu` lifting the element it is attached to (`spec:AST-094` FR31, FR33), while a mouse, the keyboard, and `presentation: 'popover'` open the released menu without the lift (`spec:AST-094` FR41), and focus returns to the row's focus target when the menu closes. A row that is already focusable (`onClick` or `href`) takes Shift+F10 and the context-menu key directly, and a row with an `interactiveRef` takes them on that element; neither shows a "More actions" button; a row with none of these shows a visible "More actions" button named with the row's label, such as "More actions for Inbox", its keyboard and screen-reader path to the menu. When a menu opens, focus moves into it; when it closes, focus returns to the row's focus target, or to the "More actions" button on a row that shows one. Who moves focus there, since `useContextMenu` returns focus to the `
  • `, which takes none, is part of OQ2. A reorderable row always has its handle (`spec:AST-103` FR3), so when it also has a row menu, a hold on the handle picks the row up and a hold anywhere else on the row opens the menu. | Owner decision, 2026-10-26; `spec:AST-107` FR8 | Accepted contract | diff --git a/packages/core/src/Button/__tests__/ButtonPress.interaction.browser.spec.ts b/packages/core/src/Button/__tests__/ButtonPress.interaction.browser.spec.ts index 3386f5f53eb22..3d8f9a8c62636 100644 --- a/packages/core/src/Button/__tests__/ButtonPress.interaction.browser.spec.ts +++ b/packages/core/src/Button/__tests__/ButtonPress.interaction.browser.spec.ts @@ -40,12 +40,15 @@ import {scroll} from '../../motion/uikit/scroll'; const storybook = withStorybook(test); -/** - * The release clock: `PRESS_FADE_MS` in utils/pressGesture.ts, which this - * runner cannot import (it compiles StyleX). pressFeedback.test.ts holds the - * two equal. - */ -const RELEASE_MS = 200; +/** The release clock where the press paints: the theme's `--duration-exit`, ms. */ +async function exitMsOf(surface: Locator): Promise { + return surface.evaluate(el => { + const time = getComputedStyle(el) + .getPropertyValue('--duration-exit') + .trim(); + return time.endsWith('ms') ? parseFloat(time) : parseFloat(time) * 1000; + }); +} const CONTENT_TOUCH_DELAY_MS = scroll.delaysContentTouchesMs; /** Long enough that a press in a scroller is believed. */ const HOLD_MS = 400; @@ -140,9 +143,11 @@ test('outside a scroller, a finger’s press highlights in the frame it lands, a } expect(on[1] - down[1]).toBeLessThan(SAME_FRAME_MS); expect(fading[1] - up[1]).toBeLessThan(SAME_FRAME_MS); - // The attribute leaves when the shared release clock runs out. - expect(gone[1] - fading[1]).toBeGreaterThanOrEqual(RELEASE_MS - 5); - expect(gone[1] - fading[1]).toBeLessThan(RELEASE_MS + 80); + // The attribute leaves when the shared release clock, the theme's exit + // duration, runs out. + const releaseMs = await exitMsOf(opened.surface); + expect(gone[1] - fading[1]).toBeGreaterThanOrEqual(releaseMs - 5); + expect(gone[1] - fading[1]).toBeLessThan(releaseMs + 80); // WebKit synthesises a click only from its own tap path, not from a raw // touch sequence (harness README, engine limits); Chromium does from both. if (opened.driver.engine === 'chromium') { diff --git a/packages/core/src/List/__tests__/ListRowPress.interaction.browser.spec.ts b/packages/core/src/List/__tests__/ListRowPress.interaction.browser.spec.ts index 9c8ac9b1d3c20..77757e90e48c3 100644 --- a/packages/core/src/List/__tests__/ListRowPress.interaction.browser.spec.ts +++ b/packages/core/src/List/__tests__/ListRowPress.interaction.browser.spec.ts @@ -4,11 +4,10 @@ * @file ListRowPress.interaction.browser.spec.ts * @input The built List, TreeList and press feedback stories, real touch input * (Chromium CDP / WebKit's input command), the interaction harness, and the - * cell release clock and curve from the UIKit constants table - * (motion/uikit/timing.ts) + * default theme's exit motion tokens, which the release plays * @output Interaction and animation proofs for a list row's touch press * release: a held row paints the pressed token at full strength, and the - * release follows the cell release curve sampled frame by frame (both + * release follows the exit easing sampled frame by frame (both * engines); the release keeps fading on every frame while the main thread * is blocked (Chromium screencast, WebKit video); the release does no * Layout or Paint; storyboards of the release and of the blocked window @@ -59,15 +58,36 @@ import { type CastFrame, type TouchDriver, } from '@astryxdesign/interaction-harness'; -import {timing} from '../../motion/uikit/timing'; const storybook = withStorybook(test); -const RELEASE_MS = timing.press.highlightOutMs; -const releaseEase = cubicBezier(...timing.press.highlightOutCurve); +/** The release's clock and curve: the theme's exit motion tokens. */ +interface Exit { + readonly ms: number; + readonly curve: readonly [number, number, number, number]; + readonly ease: (t: number) => number; +} + +/** Reads `--duration-exit` and `--ease-exit` where the press paints. */ +async function exitOf(surface: Locator): Promise { + const {ms, curve} = await surface.evaluate(el => { + const style = getComputedStyle(el); + const time = style.getPropertyValue('--duration-exit').trim(); + const points = ( + style.getPropertyValue('--ease-exit').match(/-?\d*\.?\d+/g) ?? [] + ).map(Number); + return { + ms: time.endsWith('ms') ? parseFloat(time) : parseFloat(time) * 1000, + curve: points, + }; + }); + const points = curve as unknown as Exit['curve']; + return {ms, curve: points, ease: cubicBezier(...points)}; +} + /** The layer's opacity t ms into the release: the strength, 1 → 0. */ -const expectedStrength = (t: number) => - 1 - releaseEase(Math.min(1, Math.max(0, t / RELEASE_MS))); +const strengthAt = (exit: Exit, t: number) => + 1 - exit.ease(Math.min(1, Math.max(0, t / exit.ms))); /** Long enough that the press is believed (scroll.delaysContentTouchesMs). */ const HOLD_MS = 400; @@ -217,10 +237,11 @@ async function samplePixel( } } -test('a held row paints the pressed token and its release follows the cell release curve, sampled frame by frame', async ({ +test('a held row paints the pressed token and its release follows the exit easing, sampled frame by frame', async ({ page, }, testInfo) => { const {driver, row, center} = await openRow(page, LIST_ROW); + const exit = await exitOf(row); expect(await layerOpacity(row)).toBe(0); // Gate the page's timers before the press, so the controller's check at // the release clock (after which the release ends with its animation) can @@ -234,22 +255,22 @@ test('a held row paints the pressed token and its release follows the cell relea a => a.target?.startsWith('li') && a.kind === 'CSSAnimation', ); expect(release, JSON.stringify(held)).toBeDefined(); - expect(release?.durationMs).toBe(RELEASE_MS); + expect(release?.durationMs).toBe(exit.ms); const samples: {t: number; value: number}[] = []; - for (const t of timeline(RELEASE_MS)) { + for (const t of timeline(exit.ms)) { await seekAnimations(page, t); samples.push({t, value: await layerOpacity(row)}); } await releaseAnimations(page, {finish: true}); await thawTimers(page); - const comparison = compareCurve(samples, expectedStrength, 0.005); + const comparison = compareCurve(samples, t => strengthAt(exit, t), 0.005); await writeEvidence( 'curves/list-row-press-release', { [`${testInfo.project.name}.json`]: JSON.stringify( { - releaseMs: RELEASE_MS, - curve: timing.press.highlightOutCurve, + releaseMs: exit.ms, + curve: exit.curve, held, samples, comparison, @@ -406,6 +427,7 @@ test('a busy first frame after the lift still plays the whole release before the browserName, }) => { const {driver, row, center} = await openRow(page, LIST_ROW); + const exit = await exitOf(row); // When the press controller writes the release, 150 ms of script hold the // main thread before the first frame, so the release starts after the // block. Every frame after it is read until the attribute goes: the layer @@ -457,7 +479,10 @@ test('a busy first frame after the lift still plays the whole release before the } // The last frame still fading is at rest or within one frame of it: the // attribute goes once the release has played, however late it started. - expect(fading.at(-1)?.[1], JSON.stringify(frames)).toBeLessThanOrEqual(0.05); + const oneFrameFromRest = strengthAt(exit, exit.ms - 17) + 0.01; + expect(fading.at(-1)?.[1], JSON.stringify(frames)).toBeLessThanOrEqual( + oneFrameFromRest, + ); // And the release played on the way there, frame by frame. for (let i = 1; i < fading.length; i++) { expect(fading[i][1], JSON.stringify(frames)).toBeLessThanOrEqual( @@ -472,12 +497,13 @@ test('the release does no Layout or Paint per frame', async ({ }) => { test.skip(browserName !== 'chromium', 'rendering traces are Chromium-only'); const {driver, row, center} = await openRow(page, LIST_ROW); + const exit = await exitOf(row); let release = {from: 0, to: 0}; const started = Date.now(); const trace = await traceRendering(page, async () => { await driver.play(tap(center, {holdMs: HOLD_MS})); [release] = await waitForAnimation(row); - await page.waitForTimeout(RELEASE_MS + 100); + await page.waitForTimeout(exit.ms + 100); }); // The steady part of the release: its first and last frames may commit. const during = countBetween(trace, release.from + 30, release.to - 30); @@ -666,6 +692,7 @@ test( {tag: '@storyboard'}, async ({page}, testInfo) => { const {driver, row, center} = await openRow(page, LIST_ROW); + const exit = await exitOf(row); await installTimerGate(page); const box = await row.boundingBox(); if (box == null) { @@ -701,7 +728,7 @@ test( expect(opacities[i]).toBeLessThanOrEqual(opacities[i - 1] + 1e-6); } storyboard.frames.forEach(f => - expect(f.data.opacity as number).toBeCloseTo(expectedStrength(f.t), 2), + expect(f.data.opacity as number).toBeCloseTo(strengthAt(exit, f.t), 2), ); }, ); diff --git a/packages/core/src/List/__tests__/ListRowPressPaths.interaction.browser.spec.ts b/packages/core/src/List/__tests__/ListRowPressPaths.interaction.browser.spec.ts index 4ccf0420b9be4..87314d4b958c1 100644 --- a/packages/core/src/List/__tests__/ListRowPressPaths.interaction.browser.spec.ts +++ b/packages/core/src/List/__tests__/ListRowPressPaths.interaction.browser.spec.ts @@ -3,7 +3,8 @@ /** * @file ListRowPressPaths.interaction.browser.spec.ts * @input The built List "Interactive" and Press Feedback "Rows" stories, the - * interaction harness, and the cell release clock (motion/uikit/timing.ts) + * interaction harness, and the release clock (the default theme's + * `--duration-exit`) * @output The row press paths beside the main release proofs: a press the * browser takes for a scroll drops the highlight at once (the press * controller's cancel, unchanged by the compositor layer); Reduce @@ -34,11 +35,18 @@ import { } from '@astryxdesign/interaction-harness'; // @ts-expect-error -- pngjs ships no declarations; runtime support is pinned. import {PNG} from 'pngjs'; -import {timing} from '../../motion/uikit/timing'; const storybook = withStorybook(test); -const RELEASE_MS: number = timing.press.highlightOutMs; +/** The release clock where the press paints: the theme's `--duration-exit`, ms. */ +async function exitMsOf(surface: Locator): Promise { + return surface.evaluate(el => { + const time = getComputedStyle(el) + .getPropertyValue('--duration-exit') + .trim(); + return time.endsWith('ms') ? parseFloat(time) : parseFloat(time) * 1000; + }); +} /** The press layer's strength: 0 while the row has no layer box (at rest). */ const layerOpacity = async (row: Locator) => @@ -163,10 +171,11 @@ test('Reduce Motion keeps the release fade: a fade, not a movement', async ({ await driver.play( tap({x: box.x + box.width / 2, y: box.y + box.height / 2}, {holdMs: 300}), ); + const releaseMs = await exitMsOf(row); await holdAnimations(page); await seekAnimations(page, 0); expect(await layerOpacity(row)).toBeCloseTo(1, 2); - await seekAnimations(page, RELEASE_MS / 2); + await seekAnimations(page, releaseMs / 2); const midway = await layerOpacity(row); expect(midway).toBeGreaterThan(0.05); expect(midway).toBeLessThan(0.95); @@ -234,8 +243,9 @@ test('under a finger, a row held and released between two divided rows keeps bot }, ); // Released: halfway through the release fade. + const releaseMs = await exitMsOf(pressed); await holdAnimations(page); - await seekAnimations(page, RELEASE_MS / 2); + await seekAnimations(page, releaseMs / 2); expect(await pressState(pressed)).toBe('fading'); await expectDividersAsAtRest('releasing'); await releaseAnimations(page, {finish: true}); diff --git a/packages/core/src/Switch/Switch.test.tsx b/packages/core/src/Switch/Switch.test.tsx index 705ff15188128..c8ea267eed4ba 100644 --- a/packages/core/src/Switch/Switch.test.tsx +++ b/packages/core/src/Switch/Switch.test.tsx @@ -39,7 +39,7 @@ import { rulesDeclaredFor, rulesWithSelector, } from '../__tests__/pressState'; -import {press as uikitPress} from '../motion/uikit/timing'; +import {PRESS_FADE_MS} from '../utils/pressGesture'; import {__resetLiveRegionsForTest} from '../hooks/useAnnounce'; import {InternationalizationProvider} from '../i18n'; import {installPressFeedback} from '../utils/pressFeedback'; @@ -1200,9 +1200,7 @@ describe('Switch', () => { false, ); for (const part of [track, thumb]) { - expect( - releasesOnLayer(part, '::before', uikitPress.highlightOutMs), - ).toBe(true); + expect(releasesOnLayer(part, '::before', PRESS_FADE_MS)).toBe(true); } // Nothing reads the strength under a finger, so its main-thread // release stops there; any other pointer keeps it (the desktop rule). diff --git a/packages/core/src/Switch/Switch.tsx b/packages/core/src/Switch/Switch.tsx index c1f52a58826fd..6f291bf4ef52a 100644 --- a/packages/core/src/Switch/Switch.tsx +++ b/packages/core/src/Switch/Switch.tsx @@ -62,9 +62,9 @@ import { import english from '../i18n/generated-english/Switch/Switch.generated'; registerEnglishFallback(english); +import {reducedMotionFadeConsts} from '../motion/reducedMotion.stylex'; import { interactionOverlayStyles, - pressConsts, pressVars, } from '../utils/interactionOverlay.stylex'; import {pressHoldsDragProps} from '../privateAttributes/pressHoldsDrag.stylex'; @@ -93,14 +93,15 @@ const PRESS_FADING = '[data-astryx-press="fading"]'; /** * The shared press release, for the press layers the switch fades on the - * compositor. The clock scales by the theme's tempo, and the curve is the - * controller's own. Switch.test.tsx compares the two. + * compositor: the theme's exit motion tokens, no longer than the Reduce + * Motion fade under Reduce Motion. Switch.test.tsx compares them with + * the overlay's. * SYNC: utils/interactionOverlay.stylex.ts PRESS_RELEASE_DURATION, * PRESS_RELEASE_DURATION_REDUCED and RELEASE_EASE. */ -const PRESS_RELEASE_DURATION = `calc(${pressConsts.releaseMs}ms * min(var(--_astryx-motion-tempo, 1), 1.5))`; -const PRESS_RELEASE_DURATION_REDUCED = `min(${pressConsts.releaseMs}ms, calc(${pressConsts.releaseMs}ms * var(--_astryx-motion-tempo, 1)))`; -const PRESS_RELEASE_EASE = 'cubic-bezier(0, 0, 0.2, 1)'; +const PRESS_RELEASE_DURATION = durationVars['--duration-exit']; +const PRESS_RELEASE_DURATION_REDUCED = `min(${durationVars['--duration-exit']}, ${reducedMotionFadeConsts.duration})`; +const PRESS_RELEASE_EASE = easeVars['--ease-exit']; // The measured hit insets (utils/hitArea), 0 until measured. const HIT_TOP = 'var(--astryx-hit-inset-top, 0px)'; diff --git a/packages/core/src/Switch/__tests__/Switch.interaction.browser.spec.ts b/packages/core/src/Switch/__tests__/Switch.interaction.browser.spec.ts index b1f50c011e2aa..4df9d24a81da9 100644 --- a/packages/core/src/Switch/__tests__/Switch.interaction.browser.spec.ts +++ b/packages/core/src/Switch/__tests__/Switch.interaction.browser.spec.ts @@ -53,7 +53,6 @@ import { type CastFrame, type TouchDriver, } from '@astryxdesign/interaction-harness'; -import {press} from '../../motion/uikit/timing'; import {BROWSER_CLICK_WAIT_MS} from '../../utils/browserClickWait'; import {CLICK_GUARD_WINDOW_MS} from '../../utils/clickGuard'; @@ -81,12 +80,33 @@ const LIFT_SCALE = 1 + INSET / THUMB_OFF; * opacity-only cross-fade of this many ms or less. */ const REDUCED_FADE_MAX_MS = 200; -/** The shared press release (motion/uikit/timing.ts `press`), ms. */ -const PRESS_RELEASE_MS = press.highlightOutMs; -const pressReleaseEase = cubicBezier(...press.highlightOutCurve); -/** The press layer's opacity t ms into the release, 1 → 0. */ -const pressReleaseAt = (t: number) => - 1 - pressReleaseEase(Math.min(1, Math.max(0, t / PRESS_RELEASE_MS))); +/** The release's clock and curve: the theme's exit motion tokens. */ +interface Exit { + readonly ms: number; + readonly curve: readonly [number, number, number, number]; + readonly ease: (t: number) => number; +} + +/** Reads `--duration-exit` and `--ease-exit` where the press paints. */ +async function exitOf(surface: Locator): Promise { + const {ms, curve} = await surface.evaluate(el => { + const style = getComputedStyle(el); + const time = style.getPropertyValue('--duration-exit').trim(); + const points = ( + style.getPropertyValue('--ease-exit').match(/-?\d*\.?\d+/g) ?? [] + ).map(Number); + return { + ms: time.endsWith('ms') ? parseFloat(time) : parseFloat(time) * 1000, + curve: points, + }; + }); + const points = curve as unknown as Exit['curve']; + return {ms, curve: points, ease: cubicBezier(...points)}; +} + +/** The layer's opacity t ms into the release: the strength, 1 → 0. */ +const strengthAt = (exit: Exit, t: number) => + 1 - exit.ease(Math.min(1, Math.max(0, t / exit.ms))); interface ChangeSample { t: number; @@ -886,6 +906,7 @@ test.describe('Switch touch presentation (UISwitch, iOS 26.2), under a finger', expect(await pressState(page)).toBe('fading'); await freezeTimers(page); const row = page.locator('#storybook-root [data-astryx-pressable]').first(); + const exit = await exitOf(row); const held = await holdAnimations(page, row); // One CSS animation per press layer runs the release, the track's and // the thumb's (design:user-states DR1), each its opacity on the shared @@ -897,27 +918,27 @@ test.describe('Switch touch presentation (UISwitch, iOS 26.2), under a finger', JSON.stringify(held), ).toEqual(['astryx-switch', 'astryx-switch-thumb']); for (const animation of css) { - expect(animation.durationMs).toBe(PRESS_RELEASE_MS); + expect(animation.durationMs).toBe(exit.ms); } // The lift's settle started at the lift's touch event, a frame or two // before the attribute's change started the release. So seek on the // release's own clock. const samples: {t: number; value: number}[] = []; - for (const t of timeline(PRESS_RELEASE_MS)) { + for (const t of timeline(exit.ms)) { await seekAnimations(page, t + css[0].offsetMs); samples.push({t, value: (await pressLayer(page)) ?? 0}); } await restoreHeldRates(page); await releaseAnimations(page, {finish: true}); await thawTimers(page); - const comparison = compareCurve(samples, pressReleaseAt, 0.005); + const comparison = compareCurve(samples, t => strengthAt(exit, t), 0.005); await writeEvidence( 'curves/switch-press-release', { [`${testInfo.project.name}.json`]: JSON.stringify( { - releaseMs: PRESS_RELEASE_MS, - curve: press.highlightOutCurve, + releaseMs: exit.ms, + curve: exit.curve, held, samples, comparison, @@ -937,21 +958,24 @@ test.describe('Switch touch presentation (UISwitch, iOS 26.2), under a finger', expect(await pressLayer(page)).toBeNull(); }); - test('the press layers’ release follows the theme’s tempo, and Reduce Motion never lengthens it', async ({ + test('the press layers’ release plays the theme’s exit duration, no longer than DR2’s bound under Reduce Motion', async ({ page, }) => { - // The shared release scales its clock by the theme's tempo, up to 1.5; - // under Reduce Motion a tempo may shorten it, never lengthen it. + // The shared release plays `--duration-exit` (spec:AST-094 FR5); under + // Reduce Motion it is no longer than design:reduced-motion DR2's bound. const releaseMsUnder = async ( reducedMotion: 'reduce' | 'no-preference', ): Promise => { await page.emulateMedia({reducedMotion}); const {driver, center} = await openSwitch(page); + // On the pressable itself: a theme root nearer than the document + // declares its own exit duration. await page.evaluate(() => - document.documentElement.style.setProperty( - '--_astryx-motion-tempo', - '1.5', - ), + document + .querySelector( + '#storybook-root [data-astryx-pressable]', + )! + .style.setProperty('--duration-exit', '333ms'), ); // Hold the release timer and each release animation from its start, so // a loaded machine still reads them before the clock runs out. @@ -994,8 +1018,8 @@ test.describe('Switch touch presentation (UISwitch, iOS 26.2), under a finger', return css.map(a => a.durationMs); }; for (const [reducedMotion, releaseMs] of [ - ['no-preference', PRESS_RELEASE_MS * 1.5], - ['reduce', PRESS_RELEASE_MS], + ['no-preference', 333], + ['reduce', REDUCED_FADE_MAX_MS], ] as const) { const durations = await releaseMsUnder(reducedMotion); for (const durationMs of durations) { @@ -2210,6 +2234,13 @@ test.describe('Switch touch presentation (UISwitch, iOS 26.2), under a finger', 'screencast frame evidence is Chromium-only in Playwright (WebKit: checked on the iOS simulator instead)', ); const {driver, input, onLayer, center} = await openSwitch(page); + // A long exit duration on the pressable, so the block spans enough + // delivered frames to judge whatever the theme's own value is. + await page.evaluate(() => + document + .querySelector('#storybook-root [data-astryx-pressable]')! + .style.setProperty('--duration-exit', '400ms'), + ); // Only the press layer may change the probe. The thumb is hidden, though // its animations still run, and the colour fade is off. The value stays // off: every click is stopped before it reaches the input. diff --git a/packages/core/src/__tests__/pressState.ts b/packages/core/src/__tests__/pressState.ts index e63a562542cb6..86c9a966d285e 100644 --- a/packages/core/src/__tests__/pressState.ts +++ b/packages/core/src/__tests__/pressState.ts @@ -26,6 +26,7 @@ * SYNC: When modified, update this header. */ +import {durationDefaults} from '../theme/tokens.stylex'; import {PRESS_FADE_MS} from '../utils/pressGesture'; const PRESSED_TOKEN = '--color-overlay-pressed'; @@ -224,17 +225,52 @@ export function declaresPressPaint(el: Element): boolean { ); } -/** `.2s`, `200ms`, `0.2s` → 200; a scaled clock reads its base, at tempo 1. */ +/** + * A clock in ms as a page without a theme resolves it: `.2s`, `200ms` → 200; + * a duration token (`var(--duration-exit)`) reads its default; `min(a, b)` + * is the smaller. The press release plays the exit token, no longer than + * DR2's bound under Reduce Motion (`spec:AST-094` FR5). + */ export function durationToMs(value: string): number { - // The press release scales its base clock by the theme's tempo, capped under - // Reduce Motion: `calc( * …)`, `min(, …)` (`spec:AST-108` FR4). - const match = value.trim().match(/^(?:(?:calc|min)\(\s*)?([\d.]+)(ms|s)\b/); + const css = value.trim(); + const smaller = /^min\((.*)\)$/u.exec(css); + if (smaller != null) { + return Math.min(...topLevelArguments(smaller[1]).map(durationToMs)); + } + const token = /^var\((--[\w-]+)\)$/u.exec(css); + if (token != null) { + const fallback: string | undefined = ( + durationDefaults as Record + )[token[1]]; + return fallback == null ? Number.NaN : durationToMs(fallback); + } + const match = /^([\d.]+)(ms|s)$/u.exec(css); if (match == null) { return Number.NaN; } return Number(match[1]) * (match[2] === 's' ? 1000 : 1); } +/** `a, f(b, c)` → `['a', 'f(b, c)']`: the commas outside parentheses split. */ +function topLevelArguments(list: string): string[] { + const parts: string[] = []; + let depth = 0; + let start = 0; + for (let index = 0; index < list.length; index++) { + const char = list[index]; + if (char === '(') { + depth++; + } else if (char === ')') { + depth--; + } else if (char === ',' && depth === 0) { + parts.push(list.slice(start, index)); + start = index + 1; + } + } + parts.push(list.slice(start)); + return parts; +} + /** * Does the element run the release animation on the fading arm, for exactly * the machine's release clock ({@link PRESS_FADE_MS}), with no animation on diff --git a/packages/core/src/hooks/actionGroupTracker.test.ts b/packages/core/src/hooks/actionGroupTracker.test.ts index ba190c765c64b..771238dc4b570 100644 --- a/packages/core/src/hooks/actionGroupTracker.test.ts +++ b/packages/core/src/hooks/actionGroupTracker.test.ts @@ -21,6 +21,7 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {__resetClickGuardForTest, isClickGuardArmed} from '../utils/clickGuard'; import {PRESSED_ATTRIBUTE} from '../utils/pressFeedback'; +import {reducedMotionFadeConsts} from '../motion/reducedMotion.stylex'; import {PRESS_FADE_MS} from '../utils/pressGesture'; import { createActionGroupTracker, @@ -240,26 +241,26 @@ describe('createActionGroupTracker', () => { ]); }); - it('times the release fade by the tempo its stylesheet reads, as the press controller does (spec:AST-108 FR4)', () => { - // A rule writes the tempo; browsers inherit it from the theme, jsdom + it("times the release fade by the theme's exit duration, as the press controller does (spec:AST-094 FR5)", () => { + // A rule writes the token; browsers inherit it from the theme, jsdom // only reads it where it is declared. const style = document.createElement('style'); - style.textContent = '[data-action] { --_astryx-motion-tempo: 1.5; }'; + style.textContent = '[data-action] { --duration-exit: 333ms; }'; document.head.append(style); try { const group = tracker(); group.begin(100, 20); group.end(); - vi.advanceTimersByTime(PRESS_FADE_MS); + vi.advanceTimersByTime(332); expect(first.button.getAttribute(PRESSED_ATTRIBUTE)).toBe('fading'); - vi.advanceTimersByTime(PRESS_FADE_MS / 2); + vi.advanceTimersByTime(1); expect(first.button.hasAttribute(PRESSED_ATTRIBUTE)).toBe(false); } finally { style.remove(); } }); - it('under Reduce Motion a tempo may shorten the release fade but never lengthens it past 200 ms (spec:AST-108 FR4)', () => { + it("under Reduce Motion the release fade is no longer than design:reduced-motion DR2's bound", () => { vi.stubGlobal( 'matchMedia', (query: string): MediaQueryList => @@ -275,13 +276,17 @@ describe('createActionGroupTracker', () => { }) as MediaQueryList, ); const style = document.createElement('style'); - style.textContent = '[data-action] { --_astryx-motion-tempo: 1.5; }'; + style.textContent = '[data-action] { --duration-exit: 333ms; }'; document.head.append(style); try { const group = tracker(); group.begin(100, 20); group.end(); - vi.advanceTimersByTime(PRESS_FADE_MS); + vi.advanceTimersByTime( + Number.parseFloat(reducedMotionFadeConsts.duration) - 1, + ); + expect(first.button.getAttribute(PRESSED_ATTRIBUTE)).toBe('fading'); + vi.advanceTimersByTime(1); expect(first.button.hasAttribute(PRESSED_ATTRIBUTE)).toBe(false); } finally { style.remove(); diff --git a/packages/core/src/motion/uikit/timing.ts b/packages/core/src/motion/uikit/timing.ts index a0d4ea8d027b0..1cdb1d9fce2b9 100644 --- a/packages/core/src/motion/uikit/timing.ts +++ b/packages/core/src/motion/uikit/timing.ts @@ -50,17 +50,19 @@ export const press = { */ flashMs: 100, /** - * Highlight fade after release: UIKit's deselect cross-fade. + * Highlight fade after release: UIKit's deselect cross-fade. Reference + * only, not played: a press release plays the theme's exit motion tokens + * (`--duration-exit`, `--ease-exit`). * @source measured: Settings and Contacts, iOS 26.2, simulator driven - * with real HID touches at 60 fps (PRESS_FADE_MS / pressConsts.releaseMs, + * with real HID touches at 60 fps (touch press model, * packages/core/src/utils/pressGesture.ts, 2026-10-06) */ highlightOutMs: 200, /** * The highlight fade's curve after release: an ease-out that spends the - * whole clock fading (strength about 0.2 at the midpoint). - * @source GUESS — the release curve the touch press model ships with - * (`RELEASE_EASE`, packages/core/src/utils/interactionOverlay.stylex.ts), + * whole clock fading (strength about 0.2 at the midpoint). Reference only, + * not played: a press release plays the theme's `--ease-exit`. + * @source GUESS — the release curve the touch press model shipped with, * chosen to read as UIKit's deselect cross-fade, not read from UIKit * @measure M-PR1 */ diff --git a/packages/core/src/motion/uikit/uikit.test.ts b/packages/core/src/motion/uikit/uikit.test.ts index df4c783cd98c4..495cd82070e15 100644 --- a/packages/core/src/motion/uikit/uikit.test.ts +++ b/packages/core/src/motion/uikit/uikit.test.ts @@ -15,7 +15,6 @@ import fs from 'node:fs'; import path from 'node:path'; import {describe, expect, it} from 'vitest'; import { - PRESS_FADE_MS, PRESS_FLASH_MS, PRESS_ONSET_MS, PRESS_SCROLL_BRAKE_MS, @@ -373,7 +372,6 @@ describe('UIKit constants table: values', () => { it('press clocks agree with the touch press model they were measured for', () => { expect(scroll.delaysContentTouchesMs).toBe(PRESS_ONSET_MS); expect(timing.press.flashMs).toBe(PRESS_FLASH_MS); - expect(timing.press.highlightOutMs).toBe(PRESS_FADE_MS); expect(timing.press.scrollBrakeMs).toBe(PRESS_SCROLL_BRAKE_MS); }); diff --git a/packages/core/src/motion/uikit/values.test.ts b/packages/core/src/motion/uikit/values.test.ts index aa58c72fe32fd..8b57f74293961 100644 --- a/packages/core/src/motion/uikit/values.test.ts +++ b/packages/core/src/motion/uikit/values.test.ts @@ -2,20 +2,15 @@ /** * @file values.test.ts - * @input vitest, the UIKit constants table, the motion math, and the press - * overlay's release rule as StyleX's dev runtime injects it + * @input vitest, the UIKit constants table and the motion math * @output Checks the table's values through the math that plays them: every * spring is valid and settles; the alert spring is UIKit's legacy * derivation; a sheet's detent and presentation springs render alike; - * scroll-to-top and the rubber band reproduce what UIKit was measured doing; - * the press release curve is the press model's + * scroll-to-top and the rubber band reproduce what UIKit was measured doing * @position Testing; the source rules are uikit.test.ts */ -import * as stylex from '@stylexjs/stylex'; import {describe, expect, it} from 'vitest'; -import {rulesDeclaredFor} from '../../__tests__/pressState'; -import {interactionOverlayStyles} from '../../utils/interactionOverlay.stylex'; import {solveDecay} from '../decay'; import {rubberBand} from '../rubberBand'; import { @@ -26,7 +21,6 @@ import { type SpringDescription, } from '../spring'; import {scroll, springs} from '.'; -import {press} from './timing'; describe('UIKit constants table through the motion math', () => { const springEntries: [string, SpringDescription][] = [ @@ -95,26 +89,4 @@ describe('UIKit constants table through the motion math', () => { ).toBeLessThan(0.5); } }); - - it('the press release curve is the press model\u2019s RELEASE_EASE', () => { - // The table records the curve the touch press model ships with; this pins - // the two together so neither drifts alone. It reads the curve the - // overlay's fading arm ships (the rule StyleX injects), so it holds - // however the stylesheet spells the value. - const element = document.createElement('div'); - element.className = - stylex.props(interactionOverlayStyles.backgroundColor).className ?? ''; - const curves = rulesDeclaredFor(element) - .filter(rule => - rule - .slice(0, rule.indexOf('{')) - .includes('[data-astryx-press="fading"]'), - ) - .flatMap(rule => { - const match = - /animation-timing-function:\s*cubic-bezier\(([^)]*)\)/.exec(rule); - return match == null ? [] : [match[1].split(',').map(Number)]; - }); - expect(curves).toEqual([[...press.highlightOutCurve]]); - }); }); diff --git a/packages/core/src/privateAttributes/press.stylex.ts b/packages/core/src/privateAttributes/press.stylex.ts index 46e51d7393d70..ddeb6a358f89d 100644 --- a/packages/core/src/privateAttributes/press.stylex.ts +++ b/packages/core/src/privateAttributes/press.stylex.ts @@ -19,9 +19,9 @@ */ import * as stylex from '@stylexjs/stylex'; -import {colorVars} from '../theme/tokens.stylex'; +import {colorVars, durationVars, easeVars} from '../theme/tokens.stylex'; +import {reducedMotionFadeConsts} from '../motion/reducedMotion.stylex'; import {pressVars} from '../utils/interactionOverlay.stylex'; -import {cellPressConsts} from '../utils/rowPressLayer.stylex'; // The overlay's arm selectors (interactionOverlay.stylex.ts), restated here: // StyleX resolves a style's keys within its own file. @@ -54,13 +54,12 @@ const pressRelease = stylex.keyframes({ }); /** - * The row layer's clock as CSS. It is the strength release's clock, scaled - * by the theme's `--_astryx-motion-tempo` up to 1.5, so the layer and the - * strength land together. + * The row layer's clock as CSS: the strength release's clock, the theme's + * exit duration, so the layer and the strength land together. */ -const CELL_RELEASE = `calc(${cellPressConsts.cellReleaseMs}ms * min(var(--_astryx-motion-tempo, 1), 1.5))`; -/** The same clock under Reduce Motion: a tempo may shorten it, never lengthen it. */ -const CELL_RELEASE_REDUCED = `min(${cellPressConsts.cellReleaseMs}ms, calc(${cellPressConsts.cellReleaseMs}ms * var(--_astryx-motion-tempo, 1)))`; +const CELL_RELEASE = durationVars['--duration-exit']; +/** The same clock under Reduce Motion, no longer than the Reduce Motion fade. */ +const CELL_RELEASE_REDUCED = `min(${durationVars['--duration-exit']}, ${reducedMotionFadeConsts.duration})`; const REDUCED_MOTION = '@media (prefers-reduced-motion: reduce)'; /** Opacity 1 → 0: the row layer leaves; its fading arm runs it. */ @@ -69,8 +68,8 @@ const cellRelease = stylex.keyframes({ to: {opacity: 0}, }); -/** The release's curve: the table's gentle ease-out. */ -const RELEASE_EASE = cellPressConsts.cellReleaseEase; +/** The release's curve: the theme's exit easing, as the strength's. */ +const RELEASE_EASE = easeVars['--ease-exit']; export const rowPressLayerStyles = stylex.create({ /** diff --git a/packages/core/src/theme/themeMotion.ts b/packages/core/src/theme/themeMotion.ts index 666bd1a76b9e9..86a7db7a636d1 100644 --- a/packages/core/src/theme/themeMotion.ts +++ b/packages/core/src/theme/themeMotion.ts @@ -82,8 +82,9 @@ function msOf(value: string): number { /** * The private custom property a theme with a `tempo` writes beside its - * duration tokens: the press release fade (`utils/interactionOverlay.stylex.ts`) - * multiplies its clock by it. + * duration tokens, for a style that scales a clock of its own by it + * (ProgressBar's move). A press release needs none: it plays + * `--duration-exit`, which already follows the tempo. */ export const MOTION_TEMPO_PROPERTY = '--_astryx-motion-tempo'; diff --git a/packages/core/src/utils/interactionOverlay.cell.test.ts b/packages/core/src/utils/interactionOverlay.cell.test.ts index e051f05536e7b..b4a9fa38aac22 100644 --- a/packages/core/src/utils/interactionOverlay.cell.test.ts +++ b/packages/core/src/utils/interactionOverlay.cell.test.ts @@ -2,20 +2,18 @@ /** * @file interactionOverlay.cell.test.ts - * @input vitest, the UIKit constants table (motion/uikit/timing.ts), the press + * @input vitest, the press * layer style (`rowPressLayerStyles.cell`, privateAttributes/press.stylex.ts) and the rows that compose * it (Item, TreeList) - * @output Holds the layer's build-time clock to the constants table, and + * @output Holds the layer's release to the theme's exit motion tokens, and * asserts what a row ships for the touch press: the pressed token on a * `::before` shown at once on the on arm, an opacity release on the fading - * arm on the cell clock, and none of the overlay's own main-thread release, + * arm on the exit tokens, and none of the overlay's own main-thread release, * all under a finger; a fine pointer's row as it was (no layer, no * stacking context, the overlay's own paint); and that the layer and the - * overlay cap their release under reduced motion with the Reduce Motion - * constant, never a literal + * overlay cap their release under reduced motion with DR2's bound * @position Unit test of the row press layer (privateAttributes/press.stylex.ts - * `rowPressLayerStyles.cell`, its clocks in utils/rowPressLayer.stylex.ts - * `cellPressConsts`); the real-engine proofs (compositor, busy main + * `rowPressLayerStyles.cell`); the real-engine proofs (compositor, busy main * thread, curve) are in * List/__tests__/ListRowPress.interaction.browser.spec.ts */ @@ -27,13 +25,12 @@ import * as stylex from '@stylexjs/stylex'; import {createElement} from 'react'; import {render, screen} from '@testing-library/react'; import {describe, expect, it} from 'vitest'; -import {timing} from '../motion/uikit/timing'; import {Item} from '../Item'; import {TreeList} from '../TreeList'; import {durationToMs, rulesDeclaredFor} from '../__tests__/pressState'; +import {reducedMotionFadeConsts} from '../motion/reducedMotion.stylex'; import {interactionOverlayStyles} from './interactionOverlay.stylex'; import {PRESS_FADE_MS} from './pressGesture'; -import {cellPressConsts} from './rowPressLayer.stylex'; const ON = '[data-astryx-press="on"]'; const FADING = '[data-astryx-press="fading"]'; @@ -91,14 +88,15 @@ const values = (rules: string[], property: string) => return match == null ? [] : [match[1].trim()]; }); +/** A declared value without spaces, as StyleX may minify it. */ +const compact = (css: string) => css.replace(/\s+/g, ''); + describe('press layer clock', () => { - it('plays the strength release’s tempo clock and its Reduce Motion cap, so the layer and the strength land together (spec:AST-108 FR4, FR4a)', () => { + it("plays the strength release's clock, the exit duration with DR2's bound under Reduce Motion, so the layer and the strength land together (spec:AST-094 FR5)", () => { const clocksOf = (file: string) => - ( - readFileSync(resolve(__dirname, file), 'utf8').match( - /`(?:calc|min)\([^`]*--_astryx-motion-tempo[^`]*\)`/g, - ) ?? [] - ).map(clock => clock.replace(/\$\{[^}]+\}/g, '${ms}')); + readFileSync(resolve(__dirname, file), 'utf8').match( + /(?:durationVars\['--duration-exit'\]|`min\(\$\{durationVars\['--duration-exit'\]\}, \$\{reducedMotionFadeConsts\.duration\}\)`);/g, + ) ?? []; const layer = clocksOf('../privateAttributes/press.stylex.ts'); expect(layer.length).toBe(2); expect(new Set(layer)).toEqual( @@ -106,18 +104,11 @@ describe('press layer clock', () => { ); }); - it('is the constants table’s press release, mirrored for the stylesheet', () => { - expect(cellPressConsts.cellReleaseMs).toBe(timing.press.highlightOutMs); - expect(cellPressConsts.cellReleaseEase).toBe( - `cubic-bezier(${timing.press.highlightOutCurve.join(', ')})`, - ); - }); - - it('runs on the clock the controller checks the release at', () => { - expect(cellPressConsts.cellReleaseMs).toBe(PRESS_FADE_MS); + it('resolves, without a theme, to the clock the controller checks the release at', () => { + expect(durationToMs('var(--duration-exit)')).toBe(PRESS_FADE_MS); }); - it("the shared overlay's release plays the same curve, the table's, from its own copy", () => { + it("the shared overlay's release plays the exit easing", () => { const element = document.createElement('button'); element.className = stylex.props(interactionOverlayStyles.pressedAlpha).className ?? ''; @@ -126,11 +117,8 @@ describe('press layer clock', () => { const selector = selectorOf(rule); return selector.includes(FADING) && !selector.includes('::'); }); - // StyleX writes the curve minified: compare the control points. - const points = (css: string) => - (css.match(/-?\d*\.?\d+/g) ?? []).map(Number); - expect(values(fading, 'animation-timing-function').map(points)).toEqual([ - [...timing.press.highlightOutCurve], + expect(values(fading, 'animation-timing-function').map(compact)).toEqual([ + 'var(--ease-exit)', ]); element.remove(); }); @@ -209,20 +197,19 @@ describe.each([ expect(values(on, 'animation-name')).toEqual([]); }); - it('releases with an opacity animation on the cell clock and curve', () => { + it('releases with an opacity animation on the exit tokens', () => { const row = mount(); const fading = layerRules(row, FADING); expect(values(fading, 'animation-name')).toHaveLength(1); - // The clock, and its reduced-motion cap (DR2), both the table's value. - expect([ - ...new Set(values(fading, 'animation-duration').map(durationToMs)), - ]).toEqual([timing.press.highlightOutMs]); - // StyleX writes the curve minified (`cubic-bezier(0,0,.2,1)`): compare - // the control points. - const points = (css: string) => - (css.match(/-?\d*\.?\d+/g) ?? []).map(Number); - expect(values(fading, 'animation-timing-function').map(points)).toEqual([ - [...timing.press.highlightOutCurve], + // The clock, and its reduced-motion cap (DR2). + expect(new Set(values(fading, 'animation-duration').map(compact))).toEqual( + new Set([ + 'var(--duration-exit)', + `min(var(--duration-exit),${reducedMotionFadeConsts.duration})`, + ]), + ); + expect(values(fading, 'animation-timing-function').map(compact)).toEqual([ + 'var(--ease-exit)', ]); expect(values(fading, 'animation-fill-mode')).toEqual(['both']); }); diff --git a/packages/core/src/utils/interactionOverlay.exports.test.ts b/packages/core/src/utils/interactionOverlay.exports.test.ts index e411f6f25fe8e..6620786f345a9 100644 --- a/packages/core/src/utils/interactionOverlay.exports.test.ts +++ b/packages/core/src/utils/interactionOverlay.exports.test.ts @@ -6,11 +6,9 @@ * (interactionOverlay.stylex.ts) and the utils entry point (index.ts) * @output Asserts the list row's press layer stays internal: neither * `@astryxdesign/core/utils` nor `@astryxdesign/core/utils/interactionOverlay.stylex` - * exposes the row layer's styles (`cell`, `cellOnImage`), its clocks - * (`cellPressConsts`), - * and core's `exports` do not list the modules that hold them (`architecture:public-component-api` INV12) + * exposes the row layer's styles (`rowPressLayerStyles`, `cell`, + * `cellOnImage`), and core's `exports` do not list the modules that hold them (`architecture:public-component-api` INV12) * @position Unit test beside utils/interactionOverlay.stylex.ts and - * utils/rowPressLayer.stylex.ts */ import {readFileSync} from 'node:fs'; @@ -20,7 +18,7 @@ import * as utils from './index'; import * as overlay from './interactionOverlay.stylex'; const ROW_LAYER_STYLES = ['cell', 'cellOnImage']; -const ROW_LAYER_EXPORTS = ['cellPressConsts']; +const ROW_LAYER_EXPORTS = ['rowPressLayerStyles']; describe("the list row's press layer stays internal", () => { it('is not exported from @astryxdesign/core/utils/interactionOverlay.stylex', () => { @@ -48,7 +46,6 @@ describe("the list row's press layer stays internal", () => { const sources = Object.values(pkg.exports).map(entry => typeof entry === 'string' ? entry : entry.source, ); - expect(sources).not.toContain('./src/utils/rowPressLayer.stylex.ts'); expect(sources).not.toContain('./src/privateAttributes/press.stylex.ts'); }); }); diff --git a/packages/core/src/utils/interactionOverlay.stylex.ts b/packages/core/src/utils/interactionOverlay.stylex.ts index fdc287f0266d7..3cf8008b6a0ae 100644 --- a/packages/core/src/utils/interactionOverlay.stylex.ts +++ b/packages/core/src/utils/interactionOverlay.stylex.ts @@ -42,7 +42,7 @@ // background image, so a composer's color transition cannot fade the onset // in. `on` sets the strength to 1 on the first frame. `fading` animates it // from 1 to 0, not to the hover strength, because a finger has no hover. The -// theme's motion tempo scales the release clock (AST-108). +// release plays the theme's exit motion tokens, which follow its tempo. // // The release is an animation, not a transition. Composers own their // `transition-*` longhands, and StyleX is last-wins per property. A @@ -55,7 +55,8 @@ // `animation-*` on the element that composes them. The touch arms own both. import * as stylex from '@stylexjs/stylex'; -import {colorVars} from '../theme/tokens.stylex'; +import {reducedMotionFadeConsts} from '../motion/reducedMotion.stylex'; +import {colorVars, durationVars, easeVars} from '../theme/tokens.stylex'; /** * The touch press's strength on the surface the controller painted, 0 to 1. @@ -73,26 +74,22 @@ export const pressVars = stylex.defineVars({ }); /** - * The touch press's clocks that both the stylesheet and the controller read. - * - * `defineConsts`, so the one number is inlined here at build time and is the - * controller's constant at run time (`PRESS_FADE_MS` in pressGesture.ts): - * the release animation and the controller's clock cannot drift apart. When - * that clock runs out, the controller waits for a release animation that a - * busy main thread started late, then removes the attribute. + * The touch press's measured release clock, kept as a reference value. Press + * releases now play the theme's `--duration-exit`. */ export const pressConsts = stylex.defineConsts({ - /** How long the release takes, in ms. UIKit's deselect clock. */ + /** UIKit's deselect clock, in ms: the measured reference value. */ releaseMs: 200, }); /** - * The release's clock as CSS, scaled by the theme's `--_astryx-motion-tempo` - * up to 1.5, its maximum. The controller's timer reads the same property. + * The release's clock: the theme's exit duration. The token already + * follows the theme's tempo. The controller's timer reads the + * same property. */ -const PRESS_RELEASE_DURATION = `calc(${pressConsts.releaseMs}ms * min(var(--_astryx-motion-tempo, 1), 1.5))`; -/** The same clock under Reduce Motion: a tempo may shorten it, never lengthen it. */ -const PRESS_RELEASE_DURATION_REDUCED = `min(${pressConsts.releaseMs}ms, calc(${pressConsts.releaseMs}ms * var(--_astryx-motion-tempo, 1)))`; +const PRESS_RELEASE_DURATION = durationVars['--duration-exit']; +/** The same clock under Reduce Motion, no longer than the Reduce Motion fade. */ +const PRESS_RELEASE_DURATION_REDUCED = `min(${durationVars['--duration-exit']}, ${reducedMotionFadeConsts.duration})`; const ENABLED = ':where(:not(:disabled,[aria-disabled="true"]))'; const HOVER_HOVER = '@media (hover: hover)'; @@ -145,17 +142,12 @@ const pressRelease = stylex.keyframes({ }); /** - * The release's curve: a gentle ease-out that spends the whole clock fading - * (strength ≈ 0.2 at the midpoint), which is what a deselect crossfade reads - * as. Not the system's `--ease-standard`: that curve is for things that move, - * and is so front-loaded (strength 0.05 at the midpoint) that the release - * would read as half its clock. It is the constants table's - * `timing.press.highlightOutCurve`, which the list row's layer mirrors too - * (rowPressLayer.stylex.ts); interactionOverlay.cell.test.ts fails if either - * drifts. A copy, not an import: this module reaches nearly every component, - * and the row layer's constants stay with the rows. + * The release's curve: the theme's exit easing. The list row's layer + * (privateAttributes/press.stylex.ts) and Switch's press layers use the same + * token; interactionOverlay.cell.test.ts and Switch.test.tsx fail if they + * differ. */ -const RELEASE_EASE = 'cubic-bezier(0, 0, 0.2, 1)'; +const RELEASE_EASE = easeVars['--ease-exit']; /** * The touch press's strength and its release, on the element the controller diff --git a/packages/core/src/utils/pressFeedback.test.ts b/packages/core/src/utils/pressFeedback.test.ts index 01003cb397db5..927b1487307cb 100644 --- a/packages/core/src/utils/pressFeedback.test.ts +++ b/packages/core/src/utils/pressFeedback.test.ts @@ -12,7 +12,6 @@ */ import * as stylex from '@stylexjs/stylex'; -import {TEMPO_RANGE, expandThemeMotion} from '../theme/themeMotion'; import {readFileSync} from 'node:fs'; import {dirname, join} from 'node:path'; import {fileURLToPath} from 'node:url'; @@ -24,10 +23,8 @@ import { rulesDeclaredFor, rulesWithSelector, } from '../__tests__/pressState'; -import { - interactionOverlayStyles, - pressConsts, -} from './interactionOverlay.stylex'; +import {reducedMotionFadeConsts} from '../motion/reducedMotion.stylex'; +import {interactionOverlayStyles} from './interactionOverlay.stylex'; import { installPressFeedback, PRESSABLE_ATTRIBUTE, @@ -632,12 +629,41 @@ describe('a finger waits only inside a scroller (spec:AST-094 FR11)', () => { }); }); -describe('the release clock the browser specs read', () => { - it('is 200 ms (ButtonPress.interaction.browser.spec.ts RELEASE_MS)', () => { - expect(PRESS_FADE_MS).toBe(200); +describe('the release clock without a theme', () => { + it("is the exit duration token's default, 175 ms (spec:AST-094 FR5)", () => { + expect(PRESS_FADE_MS).toBe(175); }); }); +/** The Reduce Motion fade, ms: the longest a finger's release plays there. */ +const REDUCED_FADE_MS = Number.parseFloat(reducedMotionFadeConsts.duration); + +/** Stubs `prefers-reduced-motion: reduce` on; `vi.unstubAllGlobals` undoes it. */ +function stubReducedMotion(): void { + vi.stubGlobal( + 'matchMedia', + (query: string): MediaQueryList => + ({ + matches: /prefers-reduced-motion:\s*reduce/.test(query), + media: query, + onchange: null, + addListener: () => undefined, + removeListener: () => undefined, + addEventListener: () => undefined, + removeEventListener: () => undefined, + dispatchEvent: () => false, + }) as MediaQueryList, + ); +} + +/** Declares `css` for the test's duration and returns the remover. */ +function addRules(css: string): () => void { + const style = document.createElement('style'); + style.textContent = css; + document.head.appendChild(style); + return () => style.remove(); +} + describe('the release', () => { it('fades, then leaves no attribute behind', () => { press(rows[0], 'pointerdown', 10, 10); @@ -648,68 +674,70 @@ describe('the release', () => { expect(rows[0].hasAttribute(PRESSED_ATTRIBUTE)).toBe(false); }); - it('scales the fade by the tempo its stylesheet reads, a theme\u2019s or an adaptation rule\u2019s (spec:AST-108 FR4, component:Button FR15)', () => { - // A rule writes the tempo (a media-query adaptation, say); browsers - // inherit it from the theme, jsdom only reads it where it is declared. - const style = document.createElement('style'); - style.textContent = '.slow-row { --_astryx-motion-tempo: 1.5; }'; - document.head.appendChild(style); + it("plays the theme's exit duration, however a rule sets it (spec:AST-094 FR5)", () => { + // Browsers inherit the token from the theme; jsdom only reads it where + // it is declared. + const remove = addRules('.slow-row { --duration-exit: 333ms; }'); + rows[0].classList.add('slow-row'); + try { + press(rows[0], 'pointerdown', 10, 10); + vi.advanceTimersByTime(PRESS_ONSET_MS); + press(rows[0], 'pointerup', 10, 10); + vi.advanceTimersByTime(332); + expect(rows[0].getAttribute(PRESSED_ATTRIBUTE)).toBe('fading'); + vi.advanceTimersByTime(1); + expect(rows[0].hasAttribute(PRESSED_ATTRIBUTE)).toBe(false); + } finally { + rows[0].classList.remove('slow-row'); + remove(); + } + }); + + it('reads an exit duration written in seconds', () => { + const remove = addRules('.slow-row { --duration-exit: 0.3s; }'); rows[0].classList.add('slow-row'); try { press(rows[0], 'pointerdown', 10, 10); vi.advanceTimersByTime(PRESS_ONSET_MS); press(rows[0], 'pointerup', 10, 10); - vi.advanceTimersByTime(PRESS_FADE_MS); + vi.advanceTimersByTime(299); expect(rows[0].getAttribute(PRESSED_ATTRIBUTE)).toBe('fading'); - vi.advanceTimersByTime(PRESS_FADE_MS / 2); + vi.advanceTimersByTime(1); expect(rows[0].hasAttribute(PRESSED_ATTRIBUTE)).toBe(false); } finally { - style.remove(); + rows[0].classList.remove('slow-row'); + remove(); } }); - it('under Reduce Motion a tempo may shorten the fade but never lengthens it past 200 ms (spec:AST-108 FR4)', () => { - vi.stubGlobal( - 'matchMedia', - (query: string): MediaQueryList => - ({ - matches: /prefers-reduced-motion:\s*reduce/.test(query), - media: query, - onchange: null, - addListener: () => undefined, - removeListener: () => undefined, - addEventListener: () => undefined, - removeEventListener: () => undefined, - dispatchEvent: () => false, - }) as MediaQueryList, + it("under Reduce Motion a finger's release plays the exit duration, no longer than design:reduced-motion DR2's bound", () => { + stubReducedMotion(); + const remove = addRules( + '.slow-row { --duration-exit: 333ms; } .quick-row { --duration-exit: 120ms; }', ); - const style = document.createElement('style'); - style.textContent = - '.slow-row { --_astryx-motion-tempo: 1.5; } .quick-row { --_astryx-motion-tempo: 0.75; }'; - document.head.appendChild(style); try { rows[0].classList.add('slow-row'); press(rows[0], 'pointerdown', 10, 10); vi.advanceTimersByTime(PRESS_ONSET_MS); press(rows[0], 'pointerup', 10, 10); - vi.advanceTimersByTime(PRESS_FADE_MS); + vi.advanceTimersByTime(REDUCED_FADE_MS - 1); + expect(rows[0].getAttribute(PRESSED_ATTRIBUTE)).toBe('fading'); + vi.advanceTimersByTime(1); expect(rows[0].hasAttribute(PRESSED_ATTRIBUTE)).toBe(false); rows[0].classList.replace('slow-row', 'quick-row'); press(rows[0], 'pointerdown', 10, 10); vi.advanceTimersByTime(PRESS_ONSET_MS); press(rows[0], 'pointerup', 10, 10); - vi.advanceTimersByTime(PRESS_FADE_MS * 0.75); + vi.advanceTimersByTime(120); expect(rows[0].hasAttribute(PRESSED_ATTRIBUTE)).toBe(false); } finally { rows[0].classList.remove('slow-row', 'quick-row'); - style.remove(); + remove(); vi.unstubAllGlobals(); } }); - it('bounds the release’s tempo at its own maximum, so the bound changes nothing', () => { - // The clock's 1.5 is TEMPO_RANGE.max, and every tempo a theme writes is - // clamped to it: a theme asking for 3 writes 1.5. + it("the stylesheet's release plays the exit tokens, with DR2's bound under Reduce Motion", () => { const source = readFileSync( join( dirname(fileURLToPath(import.meta.url)), @@ -718,50 +746,17 @@ describe('the release', () => { 'utf8', ); expect(source).toContain( - `min(var(--_astryx-motion-tempo, 1), ${TEMPO_RANGE.max})`, - ); - expect(expandThemeMotion({tempo: 3})['--_astryx-motion-tempo']).toBe( - String(TEMPO_RANGE.max), + "const PRESS_RELEASE_DURATION = durationVars['--duration-exit'];", ); - }); - - it('caps the stylesheet’s release fade under Reduce Motion', () => { - const source = readFileSync( - join( - dirname(fileURLToPath(import.meta.url)), - 'interactionOverlay.stylex.ts', - ), - 'utf8', - ); - expect(source).toMatch( - /min\(\$\{pressConsts\.releaseMs\}ms, calc\(\$\{pressConsts\.releaseMs\}ms \* var\(--_astryx-motion-tempo, 1\)\)\)/, + expect(source).toContain( + "`min(${durationVars['--duration-exit']}, ${reducedMotionFadeConsts.duration})`", ); + expect(source).toContain("const RELEASE_EASE = easeVars['--ease-exit'];"); expect(source).toMatch( /'@media \(prefers-reduced-motion: reduce\)': PRESS_RELEASE_DURATION_REDUCED/, ); - }); - - it('keeps the 200 ms fade without a tempo', () => { - press(rows[0], 'pointerdown', 10, 10); - vi.advanceTimersByTime(PRESS_ONSET_MS); - press(rows[0], 'pointerup', 10, 10); - vi.advanceTimersByTime(PRESS_FADE_MS); - expect(rows[0].hasAttribute(PRESSED_ATTRIBUTE)).toBe(false); - }); - - it('times the stylesheet’s release fade by the same tempo', () => { - // The animation the attribute drives, scaled by the custom property a - // theme with a tempo writes beside its duration tokens. - const source = readFileSync( - join( - dirname(fileURLToPath(import.meta.url)), - 'interactionOverlay.stylex.ts', - ), - 'utf8', - ); - expect(source).toMatch( - /calc\(\$\{pressConsts\.releaseMs\}ms \* min\(var\(--_astryx-motion-tempo, 1\),/, - ); + // No clock of its own: the tokens already follow the theme's tempo. + expect(source).not.toContain('--_astryx-motion-tempo'); }); it('answers a tap too quick for the delay, at the release', () => { @@ -1023,21 +1018,19 @@ describe('a phone or tablet paints a mouse or Space press itself', () => { expect(rows[1].hasAttribute(PRESSED_ATTRIBUTE)).toBe(false); }); - it('fades a mouse press by the theme\u2019s tempo too (spec:AST-108 FR4, component:Button FR15)', () => { - const style = document.createElement('style'); - style.textContent = '.slow-row { --_astryx-motion-tempo: 1.5; }'; - document.head.appendChild(style); + it("fades a mouse press on the theme's exit duration too (spec:AST-094 FR5)", () => { + const remove = addRules('.slow-row { --duration-exit: 333ms; }'); rows[1].classList.add('slow-row'); try { press(rows[1], 'pointerdown', 10, 100, 'mouse'); press(rows[1], 'pointerup', 10, 100, 'mouse'); - vi.advanceTimersByTime(PRESS_FADE_MS); + vi.advanceTimersByTime(332); expect(rows[1].getAttribute(PRESSED_ATTRIBUTE)).toBe('fading'); - vi.advanceTimersByTime(PRESS_FADE_MS / 2); + vi.advanceTimersByTime(1); expect(rows[1].hasAttribute(PRESSED_ATTRIBUTE)).toBe(false); } finally { rows[1].classList.remove('slow-row'); - style.remove(); + remove(); } }); @@ -1527,8 +1520,7 @@ describe('the release fades: the arms the controller writes to', () => { ); }); - it("the machine's clock is the stylesheet's constant: one number", () => { - expect(PRESS_FADE_MS).toBe(pressConsts.releaseMs); + it("without a theme, the stylesheet's release resolves to the machine's clock: the exit token's default", () => { expect(hasReleaseFade(surface(interactionOverlayStyles.pressedAlpha))).toBe( true, ); diff --git a/packages/core/src/utils/pressFeedback.ts b/packages/core/src/utils/pressFeedback.ts index 7bd08417afc90..44323dd7282a2 100644 --- a/packages/core/src/utils/pressFeedback.ts +++ b/packages/core/src/utils/pressFeedback.ts @@ -47,6 +47,8 @@ import { pressGestureStep, } from './pressGesture'; import {isCoarsePointer, watchCoarsePointer} from './coarsePointer'; +import {reducedMotionFadeConsts} from '../motion/reducedMotion.stylex'; +import {parseMotionRoleValue} from '../motion/roles'; import {subscribeInput} from './inputStream'; import {isInsideScrollView} from './scrollView'; import {TOUCH_QUERY} from './touchPresentation'; @@ -156,19 +158,24 @@ const TIMER_MS: Record, number> = { /** * The release fade's clock on `element`, read once, at the release, from the - * property its stylesheet reads: the theme's `--_astryx-motion-tempo`. Under - * Reduce Motion a tempo may shorten the fade, never lengthen it past 200 ms. + * token its stylesheet reads: the theme's `--duration-exit`. Without a + * readable value it is the token's default. Under Reduce Motion it is no + * longer than the Reduce Motion fade, as in the stylesheet. */ export function pressFadeMs(element: Element): number { - const style = getComputedStyle(element); - const value = style.getPropertyValue('--_astryx-motion-tempo').trim(); - const read = value === '' ? Number.NaN : Number(value); - const tempo = Number.isFinite(read) && read > 0 ? read : 1; - const scaled = PRESS_FADE_MS * tempo; + const exit = + parseMotionRoleValue( + 'duration', + getComputedStyle(element).getPropertyValue('--duration-exit'), + ) ?? PRESS_FADE_MS; const reduced = typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches; - return reduced ? Math.min(scaled, PRESS_FADE_MS) : scaled; + const bound = parseMotionRoleValue( + 'duration', + reducedMotionFadeConsts.duration, + ); + return reduced && bound != null ? Math.min(exit, bound) : exit; } /** diff --git a/packages/core/src/utils/pressGesture.test.ts b/packages/core/src/utils/pressGesture.test.ts index e244a5ffc1b26..3cfe99623d930 100644 --- a/packages/core/src/utils/pressGesture.test.ts +++ b/packages/core/src/utils/pressGesture.test.ts @@ -50,9 +50,10 @@ describe('the clocks are the native ones', () => { expect(PRESS_ONSET_MS).toBe(150); }); - it("uses the scroller's own slop, the release's own fade, and a flash for a quick tap", () => { + it("uses the scroller's own slop and a flash for a quick tap, and without a theme the exit token's fade", () => { expect(PRESS_SLOP_PX).toBe(10); - expect(PRESS_FADE_MS).toBe(200); + // `--duration-exit` is `var(--duration-fast)`, 175 ms by default. + expect(PRESS_FADE_MS).toBe(175); expect(PRESS_FLASH_MS).toBe(100); }); diff --git a/packages/core/src/utils/pressGesture.ts b/packages/core/src/utils/pressGesture.ts index b1e63c0d05ddf..212186d6cb6ea 100644 --- a/packages/core/src/utils/pressGesture.ts +++ b/packages/core/src/utils/pressGesture.ts @@ -28,7 +28,8 @@ // The constants were measured against native apps. The machine knows nothing // of the DOM, so {@link pressGestureStep}'s cases are the state diagram. -import {pressConsts} from './interactionOverlay.stylex'; +import {resolveTokenReferences} from '../theme/tokenReferences'; +import {durationDefaults} from '../theme/tokens.stylex'; /** * How long a finger must stay put before the surface believes it. @@ -63,14 +64,16 @@ export const PRESS_SLOP_PX = 10; export const PRESS_FLASH_MS = 100; /** - * How long the paint takes to leave after a release: UIKit's deselect - * crossfade. The one number, shared with the stylesheet: the release - * animation in interactionOverlay.stylex.ts runs on the same constant, so the - * controller's release clock and the fade cannot drift apart. When the clock - * runs out, the controller waits for a release animation that still runs, - * then removes the attribute. + * How long the paint takes to leave after a release when no theme sets + * `--duration-exit`: that token's default. The release plays the theme's + * exit motion tokens, so the controller reads the token on + * the pressed element (`pressFadeMs` in pressFeedback.ts) and falls back to + * this. When the clock runs out, the controller waits for a release + * animation that still runs, then removes the attribute. */ -export const PRESS_FADE_MS: number = pressConsts.releaseMs; +export const PRESS_FADE_MS: number = Number.parseFloat( + resolveTokenReferences(durationDefaults)['--duration-exit'], +); /** * How recently a scroller must have moved for a touch on it to be a BRAKE diff --git a/packages/core/src/utils/rowPressLayer.stylex.ts b/packages/core/src/utils/rowPressLayer.stylex.ts deleted file mode 100644 index e350f169570fa..0000000000000 --- a/packages/core/src/utils/rowPressLayer.stylex.ts +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. - -/** - * @file rowPressLayer.stylex.ts - * @input StyleX - * @output The list row press layer's clocks (`cellPressConsts`) - * @position Internal to core: no package entry exports it. Only the row - * layer (privateAttributes/press.stylex.ts) reads it, so a component - * without a press row ships none of it; interactionOverlay.exports.test.ts - * keeps it off the published paths. - */ - -import * as stylex from '@stylexjs/stylex'; - -/** - * A list row's release (`rowPressLayerStyles.cell`), mirrored for the - * stylesheet. StyleX evaluates a style at build time and cannot read the - * constants table, so these are the table's press release values - * (`timing.press`, motion/uikit/timing.ts). interactionOverlay.cell.test.ts - * fails if they drift. - */ -export const cellPressConsts = stylex.defineConsts({ - /** `timing.press.highlightOutMs`, in ms. */ - cellReleaseMs: 200, - /** `timing.press.highlightOutCurve`, as CSS. */ - cellReleaseEase: 'cubic-bezier(0, 0, 0.2, 1)', -}); diff --git a/scripts/build-css.test.mjs b/scripts/build-css.test.mjs index 4d75f0894050d..5b2257c33f3c3 100644 --- a/scripts/build-css.test.mjs +++ b/scripts/build-css.test.mjs @@ -19,28 +19,37 @@ import {fileURLToPath} from 'node:url'; import {ensureCoreBuilt} from '../packages/cli/clients/cli/commands/ensure-core-built.mjs'; /** - * The release clock, read off its one source: `pressConsts.releaseMs` in - * utils/interactionOverlay.stylex.ts, which both the stylesheet (at build - * time) and the controller (at run time) take it from. The module itself - * cannot be imported here, since a StyleX source file only runs compiled. + * The Reduce Motion fade, read off its one source: `reducedMotionFadeConsts` + * in motion/reducedMotion.stylex.ts. A finger's press release under Reduce + * Motion is no longer than it. The module itself cannot be imported here, + * since a StyleX source file only runs compiled. */ -async function readReleaseMs() { +async function readReducedFade() { const source = await fs.readFile( path.join( path.dirname(fileURLToPath(import.meta.url)), - '../packages/core/src/utils/interactionOverlay.stylex.ts', + '../packages/core/src/motion/reducedMotion.stylex.ts', ), 'utf8', ); - const match = source.match(/releaseMs:\s*(\d+)/); + const match = source.match(/duration:\s*'([^']+)'/); if (match == null) { - throw new Error( - 'pressConsts.releaseMs not found in interactionOverlay.stylex.ts', - ); + throw new Error('reducedMotionFadeConsts.duration not found'); } - return Number(match[1]); + return match[1]; +} + +/** The release's clocks as the sheet writes them: the exit token and its Reduce Motion bound. */ +async function releaseClocks() { + return { + plain: 'var(--duration-exit)', + reduced: `min(var(--duration-exit),${await readReducedFade()})`, + }; } +/** A declared value without spaces, as the minified sheet writes it. */ +const compact = value => value.replace(/\s+/g, ''); + const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(__dirname, '..'); const CORE_DIST = path.resolve(ROOT, 'packages/core/dist'); @@ -248,17 +257,6 @@ describe('build-css astryx.css', () => { }); describe('the touch press release (utils/interactionOverlay.stylex.ts)', () => { - /** `.2s`, `200ms`, `0.2s` → 200. */ - const toMs = value => { - // The release scales its base clock by the theme's tempo, capped under - // Reduce Motion (`calc( * …)`, `min(, …)`); it reads at tempo 1. - const match = value - .trim() - .match(/^(?:(?:calc|min)\(\s*)?([\d.]+)(ms|s)\b/); - return match == null - ? Number.NaN - : Number(match[1]) * (match[2] === 's' ? 1000 : 1); - }; const lines = () => astryxCss.split('\n'); it('registers the press strength as an interpolable number, once', () => { @@ -273,7 +271,7 @@ describe('build-css astryx.css', () => { expect(astryxCss).toMatch(/:root[^{]*\{--astryx-press-alpha:0;?\}/); }); - it("animates the strength 1 → 0 on the fading arm, on the machine's own clock", async () => { + it("animates the strength 1 → 0 on the fading arm, on the theme's exit duration", async () => { const keyframes = astryxCss.match( /@keyframes ([\w-]+)\{from\{--astryx-press-alpha:1;?\}to\{--astryx-press-alpha:0;?\}\}/, ); @@ -293,18 +291,18 @@ describe('build-css astryx.css', () => { .filter(line => line.includes('prefers-reduced-motion') === reduced) .map(line => line.match(/animation-duration:\s*([^;}]+)/)?.[1]) .filter(Boolean); - // One clock, the one the controller removes the attribute on, and its - // Reduce Motion arm, which a tempo never lengthens past it (FR4). A - // press layer that fades on the compositor (Switch's) runs on it too. - const releaseMs = await readReleaseMs(); + // One clock, the theme's exit duration, which the controller reads + // too, and its Reduce Motion arm, no longer than the Reduce Motion + // fade. A press layer that fades on the compositor (Switch's) runs on + // it too. + const clocks = await releaseClocks(); for (const reduced of [false, true]) { const durations = durationsOf(reduced); expect(durations.length).toBeGreaterThan(0); - expect(durations.map(toMs), durations.join(' | ')).toEqual( - durations.map(() => releaseMs), + expect(durations.map(compact), durations.join(' | ')).toEqual( + durations.map(() => (reduced ? clocks.reduced : clocks.plain)), ); } - expect(durationsOf(true).every(value => /^min\(/.test(value))).toBe(true); // The onset is instant: nothing animates on the on arm. const on = lines().filter(line => line.includes('[data-astryx-press="on"]'), @@ -352,39 +350,12 @@ describe('build-css astryx.css', () => { }); }); describe('the compositor press layer (privateAttributes/press.stylex.ts `cell`)', () => { - /** The cell clock and curve, read off their one stylesheet source. */ - async function readLayerConsts() { - const source = await fs.readFile( - path.join(ROOT, 'packages/core/src/utils/rowPressLayer.stylex.ts'), - 'utf8', - ); - const ms = source.match(/cellReleaseMs:\s*(\d+)/); - const ease = source.match(/cellReleaseEase:\s*'([^']+)'/); - if (ms == null || ease == null) { - throw new Error('cellPressConsts not found in rowPressLayer.stylex.ts'); - } - return { - ms: Number(ms[1]), - points: ease[1].match(/-?\d*\.?\d+/g).map(Number), - }; - } - const toMs = value => { - // The layer's release scales its base clock by the theme's tempo, as - // the strength's does (`calc( * …)`, `min(, …)`); it reads at - // tempo 1. - const match = value - .trim() - .match(/^(?:(?:calc|min)\(\s*)?([\d.]+)(ms|s)\b/); - return match == null - ? Number.NaN - : Number(match[1]) * (match[2] === 's' ? 1000 : 1); - }; const layerLines = arm => astryxCss .split('\n') .filter(line => line.includes(arm) && line.includes('::before')); - it('releases with an opacity keyframe animation on the cell clock and curve', async () => { + it("releases with an opacity keyframe animation on the strength's clock and curve, the exit tokens", async () => { // Opacity, the property engines run on the compositor: the release // keeps fading while script runs. const keyframes = astryxCss.match( @@ -395,20 +366,20 @@ describe('build-css astryx.css', () => { expect( fading.some(line => line.includes(`animation-name:${keyframes[1]}`)), ).toBe(true); - const {ms, points} = await readLayerConsts(); + const clocks = await releaseClocks(); const durations = fading .map(line => line.match(/animation-duration:\s*([^;}]+)/)) .filter(Boolean) - .map(match => toMs(match[1])); - expect([...new Set(durations)]).toEqual([ms]); + .map(match => compact(match[1])); + expect(new Set(durations)).toEqual( + new Set([clocks.plain, clocks.reduced]), + ); const curves = fading .map(line => line.match(/animation-timing-function:\s*([^;}]+)/)) .filter(Boolean) - .map(match => match[1].match(/-?\d*\.?\d+/g).map(Number)); + .map(match => compact(match[1])); // One curve, whether the Reduce Motion arm restates it or not. - expect([...new Set(curves.map(curve => curve.join(',')))]).toEqual([ - points.join(','), - ]); + expect([...new Set(curves)]).toEqual(['var(--ease-exit)']); }); it('shows the layer at once on the on arm, and rests it at 0 on the fading arm under its release', () => { From c377f911a4e0f5bfb087a0cda142a3d84d805d69 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Mon, 2 Nov 2026 21:40:36 -0800 Subject: [PATCH 2/3] fix(core): no press scale or card color fade under reduced motion Under reduced motion a press never scales a Button, and ClickableCard, SelectableCard and Thumbnail change their hover and selection colors at once on every pointer. Under a coarse primary pointer the three cards' press release fades on the theme's exit duration and easing, capped at the press controller's release clock (component:ClickableCard FR5, component:SelectableCard FR4, component:Thumbnail FR4). The press still shows at once. Under reduced motion that fade takes at most 200 ms, opacity only. A fine primary pointer keeps the timing it had for hover and a mouse press. --- .changeset/cards-touch-release-exit-tokens.md | 7 + .changeset/reduced-motion-button-cards.md | 7 + docs/design/reduced-motion.md | 36 +- docs/specs/components/Button/Button.spec.md | 26 +- .../ClickableCard/ClickableCard.spec.md | 33 +- .../SelectableCard/SelectableCard.spec.md | 48 +- .../components/Thumbnail/Thumbnail.spec.md | 31 +- packages/core/src/Button/Button.tsx | 22 +- .../src/ClickableCard/ClickableCard.test.tsx | 5 +- .../core/src/ClickableCard/ClickableCard.tsx | 86 +++- .../SelectableCard/SelectableCard.test.tsx | 5 +- .../src/SelectableCard/SelectableCard.tsx | 86 +++- .../core/src/Thumbnail/Thumbnail.test.tsx | 5 +- packages/core/src/Thumbnail/Thumbnail.tsx | 63 ++- .../ReducedMotionPress.a11y.chromium.spec.ts | 460 ++++++++++++++++++ packages/core/src/__tests__/pressState.ts | 47 ++ .../src/motion/reducedMotionValues.test.ts | 78 +++ .../core/src/utils/sharedPressRelease.test.ts | 68 +++ scripts/build-css.test.mjs | 15 +- 19 files changed, 1034 insertions(+), 94 deletions(-) create mode 100644 .changeset/cards-touch-release-exit-tokens.md create mode 100644 .changeset/reduced-motion-button-cards.md create mode 100644 packages/core/src/__tests__/ReducedMotionPress.a11y.chromium.spec.ts create mode 100644 packages/core/src/motion/reducedMotionValues.test.ts create mode 100644 packages/core/src/utils/sharedPressRelease.test.ts diff --git a/.changeset/cards-touch-release-exit-tokens.md b/.changeset/cards-touch-release-exit-tokens.md new file mode 100644 index 0000000000000..998271d55c11a --- /dev/null +++ b/.changeset/cards-touch-release-exit-tokens.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[feat] Under a coarse primary pointer, the press release of ClickableCard, SelectableCard and Thumbnail now fades on the theme's exit duration and easing, capped at the press's release time. + +@vjeux diff --git a/.changeset/reduced-motion-button-cards.md b/.changeset/reduced-motion-button-cards.md new file mode 100644 index 0000000000000..f37dd9ea46698 --- /dev/null +++ b/.changeset/reduced-motion-button-cards.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] Under reduced motion, a pressed Button no longer shrinks, and ClickableCard, SelectableCard and Thumbnail change their hover, border and selection colors at once on every pointer, whatever the theme's duration. A finger's press highlight still fades out on release, within 200 ms. + +@vjeux diff --git a/docs/design/reduced-motion.md b/docs/design/reduced-motion.md index 31995ac89528e..449334d4dbbb8 100644 --- a/docs/design/reduced-motion.md +++ b/docs/design/reduced-motion.md @@ -10,7 +10,11 @@ approved_by: cixzhang approved_at: 2026-10-24 owners: [cixzhang] review_triggers: [motion, interaction, accessibility] -verified_by: [scripts/check-knowledge.mjs] +verified_by: + [ + scripts/check-knowledge.mjs, + packages/core/src/__tests__/ReducedMotionPress.a11y.chromium.spec.ts, + ] architecture: [architecture:interaction-modality] components: [] families: [] @@ -32,8 +36,8 @@ deciding_specs: [] A person who asks their device to reduce motion still sees every change Astryx shows, with no travel, scaling, or springing. On a touch screen a change cross-fades, as iOS does with Reduce Motion and Prefer Cross-Fade, except an -in-place value change, which applies at once; with a mouse, -trackpad, or keyboard the change is immediate. +in-place value change and a hover or selection color, which apply at once; +with a mouse, trackpad, or keyboard the change is immediate. ## Design principles @@ -56,7 +60,7 @@ trackpad, or keyboard the change is immediate. | State | Required representation | Allowed variation | | ------------------------------------------------- | ----------------------------------------------------------------------------------------------- | -------------------------- | -| reduced motion, coarse pointer | Opacity-only cross-fade of 200 ms or less (DR2); an in-place value change applies at once (DR2) | A shorter cross-fade | +| reduced motion, coarse pointer | Opacity-only cross-fade of 200 ms or less (DR2); in-place values and colors apply at once (DR2) | A shorter cross-fade | | reduced motion, any other pointer | Immediate change (DR3), except a touch drag's release (DR2) | None | | no reduced-motion preference, theme on `'system'` | Outside this record | Component and theme motion | | a theme on `'always'` (DR2a) | As reduced motion, whatever the person's setting | DR1–DR3 | @@ -75,7 +79,13 @@ trackpad, or keyboard the change is immediate. coast Astryx animates itself after a throw behaves like native momentum under Reduce Motion. An in-place value change, such as a progress fill's length or another continuous value, does not cross-fade: under reduced motion it - applies at once on any pointer, with no added element (DEC-3). + applies at once on any pointer, with no added element (DEC-3). A control's + hover or selection color, such as SelectableCard's selection border and + ring, also applies at once on any pointer, as on iOS (DEC-4). So does the + onset of its press color. A color that changes with a part that cross-fades + follows that cross-fade, such as Switch's track with its thumb + (`component:Switch` FR15). A touch press's release keeps its fade of 200 ms + or less. - **DR2a — A theme may reduce motion for everyone.** A theme's `reducedMotion: 'always'` applies DR1–DR3 for every person, whatever their setting; `'system'` follows each person's setting (`spec:AST-108`). No theme @@ -131,6 +141,7 @@ No normative assets are included. | DR1, DR2 | Real-browser computed animations under reduced motion, coarse pointer | Layer entrance and exit (Tooltip, Popover, menu); Dialog; modal BottomSheet; a presentation switch | An animated transform, size, or position; a spring; a property other than opacity; over 200 ms; a change with no cross-fade | | DR2 momentum | Real-browser computed animations under reduced motion after a touch throw | A flick that coasts and settles at an edge; a release that snaps to a detent, dismisses, or pages to a target | A coast replaced by a cross-fade or stopped at release; a snap, dismissal, or page that travels instead of cross-fading | | DR2 values | Real-browser computed animations under reduced motion, coarse and fine pointer | A determinate ProgressBar's fill and another continuous value changing in place | A value that animates, cross-fades, or adds an element under reduced motion fails | +| DR2 colors | Real-browser computed animations under reduced motion, coarse and fine pointer | Button's press onset; ClickableCard's, SelectableCard's and Thumbnail's hover, border and selection colors | A hover or selection color, or a press onset, that animates or cross-fades; a touch release fade over 200 ms | | DR1, DR3 | Real-browser computed animations under reduced motion, fine pointer | The same components | Any animated property, or a frame where the change is partly applied | | DR4 | Component prop and theme review plus the browser checks above | A component with a motion prop or theme motion value set | A prop or theme value that restores travel or a longer animation under reduced motion | @@ -174,6 +185,21 @@ Under reduced motion they take the new value at once on any pointer. Rejected: a cross-fade between the old and new value on a touch screen. +### DEC-4 — A color-only change of state applies at once + +**Reference:** `design:reduced-motion/DEC-4` +**Decider:** `cixzhang`, `2026-11-03`; companion clause per the owner's motion rule (2026-11-04) + +A control's hover and selection colors, and the onset of its press color, +change where they are and move nothing, so reduced motion has nothing to +remove from them. Under reduced motion they take the new color at once on any +pointer, as iOS does and as an in-place value does (DEC-3). A color that +changes with a part that cross-fades, such as Switch's track with its thumb, +follows that cross-fade, so the two never show different states. A touch +press's release keeps its fade of 200 ms or less, which confirms the tap. + +Rejected: a cross-fade of a hover or selection color on a touch screen. + ## Open questions None. diff --git a/docs/specs/components/Button/Button.spec.md b/docs/specs/components/Button/Button.spec.md index abd2f106d4776..4ccefef588157 100644 --- a/docs/specs/components/Button/Button.spec.md +++ b/docs/specs/components/Button/Button.spec.md @@ -16,6 +16,7 @@ verified_by: packages/core/src/ButtonGroup/ButtonGroup.test.tsx, packages/core/src/Button/__tests__/Button.a11y.test.tsx, packages/core/src/Button/__tests__/Button.a11y.chromium.spec.ts, + packages/core/src/__tests__/ReducedMotionPress.a11y.chromium.spec.ts, packages/core/src/Layout/__tests__/edgeCompensation.test.tsx, packages/core/src/utils/pressFeedback.test.ts, ] @@ -37,15 +38,15 @@ system_specs: [spec:AST-005, spec:AST-085, spec:AST-086] ## Contract at a glance -| Area | Contract | -| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Public contract | None changes. This record states the shipped Button surface: `label`, variant, size, elevation, content mode, loading and `clickAction`, `isInterruptible`, disabled, tooltip, width, form attributes, and link mode. | -| Behavior | A named native `