Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/theme-reduced-motion-mark-reach.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astryxdesign/core': patch
---

[chore] Internal: the reduced-motion mark carries a marker class of its own, on the theme's root and on `<html>` for the root theme, and the chat scroll, streaming text and a swipe row's settle read the mark. No theme can set `reducedMotion: 'always'` yet, so nothing changes for anyone.

@vjeux @cixzhang
33 changes: 33 additions & 0 deletions packages/core/src/Chat/useChatStreamScroll.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@ import {
type UseChatStreamScrollOptions,
type UseChatStreamScrollReturn,
} from './useChatStreamScroll';
import {defineTheme} from '../theme/defineTheme';
import {ALWAYS_TEMPO} from '../theme/reducedMotionAlways.testing';
import {Theme} from '../theme/Theme';

// No public theme can set reducedMotion yet: a theme with ALWAYS_TEMPO
// resolves to 'always' through this mock.
vi.mock('../theme/themeMotion', async importOriginal => {
const {resolvingAlways} =
await import('../theme/reducedMotionAlways.testing');
return resolvingAlways(await importOriginal());
});

// ---------------------------------------------------------------------------
// Test infrastructure
Expand Down Expand Up @@ -191,6 +202,28 @@ describe("useChatStreamScroll — scrollToBottom({behavior: 'instant'})", () =>
});

