feat: add reason strings per patient, fix export headers, add signal-ui source

- Add _build_reason() to backend — per-patient reason strings with specific
  day counts (e.g. "Supply lapsed 70 days ago. Prescriber contact required.")
- Add reason field to RecordOut model and backend /api/export CSV
- Fix export column headers: Coverage End Date → Resupply End Date,
  Days Until Coverage End → Days Until Resupply End
- Pass reason through apiRecordToLocal in frontend api.js
- Display reason as muted sub-line under status badge in WorklistTable
- Add reason column to client-side CSVExport
- Add signal-ui React source to repo (was untracked)
- CLAUDE.md: add Billing and CMS integrations to Phase 2 deferred table
- research: restore Section 14 stat verification (May 23 recovery)

Deployed to Railway production — health check confirmed live.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Kisa 2026-05-26 09:45:02 -04:00
parent 0af32ec983
commit a424ac9d13
27 changed files with 3105 additions and 2 deletions

View file

@ -58,6 +58,8 @@ Two-curve graph showing supplier staff time over months:
| Item | Phase | Reason deferred | | Item | Phase | Reason deferred |
|------|-------|----------------| |------|-------|----------------|
| Dexcom OAuth API integration | Phase 2 | Requires vendor agreement + PHI scope expansion | | Dexcom OAuth API integration | Phase 2 | Requires vendor agreement + PHI scope expansion |
| Billing system API integration (Brightree, Bonafide, Fastrack, etc.) | Phase 2 | Build after pilot reveals which systems suppliers use; vendor agreements required; PHI scope expands significantly |
| CMS and contact management integration (Salesforce, etc.) | Phase 2 | Pilot feedback will reveal if suppliers have CMS and what they use; do not assume |
| Prescriber fax automation | Phase 2 | Workflow complexity; Level 1 manual outreach sufficient | | Prescriber fax automation | Phase 2 | Workflow complexity; Level 1 manual outreach sufficient |
| Patient-facing SMS (Twilio, not SignalWire) | Phase 3 | PHI transmission to third-party; requires BAA + consent layer | | Patient-facing SMS (Twilio, not SignalWire) | Phase 3 | PHI transmission to third-party; requires BAA + consent layer |
| Consortium strategy (Level 2/3) | 1824 months | Depends on 15+ paying Level 1 suppliers first | | Consortium strategy (Level 2/3) | 1824 months | Depends on 15+ paying Level 1 suppliers first |

View file

@ -67,6 +67,7 @@ class RecordOut(BaseModel):
next_visit_due_date: Optional[str] = None next_visit_due_date: Optional[str] = None
action: str action: str
status_label: str status_label: str
reason: str
class UploadResponse(BaseModel): class UploadResponse(BaseModel):
@ -77,6 +78,27 @@ class UploadResponse(BaseModel):
stats: dict stats: dict
def _build_reason(flag_val: str, days_until_end: int, days_until_visit: Optional[int]) -> str:
if flag_val == "OUT_OF_COVERAGE":
ago = abs(days_until_end)
unit = "day" if ago == 1 else "days"
return f"Supply lapsed {ago} {unit} ago. Prescriber contact required before next shipment."
if flag_val == "VISIT_DUE":
if days_until_visit is not None and days_until_visit <= 0:
overdue = abs(days_until_visit)
unit = "day" if overdue == 1 else "days"
return f"Qualifying visit overdue by {overdue} {unit}. Confirm documentation immediately."
if days_until_visit is not None:
unit = "day" if days_until_visit == 1 else "days"
return f"Qualifying visit due in {days_until_visit} {unit}. Confirm visit documentation before resupply."
return "Qualifying visit renewal required. Confirm documentation before resupply."
if flag_val == "REFILL_WINDOW":
unit = "day" if days_until_end == 1 else "days"
return f"Coverage ends in {days_until_end} {unit}. Patient is within resupply window — initiate shipment now."
unit = "day" if days_until_end == 1 else "days"
return f"Coverage on track. Resupply window opens in approximately {days_until_end} {unit}."
def _to_record_out(r) -> RecordOut: def _to_record_out(r) -> RecordOut:
flag_val = r.flag.value if hasattr(r.flag, "value") else str(r.flag) flag_val = r.flag.value if hasattr(r.flag, "value") else str(r.flag)
return RecordOut( return RecordOut(
@ -93,6 +115,7 @@ def _to_record_out(r) -> RecordOut:
next_visit_due_date=r.next_visit_due_date.isoformat() if r.next_visit_due_date else None, next_visit_due_date=r.next_visit_due_date.isoformat() if r.next_visit_due_date else None,
action=FLAG_ACTIONS.get(flag_val, "Review"), action=FLAG_ACTIONS.get(flag_val, "Review"),
status_label=FLAG_LABELS.get(flag_val, flag_val), status_label=FLAG_LABELS.get(flag_val, flag_val),
reason=_build_reason(flag_val, r.days_until_coverage_end, r.days_until_visit_due),
) )
@ -158,10 +181,11 @@ async def export_work_queue(records: list[RecordOut]):
"Payer", "Payer",
"Status", "Status",
"Priority Score", "Priority Score",
"Days Until Coverage End", "Days Until Resupply End",
"Next Visit Due", "Next Visit Due",
"Recommended Action", "Recommended Action",
"Coverage End Date", "Resupply End Date",
"Reason",
]) ])
for r in records: for r in records:
writer.writerow([ writer.writerow([
@ -174,6 +198,7 @@ async def export_work_queue(records: list[RecordOut]):
r.next_visit_due_date or "", r.next_visit_due_date or "",
r.action, r.action,
r.coverage_end_date, r.coverage_end_date,
r.reason,
]) ])
output.seek(0) output.seek(0)

View file

