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
+
+ {/* 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,