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; + } +}