fix(frontend): finish the token migration — raw grey classes -> theme tokens

UI_REDESIGN chunk 4 was never finished: 39 app/component files still used
raw Tailwind greys, kept readable in light mode only by the !important
override sheet. In dark mode text-gray-600 is ~2.3:1 on surface, and the
light override maps it to #94a3b8 (~2.6:1 on white) — unreadable in both.

Mechanical mapping (886 classes): gray-100/200 -> ink, 300/400 -> ink-2,
500/600/700 -> ink-muted, bg 950/900/800 -> page/surface/raised,
border 800 -> line, 700/600 -> line-strong, plus hover/divide/placeholder.
Alpha suffixes kept (tokens are color-mix, so /60 works). bg-gray-700,
text-white and marketing pages left alone. text-[9px] -> text-[10px].

Frontend unverified: no Node on the authoring box (#207).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-10-25 21:29:13 -04:00
co-authored by Claude Opus 5.5
parent 3968b49b80
commit bf206334e3
39 changed files with 655 additions and 655 deletions
+113 -113
View File
@@ -438,17 +438,17 @@ function RRImportModal({
return (
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
<div className="bg-gray-900 border border-gray-700 rounded-xl w-full max-w-lg max-h-[90vh] flex flex-col font-mono">
<div className="bg-surface border border-line-strong rounded-xl w-full max-w-lg max-h-[90vh] flex flex-col font-mono">
{/* Header */}
<div className="px-5 pt-5 pb-4 border-b border-gray-800">
<div className="px-5 pt-5 pb-4 border-b border-line">
<h2 className="text-white font-semibold">{system.name}</h2>
<p className="text-xs text-gray-500 mt-0.5">
<p className="text-xs text-ink-muted mt-0.5">
{system.systemType}{system.location ? ` · ${system.location}` : ""}
</p>
{system.sysIds && (
<p className="text-xs text-gray-600 mt-0.5">System IDs: {system.sysIds}</p>
<p className="text-xs text-ink-muted mt-0.5">System IDs: {system.sysIds}</p>
)}
<p className="text-xs text-gray-500 mt-1">
<p className="text-xs text-ink-muted mt-1">
{system.categories.length} categor{system.categories.length !== 1 ? "ies" : "y"} · {total} talkgroups
</p>
</div>
@@ -456,7 +456,7 @@ function RRImportModal({
{/* Category list */}
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-1.5">
<div className="flex items-center justify-between mb-2">
<p className="text-xs text-gray-400 uppercase tracking-wider">Talkgroup Categories</p>
<p className="text-xs text-ink-2 uppercase tracking-wider">Talkgroup Categories</p>
<div className="flex gap-3 text-xs text-indigo-400">
<button
type="button"
@@ -480,18 +480,18 @@ function RRImportModal({
type="checkbox"
checked={selected.has(cat.name)}
onChange={() => toggle(cat.name)}
className="rounded border-gray-600 bg-gray-800 accent-indigo-500"
className="rounded border-line-strong bg-raised accent-indigo-500"
/>
<span className="text-sm text-gray-200 flex-1 group-hover:text-white transition-colors truncate">
<span className="text-sm text-ink flex-1 group-hover:text-white transition-colors truncate">
{cat.name}
</span>
<span className="text-xs text-gray-600 shrink-0">{cat.talkgroups.length} TGs</span>
<span className="text-xs text-ink-muted shrink-0">{cat.talkgroups.length} TGs</span>
</label>
))}
</div>
{/* Footer */}
<div className="px-5 pb-5 pt-4 border-t border-gray-800 flex gap-3">
<div className="px-5 pb-5 pt-4 border-t border-line flex gap-3">
<button
type="button"
onClick={handleImport}
@@ -503,7 +503,7 @@ function RRImportModal({
<button
type="button"
onClick={onCancel}
className="flex-1 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg py-2 text-sm transition-colors"
className="flex-1 bg-raised hover:bg-gray-700 text-ink-2 rounded-lg py-2 text-sm transition-colors"
>
Cancel
</button>
@@ -533,10 +533,10 @@ function LocalKnowledgeField({
multiline?: boolean;
}) {
const cls =
"w-full mt-0.5 bg-gray-900 border border-gray-700 rounded px-2 py-1 text-white text-xs focus:outline-none focus:border-indigo-500";
"w-full mt-0.5 bg-surface border border-line-strong rounded px-2 py-1 text-white text-xs focus:outline-none focus:border-indigo-500";
return (
<label className="block">
<span className="text-xs text-gray-500 font-sans">{label}</span>
<span className="text-xs text-ink-muted font-sans">{label}</span>
{multiline ? (
<textarea
value={value}
@@ -656,8 +656,8 @@ function TalkgroupEditor({
)}
<div className="flex items-center justify-between">
<label className="text-xs text-gray-400">
Talkgroups{talkgroups.length > 0 && <span className="text-gray-600 ml-1">({talkgroups.length})</span>}
<label className="text-xs text-ink-2">
Talkgroups{talkgroups.length > 0 && <span className="text-ink-muted ml-1">({talkgroups.length})</span>}
</label>
<div className="flex gap-3">
{/* RadioReference file import */}
@@ -704,10 +704,10 @@ function TalkgroupEditor({
)}
{showPaste && (
<div className="space-y-2 p-3 bg-gray-800 rounded-lg border border-gray-700">
<p className="text-xs text-gray-500">
<div className="space-y-2 p-3 bg-raised rounded-lg border border-line-strong">
<p className="text-xs text-ink-muted">
Paste rows from RadioReference — tab- or comma-separated:{" "}
<span className="text-gray-400">ID, Name, Tag</span>
<span className="text-ink-2">ID, Name, Tag</span>
<br />Tags: fire · police · ems · transit · public works · other
</p>
<textarea
@@ -715,7 +715,7 @@ function TalkgroupEditor({
onChange={(e) => setPasteText(e.target.value)}
rows={6}
placeholder={"1234\tFire Dispatch\tfire\n5678\tPolice Zone 1\tpolice\n9012\tEMS\tems"}
className="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-white text-xs font-mono focus:outline-none focus:border-indigo-500"
className="w-full bg-surface border border-line-strong rounded px-3 py-2 text-white text-xs font-mono focus:outline-none focus:border-indigo-500"
/>
<button
type="button"
@@ -729,10 +729,10 @@ function TalkgroupEditor({
)}
{talkgroups.length > 0 ? (
<div className="border border-gray-800 rounded-lg overflow-hidden">
<div className="border border-line rounded-lg overflow-hidden">
<table className="w-full text-xs font-mono">
<thead>
<tr className="bg-gray-800 text-gray-400">
<tr className="bg-raised text-ink-2">
<th className="px-3 py-1.5 text-left w-20">Dec ID</th>
<th className="px-3 py-1.5 text-left">Name</th>
<th className="px-3 py-1.5 text-left w-28">Tag</th>
@@ -742,7 +742,7 @@ function TalkgroupEditor({
<tbody>
{talkgroups.map((tg, i) => (
<Fragment key={i}>
<tr className="border-t border-gray-800 hover:bg-gray-800/30">
<tr className="border-t border-line hover:bg-raised/30">
<td className="px-2 py-1">
<input
value={tg.id}
@@ -763,7 +763,7 @@ function TalkgroupEditor({
<select
value={tg.tag}
onChange={(e) => updateRow(i, "tag", e.target.value)}
className="w-full bg-gray-900 text-gray-300 focus:outline-none rounded px-1"
className="w-full bg-surface text-ink-2 focus:outline-none rounded px-1"
>
{TG_TAGS.map((t) => (
<option key={t} value={t}>{t}</option>
@@ -776,7 +776,7 @@ function TalkgroupEditor({
title="Local knowledge for the transcript corrector"
onClick={() => setExpanded(expanded === i ? null : i)}
className={`transition-colors font-bold mr-2 ${
hasLocalKnowledge(tg) ? "text-indigo-400" : "text-gray-600 hover:text-gray-300"
hasLocalKnowledge(tg) ? "text-indigo-400" : "text-ink-muted hover:text-ink"
}`}
>
{expanded === i ? "▾" : "▸"}
@@ -784,7 +784,7 @@ function TalkgroupEditor({
<button
type="button"
onClick={() => removeRow(i)}
className="text-gray-600 hover:text-red-400 transition-colors font-bold"
className="text-ink-muted hover:text-red-400 transition-colors font-bold"
>
×
</button>
@@ -794,11 +794,11 @@ function TalkgroupEditor({
talkgroups and most inherit the system's context rather than
override it. */}
{expanded === i && (
<tr className="border-t border-gray-800 bg-gray-900/60">
<tr className="border-t border-line bg-surface/60">
<td colSpan={4} className="px-3 py-3">
<p className="text-xs text-gray-500 mb-2 font-sans">
<p className="text-xs text-ink-muted mb-2 font-sans">
Given to the transcript corrector for this talkgroup only, ranked
<span className="text-gray-300"> above</span> the system&apos;s own list.
<span className="text-ink-2"> above</span> the system&apos;s own list.
Leave blank to inherit the system&apos;s.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
@@ -849,7 +849,7 @@ function TalkgroupEditor({
</table>
</div>
) : (
<p className="text-xs text-gray-600 italic py-1">
<p className="text-xs text-ink-muted italic py-1">
No talkgroups — add rows, paste from RadioReference, or use the RadioReference import button.
</p>
)}
@@ -864,52 +864,52 @@ function P25Form({ value, onChange }: { value: P25Config; onChange: (v: P25Confi
<div className="space-y-4">
<div className="grid grid-cols-3 gap-3">
<div>
<label className="text-xs text-gray-400 block mb-1">NAC (hex)</label>
<label className="text-xs text-ink-2 block mb-1">NAC (hex)</label>
<input
value={value.nac}
onChange={(e) => onChange({ ...value, nac: e.target.value })}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
placeholder="0x293"
/>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">System ID</label>
<label className="text-xs text-ink-2 block mb-1">System ID</label>
<input
value={value.system_id}
onChange={(e) => onChange({ ...value, system_id: e.target.value })}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
placeholder="50513"
/>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">WACN (hex)</label>
<label className="text-xs text-ink-2 block mb-1">WACN (hex)</label>
<input
value={value.wacn}
onChange={(e) => onChange({ ...value, wacn: e.target.value })}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
placeholder="0xBEE00"
/>
</div>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">
Control Channels <span className="text-gray-600">(MHz, comma-separated)</span>
<label className="text-xs text-ink-2 block mb-1">
Control Channels <span className="text-ink-muted">(MHz, comma-separated)</span>
</label>
<input
value={value.control_channels}
onChange={(e) => onChange({ ...value, control_channels: e.target.value })}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
placeholder="851.0125, 851.5125, 852.0125"
/>
</div>
<div>
<label className="text-xs text-gray-400 block mb-1">
Voice Channels <span className="text-gray-600">(MHz, comma-separated — leave blank for auto-discovery)</span>
<label className="text-xs text-ink-2 block mb-1">
Voice Channels <span className="text-ink-muted">(MHz, comma-separated — leave blank for auto-discovery)</span>
</label>
<input
value={value.voice_channels}
onChange={(e) => onChange({ ...value, voice_channels: e.target.value })}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
placeholder="Optional"
/>
</div>
@@ -923,7 +923,7 @@ function P25Form({ value, onChange }: { value: P25Config; onChange: (v: P25Confi
// ── Fixed-channel editors (node-26#24) ────────────────────────────────────────
const INPUT = "w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500";
const INPUT = "w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500";
function ConventionalForm({ value, onChange }: { value: P25Config; onChange: (v: P25Config) => void }) {
const chs = value.channels;
@@ -931,13 +931,13 @@ function ConventionalForm({ value, onChange }: { value: P25Config; onChange: (v:
onChange({ ...value, channels: chs.map((ch, j) => (j === i ? { ...ch, [field]: v } : ch)) });
return (
<div className="space-y-4">
<p className="text-xs text-gray-500">
<p className="text-xs text-ink-muted">
Fixed voice channels with no control channel — RadioReference lists them as conventional
frequencies with mode P25 (or &quot;DE&quot;). A node decodes up to {MAX_CONVENTIONAL_CHANNELS} on
one SDR, within about 1.5 MHz of each other. Each channel&apos;s calls are labelled with its name.
</p>
<div className="space-y-2">
<div className="grid grid-cols-[1fr_1.5fr_0.8fr_auto] gap-2 text-xs text-gray-400">
<div className="grid grid-cols-[1fr_1.5fr_0.8fr_auto] gap-2 text-xs text-ink-2">
<span>Frequency (MHz)</span>
<span>Channel name</span>
<span>NAC (hex, optional)</span>
@@ -992,7 +992,7 @@ function ConventionalForm({ value, onChange }: { value: P25Config; onChange: (v:
talkgroups={value.talkgroups}
onChange={(tgs) => onChange({ ...value, talkgroups: tgs })}
/>
<p className="text-xs text-gray-600 -mt-2">
<p className="text-xs text-ink-muted -mt-2">
Talkgroups are optional here: only needed if the radios send distinct talkgroup IDs you want named.
</p>
</div>
@@ -1004,7 +1004,7 @@ function AnalogForm({ value, onChange }: { value: AnalogConfig; onChange: (v: An
return (
<div className="grid grid-cols-2 gap-3">
<div>
<label htmlFor="analog-freq" className="text-xs text-gray-400 block mb-1">Frequency (MHz)</label>
<label htmlFor="analog-freq" className="text-xs text-ink-2 block mb-1">Frequency (MHz)</label>
<input
id="analog-freq"
value={ch.freq}
@@ -1016,7 +1016,7 @@ function AnalogForm({ value, onChange }: { value: AnalogConfig; onChange: (v: An
/>
</div>
<div>
<label htmlFor="analog-name" className="text-xs text-gray-400 block mb-1">Channel name</label>
<label htmlFor="analog-name" className="text-xs text-ink-2 block mb-1">Channel name</label>
<input
id="analog-name"
value={ch.name}
@@ -1124,27 +1124,27 @@ function SystemForm({
const title = initial && !createOnly ? "Edit System" : createOnly ? "Duplicate System" : "New System";
return (
<form onSubmit={handleSubmit} className="bg-gray-900 border border-gray-700 rounded-xl p-5 space-y-4 font-mono">
<form onSubmit={handleSubmit} className="bg-surface border border-line-strong rounded-xl p-5 space-y-4 font-mono">
<h3 className="text-white font-semibold">{title}</h3>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-gray-400 block mb-1">Name</label>
<label className="text-xs text-ink-2 block mb-1">Name</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
required
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
placeholder="Westchester County P25"
/>
</div>
<div>
<label htmlFor="system-kind" className="text-xs text-gray-400 block mb-1">Type</label>
<label htmlFor="system-kind" className="text-xs text-ink-2 block mb-1">Type</label>
<select
id="system-kind"
value={kind}
onChange={(e) => handleKindChange(e.target.value as SystemKind)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
>
{KIND_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
@@ -1165,12 +1165,12 @@ function SystemForm({
<AnalogForm value={analog} onChange={setAnalog} />
) : (
<div>
<label className="text-xs text-gray-400 block mb-1">Config (JSON)</label>
<label className="text-xs text-ink-2 block mb-1">Config (JSON)</label>
<textarea
value={rawJson}
onChange={(e) => setRawJson(e.target.value)}
rows={10}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-xs font-mono focus:outline-none focus:border-indigo-500"
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-xs font-mono focus:outline-none focus:border-indigo-500"
/>
</div>
)}
@@ -1179,7 +1179,7 @@ function SystemForm({
<button
type="button"
onClick={toggleRaw}
className="text-xs text-gray-600 hover:text-gray-400 transition-colors"
className="text-xs text-ink-muted hover:text-ink-2 transition-colors"
>
{showRaw ? "← Use structured form" : "Edit raw JSON →"}
</button>
@@ -1198,7 +1198,7 @@ function SystemForm({
<button
type="button"
onClick={onCancel}
className="flex-1 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg py-2 text-sm transition-colors"
className="flex-1 bg-raised hover:bg-gray-700 text-ink-2 rounded-lg py-2 text-sm transition-colors"
>
Cancel
</button>
@@ -1267,10 +1267,10 @@ function PreferredTokenPanel({ systemId, initialTokenId }: { systemId: string; i
const currentToken = tokens?.find((t) => t.token_id === preferredId);
return (
<div className="mt-3 border-t border-gray-800 pt-3">
<div className="mt-3 border-t border-line pt-3">
<button
onClick={toggle}
className="text-xs text-gray-500 hover:text-gray-300 font-mono transition-colors flex items-center gap-1"
className="text-xs text-ink-muted hover:text-ink font-mono transition-colors flex items-center gap-1"
>
<span>{open ? "▲" : "▼"}</span>
<span>
@@ -1282,14 +1282,14 @@ function PreferredTokenPanel({ systemId, initialTokenId }: { systemId: string; i
{open && (
<div className="mt-3 space-y-2 font-mono text-xs">
{tokens === null ? (
<p className="text-gray-600 italic">Loading tokens…</p>
<p className="text-ink-muted italic">Loading tokens…</p>
) : error ? (
<p className="text-red-400">{error}</p>
) : tokens.length === 0 ? (
<p className="text-gray-600 italic">No tokens in pool.</p>
<p className="text-ink-muted italic">No tokens in pool.</p>
) : (
<>
<p className="text-gray-600">
<p className="text-ink-muted">
When a node on this system joins a voice channel, this token is tried first.
</p>
<div className="space-y-1.5">
@@ -1303,7 +1303,7 @@ function PreferredTokenPanel({ systemId, initialTokenId }: { systemId: string; i
disabled={saving}
className="accent-indigo-500"
/>
<span className={`flex-1 ${t.in_use && preferredId !== t.token_id ? "text-gray-600" : "text-gray-300"}`}>
<span className={`flex-1 ${t.in_use && preferredId !== t.token_id ? "text-ink-muted" : "text-ink-2"}`}>
{t.name}
{t.in_use && <span className="ml-1.5 text-green-600">in use</span>}
</span>
@@ -1314,13 +1314,13 @@ function PreferredTokenPanel({ systemId, initialTokenId }: { systemId: string; i
<button
onClick={handleClear}
disabled={saving}
className="text-gray-600 hover:text-gray-400 transition-colors disabled:opacity-50"
className="text-ink-muted hover:text-ink-2 transition-colors disabled:opacity-50"
>
Clear preference (use any free token)
</button>
)}
{!preferredId && (
<p className="text-gray-700">No preference — any free token will be used.</p>
<p className="text-ink-muted">No preference — any free token will be used.</p>
)}
{currentToken && (
<p className="text-indigo-500">Preferred: {currentToken.name}</p>
@@ -1371,10 +1371,10 @@ function AiFlagsPanel({ systemId, initial }: { systemId: string; initial: System
];
return (
<div className="mt-3 border-t border-gray-800 pt-3">
<div className="mt-3 border-t border-line pt-3">
<button
onClick={() => setOpen((v) => !v)}
className="text-xs text-gray-500 hover:text-gray-300 font-mono transition-colors flex items-center gap-1"
className="text-xs text-ink-muted hover:text-ink font-mono transition-colors flex items-center gap-1"
>
<span>{open ? "▲" : "▼"}</span>
<span>AI Flags</span>
@@ -1400,23 +1400,23 @@ function AiFlagsPanel({ systemId, initial }: { systemId: string; initial: System
>
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform ${isOn ? "translate-x-4" : "translate-x-0.5"}`} />
</button>
<span className="text-gray-300 flex-1">{label}</span>
<span className="text-ink-2 flex-1">{label}</span>
{isSet ? (
<button
onClick={() => handleClear(key)}
disabled={saving === key}
className="text-gray-600 hover:text-gray-400 transition-colors"
className="text-ink-muted hover:text-ink-2 transition-colors"
title="Clear override (inherit global)"
>
reset
</button>
) : (
<span className="text-gray-700">inherits global</span>
<span className="text-ink-muted">inherits global</span>
)}
</div>
);
})}
<p className="text-gray-700 pt-1">Overrides apply on top of global AI flags. "reset" restores global default.</p>
<p className="text-ink-muted pt-1">Overrides apply on top of global AI flags. "reset" restores global default.</p>
</div>
)}
</div>
@@ -1456,15 +1456,15 @@ function SourceCallPlayer({ callId }: { callId: string }) {
{loading ? "loading…" : open ? "▲ source" : "▶ source"}
</button>
{open && call && (
<div className="mt-1.5 space-y-1 pl-2 border-l border-gray-700">
<div className="mt-1.5 space-y-1 pl-2 border-l border-line-strong">
{call.audio_url ? (
<audio src={call.audio_url} controls className="w-full" style={{ height: "1.75rem" }} />
) : (
<p className="text-gray-600 italic">No audio</p>
<p className="text-ink-muted italic">No audio</p>
)}
{transcript && (
<>
<p className="text-gray-500 italic line-clamp-2">{transcript}</p>
<p className="text-ink-muted italic line-clamp-2">{transcript}</p>
{/* Gate A / A2 (server-26#46) — this is a raw pipeline transcript. */}
<MachineOutputNotice variant="inline" className="text-[10px]" />
</>
@@ -1528,16 +1528,16 @@ function AreaContextPanel({ systemId }: { systemId: string }) {
: 0;
return (
<div className="mt-3 border-t border-gray-800 pt-3">
<div className="mt-3 border-t border-line pt-3">
<button
onClick={toggle}
className="text-xs text-gray-500 hover:text-gray-300 font-mono transition-colors flex items-center gap-1"
className="text-xs text-ink-muted hover:text-ink font-mono transition-colors flex items-center gap-1"
>
<span>{open ? "▲" : "▼"}</span>
<span>
Local Area
{area !== null && (
<span className="text-gray-600 ml-1">
<span className="text-ink-muted ml-1">
({filled > 0 ? `${filled} field${filled === 1 ? "" : "s"} set` : "not set"})
</span>
)}
@@ -1546,16 +1546,16 @@ function AreaContextPanel({ systemId }: { systemId: string }) {
{open && (
<div className="mt-3 space-y-3 text-xs">
{loading && <p className="text-gray-600 italic font-mono">Loading…</p>}
{loading && <p className="text-ink-muted italic font-mono">Loading…</p>}
{area && (
<>
<p className="text-gray-500">
<p className="text-ink-muted">
Given to the transcript corrector for every talkgroup on this system.
Whisper mishears local names constantly — naming them here is what lets
them be put back.
</p>
<p className="text-gray-500">
Fill this in <span className="text-gray-300">only if it is true of every
<p className="text-ink-muted">
Fill this in <span className="text-ink-2">only if it is true of every
talkgroup</span> on the system. One town, one department — fill it once here.
A system spanning several counties — leave it blank and describe each
talkgroup in the edit form instead.
@@ -1592,7 +1592,7 @@ function AreaContextPanel({ systemId }: { systemId: string }) {
{/* The anchor is the backend's answer to what was typed above, and
it decides whether location checking runs at all — so it is
worth showing rather than leaving as invisible state. */}
<p className="text-gray-600 font-mono">{anchorLabel(area)}</p>
<p className="text-ink-muted font-mono">{anchorLabel(area)}</p>
<button
type="button"
onClick={save}
@@ -1659,16 +1659,16 @@ function TalkgroupPendingPanel({ systemId }: { systemId: string }) {
const total = (rows ?? []).reduce((n, r) => n + r.pending.length, 0);
return (
<div className="mt-3 border-t border-gray-800 pt-3">
<div className="mt-3 border-t border-line pt-3">
<button
onClick={toggle}
className="text-xs text-gray-500 hover:text-gray-300 font-mono transition-colors flex items-center gap-1"
className="text-xs text-ink-muted hover:text-ink font-mono transition-colors flex items-center gap-1"
>
<span>{open ? "▲" : "▼"}</span>
<span>
Proposed Terms
{rows !== null && (
<span className={total > 0 ? "text-indigo-400 ml-1" : "text-gray-600 ml-1"}>
<span className={total > 0 ? "text-indigo-400 ml-1" : "text-ink-muted ml-1"}>
({total > 0 ? `${total} awaiting review` : "none"})
</span>
)}
@@ -1677,9 +1677,9 @@ function TalkgroupPendingPanel({ systemId }: { systemId: string }) {
{open && (
<div className="mt-3 space-y-3 text-xs">
{rows === null && <p className="text-gray-600 italic font-mono">Loading…</p>}
{rows === null && <p className="text-ink-muted italic font-mono">Loading…</p>}
{rows !== null && total === 0 && (
<p className="text-gray-600 italic">
<p className="text-ink-muted italic">
Nothing proposed yet. Terms appear here when a corrected place name
turns out to be real nearby, or when the induction loop spots one in
recent traffic.
@@ -1687,19 +1687,19 @@ function TalkgroupPendingPanel({ systemId }: { systemId: string }) {
)}
{(rows ?? []).map((row) => (
<div key={row.talkgroup_id}>
<p className="text-gray-400 font-mono mb-1">
<p className="text-ink-2 font-mono mb-1">
{row.talkgroup_name || `TGID ${row.talkgroup_id}`}
</p>
<ul className="space-y-1">
{row.pending.map((p) => (
<li
key={p.term}
className="flex items-start gap-2 bg-gray-900/60 border border-gray-800 rounded px-2 py-1"
className="flex items-start gap-2 bg-surface/60 border border-line rounded px-2 py-1"
>
<span className="flex-1">
<span className="text-white font-mono">{p.term}</span>
{p.meaning && <span className="text-gray-500"> — {p.meaning}</span>}
<span className="text-gray-700 ml-2">{p.source}</span>
{p.meaning && <span className="text-ink-muted"> — {p.meaning}</span>}
<span className="text-ink-muted ml-2">{p.source}</span>
</span>
<button
type="button"
@@ -1713,7 +1713,7 @@ function TalkgroupPendingPanel({ systemId }: { systemId: string }) {
type="button"
disabled={busy === `${row.talkgroup_id}:${p.term}`}
onClick={() => act(row.talkgroup_id, p.term, false)}
className="text-gray-600 hover:text-red-400 disabled:opacity-40 font-semibold"
className="text-ink-muted hover:text-red-400 disabled:opacity-40 font-semibold"
>
Drop
</button>
@@ -1802,16 +1802,16 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
}
return (
<div className="mt-3 border-t border-gray-800 pt-3">
<div className="mt-3 border-t border-line pt-3">
<button
onClick={toggle}
className="text-xs text-gray-500 hover:text-gray-300 font-mono transition-colors flex items-center gap-1"
className="text-xs text-ink-muted hover:text-ink font-mono transition-colors flex items-center gap-1"
>
<span>{open ? "▲" : "▼"}</span>
<span>
Vocabulary
{vocab !== null && (
<span className="text-gray-600 ml-1">
<span className="text-ink-muted ml-1">
({vocab.length} terms{pending.length > 0 ? `, ${pending.length} pending` : ""})
</span>
)}
@@ -1820,7 +1820,7 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
{open && (
<div className="mt-3 space-y-3 font-mono text-xs">
{loading && <p className="text-gray-600 italic">Loading…</p>}
{loading && <p className="text-ink-muted italic">Loading…</p>}
{!loading && vocab !== null && (
<>
@@ -1832,19 +1832,19 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
>
{bootstrapping ? "Bootstrapping…" : bootstrapped ? "Re-bootstrap with AI" : "Bootstrap with AI"}
</button>
<span className="text-gray-600">GPT-4o generates local knowledge (agencies, units, streets)</span>
<span className="text-ink-muted">GPT-4o generates local knowledge (agencies, units, streets)</span>
</div>
<div>
<p className="text-gray-500 uppercase tracking-wider mb-1.5">Approved ({vocab.length})</p>
<p className="text-ink-muted uppercase tracking-wider mb-1.5">Approved ({vocab.length})</p>
{vocab.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{vocab.map((term) => (
<span key={term} className="inline-flex items-center gap-1 bg-gray-800 text-gray-300 px-2 py-0.5 rounded-full">
<span key={term} className="inline-flex items-center gap-1 bg-raised text-ink-2 px-2 py-0.5 rounded-full">
{term}
<button
onClick={() => handleRemove(term)}
className="text-gray-600 hover:text-red-400 transition-colors leading-none"
className="text-ink-muted hover:text-red-400 transition-colors leading-none"
>
×
</button>
@@ -1852,7 +1852,7 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
))}
</div>
) : (
<p className="text-gray-600 italic">No terms yet — bootstrap or add manually.</p>
<p className="text-ink-muted italic">No terms yet — bootstrap or add manually.</p>
)}
</div>
@@ -1861,12 +1861,12 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
value={newTerm}
onChange={(e) => setNewTerm(e.target.value)}
placeholder="Add term (e.g. 5-baker, YVAC)"
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-xs font-mono focus:outline-none focus:border-indigo-500"
className="flex-1 bg-raised border border-line-strong rounded-lg px-3 py-1.5 text-white text-xs font-mono focus:outline-none focus:border-indigo-500"
/>
<button
type="submit"
disabled={adding || !newTerm.trim()}
className="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 text-gray-200 px-3 py-1.5 rounded-lg transition-colors"
className="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 text-ink px-3 py-1.5 rounded-lg transition-colors"
>
Add
</button>
@@ -1874,17 +1874,17 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
{pending.length > 0 && (
<div>
<p className="text-gray-500 uppercase tracking-wider mb-1.5">
<p className="text-ink-muted uppercase tracking-wider mb-1.5">
Induction suggestions ({pending.length})
</p>
<div className="space-y-2">
{pending.map((p) => (
<div key={p.term} className="space-y-1">
<div className="flex items-center gap-2">
<span className="text-gray-300 flex-1">{p.term}</span>
<span className="text-gray-600">{p.source}</span>
<span className="text-ink-2 flex-1">{p.term}</span>
<span className="text-ink-muted">{p.source}</span>
<button onClick={() => handleApprove(p.term)} className="text-green-500 hover:text-green-400 transition-colors px-1">✓</button>
<button onClick={() => handleDismiss(p.term)} className="text-gray-600 hover:text-red-400 transition-colors px-1">✕</button>
<button onClick={() => handleDismiss(p.term)} className="text-ink-muted hover:text-red-400 transition-colors px-1">✕</button>
</div>
{p.source_call_ids && p.source_call_ids.length > 0 && (
<div className="pl-1 space-y-1">
@@ -1970,9 +1970,9 @@ export default function SystemsPage() {
)}
{loading ? (
<p className="text-gray-600 text-sm font-mono">Loading…</p>
<p className="text-ink-muted text-sm font-mono">Loading…</p>
) : systems.length === 0 ? (
<p className="text-gray-600 text-sm font-mono">No systems defined yet.</p>
<p className="text-ink-muted text-sm font-mono">No systems defined yet.</p>
) : (
<div className="space-y-3">
{systems.map((s) => {
@@ -1987,13 +1987,13 @@ export default function SystemsPage() {
? (s.config.channels as unknown[]).length
: null;
return (
<div key={s.system_id} className="bg-gray-900 border border-gray-800 rounded-lg p-4 font-mono">
<div key={s.system_id} className="bg-surface border border-line rounded-lg p-4 font-mono">
<div className="flex items-start justify-between">
<div>
<p className="text-white font-semibold">{s.name}</p>
<p className="text-xs text-gray-500">{s.system_id}</p>
<p className="text-xs text-ink-muted">{s.system_id}</p>
{(s.config.nac || ccCount !== null || chCount !== null || tgCount !== null) && (
<p className="text-xs text-gray-600 mt-0.5 space-x-2">
<p className="text-xs text-ink-muted mt-0.5 space-x-2">
{!!s.config.nac && <span>NAC {String(s.config.nac)}</span>}
{ccCount !== null && <span>· {ccCount} CC</span>}
{chCount !== null && <span>· {chCount} ch</span>}
@@ -2001,7 +2001,7 @@ export default function SystemsPage() {
</p>
)}
</div>
<span className="text-xs bg-gray-800 border border-gray-700 text-gray-400 px-2 py-0.5 rounded">
<span className="text-xs bg-raised border border-line-strong text-ink-2 px-2 py-0.5 rounded">
{kindLabel(s)}
</span>
</div>
@@ -2014,7 +2014,7 @@ export default function SystemsPage() {
</button>
<button
onClick={() => openDuplicate(s)}
className="text-xs text-gray-500 hover:text-gray-300 transition-colors"
className="text-xs text-ink-muted hover:text-ink transition-colors"
>
Duplicate
</button>