Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions frontend/src/v2/__tests__/V2InactiveFold.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand Down
25 changes: 23 additions & 2 deletions frontend/src/v2/components/V2InactiveFold.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,41 @@ type V2InactiveFoldProps = {
const V2InactiveFold: React.FC<V2InactiveFoldProps> = ({ 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 (
<div className="v2-connectors__inactive-fold">
<button
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);
}
}}
>
<span className="v2-connectors__inactive-caret" aria-hidden="true" />
{t('connectors.inactiveFold', { defaultValue: 'inactive · {{n}}', n: count })}
</button>
{open && <div className="v2-connectors__inactive-rows">{children}</div>}
{showBody && (
<div
className={`v2-connectors__inactive-fold-body${closing ? ' v2-connectors__inactive-fold-body--closing' : ''}`}
{...(closing ? { inert: '' } : {})}
onAnimationEnd={(event) => {
if (event.target === event.currentTarget && closing) setClosing(false);
}}
>
<div className="v2-connectors__inactive-rows">{children}</div>
</div>
)}
</div>
);
};
Expand Down
38 changes: 38 additions & 0 deletions frontend/src/v2/utils/__tests__/theme.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('</head>'));
Expand Down
10 changes: 10 additions & 0 deletions frontend/src/v2/utils/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
62 changes: 61 additions & 1 deletion frontend/src/v2/v2.css
Original file line number Diff line number Diff line change
@@ -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).
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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;
}
}
Loading