Skip to content

fix(core): the reduced-motion mark carries a marker of its own, and the chat scroll, streaming text and swipe settle read it (spec:AST-108 FR6) - #8895

Draft
vjeux wants to merge 928 commits into
meta:mainfrom
vjeux:theme/reduced-motion-mark
Draft

vjeux wants to merge 928 commits into
meta:mainfrom
vjeux:theme/reduced-motion-mark

Conversation

@vjeux

@vjeux vjeux commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Review only this PR's change: vjeux/astryx@f42fdcd...403091e

Why

A theme's reducedMotion: 'always' marks its root, but most of core keys Reduce Motion on prefers-reduced-motion alone: the mark reaches only the press release fade, the adaptation rules and the animator. spec:AST-108 FR6 asks every Reduce Motion style and script reader to key on it too. This lays the foundation and converts three script readers; stacked pull requests convert the rest by family.

What led to it

With StyleX 0.19, stylex.when.ancestor('[data-astryx-reduced-motion]') compiles to :where(.x-default-marker[data-astryx-reduced-motion] *), a class neither the Theme wrapper nor <html> carries, so it never matches. A bare marker key also lands at priority 3040, below pseudo-class states (3130) and the media arms (3200).

Options and tradeoffs

  • The default marker on the theme root: every default-marker :hover rule under it would match.
  • Key styles on --_astryx-reduced-motion alone: only computed values such as durations, never animation-name.
  • A marker of its own; twins nest each arm's pointer query (this pull request).

Why this choice

reducedMotionScope goes on the marked wrapper, server markup included, and on <html> from the root provider's layout effect, so portaled layers match. A twin, [stylex.when.ancestor('[data-astryx-reduced-motion]', reducedMotionScope)], nests the arm's pointer query ('@media all' without the split; motion/reducedMotion.ts shows one) and compiles where the media arms sit, at priority 3240: above defaults and pseudo-classes, below hover-guarded states, keeping design:reduced-motion's split.

reducesMotion(element) reads an element's ancestry, as the twins do; useReducesMotion(), for a hook without an element, reads the setting live or the theme tree, where a nested 'system' theme cannot remove an ancestor's mark. The chat scroll, streaming text and swipe settle use them. Readers tied to a component's CSS (AppShell, MobileNav, BottomSheet, Carousel) convert with its family's styles, so no component is half-reduced.

Two guards hold the rest, each with a pending list that only shrinks: every Reduce Motion arm needs its twin (40 files, 88 arms pending), and every script read of the setting needs the mark (6 pending).

How to check it

  1. reducedMotionScope.test.tsx: the marker in server markup and on <html>, and a mark-keyed style in the tree and in a layer portaled to <body>.
  2. The chat scroll, streaming text and swipe tests: an 'always' theme, the setting off.
  3. motion/reducedMotion.test.ts: the guards.

Evidence

  • Red first: 6 of the 10 marker tests and each reader test failed before.
  • Guard mutants: a missing twin, a stale entry and a wrong nested key fail; the right twin passes.
  • astryx.css is byte-identical (34,293 B gzipped): a marker adds no rule.
  • Core never server-renders <html> (architecture:theme-application INV2), and layers render on the client.
  • The authoring contract's known gap changes with the last family pull request.

cixzhang and others added 30 commits October 26, 2026 04:21
…fine pointer); the changeset says the same

animator.test.ts keeps this layer's header (no compositor browser spec in
this layer). The changeset now says decay sends a spring back from past an
edge through the Reduce Motion policy: a cross-fade on a coarse pointer, at
once elsewhere.
…side a scroll view

The press profiles' README said a cell's highlight always waits the
content-touch delay. PressTracker waits it inside a scroll view and lights
at once outside one (spec:AST-094 FR11), as its own header says; the README
now says so too.
What one call becomes under Reduce Motion (an opacity-only cross-fade
whose travel lands while the element is transparent, or everything at
once; a dip on a borrowed stylesheet opacity) moves from createAnimator's
closure to reducedMotionPlan.ts, a pure planner that reads the animator
through a view and returns the plan; the animator turns the plan into its
segments and runs the landings. No behavior change: the animator's tests
pass unchanged, and the planner has its own.

Co-authored-by: Christopher Chedeau <vjeuxx@gmail.com>
…ed pull requests

