+ );
+}
diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css
index 2e31bf50f..b2a1b48ac 100644
--- a/frontend/src/app/globals.css
+++ b/frontend/src/app/globals.css
@@ -963,6 +963,66 @@ body.barcode-scanner-active .barcode-scanner-ui {
}
}
+/* Live dot on the public workbench surfaces (landing hero, /demo). */
+@keyframes rePulse {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.35;
+ }
+}
+
+.re-pulse-dot {
+ /* The global prefers-reduced-motion block below stops the pulse. */
+ animation: rePulse 2s cubic-bezier(0.4, 0, 0.2, 1) infinite;
+}
+
+/* The two dark-only public screens (landing, /demo) set display type in
+ Outfit. Scoped to a class so the rest of the site keeps Inter. */
+.re-display-outfit {
+ --re-font-display: "Outfit", "Inter", system-ui, -apple-system, sans-serif;
+}
+
+/* /demo shell: 200px sidebar beside the main column; a top row under 800px. */
+.re-demo-shell {
+ display: grid;
+ grid-template-columns: 200px minmax(0, 1fr);
+ min-height: 100vh;
+}
+
+.re-demo-shell > aside {
+ position: sticky;
+ top: 0;
+ max-height: 100vh;
+}
+
+@media (max-width: 799px) {
+ .re-demo-shell {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .re-demo-shell > aside {
+ position: static;
+ max-height: none;
+ }
+
+ .re-demo-shell [data-demo-nav] {
+ flex-direction: row;
+ flex-wrap: wrap;
+ }
+
+ /* The nav buttons are full width in the sidebar; let them share a row here. */
+ .re-demo-shell [data-demo-nav] > button {
+ width: auto;
+ }
+
+ .re-demo-shell [data-demo-aside-foot] {
+ display: none;
+ }
+}
+
@media (prefers-reduced-motion: reduce) {
*,
*::before,
diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx
index a0ade40f2..f8c222cb1 100644
--- a/frontend/src/app/layout.tsx
+++ b/frontend/src/app/layout.tsx
@@ -56,7 +56,7 @@ export default async function RootLayout({
{/* PWA manifest (#568) */}
diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx
index 63e7f5b29..b35165b1e 100644
--- a/frontend/src/app/page.tsx
+++ b/frontend/src/app/page.tsx
@@ -1,28 +1,14 @@
import Link from "next/link";
-import { BookCallLink } from "@/components/conversion/BookCallLink";
import type { Metadata } from "next";
-import {
- ArrowRight,
- Boxes,
- CalendarClock,
- CheckCircle2,
- ClipboardCheck,
- Database,
- FileCheck2,
- FileSpreadsheet,
- Gauge,
- GitBranch,
- PackageCheck,
- ScanLine,
- ShieldCheck,
-} from "lucide-react";
-import { EvidenceCard } from "@/components/compliance";
+import { ArrowRight, CheckCircle2 } from "lucide-react";
+import { BookCallLink } from "@/components/conversion/BookCallLink";
+import { LiveDot } from "@/components/demo-workspace/HueDot";
import { Container } from "@/components/layout/container";
-import { Section } from "@/components/layout/section";
+import { HeroWorkbench } from "@/components/marketing/HeroWorkbench";
import { Button } from "@/components/ui/button";
-import { Callout } from "@/components/ui/callout";
import { Card } from "@/components/ui/card";
import { capabilities } from "@/lib/capabilities";
+import { cn } from "@/lib/utils";
export const metadata: Metadata = {
title: "FSMA 204 Recordkeeping Software | RegEngine",
@@ -38,84 +24,59 @@ export const metadata: Metadata = {
},
};
-const problem = [
+const ruleStats = [
{
- title: "The request",
+ value: "24 hrs",
detail:
- "Under 21 CFR 1.1455(c)(3)(ii), FDA can ask a covered firm for an electronic sortable spreadsheet of traceability records during an outbreak, a recall, or another threat to public health. It is due within 24 hours unless FDA agrees to more time.",
- icon: FileSpreadsheet,
+ "Time the FDA gives you to produce an electronic sortable spreadsheet under 21 CFR 1.1455(c)(3)(ii) when it asks for one during an outbreak, a recall or another public-health threat, unless it agrees to more.",
},
{
- title: "The records",
+ value: "7 CTEs",
detail:
- "Dates, quantities, locations, and the traceability lot code once one is assigned, for every Critical Tracking Event: harvesting, cooling, initial packing, first land-based receiving, shipping, receiving, and transformation. Which fields apply varies by event. Most of it comes from suppliers, in whatever shape they keep it.",
- icon: Boxes,
+ "Harvesting, cooling, initial packing, first land-based receiving, shipping, receiving, transformation. Each has its own Key Data Elements.",
},
{
- title: "The date",
+ value: "2028",
detail:
- "FDA has said it will not enforce the Food Traceability Rule before July 20, 2028, following a Congressional directive. The requirements did not change. Buyer programs run on their own calendars.",
- icon: CalendarClock,
+ "The FDA says it will not enforce before July 20, 2028. The requirements did not change, and buyer programs run on their own calendars.",
},
];
-const evidenceCards = [
- {
- title: "Ingest workspace",
- description: "CTE/KDE records arrive through CSV upload or the JSON events API and enter the inflow workbench for coverage checking.",
- state: "needs-correction" as const,
- meta: "Inflow workbench",
- icon: Gauge,
- },
- {
- title: "Commit ledger",
- description: "After checks you configure, accepted records enter the append-only evidence ledger with provenance and workspace scope.",
- state: "committed" as const,
- meta: "Evidence ledger",
- icon: GitBranch,
- },
+const steps = [
+ { label: "Ingest", detail: "CSV upload, or events over the JSON API. EPCIS 2.0 intake is in preview." },
+ { label: "Check", detail: "Each record is tested for the CTEs and KDEs its event type requires." },
+ { label: "Fix", detail: "Every gap gets a reason, an owner, and a correction link the supplier can answer." },
{
- title: "Export package state",
- description: "Drafted, blocked on checklist, or exported. §1.1455 is the FDA records-request rule. This card does not claim we meet that clock.",
- state: "needs-correction" as const,
- meta: "Package state",
- icon: PackageCheck,
+ label: "Commit",
+ detail: "Accepted records enter a hash-chained, append-only ledger. A commit is a record, not a certification.",
+ filled: true,
},
+ { label: "Export", detail: "FDA-shaped, buyer-shaped, or internal packets, each with a SHA-256 manifest." },
];
-const workflow = [
- { label: "Ingest", detail: "CSV uploads and JSON events API submissions enter the workbench.", icon: ScanLine },
- { label: "Check", detail: "CTE/KDE coverage and completeness are flagged in the inflow workbench.", icon: Database },
- { label: "Fix", detail: "Every gap gets a reason, owner, and recovery path.", icon: ClipboardCheck },
- { label: "Commit", detail: "Commit is our append-only step. After the checks you configure, accepted records enter the evidence ledger. Commit is not a certification, not an FDA gate, and not a retailer gate.", icon: ShieldCheck },
- { label: "Export", detail: "FDA-oriented, buyer-oriented, or internal packets with a hash manifest where enabled.", icon: PackageCheck },
-];
-
-const destinations = [
- { label: "Product", href: "/product", icon: Database, note: "Command Center workflows" },
- { label: "Tools", href: "/tools", icon: Gauge, note: "Compliance instruments" },
- { label: "Security", href: "/security", icon: FileCheck2, note: "Evidence Ledger controls" },
- { label: "Trust", href: "/trust", icon: CheckCircle2, note: "Verification posture" },
-];
+const EYEBROW = "font-mono text-small font-medium uppercase tracking-[0.14em]";
+const H2 = "font-display text-heading-lg text-re-text-primary";
+const TOOL_CARD =
+ "group flex items-center justify-between gap-4 rounded-lg border border-re-border-subtle bg-re-surface-base px-5 py-4 no-underline transition-colors duration-120 hover:border-re-brand motion-reduce:transition-none";
-function IngestPathsCard() {
+function IngestPathsToday() {
return (
-
-
Ingest paths today
+
+
Ingest paths today
-
Live
-
+
Live
+
{capabilities.live.map((name) => (
-
+ {name}
))}
-
Preview
-
+
Preview
+
{capabilities.preview.map((name) => (
{name}
@@ -123,185 +84,155 @@ function IngestPathsCard() {
))}
-
Preview paths are in active development.
-
- Verify an evidence bundle
-
-
);
}
export default function HomePage() {
return (
-
-
-
-
- FSMA 204 · 21 CFR Part 1 Subpart S
-
-
- In an outbreak or recall, the Food Traceability Rule lets the FDA ask for a sortable spreadsheet due within 24 hours. Supplier data does not arrive that way.
-
-
- RegEngine checks supplier records for CTE and KDE gaps before they become evidence, routes each gap to an owner, and keeps accepted records in an append-only ledger you can export to support your response to a request.
-
-
- Send records by CSV upload or the JSON events API. EPCIS 2.0 is in Preview.
-
-
-
- FDA has said it will not enforce the Food Traceability Rule before July 20, 2028, following a Congressional directive. Buyer programs are separate and must be confirmed with your buyer.
+
+
+
+
+
+
+
+ FSMA 204 · 21 CFR PART 1 SUBPART S
+
-
-
-
-
+
+
+ The FDA can ask for your traceability records on{" "}
+ 24 hours' notice.
+
+ They come from suppliers, in forty different shapes.
+
+
+ Find the gaps before the FDA does.
+
+
+ RegEngine checks each supplier record against the fields the rule requires, routes every gap to an owner,
+ and exports the sortable spreadsheet with a hash manifest.
+
+
+
+
+
-
- The checklist is free. No account, no API key, no email required. Print or download the result.
-
-
-
-
-
-
-
- What the rule asks for, and what you actually have.
-
-
- {problem.map((item) => (
-
-
-
{item.title}
-
{item.detail}
-
- ))}
+
-
-
-
-
-
How RegEngine works
-
- {workflow.map((step, index) => (
-
-
-
- {String(index + 1).padStart(2, "0")}
-
-
{step.label}
-
{step.detail}
-
- ))}
-
-
-
+
-
-
-
-
- Make every compliance object show its state.
+
+
+
+
What the rule asks for
+
+ A sortable spreadsheet, built from records your suppliers keep.
-
- Teams can see blocked, needs correction, corrected, committed, or exported. These statuses do not establish readiness for a recall, a retailer, or an FDA request.
-
-
- {evidenceCards.map((card) => (
-
-
Object state drives badge, border, copy, and next action.
-
+
+ {ruleStats.map((stat) => (
+
+
+ {stat.value}
+
+
{stat.detail}
+
))}
+
+
+
+
+
+
+
How RegEngine works
+
Five steps. Every record shows which one it is on.
-
+
+
+ {steps.map((step, index) => (
+
+
+ {String(index + 1).padStart(2, "0")}
+
+
{step.label}
+
{step.detail}
+
+ ))}
+
+
-
-
Next steps
-
-
-
-
See the fields your records need
-
- Pick a food on the Food Traceability List and an event. Get the starting list of Key Data Elements the record needs. Free. No account or email required; print or download the result.
-
-
-
+
+
+
+
+ RegEngine does not imply FDA endorsement or certification, is not a readiness score, does not claim that any
+ workspace meets the 24-hour request, is not a recall clock product, shows no live customer metrics, and is not
+ legal advice. Buyer programs are separate from the FDA; confirm them with your buyer.{" "}
+ Live today: {capabilities.live.join(" and ")}.{" "}
+ In preview: EPCIS 2.0 intake and export.
+
+
);
}
diff --git a/frontend/src/app/sitemap.ts b/frontend/src/app/sitemap.ts
index 185a4aba2..d0af42525 100644
--- a/frontend/src/app/sitemap.ts
+++ b/frontend/src/app/sitemap.ts
@@ -38,6 +38,7 @@ export default function sitemap(): MetadataRoute.Sitemap {
const marketingPages = [
{ url: `${baseUrl}/`, changeFrequency: 'weekly' as const, priority: 1.0 },
{ url: `${baseUrl}/pricing`, changeFrequency: 'monthly' as const, priority: 0.9 },
+ { url: `${baseUrl}/demo`, changeFrequency: 'monthly' as const, priority: 0.8 },
{ url: `${baseUrl}/security`, changeFrequency: 'monthly' as const, priority: 0.8 },
// /alpha, /verticals/food-safety and /developer/register are redirect
// stubs (the last one a next.config.js redirect to /login), so they are
diff --git a/frontend/src/components/cookie-consent/CookieBanner.tsx b/frontend/src/components/cookie-consent/CookieBanner.tsx
index 5e2fb6350..ff0872dbe 100644
--- a/frontend/src/components/cookie-consent/CookieBanner.tsx
+++ b/frontend/src/components/cookie-consent/CookieBanner.tsx
@@ -11,7 +11,7 @@ import {
setConsentCookie,
type ConsentValue,
} from '@/lib/cookie-consent';
-import { shouldHideMarketingChrome } from '@/lib/app-routes';
+import { shouldHideCookieBanner } from '@/lib/app-routes';
import {
filterConversionAnalyticsEvent,
getConversionAttribution,
@@ -53,7 +53,7 @@ export function CookieBanner({ enableAnalytics }: CookieBannerProps) {
const [consent, setConsent] = useState('loading');
const [visible, setVisible] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
- const hideBanner = shouldHideMarketingChrome(pathname);
+ const hideBanner = shouldHideCookieBanner(pathname);
// Read stored consent on mount
useEffect(() => {
diff --git a/frontend/src/components/demo-workspace/DemoShell.tsx b/frontend/src/components/demo-workspace/DemoShell.tsx
new file mode 100644
index 000000000..8f67b4f26
--- /dev/null
+++ b/frontend/src/components/demo-workspace/DemoShell.tsx
@@ -0,0 +1,124 @@
+'use client';
+
+import Link from 'next/link';
+import type { ReactNode } from 'react';
+import { BookCallLink } from '@/components/conversion/BookCallLink';
+import { RegEngineWordmark } from '@/components/layout/regengine-wordmark';
+import { Button } from '@/components/ui/button';
+import { shortLabel, type Scenario, type ViewId } from '@/lib/demo-workspace/model';
+import { cn } from '@/lib/utils';
+import { HueDot, LiveDot } from './HueDot';
+
+export const NAV_ITEMS: ReadonlyArray<{ id: ViewId; label: string }> = [
+ { id: 'overview', label: 'Overview' },
+ { id: 'import', label: 'Import' },
+ { id: 'fix', label: 'Fix queue' },
+ { id: 'ledger', label: 'Ledger' },
+ { id: 'export', label: 'Export' },
+];
+
+interface DemoShellProps {
+ view: ViewId;
+ onNavigate: (view: ViewId) => void;
+ openCount: number;
+ title: string;
+ subtitle: string;
+ legend: readonly Scenario[];
+ onResetAll: () => void;
+ children: ReactNode;
+}
+
+/** Sidebar, banner and page header around the public workspace views. */
+export function DemoShell({ view, onNavigate, openCount, title, subtitle, legend, onResetAll, children }: DemoShellProps) {
+ return (
+
+
+
+
+
+
+ Demo workspace. No account needed. Five supply chains of
+ synthetic data modelled on FDA major recall notices; every firm, lot and gap is invented.
+
+
+ Live: CSV upload · JSON API
+
+
+ );
+}
diff --git a/frontend/src/components/demo-workspace/HueDot.tsx b/frontend/src/components/demo-workspace/HueDot.tsx
new file mode 100644
index 000000000..81d0d42c9
--- /dev/null
+++ b/frontend/src/components/demo-workspace/HueDot.tsx
@@ -0,0 +1,31 @@
+import type { CSSProperties } from 'react';
+import { cn } from '@/lib/utils';
+
+/**
+ * Food marker for the public workbench surfaces. The hue comes from the
+ * dataset and is applied through a custom property, so no colour is written
+ * into page markup. Never the only signal: a dot always sits beside a name.
+ */
+export function HueDot({ hue, size = 'md', className }: { hue: string; size?: 'sm' | 'md'; className?: string }) {
+ return (
+
+ );
+}
+
+/** Small brand dot that pulses to mark a live surface. */
+export function LiveDot({ className }: { className?: string }) {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/demo-workspace/RecordDetail.tsx b/frontend/src/components/demo-workspace/RecordDetail.tsx
new file mode 100644
index 000000000..f041df920
--- /dev/null
+++ b/frontend/src/components/demo-workspace/RecordDetail.tsx
@@ -0,0 +1,78 @@
+'use client';
+
+import { Button } from '@/components/ui/button';
+import type { Detail, DemoSurface } from '@/lib/demo-workspace/model';
+import { cn } from '@/lib/utils';
+
+interface RecordDetailProps {
+ detail: Detail;
+ surface: DemoSurface;
+ onFix: () => void;
+ onReset?: () => void;
+ className?: string;
+}
+
+const LABEL_TONE: Record = {
+ gap: 'text-re-warning',
+ corrected: 'text-re-state-corrected',
+ committed: 'text-re-brand',
+};
+
+/** The panel under a workbench table once a record is selected. */
+export function RecordDetail({ detail, surface, onFix, onReset, className }: RecordDetailProps) {
+ const landing = surface === 'landing';
+ const owner = detail.kv.find(([key]) => key === 'Owner')?.[1];
+
+ return (
+
+
+
+ {detail.label}
+
+
+ {detail.title}
+
+
+ {detail.body}
+
+
+ {detail.canFix && (
+
+
+ Send correction link to supplier
+
+
+ {landing && owner
+ ? `Assigns ${owner} · supplier replies through a single-use link`
+ : 'Single-use link · supplier answers without an account'}
+
+
+ )}
+
+ {detail.kind === 'corrected' && (
+
+
+ {landing
+ ? '✓ Supplier corrected the record. Committed to the ledger.'
+ : '✓ Corrected by the supplier and committed to the ledger.'}
+
+ {onReset && (
+
+ Reset demo
+
+ )}
+
+ )}
+
+
+
+ {detail.kv.map(([key, value]) => (
+
+
{key}
+
{value}
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/src/components/demo-workspace/RecordTable.tsx b/frontend/src/components/demo-workspace/RecordTable.tsx
new file mode 100644
index 000000000..73d3b9212
--- /dev/null
+++ b/frontend/src/components/demo-workspace/RecordTable.tsx
@@ -0,0 +1,96 @@
+'use client';
+
+import { StatusBadge } from '@/components/ui/status-badge';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
+import type { Row } from '@/lib/demo-workspace/model';
+import { cn } from '@/lib/utils';
+import { HueDot } from './HueDot';
+
+interface RecordTableProps {
+ rows: Row[];
+ hue: string;
+ selected: number | null;
+ onPick: (index: number) => void;
+ /** `hero` is the three-column landing mock; `workspace` is the four-column import view. */
+ variant: 'hero' | 'workspace';
+ caption: string;
+}
+
+const HEAD = 'h-9 bg-transparent font-mono text-label uppercase tracking-[0.08em] text-re-text-muted';
+
+export function RecordTable({ rows, hue, selected, onPick, variant, caption }: RecordTableProps) {
+ const hero = variant === 'hero';
+
+ return (
+
+ Packages are FDA-shaped sortable spreadsheets built from the committed records of each chain. Open records are
+ marked OPEN in the sheet, never silently dropped. The hashes on this page are stand-ins, not SHA-256, and the
+ downloads here are a CSV and a plain-text manifest. A real export is an audit bundle with SHA-256 hashes that
+ you check on your own machine with verify_chain.py. A package is a record
+ of what you hold, not a certification and not a statement that any request has been met.
+
+ >
+ );
+}
diff --git a/frontend/src/components/demo-workspace/views/FixQueueView.tsx b/frontend/src/components/demo-workspace/views/FixQueueView.tsx
new file mode 100644
index 000000000..fb22332db
--- /dev/null
+++ b/frontend/src/components/demo-workspace/views/FixQueueView.tsx
@@ -0,0 +1,61 @@
+'use client';
+
+import { HueDot } from '@/components/demo-workspace/HueDot';
+import { Button } from '@/components/ui/button';
+import { Card } from '@/components/ui/card';
+import { StatusBadge } from '@/components/ui/status-badge';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
+import type { FixItem } from '@/lib/demo-workspace/model';
+
+const HEAD = 'h-10 bg-transparent font-mono text-label uppercase tracking-[0.08em] text-re-text-muted';
+
+export function FixQueueView({ items, onFix }: { items: FixItem[]; onFix: (scenarioId: string) => void }) {
+ return (
+
+
+ Synthetic data modelled on FDA major recall notices; firms, lots and gaps are invented.{' '}
+
+ Open the full demo workspace →
+ {' '}
+ No account needed. CSV upload and the JSON events API are live today; the workbench view ships next.
+
+
+ );
+}
diff --git a/frontend/src/lib/app-routes.ts b/frontend/src/lib/app-routes.ts
index 59550688a..b137aa21b 100644
--- a/frontend/src/lib/app-routes.ts
+++ b/frontend/src/lib/app-routes.ts
@@ -35,7 +35,18 @@ export const AUTHENTICATED_APP_ROUTE_PREFIXES = [
* Note "/developer" does not prefix-match "/developers" (the public marketing
* page), which keeps its marketing header.
*/
-export const SELF_CHROMED_ROUTE_PREFIXES = ["/developer"] as const;
+export const SELF_CHROMED_ROUTE_PREFIXES = ["/developer", "/demo"] as const;
+
+/**
+ * Public routes that always render the dark token set, header and footer
+ * included, because their design is dark-only. next-themes gets this as
+ * `forcedTheme`; every other route keeps the visitor's toggle.
+ */
+export const FORCED_DARK_ROUTES = ["/", "/demo"] as const;
+
+export function isForcedDarkRoute(pathname: string): boolean {
+ return (FORCED_DARK_ROUTES as readonly string[]).includes(pathname);
+}
const CHROMELESS_EXACT_ROUTES = new Set([
"/mobile/capture",
@@ -67,3 +78,17 @@ export function shouldHideMarketingChrome(pathname: string): boolean {
matchesRoutePrefix(pathname, SELF_CHROMED_ROUTE_PREFIXES)
);
}
+
+/**
+ * Self-chromed routes that are public, indexed pages. They replace the
+ * marketing header and footer but still owe a first-time visitor the cookie
+ * consent prompt, because their analytics events are consent-gated.
+ */
+const PUBLIC_SELF_CHROMED_ROUTE_PREFIXES = ["/demo"] as const;
+
+export function shouldHideCookieBanner(pathname: string): boolean {
+ return (
+ shouldHideMarketingChrome(pathname) &&
+ !matchesRoutePrefix(pathname, PUBLIC_SELF_CHROMED_ROUTE_PREFIXES)
+ );
+}
diff --git a/frontend/src/lib/conversion-events.ts b/frontend/src/lib/conversion-events.ts
index d060305ad..e71b3bf37 100644
--- a/frontend/src/lib/conversion-events.ts
+++ b/frontend/src/lib/conversion-events.ts
@@ -17,7 +17,15 @@ type ConversionEvent =
| 'readiness_assessment_submit'
| 'book_call_click'
| 'contact_form_submit'
- | 'webinar_register_click';
+ | 'webinar_register_click'
+ | 'scenario_tab'
+ | 'row_click'
+ | 'send_correction_link'
+ | 'reset_demo'
+ | 'toggle_rows';
+
+/** Which public surface a workbench interaction happened on. */
+export type DemoSurface = 'landing' | 'workspace';
type EventValue = string | number | boolean;
type Attribution = Partial>;
@@ -31,6 +39,11 @@ const EVENT_NAMES = new Set([
'book_call_click',
'contact_form_submit',
'webinar_register_click',
+ 'scenario_tab',
+ 'row_click',
+ 'send_correction_link',
+ 'reset_demo',
+ 'toggle_rows',
]);
const UTM_KEYS = ['utm_source', 'utm_medium', 'utm_campaign'] as const;
const EVENT_EXTRA_KEYS: Record = {
@@ -42,12 +55,18 @@ const EVENT_EXTRA_KEYS: Record = {
book_call_click: ['placement', 'tool'],
contact_form_submit: ['outcome', 'error_code'],
webinar_register_click: ['webinar', 'placement'],
+ scenario_tab: ['surface', 'scenario'],
+ row_click: ['surface', 'scenario', 'cte', 'gap'],
+ send_correction_link: ['surface', 'scenario', 'cte'],
+ reset_demo: ['surface', 'scenario'],
+ toggle_rows: ['surface', 'scenario'],
};
const ENUM_VALUES: Record = {
form: ['email_gate', 'lead_gate'],
step: ['verify', 'confirm', 'gate', 'enrich'],
outcome: ['success', 'error'],
error_code: ['validation', 'rate_limited', 'unavailable', 'server'],
+ surface: ['landing', 'workspace'],
};
const CODE_VALUE = /^[a-z0-9]+(?:[-_][a-z0-9]+)*$/;
const GRADE_VALUE = /^[A-F]$/;
@@ -179,13 +198,19 @@ export function filterConversionAnalyticsEvent(event:
}
function cleanExtra(key: string, value: unknown): EventValue | undefined {
+ if (key === 'gap') return typeof value === 'boolean' ? value : undefined;
const cleaned = cleanString(value);
if (!cleaned) return undefined;
if (ENUM_VALUES[key]) return ENUM_VALUES[key].includes(cleaned) ? cleaned : undefined;
if (key === 'grade') return GRADE_VALUE.test(cleaned) ? cleaned : undefined;
- if (key === 'tool' || key === 'placement' || key === 'webinar') {
+ if (key === 'tool' || key === 'placement' || key === 'webinar' || key === 'scenario') {
return CODE_VALUE.test(cleaned) ? cleaned : undefined;
}
+ if (key === 'cte') {
+ // CTE names come from the static dataset ("Initial packing"); keep them as codes.
+ const code = cleaned.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '');
+ return CODE_VALUE.test(code) ? code : undefined;
+ }
// The current allowlisted extras all represent fixed labels, not numeric data.
return undefined;
}
@@ -260,3 +285,23 @@ export function trackContactFormSubmit(outcome: Outcome, error_code?: ErrorCode)
export function trackWebinarRegisterClick(webinarSlug: string, placement: string): void {
trackConversionEvent('webinar_register_click', { webinar: webinarSlug, placement });
}
+
+export function trackScenarioTab(surface: DemoSurface, scenario: string): void {
+ trackConversionEvent('scenario_tab', { surface, scenario });
+}
+
+export function trackRowClick(surface: DemoSurface, scenario: string, cte: string, gap: boolean): void {
+ trackConversionEvent('row_click', { surface, scenario, cte, gap });
+}
+
+export function trackSendCorrectionLink(surface: DemoSurface, scenario: string, cte: string): void {
+ trackConversionEvent('send_correction_link', { surface, scenario, cte });
+}
+
+export function trackResetDemo(surface: DemoSurface, scenario?: string): void {
+ trackConversionEvent('reset_demo', { surface, scenario });
+}
+
+export function trackToggleRows(surface: DemoSurface, scenario: string): void {
+ trackConversionEvent('toggle_rows', { surface, scenario });
+}
diff --git a/frontend/src/lib/demo-workspace/download.ts b/frontend/src/lib/demo-workspace/download.ts
new file mode 100644
index 000000000..d47f9fb4e
--- /dev/null
+++ b/frontend/src/lib/demo-workspace/download.ts
@@ -0,0 +1,10 @@
+/** Hands the visitor a text file from the browser. Kept apart so tests can mock it. */
+export function downloadText(name: string, text: string, type: string): void {
+ if (typeof document === 'undefined' || typeof URL.createObjectURL !== 'function') return;
+ const url = URL.createObjectURL(new Blob([text], { type }));
+ const anchor = document.createElement('a');
+ anchor.href = url;
+ anchor.download = name;
+ anchor.click();
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+}
diff --git a/frontend/src/lib/demo-workspace/model.ts b/frontend/src/lib/demo-workspace/model.ts
new file mode 100644
index 000000000..87ea98267
--- /dev/null
+++ b/frontend/src/lib/demo-workspace/model.ts
@@ -0,0 +1,525 @@
+/**
+ * Pure derivations for the public demo workspace and the landing-page hero
+ * workbench. No React, no DOM: everything here is a function of the static
+ * scenario dataset plus a small amount of visitor state (selected scenario,
+ * selected row, which scenarios have been "fixed").
+ *
+ * Hashes are deterministic stand-ins (a djb2 pair), not SHA-256. The ledger
+ * and manifest hashes exist so the surfaces can show the shape of a
+ * hash-chained record; the real engine uses SHA-256 and `verify_chain.py`.
+ */
+import data from './recall-scenarios.json';
+
+export interface Kdes {
+ lot: string;
+ qty: string;
+ location: string;
+}
+
+export interface Gap {
+ field: string;
+ cfr: string;
+ reason: string;
+ owner: string;
+ recovery: string;
+}
+
+export interface ChainRecord {
+ cte: string;
+ firm: string;
+ place: string;
+ date: string;
+ kdes: Kdes;
+ gap?: Gap;
+}
+
+export interface ScenarioSource {
+ title: string;
+ agent: string;
+ firstRecall: string;
+ pattern: string;
+}
+
+export interface Scenario {
+ id: string;
+ food: string;
+ ftlCategory: string;
+ hue: string;
+ source: ScenarioSource;
+ request: string;
+ lot: string;
+ chain: ChainRecord[];
+}
+
+/** scenario id -> true once the visitor has sent the correction link. */
+export type FixedMap = Readonly>;
+
+export type RecordState = 'committed' | 'needs-correction' | 'corrected';
+
+export type DemoSurface = 'landing' | 'workspace';
+
+export const VIEW_IDS = ['overview', 'import', 'fix', 'ledger', 'export'] as const;
+export type ViewId = (typeof VIEW_IDS)[number];
+
+export const SHORT_LABELS: Readonly> = {
+ peppers: 'Jalapeños',
+ cucumbers: 'Cucumbers',
+ shrimp: 'Shrimp',
+ cantaloupe: 'Cantaloupe',
+ cheese: 'Soft cheese',
+};
+
+/** Shown while a record has no traceability lot code. */
+export const MISSING_LOT = 'missing';
+
+const SCENARIOS = (data as { scenarios: Scenario[] }).scenarios;
+
+export function scenarios(): readonly Scenario[] {
+ return SCENARIOS;
+}
+
+export function shortLabel(scenario: Pick): string {
+ return SHORT_LABELS[scenario.id] ?? scenario.food;
+}
+
+/** "Rancho Las Lomas (grower)" -> "Rancho Las Lomas". */
+export function firmName(firm: string): string {
+ return firm.replace(/ \(.*\)$/, '');
+}
+
+export function fileName(scenario: Scenario): string {
+ return `${scenario.id}_receiving_log.xlsx`;
+}
+
+export function gapIndex(scenario: Scenario): number {
+ return scenario.chain.findIndex((record) => Boolean(record.gap));
+}
+
+export function gapRecord(scenario: Scenario): ChainRecord | undefined {
+ return scenario.chain.find((record) => Boolean(record.gap));
+}
+
+export function isFixed(scenario: Pick, fixed: FixedMap): boolean {
+ return Boolean(fixed[scenario.id]);
+}
+
+export function recordState(record: ChainRecord, scenarioFixed: boolean): RecordState {
+ if (!record.gap) return 'committed';
+ return scenarioFixed ? 'corrected' : 'needs-correction';
+}
+
+export function parseView(hash: string): ViewId {
+ const value = hash.replace(/^#/, '');
+ return (VIEW_IDS as readonly string[]).includes(value) ? (value as ViewId) : 'overview';
+}
+
+/** Deterministic stand-in hash: `xxxxxxxx…xxxx`. Not SHA-256. */
+export function stableHash(input: string): string {
+ let h1 = 5381;
+ let h2 = 52711;
+ for (let i = 0; i < input.length; i += 1) {
+ const c = input.charCodeAt(i);
+ h1 = (h1 * 33) ^ c;
+ h2 = (h2 * 31) ^ c;
+ }
+ const hex = (n: number) => (n >>> 0).toString(16).padStart(8, '0');
+ return `${hex(h1)}…${hex(h2).slice(-4)}`;
+}
+
+export function eventHash(scenario: Pick, record: ChainRecord): string {
+ return stableHash(scenario.id + record.cte + record.date);
+}
+
+/** The dataset marks a record with no traceability lot code with an em dash (U+2014). */
+const NO_LOT = String.fromCharCode(0x2014);
+
+/**
+ * The lot code to show for a record. Only a record whose own lot is missing
+ * borrows the scenario's lot once corrected; a gap on another field (quantity,
+ * location, reference document) leaves the record's lot code untouched.
+ */
+export function lotFor(scenario: Pick, record: ChainRecord, open: boolean): string {
+ if (record.kdes.lot !== NO_LOT) return record.kdes.lot;
+ return open ? MISSING_LOT : scenario.lot;
+}
+
+export interface Row {
+ index: number;
+ cte: string;
+ firm: string;
+ place: string;
+ date: string;
+ qty: string;
+ location: string;
+ lot: string;
+ state: RecordState;
+ isGap: boolean;
+ openGap: boolean;
+}
+
+export function deriveRows(scenario: Scenario, fixed: FixedMap): Row[] {
+ const scenarioFixed = isFixed(scenario, fixed);
+ return scenario.chain.map((record, index) => {
+ const isGap = Boolean(record.gap);
+ const openGap = isGap && !scenarioFixed;
+ return {
+ index,
+ cte: record.cte,
+ firm: firmName(record.firm),
+ place: record.place,
+ date: record.date,
+ qty: record.kdes.qty,
+ location: record.kdes.location,
+ lot: lotFor(scenario, record, openGap),
+ state: recordState(record, scenarioFixed),
+ isGap,
+ openGap,
+ };
+ });
+}
+
+export interface RowWindow {
+ visible: Row[];
+ hidden: number;
+}
+
+/** The hero shows four rows around the gap unless expanded. */
+export function windowRows(rows: Row[], gapIdx: number, expanded: boolean): RowWindow {
+ if (expanded) return { visible: rows, hidden: 0 };
+ const lo = Math.max(0, Math.min(gapIdx - 2, rows.length - 4));
+ const hi = Math.min(rows.length, lo + 4);
+ const visible = rows.filter((row) => row.index >= lo && row.index < hi);
+ return { visible, hidden: rows.length - visible.length };
+}
+
+export interface Detail {
+ kind: 'gap' | 'committed' | 'corrected';
+ label: string;
+ title: string;
+ body: string;
+ kv: Array<[string, string]>;
+ canFix: boolean;
+}
+
+export function detailFor(
+ scenario: Scenario,
+ rowIndex: number | null,
+ fixed: FixedMap,
+ surface: DemoSurface,
+): Detail | null {
+ if (rowIndex === null) return null;
+ const record = scenario.chain[rowIndex];
+ if (!record) return null;
+ const scenarioFixed = isFixed(scenario, fixed);
+ const firm = firmName(record.firm);
+
+ if (record.gap && !scenarioFixed) {
+ const recovery =
+ surface === 'landing' ? record.gap.recovery.split(';')[0] : record.gap.recovery;
+ const kv: Array<[string, string]> = [
+ ['Owner', record.gap.owner],
+ ['Recovery', recovery],
+ ['Date', record.date],
+ ];
+ if (surface === 'workspace') kv.push(['Location', record.kdes.location]);
+ return {
+ kind: 'gap',
+ label: `Gap · ${record.cte} · ${firm}`,
+ title: `${record.gap.field} (${record.gap.cfr})`,
+ body: record.gap.reason,
+ kv,
+ canFix: true,
+ };
+ }
+
+ const corrected = Boolean(record.gap);
+ const kv: Array<[string, string]> = [
+ ['Lot', lotFor(scenario, record, false)],
+ ['Quantity', record.kdes.qty],
+ ['Location', record.kdes.location],
+ ['Date', record.date],
+ ];
+ if (surface === 'workspace') kv.push(['Event hash', eventHash(scenario, record)]);
+ return {
+ kind: corrected ? 'corrected' : 'committed',
+ label: `${record.cte} · ${firm}${corrected ? ' · corrected' : ''}`,
+ title: record.place,
+ body: corrected
+ ? `Supplier supplied ${record.gap!.field.toLowerCase()} through the correction link. Re-checked and committed.`
+ : 'All Key Data Elements present for this event. Hashed into the ledger against the previous entry.',
+ kv,
+ canFix: false,
+ };
+}
+
+export function committedRecords(scenario: Scenario, fixed: FixedMap): ChainRecord[] {
+ const scenarioFixed = isFixed(scenario, fixed);
+ return scenario.chain.filter((record) => !record.gap || scenarioFixed);
+}
+
+export interface HeroStatus {
+ committed: number;
+ total: number;
+ open: number;
+ latestHash: string;
+ ledgerLine: string;
+ exportBadge: string;
+ exportLine: string;
+ exportReady: boolean;
+}
+
+export function heroStatus(scenario: Scenario, fixed: FixedMap): HeroStatus {
+ const scenarioFixed = isFixed(scenario, fixed);
+ const committed = committedRecords(scenario, fixed);
+ const total = scenario.chain.length;
+ const open = total - committed.length;
+ const gap = gapRecord(scenario);
+ const latest = scenarioFixed && gap ? gap : committed[committed.length - 1];
+ return {
+ committed: committed.length,
+ total,
+ open,
+ latestHash: latest ? eventHash(scenario, latest) : '',
+ ledgerLine: `${committed.length} of ${total} records committed`,
+ exportBadge: open ? `${committed.length} of ${total} ready` : 'Ready to export',
+ exportLine: open
+ ? `${open} record still open. Export marks it OPEN in the sheet until corrected.`
+ : `All ${committed.length} records committed. Hash manifest attached.`,
+ exportReady: open === 0,
+ };
+}
+
+export interface LedgerEntry {
+ n: number;
+ ts: string;
+ scenarioId: string;
+ hue: string;
+ label: string;
+ lot: string;
+ eventHash: string;
+ chainHash: string;
+}
+
+const pad2 = (n: number) => String(n).padStart(2, '0');
+
+/**
+ * Every committed record across all chains, hashed against the one before.
+ * Append-only: records committed at import keep their position, sequence
+ * number and hashes; each correction is appended after them, in the order the
+ * visitor fixed the scenarios (FixedMap key order, since fixes add keys).
+ */
+export function deriveLedger(all: readonly Scenario[], fixed: FixedMap): LedgerEntry[] {
+ let prev = 'genesis';
+ let n = 1040;
+ const entries: LedgerEntry[] = [];
+ const append = (scenario: Scenario, record: ChainRecord) => {
+ const eh = eventHash(scenario, record);
+ const ch = stableHash(prev + eh);
+ prev = ch;
+ n += 1;
+ entries.push({
+ n,
+ ts: `${record.date}T${pad2(8 + (n % 9))}:${pad2((n * 7) % 60)}:00-07:00`,
+ scenarioId: scenario.id,
+ hue: scenario.hue,
+ label: `${shortLabel(scenario)} · ${record.cte}`,
+ lot: lotFor(scenario, record, false),
+ eventHash: eh,
+ chainHash: ch,
+ });
+ };
+
+ for (const scenario of all) {
+ for (const record of scenario.chain) {
+ if (!record.gap) append(scenario, record);
+ }
+ }
+
+ for (const id of Object.keys(fixed)) {
+ if (!fixed[id]) continue;
+ const scenario = all.find((s) => s.id === id);
+ const record = scenario && gapRecord(scenario);
+ if (scenario && record) append(scenario, record);
+ }
+ return entries;
+}
+
+export const CSV_HEADER = 'ftl_food,cte,firm,location,date,traceability_lot_code,quantity,state';
+
+const DQ = String.fromCharCode(34);
+const csvCell = (value: string) => DQ + value.split(DQ).join(DQ + DQ) + DQ;
+
+/** One FDA-shaped sortable sheet per chain. Open records are marked OPEN, never dropped. */
+export function buildCsv(scenario: Scenario, fixed: FixedMap): string {
+ const scenarioFixed = isFixed(scenario, fixed);
+ const rows = scenario.chain.map((record) => {
+ const open = Boolean(record.gap) && !scenarioFixed;
+ const shown = lotFor(scenario, record, open);
+ const lot = shown === MISSING_LOT ? '' : shown;
+ return [
+ scenario.ftlCategory,
+ record.cte,
+ firmName(record.firm),
+ record.kdes.location,
+ record.date,
+ lot,
+ record.kdes.qty,
+ open ? 'OPEN' : 'COMMITTED',
+ ]
+ .map(csvCell)
+ .join(',');
+ });
+ return [CSV_HEADER, ...rows].join('\n');
+}
+
+export function packageFileName(scenario: Pick): string {
+ return `fda_sortable_${scenario.id}.csv`;
+}
+
+export function buildManifest(file: string, hash: string): string {
+ return (
+ `${file}\tstand-in-hash\t${hash}\n` +
+ 'note: stand-in hash, not SHA-256. This file cannot be checked with verify_chain.py, ' +
+ 'which reads a RegEngine audit bundle ZIP.\n'
+ );
+}
+
+export interface Package {
+ scenarioId: string;
+ food: string;
+ hue: string;
+ file: string;
+ ready: boolean;
+ badge: string;
+ line: string;
+ manifest: string;
+ csv: string;
+}
+
+export function derivePackages(all: readonly Scenario[], fixed: FixedMap): Package[] {
+ return all.map((scenario) => {
+ const ready = isFixed(scenario, fixed);
+ const gap = gapRecord(scenario);
+ const total = scenario.chain.length;
+ const committed = total - 1;
+ const gapCte = gap?.cte ?? 'record';
+ const gapField = gap?.gap?.field.toLowerCase() ?? 'field';
+ const csv = buildCsv(scenario, fixed);
+ return {
+ scenarioId: scenario.id,
+ food: shortLabel(scenario),
+ hue: scenario.hue,
+ file: packageFileName(scenario),
+ ready,
+ badge: ready ? 'READY' : `${committed} OF ${total} READY`,
+ line: ready
+ ? `${total} records · all committed`
+ : `${committed} committed · 1 open (${gapCte}: ${gapField})`,
+ manifest: stableHash(csv),
+ csv,
+ };
+ });
+}
+
+export interface OverviewStats {
+ total: number;
+ committed: number;
+ open: number;
+ ready: number;
+ chains: number;
+}
+
+export function openScenarioCount(all: readonly Scenario[], fixed: FixedMap): number {
+ return all.filter((scenario) => !isFixed(scenario, fixed)).length;
+}
+
+export function overviewStats(all: readonly Scenario[], fixed: FixedMap): OverviewStats {
+ const total = all.reduce((sum, scenario) => sum + scenario.chain.length, 0);
+ const open = openScenarioCount(all, fixed);
+ return { total, committed: total - open, open, ready: all.length - open, chains: all.length };
+}
+
+export interface AttentionRow {
+ scenarioIndex: number;
+ scenarioId: string;
+ rowIndex: number;
+ hue: string;
+ item: string;
+ owner: string;
+ source: string;
+}
+
+export function attentionRows(all: readonly Scenario[], fixed: FixedMap): AttentionRow[] {
+ const rows: AttentionRow[] = [];
+ all.forEach((scenario, scenarioIndex) => {
+ if (isFixed(scenario, fixed)) return;
+ const index = gapIndex(scenario);
+ const record = scenario.chain[index];
+ if (!record?.gap) return;
+ rows.push({
+ scenarioIndex,
+ scenarioId: scenario.id,
+ rowIndex: index,
+ hue: scenario.hue,
+ item: `${shortLabel(scenario)} · ${record.cte} at ${firmName(record.firm)}: ${record.gap.field.toLowerCase()}`,
+ owner: record.gap.owner,
+ source: fileName(scenario),
+ });
+ });
+ return rows;
+}
+
+export interface NextAction {
+ n: string;
+ label: string;
+ view: ViewId;
+}
+
+export function nextActions(all: readonly Scenario[], fixed: FixedMap): NextAction[] {
+ const open = openScenarioCount(all, fixed);
+ const ready = all.length - open;
+ const actions: Array> = [];
+ if (open) {
+ actions.push({ label: `Resolve ${open} open ${open === 1 ? 'fix' : 'fixes'} in imported records`, view: 'fix' });
+ }
+ let exportLabel = 'Export all five packages';
+ if (open && ready === 0) exportLabel = 'See what each export package will hold';
+ else if (open) exportLabel = `Export the ${ready} complete ${ready === 1 ? 'package' : 'packages'}`;
+ actions.push({ label: exportLabel, view: 'export' });
+ actions.push({ label: 'Review the evidence ledger', view: 'ledger' });
+ return actions.map((action, i) => ({ ...action, n: String(i + 1).padStart(2, '0') }));
+}
+
+export interface FixItem {
+ scenarioId: string;
+ hue: string;
+ food: string;
+ cte: string;
+ firm: string;
+ owner: string;
+ field: string;
+ cfr: string;
+ reason: string;
+ done: boolean;
+}
+
+export function fixQueue(all: readonly Scenario[], fixed: FixedMap): FixItem[] {
+ const items: FixItem[] = [];
+ for (const scenario of all) {
+ const record = gapRecord(scenario);
+ if (!record?.gap) continue;
+ items.push({
+ scenarioId: scenario.id,
+ hue: scenario.hue,
+ food: shortLabel(scenario),
+ cte: record.cte,
+ firm: firmName(record.firm),
+ owner: record.gap.owner,
+ field: record.gap.field,
+ cfr: record.gap.cfr,
+ reason: record.gap.reason,
+ done: isFixed(scenario, fixed),
+ });
+ }
+ return items;
+}
diff --git a/frontend/src/lib/demo-workspace/recall-scenarios.json b/frontend/src/lib/demo-workspace/recall-scenarios.json
new file mode 100644
index 000000000..5e0379a16
--- /dev/null
+++ b/frontend/src/lib/demo-workspace/recall-scenarios.json
@@ -0,0 +1,99 @@
+{
+ "_about": "Synthetic FSMA 204 traceability scenarios for RegEngine demos and tests. Each scenario is modelled on a real FDA Major Product Recall (source cited) but every firm, location, lot code, date and gap below is invented. Nothing here describes the records of any real company.",
+ "_sources": [
+ "https://www.fda.gov/safety/recalls-market-withdrawals-safety-alerts/major-product-recalls",
+ "https://www.fda.gov/food/food-safety-modernization-act-fsma/food-traceability-list"
+ ],
+ "ctes": ["Harvesting", "Cooling", "Initial packing", "Shipping", "Receiving", "Transformation", "Shipping"],
+ "scenarios": [
+ {
+ "id": "peppers",
+ "food": "Jalapeño peppers",
+ "ftlCategory": "Peppers (fresh)",
+ "hue": "#2fb344",
+ "source": { "title": "2026 recalls associated with fresh jalapeños from Sinaloa, Mexico", "agent": "Salmonella Javiana", "firstRecall": "2026-07-22", "pattern": "Bulk generic packaging; downstream firms used the peppers as an ingredient in salsa, pico de gallo and dressings, so the lot code was lost at transformation." },
+ "request": "FDA asks a salsa maker for all receiving and transformation records for jalapeños received 2026-06-20 to 2026-07-22.",
+ "lot": "PEP-SIN-260628-07",
+ "chain": [
+ { "cte": "Harvesting", "firm": "Rancho Las Lomas (grower)", "place": "Sinaloa, MX", "date": "2026-06-28", "kdes": { "lot": "PEP-SIN-260628-07", "qty": "1,200 kg", "location": "GLN 7540…0012" } },
+ { "cte": "Cooling", "firm": "Rancho Las Lomas cooler", "place": "Sinaloa, MX", "date": "2026-06-28", "kdes": { "lot": "PEP-SIN-260628-07", "qty": "1,200 kg", "location": "GLN 7540…0012" } },
+ { "cte": "Initial packing", "firm": "Culiacán Packing S.A.", "place": "Culiacán, MX", "date": "2026-06-29", "kdes": { "lot": "PEP-SIN-260628-07", "qty": "120 × 10 kg bulk", "location": "GLN 7540…0031" } },
+ { "cte": "Shipping", "firm": "Culiacán Packing S.A.", "place": "to Nogales, AZ", "date": "2026-06-30", "kdes": { "lot": "PEP-SIN-260628-07", "qty": "120 × 10 kg", "location": "GLN 0614…0208" } },
+ { "cte": "Receiving", "firm": "Border Produce Distributors", "place": "Nogales, AZ", "date": "2026-07-01", "kdes": { "lot": "PEP-SIN-260628-07", "qty": "120 × 10 kg", "location": "GLN 0614…0208" } },
+ { "cte": "Transformation", "firm": "Casa Verde Salsa Co.", "place": "San Antonio, TX", "date": "2026-07-03", "kdes": { "lot": "—", "qty": "480 × 16 oz salsa", "location": "GLN 0614…0455" }, "gap": { "field": "Traceability lot code of the input peppers", "cfr": "21 CFR § 1.1350(a)(1)", "reason": "Receiving log recorded the pepper lot as 'bulk, generic'. New product lot SAL-260703-A was assigned with no link to the input lot.", "owner": "Salsa QA lead", "recovery": "Supplier correction link to Border Produce; match by ship date and quantity." } },
+ { "cte": "Shipping", "firm": "Casa Verde Salsa Co.", "place": "to retail DC, Dallas, TX", "date": "2026-07-05", "kdes": { "lot": "SAL-260703-A", "qty": "480 × 16 oz", "location": "GLN 0614…0812" } }
+ ]
+ },
+ {
+ "id": "cucumbers",
+ "food": "Whole cucumbers",
+ "ftlCategory": "Cucumbers (fresh)",
+ "hue": "#84cc16",
+ "source": { "title": "2025 recalls of cucumbers associated with a grower in Boynton Beach, Florida", "agent": "Salmonella", "firstRecall": "2025-05-19", "pattern": "Sold direct to consumers and to one wholesale distributor; nine downstream recalls of salads, subs, sushi and fresh-cut slices within nine days." },
+ "request": "FDA asks a deli-salad maker for receiving records for whole cucumbers received 2025-04-29 to 2025-05-19.",
+ "lot": "CUC-FLA-250505-3B",
+ "chain": [
+ { "cte": "Harvesting", "firm": "Palmetto Ridge Farm (grower)", "place": "Palm Beach County, FL", "date": "2025-05-05", "kdes": { "lot": "CUC-FLA-250505-3B", "qty": "640 bu", "location": "GLN 0614…1101" } },
+ { "cte": "Cooling", "firm": "Palmetto Ridge hydrocooler", "place": "Palm Beach County, FL", "date": "2025-05-05", "kdes": { "lot": "CUC-FLA-250505-3B", "qty": "640 bu", "location": "GLN 0614…1101" } },
+ { "cte": "Initial packing", "firm": "Palmetto Ridge packhouse", "place": "Palm Beach County, FL", "date": "2025-05-06", "kdes": { "lot": "CUC-FLA-250505-3B", "qty": "580 ctn", "location": "GLN 0614…1101" } },
+ { "cte": "Shipping", "firm": "Palmetto Ridge Farm", "place": "to Jessup, MD", "date": "2025-05-06", "kdes": { "lot": "CUC-FLA-250505-3B", "qty": "580 ctn", "location": "GLN 0614…2290" } },
+ { "cte": "Receiving", "firm": "Chesapeake Fresh Wholesale", "place": "Jessup, MD", "date": "2025-05-08", "kdes": { "lot": "CUC-FLA-250505-3B", "qty": "580 ctn", "location": "GLN 0614…2290" }, "gap": { "field": "Ship-from location (traceability lot code source)", "cfr": "21 CFR § 1.1345(a)(7)", "reason": "Receiving spreadsheet lists the vendor as 'FL cukes' with no location description or GLN.", "owner": "Receiving supervisor", "recovery": "Pull the bill of lading; populate location from the shipper's record." } },
+ { "cte": "Transformation", "firm": "Harbor Deli Kitchens", "place": "Baltimore, MD", "date": "2025-05-10", "kdes": { "lot": "GRK-250510-02", "qty": "1,900 × 12 oz Greek salad", "location": "GLN 0614…2377" } },
+ { "cte": "Shipping", "firm": "Harbor Deli Kitchens", "place": "to grocery DC, Carlisle, PA", "date": "2025-05-11", "kdes": { "lot": "GRK-250510-02", "qty": "1,900 × 12 oz", "location": "GLN 0614…3002" } }
+ ]
+ },
+ {
+ "id": "shrimp",
+ "food": "Frozen raw shrimp",
+ "ftlCategory": "Crustaceans (fresh, frozen, previously frozen)",
+ "hue": "#fb7185",
+ "source": { "title": "2025 recalls of frozen shrimp associated with Cesium-137 contamination, Indonesian processor", "agent": "Cesium-137", "firstRecall": "2025-08-21", "pattern": "One foreign processor; a dozen U.S. importers and store brands recalled over four months as container-level traceback widened." },
+ "request": "FDA asks an importer for first land-based receiving records for every container from one processor, 2025-06-01 to 2025-09-30.",
+ "lot": "SHR-IDN-250612-C4",
+ "chain": [
+ { "cte": "Harvesting", "firm": "Teluk Biru aquaculture pond (farm)", "place": "Lampung, ID", "date": "2025-06-10", "kdes": { "lot": "SHR-IDN-250612-C4", "qty": "18,000 kg", "location": "GLN 8990…0107" } },
+ { "cte": "Initial packing", "firm": "Teluk Biru Processing (peel, freeze, pack)", "place": "Lampung, ID", "date": "2025-06-12", "kdes": { "lot": "SHR-IDN-250612-C4", "qty": "1,600 × 10 lb", "location": "GLN 8990…0107" } },
+ { "cte": "Shipping", "firm": "Teluk Biru Processing", "place": "container MSKU 48…21 to Los Angeles", "date": "2025-06-15", "kdes": { "lot": "SHR-IDN-250612-C4", "qty": "1,600 × 10 lb", "location": "GLN 0614…4410" } },
+ { "cte": "Receiving", "firm": "Pacific Gate Seafood Imports (first land-based receiver)", "place": "Los Angeles, CA", "date": "2025-07-09", "kdes": { "lot": "—", "qty": "1,600 × 10 lb", "location": "GLN 0614…4410" }, "gap": { "field": "Traceability lot code and harvest-date range", "cfr": "21 CFR § 1.1335 / § 1.1345(b)", "reason": "Importer keyed the container number as the lot. Processor lot and harvest dates sit in a PDF packing list that was never transcribed.", "owner": "Import compliance", "recovery": "Attach packing list; map container → processor lot; add harvest-date range." } },
+ { "cte": "Transformation", "firm": "Pacific Gate repack line", "place": "Vernon, CA", "date": "2025-07-14", "kdes": { "lot": "PGS-250714-16", "qty": "8,000 × 2 lb bags", "location": "GLN 0614…4432" } },
+ { "cte": "Shipping", "firm": "Pacific Gate Seafood Imports", "place": "to grocery DCs, 4 states", "date": "2025-07-16", "kdes": { "lot": "PGS-250714-16", "qty": "8,000 × 2 lb", "location": "GLN 0614…5120" } }
+ ]
+ },
+ {
+ "id": "cantaloupe",
+ "food": "Whole and fresh-cut cantaloupe",
+ "ftlCategory": "Melons (fresh) · Fruits (fresh-cut)",
+ "hue": "#f59e0b",
+ "source": { "title": "2023 recalls of food products associated with cantaloupes", "agent": "Salmonella Sundsvall", "firstRecall": "2023-11-09", "pattern": "Two brand names across three importers; fresh-cut processors recalled fruit cups and trays for a month after the whole-melon recall." },
+ "request": "FDA asks a fresh-cut processor for transformation records tying each fruit-cup lot to its whole-melon lots, 2023-10-15 to 2023-11-24.",
+ "lot": "MEL-SON-231018-R2",
+ "chain": [
+ { "cte": "Harvesting", "firm": "Valle Seco Melons (grower)", "place": "Sonora, MX", "date": "2023-10-18", "kdes": { "lot": "MEL-SON-231018-R2", "qty": "2,100 ctn", "location": "GLN 7540…0920" } },
+ { "cte": "Cooling", "firm": "Valle Seco forced-air cooler", "place": "Sonora, MX", "date": "2023-10-18", "kdes": { "lot": "MEL-SON-231018-R2", "qty": "2,100 ctn", "location": "GLN 7540…0920" } },
+ { "cte": "Initial packing", "firm": "Valle Seco packhouse", "place": "Sonora, MX", "date": "2023-10-19", "kdes": { "lot": "MEL-SON-231018-R2", "qty": "2,100 ctn", "location": "GLN 7540…0920" } },
+ { "cte": "Shipping", "firm": "Valle Seco Melons", "place": "to Nogales, AZ", "date": "2023-10-19", "kdes": { "lot": "MEL-SON-231018-R2", "qty": "2,100 ctn", "location": "GLN 0614…0633" } },
+ { "cte": "Receiving", "firm": "Desert Gate Produce (first land-based receiver)", "place": "Nogales, AZ", "date": "2023-10-20", "kdes": { "lot": "MEL-SON-231018-R2", "qty": "2,100 ctn", "location": "GLN 0614…0633" } },
+ { "cte": "Transformation", "firm": "Lone Star Fresh-Cut", "place": "Dallas, TX", "date": "2023-10-24", "kdes": { "lot": "FC-231024-11", "qty": "6,400 × 10 oz cups", "location": "GLN 0614…0718" }, "gap": { "field": "Input lot quantity and unit", "cfr": "21 CFR § 1.1350(a)(1)(iii)", "reason": "Transformation record lists output cups but not how many cartons of MEL-SON-231018-R2 went in. Two melon lots were cut the same shift.", "owner": "Plant records clerk", "recovery": "Reconcile against the day's cut sheet; split quantity across the two input lots." } },
+ { "cte": "Shipping", "firm": "Lone Star Fresh-Cut", "place": "to convenience-store DC, Tulsa, OK", "date": "2023-10-25", "kdes": { "lot": "FC-231024-11", "qty": "6,400 × 10 oz", "location": "GLN 0614…0901" } }
+ ]
+ },
+ {
+ "id": "cheese",
+ "food": "Queso fresco and cotija",
+ "ftlCategory": "Cheese (pasteurized milk), fresh soft or soft unripened",
+ "hue": "#60a5fa",
+ "source": { "title": "2024 recalls of dairy products associated with a California soft-cheese maker", "agent": "Listeria monocytogenes", "firstRecall": "2024-02-05", "pattern": "One dairy's cheese appeared under many store brands and inside prepared foods; downstream recalls ran for weeks as retailers found it in kits and salads." },
+ "request": "FDA asks a meal-kit assembler for receiving and transformation records for soft cheese received 2024-01-01 to 2024-02-05.",
+ "lot": "QF-MOD-240115-04",
+ "chain": [
+ { "cte": "Transformation", "firm": "Arroyo Creamery (cheese make)", "place": "Modesto, CA", "date": "2024-01-15", "kdes": { "lot": "QF-MOD-240115-04", "qty": "3,200 × 12 oz", "location": "GLN 0614…6100" } },
+ { "cte": "Shipping", "firm": "Arroyo Creamery", "place": "to Fontana, CA", "date": "2024-01-16", "kdes": { "lot": "QF-MOD-240115-04", "qty": "3,200 × 12 oz", "location": "GLN 0614…6255" } },
+ { "cte": "Receiving", "firm": "Inland Cold Storage", "place": "Fontana, CA", "date": "2024-01-17", "kdes": { "lot": "QF-MOD-240115-04", "qty": "3,200 × 12 oz", "location": "GLN 0614…6255" } },
+ { "cte": "Shipping", "firm": "Inland Cold Storage", "place": "to Phoenix, AZ", "date": "2024-01-22", "kdes": { "lot": "QF-MOD-240115-04", "qty": "800 × 12 oz", "location": "GLN 0614…7010" } },
+ { "cte": "Receiving", "firm": "Sol Kitchens (meal-kit assembler)", "place": "Phoenix, AZ", "date": "2024-01-23", "kdes": { "lot": "QF-MOD-240115-04", "qty": "800 × 12 oz", "location": "GLN 0614…7010" }, "gap": { "field": "Reference document type and number", "cfr": "21 CFR § 1.1345(a)(8)", "reason": "Receipt was logged from a phone photo of the pallet tag. No PO, BOL or invoice number recorded.", "owner": "Purchasing", "recovery": "Attach PO 48811 from the ERP; link to the cold-storage BOL." } },
+ { "cte": "Transformation", "firm": "Sol Kitchens", "place": "Phoenix, AZ", "date": "2024-01-24", "kdes": { "lot": "KIT-240124-TACO", "qty": "2,400 taco kits", "location": "GLN 0614…7010" } },
+ { "cte": "Shipping", "firm": "Sol Kitchens", "place": "to grocery stores, AZ & NM", "date": "2024-01-25", "kdes": { "lot": "KIT-240124-TACO", "qty": "2,400 kits", "location": "GLN 0614…7388" } }
+ ]
+ }
+ ]
+}
diff --git a/frontend/src/lib/marketing-analytics-routes.ts b/frontend/src/lib/marketing-analytics-routes.ts
index 0ae912921..719ac2fc2 100644
--- a/frontend/src/lib/marketing-analytics-routes.ts
+++ b/frontend/src/lib/marketing-analytics-routes.ts
@@ -13,7 +13,7 @@ const INTERNAL_STORAGE_KEY = 're_internal';
const SENSITIVE_ROUTE_PREFIXES = ['/portal'] as const;
const PUBLIC_MARKETING_SECTIONS = new Set([
'/about', '/alpha', '/blog', '/buyer-programs',
- '/compare', '/contact', '/design-system', '/developers',
+ '/compare', '/contact', '/demo', '/design-system', '/developers',
'/docs', '/dpa', '/fsma', '/fsma-204', '/integrations', '/legal',
'/pricing', '/privacy', '/product', '/retailer-readiness',
'/security', '/status', '/supplier-compliance', '/terms',
diff --git a/frontend/src/lib/posthog-provider.tsx b/frontend/src/lib/posthog-provider.tsx
index 4a0857da8..ef349840e 100644
--- a/frontend/src/lib/posthog-provider.tsx
+++ b/frontend/src/lib/posthog-provider.tsx
@@ -25,7 +25,7 @@ const SAFE_PROPERTY_KEYS = new Set([
'tool_id', 'lead_captured', 'categories_selected', 'covered_count',
'risk_level', 'status', 'total_events', 'non_compliant_events',
'compliant_events', 'tool', 'outcome', 'error_code', 'placement', 'form', 'step',
- 'grade', 'webinar',
+ 'grade', 'webinar', 'surface', 'scenario', 'cte', 'gap',
]);
const EMAIL_SHAPE = /^[^\s@]{1,64}@[^\s@]{1,253}\.[^\s@]{2,63}$/;
const SAFE_LABEL = /^[a-z0-9][a-z0-9_-]{0,79}$/i;
diff --git a/frontend/src/lib/providers.tsx b/frontend/src/lib/providers.tsx
index d5549cf1c..7ca6e8037 100644
--- a/frontend/src/lib/providers.tsx
+++ b/frontend/src/lib/providers.tsx
@@ -2,6 +2,8 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from 'next-themes';
+import { usePathname } from 'next/navigation';
+import { isForcedDarkRoute } from '@/lib/app-routes';
import { AuthProvider } from './auth-context';
import { TenantProvider } from './tenant-context';
import { useState } from 'react';
@@ -15,6 +17,8 @@ type ProvidersProps = {
};
export function Providers({ children, nonce }: ProvidersProps) {
+ const pathname = usePathname();
+ const forcedTheme = isForcedDarkRoute(pathname ?? '') ? 'dark' : undefined;
const [queryClient] = useState(() => new QueryClient({
defaultOptions: {
queries: {
@@ -25,7 +29,7 @@ export function Providers({ children, nonce }: ProvidersProps) {
}));
return (
-
+
diff --git a/frontend/tests/visual/routes.json b/frontend/tests/visual/routes.json
index b51fb1c80..ab80e4056 100644
--- a/frontend/tests/visual/routes.json
+++ b/frontend/tests/visual/routes.json
@@ -1 +1 @@
-{"_generated":"2026-10-23 from /tmp/re-all-routes.txt (155 non-empty lines) crosschecked against frontend/src/app/**/page.{tsx,jsx}; reconciled 2026-10-23 with the designer crawl (81 routes 200-no-redirect on production): 4 routes moved public->expectRedirectOr404 (/design-system, /tools/anomaly-simulator, /trust-center, /verticals/food-safety)","_note":"Single replaceable route data file for the visual suite. Public vs auth determined by frontend/src/proxy.ts requireAppAuth + AUTHENTICATED_APP_ROUTE_PREFIXES in frontend/src/lib/app-routes.ts + GATED_DEV_ROUTES (/developer/portal) + next.config.js redirects(). Signed-out behavior only.","authOnly":["/admin","/admin/api-console","/api-keys","/audit","/compliance/labels","/compliance/profile","/compliance/snapshots","/compliance/status","/compliance/traceability-plan","/controls","/dashboard","/dashboard/agent-reviews","/dashboard/alerts","/dashboard/audit-log","/dashboard/compliance","/dashboard/export-jobs","/dashboard/heartbeat","/dashboard/inflow-lab","/dashboard/integrations","/dashboard/issues","/dashboard/notifications","/dashboard/products","/dashboard/qa-health","/dashboard/recall-drills","/dashboard/recall-report","/dashboard/recall-response","/dashboard/receiving","/dashboard/scan","/dashboard/settings","/dashboard/suppliers","/dashboard/team","/developer/portal","/developer/portal/keys","/exceptions","/fsma","/fsma/assessment","/fsma/field-capture","/fsma/integration","/identity","/ingest","/ingest/curation","/onboarding","/onboarding/bulk-upload","/onboarding/setup","/onboarding/setup/facility","/onboarding/setup/ftl-check","/onboarding/setup/welcome","/records","/remediation","/requests","/review","/rules","/settings/notifications","/settings/profile","/settings/security","/settings/support","/sysadmin","/trace"],"baseURLEnv":"VISUAL_BASE_URL","excludedReason":{"/admin":"auth-gated: /admin prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/admin/api-console":"auth-gated: /admin prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/alpha":"redirect(): src/app/alpha/page.tsx redirects to /contact; asserted in expectRedirectOr404","/api-keys":"auth-gated transitively: bare redirect() to gated /developer/portal/keys (see GATED_DEV_ROUTES comment in proxy.ts)","/audit":"auth-gated: /audit prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/auth/sso/mock":"excluded entirely: mock SSO fixture page (CHROMELESS_EXACT_ROUTES); must never be screenshotted or visited by monitoring","/compliance":"redirect source: next.config.js 308 /compliance -> /dashboard/compliance; also auth-gated (/compliance prefix); asserted in expectRedirectOr404","/compliance/labels":"auth-gated: /compliance prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/compliance/profile":"auth-gated: /compliance prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/compliance/snapshots":"auth-gated: /compliance prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/compliance/status":"auth-gated: /compliance prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/compliance/traceability-plan":"auth-gated: /compliance prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/controls":"auth-gated: /controls prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/dashboard":"auth-gated: /dashboard prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/dashboard/agent-reviews":"auth-gated: /dashboard prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/dashboard/alerts":"auth-gated: /dashboard prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/dashboard/audit-log":"auth-gated: /dashboard prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/dashboard/compliance":"auth-gated: /dashboard prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/dashboard/export-jobs":"auth-gated: /dashboard prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/dashboard/heartbeat":"auth-gated: /dashboard prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/dashboard/inflow-lab":"auth-gated: /dashboard prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/dashboard/integrations":"auth-gated: /dashboard prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/dashboard/issues":"auth-gated: /dashboard prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/dashboard/notifications":"auth-gated: /dashboard prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/dashboard/products":"auth-gated: /dashboard prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/dashboard/qa-health":"auth-gated: /dashboard prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/dashboard/recall-drills":"auth-gated: /dashboard prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/dashboard/recall-report":"auth-gated: /dashboard prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/dashboard/recall-response":"auth-gated: /dashboard prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/dashboard/receiving":"auth-gated: /dashboard prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/dashboard/scan":"auth-gated: /dashboard prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/dashboard/settings":"auth-gated: /dashboard prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/dashboard/suppliers":"auth-gated: /dashboard prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/dashboard/team":"auth-gated: /dashboard prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/demo":"demo surface: asserted in expectRedirectOr404 (no baseline). /demo is removed by #3543: src/app/demo/page.tsx is deleted, so the expectRedirectOr404 assertion now passes","/design-system":"404 on production: src/app/design-system/page.tsx calls notFound() when NODE_ENV=production (Internal, development only); absent from the designer 200-no-redirect crawl; asserted in expectRedirectOr404","/developer/portal":"auth-gated: GATED_DEV_ROUTES in proxy.ts; signed-out proxy redirects to /login","/developer/portal/keys":"auth-gated: GATED_DEV_ROUTES in proxy.ts; signed-out proxy redirects to /login","/exceptions":"auth-gated: /exceptions prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/fsma":"auth-gated: /fsma prefix in AUTHENTICATED_APP_ROUTE_PREFIXES (exact + slash match only; does NOT cover /fsma-204); signed-out proxy redirects to /login","/fsma-204/[cte]":"dynamic [cte] param: no fixed URL to baseline; needs a param allowlist before it can be covered","/fsma/assessment":"auth-gated: /fsma prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/fsma/field-capture":"auth-gated: /fsma prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/fsma/integration":"auth-gated: /fsma prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/identity":"auth-gated: /identity prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/ingest":"auth-gated: /ingest prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/ingest/curation":"auth-gated: /ingest prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/ingest/results/[id]":"dynamic [id] param: no fixed URL to baseline; also auth-gated (/ingest prefix)","/onboarding":"auth-gated: /onboarding prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/onboarding/bulk-upload":"auth-gated: /onboarding prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/onboarding/setup":"auth-gated: /onboarding prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/onboarding/setup/facility":"auth-gated: /onboarding prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/onboarding/setup/ftl-check":"auth-gated: /onboarding prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/onboarding/setup/welcome":"auth-gated: /onboarding prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/portal/[portalId]":"dynamic [portalId] param: no fixed URL to baseline","/proof/recall-drill":"deleted in #3551: src/app/proof/recall-drill/page.tsx no longer exists (recall drill simulation removed), so the route is expected to 404; asserted in expectRedirectOr404","/records":"auth-gated: /records prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/remediation":"auth-gated: /remediation prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/requests":"auth-gated: /requests prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/requests/[id]":"dynamic [id] param: no fixed URL to baseline; also auth-gated (/requests prefix)","/retailer-readiness":"redirect source: next.config.js 308 /retailer-readiness -> /buyer-programs; page.tsx exists but the redirect wins; asserted in expectRedirectOr404","/review":"auth-gated: /review prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/rules":"auth-gated: /rules prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/sandbox":"deleted in #3557: src/app/sandbox/ no longer exists, so the route is expected to 404; asserted in expectRedirectOr404","/settings":"redirect source: next.config.js 308 /settings -> /dashboard/settings; also auth-gated (/settings prefix); asserted in expectRedirectOr404","/settings/notifications":"auth-gated: /settings prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/settings/profile":"auth-gated: /settings prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/settings/security":"auth-gated: /settings prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/settings/support":"auth-gated: /settings prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/settings/users":"redirect source: next.config.js 308 /settings/users -> /dashboard/settings/users; has NO page.tsx (see ROUTE_CROSSCHECK.md); also auth-gated; asserted in expectRedirectOr404","/sysadmin":"auth-gated: /sysadmin prefix in AUTHENTICATED_APP_ROUTE_PREFIXES (+ sysadmin-only check); signed-out proxy redirects to /login","/tools/anomaly-simulator":"redirect(): src/app/tools/anomaly-simulator/page.tsx permanentRedirects to /tools/drill-simulator (in public); excluded from the designer 200-no-redirect crawl as redirecting; asserted in expectRedirectOr404","/tools/readiness-assessment":"redirect source: next.config.js 308 /tools/readiness-assessment -> /tools/self-reported-checklist; asserted in expectRedirectOr404","/tools/recall-readiness":"redirect source: next.config.js 308 /tools/recall-readiness -> /tools/records-request-checklist; asserted in expectRedirectOr404","/tools/retailer-readiness":"redirect source: page.tsx redirects to /retailer-readiness, which 308s to /buyer-programs; asserted in expectRedirectOr404","/trace":"auth-gated: /trace prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/trust-center":"redirect(): src/app/trust-center/page.tsx redirects to /trust (in public); excluded from the designer 200-no-redirect crawl as redirecting; asserted in expectRedirectOr404","/verticals/food-safety":"redirect(): src/app/verticals/food-safety/page.tsx redirects to /fsma-204 (in public); excluded from the designer 200-no-redirect crawl as redirecting; asserted in expectRedirectOr404"},"expectRedirectOr404":["/demo","/sandbox","/alpha","/retailer-readiness","/tools/recall-readiness","/tools/readiness-assessment","/tools/retailer-readiness","/settings","/settings/users","/compliance","/design-system","/tools/anomaly-simulator","/trust-center","/verticals/food-safety","/proof/recall-drill","/proof/transformation","/case-studies"],"priority":["/","/pricing","/integrations","/developers","/trust","/why-regengine","/proof","/tools/scan","/login","/signup"],"public":["/","/about","/accept-invite","/auth/verify","/blog","/blog/24-hour-rule","/blog/fda-food-traceability-requirements","/blog/food-traceability-software","/blog/fsma-204-compliance-checklist-2026","/blog/fsma-204-compliance-guide","/blog/fsma-204-cte-kde-guide","/blog/fsma-204-mid-market-guide","/blog/fsma-204-traceability-lot-codes","/blog/regengine-vs-spreadsheets-traceability","/buyer-programs","/compare/foodlogiq","/compare/repositrak","/compare/tracegains","/contact","/developers","/developers/compliance-verticals","/docs","/docs/api","/docs/authentication","/docs/changelog","/docs/connectors/inflow-lab","/docs/errors","/docs/fsma-204","/docs/quickstart","/docs/rate-limits","/docs/sdks","/docs/walkthrough","/docs/webhooks","/dpa","/fsma-204","/integrations","/legal/vulnerability-disclosure","/login","/pricing","/privacy","/product","/proof","/security","/signup","/status","/supplier-compliance","/terms","/tools","/tools/ask","/tools/cte-mapper","/tools/data-import","/tools/drill-simulator","/tools/export","/tools/fsma-unified","/tools/ftl-checker","/tools/inflow-lab","/tools/inflow-lab-dashboard-preview","/tools/kde-checker","/tools/knowledge-graph","/tools/label-scanner","/tools/notice-validator","/tools/obligation-scanner","/tools/records-request-checklist","/tools/roi-calculator","/tools/scan","/tools/self-reported-checklist","/tools/sop-generator","/tools/tlc-validator","/trust","/trust/architecture","/trust/retention","/trust/support","/verify","/walkthrough","/why-regengine"],"skippedDynamic":["/fsma-204/[cte]","/ingest/results/[id]","/portal/[portalId]","/requests/[id]"]}
+{"_generated":"2026-10-23 from /tmp/re-all-routes.txt (155 non-empty lines) crosschecked against frontend/src/app/**/page.{tsx,jsx}; reconciled 2026-10-23 with the designer crawl (81 routes 200-no-redirect on production): 4 routes moved public->expectRedirectOr404 (/design-system, /tools/anomaly-simulator, /trust-center, /verticals/food-safety)","_note":"Single replaceable route data file for the visual suite. Public vs auth determined by frontend/src/proxy.ts requireAppAuth + AUTHENTICATED_APP_ROUTE_PREFIXES in frontend/src/lib/app-routes.ts + GATED_DEV_ROUTES (/developer/portal) + next.config.js redirects(). Signed-out behavior only.","authOnly":["/admin","/admin/api-console","/api-keys","/audit","/compliance/labels","/compliance/profile","/compliance/snapshots","/compliance/status","/compliance/traceability-plan","/controls","/dashboard","/dashboard/agent-reviews","/dashboard/alerts","/dashboard/audit-log","/dashboard/compliance","/dashboard/export-jobs","/dashboard/heartbeat","/dashboard/inflow-lab","/dashboard/integrations","/dashboard/issues","/dashboard/notifications","/dashboard/products","/dashboard/qa-health","/dashboard/recall-drills","/dashboard/recall-report","/dashboard/recall-response","/dashboard/receiving","/dashboard/scan","/dashboard/settings","/dashboard/suppliers","/dashboard/team","/developer/portal","/developer/portal/keys","/exceptions","/fsma","/fsma/assessment","/fsma/field-capture","/fsma/integration","/identity","/ingest","/ingest/curation","/onboarding","/onboarding/bulk-upload","/onboarding/setup","/onboarding/setup/facility","/onboarding/setup/ftl-check","/onboarding/setup/welcome","/records","/remediation","/requests","/review","/rules","/settings/notifications","/settings/profile","/settings/security","/settings/support","/sysadmin","/trace"],"baseURLEnv":"VISUAL_BASE_URL","excludedReason":{"/admin":"auth-gated: /admin prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/admin/api-console":"auth-gated: /admin prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/alpha":"redirect(): src/app/alpha/page.tsx redirects to /contact; asserted in expectRedirectOr404","/api-keys":"auth-gated transitively: bare redirect() to gated /developer/portal/keys (see GATED_DEV_ROUTES comment in proxy.ts)","/audit":"auth-gated: /audit prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/auth/sso/mock":"excluded entirely: mock SSO fixture page (CHROMELESS_EXACT_ROUTES); must never be screenshotted or visited by monitoring","/compliance":"redirect source: next.config.js 308 /compliance -> /dashboard/compliance; also auth-gated (/compliance prefix); asserted in expectRedirectOr404","/compliance/labels":"auth-gated: /compliance prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/compliance/profile":"auth-gated: /compliance prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/compliance/snapshots":"auth-gated: /compliance prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/compliance/status":"auth-gated: /compliance prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/compliance/traceability-plan":"auth-gated: /compliance prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/controls":"auth-gated: /controls prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/dashboard":"auth-gated: /dashboard prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/dashboard/agent-reviews":"auth-gated: /dashboard prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/dashboard/alerts":"auth-gated: /dashboard prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/dashboard/audit-log":"auth-gated: /dashboard prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/dashboard/compliance":"auth-gated: /dashboard prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/dashboard/export-jobs":"auth-gated: /dashboard prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/dashboard/heartbeat":"auth-gated: /dashboard prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/dashboard/inflow-lab":"auth-gated: /dashboard prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/dashboard/integrations":"auth-gated: /dashboard prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/dashboard/issues":"auth-gated: /dashboard prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/dashboard/notifications":"auth-gated: /dashboard prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/dashboard/products":"auth-gated: /dashboard prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/dashboard/qa-health":"auth-gated: /dashboard prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/dashboard/recall-drills":"auth-gated: /dashboard prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/dashboard/recall-report":"auth-gated: /dashboard prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/dashboard/recall-response":"auth-gated: /dashboard prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/dashboard/receiving":"auth-gated: /dashboard prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/dashboard/scan":"auth-gated: /dashboard prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/dashboard/settings":"auth-gated: /dashboard prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/dashboard/suppliers":"auth-gated: /dashboard prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/dashboard/team":"auth-gated: /dashboard prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/design-system":"404 on production: src/app/design-system/page.tsx calls notFound() when NODE_ENV=production (Internal, development only); absent from the designer 200-no-redirect crawl; asserted in expectRedirectOr404","/developer/portal":"auth-gated: GATED_DEV_ROUTES in proxy.ts; signed-out proxy redirects to /login","/developer/portal/keys":"auth-gated: GATED_DEV_ROUTES in proxy.ts; signed-out proxy redirects to /login","/exceptions":"auth-gated: /exceptions prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/fsma":"auth-gated: /fsma prefix in AUTHENTICATED_APP_ROUTE_PREFIXES (exact + slash match only; does NOT cover /fsma-204); signed-out proxy redirects to /login","/fsma-204/[cte]":"dynamic [cte] param: no fixed URL to baseline; needs a param allowlist before it can be covered","/fsma/assessment":"auth-gated: /fsma prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/fsma/field-capture":"auth-gated: /fsma prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/fsma/integration":"auth-gated: /fsma prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/identity":"auth-gated: /identity prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/ingest":"auth-gated: /ingest prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/ingest/curation":"auth-gated: /ingest prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/ingest/results/[id]":"dynamic [id] param: no fixed URL to baseline; also auth-gated (/ingest prefix)","/onboarding":"auth-gated: /onboarding prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/onboarding/bulk-upload":"auth-gated: /onboarding prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/onboarding/setup":"auth-gated: /onboarding prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/onboarding/setup/facility":"auth-gated: /onboarding prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/onboarding/setup/ftl-check":"auth-gated: /onboarding prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/onboarding/setup/welcome":"auth-gated: /onboarding prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/portal/[portalId]":"dynamic [portalId] param: no fixed URL to baseline","/proof/recall-drill":"deleted in #3551: src/app/proof/recall-drill/page.tsx no longer exists (recall drill simulation removed), so the route is expected to 404; asserted in expectRedirectOr404","/records":"auth-gated: /records prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/remediation":"auth-gated: /remediation prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/requests":"auth-gated: /requests prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/requests/[id]":"dynamic [id] param: no fixed URL to baseline; also auth-gated (/requests prefix)","/retailer-readiness":"redirect source: next.config.js 308 /retailer-readiness -> /buyer-programs; page.tsx exists but the redirect wins; asserted in expectRedirectOr404","/review":"auth-gated: /review prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/rules":"auth-gated: /rules prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/sandbox":"deleted in #3557: src/app/sandbox/ no longer exists, so the route is expected to 404; asserted in expectRedirectOr404","/settings":"redirect source: next.config.js 308 /settings -> /dashboard/settings; also auth-gated (/settings prefix); asserted in expectRedirectOr404","/settings/notifications":"auth-gated: /settings prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/settings/profile":"auth-gated: /settings prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/settings/security":"auth-gated: /settings prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/settings/support":"auth-gated: /settings prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/settings/users":"redirect source: next.config.js 308 /settings/users -> /dashboard/settings/users; has NO page.tsx (see ROUTE_CROSSCHECK.md); also auth-gated; asserted in expectRedirectOr404","/sysadmin":"auth-gated: /sysadmin prefix in AUTHENTICATED_APP_ROUTE_PREFIXES (+ sysadmin-only check); signed-out proxy redirects to /login","/tools/anomaly-simulator":"redirect(): src/app/tools/anomaly-simulator/page.tsx permanentRedirects to /tools/drill-simulator (in public); excluded from the designer 200-no-redirect crawl as redirecting; asserted in expectRedirectOr404","/tools/readiness-assessment":"redirect source: next.config.js 308 /tools/readiness-assessment -> /tools/self-reported-checklist; asserted in expectRedirectOr404","/tools/recall-readiness":"redirect source: next.config.js 308 /tools/recall-readiness -> /tools/records-request-checklist; asserted in expectRedirectOr404","/tools/retailer-readiness":"redirect source: page.tsx redirects to /retailer-readiness, which 308s to /buyer-programs; asserted in expectRedirectOr404","/trace":"auth-gated: /trace prefix in AUTHENTICATED_APP_ROUTE_PREFIXES; signed-out proxy redirects to /login","/trust-center":"redirect(): src/app/trust-center/page.tsx redirects to /trust (in public); excluded from the designer 200-no-redirect crawl as redirecting; asserted in expectRedirectOr404","/verticals/food-safety":"redirect(): src/app/verticals/food-safety/page.tsx redirects to /fsma-204 (in public); excluded from the designer 200-no-redirect crawl as redirecting; asserted in expectRedirectOr404"},"expectRedirectOr404":["/sandbox","/alpha","/retailer-readiness","/tools/recall-readiness","/tools/readiness-assessment","/tools/retailer-readiness","/settings","/settings/users","/compliance","/design-system","/tools/anomaly-simulator","/trust-center","/verticals/food-safety","/proof/recall-drill","/proof/transformation","/case-studies"],"priority":["/","/pricing","/integrations","/developers","/trust","/why-regengine","/proof","/tools/scan","/login","/signup"],"public":["/","/about","/accept-invite","/auth/verify","/blog","/blog/24-hour-rule","/blog/fda-food-traceability-requirements","/blog/food-traceability-software","/blog/fsma-204-compliance-checklist-2026","/blog/fsma-204-compliance-guide","/blog/fsma-204-cte-kde-guide","/blog/fsma-204-mid-market-guide","/blog/fsma-204-traceability-lot-codes","/blog/regengine-vs-spreadsheets-traceability","/buyer-programs","/compare/foodlogiq","/compare/repositrak","/compare/tracegains","/contact","/demo","/developers","/developers/compliance-verticals","/docs","/docs/api","/docs/authentication","/docs/changelog","/docs/connectors/inflow-lab","/docs/errors","/docs/fsma-204","/docs/quickstart","/docs/rate-limits","/docs/sdks","/docs/walkthrough","/docs/webhooks","/dpa","/fsma-204","/integrations","/legal/vulnerability-disclosure","/login","/pricing","/privacy","/product","/proof","/security","/signup","/status","/supplier-compliance","/terms","/tools","/tools/ask","/tools/cte-mapper","/tools/data-import","/tools/drill-simulator","/tools/export","/tools/fsma-unified","/tools/ftl-checker","/tools/inflow-lab","/tools/inflow-lab-dashboard-preview","/tools/kde-checker","/tools/knowledge-graph","/tools/label-scanner","/tools/notice-validator","/tools/obligation-scanner","/tools/records-request-checklist","/tools/roi-calculator","/tools/scan","/tools/self-reported-checklist","/tools/sop-generator","/tools/tlc-validator","/trust","/trust/architecture","/trust/retention","/trust/support","/verify","/walkthrough","/why-regengine"],"skippedDynamic":["/fsma-204/[cte]","/ingest/results/[id]","/portal/[portalId]","/requests/[id]"]}
diff --git a/scripts/copy-lint/allowlist.json b/scripts/copy-lint/allowlist.json
index f567f3a5f..679809727 100644
--- a/scripts/copy-lint/allowlist.json
+++ b/scripts/copy-lint/allowlist.json
@@ -1,6 +1,77 @@
{
"comment": "Justified exceptions only. Each entry needs file, rule, match (substring of the flagged text) and reason. Optional line pins it to one line. Review every entry in PR.",
"entries": [
- { "file": "frontend/src/lib/api-proxy.ts", "rule": "nan-undefined", "match": "bearer undefined", "reason": "Auth header guard that rejects a malformed token. Never rendered." }
+ {
+ "file": "frontend/src/lib/api-proxy.ts",
+ "rule": "nan-undefined",
+ "match": "bearer undefined",
+ "reason": "Auth header guard that rejects a malformed token. Never rendered."
+ },
+ {
+ "file": "frontend/src/components/layout/marketing-nav.ts",
+ "rule": "sample-demo",
+ "match": "Demo workspace",
+ "reason": "Approved copy for the public demo workspace and landing hero (design handoff 2026-10-28). The surface is labelled as a demo on synthetic data on purpose."
+ },
+ {
+ "file": "frontend/src/app/page.tsx",
+ "rule": "sample-demo",
+ "match": "Open the demo workspace",
+ "reason": "Approved copy for the public demo workspace and landing hero (design handoff 2026-10-28). The surface is labelled as a demo on synthetic data on purpose."
+ },
+ {
+ "file": "frontend/src/components/marketing/HeroWorkbench.tsx",
+ "rule": "sample-demo",
+ "match": "demo workspace",
+ "reason": "Approved copy for the public demo workspace and landing hero (design handoff 2026-10-28). The surface is labelled as a demo on synthetic data on purpose."
+ },
+ {
+ "file": "frontend/src/components/marketing/HeroWorkbench.tsx",
+ "rule": "sample-demo",
+ "match": "Synthetic data",
+ "reason": "Approved copy for the public demo workspace and landing hero (design handoff 2026-10-28). The surface is labelled as a demo on synthetic data on purpose."
+ },
+ {
+ "file": "frontend/src/components/demo-workspace/DemoShell.tsx",
+ "rule": "sample-demo",
+ "match": "Demo workspace",
+ "reason": "Approved copy for the public demo workspace and landing hero (design handoff 2026-10-28). The surface is labelled as a demo on synthetic data on purpose."
+ },
+ {
+ "file": "frontend/src/components/demo-workspace/DemoShell.tsx",
+ "rule": "sample-demo",
+ "match": "Reset demo",
+ "reason": "Approved copy for the public demo workspace and landing hero (design handoff 2026-10-28). The surface is labelled as a demo on synthetic data on purpose."
+ },
+ {
+ "file": "frontend/src/components/demo-workspace/DemoShell.tsx",
+ "rule": "sample-demo",
+ "match": "synthetic data",
+ "reason": "Approved copy for the public demo workspace and landing hero (design handoff 2026-10-28). The surface is labelled as a demo on synthetic data on purpose."
+ },
+ {
+ "file": "frontend/src/components/demo-workspace/RecordDetail.tsx",
+ "rule": "sample-demo",
+ "match": "Reset demo",
+ "reason": "Approved copy for the public demo workspace and landing hero (design handoff 2026-10-28). The surface is labelled as a demo on synthetic data on purpose."
+ },
+ {
+ "file": "frontend/src/app/demo/layout.tsx",
+ "rule": "sample-demo",
+ "match": "Demo workspace",
+ "reason": "Approved copy for the public demo workspace and landing hero (design handoff 2026-10-28). The surface is labelled as a demo on synthetic data on purpose."
+ },
+ {
+ "file": "frontend/src/app/demo/layout.tsx",
+ "rule": "sample-demo",
+ "match": "synthetic data",
+ "reason": "Approved copy for the public demo workspace and landing hero (design handoff 2026-10-28). The surface is labelled as a demo on synthetic data on purpose."
+ },
+ {
+ "file": "frontend/src/app/demo/layout.tsx",
+ "rule": "sample-demo",
+ "match": "demo workspace",
+ "reason": "Approved copy for the public demo workspace and landing hero (design handoff 2026-10-28). The surface is labelled as a demo on synthetic data on purpose."
+ }
]
}