Skip to content

feat(ui): phase 5 — modal/drawer exit, OTP auto-submit, icon swaps (#751) - #847

Open
alex-dembele wants to merge 7 commits into
masterfrom
feat/751-phase5-micro
Open

alex-dembele wants to merge 7 commits into
masterfrom
feat/751-phase5-micro

Conversation

@alex-dembele

@alex-dembele alex-dembele commented Sep 29, 2026 •

Copy link
Copy Markdown
Member

Part of #751. This is phase 5, auth and micro-interactions. The branch is cut from master after #819 and #823 and merges cleanly into the current master (with #824). The spec was arbitrated from art-director and ux-designer and is posted on the issue.

What changes

  • ds Modal and Drawer: a real exit.
  • OTP.
    • The segment ring transitions border-color and box-shadow, and characters fade in.
    • onComplete now submits in both MFA enrolment flows, guarded against double submit.
    • Fixed on the way: onComplete fired before React applied setCode, so a stale closure was submitted.
    • The error clears on edit.
  • Theme toggle. Sun and Moon cross-fade in one grid cell. The accessible name says what pressing it does ("Passer au thème clair/sombre"). Copy→Copied on the MFA key does the same and confirms with a toast.
  • Stopgap entrances.
    • The governance approval modal's or-scalein class never had a CSS rule, so it had no motion at all; it now gets the house or-rise/or-fadein.
    • The MFA enrolment dialog gets the same.
  • D-061 raised for the owner: the 3D tilt on the score card. Both specs recommend dropping it.
  • Not built: an avatar group. None exists in the product.

Verification

The runs were in an isolated git worktree, because another session switched the shared checkout mid-run.

npx vitest run  → Test Files 3 failed | 86 passed (89) · Tests 11 failed | 850 passed (861)
                  all 11 failures are the master baseline (lost imports, fixed by #845):
                  members.test.tsx ×9, CreateRiskModal.test.tsx ×1, App.integration ×1
npx tsc -b      → only the same 2 baseline TS2304 errors (#840)
eslint (touched)→ only the pre-existing GovernancePage:667 react-hooks/purity error (identical on the base)
budget          → 201.2 KB vs 201.0 KB on the base (+0.2 KB; the 180 KB gate was already red)

Every new test fails without its change. The Drawer reopen-mid-exit test was proven against the pre-phase Drawer.tsx.

Live, real stack

A server was built from master on a throwaway Postgres 16 and Redis 7, with Vite serving this branch and Chromium sampling getComputedStyle every frame.

Check Result
Modal (API-token revoke confirm), enter opacity 0→.37→.65→.89→1 and y 8→0px over ≈180 ms
Modal, exit 1→0 over ≈120 ms at data-state=closed, then unmounted; scroll lock released
Modal, reduced motion opacity 1 on the first frame; unmounted within 2 frames of Escape
Drawer (asset history), enter x 480→0 over ≈400 ms (--motion-panel); focus inside the panel
Drawer, exit ≈180 ms, then unmounted
Drawer, reduced motion (light theme) translate 0 at once; unmounted within 2 frames
Governance approval modal or-rise, 0→1 over ≈170 ms (it had no motion before)
MFA enrolment dialog or-rise, 0→1 over ≈170 ms
MFA, wrong code 000000 exactly 1 /auth/mfa/verify request; the error shows and aria-invalid=true; one edit clears both
MFA, correct TOTP (RFC 6238 computed in the page) exactly 1 request; the dialog closes and MFA is enabled
Copy the key name "Copier la clé" → "Copié", and one toast
Theme toggle, first paint no transition running, icons at 1/0
Theme toggle, click cross-fade (.09/.91 at 126 ms); the name flips both ways; width stays 36px

Honest remainders

  • Callers that still cut because they conditionally render the component: VendorDetailPage:188, RemediationDetailPage:180, AuditDetailPage:194, and EntityDrawerHost:56. They need their own change.
  • Focus after closing a modal opened from a menu item lands on <body>: the trigger (the menu item) has gone by then. This is pre-existing, not introduced here.
  • Review nits left as they are:
    • The scrim has no pointer-events:none while closing. It is harmless, since it still covers the page for 120 ms.
    • The enter transition relies on a single rAF inside useEffect. It is proven to play live, but the mechanism is fragile if someone changes it to useLayoutEffect.
  • Not unit-tested: the governance modal's entrance classes (there is no test file for that page). It was verified live instead.
  • Not run: a full axe sweep, and 60 fps on a mid-range device.

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 <alexandredembele16@gmail.com>
`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 <alexandredembele16@gmail.com>
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 <label>.

MFA login (AuthScreen's MFAChallenge) is deliberately untouched: it
takes a 6-digit TOTP code OR a 12-character recovery code and is not
an OtpField, so there is no onComplete to wire.

Signed-off-by: alex-dembele <alexandredembele16@gmail.com>
…ght (#751)

AppHeader.tsx's Sun/Moon toggle switched icons abruptly and its
aria-label was the static "Toggle theme", which never told a
screen-reader user what pressing it would do or that anything had
changed. Both icons now sit in one grid cell and cross-fade (opacity
+ a slight scale, no rotation) on --motion-hover, and the accessible
name names the result of pressing it — "Switch to light/dark theme" —
so it flips with the theme the same way the icon does. New
themeToLight/themeToDark keys in shared/uiStrings.ts, FR and EN,
following the file's own established pattern for this chrome (it is
kept separate from locales/*.json, consumed via useUIStrings()).

The MFAEnrollmentDialog Copy→Check icon swap, the other half of this
spec item, landed in the previous commit alongside the OTP wiring
touching the same file — noted here rather than split after the fact.

Signed-off-by: alex-dembele <alexandredembele16@gmail.com>
The policy/control modal (GovernancePage.tsx) used an `or-scalein`
class with no matching CSS rule anywhere, so it popped in with no
motion at all. MFAEnrollmentDialog.tsx never had an entrance class in
the first place. Both are stopgapped with the house enter classes
(motion-safe:animate-or-rise on the panel, motion-safe:animate-or-fadein
on the scrim) rather than rebuilt: neither is the ds Modal, so neither
gets the real data-state-driven exit transition Modal.tsx now has.
Migrating them onto it is its own issue.

Signed-off-by: alex-dembele <alexandredembele16@gmail.com>
frontend/src/components/layout/AppHeader.tsx,
frontend/src/features/auth/__tests__/mfaEnrollmentDialog.test.tsx and
frontend/src/shared/ds/__tests__/primitives.test.tsx only — no other
file in the tree was reformatted.

Signed-off-by: alex-dembele <alexandredembele16@gmail.com>
Only Modal had this test. The Drawer uses the same exit timer on its own
duration, so it gets the same guard: same node, and the queued timer
does not unmount a drawer that is open again.

Signed-off-by: alex-dembele <alexandredembele16@gmail.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant