mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 19:57:37 +00:00
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:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 }), []);
|
||||
|
||||
Reference in New Issue
Block a user