diff --git a/docs/features/appearance.mdx b/docs/features/appearance.mdx index 06354425..01ef39d2 100644 --- a/docs/features/appearance.mdx +++ b/docs/features/appearance.mdx @@ -45,9 +45,9 @@ The **Chart palette** sets the colors used on the Security page charts: ## Motion & effects -- **Reduced motion** minimizes interface animations and transitions (dialogs, menus, expand and collapse). Toasts are unaffected. On constrained graphics, this is the first control to try when the active Sencho tab feels heavy on idle. +- **Reduced motion** minimizes interface animations and transitions (dialogs, menus, expand and collapse). Toasts are unaffected. The **Calm** visual style turns this on by default; choosing **Signature** turns it off. You can still toggle it independently afterward without changing which visual-style card is selected. On constrained graphics, this is the first control to try when the active Sencho tab feels heavy on idle. - When Reduced motion is off, Appearance shows a short notice about constrained graphics and points you at Reduced motion. -- **Reduced effects** flattens card bevels, the accent glow, and chart gradients, and uses a simpler rendering path: glass blur is off and chrome fills are solid. Fresh installs get this through the default **Calm** visual style. Treat it as an optional secondary experiment for material cost; it is not a substitute for Reduced motion on the reported idle-GPU symptom. The impact varies by device, browser, and graphics configuration. +- **Reduced effects** flattens card bevels, the accent glow, and chart gradients, and uses a simpler rendering path: glass blur is off and chrome fills are solid. It also stops the decorative masthead rail animations even if Reduced motion is off. Fresh installs get this through the default **Calm** visual style. Treat it as an optional secondary experiment for material cost; it is not a substitute for Reduced motion on the reported idle-GPU symptom. The impact varies by device, browser, and graphics configuration. - **Ambient glow** sets the intensity of the soft, accent-tinted glow behind the page. Slide it to zero for a completely flat background. (Reduced effects already dims it.) If the active Sencho tab feels heavy on constrained graphics, see [High GPU usage on constrained graphics](/operations/troubleshooting#high-gpu-usage-on-constrained-graphics). diff --git a/docs/operations/troubleshooting.mdx b/docs/operations/troubleshooting.mdx index 06cde2e7..03146475 100644 --- a/docs/operations/troubleshooting.mdx +++ b/docs/operations/troubleshooting.mdx @@ -18,8 +18,8 @@ If something has broken and you want a quick "what do I do now?" overview rather **What to try:** 1. Open **Settings → Appearance → Motion & effects**. -2. Turn on **Reduced motion**. That is the first step to try for high idle GPU use on constrained graphics. -3. Optionally try **Reduced effects** or **Calm** as a secondary experiment (simpler glass and solid chrome). That path is unverified for this symptom. +2. Turn on **Reduced motion**. Fresh installs that use **Calm** already enable it by default. That is the first step to try for high idle GPU use on constrained graphics. +3. Optionally try **Reduced effects** as a secondary experiment (simpler glass, solid chrome, and quieter decorative masthead rails). That path is unverified for the idle-GPU symptom on its own. 4. On dual-GPU Windows PCs, you can also set the browser's GPU preference to let the OS decide, or prefer a discrete GPU, if your OS exposes that control. **How to help validate a fix on your hardware:** Identify Chrome's GPU process (Shift+Esc), use Task Manager Details at High update speed on that PID's GPU column, leave the Sencho tab in the foreground for about 15 seconds, then record about ten one-second samples and take the median. Repeat for at least three independent runs (close and reopen the tab). Compare idle Home or Settings with Reduced motion off versus on. Separately compare an update or deploy dialog with Raw output hidden and visible. On Linux single-board devices, use one documented GPU or responsiveness counter for the whole matrix, and label CPU-only readings as secondary. diff --git a/docs/reference/settings.mdx b/docs/reference/settings.mdx index 25880b98..890b6651 100644 --- a/docs/reference/settings.mdx +++ b/docs/reference/settings.mdx @@ -118,8 +118,8 @@ Control how Sencho looks and how dense the workspace feels. Each browser remembe | Control | What it does | |---------|--------------| | **Chart palette** | Colors the Security page charts: **Muted**, **Heat** (one warm ramp), or **Signature** (saturated). | -| **Reduced motion** | Minimizes interface animations and transitions. First control to try for high idle GPU use on constrained graphics. Toasts are unaffected. | -| **Reduced effects** | 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 the reported idle-GPU symptom. | +| **Reduced motion** | Minimizes interface animations and transitions. Calm enables it by default; Signature disables it. Independently toggleable afterward. First control to try for high idle GPU use on constrained graphics. Toasts are unaffected. | +| **Reduced effects** | Flattens card bevels, the accent glow, and chart gradients, turns off glass blur with solid chrome fills, and stops decorative masthead rail animations. Optional secondary material simplification; not a substitute for Reduced motion on the reported idle-GPU symptom. | | **Ambient glow** | Intensity of the accent-tinted page glow. | ### Theme and typography diff --git a/e2e/calm-reduced-motion.spec.ts b/e2e/calm-reduced-motion.spec.ts new file mode 100644 index 00000000..b71637d2 --- /dev/null +++ b/e2e/calm-reduced-motion.spec.ts @@ -0,0 +1,211 @@ +/** + * Calm Reduced Motion defaults + Reduced Effects decorative-rail quieting. + * Related to #1614; correctness only (not GPU %). + */ +import { test, expect, type Page, type Locator } from '@playwright/test'; +import { loginAs, waitForStacksLoaded } from './helpers'; + +const STORAGE_KEY = 'sencho.appearance.theme'; +const LEGACY_KEY = 'sencho-theme'; + +const SIGNATURE_STATE = { + theme: 'dim', + accent: 'cyan', + borderBoost: 0, + glow: 0.16, + contrast: 0, + uiFont: 'Geist', + monoFont: 'Geist Mono', + typeScale: 1, + visualStyle: 'signature', + headingStyle: 'signature', + chartStyle: 'signature', + reducedEffects: false, + reducedMotion: false, + readability: false, +} as const; + +async function seedAppearance(page: Page, state: Record | null) { + await page.addInitScript( + ({ key, legacy, payload, skipKey }) => { + // Tests that mutate appearance mid-run set this so reload is not overwritten. + if (sessionStorage.getItem(skipKey) === '1') return; + if (payload === null) { + localStorage.removeItem(key); + localStorage.removeItem(legacy); + } else { + localStorage.setItem(key, JSON.stringify(payload)); + localStorage.removeItem(legacy); + } + }, + { key: STORAGE_KEY, legacy: LEGACY_KEY, payload: state, skipKey: 'sencho.e2e.skipThemeSeed' }, + ); +} + +async function openAppearance(page: Page) { + await page.getByRole('button', { name: /profile/i }).click(); + await page.getByRole('button', { name: 'Settings', exact: true }).click(); + await page.getByRole('button', { name: 'Appearance', exact: true }).click(); + await expect(page.getByText('Motion & effects')).toBeVisible(); +} + +async function railAnim(locator: Locator): Promise<{ duration: string; iterations: string; name: string }> { + return locator.evaluate((el) => { + const cs = getComputedStyle(el); + return { + duration: cs.animationDuration, + iterations: cs.animationIterationCount, + name: cs.animationName, + }; + }); +} + +function expectRailsStatic(info: { duration: string; iterations: string; name: string }) { + const stopped = + info.name === 'none' || + info.duration === '0s' || + info.duration === '0.01ms' || + info.iterations === '1'; + expect(stopped).toBeTruthy(); +} + +function expectRailsRunning(info: { duration: string; iterations: string; name: string }) { + expect(info.name === 'none').toBeFalsy(); + expect(info.duration === '0s' || info.duration === '0.01ms').toBeFalsy(); +} + +async function assertRails(page: Page, mode: 'static' | 'running') { + const glow = page.locator('.masthead-rail-glow'); + const shimmer = page.locator('.masthead-rail-shimmer'); + const glowCount = await glow.count(); + const shimmerCount = await shimmer.count(); + expect(glowCount + shimmerCount).toBeGreaterThan(0); + for (let i = 0; i < glowCount; i++) { + const info = await railAnim(glow.nth(i)); + if (mode === 'static') expectRailsStatic(info); + else expectRailsRunning(info); + } + for (let i = 0; i < shimmerCount; i++) { + const info = await railAnim(shimmer.nth(i)); + if (mode === 'static') expectRailsStatic(info); + else expectRailsRunning(info); + } +} + +test.describe('Calm reduced motion defaults', () => { + test('fresh load applies data-motion by DOMContentLoaded', async ({ page }) => { + await seedAppearance(page, null); + await page.addInitScript(() => { + document.addEventListener( + 'DOMContentLoaded', + () => { + (window as unknown as { __snMotionAtDCL?: string | null }).__snMotionAtDCL = + document.documentElement.getAttribute('data-motion'); + }, + { once: true }, + ); + }); + await page.goto('/'); + const atDcl = await page.evaluate( + () => (window as unknown as { __snMotionAtDCL?: string | null }).__snMotionAtDCL ?? null, + ); + expect(atDcl).toBe('reduced'); + }); + + test('returning Signature object missing reducedMotion has no data-motion', async ({ page }) => { + const { reducedMotion: _omit, ...withoutMotion } = SIGNATURE_STATE; + await seedAppearance(page, withoutMotion); + await loginAs(page); + await waitForStacksLoaded(page); + await expect(page.locator('html')).not.toHaveAttribute('data-motion', 'reduced'); + }); + + test('stored reducedMotion true and false survive reload', async ({ page }) => { + await seedAppearance(page, { ...SIGNATURE_STATE, reducedMotion: true }); + await loginAs(page); + await waitForStacksLoaded(page); + await expect(page.locator('html')).toHaveAttribute('data-motion', 'reduced'); + await page.reload(); + await waitForStacksLoaded(page); + await expect(page.locator('html')).toHaveAttribute('data-motion', 'reduced'); + + // Stop init-script reseeding so the false write is what theme-init reads next. + await page.evaluate((key) => { + sessionStorage.setItem('sencho.e2e.skipThemeSeed', '1'); + const raw = localStorage.getItem(key); + const parsed = raw ? JSON.parse(raw) : {}; + parsed.reducedMotion = false; + localStorage.setItem(key, JSON.stringify(parsed)); + }, STORAGE_KEY); + await page.reload(); + await waitForStacksLoaded(page); + await expect(page.locator('html')).not.toHaveAttribute('data-motion', 'reduced'); + }); + + test('Signature to Calm and back writes both effects and motion', async ({ page }) => { + await seedAppearance(page, SIGNATURE_STATE); + await loginAs(page); + await waitForStacksLoaded(page); + await openAppearance(page); + + await page.getByRole('button', { name: /readable default|Calm/i }).click(); + await expect(page.locator('html')).toHaveAttribute('data-effects', 'reduced'); + await expect(page.locator('html')).toHaveAttribute('data-motion', 'reduced'); + + await page.getByRole('button', { name: /Today's look|Signature/i }).click(); + await expect(page.locator('html')).not.toHaveAttribute('data-effects', 'reduced'); + await expect(page.locator('html')).not.toHaveAttribute('data-motion', 'reduced'); + }); + + test('Calm card stays selected when Motion is toggled off; rails stay static', async ({ page }) => { + await seedAppearance(page, SIGNATURE_STATE); + await loginAs(page); + await waitForStacksLoaded(page); + await openAppearance(page); + + await page.getByRole('button', { name: /readable default|Calm/i }).click(); + await page.getByRole('switch', { name: 'Reduced motion' }).click(); + await expect(page.locator('html')).not.toHaveAttribute('data-motion', 'reduced'); + await expect(page.getByRole('button', { name: /readable default/i })).toHaveAttribute('aria-pressed', 'true'); + + await assertRails(page, 'static'); + }); + + test('Signature full effects with Motion off keeps rails running', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'no-preference' }); + await seedAppearance(page, SIGNATURE_STATE); + await loginAs(page); + await waitForStacksLoaded(page); + await openAppearance(page); + + await expect(page.locator('html')).not.toHaveAttribute('data-effects', 'reduced'); + await expect(page.locator('html')).not.toHaveAttribute('data-motion', 'reduced'); + await assertRails(page, 'running'); + }); + + test('manual Reduced effects under custom style staticizes rails without enabling Motion', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'no-preference' }); + await seedAppearance(page, SIGNATURE_STATE); + await loginAs(page); + await waitForStacksLoaded(page); + await openAppearance(page); + + await page.getByRole('radio', { name: 'Heat' }).click(); + await page.getByRole('switch', { name: 'Reduced effects' }).click(); + await expect(page.locator('html')).toHaveAttribute('data-effects', 'reduced'); + await expect(page.locator('html')).not.toHaveAttribute('data-motion', 'reduced'); + await assertRails(page, 'static'); + }); + + test('Readability with Motion off staticizes rails and leaves Motion off', async ({ page }) => { + await seedAppearance(page, SIGNATURE_STATE); + await loginAs(page); + await waitForStacksLoaded(page); + await openAppearance(page); + + await page.getByRole('switch', { name: 'Readability mode' }).click(); + await expect(page.locator('html')).toHaveAttribute('data-effects', 'reduced'); + await expect(page.locator('html')).not.toHaveAttribute('data-motion', 'reduced'); + await assertRails(page, 'static'); + }); +}); diff --git a/e2e/editor-save-deploy.spec.ts b/e2e/editor-save-deploy.spec.ts index f2cc3589..981bc1cc 100644 --- a/e2e/editor-save-deploy.spec.ts +++ b/e2e/editor-save-deploy.spec.ts @@ -2,6 +2,10 @@ * EditorView save-and-deploy: a failed PUT must abort the deploy. Verified by * intercepting the PUT with a forced 500 and asserting that no POST to /deploy * is observed, plus a "Failed to save file" toast surfaces. + * + * Empty-stack create opens an editable compose workspace immediately + * (startInComposeEdit), so this spec waits for Save & Deploy rather than + * clicking Anatomy "Edit compose". */ import { test, expect } from '@playwright/test'; import { loginAs, waitForStacksLoaded } from './helpers'; @@ -20,6 +24,8 @@ async function createTestStack(page: import('@playwright/test').Page) { await page.locator('#create-stack-name').fill(TEST_STACK); await page.locator('[role="dialog"]').getByRole('button', { name: 'Create' }).click(); await expect(page.getByRole('dialog')).toBeHidden({ timeout: 8_000 }); + // Empty create auto-opens compose edit; wait for that before asserting. + await expect(page.getByRole('button', { name: 'Save & Deploy', exact: true })).toBeVisible({ timeout: 10_000 }); } test.describe('EditorView save-and-deploy', () => { @@ -32,8 +38,6 @@ test.describe('EditorView save-and-deploy', () => { await loginAs(page); await waitForStacksLoaded(page); await createTestStack(page); - // Open the new stack in the editor. - await page.locator('[role="listbox"]').getByText(TEST_STACK, { exact: true }).click(); }); test.afterEach(async ({ page }) => { @@ -57,11 +61,6 @@ test.describe('EditorView save-and-deploy', () => { await route.continue(); }); - // One click on Anatomy "Edit compose" opens an immediately editable Monaco - // workspace with Save & Deploy visible (no second Edit gate). - await page.getByTestId('anatomy-edit-compose-btn').click(); - await expect(page.getByRole('button', { name: 'Save & Deploy', exact: true })).toBeVisible({ timeout: 5_000 }); - // No need to modify Monaco content: saveFile fires the PUT regardless of // dirty state. The route interceptor forces it to 500; the gated handler // then must not call POST /deploy. diff --git a/e2e/mobile-stack-edit.spec.ts b/e2e/mobile-stack-edit.spec.ts index 20ffc07d..2efda1e1 100644 --- a/e2e/mobile-stack-edit.spec.ts +++ b/e2e/mobile-stack-edit.spec.ts @@ -2,9 +2,10 @@ * Mobile compose/.env editing (below the md breakpoint). * * Logs in and creates the stack at desktop width (the create dialog and stack - * list are desktop-driven), then resizes to a phone viewport so EditorView - * renders MobileStackDetail. Covers the acceptance-criteria flows: open on - * mobile, edit compose, save, save-and-deploy guard, and discard dirty changes. + * list are desktop-driven), then resizes to a phone viewport. Empty-stack + * create opens compose edit immediately (startInComposeEdit); on phone that + * surfaces as MobileComposeEditor, so these tests wait for that editor rather + * than assuming an Anatomy/Compose read-only gate first. * Phone widths exercised: 390px and 430px. The compose/.env toggle and env-file * save share the same handlers and are covered by MobileStackDetail.test.tsx. */ @@ -27,18 +28,13 @@ async function createTestStack(page: Page) { await page.locator('#create-stack-name').fill(TEST_STACK); await page.locator('[role="dialog"]').getByRole('button', { name: 'Create' }).click(); await expect(page.getByRole('dialog')).toBeHidden({ timeout: 8_000 }); + // Empty create auto-opens desktop compose edit before we shrink the viewport. + await expect(page.getByRole('button', { name: 'Save & Deploy', exact: true })).toBeVisible({ timeout: 10_000 }); } -// Open the freshly created stack in the editor (desktop), then drop to a phone -// viewport so the mobile detail surface renders, and select the Compose segment. -async function openComposeOnPhone(page: Page, viewport = PHONE_390) { - await page.locator('[role="listbox"]').getByText(TEST_STACK, { exact: true }).click(); +/** Shrink to phone and wait for the already-open compose editor surface. */ +async function openComposeEditorOnPhone(page: Page, viewport = PHONE_390) { await page.setViewportSize(viewport); - await page.getByRole('tab', { name: 'Compose' }).click(); -} - -async function openEditor(page: Page) { - await page.getByRole('button', { name: 'edit' }).click(); await expect(page.getByTestId('mobile-compose-editor')).toBeVisible({ timeout: 5_000 }); } @@ -59,8 +55,7 @@ test.describe('mobile stack editing', () => { }); test('edits and saves the compose file from a phone (390px)', async ({ page }) => { - await openComposeOnPhone(page); - await openEditor(page); + await openComposeEditorOnPhone(page); await page.getByTestId('mobile-compose-editor').fill('services:\n app:\n image: nginx:1.27\n restart: always\n'); await page.getByTestId('mobile-editor-save').click(); @@ -89,8 +84,7 @@ test.describe('mobile stack editing', () => { await route.continue(); }); - await openComposeOnPhone(page); - await openEditor(page); + await openComposeEditorOnPhone(page); await page.getByTestId('mobile-editor-save-deploy').click(); await expect(page.getByText(/failed to save file/i)).toBeVisible({ timeout: 5_000 }); @@ -99,8 +93,7 @@ test.describe('mobile stack editing', () => { }); test('guards a dirty close and discards on confirm', async ({ page }) => { - await openComposeOnPhone(page); - await openEditor(page); + await openComposeEditorOnPhone(page); await page.getByTestId('mobile-compose-editor').fill('services:\n app:\n image: nginx:1.28\n'); await page.getByTestId('mobile-editor-close').click(); @@ -109,14 +102,14 @@ test.describe('mobile stack editing', () => { await expect(dialog).toBeVisible({ timeout: 5_000 }); await page.getByRole('button', { name: 'Discard changes' }).click(); - // The editor closes back to the read-only Compose segment. + // The editor closes back to mobile stack detail (default Logs segment). await expect(page.getByTestId('mobile-compose-editor')).toBeHidden(); + await page.getByRole('tab', { name: 'Compose' }).click(); await expect(page.getByRole('button', { name: 'edit' })).toBeVisible(); }); test('opens a usable editor at 430px', async ({ page }) => { - await openComposeOnPhone(page, PHONE_430); - await openEditor(page); + await openComposeEditorOnPhone(page, PHONE_430); await expect(page.getByTestId('mobile-compose-editor')).toBeVisible(); await expect(page.getByTestId('mobile-editor-save')).toBeVisible(); diff --git a/frontend/public/theme-init.js b/frontend/public/theme-init.js index ed16da17..7eadb9a1 100644 --- a/frontend/public/theme-init.js +++ b/frontend/public/theme-init.js @@ -19,13 +19,13 @@ // returning user looks unchanged. Mirrors the presets in src/hooks/use-theme.ts. var SIG = { visualStyle: 'signature', headingStyle: 'signature', chartStyle: 'signature', - reducedEffects: false, readability: false, + reducedEffects: false, reducedMotion: false, readability: false, }; var DEFAULTS = { theme: 'dim', accent: 'cyan', borderBoost: 0, glow: 0.16, contrast: 0, uiFont: 'Geist', monoFont: 'Geist Mono', typeScale: 1, visualStyle: 'calm', headingStyle: 'clean', chartStyle: 'muted', - reducedEffects: true, readability: false, + reducedEffects: true, reducedMotion: true, readability: false, }; function num(v, min, max, def) { @@ -55,6 +55,7 @@ headingStyle: HEADINGS[p.headingStyle] ? p.headingStyle : SIG.headingStyle, chartStyle: CHARTS[p.chartStyle] ? p.chartStyle : SIG.chartStyle, reducedEffects: bool(p.reducedEffects, SIG.reducedEffects), + reducedMotion: bool(p.reducedMotion, SIG.reducedMotion), readability: bool(p.readability, SIG.readability), }; } @@ -99,6 +100,7 @@ root.dataset.headings = headings; root.dataset.chartStyle = chart; if (reduced) root.dataset.effects = 'reduced'; else delete root.dataset.effects; + if (s.reducedMotion) root.dataset.motion = 'reduced'; else delete root.dataset.motion; root.style.setProperty('--border-boost', String(rd ? 0.03 : s.borderBoost)); root.style.setProperty('--glow', String(reduced ? s.glow * 0.4 : s.glow)); root.style.setProperty('--contrast', String(s.contrast + (rd ? 0.18 : 0))); diff --git a/frontend/src/components/settings/AppearanceSection.tsx b/frontend/src/components/settings/AppearanceSection.tsx index bd81e1f7..76b578ce 100644 --- a/frontend/src/components/settings/AppearanceSection.tsx +++ b/frontend/src/components/settings/AppearanceSection.tsx @@ -251,7 +251,7 @@ export function AppearanceSection() { ) : null} { 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(); + // 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' })); diff --git a/frontend/src/hooks/use-theme.read.test.ts b/frontend/src/hooks/use-theme.read.test.ts index 00958f58..1b286fd9 100644 --- a/frontend/src/hooks/use-theme.read.test.ts +++ b/frontend/src/hooks/use-theme.read.test.ts @@ -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'); + }); }); diff --git a/frontend/src/hooks/use-theme.test.ts b/frontend/src/hooks/use-theme.test.ts index 51bbce32..f63c8a03 100644 --- a/frontend/src/hooks/use-theme.test.ts +++ b/frontend/src/hooks/use-theme.test.ts @@ -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', () => { diff --git a/frontend/src/hooks/use-theme.ts b/frontend/src/hooks/use-theme.ts index aa45e4a8..0156159f 100644 --- a/frontend/src/hooks/use-theme.ts +++ b/frontend/src/hooks/use-theme.ts @@ -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(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 }), []); diff --git a/frontend/src/index.css b/frontend/src/index.css index 2b3b17ba..2544178b 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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