diff --git a/changelog.d/fix-motion-minified-duration-tokens.md b/changelog.d/fix-motion-minified-duration-tokens.md new file mode 100644 index 0000000..4f00811 --- /dev/null +++ b/changelog.d/fix-motion-minified-duration-tokens.md @@ -0,0 +1,7 @@ +Fixed the blank home page hero on GitHub Pages. The production CSS minifier +rewrites motion duration tokens such as `100ms` as `.1s`, and +`motionDuration()` (`src/hooks/useMotion.ts`) rejected the leading dot, so the +hero animation threw after hiding its targets and left the headline, subtitle +and call-to-action at opacity 0 (since #189). The parser now accepts the +minified form, and `useMotion` fails open: if an animation setup throws, it +reverts the GSAP context so the content is shown without animation. diff --git a/src/hooks/useMotion.test.tsx b/src/hooks/useMotion.test.tsx index 80e8fa7..e277b7e 100644 --- a/src/hooks/useMotion.test.tsx +++ b/src/hooks/useMotion.test.tsx @@ -1,7 +1,7 @@ import { act, render } from '@testing-library/react'; import { useCallback, useRef } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { useMotion } from './useMotion'; +import { motionDuration, useMotion } from './useMotion'; const mocks = vi.hoisted(() => ({ context: vi.fn(), @@ -71,4 +71,48 @@ describe('useMotion', () => { expect(cleanup).toHaveBeenCalledOnce(); expect(mocks.context.mock.results[0]?.value.revert).toHaveBeenCalledOnce(); }); + + it('reverts the context when setup throws, so hidden targets are shown again', async () => { + mocks.noPreference = true; + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + const setup = vi.fn(() => { + throw new Error('Invalid motion duration token: --motion-duration-fast'); + }); + render(); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(setup).toHaveBeenCalledOnce(); + expect(mocks.context.mock.results[0]?.value.revert).toHaveBeenCalledOnce(); + expect(consoleError).toHaveBeenCalledWith( + 'Motion setup failed; showing content without animation', + expect.any(Error), + ); + consoleError.mockRestore(); + }); +}); + +describe('motionDuration', () => { + const root = document.documentElement; + const token = '--motion-duration-test' as const; + + it.each([ + ['100ms', 0.1], + ['.1s', 0.1], + ['.5s', 0.5], + ['0.3s', 0.3], + ['1.4s', 1.4], + ['50ms', 0.05], + ['1s', 1], + ])('parses %s (source and minified forms) as %s seconds', (value, seconds) => { + root.style.setProperty(token, value); + expect(motionDuration(token)).toBeCloseTo(seconds); + }); + + it.each(['', 'fast', '1.s', '-1s', '100'])('rejects %j', (value) => { + root.style.setProperty(token, value); + expect(() => motionDuration(token)).toThrow('Invalid motion duration token'); + }); }); diff --git a/src/hooks/useMotion.ts b/src/hooks/useMotion.ts index 3e56582..28860ee 100644 --- a/src/hooks/useMotion.ts +++ b/src/hooks/useMotion.ts @@ -19,13 +19,29 @@ export function useMotion( .then(({ gsap }) => { if (cancelled) return; const media = gsap.matchMedia(); + let setupError: unknown; const context = gsap.context(() => { - media.add('(prefers-reduced-motion: no-preference)', () => setup(gsap, scope)); + media.add('(prefers-reduced-motion: no-preference)', () => { + try { + return setup(gsap, scope); + } catch (error) { + setupError = error; + } + }); }, scope); revert = () => { media.revert(); context.revert(); }; + // Fail open: a setup usually hides its targets (gsap.set opacity 0) + // before animating them in, so a setup that throws part-way would leave + // the content invisible. Reverting the context undoes those sets. + if (setupError !== undefined) { + console.error('Motion setup failed; showing content without animation', setupError); + revert(); + revert = undefined; + return; + } if (cancelled) revert(); }) .catch((error: unknown) => { @@ -43,7 +59,9 @@ export function motionDuration( token: `--motion-duration-${string}`, ): number { const value = getComputedStyle(document.documentElement).getPropertyValue(token).trim(); - const match = /^(\d+(?:\.\d+)?)(ms|s)$/.exec(value); + // Accepts a leading dot: the production CSS minifier rewrites `100ms` as + // `.1s`, and rejecting that left the home page hero invisible on Pages. + const match = /^(\d*\.?\d+)(ms|s)$/.exec(value); if (!match) throw new Error(`Invalid motion duration token: ${token}`); const duration = Number(match[1]); return match[2] === 'ms' ? duration / 1000 : duration;