feat(frontend): Phase 5 Accessibility + Forms — close FE-H3 + UX-H4 primitive + FE-M1 primitive + axe-core gate

Closes the Phase 5 batch from cowork/frontend-design-audit.html: ships
the joint UX-H4 + FE-M1 lever (FormField primitive + react-hook-form +
zod schemas) and the FE-H3 fix (Headless UI Dialog focus trap on the 3
inline-managed modals), with an axe-core regression test + CI guard to
prevent UX-H4 regressions.

═════════════════════════ AUDIT VERIFICATION ═════════════════════════
Confirmed live against the repo before implementing:

  • Q1 labels / htmlFor / input-id = 139 / 6 / 0
    (audit said 138 / 6 / 0 — labels +1, otherwise accurate)
  • Q2 no form library installed
    (no react-hook-form, formik, @tanstack/react-form, final-form)
  • Q3 3 inline-managed dialog sites confirmed:
    SCEPAdminPage.tsx:272, AgentsPage.tsx:314, ESTAdminPage.tsx:281
  • Q4 audit's top-6 list was OFF — actual top form-heaviest pages
    by useState count are: OIDCProviderDetailPage 21, AgentGroupsPage
    18, CertificatesPage 17, CertificateDetailPage 14, BreakglassPage
    13, ProfilesPage 13 — NOT the audit-suggested OnboardingWizard 5
    (now split in Phase 4) / OIDCProvidersPage 8 / IssuersPage 11 /
    ProfilesPage 13 / TargetsPage 9 / ApprovalsPage 5. Audit's
    intuition skipped the higher-useState pages.
  • Q5 jest-dom imported in src/test/setup.ts — axe-core landed
    cleanly

═════════════════════════════ CLOSURES ═══════════════════════════════

UX-H4 (label/input binding) — FormField primitive shipped
  • web/src/components/FormField.tsx wraps a <label> + an input child
    and auto-generates a stable id via React 18's useId(); cloneElement
    threads that id onto BOTH the <label htmlFor> AND the child's id
    prop so the WCAG 1.3.1 binding holds by construction. Supports
    `required` (asterisk + aria-required), `description` (wires
    aria-describedby), `error` (aria-invalid + role=alert + extends
    aria-describedby). 7 tests pin the contract.