This pull request keeps the animator's Reduce Motion plan (now its own
planner module), the browser-set start of a call from rest, promotion,
atRest, rebase and animatorFor. Two features it carried move up the stack,
each with its own tests and changeset:
- play(curves), baking any closed-form curve like a spring;
- driving a pseudo-element (a dialog's ::backdrop).
The changeset is split the same way.

Co-authored-by: Christopher Chedeau <vjeuxx@gmail.com>
play(curves) bakes any curve given as value and velocity functions of
time (a spring chasing a moving target from solveSpringTowardRamp, a
fitted UIKit curve) exactly like a spring: presentation values,
velocities, stop, finished and the shared clock work the same, a later
animate starts from the curve's value and velocity, and under Reduce
Motion a curve that moves is travel the policy replaces. Tests and a
changeset of its own.

Co-authored-by: Christopher Chedeau <vjeuxx@gmail.com>
createAnimator(element, {pseudoElement: '::backdrop'}) animates a
pseudo-element: with no inline style, a static filling animation holds
its value after a curve ends and after set; a custom property on its host
promotes it; a Reduce Motion dip borrows and hands back the
pseudo-element's own opacity, and its holds follow a node replacement.
animatorFor keeps one animator per element and pseudo-element. Tests and
a changeset of its own.

Co-authored-by: Christopher Chedeau <vjeuxx@gmail.com>
…ot the page's frames around it

"a harness read inside a trace is not the page's long task…" also asserted
that no long task started anywhere in the read's window. On CI's runner each
frame of the fixture's 1,200 running rows took 51-54 ms, the page's own long
tasks, and failed it; at 4x CPU locally they take 126-220 ms. The read
itself was set apart correctly.

The test now asserts the read's own classification, and removes the rows
before the timer, click and direct-call windows, so each holds only its own
work. It passes at 4x CPU, where the previous assertion failed.
…ot hold up a wait) into the frame evidence
…hed curve

waitForAnimation's finished branch read every recorded animation, so a
scroll- or view-timeline animation that reached its end (a scroller scrolled
to its end) came back as finished with a NaN duration and interval. The
recorded set now keeps document-timeline animations only. The scroll-timeline
self-test scrolls its scroller to the end first; it returned the NaN entry
before (Chromium, 3x repeat pass). The README, JSDoc and file header name
watchAnimations where they list what moves the wait window and what the file
exports.

Co-authored-by: Christopher Chedeau <vjeuxx@gmail.com>
…rves), pseudo-element support) as a dependency

# Conflicts:
#	packages/core/src/motion/README.md
#	packages/core/src/motion/animator.ts
… a finished curve) into the recorder stand-down
…ion plan

Main carries meta#8055 as one commit; this branch already had its head
(8fc7f2e), so the merge keeps this branch's animator, coast, test and
changeset files over it and takes main's other changes as they are.
… motion-role animator

# Conflicts:
#	packages/core/src/motion/animator.ts
…into the experimental touch layer

# Conflicts:
#	.changeset/uikit-motion-animator.md
The travel list moved to the planner with the Reduce Motion plan; nothing
in the animator reads it any more (no-unused-vars).
…deselect clock; the animator changeset drops its no-component clause

The press profiles' README gave a control UIButton's 0.25 s release.
PRESS_PROFILES releases every surface on the list's deselect clock until a
control runs its own release, as its comment says; the README now says so.
Its test list still credited VelocityTracker's tests with checking each
strategy against the true velocity, though one filter remains: it now
names what they check. The animator's changeset said no component uses
it, which goes false in the release that carries the components that do.
…removed at its end is still reported

- Arming the recorder now records first and stamps strictly after the
  newest entry: a watch taken right after a step that changed style no
  longer reports that step's transition (created by the arm's own style
  read) as started after it.
- Each entry keeps its target's ancestors as recorded, so an exit whose
  element the page removes at its end is still reported for the container
  it left, instead of a wait rejecting with nothing recorded.

A self-test for each; both failed before (Chromium, 3x repeat pass).

Co-authored-by: Christopher Chedeau <vjeuxx@gmail.com>
solveSpringTowardRamp went with play(curves)'s tests, and a coast test
destructured an element it never reads (no-unused-vars).
play(curves)'s tests use solveSpringTowardRamp, so this layer keeps its
import.
…he frames it credits as skipped

With a compositor record and an on-screen start, the moved criterion counted
from the first moved frame after the on-screen frame, while the gap from the
on-screen frame to it was credited as frames the screencast skipped. When the
cast skipped the frames right after the on-screen start, those credits had
no place in the denominator: four unmoved frames that fail on a full cast
("31 of 35 expected frames moved") passed as "26/30 moved and 5 skipped by
the screencast (103%)", and a clean run read 117%.

The criterion now counts the compositor's frames from the on-screen frame,
so both sides cover the same frames: the skipped cast fails like the full
one, and a clean run reads at most 100%. A busy.test.ts case pins all three.
…an exit removed at its end is still reported) into the frame evidence
vjeux added 11 commits October 28, 2026 21:30
…prings, beside the tempo (spec:AST-108 FR3)