describe('useChatStreamScroll — prefers-reduced-motion', () => {
it("under a theme with reducedMotion: 'always', streaming growth jumps with the setting off", () => {
const still = defineTheme({
name: 'ChatStill',
motion: {tempo: ALWAYS_TEMPO},
});
const api: {current: UseChatStreamScrollReturn | null} = {current: null};
const el = render(
<Theme theme={still}>
<Harness api={api} />
</Theme>,
).getByTestId('scroller');
setGeometry(el, {scrollHeight: 1000, clientHeight: 400});
flushRaf(); // consume the mount jump
expect(el.scrollTop).toBe(600);

setGeometry(el, {scrollHeight: 1400, clientHeight: 400});
rafQueue = [];
act(() => api.current!.scrollIfLocked());
expect(el.scrollTop).toBe(1000);
expect(rafQueue).toHaveLength(0);
});

it('streaming growth jumps synchronously instead of springing', () => {
stubReducedMotion();
const {api, el} = renderHook();
Expand Down
18 changes: 9 additions & 9 deletions packages/core/src/Chat/useChatStreamScroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

/**
* @file useChatStreamScroll.ts
* @input Uses React refs, state, callbacks, useMediaQuery
* @input Uses React refs, state, callbacks, useReducesMotion
* @output Exports useChatStreamScroll hook for AI chat scroll behavior
* @position Utility hook — used by ChatLayout, also usable standalone
*
Expand All @@ -15,8 +15,9 @@
* - The first fill positions instantly (whether content is present at
* mount or arrives async); only subsequent growth springs
* - `scrollToBottom({behavior: 'instant'})` jumps in one frame, no animation
* - Under `prefers-reduced-motion`, every spring path falls back to the
* same instant jump — following still works, it just doesn't animate
* - Under reduced motion (the person's setting, or a theme's
* `reducedMotion: 'always'`), every spring path falls back to the same
* instant jump — following still works, it just doesn't animate
* - Fractional spring position is retained between frames so browsers that
* round scrollTop do not strand the animation short of the bottom
*
Expand All @@ -32,7 +33,7 @@
*/

import {useCallback, useEffect, useRef, useState} from 'react';
import {useMediaQuery} from '../hooks/useMediaQuery';
import {useReducesMotion} from '../hooks/useReducesMotion';

// =============================================================================
// Types
Expand Down Expand Up @@ -256,17 +257,16 @@ export function useChatStreamScroll({
lastScrollTopRef.current = el.scrollTop;
}, [scrollRef]);

const prefersReducedMotion = useMediaQuery(
'(prefers-reduced-motion: reduce)',
);
// The setting, live, or a theme's reducedMotion: 'always'.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking (INV29): this restates useReducesMotion's JSDoc; delete it.

const reducesMotion = useReducesMotion();

// Every spring entry point (scrollToBottom, lock, scrollIfLocked growth
// follow) funnels through here, so this one branch covers them all.
const startAnimation = useCallback(() => {
if (!lockedRef.current) {
return;
}
if (prefersReducedMotion) {
if (reducesMotion) {
jumpToBottom();
return;
}
Expand All @@ -276,7 +276,7 @@ export function useChatStreamScroll({
springPositionRef.current = scrollRef.current?.scrollTop;
rafRef.current = requestAnimationFrame(animate);
}
}, [animate, jumpToBottom, prefersReducedMotion, scrollRef]);
}, [animate, jumpToBottom, reducesMotion, scrollRef]);

// --- Public API ---

Expand Down
79 changes: 79 additions & 0 deletions packages/core/src/Item/Item.swipeReducedMotion.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.

/**
* @file Item.swipeReducedMotion.test.tsx
* @input Item with swipe actions under a theme, the swipe gesture hook, spied
* @output Verifies the swipe layer settles a row at once under a theme with
* `reducedMotion: 'always'` with the person's setting off, as it does under
* the setting
* @position Companion to Item.swipeLayer.test.tsx for the swipe layer's
* reduced-motion read
*/

import {describe, expect, it, vi} from 'vitest';
import {render, screen, waitFor} from '@testing-library/react';
import {defineTheme} from '../theme/defineTheme';
import {ALWAYS_TEMPO} from '../theme/reducedMotionAlways.testing';
import {Theme} from '../theme/Theme';
import {Item} from './Item';
import type * as SwipeAction from './useSwipeAction';

// No public theme can set reducedMotion yet: a theme with ALWAYS_TEMPO
// resolves to 'always' through this mock.
vi.mock('../theme/themeMotion', async importOriginal => {
const {resolvingAlways} =
await import('../theme/reducedMotionAlways.testing');
return resolvingAlways(await importOriginal());
});

const {reducedMotionReads} = vi.hoisted(() => ({
reducedMotionReads: [] as (boolean | undefined)[],
}));

vi.mock('./useSwipeAction', async importOriginal => {
const actual = await importOriginal<typeof SwipeAction>();
return {
...actual,
useSwipeAction: (options: Parameters<typeof actual.useSwipeAction>[0]) => {
reducedMotionReads.push(options.isReducedMotion);
return actual.useSwipeAction(options);
},
};
});

const still = defineTheme({
name: 'SwipeStill',
motion: {tempo: ALWAYS_TEMPO},
});
const moving = defineTheme({name: 'SwipeMoving', motion: {tempo: 1.2}});

async function renderRowUnder(theme: typeof still) {
reducedMotionReads.length = 0;
const view = render(
<Theme theme={theme}>
<Item
label="Inbox"
data-testid="row"
swipeActions={{trailing: [{label: 'Archive', onActivate: () => {}}]}}
/>
</Theme>,
);
await waitFor(() => {
expect(
screen.getByTestId('row').querySelector('[data-swipe-panel="trailing"]'),
).not.toBeNull();
});
return view;
}

describe('the swipe layer and reduced motion', () => {
it("settles at once under a theme with reducedMotion: 'always', with the setting off", async () => {
await renderRowUnder(still);
expect(reducedMotionReads.at(-1)).toBe(true);
});

it('animates its settle under a theme that leaves reduced motion to the setting', async () => {
await renderRowUnder(moving);
expect(reducedMotionReads.at(-1)).toBe(false);
});
});
7 changes: 4 additions & 3 deletions packages/core/src/Item/ItemSwipeLayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
/**
* @file ItemSwipeLayer.tsx
* @input The row root (by ref), the swipe entries per side, the behavior;
* useSwipeAction, theme tokens
* useSwipeAction, useReducesMotion, theme tokens
* @output Exports ItemSwipeLayer (default) — the panels of a row's swipe
* actions and the gesture that uncovers them — and the ItemSwipeBridge type
* through which the root reaches the gesture's handlers
Expand Down Expand Up @@ -33,7 +33,7 @@ import {mergeProps} from '../utils/mergeProps';
import {themeProps} from '../utils/themeProps';
import {disabledInteractionProps} from '../utils/disabledInteraction';
import {focusOutlineProps} from '../utils/focusOutline.stylex';
import {useMediaQuery} from '../hooks/useMediaQuery';
import {useReducesMotion} from '../hooks/useReducesMotion';
import {
useSwipeAction,
type SwipeHandlers,
Expand Down Expand Up @@ -205,7 +205,8 @@ export default function ItemSwipeLayer({
}: ItemSwipeLayerProps) {
const leadingEntriesRef = useRef<HTMLDivElement | null>(null);
const trailingEntriesRef = useRef<HTMLDivElement | null>(null);
const isReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)');
// The setting, live, or a theme's reducedMotion: 'always'.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking (INV29): this restates useReducesMotion's JSDoc; delete it.

const isReducedMotion = useReducesMotion();
const outermost = (side: SwipeSide): ItemSwipeAction | undefined => {
const entries = side === 'leading' ? leading : trailing;
return entries[entries.length - 1];
Expand Down
30 changes: 30 additions & 0 deletions packages/core/src/hooks/useReducesMotion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.

'use client';

/**
* @file useReducesMotion.ts
* @input The person's Reduce Motion setting (live), ThemeReducesMotionContext
* @output useReducesMotion, whether motion is reduced for the calling component
* @position Hooks; the hook form of motion/reducedMotion.ts's reducesMotion,
* for a hook that holds no element
*/

import {use} from 'react';
import {ThemeReducesMotionContext} from '../theme/reducedMotionContext';
import {useMediaQuery} from './useMediaQuery';

/**
* Whether motion is reduced for the calling component: the person's Reduce
* Motion setting, followed live, or a theme above it with
* `reducedMotion: 'always'`. The same rule as
* `reducesMotion(element)`, read from the theme tree for a hook that holds no
* element.
*/
export function useReducesMotion(): boolean {
const prefersReducedMotion = useMediaQuery(
'(prefers-reduced-motion: reduce)',
);
const themeReducesMotion = use(ThemeReducesMotionContext);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking: this reads only the theme context, so a component in useToast's detached fallback root reads false even when the root theme marks <html>, which reducesMotion(element) and the CSS twins honour. Worth closing in the change that ships 'always'.

return prefersReducedMotion || themeReducesMotion;
}
58 changes: 58 additions & 0 deletions packages/core/src/hooks/useStreamingText.reducedMotion.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.

/**
* @file useStreamingText.reducedMotion.test.tsx
* @input useStreamingText under a theme, React Testing Library
* @output Verifies streaming text snaps to the full text under a theme with
* `reducedMotion: 'always'` with the person's setting off, as it does under
* the setting
* @position Companion to useStreamingText.test.ts for the theme's mark
*/

import type {ReactElement, ReactNode} from 'react';
import {describe, expect, it, vi} from 'vitest';
import {renderHook} from '@testing-library/react';
import {defineTheme} from '../theme/defineTheme';
import {ALWAYS_TEMPO} from '../theme/reducedMotionAlways.testing';
import {Theme} from '../theme/Theme';
import {useStreamingText} from './useStreamingText';

// No public theme can set reducedMotion yet: a theme with ALWAYS_TEMPO
// resolves to 'always' through this mock.
vi.mock('../theme/themeMotion', async importOriginal => {
const {resolvingAlways} =
await import('../theme/reducedMotionAlways.testing');
return resolvingAlways(await importOriginal());
});

const still = defineTheme({
name: 'StreamStill',
motion: {tempo: ALWAYS_TEMPO},
});
const moving = defineTheme({name: 'StreamMoving', motion: {tempo: 1.2}});

function under(theme: typeof still) {
return function ThemeWrapper({
children,
}: {
children: ReactNode;
}): ReactElement {
return <Theme theme={theme}>{children}</Theme>;
};
}

describe('useStreamingText and reduced motion', () => {
it("snaps to the full text under a theme with reducedMotion: 'always', with the setting off", () => {
const {result} = renderHook(() => useStreamingText('Hello world', true), {
wrapper: under(still),
});
expect(result.current).toBe('Hello world');
});

it('reveals progressively under a theme that leaves reduced motion to the setting', () => {
const {result} = renderHook(() => useStreamingText('Hello world', true), {
wrapper: under(moving),
});
expect(result.current).toBe('');
});
});
19 changes: 9 additions & 10 deletions packages/core/src/hooks/useStreamingText.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

/**
* @file useStreamingText.ts
* @input React state, effects and refs; useMediaQuery; useTheme
* @input React state, effects and refs; useReducesMotion; useTheme
* @output useStreamingText hook for smooth text streaming
* @position Core hook; smooths bursty streamed text into a steady reveal.
* Timing comes from the motion tokens, with defaults outside a theme.
Expand All @@ -15,7 +15,7 @@
*/

import {useEffect, useMemo, useRef, useState} from 'react';
import {useMediaQuery} from './useMediaQuery';
import {useReducesMotion} from './useReducesMotion';
import {useTheme} from '../theme/useTheme';

/**
Expand Down Expand Up @@ -172,14 +172,13 @@ export function useStreamingText(
}
}

// Reduced motion skips the progressive reveal and shows the full text.
// useMediaQuery follows a preference change mid-stream.
const prefersReducedMotion = useMediaQuery(
'(prefers-reduced-motion: reduce)',
);
// Reduced motion skips the progressive reveal and shows the full text: the
// person's setting, followed live so a change mid-stream takes effect, or a
// theme's reducedMotion: 'always'.
Comment on lines +175 to +177

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking (INV29): one 27-word sentence with two topics. Suggested: "Reduced motion skips the progressive reveal and shows the full text. A change mid-stream takes effect at once."

const reducesMotion = useReducesMotion();

useEffect(() => {
if (!isStreaming || speed === 'instant' || prefersReducedMotion) {
if (!isStreaming || speed === 'instant' || reducesMotion) {
return;
}

Expand Down Expand Up @@ -208,9 +207,9 @@ export function useStreamingText(
rafRef.current = null;
}
};
}, [isStreaming, charsPerTick, tickMs, speed, prefersReducedMotion]);
}, [isStreaming, charsPerTick, tickMs, speed, reducesMotion]);

if (!isStreaming || speed === 'instant' || prefersReducedMotion) {
if (!isStreaming || speed === 'instant' || reducesMotion) {
return targetText;
}

Expand Down
Loading