@ -901,6 +901,42 @@ SIGNAL CGM VALUE STACK
--- ---
## 14. Verified Stat Index — May 2026
*Stat verification completed May 23, 2026. Use this table before citing any Signal-related statistic. The "usable" column reflects what is citable with a direct source URL.*
### What Is Verified With Direct Citations
| Stat | Source | What It Measures | Usable for Signal |
|------|--------|-----------------|-------------------|
| 32.8% error rate on glucose monitor claims | CERT 2019 annual report | Random sample of PAID claims reviewed post-payment | Yes — locked stat. "Nearly 1 in 3." |
| 68.6% of those errors from insufficient documentation | CERT 2019 annual report | Share of error-rate claims with doc problems | Yes — locked. "Over two-thirds from docs." |
| 25.2% CGM improper payment rate | CMS 2024 MLN compliance page (direct URL) | Claims paid that had documentation problems — post-payment audit exposure | Yes — audit exposure narrative |
| 67.6% absent documentation | CMS 2024 MLN compliance page (direct URL) | Share of those improper payments with no docs at all | Yes — use for whitepaper/gate framing |
| **30.86% pre-pay review error rate** | **CGS MAC Jurisdiction B Q2 2024** | **Claims reviewed before or at payment — near submission** | **Best source for denial prevention pitch** |
| 18.52% TPE error rate | MAC B/C 2025 | Claims reviewed at audit | Strong supporting evidence |
| $1.9B DMEPOS improper payments FY2024 | OIG (URL exists) | All DMEPOS categories, not CGM specifically | Market context only |
| DMEPOS 22.5% vs 7.38% overall Medicare | Post-payment comparison | DMEPOS vs. all Medicare improper payment rate | Market context |
| 63.9% MA appeal success at Level 2 | KFF 2024 (direct URL) | Medicare Advantage only — not FFS | MA context only, qualify if used |
### What Is Derived or Not Directly Citable
| Stat | Problem | What to Use Instead |
|------|---------|-------------------|
| "94.2% of CGM denials are documentation failures" | Derived sum of two CMS MLN line items — not stated directly by CMS | CERT 2019: "over two-thirds from docs" |
| "3545% of CGM claims denied" | Scenario-based, not universal | "First-pass denial rates vary significantly by supplier documentation maturity" |
| "63% of denied CGM claims are written off permanently" | Derived model, not citable | Do not use as a standalone stat |
### Why CGS MAC Pre-Pay Wins for Signal's Pitch
The CGS Jurisdiction B pre-pay review measures claims being stopped and reviewed near submission — not years later in a post-payment audit. Every top-10 denial reason in the CGS data is a documentation failure. This is the only publicly available source that measures documentation risk at the point Signal addresses it: before supplies ship and before the claim is filed.
The CERT and CMS MLN stats measure what happened after the fact. The CGS pre-pay stat measures the same problem Signal solves, at the same point in the workflow Signal operates.
**Use CGS MAC pre-pay for denial prevention framing. Use CERT 2019 for LinkedIn and public-facing stats.**
---
## Sources ## Sources
Research compiled April 2026 from: Research compiled April 2026 from:

2
signal-ui/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
node_modules/
dist/

View file

@ -0,0 +1,21 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
parserOptions: { ecmaFeatures: { jsx: true } },
},
},
])

19
signal-ui/index.html Normal file
View file

@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Signal CGM — Outreach Worklist</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@600;700&family=Inter:wght@400;500&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
<style>
/* Prevent flash of light mode before ThemeProvider hydrates */
html.dark { background: #041A1A; }
</style>
</head>
<body class="antialiased">
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

29
signal-ui/package.json Normal file
View file

@ -0,0 +1,29 @@
{
"name": "signal-ui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.6",
"react-dom": "^19.2.6"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tailwindcss/vite": "^4.3.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"tailwindcss": "^4.3.0",
"vite": "^8.0.12"
}
}

1707
signal-ui/pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load diff

137
signal-ui/src/App.jsx Normal file
View file

@ -0,0 +1,137 @@
import { useState, useCallback, useRef } from "react";
import { ThemeProvider } from "./ThemeProvider";
import Sidebar from "./components/Sidebar";
import StatCard from "./components/StatCard";
import WorklistTable from "./components/WorklistTable";
import CSVImport from "./components/CSVImport";
import CSVExport from "./components/CSVExport";
import ThemeToggle from "./components/ThemeToggle";
import Toast from "./components/Toast";
import { showToast } from "./lib/toast";
import { uploadToBackend, apiRecordToLocal } from "./lib/api";
import { parseCSV, processBatch } from "./lib/coverage";
function AppInner() {
const [records, setRecords] = useState([]);
const [activeFilter, setActiveFilter] = useState("all");
const [importLabel, setImportLabel] = useState("No data imported");
const csvImportRef = useRef(null);
const ooc = records.filter((r) => r.flag === "OUT_OF_COVERAGE").length;
const visitDue = records.filter((r) => r.flag === "VISIT_DUE").length;
const refill = records.filter((r) => r.flag === "REFILL_WINDOW").length;
const okCount = records.filter((r) => r.flag === "OK").length;
const urgent = ooc + visitDue;
const handleResults = useCallback((file) => {
const label = new Date().toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
uploadToBackend(file).then((data) => {
if (data) {
const results = data.records.map(apiRecordToLocal);
setRecords(results);
setImportLabel(`${file.name} · ${label} · via Signal API`);
let msg = `Loaded ${data.total} patient${data.total !== 1 ? "s" : ""} from ${file.name}`;
if (data.skipped) msg += ` · ${data.skipped} skipped`;
showToast(msg);
return;
}
// Backend unreachable process locally
const reader = new FileReader();
reader.onload = (e) => {
const rows = parseCSV(e.target.result);
const { results, skipped } = processBatch(rows);
setRecords(results);
setImportLabel(`${file.name} · ${label} · local processing`);
let msg = `Loaded ${results.length} patient${results.length !== 1 ? "s" : ""} from ${file.name}`;
if (skipped.length) msg += ` · ${skipped.length} skipped`;
showToast(msg);
};
reader.readAsText(file);
});
}, []);
return (
<div className="flex w-full min-h-screen bg-[var(--bg-page)] text-[var(--text-primary)] transition-colors">
{/* Sidebar */}
<Sidebar
oocCount={ooc}
visitDueCount={visitDue}
refillCount={refill}
activeFilter={activeFilter}
onFilterChange={setActiveFilter}
onImportClick={() => csvImportRef.current?.trigger()}
/>
{/* Hidden CSV import trigger */}
<CSVImport
ref={csvImportRef}
onResults={handleResults}
/>
{/* Main */}
<main className="ml-[240px] flex-1 flex flex-col">
{/* Topbar */}
<header className="bg-[var(--bg-card)] border-b border-[var(--border-color)] px-7 h-[60px] flex items-center justify-between sticky top-0 z-10 transition-colors">
<div className="flex items-center gap-4">
<h1 className="font-heading font-bold text-lg text-[var(--text-heading)]">
Outreach Worklist
</h1>
<span className="font-mono text-[11px] text-[var(--text-muted)] px-2 py-[3px] bg-[var(--bg-elevated)] rounded">
{importLabel}
</span>
</div>
<div className="flex items-center gap-[10px]">
<ThemeToggle />
<CSVExport records={records} />
</div>
</header>
{/* Content */}
<div className="p-7">
{/* Stats row */}
<div className="grid grid-cols-3 gap-4 mb-6">
<StatCard
priority
label="⚡ Prescriber Action"
value={urgent}
sub={`${ooc} out of coverage · ${visitDue} visit${visitDue !== 1 ? "s" : ""} due — prescriber contact required`}
/>
<StatCard
label="⚠ Resupply Ready"
value={refill}
sub="patients within resupply window — initiate now"
/>
<StatCard
label="✓ Active"
value={okCount}
sub="patients with supply on track — no action needed"
/>
</div>
{/* Worklist */}
<WorklistTable
records={records}
activeFilter={activeFilter}
onFilterChange={setActiveFilter}
/>
</div>
</main>
<Toast />
</div>
);
}
export default function App() {
return (
<ThemeProvider>
<AppInner />
</ThemeProvider>
);
}

