From 773061a9e4b619d7e8063ea98e0fa564ac044571 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 14:43:16 -0700 Subject: [PATCH] feat(v2): motion tokens, reduced-motion switch, theme crossfade, fold height (TASK-700) Carries swift's slice 1 from #3075 onto main. Tokens are the --v2-dur set plus one ease; one global prefers-reduced-motion rule ends every v2 animation and transition. The theme switch crossfades through View Transitions where supported. The inactive fold animates its height on open and close, and stays inert while it closes. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: flint Agent-Model: claude/claude-haiku-5-5/default --- .../src/v2/__tests__/V2InactiveFold.test.tsx | 7 +++ frontend/src/v2/components/V2InactiveFold.tsx | 25 +++++++- frontend/src/v2/utils/__tests__/theme.test.ts | 38 ++++++++++++ frontend/src/v2/utils/theme.ts | 10 +++ frontend/src/v2/v2.css | 62 ++++++++++++++++++- 5 files changed, 139 insertions(+), 3 deletions(-) diff --git a/frontend/src/v2/__tests__/V2InactiveFold.test.tsx b/frontend/src/v2/__tests__/V2InactiveFold.test.tsx index 76bc621aff..806f4a71ca 100644 --- a/frontend/src/v2/__tests__/V2InactiveFold.test.tsx +++ b/frontend/src/v2/__tests__/V2InactiveFold.test.tsx @@ -20,6 +20,13 @@ test('inactive rows are hidden until the shared count fold opens', async () => { expect(screen.getByText('Expired grant')).toBeInTheDocument(); fireEvent.click(toggle); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + // The rows stay mounted, inert, until the close animation ends. + const body = screen.getByText('Revoked grant').closest('.v2-connectors__inactive-fold-body'); + expect(body).toHaveClass('v2-connectors__inactive-fold-body--closing'); + expect(body).toHaveAttribute('inert'); + + fireEvent.animationEnd(body as Element); expect(screen.queryByText('Revoked grant')).not.toBeInTheDocument(); }); diff --git a/frontend/src/v2/components/V2InactiveFold.tsx b/frontend/src/v2/components/V2InactiveFold.tsx index 589ce2ca8e..d5ca11386f 100644 --- a/frontend/src/v2/components/V2InactiveFold.tsx +++ b/frontend/src/v2/components/V2InactiveFold.tsx @@ -10,7 +10,10 @@ type V2InactiveFoldProps = { const V2InactiveFold: React.FC = ({ count, children }) => { const { t } = useTranslation(); const [open, setOpen] = useState(false); + // A closing fold stays mounted until its close animation ends, then unmounts. It is inert while it closes. + const [closing, setClosing] = useState(false); if (count === 0) return null; + const showBody = open || closing; return (
@@ -18,12 +21,30 @@ const V2InactiveFold: React.FC = ({ count, children }) => { type="button" className="v2-connectors__inactive-toggle" aria-expanded={open} - onClick={() => setOpen((wasOpen) => !wasOpen)} + onClick={() => { + if (open) { + setOpen(false); + setClosing(true); + } else { + setClosing(false); + setOpen(true); + } + }} >
); }; diff --git a/frontend/src/v2/utils/__tests__/theme.test.ts b/frontend/src/v2/utils/__tests__/theme.test.ts index 6fafb97b1c..252bd8fa21 100644 --- a/frontend/src/v2/utils/__tests__/theme.test.ts +++ b/frontend/src/v2/utils/__tests__/theme.test.ts @@ -49,6 +49,44 @@ describe('v2 appearance preference (D216)', () => { expect(decls(explicit![1])).toContain('--v2-focus-ring: 0 0 0 2px var(--v2-bg), 0 0 0 4px #8aa2ff'); }); + test('setV2Theme crossfades inside a view transition when the browser supports it (TASK-700)', () => { + const doc = document as Document & { startViewTransition?: (cb: () => void) => unknown }; + const startViewTransition = jest.fn((cb: () => void) => cb()); + doc.startViewTransition = startViewTransition as unknown as Document['startViewTransition']; + try { + setV2Theme('dark'); + expect(startViewTransition).toHaveBeenCalledTimes(1); + expect(document.documentElement.getAttribute('data-v2-theme')).toBe('dark'); + } finally { + delete doc.startViewTransition; + } + }); + + test('setV2Theme skips the crossfade under reduced motion and without the API', () => { + const doc = document as Document & { startViewTransition?: (cb: () => void) => unknown }; + const startViewTransition = jest.fn((cb: () => void) => cb()); + doc.startViewTransition = startViewTransition as unknown as Document['startViewTransition']; + const original = window.matchMedia; + window.matchMedia = jest.fn().mockImplementation((q: string) => ({ matches: q.includes('reduce'), media: q })) as unknown as typeof window.matchMedia; + try { + setV2Theme('light'); + expect(startViewTransition).not.toHaveBeenCalled(); + expect(document.documentElement.getAttribute('data-v2-theme')).toBe('light'); + } finally { + window.matchMedia = original; + delete doc.startViewTransition; + } + setV2Theme('dark'); + expect(document.documentElement.getAttribute('data-v2-theme')).toBe('dark'); + }); + + test('v2.css declares the motion tokens and one global reduced-motion switch (TASK-700)', () => { + expect(v2css).toContain('--v2-dur-fast: 120ms;'); + expect(v2css).toContain('--v2-dur: 200ms;'); + expect(v2css).toContain('--v2-dur-slow: 320ms;'); + expect(v2css).toMatch(/@media \(prefers-reduced-motion: reduce\) \{\n \*,[\s\S]*?animation-duration: 0\.01ms !important;/); + }); + test('index.html applies a stored Light or Dark choice before first paint (code-gate HOLD, rhea)', () => { const html = fs.readFileSync(path.join(__dirname, '../../../../index.html'), 'utf8'); const head = html.slice(0, html.indexOf('')); diff --git a/frontend/src/v2/utils/theme.ts b/frontend/src/v2/utils/theme.ts index 667dfd9c4b..e8d4af289e 100644 --- a/frontend/src/v2/utils/theme.ts +++ b/frontend/src/v2/utils/theme.ts @@ -19,11 +19,21 @@ export function applyV2Theme(pref: V2ThemePref): void { document.documentElement.setAttribute('data-v2-theme', pref); } +type ViewTransitionDocument = Document & { startViewTransition?: (update: () => void) => unknown }; + export function setV2Theme(pref: V2ThemePref): void { try { window.localStorage.setItem(V2_THEME_KEY, pref); } catch { // Storage can be blocked; the choice still applies for this page view. } + // The switch crossfades when the browser supports view transitions and the user has not asked + // for reduced motion. Everywhere else it applies at once. + const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; + const doc = document as ViewTransitionDocument; + if (doc.startViewTransition && !reduce) { + doc.startViewTransition(() => applyV2Theme(pref)); + return; + } applyV2Theme(pref); } diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css index 3014026a6d..cfbf5011a1 100644 --- a/frontend/src/v2/v2.css +++ b/frontend/src/v2/v2.css @@ -1,3 +1,15 @@ +/* + * Motion tokens (TASK-700). One ease curve and three durations, on :root so the + * view-transition pseudo-elements can read them. Animate transform and opacity; + * the height moments use grid rows, not a measured height. + */ +:root { + --v2-dur-fast: 120ms; + --v2-dur: 200ms; + --v2-dur-slow: 320ms; + --v2-ease: cubic-bezier(0.2, 0, 0, 1); +} + /* * V2 design tokens + base styles. All v2 styles are scoped under .v2-root * so the existing app's global CSS does not bleed in (and vice versa). @@ -6023,7 +6035,7 @@ body.modern-ui.v2-canvas { width: min(86vw, 320px); height: 100vh; transform: translateX(-100%); - transition: transform 0.22s ease; + transition: transform var(--v2-dur-slow) var(--v2-ease); box-shadow: 2px 0 24px rgba(15, 17, 21, 0.18); border-right: 1px solid var(--v2-border-soft); } @@ -13816,3 +13828,51 @@ body.modern-ui.v2-canvas { .v2-legal__section { margin-bottom: 28px; } .v2-legal__section h2 { font-size: 18px; font-weight: 600; margin: 0 0 8px; } .v2-legal__section p { font-size: 15px; line-height: 1.6; color: var(--v2-text-secondary); margin: 0 0 8px; } + +/* ---------- Motion (TASK-700) ---------- */ + +/* Theme switch: utils/theme.ts runs the change inside document.startViewTransition, and the + root snapshots crossfade on the motion tokens. Browsers without the API switch at once. */ +::view-transition-old(root), +::view-transition-new(root) { + animation-duration: var(--v2-dur); + animation-timing-function: var(--v2-ease); +} + +/* Folds open by growing their grid row from 0fr to 1fr, so the height animates without a measured + pixel value. The rows fade in on the same curve. */ +@keyframes v2-fold-open { + from { grid-template-rows: 0fr; opacity: 0; } + to { grid-template-rows: 1fr; opacity: 1; } +} +@keyframes v2-fold-close { + from { grid-template-rows: 1fr; opacity: 1; } + to { grid-template-rows: 0fr; opacity: 0; } +} +.v2-connectors__inactive-fold-body { + display: grid; + animation: v2-fold-open var(--v2-dur) var(--v2-ease) both; +} +.v2-connectors__inactive-fold-body--closing { + animation-name: v2-fold-close; +} +.v2-connectors__inactive-fold-body > .v2-connectors__inactive-rows { + min-height: 0; + overflow: hidden; +} + +/* One global switch for reduced motion: every v2 animation and transition ends at once, and the + theme change does not crossfade. */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } + ::view-transition-old(root), + ::view-transition-new(root) { + animation: none !important; + } +}