// Data Collection — Hi-Fi (Sluicebox Design System) // Same scenario + content as the wireframe, repainted with brand tokens. const { useState, useEffect, useMemo, useRef, useCallback, Fragment } = React; const cx = (...xs) => xs.filter(Boolean).join(" "); // Brand icon map (relative to project root) const ICON = (name) => `assets/sluicebox/icons/${name}.png`; // Frontend↔backend compat. Must match backend's `APP_VERSION` // (backend/app/version.py). Bump in the same commit as any // frontend↔backend incompatibility (new route, payload shape change, // renamed endpoint, new SSE event the frontend depends on). The boot // check below blocks the app from mounting until /version matches. const EXPECTED_VERSION = "app-50"; // Where the API lives when ?api= isn't given. Served by the backend itself // (production single-container mode) → same origin. Served by `make web` on // :5500 (or opened from file://) → the local backend on :8771 (not :8001 — // another Sluicebox worktree commonly squats that port in dev). const _DEFAULT_API_ORIGIN = window.location.port === "5500" || window.location.origin === "null" ? "http://127.0.0.1:8771" : window.location.origin; // Backend-served implies live — no ?live=1 needed in production. ?live=0 // still forces mock data anywhere. const _DEFAULT_LIVE = _DEFAULT_API_ORIGIN === window.location.origin; // ========================================================================= // HTTP Basic auth — single shared credential, gated by the backend. // ========================================================================= // Credentials live in sessionStorage as base64("user:pass"). Rather than // thread an Authorization header through the ~100 fetch() call sites, we // wrap window.fetch once: any request to the API origin gets the header // attached automatically. The AuthGate component (bottom of file) captures // the credentials and renders a login screen on 401. const AUTH_STORAGE_KEY = "tariff_basic_auth"; const _API_ORIGIN = new URLSearchParams(window.location.search).get("api") || _DEFAULT_API_ORIGIN; (function installAuthFetch() { const _origFetch = window.fetch.bind(window); window.fetch = (input, init) => { const url = typeof input === "string" ? input : (input && input.url) || ""; const creds = sessionStorage.getItem(AUTH_STORAGE_KEY); if (creds && url.startsWith(_API_ORIGIN)) { const headers = new Headers((init && init.headers) || {}); if (!headers.has("Authorization")) headers.set("Authorization", `Basic ${creds}`); init = { ...init, headers }; } return _origFetch(input, init); }; })(); // Drop the stored credentials and reload — the AuthGate re-mounts and shows // the login screen since /auth/check now 401s. function authLogout() { sessionStorage.removeItem(AUTH_STORAGE_KEY); window.location.reload(); } // The signed-in email (Basic creds are base64 "email:password"), for display. function authCurrentEmail() { const creds = sessionStorage.getItem(AUTH_STORAGE_KEY); if (!creds) return null; try { return atob(creds).split(":")[0] || null; } catch { return null; } } // Trigger a file download from an authenticated endpoint. Browser-native // navigation (window.location.href = url, , etc.) // does NOT carry the Basic-auth header our fetch-wrapper attaches — // because the wrapper only intercepts `window.fetch` calls, not page // navigations. The result is the browser popping its own Basic-auth // dialog. Workaround: fetch the file ourselves as a blob (goes through // the wrapper, auth attached), then synthesize a click on a temp // anchor to save it. The filename comes from the response's // Content-Disposition header when present, else from a fallback. async function _authedDownload(url, fallbackLabel) { try { const r = await fetch(url); if (!r.ok) { alert(`Download failed: ${r.status} ${r.statusText}`); return; } // Parse `filename="…"` out of Content-Disposition if the server set // it. Falls back to the URL's final path segment. const cd = r.headers.get("Content-Disposition") || ""; const m = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(cd); const filenameFromCd = m ? decodeURIComponent(m[1]) : null; const filenameFromUrl = (() => { try { const p = new URL(url, window.location.href).pathname; const tail = p.split("/").filter(Boolean).pop() || ""; return tail.includes(".") ? tail : null; } catch { return null; } })(); const filename = filenameFromCd || filenameFromUrl || (fallbackLabel || "download"); const blob = await r.blob(); const objUrl = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = objUrl; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); // Defer revoke so the browser has actually kicked off the save. setTimeout(() => URL.revokeObjectURL(objUrl), 1000); } catch (e) { alert(`Download failed: ${e?.message || e}`); } } // ========================================================================= // Live-backend hook — opt in via ?live=1&campaign=[&api=] // ========================================================================= // Reflect campaign / recipient state into the URL so a hard-reload doesn't // drop you back onto an old campaign. Uses replaceState — no history clutter. function _setUrlParam(key, value) { const url = new URL(window.location.href); if (value) url.searchParams.set(key, value); else url.searchParams.delete(key); window.history.replaceState({}, "", url.toString()); } function useLiveBackend() { const params = new URLSearchParams(window.location.search); const liveMode = params.get("live") ? params.get("live") === "1" : _DEFAULT_LIVE; const apiBase = params.get("api") || _DEFAULT_API_ORIGIN; const campaignId = params.get("campaign") || null; const recipientFromUrl = params.get("recipient") || null; const [dashboard, setDashboard] = useState(null); const [selectedRecipientId, _setSelectedRecipientId] = useState(recipientFromUrl); const setSelectedRecipientId = useCallback((id) => { _setSelectedRecipientId(id); _setUrlParam("recipient", id); }, []); const [threadView, setThreadView] = useState(null); const [error, setError] = useState(null); const [templates, setTemplates] = useState(null); const [wizardCampaignId, _setWizardCampaignId] = useState(campaignId); const setWizardCampaignId = useCallback((id) => { _setWizardCampaignId(id); _setUrlParam("campaign", id); // Switching campaigns invalidates any selected recipient if (id !== campaignId) { _setSelectedRecipientId(null); _setUrlParam("recipient", null); } }, [campaignId]); const [wizardCampaignName, setWizardCampaignName] = useState(null); const activeCampaignId = wizardCampaignId || campaignId; const refreshDashboard = useCallback(async () => { if (!liveMode || !activeCampaignId) return; try { const r = await fetch(`${apiBase}/campaigns/${activeCampaignId}/dashboard`); if (!r.ok) throw new Error(`dashboard ${r.status}`); setDashboard(await r.json()); setError(null); } catch (e) { setError(String(e)); } }, [apiBase, activeCampaignId, liveMode]); // Drop cached dashboard / thread when the active campaign changes, so the // UI doesn't briefly show stale data from a previous campaign. useEffect(() => { setDashboard(null); setThreadView(null); }, [activeCampaignId]); const refreshThread = useCallback(async (recId) => { if (!liveMode || !recId) return; try { const r = await fetch(`${apiBase}/recipients/${recId}/thread-view`); if (!r.ok) throw new Error(`thread ${r.status}`); setThreadView(await r.json()); setError(null); } catch (e) { setError(String(e)); } }, [apiBase, liveMode]); const fetchTemplates = useCallback(async () => { if (!liveMode) return; try { const r = await fetch(`${apiBase}/templates`); if (!r.ok) throw new Error(`templates ${r.status}`); setTemplates(await r.json()); } catch (e) { setError(String(e)); } }, [apiBase, liveMode]); const createFromTemplate = useCallback(async (template_id, name) => { const r = await fetch(`${apiBase}/campaigns/from-template`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ template_id, name }), }); if (!r.ok) throw new Error(`create ${r.status}`); const data = await r.json(); setWizardCampaignId(data.id); setWizardCampaignName(data.name); return data; }, [apiBase]); const uploadRecipientsCsv = useCallback(async (file) => { if (!wizardCampaignId) throw new Error("no campaign yet — pick a template first"); const fd = new FormData(); fd.append("file", file); const r = await fetch(`${apiBase}/recipients/csv?campaign_id=${wizardCampaignId}`, { method: "POST", body: fd, }); if (!r.ok) throw new Error(`csv ${r.status}: ${await r.text()}`); return await r.json(); }, [apiBase, wizardCampaignId]); const fetchCampaign = useCallback(async (id) => { const r = await fetch(`${apiBase}/campaigns/${id}`); if (!r.ok) throw new Error(`campaign ${r.status}`); return await r.json(); }, [apiBase]); const patchCampaign = useCallback(async (id, payload) => { const r = await fetch(`${apiBase}/campaigns/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); if (!r.ok) throw new Error(`patch ${r.status}`); return await r.json(); }, [apiBase]); const fetchFields = useCallback(async (id) => { const r = await fetch(`${apiBase}/campaigns/${id}/fields`); if (!r.ok) throw new Error(`fields ${r.status}`); return await r.json(); }, [apiBase]); const addField = useCallback(async (id, body) => { const r = await fetch(`${apiBase}/campaigns/${id}/fields`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!r.ok) throw new Error(`add ${r.status}: ${await r.text()}`); return await r.json(); }, [apiBase]); const patchField = useCallback(async (cid, fid, body) => { const r = await fetch(`${apiBase}/campaigns/${cid}/fields/${fid}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!r.ok) throw new Error(`patch field ${r.status}`); return await r.json(); }, [apiBase]); const deleteField = useCallback(async (cid, fid) => { const r = await fetch(`${apiBase}/campaigns/${cid}/fields/${fid}`, { method: "DELETE" }); if (!r.ok) throw new Error(`delete ${r.status}`); return await r.json(); }, [apiBase]); const addRecipient = useCallback(async (campaignId, body) => { const r = await fetch(`${apiBase}/recipients`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ campaign_id: campaignId, ...body }), }); if (!r.ok) throw new Error(`add recipient ${r.status}: ${await r.text()}`); return await r.json(); }, [apiBase]); const deleteRecipient = useCallback(async (rid) => { const r = await fetch(`${apiBase}/recipients/${rid}`, { method: "DELETE" }); if (!r.ok) throw new Error(`delete recipient ${r.status}`); return await r.json(); }, [apiBase]); const setEmailOverride = useCallback(async (id, body) => { const r = await fetch(`${apiBase}/campaigns/${id}/email-override`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!r.ok) throw new Error(`set override ${r.status}: ${await r.text()}`); return await r.json(); }, [apiBase]); const clearEmailOverride = useCallback(async (id) => { const r = await fetch(`${apiBase}/campaigns/${id}/email-override`, { method: "DELETE" }); if (!r.ok) throw new Error(`clear override ${r.status}`); return await r.json(); }, [apiBase]); const fetchContacts = useCallback(async (q = "") => { const url = q ? `${apiBase}/recipients/contacts?q=${encodeURIComponent(q)}` : `${apiBase}/recipients/contacts`; const r = await fetch(url); if (!r.ok) throw new Error(`contacts ${r.status}`); return await r.json(); }, [apiBase]); const previewEmail = useCallback(async (id) => { const r = await fetch(`${apiBase}/campaigns/${id}/preview-email`); if (!r.ok) throw new Error(`preview ${r.status}`); return await r.json(); }, [apiBase]); // Multi-recipient preview — one rendered first-touch per recipient, // each scoped to that supplier's auto-assigned parts. Triggers the // LLM-driven parts-to-recipient assignment lazily on first call. const previewEmails = useCallback(async (id) => { const r = await fetch(`${apiBase}/campaigns/${id}/preview-emails`); if (!r.ok) throw new Error(`preview-emails ${r.status}`); return await r.json(); }, [apiBase]); const launchCampaign = useCallback(async (id) => { const r = await fetch(`${apiBase}/campaigns/${id}/launch`, { method: "POST" }); if (!r.ok) throw new Error(`launch ${r.status}: ${await r.text()}`); return await r.json(); }, [apiBase]); const seedFromDocument = useCallback(async (id, file) => { const fd = new FormData(); fd.append("file", file); const r = await fetch(`${apiBase}/campaigns/${id}/seed-from-document`, { method: "POST", body: fd, }); if (!r.ok) throw new Error(`seed ${r.status}: ${await r.text()}`); return await r.json(); }, [apiBase]); const fetchAudit = useCallback(async (id) => { const r = await fetch(`${apiBase}/campaigns/${id}/audit`); if (!r.ok) throw new Error(`audit ${r.status}`); return await r.json(); }, [apiBase]); const fetchTemplateMapping = useCallback(async (id) => { const r = await fetch(`${apiBase}/campaigns/${id}/customer-template/mapping`); if (!r.ok) throw new Error(`mapping ${r.status}`); return await r.json(); }, [apiBase]); const uploadCustomerTemplate = useCallback(async (id, file) => { const fd = new FormData(); fd.append("file", file); const r = await fetch(`${apiBase}/campaigns/${id}/customer-template`, { method: "POST", body: fd, }); if (!r.ok) throw new Error(`upload ${r.status}: ${await r.text()}`); return await r.json(); }, [apiBase]); useEffect(() => { refreshDashboard(); }, [refreshDashboard]); useEffect(() => { fetchTemplates(); }, [fetchTemplates]); useEffect(() => { if (selectedRecipientId) refreshThread(selectedRecipientId); }, [selectedRecipientId, refreshThread]); return { liveMode, apiBase, campaignId, dashboard, refreshDashboard, selectedRecipientId, setSelectedRecipientId, threadView, refreshThread, templates, fetchTemplates, wizardCampaignId, setWizardCampaignId, wizardCampaignName, createFromTemplate, uploadRecipientsCsv, fetchCampaign, patchCampaign, fetchFields, addField, patchField, deleteField, addRecipient, deleteRecipient, fetchContacts, previewEmail, previewEmails, setEmailOverride, clearEmailOverride, launchCampaign, seedFromDocument, fetchAudit, fetchTemplateMapping, uploadCustomerTemplate, activeCampaignId, error, }; } // ========================================================================= // Sidebar // ========================================================================= function Sidebar({ screen, setScreen, collapsed, setCollapsed, inboxCount, branding, apiBase, onBrandingChanged }) { // Brand editor popover. Click the account chip to toggle. Closes on // outside-click + ESC. Form values seed from current `branding` each // time the editor opens so re-opening after Save shows fresh values. // // Positioning: the popover uses `position: fixed` because the // sidebar's `overflow-y: auto` clips anything that extends above the // chip's row. Coordinates are derived from the chip's bounding rect // each time it opens. const [brandEdit, setBrandEdit] = useState(false); const [brandAnchor, setBrandAnchor] = useState(null); // { bottom, left, width } in viewport coords const [bForm, setBForm] = useState({ name: "", name_long: "", color: "#CC0000", initials_override: "" }); const [bSaving, setBSaving] = useState(false); const [bErr, setBErr] = useState(""); const brandChipRef = useRef(null); const brandPopRef = useRef(null); useEffect(() => { if (!brandEdit) return; function onDoc(e) { const inPop = brandPopRef.current && brandPopRef.current.contains(e.target); const inChip = brandChipRef.current && brandChipRef.current.contains(e.target); if (!inPop && !inChip) setBrandEdit(false); } function onKey(e) { if (e.key === "Escape") setBrandEdit(false); } document.addEventListener("mousedown", onDoc); document.addEventListener("keydown", onKey); return () => { document.removeEventListener("mousedown", onDoc); document.removeEventListener("keydown", onKey); }; }, [brandEdit]); function openBrandEditor(e) { e.preventDefault(); e.stopPropagation(); setBForm({ name: branding?.name || "", name_long: branding?.name_long || "", color: branding?.color || "#CC0000", initials_override: "", // never seed with the derived initials — leave blank so user only types when they want to override }); setBErr(""); // Measure chip position so the popover (position: fixed) lands // right above it, regardless of the sidebar's overflow clipping. if (brandChipRef.current) { const r = brandChipRef.current.getBoundingClientRect(); setBrandAnchor({ top: r.top, left: r.left, width: r.width }); } setBrandEdit(true); } async function saveBranding() { if (bSaving) return; const name = (bForm.name || "").trim(); const nameLong = (bForm.name_long || "").trim(); const color = (bForm.color || "").trim(); if (!name || !nameLong) { setBErr("Name and long name are required."); return; } setBSaving(true); setBErr(""); try { const r = await fetch(`${apiBase}/branding`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, name_long: nameLong, color, initials_override: (bForm.initials_override || "").trim(), }), }); if (!r.ok) { const t = await r.text(); setBErr(t || `Save failed (${r.status})`); return; } if (onBrandingChanged) await onBrandingChanged(); setBrandEdit(false); } catch (e) { setBErr(String(e?.message || e)); } finally { setBSaving(false); } } // One workspace entry holds Collections / Inbox / Sent as tabs. The // entry stays "active" for any of those three screen ids — they all // resolve to the same workspace UI under the hood. const _isWorkspace = (s) => s === "list" || s === "inbox" || s === "sent" || s === "contacts"; const setupItems = [ ["list", inboxCount ? `Data collections (${inboxCount})` : "Data collections", null], ]; const runtimeItems = []; const refItems = []; const idx = setupItems.findIndex(([id]) => id === screen); return ( ); } // ========================================================================= // Wizard stepper + foot // ========================================================================= const STEPS = [ ["templates", "Template"], ["brief", "Brief"], ["schema", "Schema"], ["recipients", "Recipients"], ["review", "Review"], ]; function Stepper({ screen, setScreen }) { const idx = STEPS.findIndex(([id]) => id === screen); if (idx < 0) return null; return (
{STEPS.map(([id, label], i) => { const state = i < idx ? "done" : i === idx ? "active" : ""; return ( ); })}
); } function WizardFoot({ setScreen, prev, next, nextLabel = "Next" }) { return (
{prev ? ( ) : } S save draft
{next && ( )}
); } // ========================================================================= // Overview // ========================================================================= function Overview({ setScreen }) { return ( <>
Hi-fi prototype · v0.2

