Skip to content

feat(core): the animator's springs follow the theme's tempo - #8677

Draft
vjeux wants to merge 2 commits into
meta:mainfrom
vjeux:uikit/f1-theme-motion-springs
Draft

vjeux wants to merge 2 commits into
meta:mainfrom
vjeux:uikit/f1-theme-motion-springs

Conversation

@vjeux

@vjeux vjeux commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Stack: #8677 → #8686

The theme's character stays out of this pull request: its spring tables wait on #9704. This one is tempo only.

User need

A theme author sets motion.tempo to slow down or speed up their product's motion. Today only the CSS transitions follow it. The touch springs keep Astryx's own timing: a sheet's settle, a switch's travel, a slider's release and the animated-layout glide. So with a tempo of 1.5, a screen's fades take 1.5 times as long while its sheet still settles at tempo 1.

Problem and solution fit

Every touch spring and role duration goes through resolveMotion(element, role), and that function passed each role through unchanged. It now reads the tempo of the theme element is in and stretches the role:

  • A spring's undamped period grows by the tempo and its damping ratio stays the same. So it keeps its shape and takes tempo times as long.
  • A duration role is multiplied by the tempo.
  • The layout glide reads springs.settle through it, at the area.

Current specification

  • spec:AST-108 FR3 (current): "a spring's duration scales by tempo".
  • FR4 lists what stays fixed.
  • FR5: a duration scale alone leaves the springs at tempo 1.
  • spec:AST-094 FR45 (current): "The theme's tempo scales the spring's duration".
  • FR3's character offsets aren't here. The spec owner keeps them parked until spec(AST-108): the fluid character's motion tables #9704, which redefines them, lands.

