diff --git a/.gitignore b/.gitignore index 1b429a16..05445d57 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ playwright-report blob-report .playwright browser/.artifacts +site/dist diff --git a/README.md b/README.md index 010da7df..3b222379 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,22 @@ function Card({ open }: { open: boolean }) { Больше runnable-рецептов (drag с инерцией, FLIP, presence, скролл-сценарии, bottom sheet) — в [docs/recipes.md](docs/recipes.md). +## Живая витрина + +Статическая витрина в `site/` показывает три публичных контракта движка: +аналитическую пружину, координированный stagger и C¹-ретаргет (перехват цели +без разрыва траектории). Все превью используют только публичный +`@labpics/motion/animate`, без внутренних путей. + +```bash +pnpm build +pnpm site:build +pnpm site:preview # отдаёт site/dist на локальном сервере +``` + +Каждое превью уважает `prefers-reduced-motion` и корректно освобождает +слушатели при перенавигации. + ## Почему Lab Motion - **Пружины, не длительности.** Замкнутая форма вместо покадровой симуляции: diff --git a/browser/20-showcase.spec.ts b/browser/20-showcase.spec.ts new file mode 100644 index 00000000..a3d496d7 --- /dev/null +++ b/browser/20-showcase.spec.ts @@ -0,0 +1,288 @@ +import type { Page } from '@playwright/test'; +import { expect, test } from './fixtures/harness'; + +const SHOWCASE = '/site/dist/index.html'; +const EXAMPLE = `import { animate } from '@labpics/motion/animate'; + +await animate('.card', { x: 240, opacity: 1 }, { + spring: { mass: 1, stiffness: 170, damping: 26 }, + stagger: 40, +}).finished;`; + +function watchRuntimeFailures(page: Page): string[] { + const failures: string[] = []; + page.on('pageerror', (error) => failures.push(`pageerror: ${error.message}`)); + page.on('console', (message) => { + if (message.type() === 'error') failures.push(`console.error: ${message.text()}`); + }); + page.on('requestfailed', (request) => failures.push(`requestfailed: ${request.url()}`)); + page.on('response', (response) => { + if (response.status() >= 400) failures.push(`http ${response.status()}: ${response.url()}`); + }); + return failures; +} + +function translation(page: Page, selector: string): Promise<{ x: number; y: number }> { + return page.locator(selector).evaluate((element) => { + const matrix = new DOMMatrixReadOnly(getComputedStyle(element).transform); + return { x: matrix.m41, y: matrix.m42 }; + }); +} + +test('showcase renders every proof and settles without runtime failures', async ({ page }) => { + const failures = watchRuntimeFailures(page); + await page.goto(SHOWCASE); + + await expect(page).toHaveTitle(/Lab Motion/); + await expect(page.getByRole('heading', { name: 'Motion that survives interruption.' })).toBeVisible(); + await expect(page.getByRole('img', { name: 'Live spring preview' })).toBeVisible(); + await expect(page.getByRole('img', { name: 'Spring animation preview' })).toBeVisible(); + await expect(page.getByRole('img', { name: 'Staggered dots animation preview' })).toBeVisible(); + await expect(page.getByRole('img', { name: 'Retargetable object animation preview' })).toBeVisible(); + await expect(page.locator('.stage-coordinate')).toHaveCount(0); + await expect(page.locator('[data-card="spring"] [data-state]')).toHaveText('complete'); + await expect(page.locator('[data-card="stagger"] [data-state]')).toHaveText('complete'); + await expect(page.getByRole('status')).toHaveCount(3); + await expect.poll(() => translation(page, '[data-preview="spring-object"]')).toEqual({ x: 112, y: 0 }); + expect(failures).toEqual([]); +}); + +test('spring Replay starts a new observed trajectory and reaches its endpoint', async ({ page }) => { + const failures = watchRuntimeFailures(page); + await page.goto(SHOWCASE); + const state = page.locator('[data-card="spring"] [data-state]'); + await expect(state).toHaveText('complete'); + + const before = await translation(page, '[data-preview="spring-object"]'); + await page.locator('[data-action="replay-spring"]').click(); + await expect(state).toHaveText('running'); + await expect.poll(async () => (await translation(page, '[data-preview="spring-object"]')).x).not.toBe(before.x); + await expect(state).toHaveText('complete'); + await expect.poll(async () => (await translation(page, '[data-preview="spring-object"]')).x).toBeCloseTo(112, 1); + expect(failures).toEqual([]); +}); + +test('stagger Replay exposes delayed visual ordering before all items settle', async ({ page }) => { + const failures = watchRuntimeFailures(page); + await page.goto(SHOWCASE); + const state = page.locator('[data-card="stagger"] [data-state]'); + await expect(state).toHaveText('complete'); + + await page.locator('[data-action="replay-stagger"]').click(); + await expect(state).toHaveText('running'); + await expect.poll(async () => page.locator('[data-stagger-item]').evaluateAll((items) => { + const opacity = items.map((item) => Number(getComputedStyle(item).opacity)); + return Math.max(...opacity) - Math.min(...opacity); + })).toBeGreaterThan(0.1); + await expect(state).toHaveText('complete'); + await expect.poll(async () => page.locator('[data-stagger-item]').evaluateAll((items) => + items.every((item) => Math.abs(Number(getComputedStyle(item).opacity) - 1) < 0.001), + )).toBe(true); + expect(failures).toEqual([]); +}); + +test('retarget changes the observed destination and settles at the redirected target', async ({ page }) => { + const failures = watchRuntimeFailures(page); + await page.goto(SHOWCASE); + + await page.locator('[data-action="retarget"]').click(); + await expect(page.locator('[data-card="retarget"] [data-state]')).toHaveText('running'); + await expect.poll(() => page.locator('[data-retarget-copy]').textContent()).toMatch(/Redirecting|Retargeted/); + + await page.locator('[data-action="reset-retarget"]').click(); + await expect(page.locator('[data-card="retarget"] [data-state]')).toHaveText('ready'); + await expect(page.locator('[data-retarget-copy]')).toHaveText('Start a transition, then redirect it without a teleport.'); + await expect.poll(async () => (await translation(page, '[data-preview="retarget-object"]')).x).toBe(-112); + + await page.locator('[data-action="retarget"]').click(); + await expect(page.locator('[data-card="retarget"] [data-state]')).toHaveText('complete'); + await expect.poll(async () => (await translation(page, '[data-preview="retarget-object"]')).x).toBeCloseTo(-34, 1); + expect(failures).toEqual([]); +}); + +test('Copy sends the exact public example and reports success', async ({ page }) => { + const failures = watchRuntimeFailures(page); + await page.addInitScript(() => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { + writeText(text: string) { + (window as typeof window & { __showcaseCopied?: string }).__showcaseCopied = text; + return Promise.resolve(); + }, + }, + }); + }); + await page.goto(SHOWCASE); + + await page.locator('[data-copy]').click(); + await expect(page.locator('[data-copy-status]')).toHaveText('Copied to clipboard.'); + expect(await page.evaluate(() => + (window as typeof window & { __showcaseCopied?: string }).__showcaseCopied, + )).toBe(EXAMPLE); + expect(failures).toEqual([]); +}); + +test('system reduced motion resolves previews without native animations', async ({ page }) => { + const failures = watchRuntimeFailures(page); + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.goto(SHOWCASE); + + await expect(page.locator('[data-action="toggle-motion"]')).toHaveAttribute('aria-pressed', 'true'); + await expect(page.locator('[data-action="toggle-motion"]')).toBeDisabled(); + await expect(page.locator('[data-action="toggle-motion"]')).toHaveText('System setting: reduced motion'); + await expect(page.locator('[data-site-status]')).toHaveText('Your system reduced-motion preference is active; the preview follows it.'); + await expect(page.locator('[data-card="spring"] [data-state]')).toHaveText('reduced'); + await expect(page.locator('[data-card="stagger"] [data-state]')).toHaveText('reduced'); + await expect.poll(() => page.locator('[data-preview], [data-stagger-item]').evaluateAll((items) => + items.reduce((count, item) => count + item.getAnimations().length, 0), + )).toBe(0); + await expect.poll(async () => (await translation(page, '[data-preview="spring-object"]')).x).toBeCloseTo(112, 1); + expect(failures).toEqual([]); +}); + +test('changing the system motion preference updates the effective UI state', async ({ page }) => { + const failures = watchRuntimeFailures(page); + await page.emulateMedia({ reducedMotion: 'no-preference' }); + await page.goto(SHOWCASE); + await expect(page.locator('[data-card="spring"] [data-state]')).toHaveText('complete'); + + await page.emulateMedia({ reducedMotion: 'reduce' }); + + await expect(page.locator('[data-action="toggle-motion"]')).toHaveAttribute('aria-pressed', 'true'); + await expect(page.locator('[data-action="toggle-motion"]')).toBeDisabled(); + await expect(page.locator('[data-site-status]')).toHaveText('Your system reduced-motion preference is active; the preview follows it.'); + await expect(page.locator('[data-card="spring"] [data-state]')).toHaveText('reduced'); + await expect(page.locator('[data-card="stagger"] [data-state]')).toHaveText('reduced'); + expect(failures).toEqual([]); +}); + +test('enabling reduced motion clears the pending autonomous hero replay', async ({ page }) => { + const failures = watchRuntimeFailures(page); + await page.addInitScript(() => { + const nativeSetTimeout = window.setTimeout.bind(window); + const nativeClearTimeout = window.clearTimeout.bind(window); + const timers = new Map(); + let heldTimerId = -1; + (window as typeof window & { __showcaseTimers?: typeof timers }).__showcaseTimers = timers; + window.setTimeout = ((callback: TimerHandler, delay = 0, ...args: unknown[]) => { + if (delay === 700) { + const id = heldTimerId--; + timers.set(id, { cleared: false, delay, fired: false }); + return id; + } + let id = 0; + const tracked = typeof callback === 'function' + ? (...callbackArgs: unknown[]) => { + const timer = timers.get(id); + if (timer) timer.fired = true; + return Reflect.apply(callback, window, callbackArgs); + } + : callback; + id = nativeSetTimeout(tracked, delay, ...args) as unknown as number; + if (delay === 700) timers.set(id, { cleared: false, delay, fired: false }); + return id; + }) as typeof window.setTimeout; + window.clearTimeout = ((id = 0) => { + const timer = timers.get(id as unknown as number); + if (timer) { + timer.cleared = true; + return; + } + nativeClearTimeout(id); + }) as typeof window.clearTimeout; + }); + await page.goto(SHOWCASE); + await expect(page.locator('[data-card="spring"] [data-state]')).toHaveText('complete'); + await expect(page.locator('[data-card="stagger"] [data-state]')).toHaveText('complete'); + + await expect.poll(() => page.evaluate(() => { + const timers = (window as typeof window & { + __showcaseTimers?: Map; + }).__showcaseTimers; + return [...(timers?.values() ?? [])].some((timer) => timer.delay === 700 && !timer.cleared && !timer.fired); + })).toBe(true); + const pendingHeroTimers = await page.evaluate(() => { + const timers = (window as typeof window & { + __showcaseTimers?: Map; + }).__showcaseTimers; + return [...(timers?.entries() ?? [])] + .filter(([, timer]) => timer.delay === 700 && !timer.cleared && !timer.fired) + .map(([id]) => id); + }); + + await page.locator('[data-action="toggle-motion"]').click(); + + expect(await page.evaluate((ids) => { + const timers = (window as typeof window & { + __showcaseTimers?: Map; + }).__showcaseTimers; + return ids.every((id) => timers?.get(id)?.cleared === true); + }, pendingHeroTimers)).toBe(true); + expect(failures).toEqual([]); +}); + +test('small functional text meets the WCAG AA contrast floor', async ({ page }) => { + await page.goto(SHOWCASE); + + const ratios = await page.locator('.card-kicker, .card-state, .code-card-bar').evaluateAll((elements) => { + function channel(value: number): number { + const normalized = value / 255; + return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4; + } + function luminance(color: string): number { + const [r = 0, g = 0, b = 0] = color.match(/[\d.]+/g)?.map(Number) ?? []; + return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b); + } + return elements.map((element) => { + const style = getComputedStyle(element); + const foreground = luminance(style.color); + const background = luminance(getComputedStyle(element.closest('.proof-card, .code-card') ?? document.body).backgroundColor); + return (Math.max(foreground, background) + 0.05) / (Math.min(foreground, background) + 0.05); + }); + }); + + expect(Math.min(...ratios)).toBeGreaterThanOrEqual(4.5); +}); + +test('keyboard toggle enforces reduced motion and the phone layout does not overflow', async ({ page }) => { + const failures = watchRuntimeFailures(page); + await page.setViewportSize({ width: 375, height: 800 }); + await page.emulateMedia({ reducedMotion: 'no-preference' }); + await page.goto(SHOWCASE); + + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth); + expect(overflow).toBeLessThanOrEqual(0); + + let focusedAction: string | undefined; + for (let step = 0; step < 12 && focusedAction !== 'toggle-motion'; step++) { + await page.keyboard.press('Tab'); + focusedAction = await page.evaluate(() => (document.activeElement as HTMLElement | null)?.dataset.action); + } + expect(focusedAction).toBe('toggle-motion'); + await page.keyboard.press('Enter'); + + const toggle = page.locator('[data-action="toggle-motion"]'); + await expect(toggle).toHaveAttribute('aria-pressed', 'true'); + await expect(toggle).toHaveText('Use full motion'); + await expect(page.locator('[data-card="spring"] [data-state]')).toHaveText('reduced'); + await expect(page.locator('[data-card="stagger"] [data-state]')).toHaveText('reduced'); + expect(await page.locator('[data-preview], [data-stagger-item]').evaluateAll((items) => + items.reduce((count, item) => count + item.getAnimations().length, 0), + )).toBe(0); + expect(failures).toEqual([]); +}); + +test('hero preview stops off-screen and resumes when it re-enters the viewport', async ({ page }) => { + const failures = watchRuntimeFailures(page); + await page.goto(SHOWCASE); + const orb = page.locator('[data-preview="hero-orb"]'); + await expect.poll(() => orb.evaluate((element) => element.getAnimations().length)).toBeGreaterThan(0); + + await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight)); + await expect.poll(() => orb.evaluate((element) => element.getAnimations().length)).toBe(0); + + await page.evaluate(() => window.scrollTo(0, 0)); + await expect.poll(() => orb.evaluate((element) => element.getAnimations().length)).toBeGreaterThan(0); + expect(failures).toEqual([]); +}); diff --git a/package.json b/package.json index 6ad9a08d..555d4a18 100644 --- a/package.json +++ b/package.json @@ -507,7 +507,9 @@ "test:browser:all": "playwright test", "mutation": "stryker run", "typecheck:native": "tsgo --noEmit", - "check:native-gate": "node scripts/check-native-gate.mjs" + "check:native-gate": "node scripts/check-native-gate.mjs", + "site:build": "pnpm build && vite build --config site/vite.config.mjs site", + "site:preview": "vite preview --config site/vite.config.mjs site/dist" }, "peerDependencies": { "@angular/core": ">=16.0.0", diff --git a/site/index.html b/site/index.html new file mode 100644 index 00000000..6e84bcf4 --- /dev/null +++ b/site/index.html @@ -0,0 +1,193 @@ + + + + + + + + + Lab Motion — motion that survives interruption + + + + + + +
+
+
+

Headless motion kernel

+

Motion that survives interruption.

+

+ Physics-first transitions for product interfaces. Smooth retargets, compositor routing, and a small + public surface you can test instead of trusting. +

+ +

Animations are live. Use the control above to preview reduced motion.

+
+ +
+ +
+
+

Three contracts, visible

+

Feel the behavior before reading the source.

+

Each preview uses the public @labpics/motion/animate entry. The controls are intentionally plain so the motion stays the subject.

+
+ +
+
+
+
+

01 / analytic spring

+

A physical settle, not a canned curve.

+
+ ready +
+ + +
+ +
+
+
+

02 / shared clock

+

Stagger without a scheduler pile-up.

+
+ ready +
+ + +
+ +
+
+
+

03 / C¹ handoff

+

Change the target mid-flight.

+
+ ready +
+ + +
+
+
+ +
+
+

Small surface, deep behavior

+

The first useful call stays readable.

+

Use the same facade for a spring, a tween, a stagger, or an interruption. The package keeps framework bindings optional and the motion core headless.

+ Open the API reference +
+
+
motion.ts
+
import { animate } from '@labpics/motion/animate';
+
+await animate('.card', { x: 240, opacity: 1 }, {
+  spring: { mass: 1, stiffness: 170, damping: 26 },
+  stagger: 40,
+}).finished;
+

+
+
+ +
+
+

Proof, not promises

+

Measure the trade-off that matters to your interface.

+
+ +
+
+ +
+ Lab Motion by Labpics + Built for interfaces that change their mind. +
+ + + + diff --git a/site/src/scripts/main.js b/site/src/scripts/main.js new file mode 100644 index 00000000..bc70edb2 --- /dev/null +++ b/site/src/scripts/main.js @@ -0,0 +1,5 @@ +import { installShowcase } from './showcase.js'; + +const dispose = installShowcase(); + +if (import.meta.hot) import.meta.hot.dispose(dispose); diff --git a/site/src/scripts/showcase.js b/site/src/scripts/showcase.js new file mode 100644 index 00000000..6d57bf67 --- /dev/null +++ b/site/src/scripts/showcase.js @@ -0,0 +1,274 @@ +import { animate } from '@labpics/motion/animate'; + +const spring = { mass: 1, stiffness: 170, damping: 26 }; +const reducedMotionQuery = '(prefers-reduced-motion: reduce)'; +let disposeActiveShowcase = () => {}; + +export function installShowcase() { + disposeActiveShowcase(); + + let disposed = false; + let forcedReduced = false; + let heroControls; + let heroTimer; + let lastReduced; + let springControls; + let staggerControls; + let retargetControls; + let retargetTimer; + let heroVisible = true; + let heroObserver; + const cleanups = []; + const motionQuery = typeof window.matchMedia === 'function' + ? window.matchMedia(reducedMotionQuery) + : undefined; + + const listen = (target, type, handler) => { + target.addEventListener(type, handler); + cleanups.push(() => target.removeEventListener(type, handler)); + }; + + const systemReduced = () => forcedReduced || motionQuery?.matches === true; + + const options = (extra = {}) => ({ + spring, + ...extra, + matchMedia: () => ({ matches: systemReduced() }), + }); + + const cardState = (name, state) => { + if (disposed) return; + const label = document.querySelector(`[data-card="${name}"] [data-state]`); + if (!label) return; + label.dataset.kind = state; + label.textContent = state; + }; + + const whenFinished = (controls, name, current) => { + void controls.finished.then(() => { + if (!disposed && current() === controls) cardState(name, systemReduced() ? 'reduced' : 'complete'); + }); + }; + + const stopHero = () => { + if (heroTimer !== undefined) window.clearTimeout(heroTimer); + heroTimer = undefined; + const controls = heroControls; + heroControls = undefined; + controls?.cancel(); + }; + + const stopSpring = () => { + const controls = springControls; + springControls = undefined; + controls?.cancel(); + }; + + const stopStagger = () => { + const controls = staggerControls; + staggerControls = undefined; + controls?.cancel(); + }; + + const stopRetarget = () => { + if (retargetTimer !== undefined) window.clearTimeout(retargetTimer); + retargetTimer = undefined; + const controls = retargetControls; + retargetControls = undefined; + controls?.cancel(); + }; + + const stopPreviews = () => { + stopHero(); + stopSpring(); + stopStagger(); + stopRetarget(); + }; + + const replayHero = () => { + const orb = document.querySelector('[data-preview="hero-orb"]'); + if (disposed || document.hidden || !heroVisible || !orb) return; + stopHero(); + const controls = animate(orb, { x: [0, 34], y: [0, -18], rotate: [0, 8], scale: [.96, 1] }, options()); + heroControls = controls; + if (!systemReduced()) { + void controls.finished.then(() => { + if (disposed || document.hidden || !heroVisible || heroControls !== controls || systemReduced()) return; + heroTimer = window.setTimeout(() => { + heroTimer = undefined; + if (!disposed && !document.hidden && heroVisible && heroControls === controls && !systemReduced()) replayHero(); + }, 700); + }); + } + }; + + const replaySpring = () => { + const object = document.querySelector('[data-preview="spring-object"]'); + if (disposed || document.hidden || !object) return; + stopSpring(); + const controls = animate(object, { x: [-112, 112], rotate: [-5, 5], scale: [.92, 1] }, options()); + springControls = controls; + cardState('spring', systemReduced() ? 'reduced' : 'running'); + whenFinished(controls, 'spring', () => springControls); + }; + + const replayStagger = () => { + const items = document.querySelectorAll('[data-stagger-item]'); + if (disposed || document.hidden || items.length === 0) return; + stopStagger(); + const controls = animate(items, { y: [26, 0], scale: [.72, 1], opacity: [0.2, 1] }, options({ stagger: 44 })); + staggerControls = controls; + cardState('stagger', systemReduced() ? 'reduced' : 'running'); + whenFinished(controls, 'stagger', () => staggerControls); + }; + + const resetRetarget = () => { + stopRetarget(); + const object = document.querySelector('[data-preview="retarget-object"]'); + const copy = document.querySelector('[data-retarget-copy]'); + if (object) object.style.transform = 'translateX(-112px)'; + if (copy) copy.textContent = 'Start a transition, then redirect it without a teleport.'; + cardState('retarget', 'ready'); + }; + + const startRetarget = () => { + const object = document.querySelector('[data-preview="retarget-object"]'); + const copy = document.querySelector('[data-retarget-copy]'); + if (disposed || document.hidden || !object) return; + resetRetarget(); + const controls = animate(object, { x: [-112, 112] }, options()); + retargetControls = controls; + cardState('retarget', systemReduced() ? 'reduced' : 'running'); + if (copy) copy.textContent = systemReduced() ? 'Reduced motion: the target snaps by policy.' : 'Target A is moving. Redirecting to Target B…'; + if (!systemReduced()) { + retargetTimer = window.setTimeout(() => { + if (disposed || document.hidden || retargetControls !== controls) return; + const redirected = animate(object, { x: -34, scale: [.88, 1] }, options()); + retargetControls = redirected; + if (copy) copy.textContent = 'Retargeted mid-flight. Position and velocity continue together.'; + cardState('retarget', 'running'); + whenFinished(redirected, 'retarget', () => retargetControls); + }, 260); + } + }; + + const replayPreviews = () => { + resetRetarget(); + if (disposed || document.hidden) { + stopPreviews(); + return; + } + replayHero(); + replaySpring(); + replayStagger(); + }; + + const toggle = document.querySelector('[data-action="toggle-motion"]'); + const siteStatus = document.querySelector('[data-site-status]'); + const syncMotionUi = () => { + const systemManaged = motionQuery?.matches === true; + const reduced = systemManaged || forcedReduced; + const changed = lastReduced !== undefined && lastReduced !== reduced; + lastReduced = reduced; + document.documentElement.dataset.motion = reduced ? 'reduced' : 'full'; + if (toggle) { + toggle.disabled = systemManaged; + toggle.setAttribute('aria-disabled', String(systemManaged)); + toggle.setAttribute('aria-pressed', String(reduced)); + toggle.textContent = systemManaged + ? 'System setting: reduced motion' + : forcedReduced + ? 'Use full motion' + : 'Reduce motion'; + } + if (siteStatus) { + siteStatus.textContent = systemManaged + ? 'Your system reduced-motion preference is active; the preview follows it.' + : forcedReduced + ? 'Reduced motion preview is on. New previews resolve without animated travel.' + : 'Animations are live. Use the control above to preview reduced motion.'; + } + return changed; + }; + + syncMotionUi(); + if (toggle) { + listen(toggle, 'click', () => { + if (motionQuery?.matches === true) return; + forcedReduced = !forcedReduced; + syncMotionUi(); + replayPreviews(); + }); + } + + const onSystemChange = () => { + if (syncMotionUi()) replayPreviews(); + }; + if (typeof motionQuery?.addEventListener === 'function') { + motionQuery.addEventListener('change', onSystemChange); + cleanups.push(() => motionQuery.removeEventListener('change', onSystemChange)); + } else if (motionQuery?.addListener) { + motionQuery.addListener(onSystemChange); + cleanups.push(() => motionQuery.removeListener?.(onSystemChange)); + } + + const copyButton = document.querySelector('[data-copy]'); + const copySource = document.querySelector('[data-copy-source]'); + const copyStatus = document.querySelector('[data-copy-status]'); + if (copyButton) { + listen(copyButton, 'click', async () => { + const text = copySource?.textContent?.trim(); + if (!text || !copyStatus) return; + try { + await navigator.clipboard.writeText(text); + if (!disposed) copyStatus.textContent = 'Copied to clipboard.'; + } catch { + if (!disposed) copyStatus.textContent = 'Clipboard access is unavailable in this context.'; + } + }); + } + + const action = (name, handler) => { + const element = document.querySelector(`[data-action="${name}"]`); + if (element) listen(element, 'click', handler); + }; + action('replay-spring', replaySpring); + action('replay-stagger', replayStagger); + action('retarget', startRetarget); + action('reset-retarget', resetRetarget); + + listen(document, 'visibilitychange', () => { + if (document.hidden) stopPreviews(); + else replayPreviews(); + }); + listen(window, 'pagehide', stopPreviews); + listen(window, 'pageshow', () => { + if (!document.hidden) replayPreviews(); + }); + + const hero = document.querySelector('[data-preview="hero-orb"]')?.closest('.hero-stage'); + if (hero && typeof IntersectionObserver === 'function') { + heroObserver = new IntersectionObserver(([entry]) => { + const visible = entry?.isIntersecting === true; + if (heroVisible === visible || disposed) return; + heroVisible = visible; + if (visible) replayHero(); + else stopHero(); + }, { threshold: 0.01 }); + heroObserver.observe(hero); + cleanups.push(() => heroObserver?.disconnect()); + } + + const dispose = () => { + if (disposed) return; + disposed = true; + stopPreviews(); + heroObserver?.disconnect(); + while (cleanups.length > 0) cleanups.pop()(); + if (disposeActiveShowcase === dispose) disposeActiveShowcase = () => {}; + }; + disposeActiveShowcase = dispose; + + replayPreviews(); + return dispose; +} diff --git a/site/src/styles/site.css b/site/src/styles/site.css new file mode 100644 index 00000000..41952284 --- /dev/null +++ b/site/src/styles/site.css @@ -0,0 +1,158 @@ +:root { + color-scheme: dark; + --bg: #0b0d10; + --surface: #11151a; + --line: #2b333d; + --line-soft: #1f262e; + --text: #f3f7f5; + --muted: #aeb9bf; + --quiet: #6f7d85; + --mint: #a6f4d3; + --mint-strong: #73e6b6; + --coral: #ff8f70; + --shadow: 0 24px 80px rgb(2 10 16 / 34%); + --radius-lg: 24px; + --radius-md: 16px; + --content: 1180px; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-synthesis: none; + text-rendering: optimizeLegibility; +} + +* { box-sizing: border-box; } +html { scroll-behavior: smooth; background: var(--bg); } +body { margin: 0; color: var(--text); background: var(--bg); } +body::before { + position: fixed; + inset: 0; + z-index: -1; + pointer-events: none; + content: ""; + background: radial-gradient(circle at 72% 0%, rgb(91 216 167 / 8%), transparent 34rem), radial-gradient(circle at 0% 36%, rgb(255 143 112 / 5%), transparent 28rem); +} +a { color: inherit; text-decoration: none; } +button, a { -webkit-tap-highlight-color: transparent; } +button { font: inherit; } +code, pre { font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; } +.skip-link { position: fixed; left: 1rem; top: -5rem; z-index: 10; padding: .75rem 1rem; color: var(--bg); background: var(--mint); border-radius: 10px; } +.skip-link:focus { top: 1rem; } +.section-shell { width: min(calc(100% - 3rem), var(--content)); margin-inline: auto; } +.site-header { display: flex; align-items: center; justify-content: space-between; gap: 1.5rem; width: min(calc(100% - 3rem), var(--content)); margin: 0 auto; padding: 1.25rem 0; border-bottom: 1px solid var(--line-soft); } +.brand { display: inline-flex; align-items: center; gap: .7rem; font-size: .93rem; font-weight: 650; letter-spacing: -.02em; } +.brand-mark { width: .8rem; height: .8rem; border: 2px solid var(--mint); border-radius: 50%; box-shadow: 0 0 0 4px rgb(166 244 211 / 12%); } +nav { display: flex; order: 3; gap: 1.35rem; color: var(--muted); font-size: .82rem; } +nav a { display: inline-flex; align-items: center; min-height: 3rem; padding-inline: .25rem; } +nav a, .text-link, .evidence-link { transition: color 180ms ease, border-color 180ms ease; } +nav a:hover, nav a:focus-visible, .text-link:hover, .text-link:focus-visible { color: var(--mint); } +.motion-toggle, .copy-button { min-height: 2.75rem; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); background: transparent; cursor: pointer; } +.motion-toggle { order: 2; min-height: 3rem; padding: .5rem .9rem; font-size: .75rem; } +.motion-toggle:disabled { cursor: not-allowed; opacity: .78; } +.motion-toggle[aria-pressed="true"] { color: var(--bg); border-color: var(--mint); background: var(--mint); } +:where(button, a):focus-visible { outline: 3px solid var(--coral); outline-offset: 3px; } +.hero { display: grid; grid-template-columns: minmax(0, .95fr) minmax(360px, 1.05fr); gap: clamp(2.5rem, 8vw, 7rem); align-items: center; min-height: min(740px, calc(100vh - 76px)); padding: 5rem 0 6rem; } +.hero-copy { max-width: 620px; } +.eyebrow { display: flex; align-items: center; gap: .55rem; margin: 0 0 1.25rem; color: var(--mint); font-size: .72rem; font-weight: 700; letter-spacing: .16em; text-transform: uppercase; } +.status-dot { width: .45rem; height: .45rem; border-radius: 50%; background: var(--mint-strong); box-shadow: 0 0 0 5px rgb(115 230 182 / 13%); } +h1, h2, h3, p { margin-top: 0; } +h1 { max-width: 700px; margin-bottom: 1.5rem; font-size: clamp(3.4rem, 8vw, 7rem); line-height: .92; letter-spacing: -.075em; } +h2 { max-width: 690px; margin-bottom: 1rem; font-size: clamp(2rem, 4vw, 3.6rem); line-height: .98; letter-spacing: -.055em; } +h3 { margin-bottom: 0; font-size: 1.25rem; line-height: 1.1; letter-spacing: -.03em; } +.hero-lede { max-width: 550px; margin-bottom: 2rem; color: var(--muted); font-size: clamp(1rem, 1.8vw, 1.2rem); line-height: 1.55; } +.hero-actions, .button-row { display: flex; flex-wrap: wrap; gap: .7rem; align-items: center; } +.button { display: inline-flex; align-items: center; justify-content: center; min-height: 3rem; padding: .65rem 1rem; border: 1px solid transparent; border-radius: 999px; font-size: .83rem; font-weight: 700; cursor: pointer; transition: transform 180ms ease, background 180ms ease, border-color 180ms ease; } +.button:hover { transform: translateY(-2px); } +.button-primary { color: var(--bg); background: var(--mint); } +.button-primary:hover { background: var(--mint-strong); } +.button-quiet { color: var(--muted); border-color: var(--line); background: transparent; } +.button-quiet:hover { color: var(--text); border-color: var(--muted); } +.button-small { min-height: 3rem; padding: .5rem .82rem; font-size: .75rem; } +.hero-note { min-height: 1.4rem; margin: 1.2rem 0 0; color: var(--muted); font-size: .75rem; } +.hero-stage { position: relative; min-height: 430px; overflow: hidden; border: 1px solid var(--line); border-radius: var(--radius-lg); background: linear-gradient(145deg, #141a1f, #0e1115 68%); box-shadow: var(--shadow); isolation: isolate; } +.stage-grid { position: absolute; inset: 0; opacity: .38; background-image: linear-gradient(var(--line-soft) 1px, transparent 1px), linear-gradient(90deg, var(--line-soft) 1px, transparent 1px); background-size: 44px 44px; mask-image: linear-gradient(to bottom, black, transparent 86%); } +.stage-caption { position: absolute; top: 1.25rem; left: 1.25rem; color: var(--quiet); font: .68rem/1.2 "SFMono-Regular", Consolas, monospace; letter-spacing: .08em; text-transform: uppercase; } +.hero-orbit { position: absolute; top: 50%; left: 50%; border: 1px solid rgb(166 244 211 / 24%); border-radius: 50%; transform: translate(-50%, -50%); } +.orbit-a { width: 230px; height: 230px; } +.orbit-b { width: 360px; height: 360px; border-color: rgb(255 143 112 / 15%); transform: translate(-50%, -50%) rotate(28deg) scaleY(.42); } +.hero-orb { position: absolute; top: calc(50% - 28px); left: calc(50% - 28px); width: 56px; height: 56px; border-radius: 18px; background: linear-gradient(145deg, var(--mint), #4acb9c); box-shadow: 0 0 0 8px rgb(166 244 211 / 10%), 0 24px 42px rgb(2 10 16 / 42%); } +.section-block { padding: 6rem 0; border-top: 1px solid var(--line-soft); } +.section-heading { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 2rem; align-items: end; margin-bottom: 2.5rem; } +.section-heading > p:last-child, .api-copy > p:not(.eyebrow), .proof-note p { max-width: 480px; margin-bottom: 0; color: var(--muted); line-height: 1.6; } +.proof-grid { display: grid; grid-template-columns: 1.18fr .82fr; gap: 1rem; } +.proof-card { min-width: 0; display: flex; flex-direction: column; padding: 1.2rem; border: 1px solid var(--line); border-radius: var(--radius-md); background: var(--surface); } +.proof-card-wide { grid-row: span 2; } +.card-heading { display: flex; justify-content: space-between; gap: 1rem; align-items: flex-start; min-height: 3.8rem; } +.card-kicker, .card-state { margin: 0 0 .55rem; color: var(--muted); font: .65rem "SFMono-Regular", Consolas, monospace; letter-spacing: .08em; text-transform: uppercase; } +.card-state { padding: .35rem .5rem; border: 1px solid var(--line); border-radius: 999px; white-space: nowrap; } +.card-state[data-kind="running"] { color: var(--mint); border-color: rgb(166 244 211 / 38%); } +.card-state[data-kind="complete"] { color: var(--coral); border-color: rgb(255 143 112 / 38%); } +.preview-canvas { position: relative; min-height: 210px; margin: 1.2rem 0; overflow: hidden; border: 1px solid var(--line-soft); border-radius: 14px; background: #0d1115; } +.spring-canvas { min-height: 280px; } +.proof-card-wide .spring-canvas { flex: 1 1 auto; } +.track-line, .retarget-track { position: absolute; top: 50%; right: 12%; left: 12%; height: 1px; background: var(--line); } +.track-line::after, .retarget-track::after { position: absolute; top: 50%; right: 0; width: 7px; height: 7px; content: ""; border-top: 1px solid var(--mint); border-right: 1px solid var(--mint); transform: translateY(-50%) rotate(45deg); } +.spring-object, .retarget-object { position: absolute; top: calc(50% - 24px); left: calc(12% - 24px); width: 48px; height: 48px; border: 1px solid var(--mint); border-radius: 14px; background: rgb(166 244 211 / 10%); box-shadow: 0 0 0 7px rgb(166 244 211 / 6%); } +.spring-object::after, .retarget-object::after { position: absolute; inset: 13px; content: ""; border-radius: 6px; background: var(--mint); } +.track-label { position: absolute; top: calc(50% + 34px); color: var(--quiet); font: .65rem "SFMono-Regular", Consolas, monospace; } +.track-label-start { left: 12%; } +.track-label-end { right: 12%; } +.card-footer { display: flex; justify-content: space-between; gap: 1rem; align-items: end; margin-top: auto; } +.card-footer p { max-width: 360px; margin: 0; color: var(--muted); font-size: .78rem; line-height: 1.5; } +.card-footer-stack { display: block; } +.card-footer-stack .button-row { margin-top: 1rem; } +.stagger-canvas { display: grid; place-items: center; } +.stagger-grid { display: grid; grid-template-columns: repeat(5, 16px); gap: 13px; } +.stagger-item { display: block; width: 16px; height: 16px; border-radius: 6px; background: var(--mint); box-shadow: 0 0 0 5px rgb(166 244 211 / 8%); } +.retarget-canvas { min-height: 210px; } +.retarget-object { border-color: var(--coral); background: rgb(255 143 112 / 10%); } +.retarget-object::after { background: var(--coral); } +.retarget-marker { position: absolute; top: calc(50% - 4px); width: 8px; height: 8px; border: 1px solid var(--coral); border-radius: 50%; } +.marker-left { left: 12%; } +.marker-right { right: 12%; } +.api-block { display: grid; grid-template-columns: minmax(0, .85fr) minmax(0, 1.15fr); gap: clamp(2rem, 8vw, 8rem); align-items: center; } +.text-link { display: inline-flex; gap: .45rem; margin-top: 1.6rem; color: var(--mint); font-size: .82rem; font-weight: 700; } +.code-card { min-width: 0; overflow: hidden; border: 1px solid var(--line); border-radius: var(--radius-md); background: #0e1216; box-shadow: var(--shadow); } +.code-card-bar { display: flex; justify-content: space-between; align-items: center; padding: .7rem .85rem; border-bottom: 1px solid var(--line-soft); color: var(--muted); font: .68rem "SFMono-Regular", Consolas, monospace; } +.copy-button { min-height: 3rem; padding: .35rem .7rem; font-size: .7rem; } +.copy-button:hover { color: var(--text); border-color: var(--mint); } +pre { overflow-x: auto; margin: 0; padding: 1.2rem; color: #d8e3de; font-size: clamp(.72rem, 1.5vw, .84rem); line-height: 1.75; } +.syntax-keyword { color: #d7a7ff; } +.syntax-string { color: var(--mint); } +.syntax-number { color: var(--coral); } +.copy-status { min-height: 1.2rem; margin: 0; padding: 0 1.2rem .9rem; color: var(--muted); font-size: .68rem; } +.proof-note { display: grid; grid-template-columns: minmax(0, .75fr) minmax(0, 1.25fr); gap: 3rem; align-items: start; } +.evidence-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: .75rem; } +.evidence-link { display: grid; gap: .65rem; min-height: 130px; padding: 1rem; border: 1px solid var(--line); border-radius: 14px; color: var(--muted); font-size: .78rem; line-height: 1.4; } +.evidence-link:hover, .evidence-link:focus-visible { color: var(--text); border-color: var(--mint); } +.evidence-link > span:last-child { align-self: end; color: var(--mint); } +.evidence-label { color: var(--text); font-weight: 700; } +.site-footer { display: flex; justify-content: space-between; gap: 1rem; padding: 1.5rem 0 2.5rem; color: var(--muted); font-size: .7rem; } + +html[data-motion="reduced"] { scroll-behavior: auto; } +html[data-motion="reduced"] *, +html[data-motion="reduced"] *::before, +html[data-motion="reduced"] *::after { animation-duration: .001ms !important; animation-iteration-count: 1 !important; transition-duration: .001ms !important; scroll-behavior: auto !important; } +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation-duration: .001ms !important; animation-iteration-count: 1 !important; transition-duration: .001ms !important; scroll-behavior: auto !important; } +} + +@media (max-width: 860px) { + .hero, .api-block, .proof-note { grid-template-columns: 1fr; } + .hero { min-height: auto; padding-top: 4rem; } + .hero-stage { min-height: 360px; } + .section-heading { grid-template-columns: 1fr; gap: 1rem; } + .proof-grid { grid-template-columns: 1fr; } + .proof-card-wide { grid-row: auto; } +} + +@media (max-width: 620px) { + .section-shell, .site-header { width: min(calc(100% - 1.5rem), var(--content)); } + .site-header { flex-wrap: wrap; } + nav { width: 100%; justify-content: space-between; gap: .5rem; } + .hero { padding: 3.5rem 0 4.5rem; } + h1 { font-size: clamp(3.25rem, 17vw, 5.5rem); } + .hero-stage { min-height: 300px; } + .section-block { padding: 4.5rem 0; } + .card-footer, .site-footer { align-items: flex-start; flex-direction: column; } + .evidence-grid { grid-template-columns: 1fr; } + .evidence-link { min-height: 100px; } +} diff --git a/site/vite.config.mjs b/site/vite.config.mjs new file mode 100644 index 00000000..036e2357 --- /dev/null +++ b/site/vite.config.mjs @@ -0,0 +1,8 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + base: './', + build: { + modulePreload: { polyfill: false }, + }, +}); diff --git a/src/animate/channels.ts b/src/animate/channels.ts index 96ce498e..ddca473a 100644 --- a/src/animate/channels.ts +++ b/src/animate/channels.ts @@ -42,7 +42,7 @@ const TRANSFORM_IDENTITY: Readonly> = { * (а не `key in`) отсекает УНАСЛЕДОВАННЫЕ constructor/toString/__proto__: они * функции/объект, не число — иначе классифицировались бы как transform-канал. */ -export function isTransformKey(key: string): boolean { +function isTransformKey(key: string): boolean { return typeof TRANSFORM_IDENTITY[key] === 'number'; } @@ -410,7 +410,7 @@ export interface AnimatableElement { } /** Читает текущее значение свойства: inline → computed (если среда умеет). */ -export function readStyleValue(el: AnimatableElement, cssName: string): string { +function readStyleValue(el: AnimatableElement, cssName: string): string { try { const inline = el.style.getPropertyValue(cssName); if (inline !== '') return inline; @@ -476,20 +476,6 @@ export function cssAt(ch: CssChannel, p: number): string | number { return interpolateParsed(ch._fromAst, ch._toAst, p); } -/** - * SSOT сериализации узкой numeric-поверхности. Вызов допустим только после - * доказанной topology: transform содержит ровно `x` без residual-каналов, - * иначе нужен общий buildTransform. - */ -export function formatSingleNumericSurface( - transformX: boolean, - value: number, -): string { - return transformX - ? value === 0 ? 'none' : `translateX(${value}px)` - : String(value); -} - // ─── Привязка группы к элементу (from-резолв + подхват прерывания) ─────────── /** Каналы группы, привязанные к элементу, + остаточное transform-состояние. */ diff --git a/src/compiler/core.ts b/src/compiler/core.ts index 35916f9c..8d42b3c1 100644 --- a/src/compiler/core.ts +++ b/src/compiler/core.ts @@ -140,7 +140,7 @@ export interface NanoLoweringPlan { const NANO_SOURCE = '@labpics/motion/nano'; export const COMPILED_IMPORT_SOURCE = '@labpics/motion/compiler/runtime'; -export const COMPILED_IMPORT_NAME = 'animateCompiled'; +const COMPILED_IMPORT_NAME = 'animateCompiled'; const IMPORT_LOCAL = '__labMotionNanoCompiled'; function walk(node: unknown, visit: (node: AstNode, parent: AstNode | undefined) => void, parent?: AstNode): void { @@ -490,7 +490,7 @@ export function lowerSurfaceCall(input: SurfaceCallInput): SurfaceLoweringResult // ─── Surface lowering: build-time сертификат и план байтовых правок ────────── export const SURFACE_IMPORT_SOURCE = '@labpics/motion/compiler/surface'; -export const SURFACE_IMPORT_NAME = 'runSurface'; +const SURFACE_IMPORT_NAME = 'runSurface'; const SURFACE_LOCAL = '__labMotionSurface'; const ANIMATE_SOURCE = '@labpics/motion/animate'; @@ -536,7 +536,7 @@ export function surfaceArtifactLiteral(program: SurfaceProgram): string | undefi * разные значения на одной позиции — разрыв, который не является доменным * контрактом поверхности. */ -export function hasConflictingAdjacentStops(cssLinear: string): boolean { +function hasConflictingAdjacentStops(cssLinear: string): boolean { let previous: readonly string[] = []; for (const stop of cssLinear.slice(cssLinear.indexOf('(') + 1, -1).split(',')) { const pair = stop.trim().split(' '); diff --git a/src/drive.ts b/src/drive.ts index b7d0b506..74f79c2e 100644 --- a/src/drive.ts +++ b/src/drive.ts @@ -314,9 +314,6 @@ export function drive(opts: DriveOptions): Promise { // If the injected clock returns 0 without invoking its callback (the // documented non-draining step-clock convention), install a setTimeout(0) // fallback NOW — before tick() has ever run — so the Promise always resolves. - // This is the fix for the deadlock: the bootstrap handle was previously - // discarded, so the handle=0 detection inside tick() was never reached. - // // useTimeoutFallback is set before setTimeout fires, so tick() always reads // the correct scheduler on its first (and every subsequent) invocation. let useTimeoutFallback = false; diff --git a/test/easing-determinism.test.ts b/test/easing-determinism.test.ts index fc255ea6..91b2ab8e 100644 --- a/test/easing-determinism.test.ts +++ b/test/easing-determinism.test.ts @@ -85,8 +85,12 @@ describe('easing determinism — NE4', () => { assertDeterministic('normalizeEasing(linear)', normalizeEasing(linear)); }); - it('normalizeEasing(hostile t=>Math.random()) is NOT asserted deterministic — purity test is per-function', () => { - expect(true).toBe(true); + it('normalizeEasing(hostile t=>Math.random()) returns a callable easing without throwing', () => { + const hostile = normalizeEasing((t: number) => Math.random()); + expect(typeof hostile).toBe('function'); + expect(() => hostile(0)).not.toThrow(); + expect(() => hostile(0.5)).not.toThrow(); + expect(() => hostile(1)).not.toThrow(); }); it('linear has no DOM/clock/window references — pure static import check', () => { diff --git a/test/showcase-build-contract.test.ts b/test/showcase-build-contract.test.ts new file mode 100644 index 00000000..a2cb0f8d --- /dev/null +++ b/test/showcase-build-contract.test.ts @@ -0,0 +1,41 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +const showcase = readFileSync( + new URL('../site/src/scripts/showcase.js', import.meta.url), + 'utf8', +); +const viteConfig = readFileSync( + new URL('../site/vite.config.mjs', import.meta.url), + 'utf8', +); +const siteHtml = readFileSync( + new URL('../site/index.html', import.meta.url), + 'utf8', +); +const pkg = JSON.parse( + readFileSync(new URL('../package.json', import.meta.url), 'utf8'), +) as { + scripts: Record; + devDependencies: Record; +}; + +describe('showcase build contract', () => { + it('consumes the public animate export instead of an internal dist path', () => { + expect(showcase).toContain("from '@labpics/motion/animate'"); + expect(showcase).not.toContain('dist/animate/index.js'); + }); + + it('builds the public package before compiling the static consumer', () => { + expect(pkg.scripts['site:build']).toBe('pnpm build && vite build --config site/vite.config.mjs site'); + expect(pkg.scripts['site:build']).not.toContain('/site/dist'); + expect(viteConfig).toContain("base: './'"); + expect(viteConfig).toContain('modulePreload: { polyfill: false }'); + expect(pkg.devDependencies.astro).toBeUndefined(); + }); + + it('keeps the zero-connect CSP compatible with the generated bootstrap', () => { + expect(siteHtml).toContain("connect-src 'none'"); + expect(viteConfig).toContain('modulePreload: { polyfill: false }'); + }); +}); diff --git a/test/showcase-lifecycle.test.ts b/test/showcase-lifecycle.test.ts new file mode 100644 index 00000000..f68f84cb --- /dev/null +++ b/test/showcase-lifecycle.test.ts @@ -0,0 +1,162 @@ +// @vitest-environment jsdom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { animateMock } = vi.hoisted(() => ({ animateMock: vi.fn() })); + +vi.mock('@labpics/motion/animate', () => ({ animate: animateMock })); + +type Controls = { + cancel: ReturnType; + finished: Promise; +}; + +let activeDispose: (() => void) | undefined; +let hidden = false; +let mediaListeners: Set<() => void>; +let controls: Controls[]; +let observerEntries: Array<(entries: Array<{ isIntersecting: boolean }>) => void>; +let observerDisconnects: number; + +function fixture(): void { + document.body.innerHTML = ` + +

+
ready
+
ready
+
ready
+
+
+ +
+

+ + + + + + example +

+ `; +} + +beforeEach(() => { + fixture(); + hidden = false; + mediaListeners = new Set(); + controls = []; + observerEntries = []; + observerDisconnects = 0; + Object.defineProperty(document, 'hidden', { + configurable: true, + get: () => hidden, + }); + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: () => ({ + matches: false, + addEventListener: (_type: string, listener: () => void) => mediaListeners.add(listener), + removeEventListener: (_type: string, listener: () => void) => mediaListeners.delete(listener), + }), + }); + Object.defineProperty(globalThis, 'IntersectionObserver', { + configurable: true, + value: class { + constructor(callback: (entries: Array<{ isIntersecting: boolean }>) => void) { + observerEntries.push(callback); + } + + observe(): void { + observerEntries.at(-1)?.([{ isIntersecting: true }]); + } + + disconnect(): void { + observerDisconnects += 1; + } + }, + }); + animateMock.mockReset(); + animateMock.mockImplementation(() => { + const value: Controls = { cancel: vi.fn(), finished: new Promise(() => {}) }; + controls.push(value); + return value; + }); +}); + +afterEach(() => { + activeDispose?.(); + activeDispose = undefined; + vi.restoreAllMocks(); +}); + +describe('showcase lifecycle ownership', () => { + it('reinstall replaces listeners and disposer makes the runtime inert', async () => { + const { installShowcase } = await import('../site/src/scripts/showcase.js'); + const firstDispose = installShowcase(); + expect(animateMock).toHaveBeenCalledTimes(3); + + activeDispose = installShowcase(); + expect(firstDispose).toBeTypeOf('function'); + expect(activeDispose).toBeTypeOf('function'); + expect(controls.slice(0, 3).every((value) => value.cancel.mock.calls.length === 1)).toBe(true); + expect(mediaListeners.size).toBe(1); + + animateMock.mockClear(); + document.querySelector('[data-action="toggle-motion"]')!.click(); + expect(animateMock).toHaveBeenCalledTimes(3); + + activeDispose!(); + activeDispose = undefined; + animateMock.mockClear(); + document.querySelector('[data-action="toggle-motion"]')!.click(); + expect(animateMock).not.toHaveBeenCalled(); + expect(mediaListeners.size).toBe(0); + }); + + it('stops work while hidden and restarts one preview set when visible', async () => { + const { installShowcase } = await import('../site/src/scripts/showcase.js'); + activeDispose = installShowcase(); + const initial = [...controls]; + + hidden = true; + document.dispatchEvent(new Event('visibilitychange')); + expect(initial.every((value) => value.cancel.mock.calls.length === 1)).toBe(true); + + animateMock.mockClear(); + hidden = false; + document.dispatchEvent(new Event('visibilitychange')); + expect(animateMock).toHaveBeenCalledTimes(3); + }); + + it('stops the hero when its stage leaves the viewport and disconnects the observer', async () => { + const { installShowcase } = await import('../site/src/scripts/showcase.js'); + activeDispose = installShowcase(); + expect(observerEntries).toHaveLength(1); + const initialHero = controls[0]!; + + observerEntries[0]!([{ isIntersecting: false }]); + expect(initialHero.cancel).toHaveBeenCalledTimes(1); + + observerEntries[0]!([{ isIntersecting: true }]); + expect(animateMock).toHaveBeenCalledTimes(4); + + activeDispose!(); + expect(observerDisconnects).toBeGreaterThan(0); + }); + + it('ignores late finished notifications after disposal', async () => { + let resolveFinished!: () => void; + const finished = new Promise((resolve) => { resolveFinished = resolve; }); + animateMock.mockImplementationOnce(() => { + const value: Controls = { cancel: vi.fn(), finished }; + controls.push(value); + return value; + }); + const { installShowcase } = await import('../site/src/scripts/showcase.js'); + activeDispose = installShowcase(); + activeDispose(); + resolveFinished(); + await Promise.resolve(); + expect(document.querySelector('[data-card="spring"] [data-state]')?.textContent).toBe('running'); + }); +}); diff --git a/test/stagger-reduced-motion.test.ts b/test/stagger-reduced-motion.test.ts deleted file mode 100644 index 5ff04e89..00000000 --- a/test/stagger-reduced-motion.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -/** - * test/stagger-reduced-motion.test.ts — reduced-motion CHARACTER-switch for stagger() - * - * Invariant ST3 (northInvariant #5): - * When reducedMotion=true, all delays collapse to 0. - * Items still animate — they just start simultaneously with no stagger offset. - * This is a CHARACTER change (zero offset = instant cascade), NOT hard-off. - * - * TDD RED-proof: - * 1. Remove the `if (options?.reducedMotion === true) { return [...].fill(0) }` - * block in src/stagger/index.ts. - * 2. Run: pnpm test test/stagger-reduced-motion.test.ts - * 3. Every test in 'stagger — reduced-motion: CHARACTER-switch' MUST fail. - * 4. Restore → GREEN. - * - * Mutation proof: - * - Replacing `fill(0)` with `fill(gap)` → delays non-zero → toBe(0) fails. - * - Short-circuiting before the reducedMotion check → normal delays returned → fails. - * - * Test classes: - * A (Unit): reducedMotion=true collapses delays - * C (Property): CHARACTER invariant over a range of inputs - * D (Mutation proof): documented per test - */ - -import { describe, expect, it } from 'vitest'; -import { stagger } from '../src/stagger/index.js'; - -// --------------------------------------------------------------------------- -// Core CHARACTER-switch tests -// --------------------------------------------------------------------------- - -describe('stagger — reduced-motion: CHARACTER-switch (ST3)', () => { - it('A: reducedMotion=true → all delays are 0 (from=first)', () => { - // Mutation proof: remove fill(0) branch → returns normal stagger delays → fails - const result = stagger(5, { reducedMotion: true }); - expect(result).toHaveLength(5); - for (const d of result) { - expect(d).toBe(0); - } - }); - - it('A: reducedMotion=true → all delays are 0 (from=last)', () => { - const result = stagger(5, { from: 'last', reducedMotion: true }); - expect(result).toHaveLength(5); - for (const d of result) { - expect(d).toBe(0); - } - }); - - it('A: reducedMotion=true → all delays are 0 (from=center)', () => { - const result = stagger(7, { from: 'center', reducedMotion: true }); - expect(result).toHaveLength(7); - for (const d of result) { - expect(d).toBe(0); - } - }); - - it('A: reducedMotion=true → all delays are 0 (from=edges)', () => { - const result = stagger(6, { from: 'edges', reducedMotion: true }); - expect(result).toHaveLength(6); - for (const d of result) { - expect(d).toBe(0); - } - }); - - it('A: reducedMotion=true → all delays are 0 (from=number)', () => { - const result = stagger(5, { from: 2, reducedMotion: true }); - expect(result).toHaveLength(5); - for (const d of result) { - expect(d).toBe(0); - } - }); - - it('A: reducedMotion=true overrides large gap', () => { - // Even with gap=1000, all delays should be 0 - const result = stagger(5, { gap: 1000, reducedMotion: true }); - for (const d of result) { - expect(d).toBe(0); - } - }); - - it('A: reducedMotion=true overrides custom easing', () => { - // Even with exotic easing, all delays should be 0 - const result = stagger(5, { - easing: (t) => t * t * t, - reducedMotion: true, - }); - for (const d of result) { - expect(d).toBe(0); - } - }); - - it('A: reducedMotion=true with grid → all delays are 0', () => { - const result = stagger(9, { grid: { columns: 3 }, reducedMotion: true }); - expect(result).toHaveLength(9); - for (const d of result) { - expect(d).toBe(0); - } - }); -}); - -// --------------------------------------------------------------------------- -// CHARACTER = snap-to-start, NOT hard-off -// Delay=0 means items start simultaneously; they still animate to their targets. -// This test characterizes the contract (structural, not behavioral — stagger is -// pure math; the caller drives animation). -// --------------------------------------------------------------------------- - -describe('stagger — reduced-motion: CHARACTER not hard-off', () => { - it('C: reduced-motion result is an array (not undefined/null = not hard-off)', () => { - // Mutation proof: returning undefined/null would break caller iteration - const result = stagger(5, { reducedMotion: true }); - expect(Array.isArray(result)).toBe(true); - expect(result).not.toBeNull(); - }); - - it('C: reduced-motion result has the same length as count (all items present)', () => { - // All items are still "in the group" — they just start at t=0 - // Mutation proof: filtering items out (shorter array) would be hard-off - for (const n of [1, 2, 5, 10, 100]) { - const result = stagger(n, { reducedMotion: true }); - expect(result).toHaveLength(n); - } - }); - - it('C: reduced-motion result has all zeros (items start simultaneously)', () => { - // Simultaneous start = CHARACTER-switch (no stagger offset) - // Mutation proof: returning non-zero delays → CHARACTER not changed - const result = stagger(10, { gap: 200, from: 'center', reducedMotion: true }); - const allZero = result.every((d) => d === 0); - expect(allZero).toBe(true); - }); - - it('C: full-motion stagger (reducedMotion=false) has non-zero delays', () => { - // Negative test: without reduced-motion, delays are NOT all zero - const result = stagger(5, { gap: 50, reducedMotion: false }); - const someNonZero = result.some((d) => d > 0); - expect(someNonZero).toBe(true); - }); - - it('C: reducedMotion=false gives same result as omitting reducedMotion', () => { - // Default (undefined) and false should behave identically - const withFalse = stagger(5, { gap: 80, from: 'center', reducedMotion: false }); - const withUndefined = stagger(5, { gap: 80, from: 'center' }); - expect(withFalse).toEqual(withUndefined); - }); -}); - -// --------------------------------------------------------------------------- -// Edge counts in reduced-motion mode -// --------------------------------------------------------------------------- - -describe('stagger — reduced-motion: edge counts', () => { - it('A: count=0, reducedMotion=true → [] (empty, no element = not hard-off)', () => { - expect(stagger(0, { reducedMotion: true })).toEqual([]); - }); - - it('A: count=1, reducedMotion=true → [0]', () => { - const result = stagger(1, { reducedMotion: true }); - expect(result).toEqual([0]); - }); - - it('A: count=2, reducedMotion=true → [0, 0]', () => { - const result = stagger(2, { reducedMotion: true }); - expect(result).toEqual([0, 0]); - }); - - it('A: very large count, reducedMotion=true → all zeros', () => { - const n = 500; - const result = stagger(n, { reducedMotion: true }); - expect(result).toHaveLength(n); - expect(result.every((d) => d === 0)).toBe(true); - }); -}); diff --git a/test/vue.test.ts b/test/vue.test.ts index 22e42c08..8cafac24 100644 --- a/test/vue.test.ts +++ b/test/vue.test.ts @@ -469,13 +469,12 @@ describe('vMotion directive — mounted lifecycle', () => { // After unmount, setting a new target should not be possible; the mv is destroyed. // We verify via the updated hook — it should be a no-op after unmount. - const countBefore = 0; // element style cleared by unmount + const styleAfterUnmount = el.style.cssText; vMotion.updated!(el as Element, { value: { target: 99, property: 'opacity', requestFrame: clock.requestFrame }, } as any, null as any, null as any); clock.drainAll(); - // No assertion about exact count — just verify no throw and it's safe. - expect(true).toBe(true); // structural: no crash + expect(el.style.cssText).toBe(styleAfterUnmount); }); });