Configure once, the agent does the rest.

A walkthrough of the proposed Data Collection framework, anchored on the DigiKey Section 232 tariff-origin scenario. Click around the sidebar — the 5-step setup is the primary path.

Schema, Brief, Recipients → the agent does the rest.

Today every new question type (rare-earth, tariff, PCN) is custom pipeline work. The framework flips that: a CSM configures four primitives, the runtime adapts.

Campaign Context Brief Data Schema Recipients
Today's scenario

DigiKey · Section 232 origin trace

New weight-based rules from the March 2026 EO. We need smelt / cast / diffusion / assembly country per SKU across 247 suppliers. Mill certs are gold-standard.


247 suppliers 14-day window 5 data points
setScreen("templates")}>
Path A · Setup

5–10 min wizard

Five linear steps. Sensible defaults; ship in 5, tune in 10.

Start setup →
setScreen("thread")}>
Path B · Runtime

Watch the agent work

A live supplier thread — outbound, inbound parsing, follow-up loop.

Open thread →
setScreen("audit")}>
Path C · Output

Audit & export

Every value clickable to source. The trust artifact.

See audit view →
); } // ========================================================================= // Pick template // ========================================================================= const TEMPLATES = [ { id: "blank", name: "Blank", desc: "Start from zero. Add data points one at a time.", fields: 0, mins: 12, tag: "" }, { id: "carbon", name: "Carbon (current scope)", desc: "Existing 8 data points. Backwards-compatible parity with today's agent.", fields: 8, mins: 4, tag: "Parity" }, { id: "carbon-x", name: "Carbon-extended", desc: "+ cumulative energy demand, non-fossil resource use, EOL splits per country.", fields: 14, mins: 6, tag: "Tom · Apr 22" }, { id: "rare-earth", name: "Rare-Earth Provenance", desc: "Multi-tier supplier traversal. Tier-2/3 prompts baked in.", fields: 11, mins: 7, tag: "Western Digital" }, { id: "tariff", name: "Tariff Origin (Section 232)", desc: "Country of smelt / cast / diffusion / assembly. Weight-based EO logic.", fields: 5, mins: 5, tag: "DigiKey · today's pick" }, { id: "pcn", name: "PCN", desc: "Product Change Notification. Supplier-initiated structured flow.", fields: 9, mins: 8, tag: "v1.1" }, ]; function ScreenTemplates({ setScreen, tw }) { const [picked, setPicked] = useState("tariff"); return (
Step 1 of 5