Public delta and caller burden

  • There's no API change. A theme with motion: {tempo: 1.5} now also plays every touch spring and role duration inside it 1.5 times as long, the glide included.
  • Nothing changes for a theme that sets no tempo, or only a duration scale. The theme writes --_astryx-motion-tempo only when it sets tempo, and the reader treats a missing value as 1.
  • These stay as measured: finger tracking (springs.fingerTracking and Switch's drag tracking), ProgressBar's reduced-motion pulse, curves, and anything that isn't a role (momentum, the rubber band, paging, press clocks and thresholds).
  • Switch's release settles on a curve it builds itself. It now resolves its lift and settle springs and its minimum lift at the thumb, as its lift does, so the release takes the theme's tempo too.
  • A theme layer whose durations come from a duration scale alone now writes a tempo of 1, so it keeps its springs at the pace of its own durations inside a theme with a tempo (FR5). A child of a theme with a tempo keeps the base's tempo; a layer that sets only character or a few explicit durations writes none.
  • The docs that said the springs don't read the knobs now say they follow tempo but not character yet: the motion doc, defineTheme's motion JSDoc, the theme template and the author-a-theme doc.

Design and alternatives

  • Where the tempo comes from: the reader uses the private property the theme already writes beside its duration tokens. That's one custom-property read per resolve, which forces no layout. It inherits through nested themes and follows adaptation rules, as the durations do. Putting attributes on Theme's wrapper was rejected because they don't inherit and can't take rule conditions.
  • How a spring stretches: each spring stretches in its own form (response, duration, or stiffness and damping) instead of converting through one form. So tempo 1 returns the table value itself, and nothing is rounded.
  • No double scaling: a resolved spring is cached per role and tempo. It isn't a role itself, so when the animator resolves it a second time, it's left alone.
  • Every pointer: the glide follows tempo with a mouse too, as spec:AST-105 DEC-2 decides (cixzhang, 2026-11-04): "a theme that sets character or tempo sees its glides change with the rest of its motion, under every pointer." The owner's decision for the interim spring (2026-11-04, 16:24 UTC): "Until the glide moves to the curve tokens (AST-105 DEC-2), its interim springs.settle follows tempo under every pointer." At tempo 1 nothing changes on desktop. AST-108 FR1 has tempo scale every motion duration, and FR3 scales every AST-094 FR4 role's spring. The glide plays the FR4 role springs.settle under every pointer (AST-105 FR4, FR5), and AST-107 FR3 says animate's spring follows the theme's tempo. The CSS tokens already follow tempo with a mouse, so the glide now keeps step with them.
  • One tempo reader: the press release fade plays --duration-exit, which the tempo already scales, so it needs no reader of its own.

Evidence

  • themeTempo.interaction.browser.spec.ts, Chromium and WebKit on the phone profile:
    • A spring the animator plays inside a tempo 1.5 scope, inherited through a nested element, draws at 1.5 × t what it draws at t outside the scope, within 1.5 px over a 300 px travel.
    • Finger tracking draws the same in both.
    • Under three real themes that set only motion.tempo, the glide lasts 0.75, 1 and 1.5 times as long.
  • resolveMotion.tempo.test.ts:
    • Each spring form's settle time scales exactly, and its curve matches at the scaled times.
    • A duration role scales.
    • Curves, finger tracking and the busy loop don't.
    • Tempo 1 and no tempo return the table value itself.
    • The animator plays the slowed spring.
  • themeTempo.test.ts reads the theme's value, and falls back to 1 when there is none or it isn't a positive number.
  • animatedLayout.test.ts checks the glide at the area's tempo.
  • useSwitchTouch.test.ts: a release right after a press waits the minimum lift and settles, all exactly 1.5 and 0.75 times as long at those tempos.
  • themeMotion.test.ts: a theme and an adaptation rule with a duration scale alone write a tempo of 1; a child of a tempo theme keeps its base's tempo.
  • The browser spec also runs the glide with a mouse (a desktop context, no coarse pointer): 0.75, 1 and 1.5 times as long. A duration-scale theme nested in a tempo 1.5 theme glides at its own pace (1×) beside the outer theme's 1.5×.
  • The specs that set a tempo on the document (ProgressBar's move and pulse) now set it on the component: the story's theme root declares its own tempo.
  • Red first for the Switch and duration-scale fixes: with only those two files reverted (35f25756c9), the Switch case reads a ratio of 1.0 and both theme cases find no tempo.
  • Red first, on main with only these tests added (a5b1282f64): 6 unit cases fail and themeTempo.test.ts has no module to load. The browser scope case is 25 px off; finger tracking passes on both, as it should.

Scope

  • One capability is added.
  • Unrelated fixes, cleanup, and policy changes were removed or split.
  • Consumer docs, focused tests, and migration evidence ship with the feature.
  • Public text and artifacts contain no internal Meta context.

Testing

Gate at 50e47ea46d (rebased onto main after the shared press release fade moved to --duration-exit), side by side with main on one box:

  • Build, Storybook build, typecheck and lint pass; changeset coverage passes.
  • pnpm test:ui 20,636 vs 20,622, 0 failed; pnpm test:node 16,561 passed.
  • Interaction Chromium 668 vs 663, 0 failed.
  • Interaction WebKit 568 vs 564. Two cases fail, neither from this change:
    • The harness's shader-cache case fails on both arms; that box's container had no seeded shader cache.
    • ListRow's "the release keeps fading while the main thread is blocked (video)" also fails on main: in a same-moment pair of five runs each, main failed 5 and this change failed 4.

Earlier, at 6755a6a5a8: the tempo spec three times, Chromium 15/15 and WebKit 15/15; ProgressBar's move and pulse specs 26/26.

Feel check: open the Core/Theme motion tempo story ("Three tempos") on a phone. In each row, toggle the switch, drag the slider, slide the segments, and open "Shipping details". The 1.5 row should feel half again as slow as the middle row, and the 0.75 row quicker. Each finger drag still tracks the finger.

@vercel

vercel Bot commented Oct 27, 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 Nov 5, 2026 3:08am UTC

Request Review

@astracat-bot

astracat-bot Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Nov 5, 2026, 3:15 AM UTC

@github-actions github-actions Bot added needs:design-review Affects visuals — Design should review needs:spec-owner-review Current knowledge records await owner approval labels Oct 27, 2026
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 27, 2026

@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.

Approval of this pull request waits on a spec decision. On main, spec:AST-094 FR4 and DEC-11 keep the touch motion roles internal, while the spec:AST-108 requirements (with the spec:AST-094 DEC-15 and related amendments) that let a theme's tempo and character retune them appear here only as proposed text, in an earlier version than the one approved on #8613. An engineering owner records that decision by commenting /approve-spec 5e235e66b2a97e2e81d798820889f71d03bd8d29 on this pull request (the author may do so if they are an engineering owner); otherwise the pull request waits for #8613 to land on main and rebases onto it. Whatever the decision, two changes are needed:

  • Rebase onto main once #8076 and #8661 land, so the diff carries only this change; today it counts 17,872 authored lines against a 499-line own change.
  • Make the springs follow the knobs the CSS follows (an adaptation rule's tempo or character, and an enclosing theme's knobs, which themeMotionKnobs never reads), or state in the spec that they do not.

[Automated review]

Comment thread packages/core/src/motion/themeKnobs.ts Outdated
Comment on lines +58 to +78
export function themeMotionKnobs(
element: Element | null | undefined,
): ThemeMotionKnobsAt {
const scope = element?.closest?.(THEME_SCOPE);
if (scope == null) {
return DEFAULT_KNOBS;
}
const tempoValue = Number(scope.getAttribute('data-astryx-motion-tempo'));
const tempoIsSet =
scope.hasAttribute('data-astryx-motion-tempo') &&
Number.isFinite(tempoValue) &&
tempoValue > 0;
const characterValue = scope.getAttribute('data-astryx-motion-character');
const character: ThemeMotionCharacter =
characterValue === 'crisp' || characterValue === 'expressive'
? characterValue
: 'standard';
if (!tempoIsSet && character === 'standard') {
return DEFAULT_KNOBS;
}
return {tempo: tempoIsSet ? tempoValue : 1, tempoIsSet, character};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The springs read only this scope's attributes, which Theme.tsx writes from the theme's root motion. The CSS also follows an adaptation rule's tempo or character, and inherits an enclosing theme's scaled --duration-* when the nearest theme sets no motion. In both cases the springs and the CSS diverge. For example, a when: {pointer: 'coarse'} rule with tempo: 1.3 makes the CSS 1.3× while the springs keep the root tempo. Either resolve the knobs the way the CSS does, or state the limit in AST-108 FR3 and FR5, with a test either way.

Comment thread packages/core/src/motion/animator.ts Outdated
return null;
}
const curve = solveSpring(toSpring(spring), {from, to, velocity});
// The motion role it plays: its table value while DEC-11 holds.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

After this change a role follows the theme's knobs (resolveMotion.ts), so "its table value while DEC-11 holds" no longer describes it. The same goes for line 137 and for README.md lines 49-50 and 72-73.

@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

Current run: The current CI run concluded skipped. Current analysis is unavailable.


Generated by PR Enrichment workflow | View current CI run

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026

@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.

Approval of this pull request waits on a spec decision: on main, spec:AST-094 FR4 and DEC-11 keep the touch motion roles internal, and the spec:AST-108 records (with architecture:theme-authoring-contract INV15) that let a theme's tempo, character and reducedMotion change them are proposed here without approval for this head. An engineering owner records that decision by commenting /approve-spec f2546dbdc1b0830d730cdae6793c51a5d6c3028f on this pull request (the author may do so as an engineering owner), or the records land on main first. Whatever the decision, these changes are needed:

  • Make reducedMotion: 'always' reach the components' own reduced-motion styles and script readers before the docs and changeset promise that it reduces motion for everyone, or say what it reaches today.
  • Write --pre-tempo-duration-* wherever a theme writes a duration, so a nested theme's scale or explicit duration still times Spinner and Skeleton under a theme that sets tempo.
  • Make the springs follow the knobs the CSS follows (an adaptation rule's tempo or character, and an enclosing theme's knobs), or state that limit in the spec, with a test either way.
  • Rebase once #8076 and #8661 land, so the diff carries only this change (17,911 authored lines today).

[Automated review]

Comment thread packages/cli/assets/docs/motion.doc.mjs Outdated
items: [
"`tempo` scales every motion duration: 1 is Astryx's own, 0.75 the quickest and 1.5 the slowest (values outside are clamped).",
"`character` sets how lively motion is: `'crisp'` decelerates without overshoot, `'standard'` is Astryx's own, and `'expressive'` overshoots slightly on entrances and moves. Exits and fades never overshoot.",
"`reducedMotion: 'always'` reduces motion for everyone, as if each person had turned on Reduce Motion; `'system'`, the default, follows each person's setting. No value loosens Reduce Motion.",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No core component reads the data-astryx-reduced-motion mark yet: the 50 non-test core files that read prefers-reduced-motion in a style or script (Dialog, BottomSheetPanel, TopNavMegaMenu, Step among them) ignore it, so a person without the setting still gets full motion under a theme on 'always'. Only the theme's own when: {motion} adaptation rules and the internal animator honour it. Either make those styles and script readers key on the mark (as spec:AST-108 FR6 requires) before promising this, or say what 'always' reaches today. The changeset's "so motion is reduced for everyone" has the same problem.

): Record<string, string> {
const knobs = resolveMotionKnobs(config);
const tokens: Record<string, string> = {};
if (knobs.tempoIsSet) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

--pre-tempo-duration-* is written only when this theme sets tempo, and resolveThemeValues copies an explicit duration into it only when the name already exists. Custom properties inherit, so a nested theme with its own duration scale or explicit duration but no tempo leaves Spinner and Skeleton reading the outer theme's pre-tempo value. Example: inside a tempo: 1.5 theme, a nested motion: {fast: 100, medium: 250, slow: 600, ratio: 0.75} theme's Spinner turns every 730 ms instead of its own 450 ms --duration-slow-min. Writing the pre-tempo twin wherever a theme writes a duration would fix it; a nested-theme test would pin it.

* the element is in (`spec:AST-108` FR3): `tempo` scales a spring's duration
* and a role's duration, and `character` moves a spring's bounce by its
* role's weight (1 for entrances and presentations, 0.5 for state changes, 0
* for exits, which never overshoot). The theme's `MotionScaleConfig` alone

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

"0 for exits, which never overshoot" no longer matches spec:AST-108 FR3 at this head, which says a weight-0 role keeps its measured bounce: springs.menuClose (damping ratio 0.85) overshoots by about 0.6% at every character. The same wording sits in theme/motionKnobs.ts:34, theme/defineTheme.ts:243, cli/assets/theme.template.ts:186 and motion.doc.mjs:159.

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026

@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.

Approval of this pull request waits on one spec decision: architecture:theme-authoring-contract INV15 adds "an explicit token anywhere in the lineage still wins", which no record on main states and which lets a base theme's explicit --duration-* or --ease-* token beat a child's own duration scale. An engineering owner records that decision by commenting /approve-spec eeec68709d2d3c5de4c5138180bcce7b740a4f99 on this pull request (the author may do so as an engineering owner), or the clause can be dropped so a child's own scale keeps winning. Whatever the decision, these changes are needed:

  • Make component styles and script readers honour the reducedMotion: 'always' mark, as spec:AST-108 FR6 requires, or keep 'always' out of the docs and changeset until they do.
  • Make the springs follow the knobs the CSS follows: an adaptation rule's tempo or character, and an enclosing theme's knobs.
  • Write --pre-tempo-duration-* wherever a theme writes a duration, so a nested theme still times Spinner and Skeleton under a theme that sets tempo.
  • Rebase once #8076 and #8661 land, so the diff carries only this change (17,803 authored lines today).

[Automated review]

Comment thread packages/core/src/motion/themeKnobs.ts Outdated
export function themeMotionKnobs(
element: Element | null | undefined,
): ThemeMotionKnobsAt {
const scope = element?.closest?.(THEME_SCOPE);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The springs read only the nearest theme scope's attributes, which Theme.tsx writes from the theme's root motion. An adaptation rule's tempo or character, and an enclosing theme's scaled durations, reach the CSS but not the springs: a when: {pointer: 'coarse'} rule with tempo: 1.3 makes --duration-medium 533 ms while springs.settle stays 0.5 s. spec:AST-108 FR3 lists an adaptation rule among its verified states, and FR4a needs a CSS fade and its spring to land together.

): Record<string, string> {
const knobs = resolveMotionKnobs(config);
const tokens: Record<string, string> = {};
if (knobs.tempoIsSet) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Only a theme that sets tempo writes --pre-tempo-duration-*, and custom properties inherit. Inside a tempo: 1.5 theme, a nested theme with motion: {fast: 100, medium: 250, slow: 600, ratio: 0.75} leaves its Spinner turning every 730 ms instead of 450 ms and its Skeleton pulsing over 550 ms instead of 335 ms (component:Spinner FR3, component:Skeleton FR4). Writing the pre-tempo twin wherever a theme writes a duration, with a nested-theme test, would fix it.

Comment thread packages/cli/assets/docs/motion.doc.mjs Outdated
items: [
"`tempo` scales every motion duration: 1 is Astryx's own, 0.75 the quickest and 1.5 the slowest (values outside are clamped).",
"`character` sets how lively motion is: `'crisp'` decelerates without overshoot, `'standard'` is Astryx's own, and `'expressive'` overshoots slightly on entrances and moves. Exits and fades never overshoot.",
"`reducedMotion: 'always'` reduces motion for everyone, as if each person had turned on Reduce Motion; `'system'`, the default, follows each person's setting. No value loosens Reduce Motion.",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No core component reads data-astryx-reduced-motion yet: the 48 core files that key reduced motion on prefers-reduced-motion ignore it, so a person without the setting still gets full motion under an 'always' theme. spec:AST-108 FR6 requires those styles and script readers to key on the mark; until they do, this line and the changeset overstate what 'always' does.

Comment thread packages/core/src/theme/defineTheme.ts Outdated
input.motion != null && baseMotion != null
? {...baseMotion, ...input.motion}
: input.motion;
const lineageMotionTokens =

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 re-applies the base's explicit --duration-* and --ease-* tokens above whatever the child generates, so a child's own duration scale no longer beats a base's explicit token, as it does today. That is the INV15 clause waiting on an owner decision; if it stays, the record's System model precedence list should say so too.

* the element is in (`spec:AST-108` FR3): `tempo` scales a spring's duration
* and a role's duration, and `character` moves a spring's bounce by its
* role's weight (1 for entrances and presentations, 0.5 for state changes, 0
* for exits, which never overshoot). The theme's `MotionScaleConfig` alone

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

"0 for exits, which never overshoot" contradicts spec:AST-108 FR3: a weight-zero role keeps its measured bounce, and springs.menuClose (damping ratio 0.85) overshoots by about 0.6%. The same wording is in motionKnobs.ts:33-34, defineTheme.ts:242-243, theme.template.ts:186 and motion.doc.mjs:159.

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
…empo and character) into the public animator

spec:AST-107 FR3 has every animate play on springs.stateChange as
spec:AST-108 FR3 resolves it at the theme's knobs; meta#8677 brings that
resolution, with main and meta#8661 beneath it.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
…ionAnimator

The public createAnimator (spec:AST-107 FR6) takes the name; meta#8677's
tests of the internal animator now import it by its own name.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
…coasts every channel

spec:AST-107 FR3 as merged:

- Every animate plays springs.stateChange at the tempo and character of
  the theme the element is in (spec:AST-108 FR3), which meta#8677 brings;
  the new case fails with the motion module as it was before it.
- decay coasts any of the five channels it is given on the fixed
  momentum decay (spec:AST-108 FR4): the translate axes as a throw that
  Reduce Motion lets continue, scale, rotate and opacity as a transition
  it plans like a move (design:reduced-motion DR2); opacity stops at 0 or
  1. It took only x and y before.
- destroy halts every channel where it shows before letting go, so
  nothing reads as moving after it, and every method after it does
  nothing; destroy removes every listener it added (FR6).

The docs and the changeset say the same.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
Main's golden counts 367 matches for "icon set"; this branch's public
animator pages and meta#8677's motion docs each add one, so the merged count
is 369. The listed results and every other field are unchanged, and the
diff names no icon set and nothing experimental.
@vjeux
vjeux marked this pull request as draft October 29, 2026 22:38
cixzhang pushed a commit that referenced this pull request Nov 1, 2026
…ch the SideNav rail moves onto (#8721)

Implements the records #8783 put on main: `spec:AST-105` FR2 and FR9, `architecture:react-component-runtime` INV12, `component:SideNav` FR6, `component:Resizable` FR8, `component:LayoutPanel` FR7 and AR2, and `component:Table` FR15. Resizable's and LayoutPanel's slide and LayoutPanel's focus rule (AR2: focus moves to the ResizeHandle only when a collapsing pane holds it) are built on this path in #9197 and #9202. This PR moves no focus anywhere.

Behavior-module PR: `packages/core/src/motion/animatedLayout.ts` (FR9's shared edge reveal).

## User need

A panel that collapses beside content, a Collapsible closed while it opens, a resizable pane sliding shut: each reveals or conceals an element from one of its edges while the content beside it moves. Before this change Core had one such reveal of its own per component. The SideNav rail clipped and rode its content in its own code. Collapsible's reveal could only open downward, and a close while it opened restarted the conceal from fully shown while the content below kept gliding, so a gap opened. Nothing a resize controller could reuse existed. #8783 made these one shared edge reveal in the animated-layout module.

## Problem and solution fit

The animated-layout module now produces `spec:AST-105` FR9's edge reveal, in three forms over one ride:

- **One-state** (`playLayoutChange`, `holdLayoutExit`): reveals and conceals from the block-end edge by default, or from the block-start or an inline edge it is given, mirrored right to left. Once the content on that edge's side glides, the moving edge rides that glide frame by frame (its keyframes replaced, its clock the glide's), so no gap opens. It rides the content's offset relative to the element: when the same batch also moves the element itself, such as an item above them closing in a single-open CollapsibleGroup, the edge follows only the difference. A conceal by a start edge frees the element's space on that edge's side, so the element stays where it shows while the content before it moves; a layout where that would move the element itself is not held, and the element goes at once. A reversal starts from what the element shows. Collapsible, TreeList and Table's row expansion already play this block-end form, so they gain the ride, and Collapsible and TreeList, which hold their exits, the reversal too.
- **Two-state** (`holdLayoutEdge`, `playLayoutEdge`): holds the old state as FR8's swap copy, clips the larger state on the edge from the extent it shows, rides a gliding element, and raises the element above the copy while it conceals. The SideNav rail now plays through it on its inline-end edge and keeps only its labels' and icons' fades (`component:SideNav` FR6); its frames are unchanged.
- **A resized element with no copy** (`reportLayoutEdge`): measured before a size change commits and played after the new size applies. A larger extent reveals from the extent shown. A smaller one holds the element at the extent it was laid out at, freeing the difference by the margin on that edge's side so what lies beyond stays where the new size puts it, then clips down to the new extent. This is the path a resize controller's pane uses (`component:Resizable` FR8 and `component:LayoutPanel` FR7 in #9197 and #9202).

Reduce Motion follows `design:reduced-motion` DR2 under a coarse primary pointer and DR3 otherwise.

## Current specification

`spec:AST-105` FR9 ("One shared edge reveal") and FR2, `architecture:react-component-runtime` INV12, `component:SideNav` FR6, `component:Resizable` FR8, `component:LayoutPanel` FR7 and AR2, and `component:Table` FR15, all current on main since #8783. A LayoutPanel given a hand-built `ResizableProps` gets no slide, and the hook's own `props` passed through `resizable` slide; both are #9197 and #9202's to implement.

## Public delta and caller burden

None. The new functions and types stay internal: no entry point exports them (`animatedLayout.boundary.test.ts` checks this), and `useAnimatedLayout`'s contract is unchanged. The visible changes are Collapsible's and TreeList's: content closed while it opens, or opened while it closes, continues from what it shows, and its edge stays on the content after it (`[fix]` changeset). Table's row expansion reveal rides the rows after it.

## Design and alternatives

- **Riding:** the edge's keyframes are sampled on the glide's own sample grid and clock, so the clip and the glide are complementary by construction. The alternative, a separate spring tuned to match, drifts whenever the glide carries a running motion.
- **Resized element without a copy:** holding the old extent keeps the pane's content laid out as it was. Clipping a pane already at its new size would need its content to overflow, or a copy, which the runtime record rules out for a resize controller.
- **Morph routing** of the glide stays out until the shared rect morph lands (#8329), as INV12's "until then" clause says.

## Evidence

Status at this head: rebased onto main 8a03d75 after the component records moved to `docs/specs/components/`; the patch is byte-identical to 70ebcc7's. Here, build, type check, CI-strict lint, `check:knowledge`, the record-path tests (knowledge paths, check-knowledge, specs-for-file), `check:changesets` and the motion, SideNav and AppShell unit suites (1,020) pass, and `specs:for` still returns `component:SideNav` and `spec:AST-105` for the changed files. At 70ebcc7 every gate finished: `test:ui`, `check:repo`, the node suites (only the two `cache.test.mjs` lock cases that fail on main), and the three motion specs ×10 in WebKit under the CI job's command (740, with 100 Chromium-only skips) and in Chromium pinned to 2 cores at 4 workers (840). **Still running:** `test:ui`, the node suites, `check:repo` and the three motion specs ×10 in both engines at this head.

Each new proof failed on main first:
- `animatedLayout.edge.test.ts`: 17 of the 18 one- and two-state cases fail on main (the block-end default passes there and guards it), and the resized-element cases have no function there.
- `SideNavRailMotion.test.tsx`: a new case asserts the rail plays through the module's two-state form with no clip, copy or ride of its own. It fails on main; the rail's existing cases pass unchanged with the rail moved.
- `animatedLayout.interaction.browser.spec.ts` (FR9): a block-end reveal reversed into a conceal, an inline-end reveal and conceal (LTR and RTL), the two-state form, and a resized pane (start pane LTR to 48 px, start pane RTL to 0, end pane LTR to 48), each with a reversal. On every frame the moving edge stays within 1.5 px of what it rides. All fail on main: the block-end reversal jumped 40 px, the inline edge was clipped from the bottom, and the other forms don't exist there.
- `animatedLayoutAdopters.interaction.browser.spec.ts`: a Collapsible closed while it opens, and a TreeList row collapsed while it expands, keep their edge on the content after them. On main each conceal restarted from fully shown: −16 px and −36 px gaps.

Two more cases guard this PR's own paths. A block-start reveal and conceal in a column that stacks from its end keep the edge on the content before it, with the element staying where it shows; before the start-edge fix the conceal freed its space at its end, so the element jumped by its 80 px and an 80 px gap opened. And in a single-open CollapsibleGroup switching from a taller item to a shorter one below it, the opening item's edge stays on the next item on every frame; before the ride was taken relative to the element, the content showed 31 px over the next item.

How the proofs read a ride: both sides on the module's clock, the edge from its clip animation's keyframes and what it rides from its layout position plus its glide's keyframes, at the animations' current time. Chromium also asserts that its evaluated clip and rect equal those (under 0.5 px). In WebKit the two run a little apart, and that skew is reported per test, not hidden: a compositor-run `translate` is evaluated a little ahead of the frame and, on a late first frame, a main-thread clip reads one frame behind. Under the CI WebKit job's command (`LP_NUM_THREADS=4`), the summed skew per frame reached 0.7 to 3.4 px depending on the test. The reversals pause and seek every animation to 120 ms, and the adopters' wait for the component to commit the close before playing on, so neither a late first frame nor a component committing in a later task moves the moment they test.

## Scope

- [x] One capability is added (FR9's shared edge reveal), with its adopters in this PR's scope (the SideNav rail; Collapsible, TreeList and Table's row expansion keeping their form).
- [x] Unrelated fixes, cleanup, and policy changes were removed or split. Resizable's and LayoutPanel's adoption is #9197 and #9202.
- [x] Consumer docs, focused tests, and migration evidence ship with the feature.
- [x] Public text and artifacts contain no internal Meta context.

Overlaps: #8677 also edits `motion/animatedLayout.ts` (the theme's motion knobs), and #3890 edits `SideNav/SideNav.tsx`. This PR leaves `SideNav.tsx` unchanged.

## Testing

```
pnpm exec vitest run --project ui packages/core/src/motion packages/core/src/SideNav packages/core/src/AppShell packages/core/src/Collapsible packages/core/src/TreeList packages/core/src/Table
pnpm build && pnpm storybook:build
pnpm test:interaction packages/core/src/motion/__tests__/animatedLayout.interaction.browser.spec.ts packages/core/src/motion/__tests__/animatedLayoutAdopters.interaction.browser.spec.ts packages/core/src/SideNav/__tests__/SideNavRailMotion.interaction.browser.spec.ts --project chromium
```
For WebKit, use the CI WebKit job's container command (`internal/interaction-harness/scripts/webkit-container.mjs`) with podman `--cpus=4`, `CI=1`, `LP_NUM_THREADS=4` and 1 worker.
@vjeux
vjeux force-pushed the uikit/f1-theme-motion-springs branch from 6e150ba to 9359e05 Compare November 4, 2026 13:52
@vjeux vjeux changed the title feat(core): the animator's springs follow the theme's tempo and character feat(core): the animator's springs follow the theme's tempo Nov 4, 2026
@github-actions github-actions Bot removed needs:design-review Affects visuals — Design should review needs:spec-owner-review Current knowledge records await owner approval labels Nov 4, 2026

@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.

Changes requested. Springs and the layout glide now follow a theme's motion.tempo as spec:AST-108 FR3 describes, and the new unit tests fail on main and pass here. These need fixing first:

  • The glide's spring follows tempo under every pointer, so a mouse user's SideNav rail collapse or AppShell content glide is retimed (resolveMotion.ts:131-138, animatedLayout.ts:179), and this desktop behavior change needs an owner decision: gate the scaling to touch input, or cite the decision that covers it.
  • Switch's release replays the raw switchControl.lift, switchControl.settle and minimumLiftMs (useSwitchTouch.ts:894-896 and :965-966), so at tempo 1.5 the press lifts on a 970 ms spring but the release settles in 1,125 ms instead of 1,687 ms; resolve the three against the thumb.
  • A theme with only a duration scale, nested inside a tempo theme, keeps its own durations but plays its springs at the outer tempo, against spec:AST-108 FR5 and the changeset's own sentence, so a layer that writes durations without a tempo should also write --_astryx-motion-tempo: 1, with a test on defineTheme themes (nested, and under an adaptation rule).
  • themeTempo (themeTempo.ts:34-39) copies the tempo read in pressFadeMs (utils/pressFeedback.ts:158-161), so pressFadeMs should call themeTempo and leave one reader.
  • defineTheme's motion JSDoc, the theme template, the author-a-theme doc and the themeMotion.ts header still say the springs don't read tempo; update them as motion.doc.mjs was.

The inline comments also carry two non-blocking wording suggestions. CI has not run on this draft yet.

[Automated review]

if (role == null || role.kind === 'curve' || FIXED_ROLES.has(role.id)) {
return value;
}
const tempo = themeTempo(element);

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 runs under every pointer, so a theme that sets tempo also retimes the glide for mouse users: SideNav's rail collapse and AppShell's content took 1,102 ms instead of 735 ms at tempo 1.5 with a mouse. That desktop behavior change needs an owner decision: gate the scaling to touch input, or cite the decision that covers it in the description.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Kept on purpose, per the current records. AST-108 FR1 has tempo scale every motion duration, and FR3 scales every AST-094 FR4 role's spring. The glide plays the FR4 role springs.settle under every pointer (AST-105 FR4, FR5), and AST-107 FR3 says animate's spring follows the theme's tempo. The CSS tokens already follow tempo with a mouse, so the glide now keeps step with them. The description names this, and the browser spec now runs the glide with a mouse too (0.75, 1 and 1.5 times as long).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fair point — the description now cites the records behind this (AST-108 FR1/FR3, AST-105 FR4/FR5, AST-107 FR3), states the glide follows tempo with a mouse too, and the browser spec covers the glide with a mouse at 0.75×, 1× and 1.5×. That addresses the finding as written; the next full review will re-evaluate it against the updated description and spec.

Comment on lines +34 to +39
const text = view
.getComputedStyle(element)
.getPropertyValue(TEMPO_PROPERTY)
.trim();
const tempo = text === '' ? Number.NaN : Number(text);
return Number.isFinite(tempo) && tempo > 0 ? tempo : 1;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

pressFadeMs in utils/pressFeedback.ts (lines 158-161) already reads --_astryx-motion-tempo with this exact parse (empty to NaN, positive and finite, else 1). Have pressFadeMs call themeTempo(element) so the press release fade and the springs share one reader and cannot drift.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Left as is here: #9890 and #9919 both rewrite those exact lines of pressFadeMs to read --duration-exit. The second reader goes away when either merges, and touching the lines now would conflict with both.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

That makes sense — touching those lines now would conflict with both #9890 and #9919, and the description records that the second reader goes away when either merges. Leaving the duplication until then is the right trade-off; the next full review will re-evaluate it in that context.

Comment thread .changeset/theme-tempo-springs.md Outdated
'@astryxdesign/cli': patch
---

[feat] Motion: a theme's `tempo` now times the springs of touch motion as well as its CSS durations. Each spring keeps its shape and takes `tempo` times as long, and so does the animated-layout glide. Finger tracking and busy loops stay as measured, a theme with only a duration scale leaves the springs at tempo 1, and the springs don't follow `character` yet. The motion doc says so.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

"A theme with only a duration scale leaves the springs at tempo 1" holds only for a theme on its own. Nested inside a theme that sets tempo, it inherits --_astryx-motion-tempo. Its durations then follow its own scale, but its springs play the outer tempo (in Chromium, springs.settle 0.75 s next to its own 200 ms --duration-medium), against spec:AST-108 FR5. A layer that writes durations without a tempo of its own could write --_astryx-motion-tempo: 1, as resolveThemeValues.ts already does for the pre-tempo copies.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 6755a6a. A layer whose durations come from a duration scale alone now writes --_astryx-motion-tempo: 1 (resolveThemeValues.ts). It's tested on defineTheme themes and adaptation rules, and in the browser: a duration-scale theme nested in a tempo 1.5 theme glides at 1×. The changeset sentence now says so.

{
type: 'prose',
text: "A theme sets how motion feels with two knobs in `defineTheme`'s `motion`. They drive the CSS motion tokens; the springs of touch motion don't read them yet. Every theme follows each person's Reduce Motion setting; no theme loosens it.",
text: "A theme sets how motion feels with two knobs in `defineTheme`'s `motion`. They drive the CSS motion tokens, and `tempo` also times the springs of touch motion; those springs don't follow `character` yet. Every theme follows each person's Reduce Motion setting; no theme loosens it.",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

These still say the opposite: defineTheme.ts:296-297 ("touch motion's springs don't read it yet"), packages/cli/assets/theme.template.ts:184-185, packages/cli/assets/docs/author-a-theme.doc.mjs:44 and packages/core/src/theme/themeMotion.ts:16 ("No spring reads the knobs yet"). Please update them to match this sentence.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 6755a6a: defineTheme.ts, the theme template, author-a-theme.doc.mjs and the themeMotion.ts header now say the springs follow tempo but not character yet.

Comment on lines +141 to +143
* glide, the changed element and the indicator. Motion roles are internal
* (`spec:AST-094` DEC-11): no theme, scope or custom property changes it.
* The glide plays it at the tempo of the theme `area` is in.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-blocking: "no theme, scope or custom property changes it" now sits right before "The glide plays it at the tempo of the theme area is in." Suggest: "No page style sets the role. The glide plays it at the tempo of the theme that area is in."

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Applied in 6755a6a.

* with it
*
* Every touch motion passes through here, so the role a value plays is named
* in one place. A role plays its table value: the per-role tables stay

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-blocking: "A role plays its table value" now contradicts lines 19-22, and the header is 25 lines. Suggest: "Nothing reads a role's own CSS variable, and no page or theme style sets a role."

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Applied in 6755a6a.

@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.

This pull request is reviewed as part of its stack; see the review on #8686.

@cixzhang

cixzhang commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 9359e05

@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.

Changes requested. The springs, duration roles and layout glide now follow a theme's motion.tempo as spec:AST-108 FR3 describes, and last round's Switch release and nested duration-scale fixes check out. Two blocking findings remain:

  • The glide's spring follows tempo under every pointer (packages/core/src/motion/resolveMotion.ts:131-138, read by animatedLayout.ts:145-148), so a mouse user's SideNav rail collapse, AppShell content and Collapsible glides are retimed (735 ms to 1,102 ms at tempo 1.5), and this desktop behavior change needs an owner decision: gate the scaling to touch input, or cite the decision that covers it.
  • themeTempo (packages/core/src/motion/themeTempo.ts:34-39) copies the tempo read in pressFadeMs (packages/core/src/utils/pressFeedback.ts:158-161), so have pressFadeMs call themeTempo(element) and keep one reader.
  • Non-blocking: themeTempo.ts:12-13 and themeMotion.ts:84-88 no longer match the duration-scale tempo write.
  • Miss: animator.ts:159-161, animator.ts:2732, motion/README.md:59-60 and the theme authoring contract's known-gap line still say a role plays its table value or that no spring reads the knobs (non-blocking).

CI has not run on this head yet.

[Automated review]

if (role == null || role.kind === 'curve' || FIXED_ROLES.has(role.id)) {
return value;
}
const tempo = themeTempo(element);

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 still scales the glide's spring under every pointer. With a mouse, Collapsible's glide takes 735 ms on the base and 1,102 ms at tempo 1.5 here, and SideNav's rail collapse and AppShell's content glide take the same path. The cited records are not an owner decision for this desktop behavior change. Gate the scaling to touch input, or cite the decision that covers it in the description.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The decision that covers it is spec:AST-105 DEC-2 (decider cixzhang, 2026-11-04): "a theme that sets character or tempo sees its glides change with the rest of its motion, under every pointer." Its FR4 times the glide on --ease-move over --duration-medium-min, which follow tempo with a mouse too. Moving the glide from springs.settle to those tokens is its own pull request; until then the glide here keeps step with them at each tempo. The description now cites DEC-2.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

That resolves it — DEC-2 is exactly the owner decision this finding asked for, and the description now cites it verbatim, including “under every pointer.” The FR4 point also closes the consistency gap for now: the glide keeps step with --ease-move / --duration-medium-min, which already follow tempo with a mouse, and moving the glide onto those tokens is tracked as its own pull request. The next full review will re-evaluate the finding against the updated description.

Comment on lines +34 to +39
const text = view
.getComputedStyle(element)
.getPropertyValue(TEMPO_PROPERTY)
.trim();
const tempo = text === '' ? Number.NaN : Number(text);
return Number.isFinite(tempo) && tempo > 0 ? tempo : 1;

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 read and parse still duplicates pressFadeMs in utils/pressFeedback.ts:158-161, so there are two readers of --_astryx-motion-tempo. pressFadeMs can call themeTempo(element) today, which leaves one reader. A rebase clears this if #9890 or #9919 lands first.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in e058f49: pressFadeMs now reads the tempo through themeTempo(element) and keeps its Reduce Motion cap, so there is one reader.

Comment thread packages/core/src/motion/themeTempo.ts Outdated
* A theme that sets `tempo` writes it, clamped, to the private custom
* property `--_astryx-motion-tempo` beside its duration tokens. An
* adaptation rule writes it where its condition holds, and a nested theme
* that sets no tempo inherits it. A duration scale alone writes none, so its

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-blocking: resolveThemeValues.ts:334-335 now writes --_astryx-motion-tempo: 1 for a duration scale. Suggest: "A layer whose durations come from a duration scale writes a tempo of 1, so its springs keep its own pace."

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Applied in e058f49, along with the animator.ts and motion/README.md lines. The theme authoring contract's known-gap line is a record, so it stays with the records owners; themeMotion.ts lines 84–88 sit in the hunk #9890 and #9919 rewrite.

@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.

Approval waits on one owner decision about desktop motion; nothing else blocks.

  • The layout glide still plays springs.settle, and this change stretches it by tempo under every pointer (packages/core/src/motion/animatedLayout.ts:145-148), so mouse users' SideNav, AppShell and Collapsible glides are retimed (735 ms becomes 1,102 ms at tempo 1.5); spec:AST-105 DEC-2 has glides follow tempo on the curve tokens and never as a spring, so please cite the owner decision that covers this interim spring on desktop, with when it was given, or gate the glide's tempo scaling to touch input.
  • Non-blocking: packages/core/src/theme/themeMotion.ts:84-88 still says only a theme with a tempo writes --_astryx-motion-tempo and only the press release fade reads it.

CI has not run on this head yet.

[Automated review]

registerSpringsTable();
const value = motionRole(ROLE_ID)?.value;
return isSpring(value) ? value : SETTLE_SPRING;
return resolveMotion(area, isSpring(value) ? value : SETTLE_SPRING);

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 stretches the glide's springs.settle by the area's tempo under every pointer, so mouse users' SideNav, AppShell and Collapsible glides are retimed (735 ms becomes 1,102 ms at tempo 1.5). spec:AST-105 DEC-2 has glides follow tempo on --ease-move over --duration-medium-min, never as a spring, so please cite the owner decision that covers this interim spring on desktop, with when it was given, or gate the scaling to touch input.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The owner's decision for this interim, given 2026-11-04 at 16:24 UTC: "Until the glide moves to the curve tokens (AST-105 DEC-2), its interim springs.settle follows tempo under every pointer." At tempo 1 nothing changes on desktop. The description now quotes it.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

That resolves it — the description now quotes the interim decision with its timestamp (2026-11-04, 16:24 UTC), covering the springs.settle glide following tempo under every pointer until it moves to the DEC-2 curve tokens, and notes that at tempo 1 nothing changes on desktop. That is exactly what the finding asked for. The next full review will re-evaluate the finding against the updated description.

A theme's `tempo` reached the CSS motion tokens but not the touch
springs, so a slowed theme played its fades and its springs at two
speeds. resolveMotion, the one door every touch spring and role duration
passes through, now reads the tempo of the theme the element is in: the
private property the theme writes beside its duration tokens. A spring
keeps its damping ratio and takes tempo times as long, and a duration
role scales. The layout glide resolves springs.settle through it at the
area, under every pointer, as the CSS tokens already do.

Finger tracking, ProgressBar's busy loop and curves stay as measured
(spec:AST-108 FR4). The springs don't follow `character` yet; that part
waits on its record.

Switch's release settles on a curve it builds itself, so it now resolves
its lift and settle springs and its minimum lift at the thumb, as its
lift does. A theme layer whose durations come from a duration scale
alone writes a tempo of 1, so a theme nested in one with a tempo keeps
its springs at the pace of its own durations (FR5). Specs that set a
tempo on the document now set it on the component, below the story's
theme root.

A `Core/Theme motion tempo` story shows three themes side by side for a
feel check on a touch screen; it and a nested duration-scale story are
the browser proofs' fixtures.
@vjeux
vjeux force-pushed the uikit/f1-theme-motion-springs branch from e058f49 to 50e47ea Compare November 5, 2026 02:31

@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.

Approved pending CI: CI has not run on this head yet. The pull request is a draft from a fork, so once it is marked ready for review, a maintainer adds the ci:run label so CI runs.

A theme's tempo now times the role springs, the role durations and the layout glide, as spec:AST-108 FR3–FR5, spec:AST-105 FR4–FR5 and spec:AST-107 FR3 describe, and the glide's retiming under a mouse follows the owner's interim decision quoted in the description. There are no blocking findings.

Non-blocking:

  • docs/architecture/theme-authoring-contract.md still lists "no spring reads the motion knobs yet" as a known gap; that line needs updating once this lands.
  • The file headers of resolveMotion.ts (25 lines) and themeTempo.ts (18 lines) run past the guide of about 12 lines in architecture:knowledge-contracts INV29.

[Automated review]

@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.

Approved pending CI: CI has not run on this head yet. The pull request is a draft from a fork, so once it is marked ready for review, a maintainer adds the ci:run label so CI runs.

A theme's tempo now times the role springs, the role durations and the layout glide, as spec:AST-108 FR3–FR5, spec:AST-105 FR5 and DEC-2, and spec:AST-107 FR3 describe, and the glide's retiming under a mouse follows the owner's interim decision quoted in the description. There are no blocking findings.

Non-blocking:

  • docs/architecture/theme-authoring-contract.md still lists "no spring reads the motion knobs yet" as a known gap; that line needs updating once this lands.
  • The file headers of resolveMotion.ts (25 lines) and themeTempo.ts (18 lines) run past the guide of about 12 lines in architecture:knowledge-contracts INV29.
  • The description cites spec:AST-105 FR4 for the glide's spring, but on main FR4 puts the glide on the curve tokens, so the spring is that record's current-state gap rather than its requirement.

[Automated review]

This branch was successfully deployed

1 active deployment
Preview — 21540f1f Deployed Nov 5, 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.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants