From d6d738c78105f12c9226b4cd904e960a6801f639 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sun, 1 Nov 2026 21:41:14 -0800 Subject: [PATCH] feat(ProgressBar): under reduced motion the indeterminate bar pulses in place Under reduced motion, on every pointer, the indeterminate fill spans the whole track as one solid bar and only its opacity pulses, from 1 down to 0.84 and back, each way taking the progress.reducedMotionPulseMs role (2000 ms) on the timing.curves.move curve; tempo does not scale it. The 0.84 floor keeps every tone that reaches 3:1 against the track at full opacity at 3:1 in the light and dark default themes (component:ProgressBar FR6, AR4). --- .../progress-bar-reduced-motion-pulse.md | 7 + .../motion-roles.md | 55 ++--- .../ProgressBar/ProgressBar.spec.md | 13 +- .../core/src/ProgressBar/ProgressBar.doc.mjs | 6 +- .../core/src/ProgressBar/ProgressBar.test.tsx | 81 +++++++ packages/core/src/ProgressBar/ProgressBar.tsx | 74 +++++- .../ProgressBarMotion.a11y.chromium.spec.ts | 227 +++++++++++++++++- packages/core/src/motion/MEASUREMENTS.md | 43 ++-- packages/core/src/motion/README.md | 2 +- packages/core/src/motion/uikit/progressBar.ts | 42 +++- .../core/src/theme/derivedVarRegistry.test.ts | 6 + 11 files changed, 470 insertions(+), 86 deletions(-) create mode 100644 .changeset/progress-bar-reduced-motion-pulse.md diff --git a/.changeset/progress-bar-reduced-motion-pulse.md b/.changeset/progress-bar-reduced-motion-pulse.md new file mode 100644 index 0000000000000..a8b04e3f8f842 --- /dev/null +++ b/.changeset/progress-bar-reduced-motion-pulse.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[feat] Under reduced motion, an indeterminate ProgressBar no longer slides: on every pointer its fill spans the whole track and gently pulses its opacity in place, from full down to 84% and back, two seconds each way. A theme's tempo does not change the pulse, and in the default light and dark themes every fill tone that stands out from the track at 3:1 still does at the pulse's lowest point. + +@vjeux diff --git a/docs/specs/AST-094-touch-presentations/motion-roles.md b/docs/specs/AST-094-touch-presentations/motion-roles.md index 053c9fb1cf369..0854c35e6c0cd 100644 --- a/docs/specs/AST-094-touch-presentations/motion-roles.md +++ b/docs/specs/AST-094-touch-presentations/motion-roles.md @@ -4,30 +4,31 @@ Every motion role a registered table lists (`spec:AST-094` FR4, DEC-5): its kind, its default as the table measures it, and the record that names it. A role FR4 lists is named by FR4; any other role is one a component's own record measures, named by the current component records that carry its id. -| Role | Kind | Default | Named by | -| ---------------------------- | -------- | ----------------------------------- | ----------------------- | -| `progress.curves.fill` | curve | cubic-bezier(0, 0, 1, 1) | `component:ProgressBar` | -| `progress.fillTrackMs` | duration | 1000 ms | `component:ProgressBar` | -| `springs.contextMenuDismiss` | spring | response 0.35 s, damping ratio 0.85 | `spec:AST-094` FR4 | -| `springs.contextMenuLift` | spring | response 0.32 s, damping ratio 0.8 | `spec:AST-094` FR4 | -| `springs.contextMenuMorph` | spring | response 0.3 s, damping ratio 0.8 | `spec:AST-094` FR4 | -| `springs.directChange` | spring | response 0.5 s, damping ratio 0.825 | `spec:AST-094` FR4 | -| `springs.emphasis` | spring | duration 0.5 s, bounce 0.3 | `spec:AST-094` FR4 | -| `springs.fingerTracking` | spring | response 0.15 s, damping ratio 0.86 | `spec:AST-094` FR4 | -| `springs.menuClose` | spring | response 0.35 s, damping ratio 0.85 | `spec:AST-094` FR4 | -| `springs.menuOpen` | spring | response 0.32 s, damping ratio 0.8 | `spec:AST-094` FR4 | -| `springs.quickChange` | spring | duration 0.5 s, bounce 0.15 | `spec:AST-094` FR4 | -| `springs.settle` | spring | duration 0.5 s, bounce 0 | `spec:AST-094` FR4 | -| `springs.sliderDrop` | spring | response 0.5 s, damping ratio 0.7 | `component:Slider` | -| `springs.sliderLift` | spring | response 0.215 s, damping ratio 0.6 | `component:Slider` | -| `springs.sliderRelease` | spring | response 0.4 s, damping ratio 1 | `component:Slider` | -| `springs.stateChange` | spring | response 0.55 s, damping ratio 1 | `spec:AST-094` FR4 | -| `springs.subMenuBack` | spring | response 0.4 s, damping ratio 1 | `spec:AST-094` FR4 | -| `springs.subMenuOpen` | spring | response 0.4 s, damping ratio 0.85 | `spec:AST-094` FR4 | -| `springs.timedChange` | spring | duration 0.5 s, bounce 0 | `spec:AST-094` FR4 | -| `springs.viewChange` | spring | mass 3, stiffness 1000, damping 500 | `spec:AST-094` FR4 | -| `timing.curves.entrance` | curve | cubic-bezier(0, 0, 0.58, 1) | `spec:AST-094` FR4 | -| `timing.curves.exit` | curve | cubic-bezier(0.42, 0, 1, 1) | `spec:AST-094` FR4 | -| `timing.curves.move` | curve | cubic-bezier(0.42, 0, 0.58, 1) | `spec:AST-094` FR4 | -| `timing.curves.viewChange` | curve | cubic-bezier(0.25, 0.1, 0.25, 1) | `spec:AST-094` FR4 | -| `timing.stateChangeMs` | duration | 250 ms | `spec:AST-094` FR4 | +| Role | Kind | Default | Named by | +| ------------------------------- | -------- | ----------------------------------- | ----------------------- | +| `progress.curves.fill` | curve | cubic-bezier(0, 0, 1, 1) | `component:ProgressBar` | +| `progress.fillTrackMs` | duration | 1000 ms | `component:ProgressBar` | +| `progress.reducedMotionPulseMs` | duration | 2000 ms | `component:ProgressBar` | +| `springs.contextMenuDismiss` | spring | response 0.35 s, damping ratio 0.85 | `spec:AST-094` FR4 | +| `springs.contextMenuLift` | spring | response 0.32 s, damping ratio 0.8 | `spec:AST-094` FR4 | +| `springs.contextMenuMorph` | spring | response 0.3 s, damping ratio 0.8 | `spec:AST-094` FR4 | +| `springs.directChange` | spring | response 0.5 s, damping ratio 0.825 | `spec:AST-094` FR4 | +| `springs.emphasis` | spring | duration 0.5 s, bounce 0.3 | `spec:AST-094` FR4 | +| `springs.fingerTracking` | spring | response 0.15 s, damping ratio 0.86 | `spec:AST-094` FR4 | +| `springs.menuClose` | spring | response 0.35 s, damping ratio 0.85 | `spec:AST-094` FR4 | +| `springs.menuOpen` | spring | response 0.32 s, damping ratio 0.8 | `spec:AST-094` FR4 | +| `springs.quickChange` | spring | duration 0.5 s, bounce 0.15 | `spec:AST-094` FR4 | +| `springs.settle` | spring | duration 0.5 s, bounce 0 | `spec:AST-094` FR4 | +| `springs.sliderDrop` | spring | response 0.5 s, damping ratio 0.7 | `component:Slider` | +| `springs.sliderLift` | spring | response 0.215 s, damping ratio 0.6 | `component:Slider` | +| `springs.sliderRelease` | spring | response 0.4 s, damping ratio 1 | `component:Slider` | +| `springs.stateChange` | spring | response 0.55 s, damping ratio 1 | `spec:AST-094` FR4 | +| `springs.subMenuBack` | spring | response 0.4 s, damping ratio 1 | `spec:AST-094` FR4 | +| `springs.subMenuOpen` | spring | response 0.4 s, damping ratio 0.85 | `spec:AST-094` FR4 | +| `springs.timedChange` | spring | duration 0.5 s, bounce 0 | `spec:AST-094` FR4 | +| `springs.viewChange` | spring | mass 3, stiffness 1000, damping 500 | `spec:AST-094` FR4 | +| `timing.curves.entrance` | curve | cubic-bezier(0, 0, 0.58, 1) | `spec:AST-094` FR4 | +| `timing.curves.exit` | curve | cubic-bezier(0.42, 0, 1, 1) | `spec:AST-094` FR4 | +| `timing.curves.move` | curve | cubic-bezier(0.42, 0, 0.58, 1) | `spec:AST-094` FR4 | +| `timing.curves.viewChange` | curve | cubic-bezier(0.25, 0.1, 0.25, 1) | `spec:AST-094` FR4 | +| `timing.stateChangeMs` | duration | 250 ms | `spec:AST-094` FR4 | diff --git a/docs/specs/components/ProgressBar/ProgressBar.spec.md b/docs/specs/components/ProgressBar/ProgressBar.spec.md index b4d16130f811f..5c18bd6f57b32 100644 --- a/docs/specs/components/ProgressBar/ProgressBar.spec.md +++ b/docs/specs/components/ProgressBar/ProgressBar.spec.md @@ -61,7 +61,7 @@ system_specs: | Behavior | A labelled `progressbar` whose fill shows the clamped value against a muted track, moving at UIKit's speed under a finger, or slides while indeterminate and pulses in place under reduced motion, with optional value text and labelled target marks. | | End-user impact | People see how far an operation has gone, and assistive technology reads the name and value; marks reveal their labels on hover or focus. | | Builder impact | A builder's `data-variant` replaces the reflected variant today, so theme rules for the bar's real variant stop matching (FR8 gap); otherwise none. | -| Compatibility/readiness | No API or default changes; the reduced-motion pulse (FR6) is unbuilt. Implementation gaps are listed under Current-state impact. | +| Compatibility/readiness | No API or default changes. Implementation gaps are listed under Current-state impact. | | Review checks | Reject a determinate bar whose correctness depends on nearby text, a value outside `[0, max]`, a leaked `NaN`, value semantics while indeterminate, or a target off its part. | | Governing rules | `component:ProgressBar/DEC-1`, `DEC-2`; `spec:AST-002`; `spec:AST-094` FR4, FR13; `design:reduced-motion` DR1; `architecture:component-theming-surface`; `architecture:theme-tokens` INV11; `architecture:internationalization` INV1a; `architecture:public-component-api` INV5–INV8. | @@ -117,11 +117,6 @@ Consumer migration instructions belong in consumer docs and release notes. where `architecture:internationalization` INV11 resolves mirroring from the rendered region. -- **The reduced-motion indeterminate fill still travels (FR6).** Under reduced - motion the 40% segment keeps sliding, slowed to 3s a pass, on every pointer, - where FR6 holds a full-width fill that only pulses its opacity, and the - `progress.reducedMotionPulseMs` role is not in the generated motion role - table. - **Warning and neutral fills under 3:1 in the light default theme (FR6, OQ1).** Against the track, the warning fill reaches 1.65–1.82:1 and the neutral fill 1.84–2.03:1 even at full opacity (on the body and surface @@ -170,7 +165,7 @@ Consumer migration instructions belong in consumer docs and release notes. | FR3 | The root holds a label row and a track. The label names the track (`role="progressbar"`) through `aria-labelledby`; `isLabelHidden` hides it visually only. When determinate, the track exposes `aria-valuenow` (the clamped value), `aria-valuemin` 0, `aria-valuemax` (`max`), and `aria-valuetext` from `formatValueLabel`, which defaults to the rounded percentage; `hasValueLabel` shows the same text beside the label. A zero or non-finite `max` gives 0% and never renders `NaN`. Astryx-owned value text comes from one catalog message (`architecture:internationalization` INV1a). Implementation gap: the default text is assembled in code (Current-state impact). | Current source and tests | | FR4 | The track is 8px high, fully rounded, in `--color-background-muted`, and clips the fill. The determinate fill spans the track and slides along it so its leading edge sits at the clamped percentage, mirrored by the bar's own writing direction, moving over `--duration-medium` when the value changes. Under a coarse primary pointer (`spec:AST-094` FR13) it instead moves linearly from where it is drawn when the value changes, for the `progress.fillTrackMs` role (1000ms, UIKit's progress view for a whole-track change) times the share of the track the value moved, so 10% to 90% takes 800ms; above zero it draws at least 16px long, a mark that length covers counts as on the fill (FR7), and at zero it paints nothing. That duration and its linear curve, `progress.curves.fill`, are roles in the generated motion role table, and a theme's tempo scales the duration (`spec:AST-094` FR4, DEC-1, DEC-5; `spec:AST-108` FR3); any other pointer keeps the theme's eased duration. Under reduced motion a new value lands at once on every pointer (`design:reduced-motion` DR1). The fill paints `--color-accent`, `--color-success`, `--color-warning`, or `--color-error`, or `--color-text-disabled` for `neutral` and disabled bars; a theme-added variant has a safe theme-independent fill (`architecture:component-theming-surface` INV14). Implementation gap: a theme-added variant paints no fill and no on-fill mark color until a theme styles it (Current-state impact). In forced colors the fill is sized to the value instead of sliding and keeps a 1px `CanvasText` outline so its shape stays visible (`architecture:theme-tokens` INV11). | Current source and tests; UIKit measurement (iOS 26.2 simulator); `component:ProgressBar/DEC-2` | | FR5 | `isDisabled` mutes the fill, label, value text, and marks and adds no ARIA state. | Current source and tests | -| FR6 | `isIndeterminate` removes every value attribute and the value text. Without reduced motion, on every pointer, a 40% fill slides across the clipped track, eased, 1.5s a pass, mirrored by the bar's own rendered direction (`architecture:internationalization` INV11). Under reduced motion, on every pointer, the fill spans the whole track as one solid bar, with no segment and no travel, and only its opacity pulses: from 1 down to 0.84 and back, each way taking the `progress.reducedMotionPulseMs` role (2000ms) on the `timing.curves.move` curve; the track is unchanged. The pulse is a busy loop, so tempo does not scale it (`spec:AST-094` FR4, `spec:AST-108` FR4). It applies `design:reduced-motion` DR1's no-travel rule, replacing the travel with an opacity change, to a loop that record leaves to its component. At the 0.84 floor the fill keeps at least 3:1 against the track in both themes for every tone that reaches 3:1 at full opacity (AR4). Implementation gap: under reduced motion the segment still slides, slowed to 3s a pass, and the pulse role is unbuilt (Current-state impact). Implementation gap: the slide, and the centering of marks, mirror under any right-to-left ancestor rather than the bar's own direction, so in a left-to-right region of a right-to-left page they run against the label (Current-state impact). | Current source and tests; `component:ProgressBar/DEC-2` | +| FR6 | `isIndeterminate` removes every value attribute and the value text. Without reduced motion, on every pointer, a 40% fill slides across the clipped track, eased, 1.5s a pass, mirrored by the bar's own rendered direction (`architecture:internationalization` INV11). Under reduced motion, on every pointer, the fill spans the whole track as one solid bar, with no segment and no travel, and only its opacity pulses: from 1 down to 0.84 and back, each way taking the `progress.reducedMotionPulseMs` role (2000ms) on the `timing.curves.move` curve; the track is unchanged. The pulse is a busy loop, so tempo does not scale it (`spec:AST-094` FR4, `spec:AST-108` FR4). It applies `design:reduced-motion` DR1's no-travel rule, replacing the travel with an opacity change, to a loop that record leaves to its component. At the 0.84 floor the fill keeps at least 3:1 against the track in both themes for every tone that reaches 3:1 at full opacity (AR4). Implementation gap: the slide, and the centering of marks, mirror under any right-to-left ancestor rather than the bar's own direction, so in a left-to-right region of a right-to-left page they run against the label (Current-state impact). | Current source and tests; `component:ProgressBar/DEC-2` | | FR7 | Each finite mark renders, outside the `progressbar` element, a tick at its clamped position whose tooltip shows its label on hover and focus; marks inside the filled part take the fill's contrasting color, and marks do not render while indeterminate. A focusable mark exposes its label as its accessible name (WCAG 2.2 SC 4.1.2). Implementation gap: marks are focusable `span`s with no role or name, labelled only as a description once the tooltip loads (Current-state impact). | Current source and tests | | FR8 | The root carries `progress-bar` reflecting `variant`, the track `progress-bar-track`, the fill `progress-bar-fill` reflecting the resolved fill variant, and each mark `progress-bar-mark` reflecting the resolved fill variant and placement; the label and value text carry no target. Reflected state stays component-owned (`architecture:public-component-api` INV5). Implementation gap: a caller `data-variant` replaces the root's reflected variant (Current-state impact). | Current source and tests | | FR9 | `ref` and `data-testid` reach the root; supported DOM, data, ARIA, and event props reach the root, while the reflected theme attributes stay component-owned (FR8); `xstyle`, `className`, and `style` compose after component styles. | Current source and tests; `architecture:public-component-api` INV5–INV8 | @@ -281,8 +276,8 @@ those parts stay untargeted. | FR5 | `ProgressBar.test.tsx` disabled and disabled-mark tests | Disabled | A missing label or undimmed marks fail. Gap: the muted fill and label colors have no direct check. | `audit:ProgressBar/visual` | | FR6 | `ProgressBar.test.tsx` indeterminate suite | Every variant; RTL | Value attributes or value text while indeterminate, a lost label, an unclipped track, or an unmirrored slide fails. No test covers the slide in a left-to-right region inside a right-to-left page. Gap: the RTL test asserts the right-to-left ancestor selector, so it pins the FR6 gap. | `audit:ProgressBar/behavior` | | FR4 touch timing | `ProgressBarTouch.a11y.chromium.spec.ts`; the `ProgressBar.test.tsx` touch suite | Coarse vs fine pointer; 10% to 90%; a new value mid-move; 1% on a narrow track; zero; reduced motion | A coarse-pointer move that eases, starts from the old value instead of the drawn position, or lasts other than `progress.fillTrackMs` times the share moved; a fine-pointer move that leaves the theme's eased duration; a non-zero fill shorter than 16px; a painted fill at zero; or a duration played as a literal instead of the role fails. | `audit:ProgressBar/behavior` | -| FR6 reduced motion | Pending: real-browser computed-animation tests under reduced motion, coarse and fine pointer (not yet in the suite) | Every variant; coarse and fine pointer | A segment, any transform or travel, a fill narrower than the track, an animated property other than the fill's opacity, an opacity below 0.84, a period other than `progress.reducedMotionPulseMs` each way, or a changed track fails. | `audit:ProgressBar/behavior` | -| AR4 | Pending: real-browser contrast check of the fill at 0.84 opacity over the track (not yet in the suite) | Every variant; light and dark default themes; body and surface backgrounds | A tone that reaches 3:1 at full opacity and falls below 3:1 at the floor fails. Gap: warning and neutral in light are below 3:1 at full opacity (OQ1). | `audit:ProgressBar/a11y` | +| FR6 reduced motion | The reduced-motion pulse cases of `ProgressBarMotion.a11y.chromium.spec.ts`; the `ProgressBar.test.tsx` pulse suite | Every variant; coarse and fine pointer | A segment, any transform or travel, a fill narrower than the track, an animated property other than the fill's opacity, an opacity below 0.84, a period other than `progress.reducedMotionPulseMs` each way, or a changed track fails. | `audit:ProgressBar/behavior` | +| AR4 | The pulse-floor contrast case of `ProgressBarMotion.a11y.chromium.spec.ts` | Every variant; light and dark default themes; body and surface backgrounds | A tone that reaches 3:1 at full opacity and falls below 3:1 at the floor fails. Gap: warning and neutral in light are below 3:1 at full opacity (OQ1). | `audit:ProgressBar/a11y` | | FR7 | `ProgressBar.test.tsx` target-mark suite | None; one; many; clamped; non-finite; on fill; indeterminate; disabled; tooltip | A missing, misplaced, or clipped mark, a mark inside the `progressbar`, a lost tooltip, or a wrong on-fill color fails. Gap: the test asserts that marks have no role and no `aria-label`, which pins the FR7 gap. | `audit:ProgressBar/a11y` | | FR8 | `ProgressBar.test.tsx` target-name and mark-target tests; `themingTargets.test.ts` | Four canonical targets | A missing or renamed target fails. Gap: no test covers a caller `data-variant`. `themingTargets.test.ts` catches only a renamed or undocumented target; no check fails if a part stops carrying its target. | `audit:ProgressBar/theming` | | FR9 | `ProgressBar.test.tsx` ref and `data-testid` tests | Ref; test id | A ref that is not a `div` or a lost test id fails. Gap: the ref test checks only that the ref is a `div`, not that it is the root, and no test covers DOM props, `className`, `style`, or the `xstyle` merge order. | `audit:ProgressBar/api` | diff --git a/packages/core/src/ProgressBar/ProgressBar.doc.mjs b/packages/core/src/ProgressBar/ProgressBar.doc.mjs index 031bc4175cd12..561aa41b38df1 100644 --- a/packages/core/src/ProgressBar/ProgressBar.doc.mjs +++ b/packages/core/src/ProgressBar/ProgressBar.doc.mjs @@ -101,7 +101,8 @@ export const docs = { { name: 'isIndeterminate', type: 'boolean', - description: 'Animated loading indicator for unknown progress.', + description: + 'Animated loading indicator for unknown progress. Under reduced motion it pulses in place instead of sliding.', default: 'false', }, { @@ -390,7 +391,8 @@ export const docsDense = { formatValueLabel: 'Custom value label formatter; defaults to percentage string.', variant: 'Semantic color variant.', - isIndeterminate: 'Animated loading indicator for unknown progress.', + isIndeterminate: + 'Animated loading indicator for unknown progress. Under reduced motion it pulses in place instead of sliding.', marks: 'Fixed target marks ({value, label?}) drawn on the track in the 0..max scale; stay visible past the fill. Marks inside the fill take the variant on-color; marks on the bare track take the primary text color (secondary when disabled). A label reveals a tooltip on hover/focus. Ignored when indeterminate.', isDisabled: 'Visually disabled: grays out fill and text.', diff --git a/packages/core/src/ProgressBar/ProgressBar.test.tsx b/packages/core/src/ProgressBar/ProgressBar.test.tsx index d39e19c40277a..f31e8c5f04f04 100644 --- a/packages/core/src/ProgressBar/ProgressBar.test.tsx +++ b/packages/core/src/ProgressBar/ProgressBar.test.tsx @@ -24,6 +24,7 @@ import { } from '../motion/reducedMotion'; import {TOUCH_QUERY} from '../utils/touchQueries'; import {TOUCH_MIN_FILL_PX} from './ProgressBarTouch'; +import {timing} from '../motion/uikit/timing'; // A labeled mark wraps the tick in a lazily-loaded Tooltip (React.lazy + // Suspense). Preload that chunk once so the Tooltip (and its @@ -1263,6 +1264,86 @@ describe('ProgressBar under a finger: UIProgressView', () => { }); }); +describe('ProgressBar under reduced motion: the indeterminate pulse', () => { + const fillOf = () => + screen.getByRole('progressbar').firstElementChild as HTMLElement; + const source = () => + readFileSync(path.resolve(__dirname, 'ProgressBar.tsx'), 'utf8'); + + for (const coarse of [false, true]) { + it(`spans the track and only pulses its opacity, with ${coarse ? 'a finger' : 'a mouse'}`, () => { + render(); + const fill = fillOf(); + const reduce = {coarse, reduce: true}; + expect(valueUnder(fill, 'width', reduce)).toBe('100%'); + expect(valueUnder(fill, 'animation-duration', reduce)).toMatch( + /^var\(--_progress-bar-pulse\)$/, + ); + expect(valueUnder(fill, 'animation-timing-function', reduce)).toMatch( + /^var\(--_progress-bar-pulse-curve\)$/, + ); + expect(valueUnder(fill, 'animation-direction', reduce)).toBe('alternate'); + // Without reduced motion the 40% segment still slides. + const motion = {coarse, reduce: false}; + expect(valueUnder(fill, 'width', motion)).toBe('40%'); + expect(valueUnder(fill, 'animation-duration', motion)).toBe('1.5s'); + expect(valueUnder(fill, 'animation-direction', motion)).toBeUndefined(); + }); + } + + it('pulses from full opacity down to the progress table floor and back', () => { + render(); + expect(fillOf().style.getPropertyValue('--_progress-bar-pulse-floor')).toBe( + String(progressBar.reducedMotionPulseFloor), + ); + expect(progressBar.reducedMotionPulseFloor).toBe(0.84); + // jsdom has no CSSKeyframesRule global: match the rule's text. + const frames = Array.from(document.styleSheets) + .flatMap(sheet => Array.from(sheet.cssRules)) + .map(rule => rule.cssText.replace(/\s+/g, '')) + .filter(text => text.startsWith('@keyframes')) + .filter(text => text.includes('--_progress-bar-pulse-floor')); + expect(frames).toHaveLength(1); + expect(frames[0]).toMatch(/opacity:1/); + expect(frames[0]).toMatch(/opacity:var\(--_progress-bar-pulse-floor\)/); + // The pulse never moves the fill. + expect(frames[0]).not.toMatch(/transform/); + }); + + it('plays the progress.reducedMotionPulseMs role on the timing.curves.move curve, which tempo does not scale', () => { + render(); + const fill = fillOf(); + expect(fill.style.getPropertyValue('--_progress-bar-pulse')).toBe( + `${progressBar.reducedMotionPulseMs}ms`, + ); + expect(progressBar.reducedMotionPulseMs).toBe(2000); + expect(fill.style.getPropertyValue('--_progress-bar-pulse-curve')).toBe( + formatMotionRoleValue('curve', timing.curves.move), + ); + registerProgressBarTable(); + expect(motionRole('progress.reducedMotionPulseMs')?.value).toBe( + progressBar.reducedMotionPulseMs, + ); + // A busy loop: nothing in the indeterminate fill reads the theme's tempo, + // and its reduced-motion keys play no literal. + const text = source(); + const block = text.slice( + text.indexOf(' indeterminateFill: {'), + text.indexOf(' // A mark is a vertical tick'), + ); + expect(block).not.toContain('--_astryx-motion-tempo'); + const reduceValues = [ + ...block.matchAll( + /'@media \(prefers-reduced-motion: reduce\)':\s*([^,\n]+)/g, + ), + ].map(match => match[1].trim()); + expect(reduceValues.length).toBeGreaterThan(0); + for (const value of reduceValues) { + expect(value).not.toMatch(/^'\d+(\.\d+)?m?s'$|cubic-bezier|ease/); + } + }); +}); + // jsdom cannot emulate forced colors, so this asserts the compiled rule the // status fill relies on (architecture:theme-tokens INV11). describe('ProgressBar forced colors', () => { diff --git a/packages/core/src/ProgressBar/ProgressBar.tsx b/packages/core/src/ProgressBar/ProgressBar.tsx index d7cb9bd9c8365..8cb40da6d253d 100644 --- a/packages/core/src/ProgressBar/ProgressBar.tsx +++ b/packages/core/src/ProgressBar/ProgressBar.tsx @@ -4,7 +4,7 @@ /** * @file ProgressBar.tsx - * @input Uses React, useId, stylex, tokens and ProgressBarTouch + * @input Uses React, useId, stylex, tokens, ProgressBarTouch and resolveMotion * @output Exports ProgressBar component, ProgressBarProps, ProgressBarVariant types * @position Core implementation; consumed by index.ts * @@ -36,6 +36,10 @@ import {themeProps} from '../utils/themeProps'; import {VisuallyHidden} from '../VisuallyHidden'; import type {ProgressBarVariantMap} from './index'; import {useProgressBarTouch} from './ProgressBarTouch'; +import {formatMotionRoleValue} from '../motion/roles'; +import {resolveMotion} from '../motion/resolveMotion'; +import {progressBar} from '../motion/uikit/progressBar'; +import {timing} from '../motion/uikit/timing'; // Every mark is wrapped in a Tooltip (marks always carry a label), so load it // lazily: a ProgressBar with no marks never bundles the Tooltip chunk. While it @@ -117,7 +121,7 @@ export interface ProgressBarProps extends BaseProps { * When true, renders an animated indeterminate progress indicator. * Use when the progress amount is unknown (e.g. loading, processing). * The `value` and `hasValueLabel` props are ignored in this mode. - * Respects `prefers-reduced-motion` by slowing the animation. + * Under reduced motion it pulses in place instead of sliding. * @default false */ isIndeterminate?: boolean; @@ -170,6 +174,19 @@ const indeterminateSlideRtl = stylex.keyframes({ }, }); +// Under reduced motion the indeterminate fill stops travelling and pulses in +// place. It goes from full opacity down to the pulse's floor +// (`--_progress-bar-pulse-floor`, the progress table's) and back. The +// animation alternates direction each pass. +const indeterminatePulse = stylex.keyframes({ + from: { + opacity: 1, + }, + to: { + opacity: 'var(--_progress-bar-pulse-floor)', + }, +}); + // ============================================================================= // Styles // ============================================================================= @@ -266,7 +283,6 @@ const styles = stylex.create({ default: '100%', '@media (forced-colors: active)': { default: 'var(--_progress-bar-fill, 0%)', - // Under a finger: the touch minimum and the flip offset (below). '@media (pointer: coarse)': 'calc(max(var(--_progress-bar-fill, 0%), var(--_progress-bar-min, 0px)) + var(--_progress-bar-flip, 0) * 0.01px)', }, @@ -353,17 +369,37 @@ const styles = stylex.create({ '@media (forced-colors: active)': 'CanvasText', }, outlineOffset: {default: null, '@media (forced-colors: active)': '-1px'}, - width: '40%', + // Under reduced motion, on every pointer, the fill spans the whole track + // as one solid bar, and only its opacity pulses. Each way takes the + // `progress.reducedMotionPulseMs` role on the `timing.curves.move` curve + // (`--_progress-bar-pulse`, `--_progress-bar-pulse-curve`, set inline from + // the progress table). A busy loop: tempo does not scale it. + width: { + default: '40%', + '@media (prefers-reduced-motion: reduce)': '100%', + }, borderRadius: radiusVars['--radius-full'], animationName: { default: indeterminateSlide, - ':is([dir="rtl"] *)': indeterminateSlideRtl, + ':is([dir="rtl"] *)': { + default: indeterminateSlideRtl, + '@media (prefers-reduced-motion: reduce)': indeterminatePulse, + }, + '@media (prefers-reduced-motion: reduce)': indeterminatePulse, }, animationDuration: { default: '1.5s', - '@media (prefers-reduced-motion: reduce)': '3s', + '@media (prefers-reduced-motion: reduce)': 'var(--_progress-bar-pulse)', + }, + animationTimingFunction: { + default: 'ease-in-out', + '@media (prefers-reduced-motion: reduce)': + 'var(--_progress-bar-pulse-curve)', + }, + animationDirection: { + default: null, + '@media (prefers-reduced-motion: reduce)': 'alternate', }, - animationTimingFunction: 'ease-in-out', animationIterationCount: 'infinite', }, // A mark is a vertical tick centered on the track via their shared positioning container. @@ -474,6 +510,15 @@ const markOnTrackStyles = stylex.create({ }, }); +// The reduced-motion pulse's variables, from the progress table. +type PulseStyle = CSSProperties & + Record< + | '--_progress-bar-pulse' + | '--_progress-bar-pulse-curve' + | '--_progress-bar-pulse-floor', + string + >; + // `CSSProperties` has no index signature for custom properties, so the fill's // inline style — which publishes the filled share as the private // `--_progress-bar-fill` — needs this augmentation to typecheck. @@ -487,6 +532,20 @@ type FillStyle = CSSProperties & string >; +/** + * The reduced-motion pulse plays the `progress.reducedMotionPulseMs` role on + * the `timing.curves.move` curve, down to the progress table's floor. + */ +function pulseStyle(element: Element | null): PulseStyle { + const pulseMs = resolveMotion(element, progressBar, 'reducedMotionPulseMs'); + const curve = resolveMotion(element, timing.curves.move); + return { + '--_progress-bar-pulse': `${pulseMs}ms`, + '--_progress-bar-pulse-curve': formatMotionRoleValue('curve', curve), + '--_progress-bar-pulse-floor': String(progressBar.reducedMotionPulseFloor), + }; +} + function defaultFormatValueLabel(value: number, max: number): string { const pct = max > 0 ? Math.round((value / max) * 100) : 0; return `${pct}%`; @@ -662,6 +721,7 @@ export function ProgressBar({ variantStyles[fillVariant], ), )} + style={pulseStyle(null)} /> ) : (
{ @@ -54,6 +56,7 @@ const EVIDENCE = path.resolve( path.join('test-results', 'progress-bar-motion'), ); const STORY = 'core-progressbar--default'; +const INDETERMINATE = 'core-progressbar--indeterminate-variants'; const BLOCK_MS = 700; const DPR = 2; @@ -571,6 +574,79 @@ for (const pointer of ['a mouse', 'a finger'] as const) { : {hasTouch: false, isMobile: false}, ); + test('under reduced motion the indeterminate fill spans the track and only pulses its opacity', async ({ + page, + }) => { + await page.emulateMedia({reducedMotion: 'reduce'}); + await page.goto( + `${storybook.origin}/iframe.html?id=${INDETERMINATE}&viewMode=story`, + ); + await expect(page.getByRole('progressbar').first()).toBeVisible(); + expect( + await page.evaluate(() => matchMedia('(pointer: coarse)').matches), + ).toBe(pointer === 'a finger'); + const pulseMs = progressBar.reducedMotionPulseMs; + const bars = await page.evaluate( + passMs => + Array.from(document.querySelectorAll('[role="progressbar"]')).map( + track => { + const fill = track.firstElementChild as HTMLElement; + const animations = fill.getAnimations(); + const [pulse] = animations; + const timing = pulse.effect?.getTiming(); + const t = track.getBoundingClientRect(); + // Seek one pass: the box never moves, only the opacity changes. + const samples = [0, 0.25, 0.5, 1].map(share => { + pulse.currentTime = Math.min(share * passMs, passMs - 0.01); + const f = fill.getBoundingClientRect(); + return { + left: f.left - t.left, + width: f.width, + opacity: Number(getComputedStyle(fill).opacity), + }; + }); + return { + label: track.getAttribute('aria-labelledby'), + trackWidth: t.width, + count: animations.length, + duration: Number(timing?.duration), + direction: timing?.direction, + iterations: timing?.iterations, + curve: getComputedStyle(fill).animationTimingFunction, + samples, + }; + }, + ), + pulseMs, + ); + expect(bars).toHaveLength(5); + const floor = progressBar.reducedMotionPulseFloor; + for (const bar of bars) { + expect(bar.count, bar.label ?? '').toBe(1); + // The progress.reducedMotionPulseMs role each way, alternating, on + // the timing.curves.move curve. + expect(bar.duration).toBe(pulseMs); + expect(bar.direction).toBe('alternate'); + expect(bar.iterations).toBe(Infinity); + expect(bar.curve).toBe( + formatMotionRoleValue('curve', timing.curves.move), + ); + for (const sample of bar.samples) { + expect(sample.left).toBeCloseTo(0, 1); + expect(sample.width).toBeCloseTo(bar.trackWidth, 1); + } + const [start, quarter, half, end] = bar.samples.map( + sample => sample.opacity, + ); + expect(start).toBeCloseTo(1, 2); + expect(half).toBeCloseTo((1 + floor) / 2, 2); + expect(end).toBeCloseTo(floor, 2); + // Eased, not linear: a quarter of the way it has moved less than a + // quarter of the drop. + expect(quarter).toBeGreaterThan(1 - (1 - floor) / 4 + 0.005); + } + }); + test('under reduced motion a new value lands at once', async ({page}) => { // Both shared reduced-motion queries, a fine pointer's and a coarse // pointer's, set the fill's duration to 0 s. @@ -620,6 +696,141 @@ for (const pointer of ['a mouse', 'a finger'] as const) { }); } +test('a theme\u2019s tempo leaves the reduced-motion pulse unscaled', async ({ + page, +}) => { + // A busy loop: the pulse keeps its role's 2000 ms at a slower tempo. + await page.emulateMedia({reducedMotion: 'reduce'}); + await page.goto( + `${storybook.origin}/iframe.html?id=${INDETERMINATE}&viewMode=story`, + ); + await expect(page.getByRole('progressbar').first()).toBeVisible(); + await page.addStyleTag({content: ':root { --_astryx-motion-tempo: 1.5; }'}); + const durations = await page.evaluate(() => + Array.from(document.querySelectorAll('[role="progressbar"]')).map(track => + Number( + (track.firstElementChild as HTMLElement) + .getAnimations()[0] + ?.effect?.getTiming().duration, + ), + ), + ); + expect(durations).toEqual( + durations.map(() => progressBar.reducedMotionPulseMs), + ); +}); + +test('at the pulse floor every tone with 3:1 against the track keeps it, light and dark, on the body and the surface', async ({ + page, +}) => { + // In the light and dark default themes: the base tokens, Storybook's "None" + // theme. Its muted track is translucent, so the body and the surface differ. + // The colors are composited from the browser's computed colors (hex, so the + // channels are exact). They are the page background and the muted track + // over it. Then the fill over the track: at full opacity, at the floor, and + // just below the floor. + const floor = progressBar.reducedMotionPulseFloor; + const rows: { + mode: string; + surface: string; + tone: string; + full: number; + atFloor: number; + below: number; + }[] = []; + for (const mode of ['light', 'dark'] as const) { + await page.emulateMedia({reducedMotion: 'reduce'}); + await page.goto( + `${storybook.origin}/iframe.html?id=${INDETERMINATE}&viewMode=story&globals=astryxTheme:none;colorMode:${mode}`, + ); + await expect(page.getByRole('progressbar').first()).toBeVisible(); + rows.push( + ...(await page.evaluate( + ({mode: m, opacities}) => { + const parse = (css: string): number[] => { + const rgb = /^rgba?\(([^)]+)\)$/.exec(css); + if (rgb != null) { + const [r, g, b, a = 1] = rgb[1] + .split(/[\s,/]+/) + .filter(Boolean) + .map(Number); + return [r / 255, g / 255, b / 255, a]; + } + const srgb = /^color\(srgb ([^)]+)\)$/.exec(css); + if (srgb != null) { + const [r, g, b, a = 1] = srgb[1] + .split(/[\s/]+/) + .filter(Boolean) + .map(Number); + return [r, g, b, a]; + } + throw new Error(`unparsed color ${css}`); + }; + const over = (top: number[], alpha: number, under: number[]) => + under.map((channel, i) => + i < 3 ? top[i] * alpha + channel * (1 - alpha) : 1, + ); + const luminance = (c: number[]) => { + const lin = c + .slice(0, 3) + .map(v => + v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4, + ); + return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]; + }; + const contrast = (a: number[], b: number[]) => { + const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x); + return (hi + 0.05) / (lo + 0.05); + }; + const tracks = Array.from( + document.querySelectorAll('[role="progressbar"]'), + ); + const probe = document.createElement('div'); + tracks[0].parentElement?.appendChild(probe); + const out = []; + for (const surface of ['body', 'surface']) { + probe.style.backgroundColor = `var(--color-background-${surface})`; + const page = parse(getComputedStyle(probe).backgroundColor); + for (const track of tracks) { + const fill = track.firstElementChild as HTMLElement; + const trackColor = parse(getComputedStyle(track).backgroundColor); + const fillColor = parse(getComputedStyle(fill).backgroundColor); + const base = over(trackColor, trackColor[3], page); + const at = (opacity: number) => + contrast(over(fillColor, fillColor[3] * opacity, base), base); + const label = document.getElementById( + track.getAttribute('aria-labelledby') ?? '', + ); + out.push({ + mode: m, + surface, + tone: label?.textContent ?? '', + full: at(1), + atFloor: at(opacities.floor), + below: at(opacities.floor - 0.01), + }); + } + } + probe.remove(); + return out; + }, + {mode, opacities: {floor}}, + )), + ); + } + expect(rows).toHaveLength(20); + const qualifying = rows.filter(row => row.full >= 3); + expect(qualifying.length).toBeGreaterThan(0); + for (const row of qualifying) { + expect( + row.atFloor, + `${row.tone}, ${row.mode}, ${row.surface}: ${row.atFloor.toFixed(3)}:1`, + ).toBeGreaterThanOrEqual(3); + } + // The floor is the lowest that holds: one step below, some tone drops. + expect(qualifying.some(row => row.below < 3)).toBe(true); +}); + test('below one track height of fill, the leading end follows the track\u2019s curve', async ({ page, }) => { diff --git a/packages/core/src/motion/MEASUREMENTS.md b/packages/core/src/motion/MEASUREMENTS.md index da3fac0b72cbf..ced782e5fd98b 100644 --- a/packages/core/src/motion/MEASUREMENTS.md +++ b/packages/core/src/motion/MEASUREMENTS.md @@ -37,27 +37,28 @@ installed); rows marked † also on iOS 18 for the pre-glass delta. iPhone ## Shared (foundation) -| ID | What | App and gesture | Extract | Method | Feeds | -| ----- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| M-S1 | Deceleration rates | none | `UIScrollView.DecelerationRate.normal.rawValue`, `.fast.rawValue` | D | `scroll.decelerationRate` | -| M-S2 | Momentum coast | A long list (Contacts, or a probe table of 500 rows): flicks released at 1000, 2500, 5000 pt/s | contentOffset(t) from release to rest; decay constant; per-millisecond vs per-frame model; the speed at which it stops | C | `scroll.decelerationRate` | -| M-S3 | Bounce-back spring | Same list: drag 120 pt past the top, hold still, release; a flick into the top edge at 3000 pt/s | contentOffset(t) after release and after edge contact; spring response and damping ratio; velocity hand-off | C | `scroll.bounce` | -| M-S5 | Rubber band | Same list on an 852 pt view: drag 100, 300, 600 pt past the top; a horizontal pager | displayed overflow against finger overflow; confirm c = 0.55 and d = the view's size on the axis | C | `scroll.rubberBand` | -| M-PG1 | Paging | Photos full-screen swipes; a probe paging scroll view | page chosen per release position × velocity (thresholds; at most one page?); contentOffset(t) onto the page | B, C | `paging` | -| M-ST1 | Scroll to top | Settings scrolled down 500, 2000, 8000 pt; tap the status bar | contentOffset(t); duration against distance; curve or spring fit | B, C | `scroll.scrollToTopRate` | -| M-N1 | Navigation push | Settings: tap General | incoming leading edge x(t), outgoing x(t) (parallax fraction), dimming alpha(t); animation parameters | A, B | `springs.navigationPush` | -| M-N2 | Navigation pop | General: back button | same as M-N1 | A, B | `springs.navigationPop` | -| M-N3 | Interactive pop | Settings ▸ General: edge swipe released at 40 % of the width at 600 pt/s (finishes) and at 30 % at rest (cancels) | x(t) after release; spring parameters; the finish / cancel rule (position, velocity) | A, B | `springs.navigationInteractivePopFinish` | -| M-A1 | Alert | Probe app: `UIAlertController(.alert)` presented and dismissed | scale(t), alpha(t) of the alert and the dimming view; animation parameters | A | `springs.alertPresent`, `.alertDismiss` | -| M-AS1 | Action sheet and menu | Probe app: `.actionSheet`; a button with a `UIMenu` as its primary action; Files: the … menu | scale, alpha, position (t) on open and close; animation parameters | A, B | `springs.actionSheet*`, `.menu*` | -| M-CM1 | Context menu | Files: long-press an item; dismiss by tapping outside | preview scale(t) on lift, backdrop blur and dim (t), menu scale(t); the preview's return on dismiss | A, B | `springs.contextMenu*` | -| M-K1 | UIKit legacy spring | Probe app: `UIView.animate(withDuration:usingSpringWithDamping:initialSpringVelocity:)`, durations 0.3, 0.5, 1 × ratios 0.5, 0.8, 1 × velocities 0, 2 | the `CASpringAnimation` UIKit attaches: mass, stiffness, damping, initialVelocity, duration, settlingDuration | A | `springs.ts` (UIKit's legacy springs) | -| M-K2 | Default spring, keyboard | Probe app: an animator with `UISpringTimingParameters()`; focus and blur a text field | both springs' parameters and allowsOverdamping; the keyboard notification's duration and curve | A, D | `springs.viewChange`, `.keyboard`, `timing.keyboard*` | -| M-CA1 | Settling duration | none: `CASpringAnimation` mass 1, stiffness 100, damping 5, 10, 15, 20, 40 × initialVelocity 0, 5, −5 × allowsOverdamping on, off | `settlingDuration` of each (checks the critically damped and overdamped branches) | D | `springs.ts` (settling) | -| M-T1 | Banner | A notification pushed to the simulator while an app is in the foreground | banner y(t) in and out; time on screen | B | `springs.banner` | -| M-RM1 | Reduced Motion | Reduce Motion on, Prefer Cross-Fade Transitions on and off: Settings push and pop, an alert (sheets: M-SH13; controls: M-SW11, M-SL12, M-SG15) | which transitions become cross-fades; alpha(t), duration and curve; what still moves | B | `timing.reducedMotionCrossFadeMs` (played at 200 ms: `design:reduced-motion` DR2 caps it, DEC-1 of 2026-10-24, departing from the measured 0.5 s) | -| M-PR1 | Press release curve | Settings and Contacts: a row tapped and released (the deselect cross-fade) | the timing function Core Animation attaches to the deselect fade (control points) | A | `timing.press.highlightOutCurve` | -| M-NB1 | Navigation bar hide / show | none | `UINavigationController.hideShowBarDuration` | D | `timing.navigationBarHideShowMs` | +| ID | What | App and gesture | Extract | Method | Feeds | +| ----- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| M-S1 | Deceleration rates | none | `UIScrollView.DecelerationRate.normal.rawValue`, `.fast.rawValue` | D | `scroll.decelerationRate` | +| M-S2 | Momentum coast | A long list (Contacts, or a probe table of 500 rows): flicks released at 1000, 2500, 5000 pt/s | contentOffset(t) from release to rest; decay constant; per-millisecond vs per-frame model; the speed at which it stops | C | `scroll.decelerationRate` | +| M-S3 | Bounce-back spring | Same list: drag 120 pt past the top, hold still, release; a flick into the top edge at 3000 pt/s | contentOffset(t) after release and after edge contact; spring response and damping ratio; velocity hand-off | C | `scroll.bounce` | +| M-S5 | Rubber band | Same list on an 852 pt view: drag 100, 300, 600 pt past the top; a horizontal pager | displayed overflow against finger overflow; confirm c = 0.55 and d = the view's size on the axis | C | `scroll.rubberBand` | +| M-PG1 | Paging | Photos full-screen swipes; a probe paging scroll view | page chosen per release position × velocity (thresholds; at most one page?); contentOffset(t) onto the page | B, C | `paging` | +| M-ST1 | Scroll to top | Settings scrolled down 500, 2000, 8000 pt; tap the status bar | contentOffset(t); duration against distance; curve or spring fit | B, C | `scroll.scrollToTopRate` | +| M-N1 | Navigation push | Settings: tap General | incoming leading edge x(t), outgoing x(t) (parallax fraction), dimming alpha(t); animation parameters | A, B | `springs.navigationPush` | +| M-N2 | Navigation pop | General: back button | same as M-N1 | A, B | `springs.navigationPop` | +| M-N3 | Interactive pop | Settings ▸ General: edge swipe released at 40 % of the width at 600 pt/s (finishes) and at 30 % at rest (cancels) | x(t) after release; spring parameters; the finish / cancel rule (position, velocity) | A, B | `springs.navigationInteractivePopFinish` | +| M-A1 | Alert | Probe app: `UIAlertController(.alert)` presented and dismissed | scale(t), alpha(t) of the alert and the dimming view; animation parameters | A | `springs.alertPresent`, `.alertDismiss` | +| M-AS1 | Action sheet and menu | Probe app: `.actionSheet`; a button with a `UIMenu` as its primary action; Files: the … menu | scale, alpha, position (t) on open and close; animation parameters | A, B | `springs.actionSheet*`, `.menu*` | +| M-CM1 | Context menu | Files: long-press an item; dismiss by tapping outside | preview scale(t) on lift, backdrop blur and dim (t), menu scale(t); the preview's return on dismiss | A, B | `springs.contextMenu*` | +| M-K1 | UIKit legacy spring | Probe app: `UIView.animate(withDuration:usingSpringWithDamping:initialSpringVelocity:)`, durations 0.3, 0.5, 1 × ratios 0.5, 0.8, 1 × velocities 0, 2 | the `CASpringAnimation` UIKit attaches: mass, stiffness, damping, initialVelocity, duration, settlingDuration | A | `springs.ts` (UIKit's legacy springs) | +| M-K2 | Default spring, keyboard | Probe app: an animator with `UISpringTimingParameters()`; focus and blur a text field | both springs' parameters and allowsOverdamping; the keyboard notification's duration and curve | A, D | `springs.viewChange`, `.keyboard`, `timing.keyboard*` | +| M-CA1 | Settling duration | none: `CASpringAnimation` mass 1, stiffness 100, damping 5, 10, 15, 20, 40 × initialVelocity 0, 5, −5 × allowsOverdamping on, off | `settlingDuration` of each (checks the critically damped and overdamped branches) | D | `springs.ts` (settling) | +| M-T1 | Banner | A notification pushed to the simulator while an app is in the foreground | banner y(t) in and out; time on screen | B | `springs.banner` | +| M-RM1 | Reduced Motion | Reduce Motion on, Prefer Cross-Fade Transitions on and off: Settings push and pop, an alert (sheets: M-SH13; controls: M-SW11, M-SL12, M-SG15) | which transitions become cross-fades; alpha(t), duration and curve; what still moves | B | `timing.reducedMotionCrossFadeMs` (played at 200 ms: `design:reduced-motion` DR2 caps it, DEC-1 of 2026-10-24, departing from the measured 0.5 s) | +| M-PR1 | Press release curve | Settings and Contacts: a row tapped and released (the deselect cross-fade) | the timing function Core Animation attaches to the deselect fade (control points) | A | `timing.press.highlightOutCurve` | +| M-NB1 | Navigation bar hide / show | none | `UINavigationController.hideShowBarDuration` | D | `timing.navigationBarHideShowMs` | +| M-PB1 | Indeterminate pulse | Reduce Motion on: Safari loading a page (its indeterminate bar) beside ProgressBar's reduced-motion pulse | Not a parity value: an owner value (`component:ProgressBar` DEC-2); confirm the 2000 ms pulse reads as calm and the 0.84 floor keeps 3:1 (AR4) | B | `progressBar.reducedMotionPulseMs`, `progressBar.reducedMotionPulseFloor` | Done (UIKit lab, iOS 26.2 simulator, Oct 23 2026; values in `uikit/` tagged `measured:`): M-S1, M-NB1, M-K2, M-CA1 (underdamped grid matched to the last diff --git a/packages/core/src/motion/README.md b/packages/core/src/motion/README.md index cc360fae3a265..9bd9891d94660 100644 --- a/packages/core/src/motion/README.md +++ b/packages/core/src/motion/README.md @@ -20,7 +20,7 @@ roles through `resolveMotion`. | `uikit/timing.ts` | Non-spring clocks and curves: press highlight, Core Animation's named curves, keyboard, Reduced Motion cross-fade. | | `uikit/paging.ts` | A paging UIScrollView: the integrator that settles a release on its page, the page rule, the catch, `setContentOffset(animated:)`. | | `uikit/navigation.ts` | UINavigationController: push velocity, parallax, dimming, transition clock, interactive pop finish rule. | -| `uikit/progressBar.ts` | UIProgressView's fill: the whole-track duration and curve (two roles), its minimum length and empty alpha. | +| `uikit/progressBar.ts` | UIProgressView's fill: the whole-track duration and curve, its minimum length and empty alpha; the reduced-motion pulse (three roles). | | `uikit/gestures.ts` | Gesture recognizer thresholds, the velocity filter and haptic stand-ins, as `{value, unit}` entries (sources in JSDoc). | | `roles.ts` | The internal motion roles: the springs, durations and curves `spec:AST-094` FR4 names, registered by the shared tables, and a role's CSS form read back. | | `roleValue.ts` | A role's CSS form, written: a spring's duration and bounce, a duration in ms, a curve's `cubic-bezier()`. | diff --git a/packages/core/src/motion/uikit/progressBar.ts b/packages/core/src/motion/uikit/progressBar.ts index db319b2ce9c42..98da3e2714596 100644 --- a/packages/core/src/motion/uikit/progressBar.ts +++ b/packages/core/src/motion/uikit/progressBar.ts @@ -4,11 +4,11 @@ * @file progressBar.ts * @input Nothing: data only * @output Exports `progressBar` (UIProgressView's fill: the whole-track - * duration and curve, its minimum length and its empty alpha) and - * `registerProgressBarTable` + * duration and curve, its minimum length and its empty alpha; and the + * reduced-motion pulse) and `registerProgressBarTable` * @position ProgressBar's rows of the UIKit constants table (motion/uikit). * Lengths are in pt (CSS px), durations in ms, alphas in 0..1. Tests check - * each leaf's `@source`. + * each leaf's `@source`, and each GUESS's `@measure`. * * SYNC: When modified, update * /packages/core/src/ProgressBar/ProgressBarTouch.ts and @@ -18,10 +18,11 @@ import {registerMotionTable} from '../roles'; /** - * UIProgressView's values, for ProgressBar. Two values are motion roles in the - * generated motion role table: the whole-track duration, - * `progress.fillTrackMs`, and its curve, `progress.curves.fill`. Every other - * value is an internal constant. + * UIProgressView's values, for ProgressBar. Three values are motion roles in + * the generated motion role table: the whole-track duration, + * `progress.fillTrackMs`, its curve, `progress.curves.fill`, and the + * reduced-motion pulse, `progress.reducedMotionPulseMs`. Every other value is + * an internal constant. */ export const progressBar = { /** @@ -43,6 +44,24 @@ export const progressBar = { */ fill: [0, 0, 1, 1], }, + /** + * Under reduced motion the indeterminate fill stops travelling and pulses + * its opacity in place. Each way takes this long, on the `timing.curves.move` + * curve. A busy loop, so tempo does not scale it. + * @source GUESS — An owner value, not a UIKit number. iOS Safari pulses its + * indeterminate bar for 1 s between 0.30 and 0.60 opacity. ProgressBar + * pulses slower, at a floor that keeps the bar visible. + * @measure M-PB1 + */ + reducedMotionPulseMs: 2000, + /** + * That pulse's lowest opacity. It is the lowest at which every tone that + * reaches 3:1 against the track at full opacity still does. That holds in + * the light and dark default themes. + * @source GUESS — An owner value, not a UIKit number. + * @measure M-PB1 + */ + reducedMotionPulseFloor: 0.84, progressView: { /** * The track's height in the `.default` style; the fill is as tall. @@ -71,12 +90,13 @@ export const progressBar = { } as const; /** - * Registers the table's two roles, for the generated motion role table and its - * test, which call it. Nothing at runtime reads the registry: `resolveMotion` - * returns the table value. Idempotent, and nothing runs on import. + * Registers the table's three roles, for the generated motion role table and + * its test, which call it. Nothing at runtime reads the registry: + * `resolveMotion` returns the table value. Idempotent, and nothing runs on + * import. */ export function registerProgressBarTable(): void { registerMotionTable('progress', progressBar, { - roles: ['fillTrackMs', 'curves.fill'], + roles: ['fillTrackMs', 'curves.fill', 'reducedMotionPulseMs'], }); } diff --git a/packages/core/src/theme/derivedVarRegistry.test.ts b/packages/core/src/theme/derivedVarRegistry.test.ts index e5a21e983dcfa..c39ae60eee579 100644 --- a/packages/core/src/theme/derivedVarRegistry.test.ts +++ b/packages/core/src/theme/derivedVarRegistry.test.ts @@ -119,6 +119,12 @@ const STRUCTURAL_VARS = new Set([ '--_progress-bar-curve', '--_progress-bar-flip', '--_progress-bar-min', + // The reduced-motion pulse's duration, curve and floor, written inline from + // the progress table on the indeterminate fill. Runtime state, never + // authored by a theme. + '--_progress-bar-pulse', + '--_progress-bar-pulse-curve', + '--_progress-bar-pulse-floor', ]); /**