mirror of
https://github.com/certctl-io/certctl.git
synced 2026-08-25 21:31:26 +02:00
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).
170 lines
5.4 KiB
CSS
170 lines
5.4 KiB
CSS
/*
|
|
* Phase 0 hygiene (FE-H4 / PERF-H3): Inter + JetBrains Mono are now
|
|
* self-hosted via the @fontsource* packages, imported at the top of
|
|
* web/src/main.tsx so Vite can hash + bundle the font files. The old
|
|
* Google Fonts @import lived here and produced two cross-origin font
|
|
* requests on every cold load; those are gone and PERF-H3's
|
|
* preconnect/dns-prefetch suggestion collapses (no external host left
|
|
* to preconnect to).
|
|
*/
|
|
|
|
@tailwind base;
|
|
@tailwind components;
|
|
@tailwind utilities;
|
|
|
|
@layer base {
|
|
body {
|
|
@apply bg-page text-ink antialiased;
|
|
/* Phase 0 hygiene (FE-H4): "Inter Variable" is the family name
|
|
registered by @fontsource-variable/inter (single woff2 covering
|
|
wght 100-900). Keep "Inter" as a fallback for older browsers /
|
|
any pinned local install. */
|
|
font-family: 'Inter Variable', 'Inter', system-ui, -apple-system, sans-serif;
|
|
}
|
|
}
|
|
|
|
@layer components {
|
|
/* Badges */
|
|
.badge {
|
|
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold tracking-wide;
|
|
}
|
|
.badge-success { @apply bg-emerald-100 text-emerald-700; }
|
|
.badge-warning { @apply bg-amber-100 text-amber-700; }
|
|
.badge-danger { @apply bg-red-100 text-red-700; }
|
|
.badge-info { @apply bg-brand-100 text-brand-700; }
|
|
.badge-neutral { @apply bg-slate-100 text-slate-600; }
|
|
|
|
/* Cards */
|
|
.card {
|
|
@apply bg-surface border border-surface-border rounded-md shadow-sm;
|
|
}
|
|
|
|
/* Buttons */
|
|
.btn {
|
|
@apply inline-flex items-center justify-center gap-2 px-4 py-2 rounded text-sm font-semibold transition-all duration-150;
|
|
}
|
|
.btn-primary { @apply bg-brand-500 hover:bg-brand-600 text-white shadow-sm; }
|
|
.btn-danger { @apply bg-red-500 hover:bg-red-600 text-white shadow-sm; }
|
|
.btn-ghost { @apply text-ink-muted hover:text-ink hover:bg-surface-muted; }
|
|
.btn-outline { @apply border border-surface-border text-ink-muted hover:text-ink hover:bg-surface-muted; }
|
|
|
|
/* Form inputs */
|
|
.input {
|
|
@apply bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink placeholder:text-ink-faint focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 outline-none transition-colors;
|
|
}
|
|
|
|
/* Monospace data values */
|
|
.mono {
|
|
@apply font-mono text-xs;
|
|
}
|
|
|
|
/* Stat cards with colored top borders */
|
|
.stat-card {
|
|
@apply bg-surface border border-surface-border rounded-md shadow-sm p-5 border-t-4;
|
|
}
|
|
}
|
|
|
|
/*
|
|
* Phase 0 hygiene (UX-L2): honour prefers-reduced-motion. Users who
|
|
* opt out of animation at the OS level get effectively-instant
|
|
* transitions on every animated element (badges, modals, toggles).
|
|
* 0.01ms is the conventional non-zero value — fully zero can break
|
|
* libraries that observe transitionend events.
|
|
*/
|
|
@media (prefers-reduced-motion: reduce) {
|
|
*,
|
|
::before,
|
|
::after {
|
|
animation-duration: 0.01ms !important;
|
|
animation-iteration-count: 1 !important;
|
|
transition-duration: 0.01ms !important;
|
|
scroll-behavior: auto !important;
|
|
}
|
|
}
|
|
|
|
/*
|
|
* Phase 0 hygiene (UX-L3): a baseline print stylesheet. Hides the
|
|
* sidebar + top action bars, removes card shadows, expands content
|
|
* to full width, and keeps table rows intact across page breaks.
|
|
* Operator-facing — operators print certificate detail pages and
|
|
* audit-log exports for compliance archives.
|
|
*/
|
|
@media print {
|
|
/* Drop sidebar / nav chrome — only the content matters in print. */
|
|
aside,
|
|
nav,
|
|
[role="navigation"],
|
|
.no-print {
|
|
display: none !important;
|
|
}
|
|
|
|
/* Full-width content, no shadows, plain backgrounds (ink saving). */
|
|
body {
|
|
background: #ffffff !important;
|
|
color: #000000 !important;
|
|
}
|
|
main,
|
|
.card,
|
|
.stat-card {
|
|
width: 100% !important;
|
|
max-width: 100% !important;
|
|
margin: 0 !important;
|
|
box-shadow: none !important;
|
|
border-color: #cbd5e1 !important;
|
|
page-break-inside: avoid;
|
|
}
|
|
|
|
/* Tables: prevent mid-row breaks, repeat headers on each page. */
|
|
table {
|
|
width: 100% !important;
|
|
}
|
|
thead {
|
|
display: table-header-group;
|
|
}
|
|
tr,
|
|
td,
|
|
th {
|
|
page-break-inside: avoid;
|
|
}
|
|
|
|
/* Show link hrefs alongside the visible text — print readers
|
|
can't click links, so the target URL is the only signal. */
|
|
a[href]::after {
|
|
content: " (" attr(href) ")";
|
|
font-size: 0.85em;
|
|
color: #555555;
|
|
}
|
|
a[href^="#"]::after,
|
|
a[href^="javascript:"]::after {
|
|
content: "";
|
|
}
|
|
}
|
|
|
|
/*
|
|
* Phase 9 closure (FE-M2 — operator decision 2026-05-14): desktop-only.
|
|
* The audit flagged 29 partial sm:/md:/lg: responsive classes scattered
|
|
* across a handful of files, suggesting mobile support that isn't
|
|
* actually shipped. Operator chose path (a): document desktop-only +
|
|
* add a viewport-narrow banner; the partial responsive classes stay
|
|
* (no benefit to ripping them out — they don't hurt at desktop widths
|
|
* and may help if the decision ever reverses).
|
|
*
|
|
* Banner triggers at < 1024px (Tailwind `lg` breakpoint — the layout
|
|
* starts visibly cramping below this). It's a single fixed bar at the
|
|
* top of the viewport, doesn't block interaction (z-index high, but
|
|
* pointer-events: none on the rest of the body), and dismisses with a
|
|
* one-click "Dismiss" affordance that persists to localStorage.
|
|
*
|
|
* Operators who explicitly want narrow-viewport access (responsive
|
|
* design work, mobile demo, screen-recording at portrait orientation)
|
|
* can dismiss and the banner stays gone for that browser.
|
|
*/
|
|
@media (max-width: 1023px) {
|
|
.desktop-only-banner {
|
|
display: flex;
|
|
}
|
|
}
|
|
.desktop-only-banner {
|
|
display: none;
|
|
}
|