mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +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:
@@ -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<string, unknown> | 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');
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user