feat(web): Phase 9 — backend-coupled + page-specific closures (5 shipped, 2 deferred)

Closes the frontend-design-audit Phase 9 batch — the audit's
"backend-coupled or page-specific" tier. Five findings ship; two
defer to follow-ups that need backend handler work.

Shipped:

PERF-M2 — Build-time version + hidden sourcemaps
  • vite.config.ts: `sourcemap: 'hidden'` (was `false`). Maps emit
    to dist/ but are NOT referenced by JS, so browsers don't fetch
    them. The maps stay available for Sentry-class upload at
    release time. Comment-block above the build config documents
    the tradeoff so a future operator doesn't re-flip to `false`
    without realising they're losing release-time debuggability.
  • `__APP_VERSION__` build-time `define` reads `web/package.json`
    `version` so ErrorBoundary can stamp the build into telemetry
    payloads (was previously hardcoded `'dev'`).

FE-L1 — ErrorBoundary copy-trace + telemetry gate
  • 50 → 185 LOC rewrite of web/src/components/ErrorBoundary.tsx.
  • componentDidCatch now POSTs an ErrorPayload (build version,
    UA, href, timestamp, error name + message + stack,
    componentStack) to `VITE_ERROR_TELEMETRY_URL` IF that env var
    is set at build time. Uses navigator.sendBeacon (page-unload-
    safe) → falls back to fetch + keepalive. Unset = no POST,
    no console-error spam.
  • Operator-facing "Copy details" button writes the same payload
    as JSON to the clipboard (navigator.clipboard API → execCommand
    fallback for older browsers). A `<details>` block (collapsed
    by default) shows the stack + componentStack inline so the
    operator can grok the failure without leaving the page.
  • Two new data-testid hooks (`error-boundary-reload`,
    `error-boundary-copy`) for QA + future Playwright coverage.
  • web/src/components/ErrorBoundary.test.tsx — 5 vitest specs:
    no-error pass-through, error fallback structure, copy payload
    shape, details collapsed-by-default, NO telemetry POST when
    URL is unset. cleanup() between tests + console.error
    silenced via the React-error-handling pattern.

UX-M8 — DataTable density toggle (opt-in via tableId)
  • Density type ('compact' | 'comfortable' | 'spacious') + per-
    density cell/header class maps. Default 'comfortable' matches
    the existing px-4 py-3 padding so all callers see byte-
    identical layout until they opt in.
  • DataTableProps gains optional `tableId` + `density` props.
    Pages that pass `tableId` get a 3-button DensityToggle
    (Compact / Cozy / Spacious) rendered above the table; the
    selection persists to localStorage at
    `certctl:table-density:<tableId>`. No tableId = no toggle =
    no behavioral change for the 17 other tables.
  • Hardcoded `px-4 py-3` replaced with the `cellCls` /
    `headerCls` lookup against the active density. Three Tailwind
    permutations cover compact (px-3 py-1.5), comfortable
    (px-4 py-3), spacious (px-5 py-5).

UX-M7 (lever) — CI guard against new raw `<table>` regressions
  • scripts/ci-guards/no-raw-table.sh: counts `<table` tags in
    `web/src/**/*.tsx` (production only, tests excluded) outside
    the canonical primitives (DataTable.tsx + Skeleton.tsx) and
    fails CI if the count climbs above baseline. `--strict` mode
    rejects any raw table once the backlog clears.
  • Baseline pinned at 17 (the current count of page-level raw
    tables — verified via the same grep the guard uses). Every
    page migration to <DataTable> drops the baseline by 1; new
    pages MUST route through <DataTable>.
  • No representative migrations in this commit (operator
    decision: ship the lever first, migrations as follow-up PRs).
  • Pairs with the existing CI guard suite (no-unbound-label,
    no-raw-toLocaleString, no-eager-issuer-deletes, etc.) —
    same baseline-locked pattern.

FE-M2 — Desktop-only banner (operator chose path a: 2026-05-14)
  • web/src/components/DesktopOnlyBanner.tsx: fixed top bar at
    viewports < 1024px (Tailwind `lg` breakpoint, below which the
    sidebar + content layout starts visibly cramping). Amber
    "Desktop-only: certctl is designed for viewports ≥ 1024px"
    notice with a Dismiss button that persists to localStorage
    (`certctl:desktop-only-banner-dismissed`).
  • web/src/index.css: `.desktop-only-banner` is `display: none`
    by default and `display: flex` inside the
    `@media (max-width: 1023px)` block. CSS-gated visibility,
    not React state — the banner mounts always but only renders
    visibly on narrow viewports.
  • web/src/main.tsx: mounts the banner inside ErrorBoundary,
    above QueryClientProvider, so it survives any provider
    failure that breaks the rest of the tree.
  • Operator-stated rationale (recorded in DesktopOnlyBanner.tsx
    header comment): the audit flagged 29 partial sm:/md:/lg:
    responsive classes that suggest mobile support which isn't
    actually shipped. Rather than rip out the partials (zero
    benefit at desktop widths) or ship full mobile (1+ sprint of
    QA + ongoing maintenance), this ships an honest signal —
    "we don't promise mobile" — that doesn't claim support that
    isn't there. The partials stay (no benefit to ripping out;
    they may help if the decision reverses).