View file

@ -0,0 +1,3 @@
import { createContext } from "react";
export const ThemeContext = createContext(null);

View file

@ -0,0 +1,27 @@
import { useState, useEffect, useCallback } from "react";
import { ThemeContext } from "./ThemeContext";
export function ThemeProvider({ children }) {
const [dark, setDark] = useState(() => {
const stored = localStorage.getItem("signal-theme");
if (stored) return stored === "dark";
return true;
});
useEffect(() => {
if (dark) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
localStorage.setItem("signal-theme", dark ? "dark" : "light");
}, [dark]);
const toggle = useCallback(() => setDark((prev) => !prev), []);
return (
<ThemeContext value={{ dark, toggle }}>
{children}
</ThemeContext>
);
}

View file

@ -0,0 +1,48 @@
import { getFlagLabel, getFlagAction } from "../lib/coverage";
function Badge({ flag, dark }) {
const style = badgeStyles(flag, dark);
return (
<span
className={`inline-flex items-center gap-[5px] px-[9px] py-[3px] rounded-full text-[11.5px] font-medium whitespace-nowrap ${style}`}
title={getFlagAction(flag)}
>
<span>{style.icon}</span>
{getFlagLabel(flag)}
</span>
);
}
function badgeStyles(flag, dark) {
if (dark) return darkBadge(flag);
return lightBadge(flag);
}
function darkBadge(flag) {
switch (flag) {
case "OUT_OF_COVERAGE":
return { icon: "✕", className: "bg-[rgba(200,48,48,0.22)] text-[#FF7070]" };
case "VISIT_DUE":
return { icon: "⚡", className: "bg-[rgba(240,120,64,0.18)] text-[#FFB070] border border-[rgba(240,120,64,0.3)]" };
case "REFILL_WINDOW":
return { icon: "⚠", className: "bg-[rgba(168,90,24,0.16)] text-[#F0B464]" };
default:
return { icon: "✓", className: "bg-[rgba(26,122,78,0.16)] text-[#4AE899]" };
}
}
function lightBadge(flag) {
switch (flag) {
case "OUT_OF_COVERAGE":
return { icon: "✕", className: "bg-[#F8CCCC] text-[#A02020]" };
case "VISIT_DUE":
return { icon: "⚡", className: "bg-[#FFE4CC] text-[#903A14] border border-[rgba(144,58,20,0.2)]" };
case "REFILL_WINDOW":
return { icon: "⚠", className: "bg-[#FDECD5] text-[#A85A18]" };
default:
return { icon: "✓", className: "bg-[#C8EDD8] text-[#146040]" };
}
}
export default Badge;

View file