The springs are to read the knobs from the same CSS the durations use, so
the knobs inherit into nested themes and follow adaptation rules as the
durations do. A theme or rule with a tempo already writes it as a private
custom property; one that sets a character must write that too. Fails
now: no character is written.
…tion-character

Beside --_astryx-motion-tempo, which a theme or rule with a tempo already
writes for the press release fade, the character is now a private custom
property too, so the springs can read both knobs from the CSS the
durations use (spec:AST-108 FR3). A theme that sets neither writes
neither, so existing themes generate exactly what they did (FR5).
…ations use (spec:AST-108 FR3, FR4a)

A nested theme that sets no knob inherits its enclosing theme's duration
and easing tokens, and an adaptation rule's knobs apply where its
condition holds, but the springs read only attributes Theme wrote on its
own wrapper. The springs are to read the same custom properties
(--_astryx-motion-tempo, --_astryx-motion-character), so they inherit and
follow rules as the durations do.

- jsdom: the knobs are the custom properties declared on the element;
  the role tests' element carries the ones its theme writes.
- Chromium and WebKit (themeKnobs.interaction.browser.spec.ts, themes
  from defineTheme and generateThemeCSS): a nested theme that sets no knob
  plays its enclosing theme's, one that sets character 'standard' stops
  it, a coarse-pointer rule's knobs apply on a phone and not under a
  mouse, and a spring in a nested theme plays at the enclosing tempo and
  starts on its first frame while the layout is dirty. In Chromium, Long
  Animation Frames show that reading the knobs forces no layout, where
  reading a size does.

The nested, rule and spring cases fail now.
…animator's end hold

An animation's duration includes the 5 s it holds its end state
(END_HOLD_MS), and an expressive character lengthens a spring's settle
on its own, so the check compares, less the hold, the theme that sets
only character 'standard' inside the outer tempo of 1.4 with a spring
outside every theme. The layout is dirtied across a 12,000-row tree.
…om properties, so nested themes and adaptation rules reach them

themeMotionKnobs read attributes Theme wrote on its own wrapper, so the
springs played Astryx's own knobs inside a nested theme that sets none,
whose durations inherit the enclosing theme's, and ignored an adaptation
rule's tempo and character, which its durations follow (spec:AST-108 FR3,
FR4a). It now reads --_astryx-motion-tempo and
--_astryx-motion-character in one getComputedStyle at each motion start,
custom properties only, which forces no layout. Theme no longer writes
the tempo and character attributes, and their tests go with them; the
reduced-motion mark stays. resolveMotion's test now checks that no
role's own variable is read, only the knobs, and animatedLayout's that
an area inherits the tempo its theme writes.
…cter, and the springs read the knobs' custom properties

The motion docs said exits and fades never overshoot, but a role whose
character weight is zero keeps its measured bounce, and springs.menuClose
overshoots 0.6%. They now say exits and fades keep their measured bounce
(motion topic, theme template, defineTheme and motionKnobs JSDoc,
resolveMotion). The record's note on role tables says the springs follow
the custom properties a theme or adaptation rule writes, no longer
attributes.
…ts timing is a test annotation

@typescript-eslint/promise-function-async asks a function that returns a
promise to be async: knobsOf and the two page.evaluate callbacks. The
measured forced style and layout is reported as a test annotation instead
of a console line (no-console).
…er code reads resolveThemeMotion

The theme authoring contract said the animator reads resolveThemeMotion;
it reads the knobs from the custom properties the theme writes
(motion/themeKnobs.ts), and resolveThemeMotion is server code's pure
read. The motion README's themeKnobs row and resolveMotion's header still
described the attribute design; they now say the knobs are inherited
custom properties, read in one style read that forces no layout.
…he chat scroll, streaming text and swipe settle read it (spec:AST-108 FR6)

A theme's reducedMotion: 'always' marks its root, but a style keyed with stylex.when.ancestor('[data-astryx-reduced-motion]') uses the default marker, a class neither the Theme wrapper nor <html> carries, so it never matched; the default marker itself would let every default-marker :hover rule under a theme match. reducedMotionScope is a marker of its own: the marked wrapper carries it, in server markup too, and the root provider writes it on <html> beside the mark, so a portaled layer matches. A Reduce Motion arm's twin nests the arm's own pointer query, which compiles to the media arms' place in the cascade: above defaults and pseudo-classes, below hover-guarded states. reducesMotion(element) reads an element's ancestry, as the twins do, and useReducesMotion() reads the setting live or the theme tree, for a hook without an element; useChatStreamScroll, useStreamingText and the swipe settle use them. Two guards in reducedMotion.test.ts hold the rest: every arm needs its twin and every script read of the setting needs the mark, each with a pending list that only shrinks.
…s lines (spec:AST-108 FR6)

