From aa1c12ae2d17f664a746e5a777d7fa6a805fba35 Mon Sep 17 00:00:00 2001 From: shankar0123 Date: Thu, 14 May 2026 18:27:18 +0000 Subject: [PATCH] =?UTF-8?q?feat(web):=20Phase=209=20=E2=80=94=20backend-co?= =?UTF-8?q?upled=20+=20page-specific=20closures=20(5=20shipped,=202=20defe?= =?UTF-8?q?rred)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `
` 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:`. 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 `` regressions • scripts/ci-guards/no-raw-table.sh: counts ` drops the baseline by 1; new pages MUST route through . • 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
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). --- scripts/ci-guards/no-raw-table-baseline.txt | 1 + scripts/ci-guards/no-raw-table.sh | 84 ++++++++ web/src/components/DataTable.tsx | 120 ++++++++++- web/src/components/DesktopOnlyBanner.tsx | 66 ++++++ web/src/components/ErrorBoundary.test.tsx | 131 ++++++++++++ web/src/components/ErrorBoundary.tsx | 212 ++++++++++++++++++-- web/src/index.css | 28 +++ web/src/main.tsx | 5 + web/vite.config.ts | 31 ++- 9 files changed, 655 insertions(+), 23 deletions(-) create mode 100644 scripts/ci-guards/no-raw-table-baseline.txt create mode 100755 scripts/ci-guards/no-raw-table.sh create mode 100644 web/src/components/DesktopOnlyBanner.tsx create mode 100644 web/src/components/ErrorBoundary.test.tsx diff --git a/scripts/ci-guards/no-raw-table-baseline.txt b/scripts/ci-guards/no-raw-table-baseline.txt new file mode 100644 index 0000000..98d9bcb --- /dev/null +++ b/scripts/ci-guards/no-raw-table-baseline.txt @@ -0,0 +1 @@ +17 diff --git a/scripts/ci-guards/no-raw-table.sh b/scripts/ci-guards/no-raw-table.sh new file mode 100755 index 0000000..c73c6b7 --- /dev/null +++ b/scripts/ci-guards/no-raw-table.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# Phase 9 closure (UX-M7 regression gate): fail CI when a new raw +# `
` ships in production tsx outside the canonical DataTable +# + Skeleton primitives. +# +# Pre-Phase-9 the codebase had 19 `
` 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
/dev/null \ + | grep -vE '(DataTable\.tsx|Skeleton\.tsx)$' \ + | xargs -r grep -ohE '/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
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
tag(s) remain. Migrate to from web/src/components/DataTable.tsx." + exit 1 + fi + echo "PASS (--strict): zero raw
tags." + exit 0 +fi + +if [[ $COUNT_RAW -gt $BASELINE ]]; then + echo "" + echo "FAIL: A new raw
tag was added ($COUNT_RAW > baseline $BASELINE)." + echo "" + echo "Migrate to 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 diff --git a/web/src/components/DataTable.tsx b/web/src/components/DataTable.tsx index 8176871..2948346 100644 --- a/web/src/components/DataTable.tsx +++ b/web/src/components/DataTable.tsx @@ -1,6 +1,43 @@ +import { useEffect, useState } from 'react'; import type { ReactNode } from 'react'; import Skeleton from './Skeleton'; +// Phase 9 closure (UX-M8): row-density toggle. Three tiers map to the +// vertical padding on tbody td elements. Compact wins at 5K-row dense +// data review; Spacious wins for low-attention scanning; Comfortable +// is the existing pre-Phase-9 default. Choice persists per-table via +// the `tableId` prop — keyed at certctl.density. so two tables on +// one page don't fight each other. +export type Density = 'compact' | 'comfortable' | 'spacious'; + +const DENSITY_CELL_CLASS: Record = { + compact: 'px-4 py-1.5', + comfortable: 'px-4 py-3', + spacious: 'px-4 py-4', +}; + +const DENSITY_HEADER_CLASS: Record = { + compact: 'px-4 py-2', + comfortable: 'px-4 py-3', + spacious: 'px-4 py-3.5', +}; + +function readDensityPref(tableId: string | undefined): Density { + if (!tableId || typeof localStorage === 'undefined') return 'comfortable'; + try { + const v = localStorage.getItem(`certctl.density.${tableId}`); + if (v === 'compact' || v === 'comfortable' || v === 'spacious') return v; + } catch { /* noop */ } + return 'comfortable'; +} + +function writeDensityPref(tableId: string | undefined, d: Density): void { + if (!tableId || typeof localStorage === 'undefined') return; + try { + localStorage.setItem(`certctl.density.${tableId}`, d); + } catch { /* noop */ } +} + interface Column { key: string; label: string; @@ -45,9 +82,42 @@ interface DataTableProps { selectedKeys?: Set; onSelectionChange?: (keys: Set) => void; pagination?: PaginationProps; + /** + * Phase 9 (UX-M8): per-table identifier for the density preference. + * Use a stable string like `'certificates-list'` — choice persists + * to localStorage at `certctl.density.`. When unset, the + * density toggle is hidden (the table renders at the default + * 'comfortable' density) — opt-in per-page rollout. + */ + tableId?: string; + /** + * Initial density. Overridden by the persisted preference when + * tableId is set. Defaults to 'comfortable' (matches pre-Phase-9 + * vertical padding exactly so existing pages render identically + * until an operator flips the toggle). + */ + density?: Density; } -export default function DataTable({ columns, data, onRowClick, emptyMessage, emptyState, isLoading, keyField = 'id', selectable, selectedKeys, onSelectionChange, pagination }: DataTableProps) { +export default function DataTable({ columns, data, onRowClick, emptyMessage, emptyState, isLoading, keyField = 'id', selectable, selectedKeys, onSelectionChange, pagination, tableId, density: densityProp }: DataTableProps) { + // Phase 9 (UX-M8): density preference. When tableId is set, read + // localStorage at mount; otherwise use the prop default (or + // 'comfortable'). Persist writes via setDensity. + const [density, setDensityState] = useState(() => + tableId ? readDensityPref(tableId) : (densityProp ?? 'comfortable'), + ); + useEffect(() => { + // If tableId changes (rare but possible if a parent swaps it), + // re-read the persisted preference. + if (tableId) setDensityState(readDensityPref(tableId)); + }, [tableId]); + + const setDensity = (d: Density) => { + setDensityState(d); + writeDensityPref(tableId, d); + }; + const cellCls = DENSITY_CELL_CLASS[density]; + const headerCls = DENSITY_HEADER_CLASS[density]; // Phase 4 closure (UX-M1): swap the centered spinner + "Loading..." // text — which paints into a tiny vertical span and then jumps to a // full-height table on resolve, the canonical CLS source — for a @@ -94,11 +164,14 @@ export default function DataTable({ columns, data, onRowClick, emptyMessage, return (
+ {tableId && ( + + )}
{selectable && ( - )} {columns.map(col => ( - ))} @@ -125,7 +198,7 @@ export default function DataTable({ columns, data, onRowClick, emptyMessage, className={`border-b border-surface-border/50 transition-colors hover:bg-surface-muted ${onRowClick ? 'cursor-pointer' : ''} ${isSelected ? 'bg-brand-50' : ''}`} > {selectable && ( - )} {columns.map(col => ( - ))} @@ -152,6 +225,43 @@ export default function DataTable({ columns, data, onRowClick, emptyMessage, ); } +/** + * Phase 9 UX-M8: 3-button row-density toggle. Renders only when the + * parent DataTable was given a `tableId` (the opt-in signal that this + * page wants the per-table localStorage persistence). + */ +function DensityToggle({ current, onChange }: { current: Density; onChange: (d: Density) => void }) { + const opts: { value: Density; label: string }[] = [ + { value: 'compact', label: 'Compact' }, + { value: 'comfortable', label: 'Cozy' }, + { value: 'spacious', label: 'Spacious' }, + ]; + return ( +
+
+ {opts.map((o, i) => ( + + ))} +
+
+ ); +} + // F-1 closure (cat-k-e85d1099b2d7): pagination footer for DataTable // consumers that want prev/next + page counter + per-page selector // against a paginated backend response. Disabling logic guards the diff --git a/web/src/components/DesktopOnlyBanner.tsx b/web/src/components/DesktopOnlyBanner.tsx new file mode 100644 index 0000000..a210879 --- /dev/null +++ b/web/src/components/DesktopOnlyBanner.tsx @@ -0,0 +1,66 @@ +// Copyright 2026 certctl LLC. All rights reserved. +// SPDX-License-Identifier: BUSL-1.1 +// +// DesktopOnlyBanner — Phase 9 closure for FE-M2 (operator decision +// 2026-05-14: certctl is desktop-only). Renders a top-of-viewport +// notice when the viewport is narrower than the `lg` Tailwind +// breakpoint (1024px) telling operators they're outside the +// supported viewport. +// +// Visibility is gated by CSS media query (.desktop-only-banner in +// src/index.css). Component dismissal persists to localStorage so an +// operator who needs occasional narrow-viewport access doesn't see +// the banner forever. +// +// Pairs with the operator's FE-M2 decision: rather than rip out the +// 29 partial sm:/md:/lg: responsive classes (zero benefit at +// desktop widths) OR ship full mobile (1+ sprint of QA + ongoing +// maintenance), the project ships an HONEST signal — "we don't +// promise mobile" — that doesn't claim support that isn't there. + +import { useEffect, useState } from 'react'; + +const STORAGE_KEY = 'certctl:desktop-only-banner-dismissed'; + +export default function DesktopOnlyBanner() { + const [dismissed, setDismissed] = useState(() => { + if (typeof localStorage === 'undefined') return false; + try { + return localStorage.getItem(STORAGE_KEY) === 'true'; + } catch { + return false; + } + }); + + useEffect(() => { + if (dismissed && typeof localStorage !== 'undefined') { + try { + localStorage.setItem(STORAGE_KEY, 'true'); + } catch { /* noop */ } + } + }, [dismissed]); + + if (dismissed) return null; + + return ( +
+ + Desktop-only: certctl is designed for viewports ≥ 1024px. Some UI may render cramped at this width. + + +
+ ); +} diff --git a/web/src/components/ErrorBoundary.test.tsx b/web/src/components/ErrorBoundary.test.tsx new file mode 100644 index 0000000..96ea88f --- /dev/null +++ b/web/src/components/ErrorBoundary.test.tsx @@ -0,0 +1,131 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'; +import ErrorBoundary from './ErrorBoundary'; + +// Phase 9 FE-L1 closure tests — pin the new contract: +// • Error rendered → "Reload Page" + "Copy details" buttons visible. +// • "Copy details" populates navigator.clipboard with a JSON payload +// containing message, stack, componentStack, userAgent, url, +// buildVersion, timestamp. +// • Telemetry POST is gated on VITE_ERROR_TELEMETRY_URL (unset = +// no fetch; set = single sendBeacon-or-fetch call). +// • Error-details
block stays collapsed by default. + +function Boom(): never { + throw new Error('test-boundary-trip'); +} + +function silenceConsole(fn: () => void | Promise) { + // React + jsdom log the component error to console.error; mute for + // test-output cleanliness without losing real-error visibility in + // dev (we restore the original after). + const origError = console.error; + console.error = () => {}; + try { + return fn(); + } finally { + console.error = origError; + } +} + +describe('ErrorBoundary — Phase 9 FE-L1 expansion', () => { + beforeEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + it('renders children when no error', () => { + render( + + healthy + , + ); + expect(screen.getByText('healthy')).toBeInTheDocument(); + }); + + it('renders fallback + Reload + Copy buttons when child throws', () => { + silenceConsole(() => { + render( + + + , + ); + }); + expect(screen.getByText(/Something went wrong/i)).toBeInTheDocument(); + // "test-boundary-trip" appears in the

message AND inside the + //

 stack trace — assert at least one match exists.
+    expect(screen.getAllByText(/test-boundary-trip/).length).toBeGreaterThan(0);
+    expect(screen.getByTestId('error-boundary-reload')).toBeInTheDocument();
+    expect(screen.getByTestId('error-boundary-copy')).toBeInTheDocument();
+  });
+
+  it('Copy details writes a JSON payload to navigator.clipboard', async () => {
+    const writeText = vi.fn().mockResolvedValue(undefined);
+    Object.defineProperty(navigator, 'clipboard', {
+      configurable: true,
+      value: { writeText },
+    });
+
+    silenceConsole(() => {
+      render(
+        
+          
+        ,
+      );
+    });
+
+    fireEvent.click(screen.getByTestId('error-boundary-copy'));
+
+    await waitFor(() => expect(writeText).toHaveBeenCalledTimes(1));
+    const arg = writeText.mock.calls[0][0] as string;
+    const payload = JSON.parse(arg);
+    expect(payload.message).toBe('test-boundary-trip');
+    expect(typeof payload.stack).toBe('string');
+    expect(typeof payload.componentStack).toBe('string');
+    expect(typeof payload.userAgent).toBe('string');
+    expect(typeof payload.url).toBe('string');
+    expect(typeof payload.buildVersion).toBe('string');
+    expect(typeof payload.timestamp).toBe('string');
+
+    await waitFor(() => {
+      expect(screen.getByTestId('error-boundary-copy')).toHaveTextContent(/Copied/);
+    });
+  });
+
+  it('error-details 
block is collapsed by default', () => { + silenceConsole(() => { + render( + + + , + ); + }); + const details = screen.getByText('Error details').closest('details'); + expect(details).toBeTruthy(); + expect(details).not.toHaveAttribute('open'); + }); + + it('does NOT POST telemetry when VITE_ERROR_TELEMETRY_URL is unset (default)', () => { + // The constant is evaluated at module-load; in the test env + // import.meta.env.VITE_ERROR_TELEMETRY_URL is undefined, so the + // telemetry hook is a no-op. Verify via fetch + sendBeacon spies. + const fetchSpy = vi.fn().mockResolvedValue(new Response()); + globalThis.fetch = fetchSpy as never; + const sendBeacon = vi.fn(); + Object.defineProperty(navigator, 'sendBeacon', { + configurable: true, + value: sendBeacon, + }); + + silenceConsole(() => { + render( + + + , + ); + }); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(sendBeacon).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/components/ErrorBoundary.tsx b/web/src/components/ErrorBoundary.tsx index c7feca2..2f8334c 100644 --- a/web/src/components/ErrorBoundary.tsx +++ b/web/src/components/ErrorBoundary.tsx @@ -1,3 +1,29 @@ +// Copyright 2026 certctl LLC. All rights reserved. +// SPDX-License-Identifier: BUSL-1.1 +// +// ErrorBoundary — Phase 9 closure for FE-L1 (50-line stub with no copy- +// stack-trace affordance, no telemetry hook). Pre-Phase-9 a production +// exception left operators staring at a one-line "Something went wrong" +// with no way to capture the stack for a bug report. +// +// Phase 9 expansion adds: +// • Full stack trace + component-stack rendered in a
block +// (collapsed by default so the visual posture stays calm; expert +// operators expand for triage). +// • "Copy details" button that copies a structured JSON payload to +// the clipboard for paste into a bug report or Slack thread. +// Payload: { message, stack, componentStack, userAgent, url, +// buildVersion, timestamp }. +// • Optional telemetry POST gated on the VITE_ERROR_TELEMETRY_URL +// build-time env var. When set, the boundary fires a single POST +// with the same payload to the configured endpoint. No-op when +// unset (no Sentry-class endpoint is part of certctl-server v2; +// this hook is forward-compat for when one lands). +// +// Pairs with Phase 9's PERF-M2 closure: vite.config.ts now emits +// `sourcemap: 'hidden'` so a future Sentry release-artifact upload +// can symbolicate these stack traces against the unminified source. + import { Component, type ErrorInfo, type ReactNode } from 'react'; interface Props { @@ -7,44 +33,196 @@ interface Props { interface State { hasError: boolean; error: Error | null; + errorInfo: ErrorInfo | null; + copyStatus: 'idle' | 'copied' | 'failed'; +} + +interface ErrorPayload { + message: string; + stack: string; + componentStack: string; + userAgent: string; + url: string; + buildVersion: string; + timestamp: string; +} + +/** + * Buildversion is injected by Vite at build time via define() — + * falling back to 'dev' if missing means local dev doesn't fail to + * compile. + */ +const BUILD_VERSION = ( + typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'dev' +); + +declare const __APP_VERSION__: string; + +/** + * Optional Sentry-class endpoint. When set, the boundary POSTs the + * error payload as JSON. Empty / unset = no telemetry (the safe + * default; v2 certctl-server doesn't expose a /telemetry/errors + * endpoint). + */ +const TELEMETRY_URL = ( + // Vite exposes build-time env vars on import.meta.env (typed as + // `unknown` in TS until vite/client types load). Cast through unknown + // so the unset-undefined path stays sound. + (import.meta.env as Record) + .VITE_ERROR_TELEMETRY_URL || '' +); + +function buildPayload(error: Error, errorInfo: ErrorInfo | null): ErrorPayload { + return { + message: error.message || 'Unknown error', + stack: error.stack || '(no stack)', + componentStack: errorInfo?.componentStack || '(no component stack)', + userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown', + url: typeof window !== 'undefined' ? window.location.href : 'unknown', + buildVersion: BUILD_VERSION, + timestamp: new Date().toISOString(), + }; +} + +async function copyToClipboard(text: string): Promise { + // Prefer navigator.clipboard (modern + async). Falls back to the + // execCommand path only if clipboard isn't available (e.g. old + // browsers, file://, http:// in some browsers). Returns true on + // success. + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return true; + } + } catch { /* fall through */ } + // Legacy fallback — works in jsdom for tests + on http origins. + try { + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.select(); + const ok = document.execCommand?.('copy') ?? false; + document.body.removeChild(ta); + return ok; + } catch { + return false; + } +} + +function postTelemetry(payload: ErrorPayload): void { + if (!TELEMETRY_URL) return; + // Best-effort fire-and-forget. We deliberately don't await — a slow + // telemetry endpoint MUST NOT block the user's "click Reload" path. + // navigator.sendBeacon is the right primitive for this case (queued + // by the browser, survives navigation) but it requires a Blob; fall + // back to fetch() with keepalive: true otherwise. + try { + const body = JSON.stringify(payload); + if (typeof navigator !== 'undefined' && navigator.sendBeacon) { + navigator.sendBeacon(TELEMETRY_URL, new Blob([body], { type: 'application/json' })); + return; + } + fetch(TELEMETRY_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + keepalive: true, + }).catch(() => { /* swallow; telemetry must never raise */ }); + } catch { /* swallow */ } } export default class ErrorBoundary extends Component { constructor(props: Props) { super(props); - this.state = { hasError: false, error: null }; + this.state = { hasError: false, error: null, errorInfo: null, copyStatus: 'idle' }; } - static getDerivedStateFromError(error: Error): State { + static getDerivedStateFromError(error: Error): Partial { return { hasError: true, error }; } componentDidCatch(error: Error, errorInfo: ErrorInfo) { console.error('Uncaught component error:', error, errorInfo); + this.setState({ errorInfo }); + postTelemetry(buildPayload(error, errorInfo)); } + handleCopy = async () => { + if (!this.state.error) return; + const payload = buildPayload(this.state.error, this.state.errorInfo); + const ok = await copyToClipboard(JSON.stringify(payload, null, 2)); + this.setState({ copyStatus: ok ? 'copied' : 'failed' }); + // Reset to idle after 2s so the operator can copy again if needed. + setTimeout(() => this.setState({ copyStatus: 'idle' }), 2_000); + }; + + handleReload = () => { + this.setState({ hasError: false, error: null, errorInfo: null, copyStatus: 'idle' }); + window.location.reload(); + }; + render() { - if (this.state.hasError) { - return ( -
-
-

Something went wrong

-

- {this.state.error?.message || 'An unexpected error occurred'} -

+ if (!this.state.hasError || !this.state.error) { + return this.props.children; + } + const payload = buildPayload(this.state.error, this.state.errorInfo); + const copyLabel = + this.state.copyStatus === 'copied' ? 'Copied!' : + this.state.copyStatus === 'failed' ? 'Copy failed' : + 'Copy details'; + + return ( +
+
+

Something went wrong

+

+ {this.state.error.message || 'An unexpected error occurred'} +

+ +
+
+ + {/* Stack trace collapsed by default. Expert operators expand + for triage; copy-button surfaces the same payload as JSON + for paste into bug reports. */} +
+ Error details +
+
+
Build
+
{payload.buildVersion} · {payload.timestamp}
+
+
+
Stack
+
{payload.stack}
+
+
+
Component stack
+
{payload.componentStack}
+
+
+
- ); - } - return this.props.children; +
+ ); } } diff --git a/web/src/index.css b/web/src/index.css index e993170..23a1df2 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -139,3 +139,31 @@ 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; +} diff --git a/web/src/main.tsx b/web/src/main.tsx index 215d331..efc35eb 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -99,6 +99,10 @@ import Toaster from './components/Toaster'; // keydown binding stays scoped to the React tree (auto-cleanup on // HMR + StrictMode). import CommandPaletteHost from './components/CommandPaletteHost'; +// Phase 9 closure (FE-M2 operator-decision: desktop-only stance). +// Renders a top-of-viewport notice when viewport < 1024px; gated +// by CSS media query in src/index.css, dismissable + persisted. +import DesktopOnlyBanner from './components/DesktopOnlyBanner'; import { STALE_TIME, GC_TIME } from './api/queryConstants'; import './index.css'; @@ -139,6 +143,7 @@ function lazyRoute(element: React.ReactNode) { createRoot(document.getElementById('root')!).render( + diff --git a/web/vite.config.ts b/web/vite.config.ts index 3ce1846..39d7025 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -9,8 +9,28 @@ import react from '@vitejs/plugin-react' // because the dev cert is self-signed by deploy/test bootstrap and // changes per-checkout — production stops validation at the reverse // proxy or load balancer, not the Vite dev server. +// Phase 9 FE-L1 closure: ship the package.json version into the +// bundle as a build-time constant. ErrorBoundary's copy-trace payload +// uses this so a copied stack trace tells the operator which release +// produced the error. Pulled from package.json at config-load time +// (no runtime cost). Falls back to 'dev' if unreadable. +function readPkgVersion(): string { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const pkg = require('./package.json') as { version?: string }; + return pkg.version || 'dev'; + } catch { + return 'dev'; + } +} + export default defineConfig({ plugins: [react()], + define: { + // Compile-time replace of __APP_VERSION__ in src files. Quoted + // so the replaced token becomes a string literal in the bundle. + __APP_VERSION__: JSON.stringify(readPkgVersion()), + }, server: { port: 5173, proxy: { @@ -20,7 +40,16 @@ export default defineConfig({ }, build: { outDir: 'dist', - sourcemap: false, + // Phase 9 closure (PERF-M2): 'hidden' generates source maps to + // disk but does NOT emit a `//# sourceMappingURL=` comment in the + // production JS chunks — so they're not loadable via the browser + // (no risk of exposing original source to operators in DevTools), + // but the operator (or a future Sentry/error-reporting integration) + // can still upload them as release artifacts for symbolication of + // FE-L1 ErrorBoundary stack traces. Pre-fix the value was `false` + // (no maps at all), which means ANY production exception's stack + // traces are minified-only — useless for triage. + sourcemap: 'hidden', // Phase 4 closure (FE-M5 + SCALE-H1): vendor manualChunks. Pre-Phase-4 // the single index-*.js chunk weighed ~1.07 MB raw / ~281 KB gz because // every dependency landed in the same first-load file. Splitting React,
+ ({ columns, data, onRowClick, emptyMessage, + {col.label} + ({ columns, data, onRowClick, emptyMessage, + {col.render(item)}