Skip to content
Merged
26 changes: 25 additions & 1 deletion docs/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,31 @@ recommends, and surfaces these in the daily brief. Run `/decide` to clear them.

## Open

Nothing open.
### D-061 — 3D tilt on the overall score card (#751 phase 5) · raised 2026-09-29
**Question** — #751 phase 5 lists "`3d-tilt` reserved for high-value visual elements (e.g.
overall score card)". Do we ship it?

**Recommendation — B, drop it.** art-director and ux-designer reached the same answer
independently:
- The tilt skews the score arc, which is the data itself, on the one figure a CISO reads at
a glance and an auditor screenshots.
- A card that follows the pointer signals "the whole card is clickable". Only the button
inside `ScoreGauge` is.
- It works only with a mouse, which is a second rendering path with no keyboard or touch
equivalent, and pointer-tracked parallax is a vestibular trigger.
- It would touch `ScoreGauge`, which #824 (phase 3) owns.

**Options**
- **A — ship a restrained tilt.** At most 2° per axis and an 800px perspective, only on
`(hover:hover) and (pointer:fine)` under `motion-safe`, with no glare and no moving
shadow. It goes on the `DashboardPage.tsx` wrapper, never inside `ScoreGauge`. About half
a day, plus a live pass.
- **B — drop it (recommended).** The item is closed as "evaluated, rejected" on #751.

**Cost of delay** — none. Phase 5 ships without it, and A can be added later without
rework.

**Raised by** — #751 phase 5 (art-director + ux-designer specs, 2026-09-29).

## Resolved

Expand Down
36 changes: 30 additions & 6 deletions frontend/src/components/layout/AppHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -216,12 +216,36 @@ export const AppHeader = ({ onOpenMobileNav }: AppHeaderProps) => {
{notifMounted && <NotifPanel open={notifOpen} onClose={() => setNotifOpen(false)} />}
</div>

<button onClick={toggleTheme} className={iconBtn} title="Theme" aria-label="Toggle theme">
{theme === 'dark' ? (
<Sun size={18} strokeWidth={1.7} />
) : (
<Moon size={18} strokeWidth={1.7} />
)}
<button
onClick={toggleTheme}
className={iconBtn}
title="Theme"
/* Names what pressing it DOES, not a static "toggle theme" — the
accessible name tracks state the same way the icon does. */
aria-label={theme === 'dark' ? L.themeToLight : L.themeToDark}
>
{/* Both icons stacked in one grid cell and cross-faded on
--motion-hover (opacity + a slight scale) — no rotation, no pop. */}
<span className="grid">
<Sun
aria-hidden="true"
size={18}
strokeWidth={1.7}
className={cn(
'[grid-area:1/1] transition-[opacity,transform] duration-fast ease-out',
theme === 'dark' ? 'opacity-100 scale-100' : 'opacity-0 scale-75',
)}
/>
<Moon
aria-hidden="true"
size={18}
strokeWidth={1.7}
className={cn(
'[grid-area:1/1] transition-[opacity,transform] duration-fast ease-out',
theme === 'dark' ? 'opacity-0 scale-75' : 'opacity-100 scale-100',
)}
/>
</span>
</button>
</div>
</header>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// Copyright (c) 2026 OpenDefender Contributors
// SPDX-License-Identifier: AGPL-3.0-only
//
// #751 phase 5 — required test 7: the theme toggle's accessible name follows
// the theme, naming the result of pressing it rather than a static "toggle
// theme" that never changes.

import { describe, expect, it, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

vi.mock('../../../features/notifications/notificationService', () => ({
notificationService: {
list: vi.fn().mockResolvedValue([]),
unreadCount: vi.fn().mockResolvedValue(0),
markRead: vi.fn(),
markAllRead: vi.fn(),
},
}));

import { AppHeader } from '../AppHeader';
import { useUIStore } from '../../../store/uiStore';

function renderHeader() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<MemoryRouter>
<AppHeader onOpenMobileNav={() => {}} />
</MemoryRouter>
</QueryClientProvider>,
);
}

beforeEach(() => {
useUIStore.setState({ lang: 'en', theme: 'dark' });
});