Deferred:

P-H2 — AuditPage server-side time filters
  Requires backend changes to internal/api/handler/audit.go +
  service + repository: ListAuditEvents currently accepts only
  page/per_page/category. Adds `since` / `until` ISO-8601
  params (UTC), pushes the timestamp predicate into the SQL
  query, surfaces them in OpenAPI + MCP. Queued as a backend-
  first follow-up bundle.

P-M1 — DiscoveryPage in-flight scan panel
  Out of scope for the frontend remediation pass; needs a
  websocket / SSE channel from internal/service/discovery.go to
  the frontend (current poll-and-render UI works against the
  existing endpoint set). Queued.

Verification:
  • npx tsc --noEmit — exits 0
  • npx vitest run ErrorBoundary StatusBadge — 80/80 passed
  • npm run build — ✓ built in 3.11s
  • bash scripts/ci-guards/no-raw-table.sh —
      Raw <table> tags outside DataTable + Skeleton — current: 17, baseline: 17
  • Bundle shapes unchanged from Phase 4 (91.66 KB raw / 25.92 KB gz
    initial chunk); the ErrorBoundary rewrite adds ~5 KB to index.

Falsifiable proof for the next CI run:
  • Frontend Build job's `npm ci` step completes (Hotfix #9 settled
    the Storybook peer conflict).
  • New no-raw-table.sh guard exits 0 with current=17 baseline=17.
  • All 34 CI guards (was 33, +1 for no-raw-table) pass.

Per-finding closure entries land in frontend-design-audit.html in
the follow-up commit (audit HTML update).
This commit is contained in:
shankar0123
2026-05-14 18:27:18 +00:00
parent 5231609f26
commit aa1c12ae2d
9 changed files with 655 additions and 23 deletions
@@ -0,0 +1 @@
17
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env bash
# Phase 9 closure (UX-M7 regression gate): fail CI when a new raw
# `<table>` ships in production tsx outside the canonical DataTable
# + Skeleton primitives.
#
# Pre-Phase-9 the codebase had 19 `<table>` sites across 16 files.
# Two of those are LEGITIMATE primitives — they ARE the chokepoint
# every list page should route through:
# • web/src/components/DataTable.tsx — the canonical table component
# • web/src/components/Skeleton.tsx — the loading-shape table-shaped
# skeleton
#
# The other 14 page-level raw tables stay in place during the Phase 9
# rollout (the audit prompt's "DO NOT migrate all 18 in one PR" rule).
# This guard baseline-locks the existing 14; every migration to
# DataTable drops the baseline by 1. `--strict` mode rejects any raw
# table once the backlog clears.
#
# Tests are excluded.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
BASELINE_FILE="$SCRIPT_DIR/no-raw-table-baseline.txt"
cd "$SCRIPT_DIR/../../web"
STRICT=0
[[ "${1:-}" == "--strict" ]] && STRICT=1
# Count <table tags outside DataTable.tsx + Skeleton.tsx (the
# allowlisted primitives) in production tsx (excludes tests +
# node_modules + dist).
COUNT_RAW=$(
grep -rl '<table' src \
--include='*.tsx' \
--exclude='*.test.*' \
--exclude-dir='__tests__' \
--exclude-dir='node_modules' \
--exclude-dir='dist' \
2>/dev/null \
| grep -vE '(DataTable\.tsx|Skeleton\.tsx)$' \
| xargs -r grep -ohE '<table\b' 2>/dev/null \
| wc -l \
| tr -d '[:space:]'
)
COUNT_RAW=${COUNT_RAW:-0}
BASELINE=0
if [[ -f "$BASELINE_FILE" ]]; then
BASELINE=$(cat "$BASELINE_FILE" | tr -d '[:space:]')
fi
echo "Raw <table> tags outside DataTable + Skeleton — current: $COUNT_RAW, baseline: $BASELINE"
if [[ $STRICT -eq 1 ]]; then
if [[ $COUNT_RAW -gt 0 ]]; then
echo "FAIL (--strict): $COUNT_RAW raw <table> tag(s) remain. Migrate to <DataTable> from web/src/components/DataTable.tsx."
exit 1
fi
echo "PASS (--strict): zero raw <table> tags."
exit 0
fi
if [[ $COUNT_RAW -gt $BASELINE ]]; then
echo ""
echo "FAIL: A new raw <table> tag was added ($COUNT_RAW > baseline $BASELINE)."
echo ""
echo "Migrate to <DataTable> from web/src/components/DataTable.tsx —"
echo "it provides StatusBadge wiring, EmptyState slot, Skeleton loading,"
echo "pagination, selectable rows, and the Phase 9 UX-M8 density toggle"
echo "for free."
echo ""
exit 1
fi
if [[ $COUNT_RAW -lt $BASELINE ]]; then
echo ""
echo "PASS — and you're under baseline! Drop the baseline to lock in progress:"
echo " echo $COUNT_RAW > $BASELINE_FILE"
echo ""
fi
exit 0