diff --git a/frontend/src/__tests__/components/hero-workbench.test.tsx b/frontend/src/__tests__/components/hero-workbench.test.tsx new file mode 100644 index 000000000..8f4c48109 --- /dev/null +++ b/frontend/src/__tests__/components/hero-workbench.test.tsx @@ -0,0 +1,83 @@ +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { HeroWorkbench } from '@/components/marketing/HeroWorkbench'; +import { gapIndex, scenarios } from '@/lib/demo-workspace/model'; + +vi.mock('@/lib/conversion-events', () => ({ + trackScenarioTab: vi.fn(), + trackRowClick: vi.fn(), + trackSendCorrectionLink: vi.fn(), + trackResetDemo: vi.fn(), + trackToggleRows: vi.fn(), + trackBookCallClick: vi.fn(), +})); + +import * as events from '@/lib/conversion-events'; + +const first = scenarios()[0]; + +function gapRow() { + return screen.getByText('missing').closest('tr') as HTMLTableRowElement; +} + +describe('HeroWorkbench', () => { + beforeEach(() => vi.clearAllMocks()); + + it('shows four rows around the gap and a guided caption until a row is clicked', () => { + render(); + expect(screen.getAllByRole('row')).toHaveLength(5); // header + 4 + expect(screen.getByText(/one record is missing a required field/i)).toBeInTheDocument(); + expect(screen.getByText(/show \d+ more records? in this chain/i)).toBeInTheDocument(); + + fireEvent.click(gapRow()); + expect(screen.queryByText(/one record is missing a required field/i)).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /send correction link to supplier/i })).toBeInTheDocument(); + expect(events.trackRowClick).toHaveBeenCalledWith('landing', first.id, first.chain[gapIndex(first)].cte, true); + }); + + it('commits the record when the correction link is sent and restores it on reset', () => { + render(); + expect(screen.getByText(/of \d+ ready/i)).toBeInTheDocument(); + + // The status strip names the file the Export view downloads for this chain. + expect(screen.getByText(`fda_sortable_${first.id}.csv`)).toBeInTheDocument(); + // Each food tab controls the labelled panel that holds the records. + const panel = screen.getByRole('tabpanel'); + expect(screen.getAllByRole('tab')[0]).toHaveAttribute('aria-controls', panel.id); + expect(panel).toHaveAttribute('aria-labelledby', screen.getAllByRole('tab')[0].id); + + const gapButton = within(gapRow()).getByRole('button'); + expect(gapButton).toHaveAttribute('aria-pressed', 'false'); + fireEvent.click(gapButton); + expect(within(gapRow()).getByRole('button')).toHaveAttribute('aria-pressed', 'true'); + fireEvent.click(screen.getByRole('button', { name: /send correction link to supplier/i })); + + expect(screen.getByText(/supplier corrected the record/i)).toBeInTheDocument(); + expect(screen.getByText('Ready to export')).toBeInTheDocument(); + expect(screen.getByText('Corrected')).toBeInTheDocument(); + expect(screen.queryByText('missing')).not.toBeInTheDocument(); + const correctedRow = screen.getByText('Corrected').closest('tr') as HTMLTableRowElement; + expect(correctedRow).toHaveTextContent(first.lot); + expect(events.trackSendCorrectionLink).toHaveBeenCalledWith('landing', first.id, first.chain[gapIndex(first)].cte); + + fireEvent.click(screen.getByRole('button', { name: /reset demo/i })); + expect(screen.getByText('missing')).toBeInTheDocument(); + expect(screen.queryByText('Ready to export')).not.toBeInTheDocument(); + expect(events.trackResetDemo).toHaveBeenCalledWith('landing', first.id); + }); + + it('expands to the whole chain and switches chains from the food tabs', () => { + render(); + fireEvent.click(screen.getByText(/show \d+ more records? in this chain/i)); + expect(screen.getAllByRole('row')).toHaveLength(first.chain.length + 1); + expect(screen.getByText(/show fewer records/i)).toBeInTheDocument(); + expect(events.trackToggleRows).toHaveBeenCalledWith('landing', first.id); + + const tabs = screen.getAllByRole('tab'); + fireEvent.click(tabs[2]); + expect(tabs[2]).toHaveAttribute('aria-selected', 'true'); + expect(events.trackScenarioTab).toHaveBeenCalledWith('landing', scenarios()[2].id); + // switching chains collapses the table again + expect(screen.getAllByRole('row')).toHaveLength(5); + }); +}); diff --git a/frontend/src/__tests__/lib/app-routes.test.ts b/frontend/src/__tests__/lib/app-routes.test.ts index 5ca42cd2d..dcd9f87bd 100644 --- a/frontend/src/__tests__/lib/app-routes.test.ts +++ b/frontend/src/__tests__/lib/app-routes.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { isAuthenticatedAppRoute, shouldHideMarketingChrome } from '@/lib/app-routes'; +import { isAuthenticatedAppRoute, isForcedDarkRoute, shouldHideCookieBanner, shouldHideMarketingChrome } from '@/lib/app-routes'; describe('app route ownership', () => { it('treats operational root routes as authenticated app routes', () => { @@ -33,4 +33,21 @@ describe('app route ownership', () => { // Portal routes carry their own chrome but are NOT middleware-auth routes. expect(isAuthenticatedAppRoute('/developer/portal')).toBe(false); }); + + it('gives the public demo workspace its own chrome without an auth gate, and forces dark on it and the landing page', () => { + expect(shouldHideMarketingChrome('/demo')).toBe(true); + expect(isAuthenticatedAppRoute('/demo')).toBe(false); + expect(isForcedDarkRoute('/')).toBe(true); + expect(isForcedDarkRoute('/demo')).toBe(true); + expect(isForcedDarkRoute('/pricing')).toBe(false); + expect(isForcedDarkRoute('/demo/anything')).toBe(false); + }); + + it('keeps the cookie consent banner on the public demo but not in the app or the developer portal', () => { + expect(shouldHideCookieBanner('/demo')).toBe(false); + expect(shouldHideCookieBanner('/')).toBe(false); + expect(shouldHideCookieBanner('/developer/portal')).toBe(true); + expect(shouldHideCookieBanner('/dashboard')).toBe(true); + expect(shouldHideCookieBanner('/login')).toBe(true); + }); }); diff --git a/frontend/src/__tests__/lib/conversion-events.test.ts b/frontend/src/__tests__/lib/conversion-events.test.ts index 702afcee2..088388ed0 100644 --- a/frontend/src/__tests__/lib/conversion-events.test.ts +++ b/frontend/src/__tests__/lib/conversion-events.test.ts @@ -6,6 +6,7 @@ import { isInternalAnalyticsSession, setConversionAnalyticsEnabled, trackConversionEvent, + trackRowClick, trackToolRunStarted, } from '@/lib/conversion-events'; @@ -203,3 +204,18 @@ describe('conversion events reach PostHog', () => { expect(captureEvent).not.toHaveBeenCalled(); }); }); + +describe('public workbench events', () => { + it('keeps the surface, scenario, CTE and gap flag and nothing else', () => { + window.history.replaceState({}, '', '/demo'); + trackRowClick('workspace', 'peppers', 'Initial packing', true); + expect(track).toHaveBeenLastCalledWith('row_click', { + path: '/demo', surface: 'workspace', scenario: 'peppers', cte: 'initial_packing', gap: true, + }); + + trackConversionEvent('send_correction_link', { + surface: 'elsewhere', scenario: 'Rancho Las Lomas', cte: 'Receiving', gap: 'yes', email: 'person@example.com', + }); + expect(track).toHaveBeenLastCalledWith('send_correction_link', { path: '/demo', cte: 'receiving' }); + }); +}); diff --git a/frontend/src/__tests__/lib/demo-workspace-model.test.ts b/frontend/src/__tests__/lib/demo-workspace-model.test.ts new file mode 100644 index 000000000..d6e7fbe6c --- /dev/null +++ b/frontend/src/__tests__/lib/demo-workspace-model.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it } from 'vitest'; +import { + CSV_HEADER, + attentionRows, + buildCsv, + buildManifest, + deriveLedger, + derivePackages, + deriveRows, + detailFor, + fixQueue, + gapIndex, + heroStatus, + nextActions, + overviewStats, + parseView, + recordState, + scenarios, + stableHash, + windowRows, + type FixedMap, +} from '@/lib/demo-workspace/model'; + +const all = scenarios(); +const first = all[0]; + +describe('demo workspace dataset', () => { + it('ships five chains with exactly one gap each', () => { + expect(all).toHaveLength(5); + for (const scenario of all) { + expect(scenario.chain.length).toBeGreaterThanOrEqual(6); + expect(scenario.chain.length).toBeLessThanOrEqual(7); + expect(scenario.chain.filter((r) => r.gap)).toHaveLength(1); + expect(scenario.hue).toMatch(/^#[0-9a-f]{6}$/i); + } + }); +}); + +describe('record state and rows', () => { + it('marks the gap row open until the scenario is fixed', () => { + const idx = gapIndex(first); + const gap = first.chain[idx]; + expect(recordState(gap, false)).toBe('needs-correction'); + expect(recordState(gap, true)).toBe('corrected'); + expect(recordState(first.chain[0], false)).toBe('committed'); + + const open = deriveRows(first, {}); + expect(open[idx].lot).toBe('missing'); + expect(open[idx].openGap).toBe(true); + + const fixed = deriveRows(first, { [first.id]: true }); + expect(fixed[idx].lot).toBe(first.lot); + expect(fixed[idx].state).toBe('corrected'); + expect(fixed[idx].openGap).toBe(false); + }); + + it('windows four rows around the gap and expands to the full chain', () => { + const rows = deriveRows(first, {}); + const idx = gapIndex(first); + const collapsed = windowRows(rows, idx, false); + expect(collapsed.visible).toHaveLength(4); + expect(collapsed.visible.some((r) => r.index === idx)).toBe(true); + expect(collapsed.hidden).toBe(rows.length - 4); + const expanded = windowRows(rows, idx, true); + expect(expanded.visible).toHaveLength(rows.length); + expect(expanded.hidden).toBe(0); + }); + + it('describes the gap, then the corrected record, in the detail panel', () => { + const idx = gapIndex(first); + const gap = detailFor(first, idx, {}, 'landing'); + expect(gap?.kind).toBe('gap'); + expect(gap?.canFix).toBe(true); + expect(gap?.kv.map(([k]) => k)).toEqual(['Owner', 'Recovery', 'Date']); + + const workspaceGap = detailFor(first, idx, {}, 'workspace'); + expect(workspaceGap?.kv.map(([k]) => k)).toEqual(['Owner', 'Recovery', 'Date', 'Location']); + + const corrected = detailFor(first, idx, { [first.id]: true }, 'workspace'); + expect(corrected?.kind).toBe('corrected'); + expect(corrected?.canFix).toBe(false); + expect(corrected?.kv.map(([k]) => k)).toContain('Event hash'); + + expect(detailFor(first, null, {}, 'landing')).toBeNull(); + }); +}); + +describe('hashes, ledger and packages', () => { + it('produces deterministic stand-in hashes', () => { + expect(stableHash('x')).toBe(stableHash('x')); + expect(stableHash('x')).not.toBe(stableHash('y')); + expect(stableHash('x')).toMatch(/^[0-9a-f]{8}…[0-9a-f]{4}$/); + }); + + it('chains every committed record and appends one entry per fix', () => { + const total = all.reduce((sum, s) => sum + s.chain.length, 0); + const open = deriveLedger(all, {}); + expect(open).toHaveLength(total - all.length); + const after = deriveLedger(all, { [first.id]: true }); + expect(after).toHaveLength(open.length + 1); + for (const entry of after) { + expect(entry.ts).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:00-07:00$/); + expect(entry.chainHash).not.toBe(entry.eventHash); + } + expect(new Set(after.map((e) => e.chainHash)).size).toBe(after.length); + }); + + it('appends corrections in fix order without rewriting committed entries', () => { + const open = deriveLedger(all, {}); + const peppers = all.find((s) => s.id === 'peppers')!; + const after = deriveLedger(all, { peppers: true }); + expect(after.slice(0, open.length)).toEqual(open); + const appended = after[after.length - 1]; + expect(appended.scenarioId).toBe('peppers'); + expect(appended.lot).toBe(peppers.lot); + expect(appended.n).toBe(open[open.length - 1].n + 1); + + const twoFixes = deriveLedger(all, { cheese: true, peppers: true }); + expect(twoFixes.slice(0, open.length)).toEqual(open); + expect(twoFixes.slice(open.length).map((e) => e.scenarioId)).toEqual(['cheese', 'peppers']); + }); +}); + +describe('lot codes on gap records', () => { + const byId = (id: string) => all.find((s) => s.id === id)!; + + it('shows a missing lot only when the lot itself is the gap', () => { + const peppers = byId('peppers'); + const idx = gapIndex(peppers); + expect(deriveRows(peppers, {})[idx].lot).toBe('missing'); + expect(deriveRows(peppers, { peppers: true })[idx].lot).toBe(peppers.lot); + }); + + it('keeps the record lot when another field is the gap', () => { + const cantaloupe = byId('cantaloupe'); + const idx = gapIndex(cantaloupe); + const ownLot = cantaloupe.chain[idx].kdes.lot; + expect(ownLot).not.toBe(cantaloupe.lot); + const states: FixedMap[] = [{}, { cantaloupe: true }]; + for (const fixed of states) { + expect(deriveRows(cantaloupe, fixed)[idx].lot).toBe(ownLot); + expect(buildCsv(cantaloupe, fixed).split('\n')[idx + 1]).toContain(`"${ownLot}"`); + } + const detail = detailFor(cantaloupe, idx, { cantaloupe: true }, 'workspace')!; + expect(detail.kv).toContainEqual(['Lot', ownLot]); + const ledger = deriveLedger(all, { cantaloupe: true }); + expect(ledger[ledger.length - 1].lot).toBe(ownLot); + + for (const id of ['cucumbers', 'cheese']) { + const scenario = byId(id); + expect(deriveRows(scenario, {})[gapIndex(scenario)].lot).not.toBe('missing'); + } + }); +}); + +describe('csv and packages', () => { + it('labels the manifest hash as a stand-in and gives no unusable verify command', () => { + const manifest = buildManifest('fda_sortable_peppers.csv', stableHash('x')); + expect(manifest).toContain('not SHA-256'); + expect(manifest).not.toMatch(/^verify:/m); + }); + + + it('writes an OPEN row instead of dropping the gap from the CSV', () => { + const csv = buildCsv(first, {}); + const lines = csv.split('\n'); + expect(lines[0]).toBe(CSV_HEADER); + expect(lines).toHaveLength(first.chain.length + 1); + expect(lines[gapIndex(first) + 1]).toContain('"OPEN"'); + expect(lines[gapIndex(first) + 1]).toContain('""'); + const fixedCsv = buildCsv(first, { [first.id]: true }); + expect(fixedCsv).not.toContain('"OPEN"'); + expect(fixedCsv).toContain(`"${first.lot}"`); + }); + + it('flips a package to ready once its chain is fixed', () => { + const before = derivePackages(all, {}); + expect(before.every((p) => !p.ready)).toBe(true); + expect(before[0].badge).toBe(`${first.chain.length - 1} OF ${first.chain.length} READY`); + const after = derivePackages(all, { [first.id]: true }); + expect(after[0].ready).toBe(true); + expect(after[0].badge).toBe('READY'); + expect(after[0].manifest).not.toBe(before[0].manifest); + }); + + it('summarises the hero status strip', () => { + const open = heroStatus(first, {}); + expect(open.open).toBe(1); + expect(open.exportReady).toBe(false); + expect(open.ledgerLine).toBe(`${first.chain.length - 1} of ${first.chain.length} records committed`); + const done = heroStatus(first, { [first.id]: true }); + expect(done.exportReady).toBe(true); + expect(done.exportBadge).toBe('Ready to export'); + }); +}); + +describe('overview derivations', () => { + it('counts open fixes down as chains are fixed', () => { + expect(overviewStats(all, {}).open).toBe(5); + expect(overviewStats(all, { [first.id]: true }).open).toBe(4); + expect(attentionRows(all, {})).toHaveLength(5); + expect(attentionRows(all, { [first.id]: true })).toHaveLength(4); + expect(fixQueue(all, { [first.id]: true }).filter((f) => f.done)).toHaveLength(1); + }); + + it('orders next actions and drops the fix step once the queue is clear', () => { + const everything = Object.fromEntries(all.map((s) => [s.id, true])); + expect(nextActions(all, {}).map((a) => a.view)).toEqual(['fix', 'export', 'ledger']); + expect(nextActions(all, everything).map((a) => a.view)).toEqual(['export', 'ledger']); + expect(nextActions(all, {})[0].n).toBe('01'); + // No chain is complete before any fix, so the export action must not offer "0 packages". + expect(nextActions(all, {})[1].label).not.toMatch(/\b0\b/); + expect(nextActions(all, { [first.id]: true })[1].label).toBe('Export the 1 complete package'); + }); + + it('parses the view from the location hash', () => { + expect(parseView('#ledger')).toBe('ledger'); + expect(parseView('ledger')).toBe('ledger'); + expect(parseView('#nope')).toBe('overview'); + expect(parseView('')).toBe('overview'); + }); +}); diff --git a/frontend/src/__tests__/lib/marketing-analytics-routes.test.ts b/frontend/src/__tests__/lib/marketing-analytics-routes.test.ts index ff83cea14..ed28e4c6c 100644 --- a/frontend/src/__tests__/lib/marketing-analytics-routes.test.ts +++ b/frontend/src/__tests__/lib/marketing-analytics-routes.test.ts @@ -45,6 +45,7 @@ describe('marketing pageview routes', () => { it('normalizes arbitrary public child paths so names and encoded email never become pageview URLs', () => { document.cookie = 're_cookie_consent=accepted; path=/'; expect(safeMarketingPath('/pricing/Jane-Sellers')).toBe('/pricing'); + expect(safeMarketingPath('/demo')).toBe('/demo'); expect(safeMarketingPath('/tools/person%2540example.com')).toBe('/tools'); expect(safeMarketingPath('/Jane-Sellers')).toBeNull(); expect(shouldTrackMarketingPageview('/pricing/Jane-Sellers')).toBe(true); diff --git a/frontend/src/__tests__/pages/demo-workspace.test.tsx b/frontend/src/__tests__/pages/demo-workspace.test.tsx new file mode 100644 index 000000000..76d200d5e --- /dev/null +++ b/frontend/src/__tests__/pages/demo-workspace.test.tsx @@ -0,0 +1,111 @@ +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import DemoWorkspacePage from '@/app/demo/page'; +import { metadata } from '@/app/demo/layout'; +import { scenarios } from '@/lib/demo-workspace/model'; + +vi.mock('@/lib/conversion-events', () => ({ + trackScenarioTab: vi.fn(), + trackRowClick: vi.fn(), + trackSendCorrectionLink: vi.fn(), + trackResetDemo: vi.fn(), + trackToggleRows: vi.fn(), + trackBookCallClick: vi.fn(), +})); + +const all = scenarios(); + +function nav() { + return within(screen.getByRole('navigation', { name: /demo workspace/i })); +} + +describe('/demo public workspace', () => { + beforeEach(() => { + window.history.replaceState({}, '', '/demo'); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('declares its own metadata and open graph card', () => { + expect(metadata.title).toMatch(/demo workspace/i); + expect(metadata.alternates?.canonical).toBe('/demo'); + const og = metadata.openGraph as { url?: string; images?: unknown[] }; + expect(og.url).toBe('https://regengine.co/demo'); + expect(og.images).toHaveLength(1); + }); + + it('opens on the overview with every chain needing one fix', () => { + render(); + expect(screen.getByRole('heading', { level: 1, name: 'Overview' })).toBeInTheDocument(); + expect(screen.getByText('Open fixes').nextElementSibling).toHaveTextContent('5'); + expect(nav().getByRole('button', { name: /fix queue/i })).toHaveTextContent('5'); + expect(screen.getAllByText('Needs correction')).toHaveLength(all.length); + expect(screen.getByText(/demo workspace\./i)).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /book a 30-min call/i })).toHaveAttribute('href', '/contact'); + }); + + it('switches views from the sidebar and mirrors the view to the location hash', () => { + render(); + fireEvent.click(nav().getByRole('button', { name: /ledger/i })); + expect(screen.getByRole('heading', { level: 1, name: 'Evidence ledger' })).toBeInTheDocument(); + expect(window.location.hash).toBe('#ledger'); + const total = all.reduce((sum, s) => sum + s.chain.length, 0); + expect(screen.getByText(`${total - all.length} entries · hash-chained`)).toBeInTheDocument(); + }); + + it('jumps from an attention row to the import view with that record selected', () => { + render(); + const rows = screen.getAllByRole('row').filter((row) => within(row).queryByText('Needs correction')); + fireEvent.click(within(rows[1]).getByRole('button')); + expect(screen.getByRole('heading', { level: 1, name: /import/i })).toBeInTheDocument(); + expect(screen.getAllByRole('tab')[1]).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByRole('button', { name: /send correction link to supplier/i })).toBeInTheDocument(); + }); + + it('decrements the fix count and clears the attention row once a correction link is sent', () => { + render(); + fireEvent.click(nav().getByRole('button', { name: /fix queue/i })); + const sendButtons = screen.getAllByRole('button', { name: /^send correction link$/i }); + expect(sendButtons).toHaveLength(all.length); + fireEvent.click(sendButtons[0]); + expect(screen.getByText('✓ Committed')).toBeInTheDocument(); + expect(nav().getByRole('button', { name: /fix queue/i })).toHaveTextContent('4'); + + fireEvent.click(nav().getByRole('button', { name: /overview/i })); + expect(screen.getAllByText('Needs correction')).toHaveLength(all.length - 1); + expect(screen.getByText('Open fixes').nextElementSibling).toHaveTextContent('4'); + + fireEvent.click(screen.getByRole('button', { name: /reset demo/i })); + expect(screen.getAllByText('Needs correction')).toHaveLength(all.length); + }); + + it('shows the all-clear state when every chain is fixed', () => { + render(); + fireEvent.click(nav().getByRole('button', { name: /fix queue/i })); + for (const button of screen.getAllByRole('button', { name: /^send correction link$/i })) fireEvent.click(button); + fireEvent.click(nav().getByRole('button', { name: /overview/i })); + expect(screen.getByText(/nothing needs attention/i)).toBeInTheDocument(); + expect(nav().getByRole('button', { name: /fix queue/i })).not.toHaveTextContent(/\d/); + }); + + it('hands the visitor a CSV from the export view', () => { + const createObjectURL = vi.fn((_blob: Blob) => 'blob:demo'); + const revokeObjectURL = vi.fn(); + vi.stubGlobal('URL', Object.assign(URL, { createObjectURL, revokeObjectURL })); + const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); + + render(); + fireEvent.click(screen.getByRole('button', { name: /export packages/i })); + expect(screen.getByRole('heading', { level: 1, name: 'Export packages' })).toBeInTheDocument(); + expect(screen.getAllByText(`${all[0].chain.length - 1} OF ${all[0].chain.length} READY`).length).toBeGreaterThan(0); + + fireEvent.click(screen.getAllByRole('button', { name: /download csv/i })[0]); + expect(createObjectURL).toHaveBeenCalledTimes(1); + const blob = createObjectURL.mock.calls[0][0]; + expect(blob.type).toBe('text/csv'); + expect(click).toHaveBeenCalled(); + click.mockRestore(); + }); +}); diff --git a/frontend/src/__tests__/pages/homepage.test.tsx b/frontend/src/__tests__/pages/homepage.test.tsx index 7ac0f1e1e..aff92a19d 100644 --- a/frontend/src/__tests__/pages/homepage.test.tsx +++ b/frontend/src/__tests__/pages/homepage.test.tsx @@ -4,18 +4,10 @@ import HomePage from "@/app/page"; import { capabilities } from "@/lib/capabilities"; describe("HomePage", () => { - it("leads with the records request hook and the live ingest paths", () => { + it("leads with the records request hook and the disclaimer", () => { render(); - expect( - screen.getByRole("heading", { - name: "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.", - level: 1, - }), - ).toBeInTheDocument(); - expect( - screen.getByText(/send records by csv upload or the json events api\. epcis 2\.0 is in preview\./i), - ).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Find the gaps before the FDA does.", level: 1 })).toBeInTheDocument(); expect(screen.getAllByRole("heading", { level: 1 })).toHaveLength(1); expect(screen.getByText(/21 CFR 1\.1455\(c\)\(3\)\(ii\)/)).toBeInTheDocument(); const disclaimer = screen.getByText(/does not imply FDA endorsement or certification/i).textContent ?? ""; @@ -28,22 +20,38 @@ describe("HomePage", () => { ]) { expect(disclaimer).toContain(clause); } + expect(disclaimer).toMatch(/Live today: CSV upload and JSON events API\./); + expect(disclaimer).toMatch(/In preview: EPCIS 2\.0 intake and export\./); }); - it("sends the primary call to action to a free tool that works without an API key", () => { + it("sends the primary call to action to a free tool and the secondary one to the public workspace", () => { render(); - // identical destinations carry identical accessible names (hero and next steps) - const checklistLinks = screen.getAllByRole("link", { name: /^see the fields your records need$/i }); + // identical destinations carry identical accessible names (hero and free tools) + const checklistLinks = screen.getAllByRole("link", { name: /^see the fields your records need/i }); expect(checklistLinks).toHaveLength(2); for (const link of checklistLinks) expect(link).toHaveAttribute("href", "/tools/kde-checker"); - const verifyLinks = screen.getAllByRole("link", { name: /^verify an evidence bundle$/i }); - expect(verifyLinks).toHaveLength(2); + const verifyLinks = screen.getAllByRole("link", { name: /^verify an evidence bundle/i }); + expect(verifyLinks).toHaveLength(1); for (const link of verifyLinks) expect(link).toHaveAttribute("href", "/verify"); - expect(screen.getByText(/no account, no api key/i)).toBeInTheDocument(); + const demoLinks = screen.getAllByRole("link", { name: /demo workspace/i }); + expect(demoLinks.length).toBeGreaterThanOrEqual(2); + for (const link of demoLinks) expect(link).toHaveAttribute("href", "/demo"); + expect(screen.getByRole("link", { name: /book a 30-min call/i })).toHaveAttribute("href", "/contact"); + expect(screen.getAllByText(/no account needed/i).length).toBeGreaterThan(0); expect(screen.queryByRole("link", { name: /check a supplier file/i })).not.toBeInTheDocument(); }); + it("renders the hero workbench with one open gap per chain", () => { + render(); + + expect(screen.getByRole("tablist", { name: /ftl food/i })).toBeInTheDocument(); + expect(screen.getAllByRole("tab")).toHaveLength(5); + expect(screen.getByText(/one record is missing a required field/i)).toBeInTheDocument(); + expect(screen.getByText("missing")).toBeInTheDocument(); + expect(screen.getByText("Needs correction")).toBeInTheDocument(); + }); + it("lists ingest paths from the capabilities registry, each under its own status", () => { const { container } = render(); @@ -69,14 +77,13 @@ describe("HomePage", () => { expect(screen.queryByText("No data yet")).not.toBeInTheDocument(); }); - it("does not say pre-revenue and contains no invented data", () => { + it("does not say pre-revenue and contains no retired invented data", () => { const { container } = render(); const text = container.textContent ?? ""; expect(text).not.toMatch(/pre-revenue/i); - expect(text).not.toMatch(/synthetic/i); - expect(text).not.toMatch(/demo workspace/i); expect(text).not.toContain("RE-DEMO-PKG"); expect(text).not.toMatch(/FreshPack Central|Valley Farms|Cold Dock 7/); + expect(text).not.toMatch(/sandbox/i); }); }); diff --git a/frontend/src/__tests__/seo/public-snippets.test.ts b/frontend/src/__tests__/seo/public-snippets.test.ts index 9d5eb6151..76dfcb8a4 100644 --- a/frontend/src/__tests__/seo/public-snippets.test.ts +++ b/frontend/src/__tests__/seo/public-snippets.test.ts @@ -29,7 +29,7 @@ const DASH = /[\u2014\u2013]/; const HOME_TITLE = 'FSMA 204 Recordkeeping Software | RegEngine'; const HOME_DESCRIPTION = 'FSMA 204 recordkeeping software. Free FTL, CTE, KDE, and TLC checkers. FDA says it will not enforce before July 20, 2028. Not legal advice.'; -const HOME_H1 = '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.'; +const HOME_H1 = 'Find the gaps before the FDA does.'; const TOOLS_TITLE = 'Free FSMA 204 Tools | RegEngine'; const TOOLS_DESCRIPTION = diff --git a/frontend/src/app/demo/layout.tsx b/frontend/src/app/demo/layout.tsx new file mode 100644 index 000000000..8752795eb --- /dev/null +++ b/frontend/src/app/demo/layout.tsx @@ -0,0 +1,23 @@ +import type { Metadata } from 'next'; +import type { ReactNode } from 'react'; + +const TITLE = 'Demo workspace | RegEngine'; +const DESCRIPTION = + 'A public, read-only RegEngine workspace. Five supply chains of synthetic data modelled on FDA major recall notices: import, fix queue, evidence ledger and export packages. No account needed.'; + +export const metadata: Metadata = { + title: TITLE, + description: DESCRIPTION, + alternates: { canonical: '/demo' }, + openGraph: { + title: TITLE, + description: DESCRIPTION, + url: 'https://regengine.co/demo', + type: 'website', + images: [{ url: '/og-image.png', width: 1200, height: 630, alt: 'RegEngine demo workspace' }], + }, +}; + +export default function DemoLayout({ children }: { children: ReactNode }) { + return children; +} diff --git a/frontend/src/app/demo/page.tsx b/frontend/src/app/demo/page.tsx new file mode 100644 index 000000000..3e27b5162 --- /dev/null +++ b/frontend/src/app/demo/page.tsx @@ -0,0 +1,110 @@ +'use client'; + +import { useCallback, useSyncExternalStore } from 'react'; +import { DemoShell } from '@/components/demo-workspace/DemoShell'; +import { useDemoWorkspace } from '@/components/demo-workspace/useDemoWorkspace'; +import { ExportView } from '@/components/demo-workspace/views/ExportView'; +import { FixQueueView } from '@/components/demo-workspace/views/FixQueueView'; +import { ImportView } from '@/components/demo-workspace/views/ImportView'; +import { LedgerView } from '@/components/demo-workspace/views/LedgerView'; +import { OverviewView } from '@/components/demo-workspace/views/OverviewView'; +import { + attentionRows, + deriveLedger, + derivePackages, + fixQueue, + nextActions, + openScenarioCount, + overviewStats, + parseView, + type ViewId, +} from '@/lib/demo-workspace/model'; + +const TITLES: Record = { + overview: [ + 'Overview', + 'Where the records stand, what is wrong, and what to do next. Everything here is driven by five imported supplier files.', + ], + import: [ + 'Import · Inflow workbench', + 'Each supplier file, record by record, checked against the Key Data Elements its event type requires. Click a record.', + ], + fix: [ + 'Fix queue', + 'Every gap with a reason, a rule, an owner and a recovery path. Sending the correction link lets the supplier answer without an account.', + ], + ledger: ['Evidence ledger', 'Accepted records, hashed against the one before them. Nothing is edited in place; corrections append.'], + export: ['Export packages', 'One FDA-shaped sortable spreadsheet per chain, with a hash manifest. Hashes here are stand-ins, not SHA-256.'], +}; + +function subscribeHash(onChange: () => void) { + window.addEventListener('hashchange', onChange); + return () => window.removeEventListener('hashchange', onChange); +} + +const readHash = () => parseView(window.location.hash); +const serverView = (): ViewId => 'overview'; + +export default function DemoWorkspacePage() { + const view = useSyncExternalStore(subscribeHash, readHash, serverView); + const ws = useDemoWorkspace('workspace'); + const { all, scenario, state } = ws; + + const go = useCallback((next: ViewId) => { + try { + window.history.replaceState(null, '', `#${next}`); + } catch { + // Navigation state is cosmetic; the view still changes below. + } + window.dispatchEvent(new Event('hashchange')); + }, []); + + const openRecord = useCallback( + (scenarioIndex: number, rowIndex: number) => { + ws.openRecord(scenarioIndex, rowIndex); + go('import'); + }, + [go, ws], + ); + + const [title, subtitle] = TITLES[view]; + + return ( +
+ + {view === 'overview' && ( + + )} + {view === 'import' && ( + ws.fix()} + /> + )} + {view === 'fix' && } + {view === 'ledger' && } + {view === 'export' && } + +
+ ); +} 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) => (
    • -
    • ))}