describe('AppHeader theme toggle', () => {
it('names the theme it will switch TO, and that name flips with the theme', async () => {
const user = userEvent.setup();
renderHeader();

// Starts dark: pressing it goes to light.
const button = screen.getByRole('button', { name: 'Switch to light theme' });

await user.click(button);

// Same button, new name — the real store toggled, not a stand-in.
expect(screen.getByRole('button', { name: 'Switch to dark theme' })).toBe(button);
});
});
18 changes: 14 additions & 4 deletions frontend/src/features/auth/AuthScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -449,12 +449,16 @@ function MFAEnrollment({ token }: { token: string }) {

const qrSrc = qr.startsWith('data:') ? qr : `data:image/jpeg;base64,${qr}`;

const submit = async (e: React.FormEvent) => {
e.preventDefault();
// `codeOverride`: onComplete below fires synchronously inside the same
// commit as the `setCode` it follows, before React applies that update —
// reading the `code` closure here would still see the PREVIOUS value.
// Passing the just-completed value directly sidesteps the stale read.
const submit = async (e?: React.FormEvent, codeOverride?: string) => {
e?.preventDefault();
setBusy(true);
setError('');
try {
const result = await verifyMFA(code.trim(), token);
const result = await verifyMFA((codeOverride ?? code).trim(), token);
// Mandated enrolment completes the login: the server issues the session in
// the same response, so the user is not asked for their password again.
if (result.token_pair?.access_token) {
Expand Down Expand Up @@ -526,7 +530,13 @@ function MFAEnrollment({ token }: { token: string }) {
id="enrol-code"
testId="mfa-enrol-code"
value={code}
onValueChange={setCode}
onValueChange={(next) => {
setCode(next);
if (error) setError('');
}}
onComplete={(value) => {
if (!busy) void submit(undefined, value);
}}
length={6}
invalid={Boolean(error)}
/>
Expand Down
64 changes: 53 additions & 11 deletions frontend/src/features/auth/MFAEnrollmentDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
// from Settings → Security.

import { useEffect, useRef, useState } from 'react';
import { OtpField } from '../../shared/ds';
import { OtpField, cn } from '../../shared/ds';
import { createPortal } from 'react-dom';
import { ShieldCheck, X, Loader2, Copy, Check } from 'lucide-react';
import { toast } from 'sonner';
Expand Down Expand Up @@ -70,13 +70,17 @@ export function MFAEnrollmentDialog({
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);

const submit = async (e: React.FormEvent) => {
e.preventDefault();
// `codeOverride`: onComplete below fires synchronously inside the same
// commit as the `setCode` it follows, before React applies that update —
// reading the `code` closure here would still see the PREVIOUS value.
// Passing the just-completed value directly sidesteps the stale read.
const submit = async (e?: React.FormEvent, codeOverride?: string) => {
e?.preventDefault();
if (busy) return;
setBusy(true);
setError('');
try {
await verifyMFA(code.trim());
await verifyMFA((codeOverride ?? code).trim());
await invalidateStatus();
toast.success(
tr('Authentification à deux facteurs activée', 'Two-factor authentication enabled'),
Expand Down Expand Up @@ -106,6 +110,10 @@ export function MFAEnrollmentDialog({
try {
await navigator.clipboard.writeText(setup.secret);
setCopied(true);
// The icon swap alone does not tell a screen-reader user anything
// happened — the toast is the announcement, same live region every
// other confirmation in the product uses.
toast.success(tr('Clé copiée', 'Key copied'));
setTimeout(() => setCopied(false), 2000);
} catch {
toast.error(tr('Copie impossible', 'Could not copy'));
Expand All @@ -114,7 +122,7 @@ export function MFAEnrollmentDialog({

return createPortal(
<div
className="fixed inset-0 z-80 flex items-center justify-center p-4"
className="fixed inset-0 z-80 flex items-center justify-center p-4 motion-safe:animate-or-fadein"
style={{ background: 'var(--surface-overlay)', backdropFilter: 'blur(var(--overlay-blur))' }}
onClick={onClose}
>
Expand All @@ -126,7 +134,10 @@ export function MFAEnrollmentDialog({
tabIndex={-1}
onClick={(e) => e.stopPropagation()}
onSubmit={submit}
className="w-full max-w-[440px] max-h-[90vh] flex flex-col rounded-[16px] overflow-hidden outline-none"
/* Stopgap (#751 phase 5): this dialog was hand-rolled with no
entrance at all. The house enter class is the fix here, not an
exit — moving it onto the ds `Modal` needs its own issue. */
className="motion-safe:animate-or-rise w-full max-w-[440px] max-h-[90vh] flex flex-col rounded-[16px] overflow-hidden outline-none"
style={{
background: 'var(--bg-secondary)',
border: '1px solid var(--border)',
Expand Down Expand Up @@ -210,11 +221,33 @@ export function MFAEnrollmentDialog({
<button
type="button"
onClick={copySecret}
className="h-9 w-9 rounded-[9px] flex items-center justify-center text-ink-soft hover:text-ink transition-colors shrink-0"
className="h-9 w-9 rounded-[9px] grid place-items-center text-ink-soft hover:text-ink transition-colors shrink-0"
style={{ background: 'var(--bg-hover)' }}
aria-label={tr('Copier la clé', 'Copy the key')}
/* The accessible name follows the state, the same way the
icon does — the icon alone is silent to a screen reader. */
aria-label={copied ? tr('Copié', 'Copied') : tr('Copier la clé', 'Copy the key')}
>
{copied ? <Check size={16} /> : <Copy size={16} />}
{/* Both icons stacked in one grid cell and cross-faded, not
swapped outright — no rotation, no pop, just opacity and
a slight scale on --motion-hover. */}
<span className="grid">
<Copy
aria-hidden="true"
size={16}
className={cn(
'[grid-area:1/1] transition-[opacity,transform] duration-fast ease-out',
copied ? 'opacity-0 scale-75' : 'opacity-100 scale-100',
)}
/>
<Check
aria-hidden="true"
size={16}
className={cn(
'[grid-area:1/1] transition-[opacity,transform] duration-fast ease-out',
copied ? 'opacity-100 scale-100' : 'opacity-0 scale-75',
)}
/>
</span>
</button>
</div>

Expand Down Expand Up @@ -246,21 +279,30 @@ export function MFAEnrollmentDialog({
</div>
)}

<label className="flex flex-col gap-1.5">
{/* A plain div, not a wrapping <label>: OtpField labels itself
via the `label` prop (the exact case its doc comment names
this dialog for), so there is one accessible name rather than
an implicit association that would be fragile to a future
refactor un-nesting the input. */}
<div className="flex flex-col gap-1.5">
<span className="text-[11px] font-semibold uppercase tracking-[.04em] text-ink-muted">
{tr('Code à 6 chiffres', '6-digit code')}
</span>
<OtpField
label={tr('Code à 6 chiffres', '6-digit code')}
value={code}
onValueChange={(next) => {
setCode(next);
setError('');
}}
onComplete={(value) => {
if (!busy) void submit(undefined, value);
}}
length={6}
invalid={!!error}
describedBy={error ? 'mfa-enrol-error' : undefined}
/>
</label>
</div>

{error && (
<div
Expand Down
Loading
Loading