FE-M1 (no form library) — react-hook-form + @hookform/resolvers + zod
  • Added react-hook-form 7.75, @hookform/resolvers 5.2, zod 4.4 as
    runtime deps; @axe-core/react, jest-axe, @types/jest-axe as devDeps
  • Representative migration of CreateTeamModalInline (inside
    onboarding/CertificateStep — operator's first-run experience)
    from 3-useState + manual handlers to useForm + zodResolver +
    FormField. Schema at pages/onboarding/team.schema.ts.
  • Per the audit's "top-6 only, primitive is the lever" rule, the
    other 5 audit-suggested pages migrate organically as feature
    work touches them — documented as Phase 5 follow-up. The
    FormField primitive is the leverage point; per-page migrations
    are mechanical applications.

FE-H3 (no focus trap on modal pages)
  • New ModalDialog primitive at web/src/components/ModalDialog.tsx —
    Headless UI Dialog wrapper for arbitrary-content modals
    (complements ConfirmDialog which is confirm-only). Auto-emits
    role=dialog + aria-modal + aria-labelledby + ESC-to-close +
    backdrop-click-to-close + focus trap.
  • All 3 inline-managed modal sites migrated:
      • SCEPAdminPage ConfirmReloadModal
      • ESTAdminPage ConfirmReloadModal (data-testid preserved)
      • AgentsPage RetireAgentModal (3-mode: confirm / blocked / error
        — title + footer change per mode; body slot stays the same)
  • 37/37 existing modal-page tests stay green — no behavior change
    visible to the test suite, only the focus-trap + ESC handling.

UX-H4 regression gate
  • web/src/test/a11y.test.tsx runs axe-core (not jest-axe — its
    `toHaveNoViolations` matcher uses jest's expect API which can't
    plug into Vitest's expect.extend; fails with "expectAssertion.call
    is not a function"). Direct axe.run + assert violations.length===0
    gives the same gate with a readable failure message.
  • Scope: primitives, not page sweeps. Primitives carry the risk
    surface; pages compose them. 5 tests covering FormField (with +
    without description/error), Skeleton (all 4 variants),
    ModalDialog, Breadcrumbs. ~400ms total.
  • Skeleton.table's empty <th> cells are decorative shimmers inside
    a role=status + aria-busy=true tree — axe-core's
    `empty-table-header` rule doesn't model aria-busy gating, so it
    is suppressed for the Skeleton variant scan with a clear comment.

  • scripts/ci-guards/no-unbound-label.sh — fails CI if a new <label>
    without htmlFor lands. Baseline-driven (132 today) so the existing
    backlog doesn't block CI; every migration to FormField drops the
    baseline. `--strict` mode rejects any unbound label once the
    backlog clears.

═══════════════════════════ VERIFICATION ═════════════════════════════

  • npx tsc --noEmit — exits 0
  • New tests: FormField 7/7, ModalDialog 6/6, a11y 5/5 = 18/18 new
  • Component suite: 14 files / 150/150 green
  • Page suite (representative subset run): 16 files in first run
    (timeout truncated final summary) + 10 files / 48/48 in second
    run — all green
  • OnboardingWizard 4/4 (the migrated CreateTeamModalInline test
    case is the second one — `+ New team opens the inline modal,
    calls createTeam, invalidates the cache, and auto-selects the
    new team`)
  • SCEPAdminPage 20/20, ESTAdminPage 14/14, AgentsPage 3/3 — all
    37 modal-page tests stay green after ModalDialog migration
  • npm run build ✓ in 3.27s
  • CI guard: bash scripts/ci-guards/no-unbound-label.sh — passes at
    baseline 132 (current unbound count matches; failure mode is
    only on increase). --strict path will fail until backlog clears.

═══════════════════════════ RESIDUAL RISK ════════════════════════════

  • RHF migration risk: zod resolver's input/output type mismatch
    bit me once during this work (description: z.string().optional()
    gave Input: string|undefined vs Output: string after .default()).
    Both sides typed as string + defaultValues providing empty string
    fixes it; documented in team.schema.ts. Pattern applies to every
    future Zod schema with optional-but-empty-string fields.
  • The audit's "top-6" page list is stale (Phase 4 split
    OnboardingWizard; useState ranks shifted). Future RHF migrations
    should re-derive the priority list against live useState counts,
    not the audit's stamped names.
  • DataTable per-row React.memo (PERF-M1 follow-up from Phase 4)
    remains deferred — orthogonal to Phase 5 scope.
This commit is contained in:
shankar0123
2026-05-14 16:44:37 +00:00
parent 868f1c25be
commit c9f932be65
14 changed files with 1537 additions and 225 deletions
+124 -119
View File
@@ -9,6 +9,7 @@ import {
BlockedByDependenciesError,
} from '../api/client';
import PageHeader from '../components/PageHeader';
import ModalDialog from '../components/ModalDialog';
import DataTable from '../components/DataTable';
import type { Column } from '../components/DataTable';
import StatusBadge from '../components/StatusBadge';
@@ -309,129 +310,133 @@ function RetireModal({
}) {
if (mode.kind === 'closed') return null;
// Phase 5 closure (FE-H3): swapped inline `<div role="dialog">` markup
// for ModalDialog (Headless UI). Each of the 3 modes (confirm / blocked /
// error) renders inside the same dialog shell, so focus trap + ESC + click-
// outside come for free. Title + footer change per mode; body is the
// mode-specific content.
const title =
mode.kind === 'confirm' ? 'Retire agent' :
mode.kind === 'blocked' ? 'Cannot retire — active dependencies' :
/* error */ 'Retire failed';
return (
<div
role="dialog"
aria-modal="true"
className="fixed inset-0 z-40 flex items-center justify-center bg-black/40"
onClick={onClose}
<ModalDialog
open={true}
title={title}
onClose={pending ? () => {} : onClose}
maxWidth="lg"
footer={
mode.kind === 'confirm' ? (
<>
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm text-ink-muted hover:text-ink"
disabled={pending}
>
Cancel
</button>
<button
type="button"
onClick={onSoftRetire}
disabled={pending}
className="px-4 py-2 text-sm font-medium text-white bg-danger rounded hover:bg-danger/90 disabled:opacity-50"
>
{pending ? 'Retiring…' : 'Retire'}
</button>
</>
) : mode.kind === 'blocked' ? (
<>
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm text-ink-muted hover:text-ink"
disabled={pending}
>
Cancel
</button>
<button
type="button"
onClick={onForceRetire}
// Backend enforces reason on force; keep the GUI in lockstep
// rather than letting a 400 bounce back.
disabled={pending || !mode.reason.trim()}
className="px-4 py-2 text-sm font-medium text-white bg-danger rounded hover:bg-danger/90 disabled:opacity-50"
>
{pending ? 'Force-retiring…' : 'Force retire'}
</button>
</>
) : (
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm text-ink-muted hover:text-ink"
>
Close
</button>
)
}
>
<div
className="w-full max-w-lg rounded-lg bg-surface p-6 shadow-lg border border-border"
onClick={(e) => e.stopPropagation()}
>
{mode.kind === 'confirm' && (
<>
<h2 className="text-lg font-semibold text-ink">Retire agent</h2>
<p className="mt-2 text-sm text-ink-muted">
<span className="font-mono">{mode.agent.name}</span> ({mode.agent.id}) will be
soft-retired. The agent will stop receiving heartbeats and be removed from active
listings. This is reversible only by direct database intervention.
</p>
<label className="mt-4 block text-xs font-medium text-ink-muted">
Reason (optional)
<input
type="text"
value={mode.reason}
onChange={(e) => onReasonChange(e.target.value)}
placeholder="e.g. decommissioning rack 7"
className="mt-1 w-full rounded border border-border bg-surface-alt px-2 py-1 text-sm"
/>
</label>
<div className="mt-6 flex justify-end gap-2">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm text-ink-muted hover:text-ink"
disabled={pending}
>
Cancel
</button>
<button
type="button"
onClick={onSoftRetire}
disabled={pending}
className="px-4 py-2 text-sm font-medium text-white bg-danger rounded hover:bg-danger/90 disabled:opacity-50"
>
{pending ? 'Retiring…' : 'Retire'}
</button>
</div>
</>
)}
{mode.kind === 'confirm' && (
<>
<p className="text-sm text-ink-muted">
<span className="font-mono">{mode.agent.name}</span> ({mode.agent.id}) will be
soft-retired. The agent will stop receiving heartbeats and be removed from active
listings. This is reversible only by direct database intervention.
</p>
<label className="mt-4 block text-xs font-medium text-ink-muted">
Reason (optional)
<input
type="text"
value={mode.reason}
onChange={(e) => onReasonChange(e.target.value)}
placeholder="e.g. decommissioning rack 7"
className="mt-1 w-full rounded border border-border bg-surface-alt px-2 py-1 text-sm"
/>
</label>
</>
)}
{mode.kind === 'blocked' && (
<>
<h2 className="text-lg font-semibold text-ink">Cannot retire active dependencies</h2>
<p className="mt-2 text-sm text-ink-muted">
The agent <span className="font-mono">{mode.agent.name}</span> still has downstream
work tied to it. Force-retiring will cascade-retire all active targets and fail any
pending jobs.
</p>
<dl className="mt-4 grid grid-cols-3 gap-3 text-center">
<div className="rounded border border-border bg-surface-alt p-3">
<dt className="text-xs text-ink-muted">Active targets</dt>
<dd className="mt-1 text-xl font-semibold text-ink">{mode.counts.active_targets}</dd>
</div>
<div className="rounded border border-border bg-surface-alt p-3">
<dt className="text-xs text-ink-muted">Active certs</dt>
<dd className="mt-1 text-xl font-semibold text-ink">
{mode.counts.active_certificates}
</dd>
</div>
<div className="rounded border border-border bg-surface-alt p-3">
<dt className="text-xs text-ink-muted">Pending jobs</dt>
<dd className="mt-1 text-xl font-semibold text-ink">{mode.counts.pending_jobs}</dd>
</div>
</dl>
<label className="mt-4 block text-xs font-medium text-ink-muted">
Reason <span className="text-danger">(required for force retire)</span>
<input
type="text"
value={mode.reason}
onChange={(e) => onReasonChange(e.target.value)}
placeholder="e.g. rack 7 decommission, cascade retire"
className="mt-1 w-full rounded border border-border bg-surface-alt px-2 py-1 text-sm"
/>
</label>
<div className="mt-6 flex justify-end gap-2">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm text-ink-muted hover:text-ink"
disabled={pending}
>
Cancel
</button>
<button
type="button"
onClick={onForceRetire}
// Backend enforces reason on force; keep the GUI in lockstep
// rather than letting a 400 bounce back.
disabled={pending || !mode.reason.trim()}
className="px-4 py-2 text-sm font-medium text-white bg-danger rounded hover:bg-danger/90 disabled:opacity-50"
>
{pending ? 'Force-retiring…' : 'Force retire'}
</button>
{mode.kind === 'blocked' && (
<>
<p className="text-sm text-ink-muted">
The agent <span className="font-mono">{mode.agent.name}</span> still has downstream
work tied to it. Force-retiring will cascade-retire all active targets and fail any
pending jobs.
</p>
<dl className="mt-4 grid grid-cols-3 gap-3 text-center">
<div className="rounded border border-border bg-surface-alt p-3">
<dt className="text-xs text-ink-muted">Active targets</dt>
<dd className="mt-1 text-xl font-semibold text-ink">{mode.counts.active_targets}</dd>
</div>
</>
)}
<div className="rounded border border-border bg-surface-alt p-3">
<dt className="text-xs text-ink-muted">Active certs</dt>
<dd className="mt-1 text-xl font-semibold text-ink">
{mode.counts.active_certificates}
</dd>
</div>
<div className="rounded border border-border bg-surface-alt p-3">
<dt className="text-xs text-ink-muted">Pending jobs</dt>
<dd className="mt-1 text-xl font-semibold text-ink">{mode.counts.pending_jobs}</dd>
</div>
</dl>
<label className="mt-4 block text-xs font-medium text-ink-muted">
Reason <span className="text-danger">(required for force retire)</span>
<input
type="text"
value={mode.reason}
onChange={(e) => onReasonChange(e.target.value)}
placeholder="e.g. rack 7 decommission, cascade retire"
className="mt-1 w-full rounded border border-border bg-surface-alt px-2 py-1 text-sm"
/>
</label>
</>
)}
{mode.kind === 'error' && (
<>
<h2 className="text-lg font-semibold text-ink">Retire failed</h2>
<p className="mt-2 text-sm text-danger">{mode.message}</p>
<div className="mt-6 flex justify-end">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm text-ink-muted hover:text-ink"
>
Close
</button>
</div>
</>
)}
</div>
</div>
{mode.kind === 'error' && (
<p className="text-sm text-danger">{mode.message}</p>
)}
</ModalDialog>
);
}