Pick a starter template

Templates are forkable schemas — not hardcoded pipelines. Anything you change later overrides the template; nothing is locked.

{TEMPLATES.map((t) => (
setPicked(t.id)} > {picked === t.id && ✓ selected}
{t.tag || "—"}

{t.name}

{t.desc}

{t.fields} data points
))}
); } // ========================================================================= // Brief // ========================================================================= function ScreenBrief({ setScreen, tw }) { const [name, setName] = useState("DigiKey Section 232 Origin Trace — Q2 2026"); const [brief, setBrief] = useState( `Per the March 2026 executive order, Section 232 is now weight-based: aggregate Cu/steel/Al content under 15% of total weight is exempt; over 15% or unknown defaults to a 200% tariff. We need smelt/cast country, country of diffusion, and country of assembly per SKU. Suppliers may not know the new rules — explain briefly if they push back. Mill certificates are the gold-standard source.` ); const [clarifications, setClarifications] = useState([ "Smelter country must be the actual smelter, not the foundry or mill.", "If multiple diffusion sites, ask for the primary one.", "If Cu content is given as a range, take the midpoint.", "If a supplier says 'unknown', re-ask once with extra context.", ]); const [draft, setDraft] = useState(""); return (
Step 2 of 5

Name + Context Brief

2–4 sentences. Plain English: what, why, what you'll do with it. The agent ingests this as the topic frame for every supplier conversation.

The hard part
Campaign name
setName(e.target.value)} />
Context Brief