fix(ui): default Reduced Motion on Calm and quiet decorative rails (#1622)

* fix(ui): default Reduced Motion on Calm and quiet decorative rails

Calm (and Reset to default) enables Reduced Motion; Signature clears it. Manual Motion stays independent of the visual-style card. Reduced Effects also stops decorative masthead rail animations. Related to #1614; does not close that issue.

* test(e2e): fix Calm Motion specs racing empty-create compose edit

Stop appearance init-script reseeding after mid-test mutations, and wait for Save and Deploy / mobile compose editor after empty create instead of assuming Anatomy Edit compose.
This commit is contained in:
Anso
2026-07-14 17:58:38 -04:00
committed by GitHub
parent b70a529656
commit c170c3f30c
13 changed files with 358 additions and 63 deletions
@@ -251,7 +251,7 @@ export function AppearanceSection() {
) : null}
<SettingsField
label="Reduced motion"
helper="Minimizes interface animations and transitions (dialogs, menus, expand and collapse). First control to try for high idle GPU use on constrained graphics. Toasts are unaffected."
helper="Minimizes interface animations and transitions (dialogs, menus, expand and collapse). Calm turns this on by default; choosing Signature turns it off. You can still toggle it independently afterward. First control to try for high idle GPU use on constrained graphics. Toasts are unaffected."
>
<TogglePill
checked={reducedMotion}
@@ -262,7 +262,7 @@ export function AppearanceSection() {
<SettingsField
label="Reduced effects"
helper="Flattens card bevels, the accent glow, and chart gradients, and turns off glass blur with solid chrome fills. Optional secondary material simplification; not a substitute for Reduced motion on reported idle GPU cost."
helper="Flattens card bevels, the accent glow, and chart gradients, and turns off glass blur with solid chrome fills. Also stops the decorative masthead rail animations even if Reduced motion is off. Optional secondary material simplification; not a substitute for Reduced motion on reported idle GPU cost."
>
<TogglePill
checked={effectiveReduced}
@@ -33,6 +33,36 @@ describe('AppearanceSection', () => {
fireEvent.click(screen.getByRole('button', { name: /Calm/i }));
expect(document.documentElement.dataset.headings).toBe('clean');
expect(document.documentElement.dataset.chartStyle).toBe('muted');
expect(document.documentElement.dataset.effects).toBe('reduced');
expect(document.documentElement.dataset.motion).toBe('reduced');
});
it('Calm and Signature preset apply write reducedMotion; Effects alone does not', () => {
render(<AppearanceSection />);
// Baseline Signature clears Motion.
expect(document.documentElement.dataset.motion).toBeUndefined();
fireEvent.click(screen.getByRole('switch', { name: 'Reduced motion' }));
expect(document.documentElement.dataset.motion).toBe('reduced');
// Re-applying Signature clears a manually enabled Motion.
fireEvent.click(screen.getByRole('button', { name: /Today's look|Signature/i }));
expect(document.documentElement.dataset.motion).toBeUndefined();
fireEvent.click(screen.getByRole('button', { name: /Calm|readable default/i }));
expect(document.documentElement.dataset.motion).toBe('reduced');
fireEvent.click(screen.getByRole('switch', { name: 'Reduced motion' }));
expect(document.documentElement.dataset.motion).toBeUndefined();
// Calm card stays selected with Motion off.
expect(screen.getByRole('button', { name: /readable default/i }).getAttribute('aria-pressed')).toBe('true');
// Re-applying Calm turns Motion back on.
fireEvent.click(screen.getByRole('button', { name: /readable default/i }));
expect(document.documentElement.dataset.motion).toBe('reduced');
// Individual Effects toggle preserves Motion.
fireEvent.click(screen.getByRole('switch', { name: 'Reduced motion' }));
expect(document.documentElement.dataset.motion).toBeUndefined();
fireEvent.click(screen.getByRole('switch', { name: 'Reduced effects' }));
expect(document.documentElement.dataset.motion).toBeUndefined();
});
it('shows the constrained-graphics callout when Reduced motion is off, and hides it when on', () => {
@@ -125,6 +155,7 @@ describe('AppearanceSection', () => {
fireEvent.click(screen.getByRole('button', { name: 'Reset to default' }));
expect(document.documentElement.dataset.headings).toBe('clean');
expect(document.documentElement.dataset.chartStyle).toBe('muted');
expect(document.documentElement.dataset.motion).toBe('reduced');
expect(screen.getByRole('button', { name: /readable default/i }).getAttribute('aria-pressed')).toBe('true');
fireEvent.click(screen.getByRole('switch', { name: 'Readability mode' }));
+46 -5
View File
@@ -17,6 +17,7 @@ describe('use-theme read / validate / migrate', () => {
root.removeAttribute('data-headings');
root.removeAttribute('data-chart-style');
root.removeAttribute('data-effects');
root.removeAttribute('data-motion');
root.classList.remove('dark');
});
@@ -57,23 +58,25 @@ describe('use-theme read / validate / migrate', () => {
});
// ── Calm/Signature migration contract ──────────────────────────────────
// A fresh install (no key) gets the full Calm default; any existing stored
// object (even {}) is a returning user and fills missing appearance fields
// from Signature so its look is unchanged.
// A fresh install (no key) gets the full Calm default including reducedMotion;
// any existing stored object (even {}) is a returning user and fills missing
// appearance fields from Signature so its look is unchanged (Motion fills false).
it('a fresh install (no stored key) defaults to the full Calm look', async () => {
it('a fresh install (no stored key) defaults to the full Calm look with reduced motion', async () => {
const root = await applyStored();
expect(root.dataset.headings).toBe('clean');
expect(root.dataset.chartStyle).toBe('muted');
expect(root.dataset.effects).toBe('reduced');
expect(root.dataset.motion).toBe('reduced');
});
it('the legacy sencho-theme key is a returning user and keeps the Signature look', async () => {
it('the legacy sencho-theme key is a returning user and keeps the Signature look without motion', async () => {
localStorage.setItem('sencho-theme', 'dark');
const root = await applyStored();
expect(root.dataset.theme).toBe('dim');
expect(root.dataset.headings).toBe('signature');
expect(root.dataset.effects).toBeUndefined();
expect(root.dataset.motion).toBeUndefined();
});
it('an existing stored object fills missing appearance fields from Signature', async () => {
@@ -83,6 +86,7 @@ describe('use-theme read / validate / migrate', () => {
expect(root.dataset.headings).toBe('signature');
expect(root.dataset.chartStyle).toBe('signature');
expect(root.dataset.effects).toBeUndefined();
expect(root.dataset.motion).toBeUndefined();
});
it('an empty stored object {} is a returning user (Signature), not a fresh one (Calm)', async () => {
@@ -91,6 +95,7 @@ describe('use-theme read / validate / migrate', () => {
expect(root.dataset.headings).toBe('signature');
expect(root.dataset.chartStyle).toBe('signature');
expect(root.dataset.effects).toBeUndefined();
expect(root.dataset.motion).toBeUndefined();
});
it('rejects an invalid persisted appearance enum, falling back to the Signature default', async () => {
@@ -99,4 +104,40 @@ describe('use-theme read / validate / migrate', () => {
expect(root.dataset.headings).toBe('signature');
expect(root.dataset.chartStyle).toBe('signature');
});
it('preserves stored reducedMotion true and false', async () => {
localStorage.setItem(KEY, JSON.stringify({
visualStyle: 'signature', headingStyle: 'signature', chartStyle: 'signature',
reducedEffects: false, reducedMotion: true, readability: false,
}));
let root = await applyStored();
expect(root.dataset.motion).toBe('reduced');
vi.resetModules();
localStorage.setItem(KEY, JSON.stringify({
visualStyle: 'calm', headingStyle: 'clean', chartStyle: 'muted',
reducedEffects: true, reducedMotion: false, readability: false,
}));
root = await applyStored();
expect(root.dataset.effects).toBe('reduced');
expect(root.dataset.motion).toBeUndefined();
});
it('fills false when reducedMotion is missing or non-boolean on an existing object', async () => {
localStorage.setItem(KEY, JSON.stringify({
visualStyle: 'calm', headingStyle: 'clean', chartStyle: 'muted',
reducedEffects: true, reducedMotion: 'yes', readability: false,
}));
const root = await applyStored();
expect(root.dataset.effects).toBe('reduced');
expect(root.dataset.motion).toBeUndefined();
});
it('malformed localStorage JSON falls through to the fresh Calm default with motion', async () => {
localStorage.setItem(KEY, '{not-json');
const root = await applyStored();
expect(root.dataset.headings).toBe('clean');
expect(root.dataset.effects).toBe('reduced');
expect(root.dataset.motion).toBe('reduced');
});
});
+9 -1
View File
@@ -123,18 +123,26 @@ describe('useTheme', () => {
expect(b.result.current.accent).toBe('lime');
});
it('setVisualStyle("calm") writes the three calm sub-axes, applies them, and persists', () => {
it('setVisualStyle("calm") writes calm sub-axes including motion, applies them, and persists', () => {
const { result } = renderHook(() => useTheme());
act(() => result.current.setVisualStyle('calm'));
expect(result.current.visualStyle).toBe('calm');
expect(result.current.headingStyle).toBe('clean');
expect(result.current.chartStyle).toBe('muted');
expect(result.current.reducedEffects).toBe(true);
expect(result.current.reducedMotion).toBe(true);
const root = document.documentElement;
expect(root.dataset.headings).toBe('clean');
expect(root.dataset.chartStyle).toBe('muted');
expect(root.dataset.effects).toBe('reduced');
expect(root.dataset.motion).toBe('reduced');
expect(readBlob().chartStyle).toBe('muted');
expect(readBlob().reducedMotion).toBe(true);
act(() => result.current.setVisualStyle('signature'));
expect(result.current.reducedMotion).toBe(false);
expect(document.documentElement.dataset.motion).toBeUndefined();
expect(readBlob().reducedMotion).toBe(false);
});
it('useChartStyle resolves the effective palette and memoizes a stable result', () => {
+21 -19
View File
@@ -19,11 +19,11 @@ export type AccentId =
export type UiFont = 'Geist' | 'IBM Plex Sans' | 'Hanken Grotesk';
export type MonoFont = 'Geist Mono' | 'IBM Plex Mono' | 'Fira Code';
// Calm / Readability refresh. `visualStyle` is a macro that writes the three
// sub-axes below; `readability` is an independent sticky master that forces the
// calm resolution at apply time without mutating the stored sub-axes. The member
// tuples are the single source of truth for both the union and its runtime guard
// (mirrors the THEME_MODES/ACCENTS convention below).
// Calm / Readability refresh. `visualStyle` is a macro that writes heading,
// chart, reducedEffects, and reducedMotion; `readability` is an independent
// sticky master that forces the calm resolution at apply time without mutating
// the stored sub-axes. The member tuples are the single source of truth for both
// the union and its runtime guard (mirrors the THEME_MODES/ACCENTS convention).
const VISUAL_STYLES = ['calm', 'signature'] as const;
const HEADING_STYLES = ['clean', 'signature'] as const;
const CHART_STYLES = ['muted', 'heat', 'signature'] as const;
@@ -44,8 +44,9 @@ export interface ThemeState {
headingStyle: HeadingStyle;
chartStyle: ChartStyle;
reducedEffects: boolean;
/** Independent of reducedEffects (surface flattening): minimizes UI motion
* (dialogs, menus, overlays, transitions). Not part of a visual-style preset. */
/** Minimizes UI motion (dialogs, menus, overlays, transitions). Written by
* Calm/Signature visual-style preset apply (true/false); still independently
* toggleable afterward and excluded from activeVisualStyle card matching. */
reducedMotion: boolean;
readability: boolean;
}
@@ -102,17 +103,19 @@ export const TYPE_SIZES: { id: string; scale: number; px: number }[] = [
const STORAGE_KEY = 'sencho.appearance.theme';
const LEGACY_KEY = 'sencho-theme';
// The five appearance axes the Calm/Signature refresh adds, as two presets.
// `setVisualStyle` writes the macro + the three sub-axes (not readability);
// migration fills missing fields on an existing stored object from SIGNATURE so
// returning users look unchanged, while a fresh user gets CALM via DEFAULT_STATE.
// Appearance axes the Calm/Signature macros write (not readability).
// `setVisualStyle` applies heading, chart, reducedEffects, and reducedMotion.
// Card matching (activeVisualStyle) still ignores reducedMotion so toggling
// Motion alone does not deselect Calm/Signature. Migration fills missing fields
// on an existing stored object from SIGNATURE so returning users look unchanged;
// a fresh user gets CALM via DEFAULT_STATE (including reducedMotion: true).
export const CALM_PRESET = {
visualStyle: 'calm', headingStyle: 'clean', chartStyle: 'muted',
reducedEffects: true, readability: false,
reducedEffects: true, reducedMotion: true, readability: false,
} as const;
export const SIGNATURE_PRESET = {
visualStyle: 'signature', headingStyle: 'signature', chartStyle: 'signature',
reducedEffects: false, readability: false,
reducedEffects: false, reducedMotion: false, readability: false,
} as const;
/** Which visual-style preset the stored sub-axes currently match, or null for a
@@ -139,9 +142,6 @@ const DEFAULT_STATE: ThemeState = {
theme: 'dim', accent: 'cyan', borderBoost: 0, glow: 0.16, contrast: 0,
uiFont: 'Geist', monoFont: 'Geist Mono', typeScale: 1,
...CALM_PRESET,
// Independent of the visual-style presets; defaults off so the OS
// prefers-reduced-motion still governs via MotionConfig's 'user' mode.
reducedMotion: false,
};
const MODE_IDS = new Set<string>(THEME_MODES.map((m) => m.id));
@@ -206,7 +206,7 @@ function readStored(): ThemeState {
headingStyle: isHeadingStyle(p.headingStyle) ? p.headingStyle : SIGNATURE_PRESET.headingStyle,
chartStyle: isChartStyle(p.chartStyle) ? p.chartStyle : SIGNATURE_PRESET.chartStyle,
reducedEffects: isBool(p.reducedEffects) ? p.reducedEffects : SIGNATURE_PRESET.reducedEffects,
reducedMotion: isBool(p.reducedMotion) ? p.reducedMotion : false,
reducedMotion: isBool(p.reducedMotion) ? p.reducedMotion : SIGNATURE_PRESET.reducedMotion,
readability: isBool(p.readability) ? p.readability : SIGNATURE_PRESET.readability,
};
}
@@ -373,8 +373,9 @@ export function useTheme() {
const setUiFont = useCallback((uiFont: UiFont) => setState({ uiFont }), []);
const setMonoFont = useCallback((monoFont: MonoFont) => setState({ monoFont }), []);
const setTypeScale = useCallback((typeScale: number) => setState({ typeScale }), []);
// Macro: writes visualStyle + the three sub-axes from the preset. It does
// NOT touch readability, which stays a sticky master the user releases by hand.
// Macro: writes visualStyle + preset sub-axes including reducedMotion.
// Does NOT touch readability (sticky master the user releases by hand).
// Re-applying Signature clears Motion; re-applying Calm enables it.
const setVisualStyle = useCallback((visualStyle: VisualStyle) => {
const preset = visualStyle === 'calm' ? CALM_PRESET : SIGNATURE_PRESET;
setState({
@@ -382,6 +383,7 @@ export function useTheme() {
headingStyle: preset.headingStyle,
chartStyle: preset.chartStyle,
reducedEffects: preset.reducedEffects,
reducedMotion: preset.reducedMotion,
});
}, []);
const setHeadingStyle = useCallback((headingStyle: HeadingStyle) => setState({ headingStyle }), []);
+8
View File
@@ -481,6 +481,14 @@ html[data-effects="reduced"] .animate-pulse {
animation: none;
}
/* Decorative masthead rails: tied to reduced material effects (Calm, Readability,
or manual Reduced effects), not to data-motion. Motion remains independently
toggleable; turning Motion off does not restore these rails while effects are reduced. */
html[data-effects="reduced"] .masthead-rail-glow,
html[data-effects="reduced"] .masthead-rail-shimmer {
animation: none;
}
/* Heading utility: every operational title routes through this. Base sets only
family + style (Signature parity); the Calm-only descendant rule lifts weight
and tightens tracking for the sans face, beating any single tracking or weight