-

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) => ( - - - ))} +
-
- -
- -

How RegEngine works

-
    - {workflow.map((step, index) => ( -
  1. -
    -
    -

    {step.label}

    -

    {step.detail}

    -
  2. - ))} -
-
-
+ -
-
-
-

- 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.

-

+
    +
+ -
-

Next steps

-
- - - - - - -
- -

- 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. -

-
-
- -
-
- {destinations.map(({ label, href, icon: Icon, note }) => ( - -
-
- {label} - {note} - - ))} -
-
- -
- -

- RegEngine · Pasadena, CA · early-stage -

-

- FDA has said it will not enforce the Food Traceability Rule before July 20, 2028, following a Congressional directive. Buyer programs are not FDA. No seals, certifications, or FDA logo. -

+
+
- + + + +

+ 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 + +
+ +
+
+
+

{title}

+

{subtitle}

+
+ FTL food + {legend.map((scenario) => ( + + + {shortLabel(scenario)} + + ))} +
+
+
+ + +
+
+ {children} +
+
+
+ ); +} diff --git a/frontend/src/components/demo-workspace/FoodTabs.tsx b/frontend/src/components/demo-workspace/FoodTabs.tsx new file mode 100644 index 000000000..58654d9ee --- /dev/null +++ b/frontend/src/components/demo-workspace/FoodTabs.tsx @@ -0,0 +1,71 @@ +'use client'; + +import { useRef, type KeyboardEvent } from 'react'; +import { shortLabel, type Scenario } from '@/lib/demo-workspace/model'; +import { cn } from '@/lib/utils'; +import { HueDot } from './HueDot'; + +interface FoodTabsProps { + all: readonly Scenario[]; + selected: number; + onPick: (index: number) => void; + size?: 'sm' | 'md'; + className?: string; + /** id of the tabpanel these tabs control; the panel labels itself with tabId(). */ + panelId: string; +} + +export function tabId(panelId: string, scenarioId: string): string { + return `${panelId}-tab-${scenarioId}`; +} + +/** One pill per FTL food. Roving tabindex, arrow keys move the selection. */ +export function FoodTabs({ all, selected, onPick, size = 'md', className, panelId }: FoodTabsProps) { + const refs = useRef>([]); + + const onKeyDown = (event: KeyboardEvent, index: number) => { + let next: number | null = null; + if (event.key === 'ArrowRight') next = (index + 1) % all.length; + if (event.key === 'ArrowLeft') next = (index - 1 + all.length) % all.length; + if (event.key === 'Home') next = 0; + if (event.key === 'End') next = all.length - 1; + if (next === null) return; + event.preventDefault(); + onPick(next); + refs.current[next]?.focus(); + }; + + return ( +
+ {all.map((scenario, index) => { + const active = index === selected; + return ( + + ); + })} +
+ ); +} 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 ( +