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
+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 }), []);