A component's twin key, stylex.when.ancestor('[data-astryx-reduced-motion]', reducedMotionScope), runs past the line length wherever it sits deep in a style, and Prettier then breaks it across lines with a trailing comma. The guard matched it as one literal string, so every such twin read as missing. It matches the key whitespace-insensitively now.
…which StyleX's types need (spec:AST-108 FR6)

StyleX's types flatten a nested condition object into a property's value union only when it has a default key. A twin without one keeps its nested object in the union, so a style holding it is no longer assignable to a typed xstyle (a layer's animations, passed to Popover and its kin, failed the typecheck so). default: null emits no rule; the compiled CSS is byte-identical.
@vercel

vercel Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Ready Ready Preview Oct 29, 2026 8:44am UTC

Request Review

@vjeux
vjeux requested a review from josephfarina as a code owner October 29, 2026 07:34
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 29, 2026
@astracat-bot

astracat-bot Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟡 Reviewed (for maintainers only) Oct 29, 2026, 9:01 AM UTC

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes: the reduced-motion marker and the three converted readers match spec:AST-108 FR6, but this diff also carries the unmerged stack beneath it (#8446, #8076, #8661, #8677), about 17,500 authored lines against a 2,000-line size limit, so please land those first and let this pull request shrink to its own ~870 lines.

  • The changeset says reducedMotion: 'always' reaches Markdown while it streams, but Markdown's entry fade (streamingStyles.fadeIn in Markdown.tsx) still plays under 'always', so add its twin or narrow the changeset.
  • The script-reader guard in motion/reducedMotion.test.ts only matches a query written inline, so useMediaQuery(REDUCED_MOTION_COARSE_QUERY) in BottomSheetPanel.tsx will slip past it once that file's other read converts.
  • The Vercel preview deployment fails on this head while it passed on the parent commit, which is worth checking before merge.

[Automated review]

'@astryxdesign/core': patch
---

[fix] A theme's `reducedMotion: 'always'` reaches streaming text (`useStreamingText`, and so Markdown while it streams), the chat scroll's follow and a swipe row's settle, as the person's Reduce Motion setting does. The mark also carries a marker class, on the theme's root and, for the root theme, on `<html>`, so components' Reduce Motion styles can key on it, portaled layers included.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This says 'always' reaches "Markdown while it streams", but Markdown still wraps each arriving chunk in streamingStyles.fadeIn (packages/core/src/Markdown/Markdown.tsx:637-652), whose fade drops to 0s only under @media (prefers-reduced-motion: reduce), and Markdown/Markdown.tsx is still on PENDING_TWINS. Under an 'always' theme with the setting off, a streamed chunk still fades in (opacity 0.407 about 40 ms after it arrives, transition-duration: 0.3s). Either add the twin to fadeIn.transitionDuration and drop Markdown from PENDING_TWINS, or narrow this line to Markdown's progressive reveal.

Comment on lines +147 to +148
const RAW_SETTING_READ =
/(matchMedia|useMediaQuery)\(\s*'\(prefers-reduced-motion: reduce\)'/;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This matches only a query written inline. BottomSheet/BottomSheetPanel.tsx:665 reads useMediaQuery(REDUCED_MOTION_COARSE_QUERY) ('(prefers-reduced-motion: reduce) and (pointer: coarse)', :129), so once :554 converts, this guard calls the file converted and forces it off PENDING_SETTING_READERS while :665 still ignores the mark. Resolving string constants the way armsWithoutTwins does for arms, or flagging any '(prefers-reduced-motion literal outside a style key, would close it.

…wn, and the script guard catches a read through a constant (spec:AST-108 FR6)

Markdown's own CSS entry fade still plays under a theme's reducedMotion: 'always' until its twin lands, so the changeset names useStreamingText's progressive reveal, which this pull request converts, and no longer says Markdown while it streams. The script guard matched only a media query written inline, so a read through a constant (BottomSheetPanel's useMediaQuery(REDUCED_MOTION_COARSE_QUERY)) passed it; it now follows a constant the file holds the query in. Red first: a probe reading the setting through a constant passed the guard before and fails it now.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes: the reduced-motion marker, the three converted readers and the widened script guard match spec:AST-108 FR6, and the changeset now describes exactly what ships. Against main, though, this diff still carries the unmerged stack beneath it (#8446, #8076, #8661, #8677), about 17,500 authored lines against the 2,000-line limit, so please land those first and let this pull request shrink to its own ~880 lines.

[Automated review]

@vjeux

vjeux commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

Thanks.

@vjeux
vjeux marked this pull request as draft October 29, 2026 22:40
@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review label Oct 31, 2026

This branch was successfully deployed

1 active deployment
Preview — 403091e4 Deployed Oct 29, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot. needs:design-review Affects visuals — Design should review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants