From b9694b6e68e253b92e53598f06339fa550fc5b7e Mon Sep 17 00:00:00 2001 From: alex-dembele Date: Tue, 29 Sep 2026 16:57:51 +0100 Subject: [PATCH 1/7] docs(decisions): raise D-061, the 3D tilt on the score card (#751) Both the art and UX specs for phase 5 recommend dropping it. Cutting an item from the issue is the owner's call, so it is raised rather than decided. Signed-off-by: alex-dembele --- docs/DECISIONS.md | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) 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 From 491e5a2761341f4204c002027d3de3c26a507f40 Mon Sep 17 00:00:00 2001 From: alex-dembele Date: Tue, 29 Sep 2026 18:14:05 +0100 Subject: [PATCH 2/7] feat(ds): give Modal and Drawer a real CSS exit transition (#751) `if (!open) return null` cut every close instantly, so a create/edit modal that closes on mutation success never had an exit to play, and any enter/exit was one-directional keyframe animation rather than a reversible transition. Both now stay mounted at `data-state="closed"` through their exit and unmount via useExitTimer (byte-identical to the #825 branch's version, for a clean merge), with the CSS driven off `data-state` rather than a mount/unmount keyframe so a reopen mid-exit reverses the same transition instead of restarting one. Scrim and panel motion move from `motion-safe:animate-or-*` keyframes to plain CSS transitions in index.css (.or-scrim/.or-modal-panel/.or-drawer- panel), using the asymmetric-duration trick so entering and leaving can carry different tokens without a Tailwind duration-variant clash. useDismissableLayer keeps releasing focus/trap/scroll-lock on `open` itself, so that happens at the start of the close, not after the timer. Signed-off-by: alex-dembele --- frontend/src/index.css | 59 ++++++ frontend/src/shared/ds/Drawer.tsx | 47 +++-- frontend/src/shared/ds/Modal.tsx | 50 ++++- .../ds/__tests__/overlayMotionTokens.test.ts | 32 +++ .../shared/ds/__tests__/primitives.test.tsx | 187 +++++++++++++++++- frontend/src/shared/ds/index.ts | 1 + frontend/src/shared/ds/overlayMotion.ts | 30 +++ frontend/src/shared/ds/useExitTimer.ts | 39 ++++ 8 files changed, 424 insertions(+), 21 deletions(-) create mode 100644 frontend/src/shared/ds/__tests__/overlayMotionTokens.test.ts create mode 100644 frontend/src/shared/ds/overlayMotion.ts create mode 100644 frontend/src/shared/ds/useExitTimer.ts diff --git a/frontend/src/index.css b/frontend/src/index.css index 8f805cb8..4cdca82d 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -535,3 +535,62 @@ layer(base); .or-den-rows td { padding-block: var(--den-cell-y) !important; } + +/* Modal / Drawer exit (#751 phase 5) — plain CSS, not a Tailwind + `data-[state=…]:` variant. A browser plays the transition-duration/-easing + of the state being transitioned INTO, never the one being left, so the + asymmetric-duration trick (feedback_css-motion-tricks.md) puts the EXIT + values on the base/closed rule and lets `[data-state='open']` override them + for ENTER. Writing this as `data-[state=open]:duration-*` utilities instead + would also risk the duration-variant trap: a variant-scoped + `transition-[...]` re-emits Tailwind's own 150ms default and beats a + base-level `duration-*` utility (.claude/agent-memory/frontend-react). + Reduced motion needs no special case of its own — the global kill switch + above already zeroes every `transition`, so a `data-state` flip lands + instantly either way. */ +@layer components { + .or-scrim { + opacity: 0; + transition: opacity var(--motion-exit); + } + .or-scrim[data-state='open'] { + opacity: 1; + transition: opacity var(--motion-enter); + } + + .or-modal-panel { + opacity: 0; + pointer-events: none; + transform: translateY(8px); + transition: + opacity var(--motion-exit), + transform var(--motion-exit); + } + .or-modal-panel[data-state='open'] { + opacity: 1; + pointer-events: auto; + transform: translateY(0); + transition: + opacity var(--motion-enter), + transform var(--motion-enter); + } + + /* Drawer exit is --dur-base/--ease-in, not --motion-exit (--dur-fast) — see + the MOTION comment in Drawer.tsx for why the two directions differ. */ + .or-drawer-panel { + pointer-events: none; + transform: translateX(100%); + transition: transform var(--dur-base) var(--ease-in); + } + .or-drawer-panel[data-side='left'] { + transform: translateX(-100%); + } + /* Declared after [data-side='left'] so it wins the tie on `transform` + regardless of which side the panel opens from — both sides converge on + translateX(0) when open. */ + .or-drawer-panel[data-state='open'] { + pointer-events: auto; + transform: translateX(0); + transition: transform var(--motion-panel); + } +} diff --git a/frontend/src/shared/ds/Drawer.tsx b/frontend/src/shared/ds/Drawer.tsx index 2ae76594..ec9eb36b 100644 --- a/frontend/src/shared/ds/Drawer.tsx +++ b/frontend/src/shared/ds/Drawer.tsx @@ -20,9 +20,18 @@ * * SIZES sm 380 | md 480 (default) | lg 620 | xl 780 * - * MOTION Slides in from its edge over --dur-panel. Slide is the right verb - * here precisely because it says where the thing came from and where - * it will go back to — the continuity a modal's fade cannot express. + * MOTION Slides in from its edge on --motion-panel (--dur-panel), and back + * out on the slower --dur-base/--ease-in — deliberately not the + * same pair reversed: a panel that has just travelled the width of + * the viewport reads as yanked off screen on the fast timing a + * small dialog exits on. Slide is the right verb here precisely + * because it says where the thing came from and where it will go + * back to — the continuity a modal's fade cannot express. + * + * Same mount/exit contract as Modal (#751 phase 5): `open` going + * false does not unmount the panel, it stays at + * `data-state="closed"` until `useExitTimer` reports it gone, so a + * reopen mid-exit reverses the transition instead of restarting one. * * WIDE SCREENS At >=1920px a drawer marked `docked` stops overlaying and sits * beside the list instead (the .or-md-* rules in index.css). On a @@ -30,15 +39,18 @@ * a waste of the room. * * A11Y Identical contract to Modal — trap, restore, Escape, scroll lock — - * because it is the same hook. A drawer is a dialog to assistive - * technology, so it says so. + * because it is the same hook, keyed on `open` (not the exit timer) + * so it releases at the start of the close. A drawer is a dialog to + * assistive technology, so it says so. */ -import { useId, useRef, type ReactNode } from 'react'; +import { useEffect, useId, useRef, useState, type ReactNode } from 'react'; import { createPortal } from 'react-dom'; import { X } from 'lucide-react'; import { cn } from './cn'; import { useDismissableLayer } from './useDismissableLayer'; +import { useExitTimer } from './useExitTimer'; +import { DRAWER_EXIT_MS } from './overlayMotion'; import { Button } from './Button'; export type DrawerSide = 'left' | 'right'; @@ -85,14 +97,27 @@ export function Drawer({ useDismissableLayer(panelRef, { open, onClose }); - if (!open) return null; + // Same contract as Modal: stays mounted through its exit, `data-state` + // drives the CSS, closing is synchronous and opening is deferred a frame — + // see Modal.tsx for the full reasoning, identical here. + const mounted = useExitTimer(open, DRAWER_EXIT_MS); + const [state, setState] = useState<'open' | 'closed'>('closed'); + if (!open && state === 'open') setState('closed'); + useEffect(() => { + if (!open) return; + const frame = requestAnimationFrame(() => setState('open')); + return () => cancelAnimationFrame(frame); + }, [open]); + + if (!mounted) return null; return createPortal(
diff --git a/frontend/src/shared/ds/Modal.tsx b/frontend/src/shared/ds/Modal.tsx index 195e4a05..5f14da4a 100644 --- a/frontend/src/shared/ds/Modal.tsx +++ b/frontend/src/shared/ds/Modal.tsx @@ -16,25 +16,40 @@ * * SIZES sm 380 | md 520 (default) | lg 720 | xl 960 * - * MOTION Scrim fades; panel fades and rises 8px, both on --motion-enter. - * The panel does NOT scale from 0.9 — a dialog that zooms reads as a - * notification. Under prefers-reduced-motion it appears, in place. + * MOTION Scrim fades; panel fades and rises 8px, on --motion-enter in and + * --motion-exit out. The panel does NOT scale from 0.9 — a dialog + * that zooms reads as a notification. Under prefers-reduced-motion + * it appears, in place (index.css's global kill switch zeroes every + * transition, so the state flip below lands instantly). + * + * `open` going false does not unmount the panel: it stays mounted + * at `data-state="closed"` for its exit transition and is only + * removed once `useExitTimer` reports it gone (#751 phase 5 — the + * previous `if (!open) return null` cut every close instantly, + * which is why a create/edit modal that closed on mutation success + * never had an exit to play). Driving the CSS off `data-state` + * rather than a mount/unmount keyframe means a reopen mid-exit + * reverses the same transition instead of restarting one. * * A11Y role="dialog" aria-modal, labelled by its title and described by * its subtitle; focus trapped, restored on close; Escape closes; * the page behind is frozen. All of that comes from - * useDismissableLayer, so it is identical in every dialog. + * useDismissableLayer, keyed on `open` (not the exit timer) so the + * trap releases and focus returns at the START of the close, not + * after the panel finishes fading out. * * Rendered in a portal to document.body so no ancestor's overflow, transform * or stacking context can clip it — the reason "the dropdown is cut off inside * the drawer" happens. */ -import { useId, useRef, type ReactNode } from 'react'; +import { useEffect, useId, useRef, useState, type ReactNode } from 'react'; import { createPortal } from 'react-dom'; import { X } from 'lucide-react'; import { cn } from './cn'; import { useDismissableLayer } from './useDismissableLayer'; +import { useExitTimer } from './useExitTimer'; +import { MODAL_EXIT_MS } from './overlayMotion'; import { Button } from './Button'; export type ModalSize = 'sm' | 'md' | 'lg' | 'xl'; @@ -93,11 +108,28 @@ export function Modal({ useDismissableLayer(panelRef, { open, onClose, closeOnEscape: dismissable }); - if (!open) return null; + // Stays mounted through its own exit transition instead of cutting the + // instant `open` goes false. + const mounted = useExitTimer(open, MODAL_EXIT_MS); + // `data-state`, not `open` directly, drives the CSS: closing is applied + // synchronously (same render `open` goes false, matching + // useDismissableLayer's own immediate focus release), while opening is + // deferred a frame so the browser paints the closed state at least once — + // without that the enter transition has nothing to animate from. + const [state, setState] = useState<'open' | 'closed'>('closed'); + if (!open && state === 'open') setState('closed'); + useEffect(() => { + if (!open) return; + const frame = requestAnimationFrame(() => setState('open')); + return () => cancelAnimationFrame(frame); + }, [open]); + + if (!mounted) return null; return createPortal(
{ + it('MODAL_EXIT_MS equals --dur-fast, which .or-modal-panel exits on', () => { + expect(MODAL_EXIT_MS).toBe(tokenMs('dur-fast')); + }); + + it('DRAWER_EXIT_MS equals --dur-base, which .or-drawer-panel exits on', () => { + expect(DRAWER_EXIT_MS).toBe(tokenMs('dur-base')); + }); +}); diff --git a/frontend/src/shared/ds/__tests__/primitives.test.tsx b/frontend/src/shared/ds/__tests__/primitives.test.tsx index 8ae3a9c1..cf0f51cb 100644 --- a/frontend/src/shared/ds/__tests__/primitives.test.tsx +++ b/frontend/src/shared/ds/__tests__/primitives.test.tsx @@ -31,10 +31,40 @@ import { Shake } from '../Shake'; import { useSuccessFeedback } from '../useSuccessFeedback'; import { Modal } from '../Modal'; import { Drawer } from '../Drawer'; +import { MODAL_EXIT_MS, DRAWER_EXIT_MS } from '../overlayMotion'; import { TabPanel, Tabs } from '../Tabs'; import { PermissionDenied } from '../States'; import { categorical, seriesColor, severity, chartAccessibleProps } from '../chart'; +/** + * Switches `window.matchMedia` to report `prefers-reduced-motion: reduce`, + * for the "unmounts at once" exit-timer tests, and returns a function that + * puts it back. + * + * Not `vi.spyOn(...).mockRestore()`: the base mock in src/test/setup.ts is + * itself a `vi.fn().mockImplementation(...)`, and restoring a spy wrapped + * around an already-mocked function clears that implementation rather than + * bringing it back — every test after the first one to do this then got + * `window.matchMedia(...)` returning `undefined`. Saving and reassigning the + * reference directly sidesteps that. + */ +function withReducedMotion(): () => void { + const original = window.matchMedia; + window.matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches: query.includes('reduce'), + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })) as typeof window.matchMedia; + return () => { + window.matchMedia = original; + }; +} + /* ------------------------------------------------------------------ Button -- */ describe('Button', () => { @@ -502,6 +532,85 @@ describe('Modal', () => { await user.keyboard('{Escape}'); expect(document.body.style.overflow).not.toBe('hidden'); }); + + /* #751 phase 5 — the previous `if (!open) return null` cut every close + instantly, so a create/edit modal that closed on mutation success never + had an exit to play. */ + it('stays mounted at data-state="closed" after close, and unmounts once the exit timer fires', () => { + vi.useFakeTimers(); + try { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Open dialog' })); + const dialog = screen.getByRole('dialog'); + + fireEvent.click(within(dialog).getByRole('button', { name: /close/i })); + + expect(screen.getByRole('dialog')).toHaveAttribute('data-state', 'closed'); + + act(() => { + vi.advanceTimersByTime(MODAL_EXIT_MS - 1); + }); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + + act(() => { + vi.advanceTimersByTime(1); + }); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }); + + it('unmounts at once under prefers-reduced-motion, with no lingering exit timer', () => { + vi.useFakeTimers(); + const restoreMatchMedia = withReducedMotion(); + try { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Open dialog' })); + const dialog = screen.getByRole('dialog'); + + fireEvent.click(within(dialog).getByRole('button', { name: /close/i })); + expect(screen.getByRole('dialog')).toHaveAttribute('data-state', 'closed'); + + // Reduced motion collapses the exit timer to 0 — no wait needed for the + // fake clock to reach it, only for the already-queued timer to flush. + act(() => { + vi.advanceTimersByTime(0); + }); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + } finally { + restoreMatchMedia(); + vi.useRealTimers(); + } + }); + + it('reopening mid-exit reverses the same panel instead of restarting a new one', () => { + vi.useFakeTimers(); + try { + render(); + const trigger = screen.getByRole('button', { name: 'Open dialog' }); + fireEvent.click(trigger); + const dialog = screen.getByRole('dialog'); + + fireEvent.click(within(dialog).getByRole('button', { name: /close/i })); + expect(dialog).toHaveAttribute('data-state', 'closed'); + + // Reopen before MODAL_EXIT_MS elapses. + fireEvent.click(trigger); + + // Same DOM node throughout — the close was reversed, not remounted. + expect(screen.getByRole('dialog')).toBe(dialog); + + // The exit timer that was already queued must not unmount a dialog + // that is open again by the time it fires. + act(() => { + vi.advanceTimersByTime(MODAL_EXIT_MS + 10); + }); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }); }); /* ------------------------------------------------------------------ Drawer -- */ @@ -531,8 +640,84 @@ describe('Drawer', () => { expect(screen.getByRole('dialog', { name: 'web-prod-01' })).toBeInTheDocument(); await user.keyboard('{Escape}'); - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + // Focus returns at the start of the close (useDismissableLayer releases + // on `open`, not on the exit timer) — the panel itself lingers a beat + // longer for its exit transition, see the tests below. expect(document.activeElement).toBe(trigger); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); + + /* #751 phase 5 — same contract as Modal, on the drawer's own exit timing + (--dur-base, not --motion-exit's --dur-fast). */ + it('stays mounted at data-state="closed" after close, and unmounts once the exit timer fires', () => { + vi.useFakeTimers(); + try { + function Harness() { + const [open, setOpen] = useState(false); + return ( + <> + + setOpen(false)} title="web-prod-01" subtitle="Server"> +

Detail

+
+ + ); + } + render(); + fireEvent.click(screen.getByRole('button', { name: 'Open asset' })); + const dialog = screen.getByRole('dialog'); + + fireEvent.click(within(dialog).getByRole('button', { name: /close/i })); + expect(screen.getByRole('dialog')).toHaveAttribute('data-state', 'closed'); + + act(() => { + vi.advanceTimersByTime(DRAWER_EXIT_MS - 1); + }); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + + act(() => { + vi.advanceTimersByTime(1); + }); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }); + + it('unmounts at once under prefers-reduced-motion', () => { + vi.useFakeTimers(); + const restoreMatchMedia = withReducedMotion(); + try { + function Harness() { + const [open, setOpen] = useState(false); + return ( + <> + + setOpen(false)} title="web-prod-01" subtitle="Server"> +

Detail

+
+ + ); + } + render(); + fireEvent.click(screen.getByRole('button', { name: 'Open asset' })); + const dialog = screen.getByRole('dialog'); + + fireEvent.click(within(dialog).getByRole('button', { name: /close/i })); + expect(screen.getByRole('dialog')).toHaveAttribute('data-state', 'closed'); + + act(() => { + vi.advanceTimersByTime(0); + }); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + } finally { + restoreMatchMedia(); + vi.useRealTimers(); + } }); }); diff --git a/frontend/src/shared/ds/index.ts b/frontend/src/shared/ds/index.ts index 0f0a34f2..6c992a24 100644 --- a/frontend/src/shared/ds/index.ts +++ b/frontend/src/shared/ds/index.ts @@ -77,6 +77,7 @@ export { } from './RiskMatrix'; export { useDismissableLayer } from './useDismissableLayer'; export { useSuccessFeedback } from './useSuccessFeedback'; +export { useExitTimer } from './useExitTimer'; /** Visualisation contract — palette, axes, tooltip, graph. */ export * as chart from './chart'; diff --git a/frontend/src/shared/ds/overlayMotion.ts b/frontend/src/shared/ds/overlayMotion.ts new file mode 100644 index 00000000..dbc3d9d3 --- /dev/null +++ b/frontend/src/shared/ds/overlayMotion.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Modal and Drawer's exit-timer durations (#751 phase 5). + * + * `useExitTimer` keeps a panel mounted until a plain `setTimeout` fires, and + * has no way to read a CSS custom property — so these hand-copied literals + * have to match, by value, the exit transition each panel's own class + * declares in index.css (`.or-modal-panel` / `.or-drawer-panel`). A + * drift-guard test (`__tests__/overlayMotionTokens.test.ts`) reads the real + * tokens from disk and fails if either literal falls out of sync — the same + * convention `notifMotion.ts` established in phase 4. + * + * Kept out of Modal.tsx/Drawer.tsx rather than exported alongside the + * component: `react-refresh/only-export-components` requires a component + * file to export only the component (plus types, which are erased), so a + * runtime constant needs its own module. + */ + +/** Matches --dur-fast (src/styles/primitives.css) — Modal's exit, --motion-exit. */ +export const MODAL_EXIT_MS = 120; + +/** + * Matches --dur-base (src/styles/primitives.css) — Drawer's exit. Slower than + * the modal's --motion-exit (--dur-fast) on purpose: a panel that has just + * travelled the width of the viewport reads as yanked off screen if it + * disappears on the same fast timing as a small dialog's fade. + */ +export const DRAWER_EXIT_MS = 180; diff --git a/frontend/src/shared/ds/useExitTimer.ts b/frontend/src/shared/ds/useExitTimer.ts new file mode 100644 index 00000000..1483c625 --- /dev/null +++ b/frontend/src/shared/ds/useExitTimer.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Keeps a layer mounted for its CSS exit animation, then reports it as gone. + * + * `open` flipping to `false` does not unmount anything by itself: the caller + * keeps rendering the layer while this hook returns `true`, so its CSS exit + * animation (keyed off the same `open` value, e.g. a `data-open` attribute) + * gets a frame to play before the node leaves the DOM. The unmount itself + * runs off a plain timer, never `transitionend` — an event that would simply + * never fire once `prefers-reduced-motion: reduce` kills every transition and + * animation globally (index.css:243), stranding the layer mounted forever. + * Reduced motion instead collapses `exitMs` to 0 here, so the two mechanisms + * (the CSS kill switch and this timer) agree without either watching the + * other. + * + * Re-opening before the exit timer fires cancels it immediately: `open` and + * `mounted` are kept in sync at render time whenever `open` is true (React's + * "adjust state while rendering" pattern — see + * .claude/agent-memory/frontend-react/feedback_set-state-in-effect-lint.md), + * so a mount is never in doubt. + */ + +import { useEffect, useState } from 'react'; + +export function useExitTimer(open: boolean, exitMs: number): boolean { + const [mounted, setMounted] = useState(open); + if (open && !mounted) setMounted(true); + + useEffect(() => { + if (open || !mounted) return; + const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + const timer = setTimeout(() => setMounted(false), reduced ? 0 : exitMs); + return () => clearTimeout(timer); + }, [open, mounted, exitMs]); + + return mounted; +} From 0116e8cfb1ea726242f2c2524e742a304efe5df5 Mon Sep 17 00:00:00 2001 From: alex-dembele Date: Tue, 29 Sep 2026 18:23:49 +0100 Subject: [PATCH 3/7] feat(auth): auto-submit the MFA code and smooth its segment ring (#751) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OtpField's ring only transitioned border-color, so it jumped between boxes instead of sliding, and a filled segment's character popped in rather than fading (--motion-hover for the ring, --motion-press for the glyph, both plain transitions). Behaviourally, onComplete existed on OtpField and was unit-tested but neither MFA enrolment call site wired it up — AuthScreen's MFAEnrollment and MFAEnrollmentDialog both required a manual button press after the sixth digit. Both now submit on completion, guarded by `busy` so a second paste or a re-render in flight cannot double-fire, and the just-completed value is passed straight to submit rather than read off the component's `code` state, which is still one render behind at the moment onComplete fires. AuthScreen's enrolment field now also clears its error on edit, same as the dialog's already did. MFAEnrollmentDialog's OtpField gets an explicit `label` (the exact case its own doc comment names this dialog for) instead of relying on an implicit wrapping
@@ -246,21 +276,30 @@ export function MFAEnrollmentDialog({
)} -