@ -0,0 +1,59 @@
import { getFlagLabel, getFlagAction, getDeviceDisplay } from "../lib/coverage";
export default function CSVExport({ records }) {
const today = new Date().toISOString().slice(0, 10);
const exportCSV = () => {
if (!records || records.length === 0) return;
const headers = [
"Patient ID",
"Device",
"Payer",
"Status",
"Priority Score",
"Days Until Resupply End",
"Recommended Action",
"Resupply End Date",
"Reason",
];
const rows = records.map((r) => [
r.patient_id,
getDeviceDisplay(r.device_type),
r.payer,
getFlagLabel(r.flag),
r.priority,
r.daysUntilEnd,
getFlagAction(r.flag),
r.coverageEndDate || "",
r.reason || "",
]);
const csv = [headers, ...rows]
.map((row) =>
row.map((v) => `"${String(v).replace(/"/g, '""')}"`).join(",")
)
.join("\r\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `signal-work-queue-${today}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
return (
<button
onClick={exportCSV}
className="bg-[var(--brand)] text-warm-white px-4 py-[7px] rounded-md text-[13px] font-medium cursor-pointer transition-colors hover:bg-[var(--brand-hover)] font-body"
disabled={!records || records.length === 0}
>
Export Work Queue
</button>
);
}

View file

@ -0,0 +1,28 @@
import { useRef, forwardRef, useImperativeHandle } from "react";
const CSVImport = forwardRef(function CSVImport({ onResults }, ref) {
const inputRef = useRef(null);
useImperativeHandle(ref, () => ({
trigger: () => inputRef.current?.click(),
}));
const handleFile = (e) => {
const file = e.target.files?.[0];
if (!file) return;
e.target.value = "";
onResults(file);
};
return (
<input
ref={inputRef}
type="file"
accept=".csv"
onChange={handleFile}
className="hidden"
/>
);
});
export default CSVImport;

View file

@ -0,0 +1,132 @@
export default function Sidebar({
oocCount,
visitDueCount,
refillCount,
activeFilter,
onFilterChange,
onImportClick,
}) {
const navItems = [
{
label: "All Patients",
key: "all",
icon: "≡",
badge: null,
},
{
label: "Resupply Ready",
key: "REFILL_WINDOW",
icon: "⚠",
badge: refillCount,
badgeWarn: true,
},
{
label: "Renewal Due",
key: "VISIT_DUE",
icon: "⚡",
badge: visitDueCount,
},
{
label: "Supply Lapsed",
key: "OUT_OF_COVERAGE",
icon: "✕",
badge: oocCount,
},
];
return (
<aside className="w-[240px] bg-teal-900 border-r border-teal-700 flex flex-col shrink-0 fixed top-0 left-0 bottom-0 z-20">
{/* Header */}
<div className="px-5 pt-[22px] pb-[18px] border-b border-[rgba(15,94,94,0.5)]">
<div className="flex items-center gap-[10px] mb-1">
<div className="w-[33px] h-[33px] bg-gradient-to-br from-[#1A8A8A] to-[#147A7A] rounded-lg flex items-center justify-center font-heading font-bold text-[16px] text-warm-white shrink-0 shadow-[0_2px_8px_rgba(20,122,122,0.35)]">
S
</div>
<span className="font-heading font-bold text-[17px] text-warm-white tracking-[-0.01em]">
Signal
</span>
</div>
<div className="text-[11px] text-teal-300 ml-[43px] tracking-[0.01em]">
by STTIL Solutions
</div>
</div>
{/* Nav */}
<nav className="py-[10px] flex-1 overflow-y-auto">
<div className="text-[10px] font-medium tracking-[0.09em] uppercase text-[#426060] px-5 pt-[10px] pb-1">
Worklist
</div>
{navItems.map((item) => (
<a
key={item.key}
href="#"
onClick={(e) => {
e.preventDefault();
onFilterChange(item.key);
}}
className={`flex items-center gap-[10px] px-5 py-[9px] text-[13.5px] no-underline transition-all cursor-pointer border-l-[3px] ${
activeFilter === item.key
? "text-warm-white font-medium border-l-[var(--accent)] bg-[rgba(10,68,68,0.55)]"
: "text-[#7A9E9E] border-l-transparent hover:text-teal-400 hover:bg-[rgba(46,163,163,0.07)]"
}`}
>
<span className="w-[18px] text-center text-[14px] shrink-0">
{item.icon}
</span>
{item.label}
{item.badge != null && item.badge > 0 && (
<span
className={`ml-auto text-[10px] font-semibold px-[7px] py-[1px] rounded-[20px] font-mono ${
item.badgeWarn
? "bg-[rgba(217,123,53,0.22)] text-[#E49655]"
: "bg-[rgba(200,48,48,0.22)] text-[#FF8080]"
}`}
>
{item.badge}
</span>
)}
</a>
))}
<div className="text-[10px] font-medium tracking-[0.09em] uppercase text-[#426060] px-5 pt-4 pb-1">
System
</div>
<a
href="#"
onClick={(e) => {
e.preventDefault();
onImportClick();
}}
className="flex items-center gap-[10px] px-5 py-[9px] text-[13.5px] text-[#7A9E9E] no-underline transition-all cursor-pointer border-l-[3px] border-l-transparent hover:text-teal-400 hover:bg-[rgba(46,163,163,0.07)]"
>
<span className="w-[18px] text-center text-[14px] shrink-0"></span>
Import CSV
</a>
<a
href="#"
onClick={(e) => e.preventDefault()}
className="flex items-center gap-[10px] px-5 py-[9px] text-[13.5px] text-[#7A9E9E] no-underline transition-all cursor-pointer border-l-[3px] border-l-transparent hover:text-teal-400 hover:bg-[rgba(46,163,163,0.07)]"
>
<span className="w-[18px] text-center text-[14px] shrink-0"></span>
Settings
</a>
</nav>
{/* Footer */}
<div className="px-5 py-[14px] border-t border-[rgba(15,94,94,0.5)]">
<div className="flex items-center gap-[10px]">
<div className="w-[30px] h-[30px] bg-[rgba(46,163,163,0.18)] border border-[rgba(46,163,163,0.3)] rounded-full flex items-center justify-center text-[11px] text-teal-400 font-semibold shrink-0">
DS
</div>
<div>
<div className="text-xs font-medium text-warm-white">
Demo Supplier
</div>
<div className="text-[10px] text-teal-300 mt-[1px]">
Billing Staff
</div>
</div>
</div>
</div>
</aside>
);
}

View file

@ -0,0 +1,32 @@
export default function StatCard({
label,
value,
sub,
priority = false,
}) {
return (
<div
className={`rounded-[10px] p-5 border transition-all duration-200 ${
priority
? "border-[var(--accent)] shadow-[var(--accent-glow)] bg-[var(--bg-card)]"
: "border-[var(--border-color)] shadow-[var(--shadow-card-md)] bg-[var(--bg-card)]"
}`}
>
<div className="text-[11px] font-medium tracking-[0.06em] uppercase text-[var(--text-muted)] mb-[10px]">
{label}
</div>
<div
className={`font-mono text-[34px] font-medium tracking-[-0.02em] leading-none mb-[2px] ${
priority ? "text-[var(--accent-text)]" : "text-[var(--text-heading)]"
}`}
>
{value}
</div>
{sub && (
<div className="text-xs text-[var(--text-secondary)] mt-[6px]">
{sub}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,14 @@
import { useTheme } from "../useTheme";
export default function ThemeToggle() {
const { dark, toggle } = useTheme();
return (
<button
onClick={toggle}
className="bg-[var(--bg-elevated)] border border-[var(--border-color)] text-[var(--text-secondary)] px-[13px] py-[6px] rounded-md text-xs font-body cursor-pointer transition-all hover:border-[var(--brand)] hover:text-[var(--brand)]"
>
{dark ? "Dark" : "Light"}
</button>
);
}

View file

@ -0,0 +1,24 @@
import { useState, useEffect } from "react";
export default function Toast() {
const [message, setMessage] = useState("");
const [visible, setVisible] = useState(false);
useEffect(() => {
const handler = (e) => {
setMessage(e.detail.message);
setVisible(true);
setTimeout(() => setVisible(false), 3500);
};
window.addEventListener("signal-toast", handler);
return () => window.removeEventListener("signal-toast", handler);
}, []);
if (!visible) return null;
return (
<div className="fixed bottom-6 right-6 bg-[var(--bg-elevated)] border border-[var(--brand)] rounded-lg px-[18px] py-3 text-[13px] text-[var(--text-primary)] z-[100] shadow-[var(--shadow-card-md)]">
{message}
</div>
);
}

View file

@ -0,0 +1,206 @@
import { useTheme } from "../useTheme";
import Badge from "./Badge";
import { getDeviceDisplay } from "../lib/coverage";
function daysLabel(days) {
if (days < 0)
return <span className="font-mono font-semibold text-[#FF7070]">Expired {Math.abs(days)}d ago</span>;
if (days <= 7)
return <span className="font-mono font-medium text-[#FF7070]">{days} days</span>;
if (days <= 30)
return <span className="font-mono font-medium text-[var(--accent-text)]">{days} days</span>;
return <span className="font-mono font-medium text-[var(--text-primary)]">{days} days</span>;
}
function scoreClass(priority) {
if (priority >= 500) return "text-[var(--accent-text)]";
if (priority >= 200) return "text-[var(--text-secondary)]";
return "text-[var(--text-muted)]";
}
function isHighPriority(flag) {
return flag === "OUT_OF_COVERAGE" || flag === "VISIT_DUE";
}
const FILTERS = [
{ key: "all", label: "All" },
{ key: "OUT_OF_COVERAGE", label: "Supply Lapsed" },
{ key: "VISIT_DUE", label: "Renewal Due" },
{ key: "REFILL_WINDOW", label: "Resupply Ready" },
{ key: "OK", label: "Active" },
];
export default function WorklistTable({ records, activeFilter, onFilterChange }) {
const { dark } = useTheme();
const filtered =
activeFilter === "all"
? records
: records.filter((r) => r.flag === activeFilter);
const todayStr = new Date().toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
return (
<div className="bg-[var(--bg-card)] border border-[var(--border-color)] rounded-[10px] shadow-[var(--shadow-card-md)] overflow-hidden transition-colors">
{/* Header */}
<div className="flex items-center justify-between px-[22px] py-4 border-b border-[var(--border-color)] bg-[var(--bg-elevated)] gap-4 flex-wrap">
<div>
<div className="font-heading font-bold text-[15px] text-[var(--text-heading)]">
Outreach Worklist
</div>
<div className="font-mono text-[11px] text-[var(--text-muted)] mt-[2px]">
{records.length} patients · sorted by priority score · {todayStr}
</div>
</div>
<div className="flex gap-[6px] flex-wrap">
{FILTERS.map((f) => (
<button
key={f.key}
onClick={() => onFilterChange(f.key)}
className={`px-3 py-1 rounded-full text-[11.5px] cursor-pointer font-body transition-all border ${
activeFilter === f.key
? "bg-[var(--brand)] text-white border-[var(--brand)]"
: "bg-transparent border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--brand)] hover:text-[var(--brand)]"
}`}
>
{f.label}
</button>
))}
</div>
</div>
{/* Table */}
<table className="w-full border-collapse">
<thead>
<tr>
<th className="px-[22px] py-[10px] text-left text-[10.5px] font-semibold tracking-[0.06em] uppercase text-[var(--text-muted)] bg-[var(--bg-elevated)] border-b border-[var(--border-color)] whitespace-nowrap">
Patient ID
</th>
<th className="px-[22px] py-[10px] text-left text-[10.5px] font-semibold tracking-[0.06em] uppercase text-[var(--text-muted)] bg-[var(--bg-elevated)] border-b border-[var(--border-color)] whitespace-nowrap">
Device
</th>
<th className="px-[22px] py-[10px] text-left text-[10.5px] font-semibold tracking-[0.06em] uppercase text-[var(--text-muted)] bg-[var(--bg-elevated)] border-b border-[var(--border-color)] whitespace-nowrap">
Payer
</th>
<th className="px-[22px] py-[10px] text-left text-[10.5px] font-semibold tracking-[0.06em] uppercase text-[var(--text-muted)] bg-[var(--bg-elevated)] border-b border-[var(--border-color)] whitespace-nowrap">
Days Left
</th>
<th className="px-[22px] py-[10px] text-left text-[10.5px] font-semibold tracking-[0.06em] uppercase text-[var(--text-muted)] bg-[var(--bg-elevated)] border-b border-[var(--border-color)] whitespace-nowrap">
Status
</th>
<th className="px-[22px] py-[10px] text-left text-[10.5px] font-semibold tracking-[0.06em] uppercase text-[var(--text-muted)] bg-[var(--bg-elevated)] border-b border-[var(--border-color)] whitespace-nowrap">
Priority
</th>
<th className="px-[22px] py-[10px] text-left text-[10.5px] font-semibold tracking-[0.06em] uppercase text-[var(--text-muted)] bg-[var(--bg-elevated)] border-b border-[var(--border-color)] whitespace-nowrap">
Action
</th>
</tr>
</thead>
<tbody>
{filtered.map((r, i) => {
const hp = isHighPriority(r.flag);
return (
<tr
key={r.patient_id + "-" + i}
className={`border-b border-[var(--border-subtle)] last:border-b-0 transition-colors hover:bg-[var(--row-hover)] ${
hp
? dark
? "bg-[rgba(224,104,48,0.09)] hover:bg-[rgba(203,107,32,0.14)]"
: "bg-[rgba(224,96,40,0.05)] hover:bg-[rgba(203,107,32,0.14)]"
: ""
}`}
>
<td className="px-[22px] py-[13px] align-middle">
<div
className={`font-mono text-[12.5px] font-bold ${
hp
? "text-[var(--accent-text)]"
: "text-[var(--text-secondary)]"
}`}
>
{r.patient_id}
</div>
{i === 0 && (
<div className="font-mono text-[9.5px] font-semibold tracking-[0.06em] text-[var(--accent-text)] mt-[2px]">
TOP PRIORITY
</div>
)}
</td>
<td className="px-[22px] py-[13px] text-[13.5px] font-medium text-[var(--text-primary)] align-middle">
{getDeviceDisplay(r.device_type)}
</td>
<td className="px-[22px] py-[13px] text-[13px] text-[var(--text-secondary)] align-middle">
{r.payer}
</td>
<td className="px-[22px] py-[13px] align-middle">
{daysLabel(r.daysUntilEnd)}
</td>
<td className="px-[22px] py-[13px] align-middle">
<Badge flag={r.flag} dark={dark} />
{r.reason && (
<div className="text-[10.5px] text-[var(--text-muted)] mt-[4px] max-w-[220px] leading-[1.4]">
{r.reason}
</div>
)}
</td>
<td className="px-[22px] py-[13px] align-middle">
<span
className={`font-mono text-[16px] font-medium ${scoreClass(
r.priority
)}`}
>
{r.priority}
</span>
</td>
<td className="px-[22px] py-[13px] align-middle">
<ActionButton flag={r.flag} />
</td>
</tr>
);
})}
{filtered.length === 0 && (
<tr>
<td
colSpan={7}
className="px-[22px] py-8 text-center text-[var(--text-muted)]"
>
No patients match this filter.
</td>
</tr>
)}
</tbody>
</table>
{/* Footer */}
<div className="flex items-center justify-between px-[22px] py-3 border-t border-[var(--border-subtle)] text-[11px] text-[var(--text-muted)] bg-[var(--bg-elevated)]">
<span className="flex items-center gap-[6px] text-[var(--text-secondary)]">
🔒 PHI-safe patient names and DOBs never stored. Crosswalk: patient_id only.
</span>
<span>
{activeFilter === "all"
? `${records.length} patients · all results shown`
: `${filtered.length} of ${records.length} · filtered`}
</span>
</div>
</div>
);
}
function ActionButton({ flag }) {
const base =
"bg-transparent border border-[var(--border-color)] text-[var(--text-secondary)] px-[13px] py-[5px] rounded-md text-xs cursor-pointer font-body whitespace-nowrap transition-all hover:border-[var(--brand)] hover:text-[var(--brand)]";
if (flag === "OUT_OF_COVERAGE" || flag === "VISIT_DUE") {
const label =
flag === "OUT_OF_COVERAGE" ? "Contact Prescriber" : "Request Renewal →";
return <button className={`${base} !border-[var(--accent)] !text-[var(--accent-text)] hover:bg-[rgba(203,107,32,0.1)]`}>{label}</button>;
}
if (flag === "REFILL_WINDOW") {
return <button className={base}>Initiate Resupply</button>;
}
return <button className={base}>View</button>;
}

168
signal-ui/src/index.css Normal file
View file

@ -0,0 +1,168 @@
@import "tailwindcss";
/* Signal Design Tokens v1.0
Source: docs/design-tokens-v1.json + docs/sttil-brand-system-v1.md
Accent family: tangerine (warm gold on dark, restrained copper on light)
*/
@theme {
/* ── Color: Teal (dominant system color) ── */
--color-teal-50: #EEF8F8;
--color-teal-100: #CBE9E9;
--color-teal-200: #97D3D3;
--color-teal-300: #5BBBBB;
--color-teal-400: #2EA3A3;
--color-teal-500: #1A8A8A;
--color-teal-600: #147A7A;
--color-teal-700: #0F5E5E;
--color-teal-800: #0A4444;
--color-teal-900: #072E2E;
--color-teal-950: #041A1A;
/* ── Color: Tangerine (accent) ── */
--color-tng-50: #FFF4EE;
--color-tng-100: #FFE4CC;
--color-tng-200: #FFC090;
--color-tng-300: #FFB070;
--color-tng-400: #F07840;
--color-tng-500: #E06028;
--color-tng-600: #C04E1C;
--color-tng-700: #903A14;
--color-tng-800: #6A2A0C;
--color-tng-900: #3E1808;
/* ── Color: Neutral (teal-toned) ── */
--color-neutral-0: #FFFFFF;
--color-neutral-50: #F4F9F9;
--color-neutral-100: #E5EEEE;
--color-neutral-200: #C8D8D8;
--color-neutral-300: #A3BEBE;
--color-neutral-400: #7A9E9E;
--color-neutral-500: #5A7E7E;
--color-neutral-600: #426060;
--color-neutral-700: #2E4444;
--color-neutral-800: #1C2C2C;
--color-neutral-900: #111A1A;
/* ── Color: Semantic ── */
--color-success-100: #C8EDD8;
--color-success-500: #1A7A4E;
--color-success-600: #146040;
--color-warning-100: #FDECD5;
--color-warning-400: #D97B35;
--color-warning-600: #A85A18;
--color-error-100: #F8CCCC;
--color-error-500: #C83030;
--color-error-600: #A02020;
--color-info-100: #C8E0EE;
--color-info-500: #1A6A9A;
--color-purple-100: #E2D8F0;
--color-purple-500: #7A5EA0;
/* ── Color: Mode-specific surfaces ── */
--color-warm-white: #FFFAF6;
--color-page-dark: #041A1A;
--color-card-dark: #072E2E;
--color-elevated-dark: #0A4444;
--color-border-dark: #0F5E5E;
/* ── Typography ── */
--font-heading: 'Plus Jakarta Sans', sans-serif;
--font-body: 'Inter', sans-serif;
--font-mono: 'JetBrains Mono', monospace;
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.25rem;
--text-2xl: 1.5rem;
--text-3xl: 1.875rem;
--text-4xl: 2.25rem;
--text-5xl: 3rem;
--font-weight-regular: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
--leading-tight: 1.15;
--leading-snug: 1.25;
--leading-normal: 1.5;
--leading-relaxed: 1.6;
--tracking-tight: -0.02em;
--tracking-normal: 0em;
--tracking-wide: 0.05em;
--tracking-wider: 0.08em;
--tracking-widest: 0.1em;
/* ── Border radius ── */
--radius-sm: 0.25rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-xl: 1rem;
--radius-2xl: 1.5rem;
/* ── Shadows ── */
--shadow-card-sm: 0 1px 3px rgba(7,46,46,0.10);
--shadow-card-md: 0 2px 10px rgba(4,26,26,0.14);
--shadow-card-lg: 0 4px 20px rgba(4,26,26,0.18);
--shadow-copper-dark: 0 0 24px rgba(240,120,64,0.30), 0 2px 10px rgba(4,26,26,0.70);
--shadow-copper-light: 0 0 16px rgba(224,96,40,0.20), 0 2px 8px rgba(0,0,0,0.08);
}
/* ── Base layer ── */
@layer base {
*, *::before, *::after {
box-sizing: border-box;
}
body {
margin: 0;
font-family: var(--font-body);
font-weight: var(--font-weight-regular);
font-size: var(--text-sm);
line-height: var(--leading-normal);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
}
/* ── Light mode (default) ── */
:root {
--bg-page: var(--color-neutral-50);
--bg-card: var(--color-neutral-0);
--bg-elevated: var(--color-neutral-100);
--text-heading: #0A3030;
--text-primary: var(--color-neutral-800);
--text-secondary:var(--color-neutral-500);
--text-muted: var(--color-neutral-400);
--border-color: var(--color-neutral-200);
--border-subtle: rgba(200,216,216,0.7);
--brand: var(--color-teal-600);
--brand-hover: var(--color-teal-700);
--accent: var(--color-tng-500);
--accent-text: var(--color-tng-500);
--accent-glow: var(--shadow-copper-light);
--row-hover: rgba(20,122,122,0.04);
}
/* ── Dark mode ── */
.dark {
--bg-page: var(--color-page-dark);
--bg-card: var(--color-card-dark);
--bg-elevated: var(--color-elevated-dark);
--text-heading: var(--color-warm-white);
--text-primary: #F0F4F4;
--text-secondary:var(--color-teal-300);
--text-muted: #5A8080;
--border-color: var(--color-border-dark);
--border-subtle: rgba(15,94,94,0.45);
--brand: var(--color-teal-400);
--brand-hover: #1A8A8A;
--accent: var(--color-tng-400);
--accent-text: var(--color-tng-300);
--accent-glow: var(--shadow-copper-dark);
--row-hover: rgba(46,163,163,0.06);
}

48
signal-ui/src/lib/api.js Normal file
View file

@ -0,0 +1,48 @@
/**
* Signal API helper tries the backend, returns null on failure so the
* app can fall back to client-side coverage calculation.
*/
const BACKEND_URL = "https://signal-api-production-91c2.up.railway.app";
/**
* Upload a CSV file to the backend scoring endpoint.
* @param {File} file
* @returns {Promise<object|null>} API response or null on failure
*/
export async function uploadToBackend(file) {
const formData = new FormData();
formData.append("file", file);
try {
const resp = await fetch(`${BACKEND_URL}/api/upload`, {
method: "POST",
body: formData,
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail?.message || `API error ${resp.status}`);
}
return resp.json();
} catch {
return null;
}
}
/**
* Convert backend record shape to local record shape.
*/
export function apiRecordToLocal(r) {
return {
patient_id: r.patient_id,
device_type: r.device_type,
payer: r.payer,
component: r.component,
flag: r.flag,
daysUntilEnd: r.days_until_coverage_end,
daysUntilVisit: r.days_until_visit_due,
priority: r.priority_score,
coverageEndDate: r.coverage_end_date,
nextVisitDueDate: r.next_visit_due_date,
reason: r.reason || "",
};
}

View file

@ -0,0 +1,165 @@
/**
* Client-side CGM coverage calculator mirrors python-backend/core/coverage_calculator.py.
* Used as fallback when the Signal API is unreachable.
*
* PHI CONTRACT: This module receives only patient_id, device_type, shipment_date,
* quantity, payer, component. No names, SSNs, DOBs, or contact fields.
*/
export const FLAG = {
OUT_OF_COVERAGE: "OUT_OF_COVERAGE",
VISIT_DUE: "VISIT_DUE",
REFILL_WINDOW: "REFILL_WINDOW",
OK: "OK",
};
const FLAG_LABELS = {
[FLAG.OUT_OF_COVERAGE]: "Supply Lapsed",
[FLAG.VISIT_DUE]: "Renewal Due",
[FLAG.REFILL_WINDOW]: "Resupply Ready",
[FLAG.OK]: "Active",
};
const FLAG_ACTIONS = {
[FLAG.OUT_OF_COVERAGE]: "Contact Prescriber",
[FLAG.VISIT_DUE]: "Request Renewal",
[FLAG.REFILL_WINDOW]: "Initiate Resupply",
[FLAG.OK]: "No action needed",
};
/** Wear-day rules — mirrors payer_rules.json */
const DEVICE_RULES = {
dexcom_g7: { display: "Dexcom G7", sensor: 10 },
dexcom_g6: { display: "Dexcom G6", sensor: 10, transmitter: 90 },
freestyle_libre_2: { display: "FreeStyle Libre 2", sensor: 14 },
freestyle_libre_3: { display: "FreeStyle Libre 3", sensor: 14 },
omnipod_5: { display: "Omnipod 5", pod: 3, sensor: 14 },
};
export function getDeviceDisplay(deviceType) {
return DEVICE_RULES[deviceType]?.display ?? deviceType;
}
export function getFlagLabel(flag) {
return FLAG_LABELS[flag] ?? flag;
}
export function getFlagAction(flag) {
return FLAG_ACTIONS[flag] ?? "Review";
}
function today() {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d;
}
function daysDiff(a, b) {
return Math.round((a.getTime() - b.getTime()) / 86400000);
}
function addDays(dateStr, days) {
const d = new Date(dateStr + "T00:00:00");
d.setDate(d.getDate() + days);
return d;
}
function getWearDays(deviceType, component) {
const device = DEVICE_RULES[deviceType];
if (!device) return null;
return device[component] ?? null;
}
function getPayerConfig(payer) {
const p = payer.toLowerCase();
if (p.includes("medicare")) {
return { visitRenewalDays: 180, refillWindowDays: 30 };
}
return { visitRenewalDays: null, refillWindowDays: 30 };
}
function computePriority(flag, daysUntilEnd) {
if (flag === FLAG.OUT_OF_COVERAGE) return 1000 + Math.abs(daysUntilEnd);
if (flag === FLAG.VISIT_DUE) return 500 + Math.max(0, 90 - daysUntilEnd);
if (flag === FLAG.REFILL_WINDOW) return 200 + Math.max(0, 30 - daysUntilEnd);
return 0;
}
/**
* Calculate coverage for a single record.
* @param {{ patient_id, device_type, shipment_date, quantity, payer, component }} record
* @returns {{ flag, daysUntilEnd, daysUntilVisit, priority }} or null on error
*/
export function calculateCoverage(record) {
const wearDays = getWearDays(record.device_type, record.component);
if (wearDays === null) return null;
const now = today();
const totalWear = wearDays * parseInt(record.quantity, 10);
const coverageEnd = addDays(record.shipment_date, totalWear);
const daysUntilEnd = daysDiff(coverageEnd, now);
const payerCfg = getPayerConfig(record.payer);
let daysUntilVisit = null;
if (payerCfg.visitRenewalDays) {
const visitDue = addDays(record.shipment_date, payerCfg.visitRenewalDays);
daysUntilVisit = daysDiff(visitDue, now);
}
let flag;
if (daysUntilEnd < 0) {
flag = FLAG.OUT_OF_COVERAGE;
} else if (daysUntilVisit !== null && daysUntilVisit <= 30) {
flag = FLAG.VISIT_DUE;
} else if (daysUntilEnd <= payerCfg.refillWindowDays) {
flag = FLAG.REFILL_WINDOW;
} else {
flag = FLAG.OK;
}
return {
...record,
flag,
daysUntilEnd,
daysUntilVisit,
priority: computePriority(flag, daysUntilEnd),
coverageEndDate: coverageEnd.toISOString().slice(0, 10),
};
}
/**
* Parse raw CSV text into record objects.
*/
export function parseCSV(text) {
const lines = text.trim().split(/\r?\n/);
if (lines.length < 2) return [];
const headers = lines[0].split(",").map((h) => h.trim().toLowerCase());
return lines
.slice(1)
.filter((l) => l.trim())
.map((line) => {
const cols = line.split(",").map((c) => c.trim());
const row = {};
headers.forEach((h, i) => (row[h] = cols[i] || ""));
return row;
});
}
/**
* Process a batch of parsed CSV rows and return sorted results + skip list.
*/
export function processBatch(rows) {
const results = [];
const skipped = [];
for (const row of rows) {
const result = calculateCoverage(row);
if (result) {
results.push(result);
} else {
skipped.push(row.patient_id || "?");
}
}
results.sort((a, b) => b.priority - a.priority);
return { results, skipped };
}

View file

@ -0,0 +1,6 @@
/** Fire a toast notification from anywhere in the app. */
export function showToast(message) {
window.dispatchEvent(
new CustomEvent("signal-toast", { detail: { message } })
);
}

10
signal-ui/src/main.jsx Normal file
View file

@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.jsx";
import "./index.css";
createRoot(document.getElementById("root")).render(
<StrictMode>
<App />
</StrictMode>
);

View file

@ -0,0 +1,8 @@
import { useContext } from "react";
import { ThemeContext } from "./ThemeContext.js";
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
return ctx;
}

View file

@ -0,0 +1,136 @@
{
"$schema": "https://design-tokens.github.io/community-group/format/",
"$metadata": {
"version": "1.0.0",
"updated": "2026-04-23",
"scope": "STTIL Solutions — master token set (Signal CGM product expression)",
"accent-family": "tangerine",
"source": "docs/design-tokens-v1.json"
},
"color": {
"teal": {
"50": { "$value": "#EEF8F8", "$type": "color" },
"100": { "$value": "#CBE9E9", "$type": "color" },
"200": { "$value": "#97D3D3", "$type": "color" },
"300": { "$value": "#5BBBBB", "$type": "color" },
"400": { "$value": "#2EA3A3", "$type": "color" },
"500": { "$value": "#1A8A8A", "$type": "color" },
"600": { "$value": "#147A7A", "$type": "color" },
"700": { "$value": "#0F5E5E", "$type": "color" },
"800": { "$value": "#0A4444", "$type": "color" },
"900": { "$value": "#072E2E", "$type": "color" },
"950": { "$value": "#041A1A", "$type": "color" }
},
"tangerine": {
"50": { "$value": "#FFF4EE", "$type": "color" },
"100": { "$value": "#FFE4CC", "$type": "color" },
"200": { "$value": "#FFC090", "$type": "color" },
"300": { "$value": "#FFB070", "$type": "color" },
"400": { "$value": "#F07840", "$type": "color" },
"500": { "$value": "#E06028", "$type": "color" },
"600": { "$value": "#C04E1C", "$type": "color" },
"700": { "$value": "#903A14", "$type": "color" },
"800": { "$value": "#6A2A0C", "$type": "color" },
"900": { "$value": "#3E1808", "$type": "color" }
},
"neutral": {
"0": { "$value": "#FFFFFF", "$type": "color" },
"50": { "$value": "#F4F9F9", "$type": "color" },
"100": { "$value": "#E5EEEE", "$type": "color" },
"200": { "$value": "#C8D8D8", "$type": "color" },
"300": { "$value": "#A3BEBE", "$type": "color" },
"400": { "$value": "#7A9E9E", "$type": "color" },
"500": { "$value": "#5A7E7E", "$type": "color" },
"600": { "$value": "#426060", "$type": "color" },
"700": { "$value": "#2E4444", "$type": "color" },
"800": { "$value": "#1C2C2C", "$type": "color" },
"900": { "$value": "#111A1A", "$type": "color" }
},
"semantic": {
"success": {
"100": { "$value": "#C8EDD8", "$type": "color" },
"500": { "$value": "#1A7A4E", "$type": "color" },
"600": { "$value": "#146040", "$type": "color" }
},
"warning": {
"100": { "$value": "#FDECD5", "$type": "color" },
"400": { "$value": "#D97B35", "$type": "color" },
"600": { "$value": "#A85A18", "$type": "color" }
},
"error": {
"100": { "$value": "#F8CCCC", "$type": "color" },
"500": { "$value": "#C83030", "$type": "color" },
"600": { "$value": "#A02020", "$type": "color" }
},
"info": {
"100": { "$value": "#C8E0EE", "$type": "color" },
"500": { "$value": "#1A6A9A", "$type": "color" }
},
"purple": {
"100": { "$value": "#E2D8F0", "$type": "color" },
"500": { "$value": "#7A5EA0", "$type": "color" }
}
}
},
"typography": {
"fontFamily": {
"heading": { "$value": "'Plus Jakarta Sans', sans-serif", "$type": "fontFamily" },
"body": { "$value": "'Inter', sans-serif", "$type": "fontFamily" },
"mono": { "$value": "'JetBrains Mono', monospace", "$type": "fontFamily" }
},
"fontSize": {
"xs": { "$value": "0.75rem" },
"sm": { "$value": "0.875rem" },
"base": { "$value": "1rem" },
"lg": { "$value": "1.125rem" },
"xl": { "$value": "1.25rem" },
"2xl": { "$value": "1.5rem" },
"3xl": { "$value": "1.875rem" },
"4xl": { "$value": "2.25rem" },
"5xl": { "$value": "3rem" }
},
"fontWeight": {
"regular": { "$value": 400 },
"medium": { "$value": 500 },
"semibold": { "$value": 600 },
"bold": { "$value": 700 }
}
},
"spacing": {
"0": { "$value": "0rem" },
"1": { "$value": "0.25rem" },
"2": { "$value": "0.5rem" },
"3": { "$value": "0.75rem" },
"4": { "$value": "1rem" },
"5": { "$value": "1.25rem" },
"6": { "$value": "1.5rem" },
"8": { "$value": "2rem" },
"10": { "$value": "2.5rem" },
"12": { "$value": "3rem" },
"16": { "$value": "4rem" },
"20": { "$value": "5rem" },
"24": { "$value": "6rem" }
},
"borderRadius": {
"none": { "$value": "0" },
"sm": { "$value": "0.25rem" },
"md": { "$value": "0.5rem" },
"lg": { "$value": "0.75rem" },
"xl": { "$value": "1rem" },
"2xl": { "$value": "1.5rem" },
"full": { "$value": "9999px" }
},
"shadow": {
"sm": { "$value": "0 1px 3px rgba(7,46,46,0.10)" },
"md": { "$value": "0 2px 10px rgba(4,26,26,0.14)" },
"lg": { "$value": "0 4px 20px rgba(4,26,26,0.18)" },
"xl": { "$value": "0 8px 32px rgba(4,26,26,0.22)" },
"tangerine-glow-dark": { "$value": "0 0 24px rgba(240,120,64,0.30), 0 2px 10px rgba(4,26,26,0.70)" },
"tangerine-glow-light": { "$value": "0 0 16px rgba(224,96,40,0.20), 0 2px 8px rgba(0,0,0,0.08)" }
}
}

11
signal-ui/vite.config.js Normal file
View file

@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [tailwindcss(), react()],
build: {
outDir: 'dist',
assetsDir: 'assets',
},
})