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;