diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 7e175a53..b3c30dd4 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -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 diff --git a/frontend/src/components/layout/AppHeader.tsx b/frontend/src/components/layout/AppHeader.tsx index c51019a6..24d50bce 100644 --- a/frontend/src/components/layout/AppHeader.tsx +++ b/frontend/src/components/layout/AppHeader.tsx @@ -216,12 +216,36 @@ export const AppHeader = ({ onOpenMobileNav }: AppHeaderProps) => { {notifMounted && setNotifOpen(false)} />} - diff --git a/frontend/src/components/layout/__tests__/appHeaderThemeToggle.test.tsx b/frontend/src/components/layout/__tests__/appHeaderThemeToggle.test.tsx new file mode 100644 index 00000000..ceb2f03e --- /dev/null +++ b/frontend/src/components/layout/__tests__/appHeaderThemeToggle.test.tsx @@ -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( + + + {}} /> + + , + ); +} + +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); + }); +}); diff --git a/frontend/src/features/auth/AuthScreen.tsx b/frontend/src/features/auth/AuthScreen.tsx index f410bce6..39cc4dbe 100644 --- a/frontend/src/features/auth/AuthScreen.tsx +++ b/frontend/src/features/auth/AuthScreen.tsx @@ -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) { @@ -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)} /> diff --git a/frontend/src/features/auth/MFAEnrollmentDialog.tsx b/frontend/src/features/auth/MFAEnrollmentDialog.tsx index a6bd7a0a..edc6d661 100644 --- a/frontend/src/features/auth/MFAEnrollmentDialog.tsx +++ b/frontend/src/features/auth/MFAEnrollmentDialog.tsx @@ -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'; @@ -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'), @@ -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')); @@ -114,7 +122,7 @@ export function MFAEnrollmentDialog({ return createPortal(
@@ -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)', @@ -210,11 +221,33 @@ export function MFAEnrollmentDialog({
@@ -246,21 +279,30 @@ export function MFAEnrollmentDialog({ )} -