Replace dark-mode overrides with semantic colour tokens

Dark mode was 58 `.dark .bg-white`-style rules patching over components
that hardcoded light shades, so every new component silently owed the
stylesheet another override — a debt this session kept adding to.

Components now name the role of a colour rather than the shade:
bg-surface, text-ink, text-muted, border-line, text-danger. Those map
through @theme to CSS variables, and light and dark are two sets of
values for the same variables. The override block is gone entirely.

Solid button fills stay literal; they read correctly on both themes and
never had overrides.

Verified in the browser in both themes, Perspective included.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G2HFeU5neCKagTnmA6o9Tu
This commit is contained in:
Paul Trowbridge 2026-08-02 12:41:31 -04:00
parent aa9315fdd2
commit 4eda1c48b7
19 changed files with 600 additions and 561 deletions

View File

@ -144,7 +144,7 @@ export default function App() {
{/* Main */} {/* Main */}
<div className="flex-1 overflow-hidden flex flex-col min-w-0"> <div className="flex-1 overflow-hidden flex flex-col min-w-0">
{(staleSources.size > 0 || staleStacks.size > 0) && ( {(staleSources.size > 0 || staleStacks.size > 0) && (
<div className="bg-amber-50 border-b border-amber-200 px-4 py-1.5 text-xs text-amber-800 flex flex-wrap items-center gap-x-3 gap-y-1"> <div className="bg-warn-soft border-b border-warn-line px-4 py-1.5 text-xs text-warn flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="font-medium">View out of sync:</span> <span className="font-medium">View out of sync:</span>
{[...staleSources].map(name => ( {[...staleSources].map(name => (
<span key={name} className="flex items-center gap-1"> <span key={name} className="flex items-center gap-1">
@ -152,20 +152,20 @@ export default function App() {
<button <button
onClick={() => handleGenerateSource(name)} onClick={() => handleGenerateSource(name)}
disabled={generating[`src:${name}`]} disabled={generating[`src:${name}`]}
className="px-1.5 py-0.5 rounded bg-amber-200 hover:bg-amber-300 disabled:opacity-50 font-medium" className="px-1.5 py-0.5 rounded bg-warn-line hover:bg-warn-line disabled:opacity-50 font-medium"
> >
{generating[`src:${name}`] ? '…' : 'Generate'} {generating[`src:${name}`] ? '…' : 'Generate'}
</button> </button>
</span> </span>
))} ))}
{staleSources.size > 0 && staleStacks.size > 0 && <span className="text-amber-400">|</span>} {staleSources.size > 0 && staleStacks.size > 0 && <span className="text-warn">|</span>}
{[...staleStacks].map(name => ( {[...staleStacks].map(name => (
<span key={name} className="flex items-center gap-1"> <span key={name} className="flex items-center gap-1">
stack: {name} stack: {name}
<button <button
onClick={() => handleGenerateStack(name)} onClick={() => handleGenerateStack(name)}
disabled={generating[`stk:${name}`]} disabled={generating[`stk:${name}`]}
className="px-1.5 py-0.5 rounded bg-amber-200 hover:bg-amber-300 disabled:opacity-50 font-medium" className="px-1.5 py-0.5 rounded bg-warn-line hover:bg-warn-line disabled:opacity-50 font-medium"
> >
{generating[`stk:${name}`] ? '…' : 'Generate'} {generating[`stk:${name}`] ? '…' : 'Generate'}
</button> </button>
@ -174,7 +174,7 @@ export default function App() {
</div> </div>
)} )}
{reprocessSources.size > 0 && ( {reprocessSources.size > 0 && (
<div className="bg-blue-50 border-b border-blue-200 px-4 py-1.5 text-xs text-blue-800 flex flex-wrap items-center gap-x-3 gap-y-1"> <div className="bg-accent-soft border-b border-accent-line px-4 py-1.5 text-xs text-accent flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="font-medium">Mappings updated:</span> <span className="font-medium">Mappings updated:</span>
{[...reprocessSources].map(name => ( {[...reprocessSources].map(name => (
<span key={name} className="flex items-center gap-1"> <span key={name} className="flex items-center gap-1">

View File

@ -4,19 +4,19 @@ export default function SampleTable({ rows }) {
if (!rows || rows.length === 0) return null if (!rows || rows.length === 0) return null
const cols = Object.keys(rows[0]) const cols = Object.keys(rows[0])
return ( return (
<div className="overflow-auto border border-gray-100 rounded bg-gray-50 max-h-36"> <div className="overflow-auto border border-line-soft rounded bg-raised max-h-36">
<table className="text-xs w-full"> <table className="text-xs w-full">
<thead> <thead>
<tr className="text-left text-gray-400 border-b border-gray-100 bg-gray-50 sticky top-0"> <tr className="text-left text-muted border-b border-line-soft bg-raised sticky top-0">
{cols.map(c => <th key={c} className="px-2 py-1 font-medium whitespace-nowrap">{c}</th>)} {cols.map(c => <th key={c} className="px-2 py-1 font-medium whitespace-nowrap">{c}</th>)}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rows.map((row, i) => ( {rows.map((row, i) => (
<tr key={i} className="border-t border-gray-100"> <tr key={i} className="border-t border-line-soft">
{cols.map(c => ( {cols.map(c => (
<td key={c} className="px-2 py-1 whitespace-nowrap text-gray-600 max-w-32 truncate font-mono"> <td key={c} className="px-2 py-1 whitespace-nowrap text-ink-soft max-w-32 truncate font-mono">
{row[c] == null ? <span className="text-gray-300"></span> : String(row[c])} {row[c] == null ? <span className="text-muted"></span> : String(row[c])}
</td> </td>
))} ))}
</tr> </tr>

View File

@ -2,10 +2,10 @@
// separate things rather than one flat form // separate things rather than one flat form
export default function Section({ title, description, children }) { export default function Section({ title, description, children }) {
return ( return (
<section className="bg-white border border-gray-200 rounded p-4"> <section className="bg-surface border border-line rounded p-4">
<h2 className="text-sm font-semibold text-gray-700">{title}</h2> <h2 className="text-sm font-semibold text-ink-soft">{title}</h2>
{description {description
? <p className="text-xs text-gray-400 mt-0.5 mb-3">{description}</p> ? <p className="text-xs text-muted mt-0.5 mb-3">{description}</p>
: <div className="mb-3" />} : <div className="mb-3" />}
{children} {children}
</section> </section>

View File

@ -76,14 +76,14 @@ export default function Sidebar({ expanded, setExpanded, loginUser, onLogout })
return ( return (
<div <div
className="bg-white border-r border-gray-200 flex flex-col shrink-0 overflow-hidden transition-all duration-150" className="bg-surface border-r border-line flex flex-col shrink-0 overflow-hidden transition-all duration-150"
style={{ width: expanded ? 200 : 48 }} style={{ width: expanded ? 200 : 48 }}
> >
{/* Header */} {/* Header */}
<div className="h-12 flex items-center px-3 border-b border-gray-100 gap-2 shrink-0"> <div className="h-12 flex items-center px-3 border-b border-line-soft gap-2 shrink-0">
<button <button
onClick={() => setExpanded(e => !e)} onClick={() => setExpanded(e => !e)}
className="w-8 h-8 flex items-center justify-center rounded hover:bg-gray-100 text-gray-400 shrink-0" className="w-8 h-8 flex items-center justify-center rounded hover:bg-raised text-muted shrink-0"
title="Toggle sidebar" title="Toggle sidebar"
> >
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"> <svg width="16" height="16" viewBox="0 0 16 16" fill="none">
@ -93,7 +93,7 @@ export default function Sidebar({ expanded, setExpanded, loginUser, onLogout })
</svg> </svg>
</button> </button>
<span <span
className="text-xs font-semibold text-gray-600 tracking-wide uppercase whitespace-nowrap transition-opacity duration-100" className="text-xs font-semibold text-ink-soft tracking-wide uppercase whitespace-nowrap transition-opacity duration-100"
style={{ opacity: expanded ? 1 : 0, pointerEvents: expanded ? 'auto' : 'none' }} style={{ opacity: expanded ? 1 : 0, pointerEvents: expanded ? 'auto' : 'none' }}
> >
Dataflow Dataflow
@ -110,8 +110,8 @@ export default function Sidebar({ expanded, setExpanded, loginUser, onLogout })
className={({ isActive }) => className={({ isActive }) =>
`flex items-center gap-3 px-2 py-2 rounded w-full transition-colors ${ `flex items-center gap-3 px-2 py-2 rounded w-full transition-colors ${
isActive isActive
? 'bg-blue-50 text-blue-700' ? 'bg-accent-soft text-accent'
: 'text-gray-500 hover:bg-gray-100 hover:text-gray-800' : 'text-muted hover:bg-raised hover:text-ink'
}` }`
} }
> >
@ -127,11 +127,11 @@ export default function Sidebar({ expanded, setExpanded, loginUser, onLogout })
</nav> </nav>
{/* Theme */} {/* Theme */}
<div className="border-t border-gray-100 px-3 py-2 shrink-0"> <div className="border-t border-line-soft px-3 py-2 shrink-0">
<button <button
onClick={() => setDark(d => !d)} onClick={() => setDark(d => !d)}
title={dark ? 'Switch to light mode' : 'Switch to dark mode'} title={dark ? 'Switch to light mode' : 'Switch to dark mode'}
className="flex items-center gap-2.5 w-full rounded px-1 py-1 text-gray-500 hover:bg-gray-100 hover:text-gray-800" className="flex items-center gap-2.5 w-full rounded px-1 py-1 text-muted hover:bg-raised hover:text-ink"
> >
<span className="shrink-0"> <span className="shrink-0">
{dark ? ( {dark ? (
@ -158,9 +158,9 @@ export default function Sidebar({ expanded, setExpanded, loginUser, onLogout })
</div> </div>
{/* User / logout */} {/* User / logout */}
<div className="border-t border-gray-100 px-3 py-2.5 flex items-center gap-2 shrink-0 overflow-hidden"> <div className="border-t border-line-soft px-3 py-2.5 flex items-center gap-2 shrink-0 overflow-hidden">
<div <div
className="w-6 h-6 rounded-full bg-gray-200 text-gray-500 flex items-center justify-center shrink-0 text-xs font-medium" className="w-6 h-6 rounded-full bg-raised text-muted flex items-center justify-center shrink-0 text-xs font-medium"
title={!expanded ? loginUser : undefined} title={!expanded ? loginUser : undefined}
> >
{loginUser ? loginUser[0].toUpperCase() : '?'} {loginUser ? loginUser[0].toUpperCase() : '?'}
@ -169,10 +169,10 @@ export default function Sidebar({ expanded, setExpanded, loginUser, onLogout })
className="flex-1 flex items-center justify-between min-w-0 transition-opacity duration-100" className="flex-1 flex items-center justify-between min-w-0 transition-opacity duration-100"
style={{ opacity: expanded ? 1 : 0, pointerEvents: expanded ? 'auto' : 'none', width: expanded ? 'auto' : 0, overflow: 'hidden' }} style={{ opacity: expanded ? 1 : 0, pointerEvents: expanded ? 'auto' : 'none', width: expanded ? 'auto' : 0, overflow: 'hidden' }}
> >
<span className="text-xs text-gray-400 truncate">{loginUser}</span> <span className="text-xs text-muted truncate">{loginUser}</span>
<button <button
onClick={onLogout} onClick={onLogout}
className="text-xs text-gray-400 hover:text-red-500 ml-2 shrink-0" className="text-xs text-muted hover:text-danger ml-2 shrink-0"
> >
Sign out Sign out
</button> </button>

View File

@ -20,17 +20,17 @@ export default function SourceTabs({ sources }) {
<div className="flex flex-col h-full min-h-0"> <div className="flex flex-col h-full min-h-0">
<div className="px-6 pt-5 shrink-0"> <div className="px-6 pt-5 shrink-0">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Link to="/sources" className="text-xs text-gray-400 hover:text-gray-600">Sources</Link> <Link to="/sources" className="text-xs text-muted hover:text-ink-soft">Sources</Link>
<span className="text-gray-300 text-xs">/</span> <span className="text-muted text-xs">/</span>
<h1 className="text-xl font-semibold text-gray-800">{name}</h1> <h1 className="text-xl font-semibold text-ink">{name}</h1>
{sourceObj?.config?.simplefin?.account_id && ( {sourceObj?.config?.simplefin?.account_id && (
<span className="text-xs bg-blue-50 text-blue-600 border border-blue-100 rounded px-1.5 py-0.5"> <span className="text-xs bg-accent-soft text-accent border border-accent-line rounded px-1.5 py-0.5">
bank feed bank feed
</span> </span>
)} )}
</div> </div>
<nav className="flex gap-1 mt-3 border-b border-gray-200"> <nav className="flex gap-1 mt-3 border-b border-line">
{TABS.map(({ to, label, end }) => ( {TABS.map(({ to, label, end }) => (
<NavLink <NavLink
key={label} key={label}
@ -39,8 +39,8 @@ export default function SourceTabs({ sources }) {
className={({ isActive }) => className={({ isActive }) =>
`text-sm px-3 py-1.5 -mb-px border-b-2 ${ `text-sm px-3 py-1.5 -mb-px border-b-2 ${
isActive isActive
? 'border-blue-500 text-blue-600 font-medium' ? 'border-accent text-accent font-medium'
: 'border-transparent text-gray-500 hover:text-gray-700' : 'border-transparent text-muted hover:text-ink-soft'
}` }`
} }
> >

View File

@ -1,5 +1,42 @@
@import "tailwindcss"; @import "tailwindcss";
/*
Semantic colour tokens.
Components name the *role* of a colour (bg-surface, text-muted, border-line)
rather than a literal shade, so light and dark are two sets of variable values
instead of two sets of rules. Adding a component no longer means adding a
matching `.dark` override.
The raw palette below is the only place actual colours appear.
*/
@theme {
--color-canvas: var(--bg-primary);
--color-surface: var(--bg-secondary);
--color-raised: var(--bg-tertiary);
--color-ink: var(--text-primary);
--color-ink-soft: var(--text-secondary);
--color-muted: var(--text-muted);
--color-line: var(--border-color);
--color-line-soft: var(--border-light);
--color-accent: var(--accent-text);
--color-accent-soft: var(--accent-bg);
--color-accent-line: var(--accent-line);
--color-ok: var(--ok-text);
--color-ok-soft: var(--ok-bg);
--color-warn: var(--warn-text);
--color-warn-soft: var(--warn-bg);
--color-warn-line: var(--warn-line);
--color-danger: var(--danger-text);
--color-danger-soft: var(--danger-bg);
--color-danger-line: var(--danger-line);
}
:root, .light { :root, .light {
--bg-primary: #f3f4f6; --bg-primary: #f3f4f6;
--bg-secondary: #ffffff; --bg-secondary: #ffffff;
@ -11,6 +48,16 @@
--border-light: #f3f4f6; --border-light: #f3f4f6;
--accent-bg: #eff6ff; --accent-bg: #eff6ff;
--accent-text: #1d4ed8; --accent-text: #1d4ed8;
--accent-line: #bfdbfe;
--ok-text: #059669;
--ok-bg: #ecfdf5;
--warn-text: #b45309;
--warn-bg: #fffbeb;
--warn-line: #fde68a;
--danger-text: #ef4444;
--danger-bg: #fef2f2;
--danger-line: #fecaca;
} }
/* Dark palette tuned to Perspective's "Pro Dark" theme: /* Dark palette tuned to Perspective's "Pro Dark" theme:
@ -27,6 +74,17 @@
--border-light: #3b3f46; --border-light: #3b3f46;
--accent-bg: rgba(39, 113, 170, 0.32); --accent-bg: rgba(39, 113, 170, 0.32);
--accent-text: #4778c2; --accent-text: #4778c2;
--accent-line: #2770a9;
/* Status accents desaturated to sit on Pro Dark's neutral background */
--ok-text: #6ee7b7;
--ok-bg: #1a3d2c;
--warn-text: #f5c66f;
--warn-bg: #3a2e14;
--warn-line: #5a4a26;
--danger-text: #ff9485;
--danger-bg: #3d1f1f;
--danger-line: #6b3030;
} }
body { body {
@ -36,62 +94,14 @@ body {
color: var(--text-primary); color: var(--text-primary);
} }
.dark .bg-white { background-color: var(--bg-secondary); } /* Bare border utilities have no colour of their own */
.dark .bg-gray-50 { background-color: var(--bg-tertiary); } .border, .border-t, .border-b, .border-l, .border-r { border-color: var(--border-color); }
.dark .bg-gray-100 { background-color: var(--bg-tertiary); }
.dark .bg-gray-200 { background-color: var(--bg-tertiary); }
.dark .bg-gray-300 { background-color: var(--bg-tertiary); }
.dark .text-gray-300 { color: var(--text-muted); }
.dark .text-gray-400 { color: var(--text-muted); }
.dark .text-gray-500 { color: var(--text-muted); }
.dark .text-gray-600 { color: var(--text-secondary); }
.dark .text-gray-700 { color: var(--text-secondary); }
.dark .text-gray-800 { color: var(--text-primary); }
.dark .text-gray-900 { color: var(--text-primary); }
.dark .bg-blue-50 { background-color: var(--accent-bg); }
.dark .bg-blue-100 { background-color: var(--accent-bg); }
.dark .text-blue-400 { color: var(--accent-text); }
.dark .text-blue-600 { color: var(--accent-text); }
.dark .text-blue-700 { color: var(--accent-text); }
.dark .text-blue-800 { color: var(--accent-text); }
.dark .border-blue-200 { border-color: var(--accent-text); }
.dark .border-blue-300 { border-color: var(--accent-text); }
.dark .hover\:bg-blue-50:hover { background-color: var(--accent-bg); }
/* Status accents — desaturated to sit on Pro Dark's neutral background */ /* Form controls don't inherit the surface token on their own */
.dark .bg-green-50 { background-color: #1a3d2c; } input, select, textarea {
.dark .text-green-600 { color: #6ee7b7; } background-color: var(--bg-secondary);
.dark .text-green-700 { color: #6ee7b7; } color: var(--text-primary);
.dark .text-green-400 { color: #6ee7b7; } border-color: var(--border-color);
.dark .bg-amber-50 { background-color: #3a2e14; } }
.dark .text-amber-800 { color: #f5c66f; }
.dark .border-amber-200 { border-color: #5a4a26; } ::selection { background-color: var(--accent-bg); color: var(--text-primary); }
.dark .bg-amber-200 { background-color: #5a4a26; }
.dark .hover\:bg-amber-300:hover { background-color: #6b5830; }
.dark .bg-red-50 { background-color: #3d1f1f; }
.dark .text-red-500 { color: #ff9485; }
.dark .text-red-700 { color: #ff9485; }
.dark .border-gray-100 { border-color: var(--border-light); }
.dark .border-gray-200 { border-color: var(--border-color); }
.dark .border-gray-300 { border-color: var(--border-color); }
.dark .border-blue-100 { border-color: var(--border-color); }
.dark .border-b { border-color: var(--border-color); }
.dark .border-t { border-color: var(--border-color); }
.dark .border-r { border-color: var(--border-color); }
.dark .border-l { border-color: var(--border-color); }
.dark .hover\:bg-gray-50:hover { background-color: var(--bg-tertiary); }
.dark .hover\:bg-gray-100:hover { background-color: var(--bg-tertiary); }
.dark .hover\:bg-gray-200:hover { background-color: var(--bg-tertiary); }
.dark .hover\:text-gray-500:hover { color: var(--text-secondary); }
.dark .hover\:text-gray-600:hover { color: var(--text-secondary); }
.dark .hover\:text-gray-700:hover { color: var(--text-primary); }
.dark .hover\:text-gray-800:hover { color: var(--text-primary); }
.dark .hover\:border-gray-300:hover { border-color: var(--border-color); }
.dark .hover\:border-gray-400:hover { border-color: var(--border-color); }
.dark .focus\:border-gray-300:focus { border-color: var(--border-color); }
.dark .focus\:border-blue-400:focus { border-color: var(--accent-text); }
.dark ::selection { background-color: var(--accent-bg); color: var(--text-primary); }
.dark input { background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border-color); }
.dark select { background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border-color); }
.dark textarea { background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border-color); }
.dark .bg-transparent { background-color: transparent; }

View File

@ -3,6 +3,19 @@ import { Link } from 'react-router-dom'
import { api } from '../api' import { api } from '../api'
import Section from '../components/Section.jsx' import Section from '../components/Section.jsx'
// Accounting style: aligned to 2 decimals, negatives in parentheses
function money(value) {
const n = parseFloat(value)
if (!isFinite(n)) return '—'
const abs = Math.abs(n).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
return n < 0 ? `(${abs})` : abs
}
// SimpleFIN doesn't report an account type, so retirement accounts are
// recognised from the institution and account names
const RETIREMENT_RE = /401\(?k\)?|403\(?b\)?|\bira\b|retirement|pension|profit sharing/i
const isRetirement = (a) => RETIREMENT_RE.test(`${a.name} ${a.organization || ''}`)
// One SimpleFIN bridge covers every linked bank account, so connection state is // One SimpleFIN bridge covers every linked bank account, so connection state is
// a bridge-level concern rather than something to hunt for source by source. // a bridge-level concern rather than something to hunt for source by source.
export default function Bridge({ sources }) { export default function Bridge({ sources }) {
@ -29,16 +42,20 @@ export default function Bridge({ sources }) {
const sourceFor = (accountId) => const sourceFor = (accountId) =>
sources.find(s => s.config?.simplefin?.account_id === accountId) sources.find(s => s.config?.simplefin?.account_id === accountId)
const total = (accounts || []).reduce((sum, a) => sum + (parseFloat(a.balance) || 0), 0) const sum = (list) => list.reduce((t, a) => t + (parseFloat(a.balance) || 0), 0)
const banking = (accounts || []).filter(a => !isRetirement(a))
const retirement = (accounts || []).filter(isRetirement)
// Banking first, then retirement, so each subtotal sits under its own rows
const ordered = [...banking, ...retirement]
return ( return (
<div className="p-6 max-w-5xl space-y-4"> <div className="p-6 max-w-5xl space-y-4">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<h1 className="text-xl font-semibold text-gray-800">Bridge</h1> <h1 className="text-xl font-semibold text-ink">Bridge</h1>
<button <button
onClick={load} onClick={load}
disabled={loading} disabled={loading}
className="text-sm border border-gray-300 rounded px-3 py-1.5 text-gray-600 hover:bg-gray-50 hover:border-gray-400 disabled:opacity-50" className="text-sm border border-line rounded px-3 py-1.5 text-ink-soft hover:bg-raised hover:border-line disabled:opacity-50"
> >
{loading ? 'Refreshing…' : 'Refresh'} {loading ? 'Refreshing…' : 'Refresh'}
</button> </button>
@ -46,18 +63,18 @@ export default function Bridge({ sources }) {
{error && ( {error && (
<Section title="Not connected" description="Claim a setup token with manage.py option 10, then restart the service."> <Section title="Not connected" description="Claim a setup token with manage.py option 10, then restart the service.">
<p className="text-xs text-red-500">{error}</p> <p className="text-xs text-danger">{error}</p>
</Section> </Section>
)} )}
{errors.length > 0 && ( {errors.length > 0 && (
<div className="bg-orange-50 border border-orange-200 rounded p-3 text-xs text-orange-700 space-y-1"> <div className="bg-warn-soft border border-warn-line rounded p-3 text-xs text-warn space-y-1">
{errors.map((e, i) => <div key={i}>{e}</div>)} {errors.map((e, i) => <div key={i}>{e}</div>)}
</div> </div>
)} )}
{!accounts && !loading && !error && ( {!accounts && !loading && !error && (
<p className="text-sm text-gray-400">Click Refresh to load balances from SimpleFIN.</p> <p className="text-sm text-muted">Click Refresh to load balances from SimpleFIN.</p>
)} )}
{accounts && ( {accounts && (
@ -67,7 +84,7 @@ export default function Bridge({ sources }) {
> >
<table className="w-full text-xs"> <table className="w-full text-xs">
<thead> <thead>
<tr className="text-left text-gray-400 border-b border-gray-100"> <tr className="text-left text-muted border-b border-line-soft">
<th className="pb-1 font-medium">Account</th> <th className="pb-1 font-medium">Account</th>
<th className="pb-1 font-medium">Institution</th> <th className="pb-1 font-medium">Institution</th>
<th className="pb-1 font-medium text-right">Balance</th> <th className="pb-1 font-medium text-right">Balance</th>
@ -76,34 +93,46 @@ export default function Bridge({ sources }) {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{accounts.map(a => { {ordered.map(a => {
const src = sourceFor(a.id) const src = sourceFor(a.id)
return ( return (
<tr key={a.id} className="border-t border-gray-50"> <tr key={a.id} className="border-t border-line-soft">
<td className="py-1.5 text-gray-700">{a.name}</td> <td className="py-1.5 text-ink-soft">{a.name}</td>
<td className="py-1.5 text-gray-500">{a.organization}</td> <td className="py-1.5 text-muted">{a.organization}</td>
<td className="py-1.5 text-right font-mono text-gray-700">{a.balance}</td> <td className="py-1.5 text-right font-mono text-ink-soft">{money(a.balance)}</td>
<td className="py-1.5 pl-4 text-gray-400">{a.balance_date}</td> <td className="py-1.5 pl-4 text-muted">{a.balance_date}</td>
<td className="py-1.5"> <td className="py-1.5">
{src {src
? <Link to={`/sources/${encodeURIComponent(src.name)}`} className="text-blue-500 hover:text-blue-700">{src.name}</Link> ? <Link to={`/sources/${encodeURIComponent(src.name)}`} className="text-accent hover:text-accent">{src.name}</Link>
: <span className="text-gray-300">not linked</span>} : <span className="text-muted">not linked</span>}
</td> </td>
</tr> </tr>
) )
})} })}
</tbody> </tbody>
<tfoot> <tfoot>
<tr className="border-t border-gray-200"> {banking.length > 0 && (
<td className="pt-2 text-gray-600 font-medium" colSpan={2}>Total</td> <tr className="border-t border-line">
<td className="pt-2 text-right font-mono font-medium text-gray-800"> <td className="pt-2 text-muted" colSpan={2}>Banking and cards</td>
{total.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} <td className="pt-2 text-right font-mono text-ink-soft">{money(sum(banking))}</td>
</td> <td colSpan={2}></td>
</tr>
)}
{retirement.length > 0 && (
<tr>
<td className="pt-1 text-muted" colSpan={2}>Retirement</td>
<td className="pt-1 text-right font-mono text-ink-soft">{money(sum(retirement))}</td>
<td colSpan={2}></td>
</tr>
)}
<tr className="border-t border-line">
<td className="pt-2 text-ink-soft font-medium" colSpan={2}>Total</td>
<td className="pt-2 text-right font-mono font-medium text-ink">{money(sum(accounts))}</td>
<td colSpan={2}></td> <td colSpan={2}></td>
</tr> </tr>
</tfoot> </tfoot>
</table> </table>
{accounts.length === 0 && <p className="text-xs text-gray-400">No accounts returned.</p>} {accounts.length === 0 && <p className="text-xs text-muted">No accounts returned.</p>}
</Section> </Section>
)} )}
</div> </div>

View File

@ -6,7 +6,7 @@ function KeyList({ keys, label, color }) {
return ( return (
<div className="mb-2"> <div className="mb-2">
<div className={`text-xs font-medium mb-1 ${color}`}>{label} ({keys.length})</div> <div className={`text-xs font-medium mb-1 ${color}`}>{label} ({keys.length})</div>
<div className="max-h-32 overflow-y-auto bg-gray-50 rounded p-2 font-mono text-xs text-gray-500 space-y-0.5"> <div className="max-h-32 overflow-y-auto bg-raised rounded p-2 font-mono text-xs text-muted space-y-0.5">
{keys.map((k, i) => ( {keys.map((k, i) => (
<div key={i}> <div key={i}>
{typeof k === 'object' && k !== null {typeof k === 'object' && k !== null
@ -28,19 +28,19 @@ function LogRow({ entry, selected, onToggle }) {
return ( return (
<> <>
<tr className={`border-b border-gray-50 ${selected ? 'bg-red-50' : ''}`}> <tr className={`border-b border-line-soft ${selected ? 'bg-danger-soft' : ''}`}>
<td className="py-1.5 pr-2"> <td className="py-1.5 pr-2">
<input type="checkbox" checked={selected} onChange={onToggle} className="cursor-pointer" /> <input type="checkbox" checked={selected} onChange={onToggle} className="cursor-pointer" />
</td> </td>
<td className="py-1.5 text-xs text-gray-400 font-mono">{entry.id}</td> <td className="py-1.5 text-xs text-muted font-mono">{entry.id}</td>
<td className="py-1.5 text-gray-500">{new Date(entry.imported_at).toLocaleString()}</td> <td className="py-1.5 text-muted">{new Date(entry.imported_at).toLocaleString()}</td>
<td className="py-1.5 text-gray-800">{entry.records_imported}</td> <td className="py-1.5 text-ink">{entry.records_imported}</td>
<td className="py-1.5 text-gray-400">{entry.records_duplicate}</td> <td className="py-1.5 text-muted">{entry.records_duplicate}</td>
<td className="py-1.5"> <td className="py-1.5">
{hasKeys && ( {hasKeys && (
<button <button
onClick={() => setExpanded(e => !e)} onClick={() => setExpanded(e => !e)}
className="text-xs text-blue-400 hover:text-blue-600" className="text-xs text-accent hover:text-accent"
> >
{expanded ? '▲ hide' : '▼ keys'} {expanded ? '▲ hide' : '▼ keys'}
</button> </button>
@ -48,10 +48,10 @@ function LogRow({ entry, selected, onToggle }) {
</td> </td>
</tr> </tr>
{expanded && ( {expanded && (
<tr className={selected ? 'bg-red-50' : 'bg-gray-50'}> <tr className={selected ? 'bg-danger-soft' : 'bg-raised'}>
<td colSpan={6} className="px-4 py-3"> <td colSpan={6} className="px-4 py-3">
<KeyList keys={insertedKeys} label="Inserted" color="text-green-600" /> <KeyList keys={insertedKeys} label="Inserted" color="text-ok" />
<KeyList keys={excludedKeys} label="Excluded" color="text-gray-500" /> <KeyList keys={excludedKeys} label="Excluded" color="text-muted" />
</td> </td>
</tr> </tr>
)} )}
@ -168,11 +168,11 @@ export default function Import({ source }) {
} }
} }
if (!source) return <div className="p-6 text-sm text-gray-400">Select a source first.</div> if (!source) return <div className="p-6 text-sm text-muted">Select a source first.</div>
return ( return (
<div className="p-6 max-w-2xl"> <div className="p-6 max-w-2xl">
<h1 className="text-xl font-semibold text-gray-800 mb-6">Import {source}</h1> <h1 className="text-xl font-semibold text-ink mb-6">Import {source}</h1>
{/* Stats */} {/* Stats */}
{stats && ( {stats && (
@ -182,9 +182,9 @@ export default function Import({ source }) {
{ label: 'Transformed', value: stats.transformed_records }, { label: 'Transformed', value: stats.transformed_records },
{ label: 'Pending', value: stats.pending_records }, { label: 'Pending', value: stats.pending_records },
].map(({ label, value }) => ( ].map(({ label, value }) => (
<div key={label} className="bg-white border border-gray-200 rounded px-4 py-3 flex-1 text-center"> <div key={label} className="bg-surface border border-line rounded px-4 py-3 flex-1 text-center">
<div className="text-2xl font-semibold text-gray-800">{value}</div> <div className="text-2xl font-semibold text-ink">{value}</div>
<div className="text-xs text-gray-400 mt-0.5">{label}</div> <div className="text-xs text-muted mt-0.5">{label}</div>
</div> </div>
))} ))}
</div> </div>
@ -192,15 +192,15 @@ export default function Import({ source }) {
{/* SimpleFIN sync — only for sources with a bridge account in their config */} {/* SimpleFIN sync — only for sources with a bridge account in their config */}
{simplefin?.account_id && ( {simplefin?.account_id && (
<div className="bg-white border border-gray-200 rounded p-4 mb-4 flex items-center gap-3"> <div className="bg-surface border border-line rounded p-4 mb-4 flex items-center gap-3">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-700">SimpleFIN</div> <div className="text-sm font-medium text-ink-soft">SimpleFIN</div>
<div className="text-xs text-gray-400 font-mono truncate">{simplefin.account_id}</div> <div className="text-xs text-muted font-mono truncate">{simplefin.account_id}</div>
</div> </div>
<select <select
value={days} value={days}
onChange={e => setDays(e.target.value)} onChange={e => setDays(e.target.value)}
className="text-sm border border-gray-200 rounded px-2 py-1.5 bg-white text-gray-700" className="text-sm border border-line rounded px-2 py-1.5 bg-surface text-ink-soft"
> >
<option value="10">Last 10 days</option> <option value="10">Last 10 days</option>
<option value="30">Last 30 days</option> <option value="30">Last 30 days</option>
@ -217,7 +217,7 @@ export default function Import({ source }) {
{/* Drop zone */} {/* Drop zone */}
<div <div
className={`border-2 border-dashed rounded-lg p-8 text-center mb-4 cursor-pointer transition-colors ${ className={`border-2 border-dashed rounded-lg p-8 text-center mb-4 cursor-pointer transition-colors ${
dragOver ? 'border-blue-400 bg-blue-50' : 'border-gray-200 hover:border-gray-300' dragOver ? 'border-accent bg-accent-soft' : 'border-line hover:border-line'
}`} }`}
onDragOver={e => { e.preventDefault(); setDragOver(true) }} onDragOver={e => { e.preventDefault(); setDragOver(true) }}
onDragLeave={() => setDragOver(false)} onDragLeave={() => setDragOver(false)}
@ -232,22 +232,22 @@ export default function Import({ source }) {
onChange={e => handleImport(e.target.files[0])} onChange={e => handleImport(e.target.files[0])}
/> />
{loading {loading
? <p className="text-sm text-gray-500">Importing</p> ? <p className="text-sm text-muted">Importing</p>
: <p className="text-sm text-gray-400">Drop a CSV file here, or click to browse</p> : <p className="text-sm text-muted">Drop a CSV file here, or click to browse</p>
} }
</div> </div>
{error && <p className="text-sm text-red-500 mb-3">{error}</p>} {error && <p className="text-sm text-danger mb-3">{error}</p>}
{result && ( {result && (
<div className={`border rounded p-4 mb-4 text-sm ${result.success === false ? 'bg-red-50 border-red-200' : 'bg-white border-gray-200'}`}> <div className={`border rounded p-4 mb-4 text-sm ${result.success === false ? 'bg-danger-soft border-danger-line' : 'bg-surface border-line'}`}>
{result.success === false ? ( {result.success === false ? (
<> <>
<p className="text-red-600 font-medium mb-2">{result.error}</p> <p className="text-danger font-medium mb-2">{result.error}</p>
{result.duplicate_rows && ( {result.duplicate_rows && (
<div> <div>
<p className="text-xs text-red-500 mb-1">Offending rows:</p> <p className="text-xs text-danger mb-1">Offending rows:</p>
<div className="max-h-48 overflow-y-auto bg-white rounded border border-red-100 p-2 font-mono text-xs text-red-700 space-y-0.5"> <div className="max-h-48 overflow-y-auto bg-surface rounded border border-danger-line p-2 font-mono text-xs text-danger space-y-0.5">
{result.duplicate_rows.map((row, i) => ( {result.duplicate_rows.map((row, i) => (
<div key={i}> <div key={i}>
{Object.entries(row).map(([f, v]) => `${f}: ${v}`).join(' · ')} {Object.entries(row).map(([f, v]) => `${f}: ${v}`).join(' · ')}
@ -260,28 +260,28 @@ export default function Import({ source }) {
) : result.imported !== undefined ? ( ) : result.imported !== undefined ? (
<> <>
{result.errors?.length > 0 && ( {result.errors?.length > 0 && (
<div className="mb-2 text-xs text-orange-600"> <div className="mb-2 text-xs text-warn">
{result.errors.map((e, i) => <div key={i}>Bridge: {e}</div>)} {result.errors.map((e, i) => <div key={i}>Bridge: {e}</div>)}
</div> </div>
)} )}
{result.fetched !== undefined && ( {result.fetched !== undefined && (
<> <>
<span className="text-gray-500">{result.fetched} fetched</span> <span className="text-muted">{result.fetched} fetched</span>
<span className="text-gray-400 mx-2">·</span> <span className="text-muted mx-2">·</span>
</> </>
)} )}
<span className="text-green-600 font-medium">{result.imported} imported</span> <span className="text-ok font-medium">{result.imported} imported</span>
<span className="text-gray-400 mx-2">·</span> <span className="text-muted mx-2">·</span>
<span className="text-gray-500">{result.duplicates} duplicates skipped</span> <span className="text-muted">{result.duplicates} duplicates skipped</span>
{result.transform && ( {result.transform && (
<> <>
<span className="text-gray-400 mx-2">·</span> <span className="text-muted mx-2">·</span>
<span className="text-gray-500">{result.transform.transformed} transformed</span> <span className="text-muted">{result.transform.transformed} transformed</span>
</> </>
)} )}
</> </>
) : ( ) : (
<span className="text-green-600 font-medium">{result.transformed} records transformed</span> <span className="text-ok font-medium">{result.transformed} records transformed</span>
)} )}
</div> </div>
)} )}
@ -306,7 +306,7 @@ export default function Import({ source }) {
{log.length > 0 && ( {log.length > 0 && (
<div> <div>
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<h2 className="text-sm font-semibold text-gray-700">Import history</h2> <h2 className="text-sm font-semibold text-ink-soft">Import history</h2>
{selected.size > 0 && ( {selected.size > 0 && (
<button <button
onClick={handleDeleteSelected} onClick={handleDeleteSelected}
@ -319,7 +319,7 @@ export default function Import({ source }) {
</div> </div>
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="text-left text-xs text-gray-400 border-b border-gray-100"> <tr className="text-left text-xs text-muted border-b border-line-soft">
<th className="pb-1 w-6"></th> <th className="pb-1 w-6"></th>
<th className="pb-1 font-medium w-12">ID</th> <th className="pb-1 font-medium w-12">ID</th>
<th className="pb-1 font-medium">Date</th> <th className="pb-1 font-medium">Date</th>

View File

@ -65,18 +65,18 @@ export default function ImportHub({ sources }) {
<div className="px-4 py-3 flex items-center gap-3 flex-wrap"> <div className="px-4 py-3 flex items-center gap-3 flex-wrap">
<div className="flex-1 min-w-40"> <div className="flex-1 min-w-40">
<Link to={`/sources/${encodeURIComponent(s.name)}/import`} <Link to={`/sources/${encodeURIComponent(s.name)}/import`}
className="text-sm font-medium text-gray-800 hover:text-blue-600"> className="text-sm font-medium text-ink hover:text-accent">
{s.name} {s.name}
</Link> </Link>
<div className="text-xs text-gray-400"> <div className="text-xs text-muted">
{st ? `${st.total_records} records` : '—'} {st ? `${st.total_records} records` : '—'}
{st && Number(st.pending_records) > 0 && ` · ${st.pending_records} untransformed`} {st && Number(st.pending_records) > 0 && ` · ${st.pending_records} untransformed`}
{when && ` · last import ${new Date(when).toLocaleDateString()}`} {when && ` · last import ${new Date(when).toLocaleDateString()}`}
</div> </div>
</div> </div>
{results[s.name] && <span className="text-xs text-green-600">{results[s.name]}</span>} {results[s.name] && <span className="text-xs text-ok">{results[s.name]}</span>}
{errors[s.name] && <span className="text-xs text-red-500">{errors[s.name]}</span>} {errors[s.name] && <span className="text-xs text-danger">{errors[s.name]}</span>}
{isFeed ? ( {isFeed ? (
<button <button
@ -88,7 +88,7 @@ export default function ImportHub({ sources }) {
</button> </button>
) : ( ) : (
<Link to={`/sources/${encodeURIComponent(s.name)}/import`} <Link to={`/sources/${encodeURIComponent(s.name)}/import`}
className="text-sm border border-gray-300 rounded px-3 py-1.5 text-gray-600 hover:bg-gray-50 hover:border-gray-400"> className="text-sm border border-line rounded px-3 py-1.5 text-ink-soft hover:bg-raised hover:border-line">
Upload CSV Upload CSV
</Link> </Link>
)} )}
@ -98,30 +98,30 @@ export default function ImportHub({ sources }) {
return ( return (
<div className="p-6 max-w-4xl space-y-6"> <div className="p-6 max-w-4xl space-y-6">
<h1 className="text-xl font-semibold text-gray-800">Import</h1> <h1 className="text-xl font-semibold text-ink">Import</h1>
{feeds.length > 0 && ( {feeds.length > 0 && (
<div> <div>
<h2 className="text-sm font-semibold text-gray-700 mb-2">Bank feeds</h2> <h2 className="text-sm font-semibold text-ink-soft mb-2">Bank feeds</h2>
<div className="bg-white border border-gray-200 rounded divide-y divide-gray-100"> <div className="bg-surface border border-line rounded divide-y divide-line-soft">
{feeds.map(s => <Row key={s.name} s={s} isFeed />)} {feeds.map(s => <Row key={s.name} s={s} isFeed />)}
</div> </div>
<p className="text-xs text-gray-400 mt-1">Syncs pull the last 10 days; use a source&rsquo;s Import tab to backfill further.</p> <p className="text-xs text-muted mt-1">Syncs pull the last 10 days; use a source&rsquo;s Import tab to backfill further.</p>
</div> </div>
)} )}
{manual.length > 0 && ( {manual.length > 0 && (
<div> <div>
<h2 className="text-sm font-semibold text-gray-700 mb-2">CSV sources</h2> <h2 className="text-sm font-semibold text-ink-soft mb-2">CSV sources</h2>
<div className="bg-white border border-gray-200 rounded divide-y divide-gray-100"> <div className="bg-surface border border-line rounded divide-y divide-line-soft">
{manual.map(s => <Row key={s.name} s={s} isFeed={false} />)} {manual.map(s => <Row key={s.name} s={s} isFeed={false} />)}
</div> </div>
</div> </div>
)} )}
{sources.length === 0 && <p className="text-sm text-gray-400">No sources yet.</p>} {sources.length === 0 && <p className="text-sm text-muted">No sources yet.</p>}
<Link to="/log" className="inline-block text-xs text-blue-500 hover:text-blue-700"> <Link to="/log" className="inline-block text-xs text-accent hover:text-accent">
Full import history Full import history
</Link> </Link>
</div> </div>

View File

@ -6,7 +6,7 @@ function KeyList({ keys, label, color }) {
return ( return (
<div className="mb-2"> <div className="mb-2">
<div className={`text-xs font-medium mb-1 ${color}`}>{label} ({keys.length})</div> <div className={`text-xs font-medium mb-1 ${color}`}>{label} ({keys.length})</div>
<div className="max-h-32 overflow-y-auto bg-gray-50 rounded p-2 font-mono text-xs text-gray-500 space-y-0.5"> <div className="max-h-32 overflow-y-auto bg-raised rounded p-2 font-mono text-xs text-muted space-y-0.5">
{keys.map((k, i) => ( {keys.map((k, i) => (
<div key={i}> <div key={i}>
{typeof k === 'object' && k !== null {typeof k === 'object' && k !== null
@ -28,17 +28,17 @@ function LogRow({ entry }) {
return ( return (
<> <>
<tr className="border-b border-gray-50 hover:bg-gray-50"> <tr className="border-b border-line-soft hover:bg-raised">
<td className="py-1.5 text-xs text-gray-400 font-mono pr-3">{entry.id}</td> <td className="py-1.5 text-xs text-muted font-mono pr-3">{entry.id}</td>
<td className="py-1.5 text-gray-700 pr-3">{entry.source_name}</td> <td className="py-1.5 text-ink-soft pr-3">{entry.source_name}</td>
<td className="py-1.5 text-gray-500 pr-3">{new Date(entry.imported_at).toLocaleString()}</td> <td className="py-1.5 text-muted pr-3">{new Date(entry.imported_at).toLocaleString()}</td>
<td className="py-1.5 text-gray-800 pr-3">{entry.records_imported}</td> <td className="py-1.5 text-ink pr-3">{entry.records_imported}</td>
<td className="py-1.5 text-gray-400 pr-3">{entry.records_duplicate}</td> <td className="py-1.5 text-muted pr-3">{entry.records_duplicate}</td>
<td className="py-1.5"> <td className="py-1.5">
{hasKeys && ( {hasKeys && (
<button <button
onClick={() => setExpanded(e => !e)} onClick={() => setExpanded(e => !e)}
className="text-xs text-blue-400 hover:text-blue-600" className="text-xs text-accent hover:text-accent"
> >
{expanded ? '▲ hide' : '▼ keys'} {expanded ? '▲ hide' : '▼ keys'}
</button> </button>
@ -46,10 +46,10 @@ function LogRow({ entry }) {
</td> </td>
</tr> </tr>
{expanded && ( {expanded && (
<tr className="bg-gray-50"> <tr className="bg-raised">
<td colSpan={6} className="px-4 py-3"> <td colSpan={6} className="px-4 py-3">
<KeyList keys={insertedKeys} label="Inserted" color="text-green-600" /> <KeyList keys={insertedKeys} label="Inserted" color="text-ok" />
<KeyList keys={excludedKeys} label="Excluded" color="text-gray-500" /> <KeyList keys={excludedKeys} label="Excluded" color="text-muted" />
</td> </td>
</tr> </tr>
)} )}
@ -70,18 +70,18 @@ export default function Log() {
return ( return (
<div className="p-6"> <div className="p-6">
<h1 className="text-xl font-semibold text-gray-800 mb-6">Import Log</h1> <h1 className="text-xl font-semibold text-ink mb-6">Import Log</h1>
{loading && <p className="text-sm text-gray-400">Loading</p>} {loading && <p className="text-sm text-muted">Loading</p>}
{!loading && log.length === 0 && ( {!loading && log.length === 0 && (
<p className="text-sm text-gray-400">No imports yet.</p> <p className="text-sm text-muted">No imports yet.</p>
)} )}
{log.length > 0 && ( {log.length > 0 && (
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="text-left text-xs text-gray-400 border-b border-gray-100"> <tr className="text-left text-xs text-muted border-b border-line-soft">
<th className="pb-1 font-medium pr-3">ID</th> <th className="pb-1 font-medium pr-3">ID</th>
<th className="pb-1 font-medium pr-3">Source</th> <th className="pb-1 font-medium pr-3">Source</th>
<th className="pb-1 font-medium pr-3">Date</th> <th className="pb-1 font-medium pr-3">Date</th>

View File

@ -20,32 +20,32 @@ export default function Login({ onLogin }) {
} }
return ( return (
<div className="flex items-center justify-center h-screen bg-gray-50"> <div className="flex items-center justify-center h-screen bg-raised">
<div className="bg-white border border-gray-200 rounded-lg p-8 w-80 shadow-sm"> <div className="bg-surface border border-line rounded-lg p-8 w-80 shadow-sm">
<h1 className="text-lg font-semibold text-gray-800 mb-6">Dataflow</h1> <h1 className="text-lg font-semibold text-ink mb-6">Dataflow</h1>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label className="block text-xs text-gray-500 mb-1">Username</label> <label className="block text-xs text-muted mb-1">Username</label>
<input <input
type="text" type="text"
autoFocus autoFocus
value={user} value={user}
onChange={e => setUser(e.target.value)} onChange={e => setUser(e.target.value)}
className="w-full border border-gray-200 rounded px-3 py-2 text-sm focus:outline-none focus:border-blue-400" className="w-full border border-line rounded px-3 py-2 text-sm focus:outline-none focus:border-accent"
required required
/> />
</div> </div>
<div> <div>
<label className="block text-xs text-gray-500 mb-1">Password</label> <label className="block text-xs text-muted mb-1">Password</label>
<input <input
type="password" type="password"
value={pass} value={pass}
onChange={e => setPass(e.target.value)} onChange={e => setPass(e.target.value)}
className="w-full border border-gray-200 rounded px-3 py-2 text-sm focus:outline-none focus:border-blue-400" className="w-full border border-line rounded px-3 py-2 text-sm focus:outline-none focus:border-accent"
required required
/> />
</div> </div>
{error && <p className="text-xs text-red-500">{error}</p>} {error && <p className="text-xs text-danger">{error}</p>}
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading}

View File

@ -68,13 +68,13 @@ function AutocompleteInput({ value, onChange, onEnter, suggestions = [], classNa
<div <div
ref={listRef} ref={listRef}
style={{ position: 'fixed', top: dropPos.top, left: dropPos.left, minWidth: dropPos.minWidth, zIndex: 9999 }} style={{ position: 'fixed', top: dropPos.top, left: dropPos.left, minWidth: dropPos.minWidth, zIndex: 9999 }}
className="bg-white border border-gray-200 rounded shadow-lg max-h-48 overflow-y-auto" className="bg-surface border border-line rounded shadow-lg max-h-48 overflow-y-auto"
> >
{filtered.map((s, i) => ( {filtered.map((s, i) => (
<div <div
key={s} key={s}
className={`px-2 py-1 text-xs cursor-pointer whitespace-nowrap ${ className={`px-2 py-1 text-xs cursor-pointer whitespace-nowrap ${
i === highlighted ? 'bg-blue-50 text-blue-700' : 'text-gray-700 hover:bg-gray-50' i === highlighted ? 'bg-accent-soft text-accent' : 'text-ink-soft hover:bg-raised'
}`} }`}
onMouseDown={e => { e.preventDefault(); select(s) }} onMouseDown={e => { e.preventDefault(); select(s) }}
> >
@ -100,11 +100,11 @@ function SortHeader({ col, label, sortBy, onSort, className = '' }) {
const active = sortBy?.col === col const active = sortBy?.col === col
return ( return (
<th <th
className={`px-3 py-2 font-medium cursor-pointer select-none hover:text-gray-600 ${className}`} className={`px-3 py-2 font-medium cursor-pointer select-none hover:text-ink-soft ${className}`}
onClick={() => onSort(col)} onClick={() => onSort(col)}
> >
{label} {label}
<span className="ml-1 text-gray-300">{active ? (sortBy.dir === 'asc' ? '↑' : '↓') : '↕'}</span> <span className="ml-1 text-muted">{active ? (sortBy.dir === 'asc' ? '↑' : '↓') : '↕'}</span>
</th> </th>
) )
} }
@ -354,18 +354,18 @@ export default function Mappings({ source, onNeedsReprocess }) {
} }
} }
if (!source) return <div className="p-6 text-sm text-gray-400">Select a source first.</div> if (!source) return <div className="p-6 text-sm text-muted">Select a source first.</div>
const displayRows = sortedRows(filteredRows) const displayRows = sortedRows(filteredRows)
return ( return (
<div> <div>
{/* Sticky control bar */} {/* Sticky control bar */}
<div className="sticky top-0 z-10 bg-white border-b border-gray-200 px-6 py-3 flex items-center gap-3 flex-wrap"> <div className="sticky top-0 z-10 bg-surface border-b border-line px-6 py-3 flex items-center gap-3 flex-wrap">
<span className="text-sm font-medium text-gray-700">{source}</span> <span className="text-sm font-medium text-ink-soft">{source}</span>
<select <select
className="text-sm border border-gray-200 rounded px-2 py-1.5 focus:outline-none focus:border-blue-400" className="text-sm border border-line rounded px-2 py-1.5 focus:outline-none focus:border-accent"
value={selectedRule} value={selectedRule}
onChange={e => setSelectedRule(e.target.value)} onChange={e => setSelectedRule(e.target.value)}
> >
@ -374,7 +374,7 @@ export default function Mappings({ source, onNeedsReprocess }) {
</select> </select>
{selectedRule && ( {selectedRule && (
<div className="flex bg-gray-100 rounded p-0.5"> <div className="flex bg-raised rounded p-0.5">
{[ {[
{ key: 'all', label: `All (${allValues.length})` }, { key: 'all', label: `All (${allValues.length})` },
{ key: 'unmapped', label: `Unmapped (${unmappedCount})` }, { key: 'unmapped', label: `Unmapped (${unmappedCount})` },
@ -382,7 +382,7 @@ export default function Mappings({ source, onNeedsReprocess }) {
].map(({ key, label }) => ( ].map(({ key, label }) => (
<button key={key} onClick={() => setFilter(key)} <button key={key} onClick={() => setFilter(key)}
className={`text-xs px-3 py-1 rounded transition-colors ${ className={`text-xs px-3 py-1 rounded transition-colors ${
filter === key ? 'bg-white text-gray-800 shadow-sm' : 'text-gray-500' filter === key ? 'bg-surface text-ink shadow-sm' : 'text-muted'
}`}> }`}>
{label} {label}
</button> </button>
@ -393,15 +393,15 @@ export default function Mappings({ source, onNeedsReprocess }) {
{selectedRule && ( {selectedRule && (
<div className="relative"> <div className="relative">
<input <input
className={`text-xs font-mono border rounded px-2 py-1.5 w-44 focus:outline-none focus:border-blue-400 ${ className={`text-xs font-mono border rounded px-2 py-1.5 w-44 focus:outline-none focus:border-accent ${
rowFilterError ? 'border-red-400 bg-red-50' : rowFilter ? 'border-blue-300' : 'border-gray-200' rowFilterError ? 'border-danger-line bg-danger-soft' : rowFilter ? 'border-accent-line' : 'border-line'
}`} }`}
placeholder="filter regex…" placeholder="filter regex…"
value={rowFilter} value={rowFilter}
onChange={e => setRowFilter(e.target.value)} onChange={e => setRowFilter(e.target.value)}
/> />
{rowFilter && !rowFilterError && ( {rowFilter && !rowFilterError && (
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-gray-400"> <span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-muted">
{filteredRows.length} {filteredRows.length}
</span> </span>
)} )}
@ -435,12 +435,12 @@ export default function Mappings({ source, onNeedsReprocess }) {
alert(err.message) alert(err.message)
} }
}} }}
className="text-sm px-3 py-1.5 border border-gray-200 rounded hover:bg-gray-50 text-gray-600" className="text-sm px-3 py-1.5 border border-line rounded hover:bg-raised text-ink-soft"
> >
Export TSV Export TSV
</button> </button>
)} )}
<label className={`text-sm px-3 py-1.5 border border-gray-200 rounded cursor-pointer hover:bg-gray-50 text-gray-600 ${importing ? 'opacity-50 pointer-events-none' : ''}`}> <label className={`text-sm px-3 py-1.5 border border-line rounded cursor-pointer hover:bg-raised text-ink-soft ${importing ? 'opacity-50 pointer-events-none' : ''}`}>
{importing ? 'Importing…' : 'Import TSV'} {importing ? 'Importing…' : 'Import TSV'}
<input type="file" accept=".tsv,.txt" className="hidden" onChange={handleImportCSV} /> <input type="file" accept=".tsv,.txt" className="hidden" onChange={handleImportCSV} />
</label> </label>
@ -450,24 +450,24 @@ export default function Mappings({ source, onNeedsReprocess }) {
{/* Content */} {/* Content */}
<div className="p-6"> <div className="p-6">
{!selectedRule && ( {!selectedRule && (
<p className="text-sm text-gray-400">Select a rule to view mappings.</p> <p className="text-sm text-muted">Select a rule to view mappings.</p>
)} )}
{selectedRule && loading && ( {selectedRule && loading && (
<p className="text-sm text-gray-400">Loading</p> <p className="text-sm text-muted">Loading</p>
)} )}
{selectedRule && !loading && allValues.length === 0 && ( {selectedRule && !loading && allValues.length === 0 && (
<p className="text-sm text-gray-400">No extracted values for this rule. Run a transform first.</p> <p className="text-sm text-muted">No extracted values for this rule. Run a transform first.</p>
)} )}
{selectedRule && !loading && allValues.length > 0 && ( {selectedRule && !loading && allValues.length > 0 && (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
{/* Bulk assign bar */} {/* Bulk assign bar */}
{selected.size > 0 && ( {selected.size > 0 && (
<div className="flex items-center gap-2 mb-2 p-2 bg-blue-50 border border-blue-200 rounded flex-wrap"> <div className="flex items-center gap-2 mb-2 p-2 bg-accent-soft border border-accent-line rounded flex-wrap">
<span className="text-xs text-blue-700 font-medium whitespace-nowrap">{selected.size} selected</span> <span className="text-xs text-accent font-medium whitespace-nowrap">{selected.size} selected</span>
{cols.map(col => ( {cols.map(col => (
<AutocompleteInput <AutocompleteInput
key={col} key={col}
className="border border-blue-300 rounded px-2 py-1 text-xs min-w-24 focus:outline-none focus:border-blue-500 bg-white" className="border border-accent-line rounded px-2 py-1 text-xs min-w-24 focus:outline-none focus:border-accent bg-surface"
placeholder={col} placeholder={col}
value={bulkDraft[col] || ''} value={bulkDraft[col] || ''}
onChange={v => setBulkDraft(d => ({ ...d, [col]: v }))} onChange={v => setBulkDraft(d => ({ ...d, [col]: v }))}
@ -483,15 +483,15 @@ export default function Mappings({ source, onNeedsReprocess }) {
</button> </button>
<button <button
onClick={() => { setSelected(new Set()); setBulkDraft({}) }} onClick={() => { setSelected(new Set()); setBulkDraft({}) }}
className="text-xs text-blue-400 hover:text-blue-600" className="text-xs text-accent hover:text-accent"
> >
cancel cancel
</button> </button>
</div> </div>
)} )}
<table className="w-full text-xs bg-white border border-gray-200 rounded"> <table className="w-full text-xs bg-surface border border-line rounded">
<thead> <thead>
<tr className="text-left text-gray-400 border-b border-gray-100 bg-gray-50"> <tr className="text-left text-muted border-b border-line-soft bg-raised">
<th className="px-2 py-2 w-6"> <th className="px-2 py-2 w-6">
<input <input
type="checkbox" type="checkbox"
@ -511,7 +511,7 @@ export default function Mappings({ source, onNeedsReprocess }) {
{extraCols.map((col, idx) => ( {extraCols.map((col, idx) => (
<th key={`extra-${idx}`} className="px-3 py-2 font-medium"> <th key={`extra-${idx}`} className="px-3 py-2 font-medium">
<input <input
className="border border-gray-200 rounded px-1 py-0.5 w-24 focus:outline-none focus:border-blue-400 font-normal" className="border border-line rounded px-1 py-0.5 w-24 focus:outline-none focus:border-accent font-normal"
value={col} value={col}
placeholder="new key" placeholder="new key"
onChange={e => setExtraCols(ec => { const c = [...ec]; c[idx] = e.target.value; return c })} onChange={e => setExtraCols(ec => { const c = [...ec]; c[idx] = e.target.value; return c })}
@ -521,7 +521,7 @@ export default function Mappings({ source, onNeedsReprocess }) {
<th className="px-2 py-2"> <th className="px-2 py-2">
<button <button
onClick={() => setExtraCols(ec => [...ec, ''])} onClick={() => setExtraCols(ec => [...ec, ''])}
className="text-gray-400 hover:text-gray-700 font-medium" className="text-muted hover:text-ink-soft font-medium"
title="Add column" title="Add column"
>+</button> >+</button>
</th> </th>
@ -536,7 +536,7 @@ export default function Mappings({ source, onNeedsReprocess }) {
const isSaving = saving[k] const isSaving = saving[k]
const isSelected = selected.has(k) const isSelected = selected.has(k)
const hasDraft = !!(drafts[k] && Object.keys(drafts[k]).length > 0) const hasDraft = !!(drafts[k] && Object.keys(drafts[k]).length > 0)
const rowBg = isSelected ? 'bg-blue-50' : hasDraft ? 'bg-blue-50' : row.is_mapped ? '' : 'bg-yellow-50' const rowBg = isSelected ? 'bg-accent-soft' : hasDraft ? 'bg-accent-soft' : row.is_mapped ? '' : 'bg-warn-soft'
function handleRowClick(e) { function handleRowClick(e) {
if (e.target.closest('input,button,a,select')) return if (e.target.closest('input,button,a,select')) return
@ -571,7 +571,7 @@ export default function Mappings({ source, onNeedsReprocess }) {
key={k} key={k}
ref={el => rowRefs.current[k] = el} ref={el => rowRefs.current[k] = el}
tabIndex={0} tabIndex={0}
className={`border-t border-gray-50 hover:bg-gray-50 cursor-pointer outline-none ${rowBg}`} className={`border-t border-line-soft hover:bg-raised cursor-pointer outline-none ${rowBg}`}
onClick={handleRowClick} onClick={handleRowClick}
onKeyDown={handleRowKeyDown} onKeyDown={handleRowKeyDown}
> >
@ -586,13 +586,13 @@ export default function Mappings({ source, onNeedsReprocess }) {
}} }}
/> />
</td> </td>
<td className="px-3 py-1.5 font-mono text-gray-800 whitespace-nowrap">{displayValue(row.extracted_value)}</td> <td className="px-3 py-1.5 font-mono text-ink whitespace-nowrap">{displayValue(row.extracted_value)}</td>
<td className="px-3 py-1.5 text-right text-gray-400">{row.record_count}</td> <td className="px-3 py-1.5 text-right text-muted">{row.record_count}</td>
{cols.map(col => ( {cols.map(col => (
<td key={col} className="px-3 py-1.5"> <td key={col} className="px-3 py-1.5">
<AutocompleteInput <AutocompleteInput
className={`border rounded px-2 py-1 w-full min-w-24 focus:outline-none focus:border-blue-400 ${ className={`border rounded px-2 py-1 w-full min-w-24 focus:outline-none focus:border-accent ${
hasDraft ? 'border-blue-300' : row.is_mapped ? 'border-gray-200' : 'border-yellow-300' hasDraft ? 'border-accent-line' : row.is_mapped ? 'border-line' : 'border-warn-line'
}`} }`}
value={cellVal(col)} value={cellVal(col)}
onChange={v => setCellValue(row.extracted_value, col, v)} onChange={v => setCellValue(row.extracted_value, col, v)}
@ -605,7 +605,7 @@ export default function Mappings({ source, onNeedsReprocess }) {
<td className="px-3 py-1.5 whitespace-nowrap"> <td className="px-3 py-1.5 whitespace-nowrap">
{samples.length > 0 && ( {samples.length > 0 && (
<button <button
className="text-blue-400 hover:text-blue-600" className="text-accent hover:text-accent"
onClick={() => setSampleOpen(s => ({ ...s, [k]: !s[k] }))} onClick={() => setSampleOpen(s => ({ ...s, [k]: !s[k] }))}
> >
{sampleOpen[k] ? 'hide' : 'show'} {sampleOpen[k] ? 'hide' : 'show'}
@ -624,7 +624,7 @@ export default function Mappings({ source, onNeedsReprocess }) {
{row.is_mapped && ( {row.is_mapped && (
<button <button
onClick={() => deleteRow(row)} onClick={() => deleteRow(row)}
className="text-red-400 hover:text-red-600 text-base leading-none" className="text-danger hover:text-danger text-base leading-none"
title="Remove mapping" title="Remove mapping"
>×</button> >×</button>
)} )}
@ -634,21 +634,21 @@ export default function Mappings({ source, onNeedsReprocess }) {
{sampleOpen[k] && (() => { {sampleOpen[k] && (() => {
const sampleCols = [...new Set(samples.flatMap(r => Object.keys(r)))] const sampleCols = [...new Set(samples.flatMap(r => Object.keys(r)))]
return ( return (
<tr key={`${k}-sample`} className="border-t border-gray-50 bg-gray-50"> <tr key={`${k}-sample`} className="border-t border-line-soft bg-raised">
<td colSpan={3 + cols.length + 4} className="px-3 py-2"> <td colSpan={3 + cols.length + 4} className="px-3 py-2">
<table className="w-full text-xs border border-gray-100 rounded bg-white"> <table className="w-full text-xs border border-line-soft rounded bg-surface">
<thead> <thead>
<tr className="bg-gray-50 border-b border-gray-100"> <tr className="bg-raised border-b border-line-soft">
{sampleCols.map(c => ( {sampleCols.map(c => (
<th key={c} className="px-2 py-1 text-left font-medium text-gray-400 whitespace-nowrap">{c}</th> <th key={c} className="px-2 py-1 text-left font-medium text-muted whitespace-nowrap">{c}</th>
))} ))}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{samples.map((rec, i) => ( {samples.map((rec, i) => (
<tr key={i} className="border-t border-gray-50"> <tr key={i} className="border-t border-line-soft">
{sampleCols.map(c => ( {sampleCols.map(c => (
<td key={c} className="px-2 py-1 font-mono text-gray-600 whitespace-nowrap"> <td key={c} className="px-2 py-1 font-mono text-ink-soft whitespace-nowrap">
{rec[c] != null ? String(rec[c]) : ''} {rec[c] != null ? String(rec[c]) : ''}
</td> </td>
))} ))}

View File

@ -370,7 +370,7 @@ export default function Pivot({ source, selectedStack, setSelectedStack }) {
viewer.restore({ table: selectedView, settings: true, plugin_config: DEFAULT_PLUGIN_CONFIG }) viewer.restore({ table: selectedView, settings: true, plugin_config: DEFAULT_PLUGIN_CONFIG })
} }
if (!source) return <div className="p-6 text-sm text-gray-400">Select a source first.</div> if (!source) return <div className="p-6 text-sm text-muted">Select a source first.</div>
const cols = inspectedRows?.length ? Object.keys(inspectedRows[0]) : [] const cols = inspectedRows?.length ? Object.keys(inspectedRows[0]) : []
@ -416,24 +416,24 @@ export default function Pivot({ source, selectedStack, setSelectedStack }) {
<div className="w-full h-full flex flex-col"> <div className="w-full h-full flex flex-col">
{/* Layouts sub-bar */} {/* Layouts sub-bar */}
<div className="flex items-center gap-2 px-3 h-9 bg-white border-b border-gray-200 shrink-0 text-xs"> <div className="flex items-center gap-2 px-3 h-9 bg-surface border-b border-line shrink-0 text-xs">
{layouts.map(l => ( {layouts.map(l => (
<div key={l.id} <div key={l.id}
onClick={() => applyLayout(l)} onClick={() => applyLayout(l)}
className={`flex items-center gap-1 rounded px-2 py-0.5 cursor-pointer border transition-colors className={`flex items-center gap-1 rounded px-2 py-0.5 cursor-pointer border transition-colors
${activeLayoutId === l.id ${activeLayoutId === l.id
? 'bg-blue-50 border-blue-300 text-blue-700' ? 'bg-accent-soft border-accent-line text-accent'
: 'bg-white border-gray-200 text-gray-600 hover:border-gray-400'}`}> : 'bg-surface border-line text-ink-soft hover:border-line'}`}>
{l.layout_name} {l.layout_name}
<button <button
onClick={(e) => handleDelete(l, e)} onClick={(e) => handleDelete(l, e)}
className="text-gray-300 hover:text-red-400 leading-none ml-0.5 text-sm">×</button> className="text-muted hover:text-danger leading-none ml-0.5 text-sm">×</button>
</div> </div>
))} ))}
{activeLayoutId !== null && !showSaveAs && ( {activeLayoutId !== null && !showSaveAs && (
<button onClick={handleSaveOver} <button onClick={handleSaveOver}
className="text-blue-500 hover:text-blue-700 border border-blue-200 rounded px-2 py-0.5"> className="text-accent hover:text-accent border border-accent-line rounded px-2 py-0.5">
Save Save
</button> </button>
)} )}
@ -446,27 +446,27 @@ export default function Pivot({ source, selectedStack, setSelectedStack }) {
onChange={e => setSaveAsName(e.target.value)} onChange={e => setSaveAsName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleSaveAs(); if (e.key === 'Escape') { setShowSaveAs(false); setSaveAsName('') } }} onKeyDown={e => { if (e.key === 'Enter') handleSaveAs(); if (e.key === 'Escape') { setShowSaveAs(false); setSaveAsName('') } }}
placeholder="Layout name…" placeholder="Layout name…"
className="border border-gray-300 rounded px-2 py-0.5 w-36 focus:outline-none focus:border-blue-400" className="border border-line rounded px-2 py-0.5 w-36 focus:outline-none focus:border-accent"
/> />
<button onClick={handleSaveAs} className="text-blue-600 hover:text-blue-800 px-1">Save</button> <button onClick={handleSaveAs} className="text-accent hover:text-accent px-1">Save</button>
<button onClick={() => { setShowSaveAs(false); setSaveAsName('') }} className="text-gray-400 hover:text-gray-600 px-1">Cancel</button> <button onClick={() => { setShowSaveAs(false); setSaveAsName('') }} className="text-muted hover:text-ink-soft px-1">Cancel</button>
</div> </div>
) : ( ) : (
<button <button
onClick={() => setShowSaveAs(true)} onClick={() => setShowSaveAs(true)}
className="text-gray-400 hover:text-gray-600 border border-dashed border-gray-200 rounded px-2 py-0.5"> className="text-muted hover:text-ink-soft border border-dashed border-line rounded px-2 py-0.5">
+ Save as + Save as
</button> </button>
)} )}
{activeLayoutId !== null && ( {activeLayoutId !== null && (
<button onClick={handleResetToDefault} className="text-gray-300 hover:text-gray-500 ml-1">reset</button> <button onClick={handleResetToDefault} className="text-muted hover:text-muted ml-1">reset</button>
)} )}
{layoutMsg && <span className="text-green-600 ml-1">{layoutMsg}</span>} {layoutMsg && <span className="text-ok ml-1">{layoutMsg}</span>}
<div className="ml-auto flex items-center gap-1"> <div className="ml-auto flex items-center gap-1">
<span className="text-gray-400">depth:</span> <span className="text-muted">depth:</span>
{[0, 1, 2, 3].map(d => ( {[0, 1, 2, 3].map(d => (
<button key={d} onClick={async () => { <button key={d} onClick={async () => {
const v = viewerRef.current; if (!v) return const v = viewerRef.current; if (!v) return
@ -475,7 +475,7 @@ export default function Pivot({ source, selectedStack, setSelectedStack }) {
const p = await v.getPlugin() const p = await v.getPlugin()
await p.draw(view) await p.draw(view)
expandDepthRef.current = d expandDepthRef.current = d
}} className="border border-gray-200 rounded px-1.5 py-0.5 text-gray-500 hover:border-gray-400"> }} className="border border-line rounded px-1.5 py-0.5 text-muted hover:border-line">
{d} {d}
</button> </button>
))} ))}
@ -486,18 +486,18 @@ export default function Pivot({ source, selectedStack, setSelectedStack }) {
<div className="relative flex-1 flex min-h-0"> <div className="relative flex-1 flex min-h-0">
<div className="relative flex-1"> <div className="relative flex-1">
{status === 'loading' && ( {status === 'loading' && (
<div className="absolute inset-0 flex items-center justify-center z-10 bg-gray-50"> <div className="absolute inset-0 flex items-center justify-center z-10 bg-raised">
<p className="text-sm text-gray-400">Loading</p> <p className="text-sm text-muted">Loading</p>
</div> </div>
)} )}
{status === 'error' && ( {status === 'error' && (
<div className="absolute inset-0 flex items-center justify-center z-10 bg-gray-50"> <div className="absolute inset-0 flex items-center justify-center z-10 bg-raised">
<p className="text-sm text-red-500">Error: {error}</p> <p className="text-sm text-danger">Error: {error}</p>
</div> </div>
)} )}
{status === 'noview' && ( {status === 'noview' && (
<div className="absolute inset-0 flex items-center justify-center z-10 bg-gray-50"> <div className="absolute inset-0 flex items-center justify-center z-10 bg-raised">
<p className="text-sm text-gray-400">No view data generate a view and transform records first.</p> <p className="text-sm text-muted">No view data generate a view and transform records first.</p>
</div> </div>
)} )}
<perspective-viewer <perspective-viewer
@ -509,7 +509,7 @@ export default function Pivot({ source, selectedStack, setSelectedStack }) {
{inspectedRows && clickDetail && ( {inspectedRows && clickDetail && (
<div <div
style={{ width: paneWidth }} style={{ width: paneWidth }}
className="relative border-l border-gray-200 bg-white flex flex-col overflow-hidden flex-shrink-0" className="relative border-l border-line bg-surface flex flex-col overflow-hidden flex-shrink-0"
> >
{/* Drag-to-resize handle on left edge */} {/* Drag-to-resize handle on left edge */}
<div <div
@ -529,27 +529,27 @@ export default function Pivot({ source, selectedStack, setSelectedStack }) {
/> />
{/* Header: breadcrumb + row count + controls */} {/* Header: breadcrumb + row count + controls */}
<div className="flex items-center justify-between pl-3 pr-2 py-2 border-b border-gray-100 flex-shrink-0"> <div className="flex items-center justify-between pl-3 pr-2 py-2 border-b border-line-soft flex-shrink-0">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
{cellCoords.length > 0 && ( {cellCoords.length > 0 && (
<span className="text-xs text-gray-700 font-mono font-semibold truncate"> <span className="text-xs text-ink-soft font-mono font-semibold truncate">
{cellCoords.join(' ')} {cellCoords.join(' ')}
</span> </span>
)} )}
<span className="text-xs text-gray-400 flex-shrink-0"> <span className="text-xs text-muted flex-shrink-0">
{inspectedRows.length} row{inspectedRows.length !== 1 ? 's' : ''} {inspectedRows.length} row{inspectedRows.length !== 1 ? 's' : ''}
</span> </span>
</div> </div>
<div className="flex items-center gap-2 flex-shrink-0"> <div className="flex items-center gap-2 flex-shrink-0">
<div className="flex items-center gap-0.5"> <div className="flex items-center gap-0.5">
<button onClick={() => setDecimals(d => Math.max(0, d - 1))} <button onClick={() => setDecimals(d => Math.max(0, d - 1))}
className="text-xs text-gray-400 hover:text-gray-600 w-4 text-center"></button> className="text-xs text-muted hover:text-ink-soft w-4 text-center"></button>
<span className="text-xs text-gray-400 w-4 text-center">{decimals}</span> <span className="text-xs text-muted w-4 text-center">{decimals}</span>
<button onClick={() => setDecimals(d => Math.min(8, d + 1))} <button onClick={() => setDecimals(d => Math.min(8, d + 1))}
className="text-xs text-gray-400 hover:text-gray-600 w-4 text-center">+</button> className="text-xs text-muted hover:text-ink-soft w-4 text-center">+</button>
</div> </div>
<button onClick={() => { setInspectedRows(null); setClickDetail(null); lastClickKeyRef.current = null }} <button onClick={() => { setInspectedRows(null); setClickDetail(null); lastClickKeyRef.current = null }}
className="text-gray-300 hover:text-gray-500 leading-none text-lg">×</button> className="text-muted hover:text-muted leading-none text-lg">×</button>
</div> </div>
</div> </div>
@ -558,10 +558,10 @@ export default function Pivot({ source, selectedStack, setSelectedStack }) {
{(() => { {(() => {
const userFilters = (clickDetail.eventFilters || []).filter(([f]) => !coordFields.has(f)) const userFilters = (clickDetail.eventFilters || []).filter(([f]) => !coordFields.has(f))
return userFilters.length > 0 ? ( return userFilters.length > 0 ? (
<div className="px-3 py-2 border-b border-gray-100"> <div className="px-3 py-2 border-b border-line-soft">
<div className="text-xs text-gray-400 uppercase tracking-wide mb-1">Filters</div> <div className="text-xs text-muted uppercase tracking-wide mb-1">Filters</div>
{userFilters.map((f, i) => ( {userFilters.map((f, i) => (
<div key={i} className="text-xs text-gray-500 py-0.5 font-mono">{f.join(' ')}</div> <div key={i} className="text-xs text-muted py-0.5 font-mono">{f.join(' ')}</div>
))} ))}
</div> </div>
) : null ) : null
@ -572,13 +572,13 @@ export default function Pivot({ source, selectedStack, setSelectedStack }) {
<div className="overflow-auto"> <div className="overflow-auto">
<table className="w-full text-xs"> <table className="w-full text-xs">
<thead> <thead>
<tr className="text-left text-gray-400 border-b border-gray-100 bg-gray-50 sticky top-0"> <tr className="text-left text-muted border-b border-line-soft bg-raised sticky top-0">
{cols.map(c => { {cols.map(c => {
const active = sortCol === c const active = sortCol === c
return ( return (
<th key={c} <th key={c}
onClick={() => { if (active) setSortDir(d => d === 'asc' ? 'desc' : 'asc'); else { setSortCol(c); setSortDir('asc') } }} onClick={() => { if (active) setSortDir(d => d === 'asc' ? 'desc' : 'asc'); else { setSortCol(c); setSortDir('asc') } }}
className="px-2 py-1 font-medium whitespace-nowrap cursor-pointer select-none hover:text-gray-600"> className="px-2 py-1 font-medium whitespace-nowrap cursor-pointer select-none hover:text-ink-soft">
{c}{active ? (sortDir === 'asc' ? ' ▲' : ' ▼') : ''} {c}{active ? (sortDir === 'asc' ? ' ▲' : ' ▼') : ''}
</th> </th>
) )
@ -587,12 +587,12 @@ export default function Pivot({ source, selectedStack, setSelectedStack }) {
</thead> </thead>
<tbody> <tbody>
{sortedRows.map((row, i) => ( {sortedRows.map((row, i) => (
<tr key={i} className="border-t border-gray-50 hover:bg-gray-50"> <tr key={i} className="border-t border-line-soft hover:bg-raised">
{cols.map(c => { {cols.map(c => {
const f = formatVal(row[c], decimals) const f = formatVal(row[c], decimals)
return ( return (
<td key={c} className="px-2 py-1 font-mono whitespace-nowrap text-gray-700 max-w-40 truncate"> <td key={c} className="px-2 py-1 font-mono whitespace-nowrap text-ink-soft max-w-40 truncate">
{f == null ? <span className="text-gray-300"></span> : f} {f == null ? <span className="text-muted"></span> : f}
</td> </td>
) )
})} })}
@ -601,7 +601,7 @@ export default function Pivot({ source, selectedStack, setSelectedStack }) {
</tbody> </tbody>
{Object.keys(totals).length > 0 && ( {Object.keys(totals).length > 0 && (
<tfoot> <tfoot>
<tr className="border-t-2 border-gray-200 bg-gray-50 font-semibold text-gray-700 sticky bottom-0"> <tr className="border-t-2 border-line bg-raised font-semibold text-ink-soft sticky bottom-0">
{cols.map(c => ( {cols.map(c => (
<td key={c} className="px-2 py-1 font-mono whitespace-nowrap text-right"> <td key={c} className="px-2 py-1 font-mono whitespace-nowrap text-right">
{totals[c] != null ? formatVal(totals[c], decimals) : ''} {totals[c] != null ? formatVal(totals[c], decimals) : ''}

View File

@ -49,10 +49,10 @@ function AutocompleteInput({ value, onChange, onEnter, suggestions = [], classNa
{open && filtered.length > 0 && dropPos && ( {open && filtered.length > 0 && dropPos && (
<div ref={listRef} <div ref={listRef}
style={{ position: 'fixed', top: dropPos.top, left: dropPos.left, minWidth: dropPos.minWidth, zIndex: 9999 }} style={{ position: 'fixed', top: dropPos.top, left: dropPos.left, minWidth: dropPos.minWidth, zIndex: 9999 }}
className="bg-white border border-gray-200 rounded shadow-lg max-h-40 overflow-y-auto"> className="bg-surface border border-line rounded shadow-lg max-h-40 overflow-y-auto">
{filtered.map((s, i) => ( {filtered.map((s, i) => (
<div key={s} <div key={s}
className={`px-2 py-1 text-xs cursor-pointer whitespace-nowrap ${i === highlighted ? 'bg-blue-50 text-blue-700' : 'text-gray-700 hover:bg-gray-50'}`} className={`px-2 py-1 text-xs cursor-pointer whitespace-nowrap ${i === highlighted ? 'bg-accent-soft text-accent' : 'text-ink-soft hover:bg-raised'}`}
onMouseDown={e => { e.preventDefault(); select(s) }}>{s}</div> onMouseDown={e => { e.preventDefault(); select(s) }}>{s}</div>
))} ))}
</div> </div>
@ -283,7 +283,7 @@ export default function Records({ source }) {
} }
} }
if (!source) return <div className="p-6 text-sm text-gray-400">Select a source first.</div> if (!source) return <div className="p-6 text-sm text-muted">Select a source first.</div>
const displayCols = gridCols(rows.length > 0 ? Object.keys(rows[0]) : cols) const displayCols = gridCols(rows.length > 0 ? Object.keys(rows[0]) : cols)
const visCols = gridCols(cols) const visCols = gridCols(cols)
@ -300,42 +300,42 @@ export default function Records({ source }) {
<div className="flex h-full min-h-0 overflow-hidden"> <div className="flex h-full min-h-0 overflow-hidden">
<div className="flex-1 overflow-auto p-6 min-w-0"> <div className="flex-1 overflow-auto p-6 min-w-0">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h1 className="text-xl font-semibold text-gray-800">Records {source}</h1> <h1 className="text-xl font-semibold text-ink">Records {source}</h1>
{exists && rows.length > 0 && ( {exists && rows.length > 0 && (
<span className="text-xs text-gray-400 font-mono">dfv.{source}</span> <span className="text-xs text-muted font-mono">dfv.{source}</span>
)} )}
</div> </div>
{/* Filter bar */} {/* Filter bar */}
{exists !== false && visCols.length > 0 && ( {exists !== false && visCols.length > 0 && (
<div className="mb-4 flex flex-wrap gap-2 items-center"> <div className="mb-4 flex flex-wrap gap-2 items-center">
<span className="text-xs text-gray-400 font-medium mr-1">DB query:</span> <span className="text-xs text-muted font-medium mr-1">DB query:</span>
{filters.map((f, i) => ( {filters.map((f, i) => (
<div key={i} className="flex items-center gap-1 bg-white border border-gray-200 rounded px-2 py-1"> <div key={i} className="flex items-center gap-1 bg-surface border border-line rounded px-2 py-1">
<select <select
className="text-xs text-gray-600 border-0 focus:outline-none bg-transparent" className="text-xs text-ink-soft border-0 focus:outline-none bg-transparent"
value={f.col} value={f.col}
onChange={e => updateFilter(i, 'col', e.target.value)} onChange={e => updateFilter(i, 'col', e.target.value)}
> >
{visCols.map(c => <option key={c} value={c}>{c}</option>)} {visCols.map(c => <option key={c} value={c}>{c}</option>)}
</select> </select>
<span className="text-xs text-gray-300 mx-0.5">~*</span> <span className="text-xs text-muted mx-0.5">~*</span>
<input <input
className="text-xs font-mono border-0 focus:outline-none w-36 bg-transparent" className="text-xs font-mono border-0 focus:outline-none w-36 bg-transparent"
placeholder="regex…" placeholder="regex…"
value={f.pattern} value={f.pattern}
onChange={e => updateFilter(i, 'pattern', e.target.value)} onChange={e => updateFilter(i, 'pattern', e.target.value)}
/> />
<button onClick={() => removeFilter(i)} className="text-gray-300 hover:text-gray-500 ml-1 leading-none">×</button> <button onClick={() => removeFilter(i)} className="text-muted hover:text-muted ml-1 leading-none">×</button>
</div> </div>
))} ))}
<button onClick={addFilter} <button onClick={addFilter}
className="text-xs text-gray-400 hover:text-gray-600 border border-dashed border-gray-200 rounded px-2 py-1"> className="text-xs text-muted hover:text-ink-soft border border-dashed border-line rounded px-2 py-1">
+ filter + filter
</button> </button>
{filters.length > 0 && ( {filters.length > 0 && (
<button onClick={() => { setFilters([]); setOffset(0); load(0, sort.col, sort.dir, []) }} <button onClick={() => { setFilters([]); setOffset(0); load(0, sort.col, sort.dir, []) }}
className="text-xs text-gray-400 hover:text-red-500">clear</button> className="text-xs text-muted hover:text-danger">clear</button>
)} )}
</div> </div>
)} )}
@ -343,24 +343,24 @@ export default function Records({ source }) {
{/* Bulk select + override bar */} {/* Bulk select + override bar */}
{exists && visCols.length > 0 && ( {exists && visCols.length > 0 && (
<div className="mb-4 flex flex-wrap gap-2 items-center"> <div className="mb-4 flex flex-wrap gap-2 items-center">
<span className="text-xs text-gray-400 font-medium mr-1">Bulk select:</span> <span className="text-xs text-muted font-medium mr-1">Bulk select:</span>
<input <input
className={`text-xs font-mono border rounded px-2 py-1.5 w-44 focus:outline-none focus:border-blue-400 ${ className={`text-xs font-mono border rounded px-2 py-1.5 w-44 focus:outline-none focus:border-accent ${
rowFilter ? 'border-blue-300' : 'border-gray-200' rowFilter ? 'border-accent-line' : 'border-line'
}`} }`}
placeholder="regex on loaded rows…" placeholder="regex on loaded rows…"
value={rowFilter} value={rowFilter}
onChange={e => setRowFilter(e.target.value)} onChange={e => setRowFilter(e.target.value)}
/> />
{rowFilter && ( {rowFilter && (
<span className="text-xs text-gray-400">{selected.size} of {rows.length} rows selected</span> <span className="text-xs text-muted">{selected.size} of {rows.length} rows selected</span>
)} )}
{selected.size > 0 && ( {selected.size > 0 && (
<div className="flex items-center gap-2 ml-4 p-2 bg-blue-50 border border-blue-200 rounded flex-wrap"> <div className="flex items-center gap-2 ml-4 p-2 bg-accent-soft border border-accent-line rounded flex-wrap">
{allOverrideCols.map(col => ( {allOverrideCols.map(col => (
<AutocompleteInput <AutocompleteInput
key={col} key={col}
className="border border-blue-300 rounded px-2 py-1 text-xs min-w-24 focus:outline-none focus:border-blue-500 bg-white" className="border border-accent-line rounded px-2 py-1 text-xs min-w-24 focus:outline-none focus:border-accent bg-surface"
placeholder={col} placeholder={col}
value={bulkDraft[col] || ''} value={bulkDraft[col] || ''}
onChange={v => setBulkDraft(d => ({ ...d, [col]: v }))} onChange={v => setBulkDraft(d => ({ ...d, [col]: v }))}
@ -395,7 +395,7 @@ export default function Records({ source }) {
</button> </button>
<button <button
onClick={() => { setSelected(new Set()); setBulkDraft({}); setRowFilter('') }} onClick={() => { setSelected(new Set()); setBulkDraft({}); setRowFilter('') }}
className="text-xs text-blue-400 hover:text-blue-600" className="text-xs text-accent hover:text-accent"
> >
cancel cancel
</button> </button>
@ -404,25 +404,25 @@ export default function Records({ source }) {
</div> </div>
)} )}
{loading && <p className="text-sm text-gray-400">Loading</p>} {loading && <p className="text-sm text-muted">Loading</p>}
{!loading && viewError && <p className="text-sm text-red-500">View error: {viewError} check field types in Sources.</p>} {!loading && viewError && <p className="text-sm text-danger">View error: {viewError} check field types in Sources.</p>}
{!loading && exists === false && ( {!loading && exists === false && (
<p className="text-sm text-gray-400"> <p className="text-sm text-muted">
No view generated yet. Go to <span className="font-medium text-gray-600">Sources</span>, check fields as <span className="font-medium text-gray-600">In view</span>, then click <span className="font-medium text-gray-600">Generate view</span>. No view generated yet. Go to <span className="font-medium text-ink-soft">Sources</span>, check fields as <span className="font-medium text-ink-soft">In view</span>, then click <span className="font-medium text-ink-soft">Generate view</span>.
</p> </p>
)} )}
{!loading && exists && rows.length === 0 && ( {!loading && exists && rows.length === 0 && (
<p className="text-sm text-gray-400"> <p className="text-sm text-muted">
{filters.some(f => f.col && f.pattern) ? 'No records match the current filters.' : 'View exists but no transformed records yet. Import data and run a transform first.'} {filters.some(f => f.col && f.pattern) ? 'No records match the current filters.' : 'View exists but no transformed records yet. Import data and run a transform first.'}
</p> </p>
)} )}
{!loading && exists && rows.length > 0 && ( {!loading && exists && rows.length > 0 && (
<> <>
<div className="bg-white border border-gray-200 rounded overflow-auto mb-4"> <div className="bg-surface border border-line rounded overflow-auto mb-4">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="text-left text-xs text-gray-400 border-b border-gray-100 bg-gray-50"> <tr className="text-left text-xs text-muted border-b border-line-soft bg-raised">
<th className="px-2 py-2 w-8"> <th className="px-2 py-2 w-8">
<input <input
type="checkbox" type="checkbox"
@ -438,9 +438,9 @@ export default function Records({ source }) {
const active = sort.col === col const active = sort.col === col
return ( return (
<th key={col} onClick={() => toggleSort(col)} <th key={col} onClick={() => toggleSort(col)}
className="px-3 py-2 font-medium whitespace-nowrap cursor-pointer select-none hover:text-gray-600"> className="px-3 py-2 font-medium whitespace-nowrap cursor-pointer select-none hover:text-ink-soft">
{col} {col}
<span className="ml-1 text-gray-300">{active ? (sort.dir === 'asc' ? '▲' : '▼') : '⇅'}</span> <span className="ml-1 text-muted">{active ? (sort.dir === 'asc' ? '▲' : '▼') : '⇅'}</span>
</th> </th>
) )
})} })}
@ -453,8 +453,8 @@ export default function Records({ source }) {
const isPanelSelected = selectedRow?.id != null && selectedRow.id === row.id const isPanelSelected = selectedRow?.id != null && selectedRow.id === row.id
return ( return (
<tr key={i} onClick={() => openPanel(row)} <tr key={i} onClick={() => openPanel(row)}
className={`border-t border-gray-50 cursor-pointer transition-colors className={`border-t border-line-soft cursor-pointer transition-colors
${isPanelSelected ? 'bg-blue-50' : isRowSelected ? 'bg-blue-50' : isOverridden ? 'bg-amber-50 hover:bg-amber-100' : 'hover:bg-gray-50'}`}> ${isPanelSelected ? 'bg-accent-soft' : isRowSelected ? 'bg-accent-soft' : isOverridden ? 'bg-warn-soft hover:bg-warn-soft' : 'hover:bg-raised'}`}>
<td className="px-2 py-2"> <td className="px-2 py-2">
<input <input
type="checkbox" type="checkbox"
@ -469,8 +469,8 @@ export default function Records({ source }) {
{displayCols.map((col, j) => { {displayCols.map((col, j) => {
const formatted = formatVal(row[col]) const formatted = formatVal(row[col])
return ( return (
<td key={j} className="px-3 py-2 text-xs text-gray-600 whitespace-nowrap max-w-48 truncate"> <td key={j} className="px-3 py-2 text-xs text-ink-soft whitespace-nowrap max-w-48 truncate">
{formatted === null ? <span className="text-gray-300"></span> : formatted} {formatted === null ? <span className="text-muted"></span> : formatted}
</td> </td>
) )
})} })}
@ -481,12 +481,12 @@ export default function Records({ source }) {
</table> </table>
</div> </div>
<div className="flex items-center gap-3 text-sm text-gray-500"> <div className="flex items-center gap-3 text-sm text-muted">
<button onClick={prev} disabled={offset === 0} <button onClick={prev} disabled={offset === 0}
className="px-3 py-1 border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-40"> Prev</button> className="px-3 py-1 border border-line rounded hover:bg-raised disabled:opacity-40"> Prev</button>
<span>{offset + 1}{offset + rows.length}</span> <span>{offset + 1}{offset + rows.length}</span>
<button onClick={next} disabled={rows.length < LIMIT} <button onClick={next} disabled={rows.length < LIMIT}
className="px-3 py-1 border border-gray-200 rounded hover:bg-gray-50 disabled:opacity-40">Next </button> className="px-3 py-1 border border-line rounded hover:bg-raised disabled:opacity-40">Next </button>
</div> </div>
</> </>
)} )}
@ -494,58 +494,58 @@ export default function Records({ source }) {
{/* Panel */} {/* Panel */}
{panelOpen && ( {panelOpen && (
<div className="w-80 border-l border-gray-200 bg-white flex flex-col overflow-hidden flex-shrink-0"> <div className="w-80 border-l border-line bg-surface flex flex-col overflow-hidden flex-shrink-0">
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-100"> <div className="flex items-center justify-between px-3 py-2 border-b border-line-soft">
<span className="text-xs font-semibold text-gray-600 uppercase tracking-wide">Record</span> <span className="text-xs font-semibold text-ink-soft uppercase tracking-wide">Record</span>
<button onClick={closePanel} className="text-gray-300 hover:text-gray-500 leading-none text-lg">×</button> <button onClick={closePanel} className="text-muted hover:text-muted leading-none text-lg">×</button>
</div> </div>
{panelLoading && <p className="text-xs text-gray-400 p-3">Loading</p>} {panelLoading && <p className="text-xs text-muted p-3">Loading</p>}
{selectedRecord && !panelLoading && ( {selectedRecord && !panelLoading && (
<div className="flex-1 overflow-y-auto flex flex-col min-h-0"> <div className="flex-1 overflow-y-auto flex flex-col min-h-0">
{panelMsg && ( {panelMsg && (
<div className={`text-xs px-3 py-2 border-b border-gray-100 ${panelMsg.ok ? 'text-green-600' : 'text-red-500'}`}> <div className={`text-xs px-3 py-2 border-b border-line-soft ${panelMsg.ok ? 'text-ok' : 'text-danger'}`}>
{panelMsg.text} {panelMsg.text}
</div> </div>
)} )}
{/* Raw fields — read only */} {/* Raw fields — read only */}
<div className="border-b border-gray-100"> <div className="border-b border-line-soft">
<div className="px-3 py-1.5 bg-gray-50 border-b border-gray-100"> <div className="px-3 py-1.5 bg-raised border-b border-line-soft">
<span className="text-xs font-medium text-gray-400 uppercase tracking-wide">Raw</span> <span className="text-xs font-medium text-muted uppercase tracking-wide">Raw</span>
</div> </div>
{Object.entries(selectedRecord.data || {}).map(([field, val]) => ( {Object.entries(selectedRecord.data || {}).map(([field, val]) => (
<div key={field} className="flex items-baseline gap-2 px-3 py-1 border-t border-gray-50 first:border-t-0"> <div key={field} className="flex items-baseline gap-2 px-3 py-1 border-t border-line-soft first:border-t-0">
<span className="text-xs font-mono text-gray-400 w-28 shrink-0 truncate">{field}</span> <span className="text-xs font-mono text-muted w-28 shrink-0 truncate">{field}</span>
<span className="text-xs font-mono text-gray-500 truncate">{formatVal(val) ?? <span className="text-gray-300"></span>}</span> <span className="text-xs font-mono text-muted truncate">{formatVal(val) ?? <span className="text-muted"></span>}</span>
</div> </div>
))} ))}
</div> </div>
{/* Transformed fields — read only delta */} {/* Transformed fields — read only delta */}
<div className="border-b border-gray-100"> <div className="border-b border-line-soft">
<div className="px-3 py-1.5 bg-gray-50 border-b border-gray-100"> <div className="px-3 py-1.5 bg-raised border-b border-line-soft">
<span className="text-xs font-medium text-gray-400 uppercase tracking-wide">Transformed</span> <span className="text-xs font-medium text-muted uppercase tracking-wide">Transformed</span>
</div> </div>
{Object.entries(selectedRecord.transformed || {}).filter(([k]) => !HIDDEN_COLS.has(k)).length === 0 {Object.entries(selectedRecord.transformed || {}).filter(([k]) => !HIDDEN_COLS.has(k)).length === 0
? <div className="px-3 py-2 text-xs text-gray-300">No rule output yet.</div> ? <div className="px-3 py-2 text-xs text-muted">No rule output yet.</div>
: Object.entries(selectedRecord.transformed || {}).filter(([k]) => !HIDDEN_COLS.has(k)).map(([field, val]) => ( : Object.entries(selectedRecord.transformed || {}).filter(([k]) => !HIDDEN_COLS.has(k)).map(([field, val]) => (
<div key={field} className="flex items-baseline gap-2 px-3 py-1 border-t border-gray-50 first:border-t-0"> <div key={field} className="flex items-baseline gap-2 px-3 py-1 border-t border-line-soft first:border-t-0">
<span className="text-xs font-mono text-gray-400 w-28 shrink-0 truncate">{field}</span> <span className="text-xs font-mono text-muted w-28 shrink-0 truncate">{field}</span>
<span className="text-xs font-mono text-blue-600 truncate">{formatVal(val) ?? <span className="text-gray-300"></span>}</span> <span className="text-xs font-mono text-accent truncate">{formatVal(val) ?? <span className="text-muted"></span>}</span>
</div> </div>
)) ))
} }
</div> </div>
{/* Overrides — editable */} {/* Overrides — editable */}
<div className="flex-1 border-b border-gray-100"> <div className="flex-1 border-b border-line-soft">
<div className="flex items-center justify-between px-3 py-1.5 bg-gray-50 border-b border-gray-100"> <div className="flex items-center justify-between px-3 py-1.5 bg-raised border-b border-line-soft">
<span className="text-xs font-medium text-gray-400 uppercase tracking-wide">Overrides</span> <span className="text-xs font-medium text-muted uppercase tracking-wide">Overrides</span>
<button <button
onClick={() => setExtraCols(ec => [...ec, ''])} onClick={() => setExtraCols(ec => [...ec, ''])}
className="text-gray-400 hover:text-gray-700 font-medium text-sm leading-none" className="text-muted hover:text-ink-soft font-medium text-sm leading-none"
title="Add field">+</button> title="Add field">+</button>
</div> </div>
<table className="w-full text-xs"> <table className="w-full text-xs">
@ -559,14 +559,14 @@ export default function Records({ source }) {
const placeholder = formatVal(selectedRecord.transformed?.[col]) ?? '' const placeholder = formatVal(selectedRecord.transformed?.[col]) ?? ''
const suggestions = [...(globalValues[col] || [])].sort() const suggestions = [...(globalValues[col] || [])].sort()
return ( return (
<tr key={col} className="border-t border-gray-50"> <tr key={col} className="border-t border-line-soft">
<td className="px-3 py-1.5 w-28 shrink-0"> <td className="px-3 py-1.5 w-28 shrink-0">
<span className="font-mono text-gray-500 truncate block">{col}</span> <span className="font-mono text-muted truncate block">{col}</span>
</td> </td>
<td className="px-1 py-1.5"> <td className="px-1 py-1.5">
<AutocompleteInput <AutocompleteInput
className={`w-full text-xs font-mono px-2 py-0.5 rounded border focus:outline-none ${ className={`w-full text-xs font-mono px-2 py-0.5 rounded border focus:outline-none ${
override ? 'border-amber-300 bg-amber-50 text-amber-800' : 'border-gray-200 text-gray-600' override ? 'border-warn-line bg-warn-soft text-warn' : 'border-line text-ink-soft'
}`} }`}
value={override} value={override}
placeholder={placeholder} placeholder={placeholder}
@ -579,7 +579,7 @@ export default function Records({ source }) {
{override && ( {override && (
<button <button
onClick={() => setOverrideDraft(d => { const n = { ...d }; delete n[col]; return n })} onClick={() => setOverrideDraft(d => { const n = { ...d }; delete n[col]; return n })}
className="text-gray-300 hover:text-red-400 leading-none text-base">×</button> className="text-muted hover:text-danger leading-none text-base">×</button>
)} )}
</td> </td>
</tr> </tr>
@ -589,10 +589,10 @@ export default function Records({ source }) {
const val = overrideDraft[col] ?? '' const val = overrideDraft[col] ?? ''
const suggestions = [...(globalValues[col] || [])].sort() const suggestions = [...(globalValues[col] || [])].sort()
return ( return (
<tr key={`extra-${i}`} className="border-t border-gray-50"> <tr key={`extra-${i}`} className="border-t border-line-soft">
<td className="px-3 py-1.5 w-28 shrink-0"> <td className="px-3 py-1.5 w-28 shrink-0">
<input <input
className="w-full text-xs font-mono border border-gray-200 rounded px-1 py-0.5 focus:outline-none focus:border-blue-400" className="w-full text-xs font-mono border border-line rounded px-1 py-0.5 focus:outline-none focus:border-accent"
value={col} value={col}
placeholder="field name" placeholder="field name"
onChange={e => { onChange={e => {
@ -610,7 +610,7 @@ export default function Records({ source }) {
<td className="px-1 py-1.5"> <td className="px-1 py-1.5">
<AutocompleteInput <AutocompleteInput
className={`w-full text-xs font-mono px-2 py-0.5 rounded border focus:outline-none ${ className={`w-full text-xs font-mono px-2 py-0.5 rounded border focus:outline-none ${
val ? 'border-amber-300 bg-amber-50 text-amber-800' : 'border-gray-200 text-gray-600' val ? 'border-warn-line bg-warn-soft text-warn' : 'border-line text-ink-soft'
}`} }`}
value={val} value={val}
onChange={v => setOverrideDraft(d => ({ ...d, [col]: v }))} onChange={v => setOverrideDraft(d => ({ ...d, [col]: v }))}
@ -622,7 +622,7 @@ export default function Records({ source }) {
{val && ( {val && (
<button <button
onClick={() => setOverrideDraft(d => { const n = { ...d }; delete n[col]; return n })} onClick={() => setOverrideDraft(d => { const n = { ...d }; delete n[col]; return n })}
className="text-gray-300 hover:text-red-400 leading-none text-base">×</button> className="text-muted hover:text-danger leading-none text-base">×</button>
)} )}
</td> </td>
</tr> </tr>
@ -632,7 +632,7 @@ export default function Records({ source }) {
</table> </table>
</div> </div>
<div className="flex gap-2 px-3 py-2 border-t border-gray-100 shrink-0"> <div className="flex gap-2 px-3 py-2 border-t border-line-soft shrink-0">
<button <button
onClick={handleSaveOverrides} onClick={handleSaveOverrides}
disabled={panelSaving || !isDirty} disabled={panelSaving || !isDirty}
@ -643,7 +643,7 @@ export default function Records({ source }) {
<button <button
onClick={handleClearOverrides} onClick={handleClearOverrides}
disabled={panelSaving} disabled={panelSaving}
className="text-xs border border-gray-200 rounded px-3 py-1.5 text-gray-500 hover:border-red-300 hover:text-red-500 disabled:opacity-40"> className="text-xs border border-line rounded px-3 py-1.5 text-muted hover:border-danger-line hover:text-danger disabled:opacity-40">
Clear Clear
</button> </button>
)} )}

View File

@ -74,7 +74,7 @@ export default function Remap() {
return ( return (
<div className="p-6 max-w-4xl"> <div className="p-6 max-w-4xl">
<h1 className="text-base font-semibold text-gray-800 mb-4">Remap Output Values</h1> <h1 className="text-base font-semibold text-ink mb-4">Remap Output Values</h1>
{/* Search */} {/* Search */}
<form onSubmit={handleSearch} className="flex items-center gap-2 mb-5"> <form onSubmit={handleSearch} className="flex items-center gap-2 mb-5">
@ -83,7 +83,7 @@ export default function Remap() {
value={search} value={search}
onChange={e => setSearch(e.target.value)} onChange={e => setSearch(e.target.value)}
placeholder="Search output values…" placeholder="Search output values…"
className="text-sm border border-gray-300 rounded px-3 py-1.5 w-72 focus:outline-none focus:border-blue-400" className="text-sm border border-line rounded px-3 py-1.5 w-72 focus:outline-none focus:border-accent"
/> />
<button type="submit" disabled={searching} <button type="submit" disabled={searching}
className="text-sm bg-blue-600 text-white rounded px-3 py-1.5 hover:bg-blue-700 disabled:opacity-50"> className="text-sm bg-blue-600 text-white rounded px-3 py-1.5 hover:bg-blue-700 disabled:opacity-50">
@ -95,15 +95,15 @@ export default function Remap() {
{results !== null && ( {results !== null && (
<div className="mb-6"> <div className="mb-6">
{results.length === 0 ? ( {results.length === 0 ? (
<p className="text-sm text-gray-400">No matching output values found.</p> <p className="text-sm text-muted">No matching output values found.</p>
) : ( ) : (
<> <>
<div className="text-xs text-gray-400 uppercase tracking-wide mb-1"> <div className="text-xs text-muted uppercase tracking-wide mb-1">
{results.length} result{results.length !== 1 ? 's' : ''} click one to remap {results.length} result{results.length !== 1 ? 's' : ''} click one to remap
</div> </div>
<table className="w-full text-sm border border-gray-200 rounded overflow-hidden"> <table className="w-full text-sm border border-line rounded overflow-hidden">
<thead> <thead>
<tr className="bg-gray-50 text-left text-xs text-gray-400 uppercase tracking-wide"> <tr className="bg-raised text-left text-xs text-muted uppercase tracking-wide">
<th className="px-3 py-2">Field</th> <th className="px-3 py-2">Field</th>
<th className="px-3 py-2">Value</th> <th className="px-3 py-2">Value</th>
<th className="px-3 py-2 text-right">Mappings</th> <th className="px-3 py-2 text-right">Mappings</th>
@ -115,11 +115,11 @@ export default function Remap() {
return ( return (
<tr key={i} <tr key={i}
onClick={() => handleSelect(r)} onClick={() => handleSelect(r)}
className={`border-t border-gray-100 cursor-pointer transition-colors className={`border-t border-line-soft cursor-pointer transition-colors
${isActive ? 'bg-blue-50' : 'hover:bg-gray-50'}`}> ${isActive ? 'bg-accent-soft' : 'hover:bg-raised'}`}>
<td className="px-3 py-2 font-mono text-gray-500">{r.col}</td> <td className="px-3 py-2 font-mono text-muted">{r.col}</td>
<td className="px-3 py-2 font-mono text-gray-800">{r.val}</td> <td className="px-3 py-2 font-mono text-ink">{r.val}</td>
<td className="px-3 py-2 text-right text-gray-400">{r.mapping_count}</td> <td className="px-3 py-2 text-right text-muted">{r.mapping_count}</td>
</tr> </tr>
) )
})} })}
@ -132,25 +132,25 @@ export default function Remap() {
{/* Remap panel */} {/* Remap panel */}
{selected && ( {selected && (
<div className="border border-gray-200 rounded p-4 mb-6 bg-white"> <div className="border border-line rounded p-4 mb-6 bg-surface">
<div className="text-xs text-gray-400 uppercase tracking-wide mb-3"> <div className="text-xs text-muted uppercase tracking-wide mb-3">
Remap <span className="font-mono text-gray-600">{selected.col}</span> Remap <span className="font-mono text-ink-soft">{selected.col}</span>
</div> </div>
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<div className="flex-1"> <div className="flex-1">
<div className="text-xs text-gray-400 mb-1">From</div> <div className="text-xs text-muted mb-1">From</div>
<div className="text-sm font-mono bg-gray-50 border border-gray-200 rounded px-3 py-1.5 text-gray-700"> <div className="text-sm font-mono bg-raised border border-line rounded px-3 py-1.5 text-ink-soft">
{selected.val} {selected.val}
</div> </div>
</div> </div>
<div className="text-gray-300 mt-4"></div> <div className="text-muted mt-4"></div>
<div className="flex-1"> <div className="flex-1">
<div className="text-xs text-gray-400 mb-1">To</div> <div className="text-xs text-muted mb-1">To</div>
<input <input
value={toVal} value={toVal}
onChange={e => setToVal(e.target.value)} onChange={e => setToVal(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleApply()} onKeyDown={e => e.key === 'Enter' && handleApply()}
className="w-full text-sm font-mono border border-gray-300 rounded px-3 py-1.5 focus:outline-none focus:border-blue-400" className="w-full text-sm font-mono border border-line rounded px-3 py-1.5 focus:outline-none focus:border-accent"
/> />
</div> </div>
<div className="mt-4"> <div className="mt-4">
@ -164,22 +164,22 @@ export default function Remap() {
</div> </div>
{msg && ( {msg && (
<div className={`text-sm mb-3 ${msg.ok ? 'text-green-600' : 'text-red-500'}`}> <div className={`text-sm mb-3 ${msg.ok ? 'text-ok' : 'text-danger'}`}>
{msg.text} {msg.text}
</div> </div>
)} )}
{/* Affected mappings */} {/* Affected mappings */}
{loadingMatches ? ( {loadingMatches ? (
<p className="text-xs text-gray-400">Loading</p> <p className="text-xs text-muted">Loading</p>
) : matches && matches.length > 0 && ( ) : matches && matches.length > 0 && (
<div> <div>
<div className="text-xs text-gray-400 uppercase tracking-wide mb-1"> <div className="text-xs text-muted uppercase tracking-wide mb-1">
Affected mappings Affected mappings
</div> </div>
<table className="w-full text-xs border border-gray-100 rounded overflow-hidden"> <table className="w-full text-xs border border-line-soft rounded overflow-hidden">
<thead> <thead>
<tr className="bg-gray-50 text-left text-gray-400"> <tr className="bg-raised text-left text-muted">
<th className="px-2 py-1">Source</th> <th className="px-2 py-1">Source</th>
<th className="px-2 py-1">Rule</th> <th className="px-2 py-1">Rule</th>
<th className="px-2 py-1">Input</th> <th className="px-2 py-1">Input</th>
@ -188,15 +188,15 @@ export default function Remap() {
</thead> </thead>
<tbody> <tbody>
{matches.map(m => ( {matches.map(m => (
<tr key={m.id} className="border-t border-gray-50"> <tr key={m.id} className="border-t border-line-soft">
<td className="px-2 py-1 font-mono text-gray-500">{m.source_name}</td> <td className="px-2 py-1 font-mono text-muted">{m.source_name}</td>
<td className="px-2 py-1 font-mono text-gray-500">{m.rule_name}</td> <td className="px-2 py-1 font-mono text-muted">{m.rule_name}</td>
<td className="px-2 py-1 font-mono text-gray-700"> <td className="px-2 py-1 font-mono text-ink-soft">
{typeof m.input_value === 'string' ? m.input_value : JSON.stringify(m.input_value)} {typeof m.input_value === 'string' ? m.input_value : JSON.stringify(m.input_value)}
</td> </td>
<td className="px-2 py-1 font-mono text-gray-700"> <td className="px-2 py-1 font-mono text-ink-soft">
{Object.entries(m.output).map(([k, v]) => ( {Object.entries(m.output).map(([k, v]) => (
<span key={k} className={k === selected.col ? 'text-blue-600 font-semibold' : ''}> <span key={k} className={k === selected.col ? 'text-accent font-semibold' : ''}>
{k}: {v}{' '} {k}: {v}{' '}
</span> </span>
))} ))}

View File

@ -7,27 +7,27 @@ function PreviewModal({ rows, onClose }) {
const matched = rows.filter(r => r.extracted_value != null).length const matched = rows.filter(r => r.extracted_value != null).length
return ( return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}> <div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
<div className="bg-white rounded-lg shadow-xl w-3/4 max-w-3xl max-h-[80vh] flex flex-col" <div className="bg-surface rounded-lg shadow-xl w-3/4 max-w-3xl max-h-[80vh] flex flex-col"
onClick={e => e.stopPropagation()}> onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-100"> <div className="flex items-center justify-between px-5 py-3 border-b border-line-soft">
<span className="text-sm font-medium text-gray-700"> <span className="text-sm font-medium text-ink-soft">
Pattern results <span className="text-gray-500 font-normal">{matched}/{rows.length} matched</span> Pattern results <span className="text-muted font-normal">{matched}/{rows.length} matched</span>
</span> </span>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-lg leading-none"></button> <button onClick={onClose} className="text-muted hover:text-ink-soft text-lg leading-none"></button>
</div> </div>
<div className="overflow-auto flex-1 px-5 py-3"> <div className="overflow-auto flex-1 px-5 py-3">
<table className="w-full text-xs"> <table className="w-full text-xs">
<thead> <thead>
<tr className="text-left text-gray-400 border-b border-gray-100"> <tr className="text-left text-muted border-b border-line-soft">
<th className="pb-2 font-medium w-1/2 pr-4">Raw value</th> <th className="pb-2 font-medium w-1/2 pr-4">Raw value</th>
<th className="pb-2 font-medium">Result</th> <th className="pb-2 font-medium">Result</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rows.map((r, i) => ( {rows.map((r, i) => (
<tr key={i} className="border-t border-gray-50"> <tr key={i} className="border-t border-line-soft">
<td className="py-1 font-mono text-gray-400 pr-4 break-all">{r.raw_value}</td> <td className="py-1 font-mono text-muted pr-4 break-all">{r.raw_value}</td>
<td className={`py-1 font-mono break-all ${r.extracted_value != null ? 'text-gray-800' : 'text-gray-300'}`}> <td className={`py-1 font-mono break-all ${r.extracted_value != null ? 'text-ink' : 'text-muted'}`}>
{r.extracted_value != null {r.extracted_value != null
? (Array.isArray(r.extracted_value) ? r.extracted_value.join(' · ') : String(r.extracted_value)) ? (Array.isArray(r.extracted_value) ? r.extracted_value.join(' · ') : String(r.extracted_value))
: '—'} : '—'}
@ -67,33 +67,33 @@ function FormPanel({ form, setForm, editing, error, loading, fields, source, onS
}, [form.field, form.pattern, form.flags, form.function_type, form.replace_value, source]) }, [form.field, form.pattern, form.flags, form.function_type, form.replace_value, source])
return ( return (
<div className="bg-white border border-gray-200 rounded p-4 mb-4"> <div className="bg-surface border border-line rounded p-4 mb-4">
<h2 className="text-sm font-semibold text-gray-700 mb-3">{editing ? 'Edit rule' : 'New rule'}</h2> <h2 className="text-sm font-semibold text-ink-soft mb-3">{editing ? 'Edit rule' : 'New rule'}</h2>
<form onSubmit={onSubmit} className="space-y-3"> <form onSubmit={onSubmit} className="space-y-3">
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="text-xs text-gray-500 block mb-1">Rule name</label> <label className="text-xs text-muted block mb-1">Rule name</label>
<input <input
className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400" className="w-full border border-line rounded px-3 py-1.5 text-sm focus:outline-none focus:border-accent"
value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
placeholder="e.g. First 20" placeholder="e.g. First 20"
/> />
</div> </div>
<div> <div>
<label className="text-xs text-gray-500 block mb-1">Sequence</label> <label className="text-xs text-muted block mb-1">Sequence</label>
<input <input
type="number" type="number"
className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400" className="w-full border border-line rounded px-3 py-1.5 text-sm focus:outline-none focus:border-accent"
value={form.sequence} onChange={e => setForm(f => ({ ...f, sequence: parseInt(e.target.value) || 0 }))} value={form.sequence} onChange={e => setForm(f => ({ ...f, sequence: parseInt(e.target.value) || 0 }))}
/> />
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="text-xs text-gray-500 block mb-1">Input field</label> <label className="text-xs text-muted block mb-1">Input field</label>
{fields.length > 0 ? ( {fields.length > 0 ? (
<select <select
className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400" className="w-full border border-line rounded px-3 py-1.5 text-sm focus:outline-none focus:border-accent"
value={form.field} onChange={e => setForm(f => ({ ...f, field: e.target.value }))} value={form.field} onChange={e => setForm(f => ({ ...f, field: e.target.value }))}
> >
<option value=""> select field </option> <option value=""> select field </option>
@ -101,34 +101,34 @@ function FormPanel({ form, setForm, editing, error, loading, fields, source, onS
</select> </select>
) : ( ) : (
<input <input
className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400" className="w-full border border-line rounded px-3 py-1.5 text-sm focus:outline-none focus:border-accent"
value={form.field} onChange={e => setForm(f => ({ ...f, field: e.target.value }))} value={form.field} onChange={e => setForm(f => ({ ...f, field: e.target.value }))}
placeholder="e.g. description" placeholder="e.g. description"
/> />
)} )}
</div> </div>
<div> <div>
<label className="text-xs text-gray-500 block mb-1">Output field</label> <label className="text-xs text-muted block mb-1">Output field</label>
<input <input
className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400" className="w-full border border-line rounded px-3 py-1.5 text-sm focus:outline-none focus:border-accent"
value={form.output_field} onChange={e => setForm(f => ({ ...f, output_field: e.target.value }))} value={form.output_field} onChange={e => setForm(f => ({ ...f, output_field: e.target.value }))}
placeholder="e.g. merchant" placeholder="e.g. merchant"
/> />
</div> </div>
</div> </div>
<div> <div>
<label className="text-xs text-gray-500 block mb-1">Pattern (regex)</label> <label className="text-xs text-muted block mb-1">Pattern (regex)</label>
<input <input
className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:border-blue-400" className="w-full border border-line rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:border-accent"
value={form.pattern} onChange={e => setForm(f => ({ ...f, pattern: e.target.value }))} value={form.pattern} onChange={e => setForm(f => ({ ...f, pattern: e.target.value }))}
placeholder="e.g. .{1,20}" placeholder="e.g. .{1,20}"
/> />
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="text-xs text-gray-500 block mb-1">Function</label> <label className="text-xs text-muted block mb-1">Function</label>
<select <select
className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400" className="w-full border border-line rounded px-3 py-1.5 text-sm focus:outline-none focus:border-accent"
value={form.function_type} onChange={e => setForm(f => ({ ...f, function_type: e.target.value }))} value={form.function_type} onChange={e => setForm(f => ({ ...f, function_type: e.target.value }))}
> >
<option value="extract">extract</option> <option value="extract">extract</option>
@ -136,16 +136,16 @@ function FormPanel({ form, setForm, editing, error, loading, fields, source, onS
</select> </select>
</div> </div>
<div> <div>
<label className="text-xs text-gray-500 block mb-1">Flags</label> <label className="text-xs text-muted block mb-1">Flags</label>
<input <input
className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:border-blue-400" className="w-full border border-line rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:border-accent"
value={form.flags} onChange={e => setForm(f => ({ ...f, flags: e.target.value }))} value={form.flags} onChange={e => setForm(f => ({ ...f, flags: e.target.value }))}
placeholder="e.g. i" placeholder="e.g. i"
/> />
</div> </div>
</div> </div>
{form.function_type === 'extract' && ( {form.function_type === 'extract' && (
<label className="flex items-center gap-2 text-xs text-gray-600 cursor-pointer select-none"> <label className="flex items-center gap-2 text-xs text-ink-soft cursor-pointer select-none">
<input <input
type="checkbox" type="checkbox"
checked={!!form.retain} checked={!!form.retain}
@ -156,9 +156,9 @@ function FormPanel({ form, setForm, editing, error, loading, fields, source, onS
)} )}
{form.function_type === 'replace' && ( {form.function_type === 'replace' && (
<div> <div>
<label className="text-xs text-gray-500 block mb-1">Replacement string</label> <label className="text-xs text-muted block mb-1">Replacement string</label>
<input <input
className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:border-blue-400" className="w-full border border-line rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:border-accent"
value={form.replace_value} onChange={e => setForm(f => ({ ...f, replace_value: e.target.value }))} value={form.replace_value} onChange={e => setForm(f => ({ ...f, replace_value: e.target.value }))}
placeholder="e.g. leave blank to delete the match" placeholder="e.g. leave blank to delete the match"
/> />
@ -166,23 +166,23 @@ function FormPanel({ form, setForm, editing, error, loading, fields, source, onS
)} )}
{/* Live preview */} {/* Live preview */}
{(preview.length > 0 || previewing) && ( {(preview.length > 0 || previewing) && (
<div className="border border-gray-100 rounded p-2 bg-gray-50"> <div className="border border-line-soft rounded p-2 bg-raised">
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
<p className="text-xs text-gray-400"> <p className="text-xs text-muted">
{previewing ? 'Testing…' : `${preview.filter(r => r.extracted_value != null).length}/${preview.length} matched`} {previewing ? 'Testing…' : `${preview.filter(r => r.extracted_value != null).length}/${preview.length} matched`}
</p> </p>
{!previewing && preview.length > 0 && ( {!previewing && preview.length > 0 && (
<button type="button" onClick={() => setModalOpen(true)} <button type="button" onClick={() => setModalOpen(true)}
className="text-xs text-blue-400 hover:text-blue-600">expand</button> className="text-xs text-accent hover:text-accent">expand</button>
)} )}
</div> </div>
{!previewing && ( {!previewing && (
<table className="w-full text-xs"> <table className="w-full text-xs">
<tbody> <tbody>
{preview.slice(0, 5).map((r, i) => ( {preview.slice(0, 5).map((r, i) => (
<tr key={i} className="border-t border-gray-100 first:border-0"> <tr key={i} className="border-t border-line-soft first:border-0">
<td className="py-0.5 font-mono text-gray-400 truncate max-w-0 w-1/2 pr-3">{r.raw_value}</td> <td className="py-0.5 font-mono text-muted truncate max-w-0 w-1/2 pr-3">{r.raw_value}</td>
<td className={`py-0.5 font-mono truncate ${r.extracted_value != null ? 'text-gray-800' : 'text-gray-300'}`}> <td className={`py-0.5 font-mono truncate ${r.extracted_value != null ? 'text-ink' : 'text-muted'}`}>
{r.extracted_value != null {r.extracted_value != null
? (Array.isArray(r.extracted_value) ? r.extracted_value.join(' · ') : String(r.extracted_value)) ? (Array.isArray(r.extracted_value) ? r.extracted_value.join(' · ') : String(r.extracted_value))
: '—'} : '—'}
@ -197,14 +197,14 @@ function FormPanel({ form, setForm, editing, error, loading, fields, source, onS
{modalOpen && <PreviewModal rows={preview} onClose={() => setModalOpen(false)} />} {modalOpen && <PreviewModal rows={preview} onClose={() => setModalOpen(false)} />}
{error && <p className="text-xs text-red-500">{error}</p>} {error && <p className="text-xs text-danger">{error}</p>}
<div className="flex gap-2"> <div className="flex gap-2">
<button type="submit" disabled={loading} <button type="submit" disabled={loading}
className="text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700 disabled:opacity-50"> className="text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700 disabled:opacity-50">
{loading ? 'Saving…' : (editing ? 'Save' : 'Create')} {loading ? 'Saving…' : (editing ? 'Save' : 'Create')}
</button> </button>
<button type="button" onClick={onCancel} <button type="button" onClick={onCancel}
className="text-sm text-gray-500 px-3 py-1.5 rounded hover:bg-gray-100"> className="text-sm text-muted px-3 py-1.5 rounded hover:bg-raised">
Cancel Cancel
</button> </button>
</div> </div>
@ -310,12 +310,12 @@ export default function Rules({ source, onStale }) {
} }
} }
if (!source) return <div className="p-6 text-sm text-gray-400">Select a source first.</div> if (!source) return <div className="p-6 text-sm text-muted">Select a source first.</div>
return ( return (
<div className="p-6 max-w-3xl"> <div className="p-6 max-w-3xl">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h1 className="text-xl font-semibold text-gray-800">Rules {source}</h1> <h1 className="text-xl font-semibold text-ink">Rules {source}</h1>
<button onClick={startCreate} <button onClick={startCreate}
className="text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700"> className="text-sm bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700">
New rule New rule
@ -332,17 +332,17 @@ export default function Rules({ source, onStale }) {
)} )}
{rules.length === 0 && !creating && ( {rules.length === 0 && !creating && (
<p className="text-sm text-gray-400">No rules yet. Add a regex rule to start extracting values.</p> <p className="text-sm text-muted">No rules yet. Add a regex rule to start extracting values.</p>
)} )}
<div className="space-y-2"> <div className="space-y-2">
{rules.map(rule => { {rules.map(rule => {
const isExpanded = expanded === rule.id const isExpanded = expanded === rule.id
return ( return (
<div key={rule.id} className="bg-white border border-gray-200 rounded"> <div key={rule.id} className="bg-surface border border-line rounded">
{/* Header — always visible, click to expand/collapse */} {/* Header — always visible, click to expand/collapse */}
<div <div
className="flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-gray-50 select-none" className="flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-raised select-none"
onClick={() => { onClick={() => {
if (isExpanded) { setExpanded(null); setEditing(null) } if (isExpanded) { setExpanded(null); setEditing(null) }
else { setExpanded(rule.id); startEdit(rule) } else { setExpanded(rule.id); startEdit(rule) }
@ -350,33 +350,33 @@ export default function Rules({ source, onStale }) {
> >
<button <button
onClick={e => { e.stopPropagation(); handleToggle(rule) }} onClick={e => { e.stopPropagation(); handleToggle(rule) }}
className={`w-8 h-4 rounded-full flex-shrink-0 transition-colors ${rule.enabled ? 'bg-blue-500' : 'bg-gray-200'}`} className={`w-8 h-4 rounded-full flex-shrink-0 transition-colors ${rule.enabled ? 'bg-blue-500' : 'bg-raised'}`}
title={rule.enabled ? 'Disable' : 'Enable'} title={rule.enabled ? 'Disable' : 'Enable'}
/> />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<span className="font-medium text-gray-800 text-sm">{rule.name}</span> <span className="font-medium text-ink text-sm">{rule.name}</span>
<span className="text-gray-400 text-xs ml-2">seq {rule.sequence}</span> <span className="text-muted text-xs ml-2">seq {rule.sequence}</span>
{!isExpanded && ( {!isExpanded && (
<div className="text-xs text-gray-400 mt-0.5 truncate"> <div className="text-xs text-muted mt-0.5 truncate">
<span className="font-mono">{rule.field}</span> <span className="font-mono">{rule.field}</span>
<span className="mx-1"></span> <span className="mx-1"></span>
<span className="font-mono bg-gray-50 px-1 rounded">{rule.pattern}</span> <span className="font-mono bg-raised px-1 rounded">{rule.pattern}</span>
{rule.flags && <span className="text-blue-400 ml-1">/{rule.flags}</span>} {rule.flags && <span className="text-accent ml-1">/{rule.flags}</span>}
<span className="mx-1"></span> <span className="mx-1"></span>
<span className="font-mono">{rule.output_field}</span> <span className="font-mono">{rule.output_field}</span>
{rule.function_type === 'replace' && <span className="ml-1 text-orange-400">(replace)</span>} {rule.function_type === 'replace' && <span className="ml-1 text-warn">(replace)</span>}
</div> </div>
)} )}
</div> </div>
<span className="text-xs text-gray-300 flex-shrink-0">{isExpanded ? '▲' : '▼'}</span> <span className="text-xs text-muted flex-shrink-0">{isExpanded ? '▲' : '▼'}</span>
</div> </div>
{/* Expanded content */} {/* Expanded content */}
{isExpanded && ( {isExpanded && (
<div className="border-t border-gray-100"> <div className="border-t border-line-soft">
<div className="px-4 pt-3 pb-1 flex justify-end"> <div className="px-4 pt-3 pb-1 flex justify-end">
<button onClick={e => { e.stopPropagation(); handleDelete(rule.id) }} <button onClick={e => { e.stopPropagation(); handleDelete(rule.id) }}
className="text-xs text-red-400 hover:text-red-600">Delete</button> className="text-xs text-danger hover:text-danger">Delete</button>
</div> </div>
<div className="px-4 pb-4"> <div className="px-4 pb-4">
<FormPanel <FormPanel

View File

@ -156,15 +156,15 @@ export default function SourceDetail({ sources, setSources }) {
} }
} }
if (!sourceObj) return <div className="p-6 text-sm text-gray-400">Source not found.</div> if (!sourceObj) return <div className="p-6 text-sm text-muted">Source not found.</div>
return ( return (
<div className="p-6 max-w-5xl space-y-4"> <div className="p-6 max-w-5xl space-y-4">
{stats && ( {stats && (
<div className="flex gap-4 text-xs"> <div className="flex gap-4 text-xs">
<span className="text-gray-500"><span className="font-medium text-gray-800">{stats.total_records}</span> total</span> <span className="text-muted"><span className="font-medium text-ink">{stats.total_records}</span> total</span>
<span className="text-gray-500"><span className="font-medium text-gray-800">{stats.transformed_records}</span> transformed</span> <span className="text-muted"><span className="font-medium text-ink">{stats.transformed_records}</span> transformed</span>
<span className="text-gray-500"><span className="font-medium text-gray-800">{stats.pending_records}</span> pending</span> <span className="text-muted"><span className="font-medium text-ink">{stats.pending_records}</span> pending</span>
</div> </div>
)} )}
@ -176,13 +176,13 @@ export default function SourceDetail({ sources, setSources }) {
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
{bridgeAccounts === null ? ( {bridgeAccounts === null ? (
<> <>
<span className="text-xs text-gray-500 font-mono"> <span className="text-xs text-muted font-mono">
{sourceObj.config?.simplefin?.account_id || 'not linked'} {sourceObj.config?.simplefin?.account_id || 'not linked'}
</span> </span>
<button <button
onClick={loadBridgeAccounts} onClick={loadBridgeAccounts}
disabled={bridgeLoading} disabled={bridgeLoading}
className="text-xs border border-gray-300 rounded px-2 py-1 text-gray-600 hover:bg-gray-50 hover:border-gray-400 disabled:opacity-50" className="text-xs border border-line rounded px-2 py-1 text-ink-soft hover:bg-raised hover:border-line disabled:opacity-50"
> >
{bridgeLoading ? 'Loading…' : sourceObj.config?.simplefin?.account_id ? 'Change' : 'Link SimpleFIN account'} {bridgeLoading ? 'Loading…' : sourceObj.config?.simplefin?.account_id ? 'Change' : 'Link SimpleFIN account'}
</button> </button>
@ -191,7 +191,7 @@ export default function SourceDetail({ sources, setSources }) {
<select <select
value={sourceObj.config?.simplefin?.account_id || ''} value={sourceObj.config?.simplefin?.account_id || ''}
onChange={e => handleLinkAccount(e.target.value)} onChange={e => handleLinkAccount(e.target.value)}
className="text-xs border border-gray-200 rounded px-2 py-1 bg-white text-gray-700" className="text-xs border border-line rounded px-2 py-1 bg-surface text-ink-soft"
> >
<option value="">Not linked</option> <option value="">Not linked</option>
{bridgeAccounts.map(a => ( {bridgeAccounts.map(a => (
@ -203,12 +203,12 @@ export default function SourceDetail({ sources, setSources }) {
)} )}
</div> </div>
{bridgeError && <p className="text-xs text-orange-600 mt-1">{bridgeError}</p>} {bridgeError && <p className="text-xs text-warn mt-1">{bridgeError}</p>}
{/* Dedupe depends on the transaction id being the constraint key */} {/* Dedupe depends on the transaction id being the constraint key */}
{sourceObj.config?.simplefin?.account_id {sourceObj.config?.simplefin?.account_id
&& sourceObj.constraint_fields?.join(',') !== 'id' && ( && sourceObj.constraint_fields?.join(',') !== 'id' && (
<p className="text-xs text-orange-600 mt-1"> <p className="text-xs text-warn mt-1">
Constraint fields are {sourceObj.constraint_fields?.join(', ') || 'none'} a Constraint fields are {sourceObj.constraint_fields?.join(', ') || 'none'} a
bank feed should use id so re-syncs dont duplicate rows. bank feed should use id so re-syncs dont duplicate rows.
</p> </p>
@ -223,7 +223,7 @@ export default function SourceDetail({ sources, setSources }) {
> >
<table className="w-full text-xs"> <table className="w-full text-xs">
<thead> <thead>
<tr className="text-left text-gray-400 border-b border-gray-100"> <tr className="text-left text-muted border-b border-line-soft">
{[ {[
{ col: 'key', label: 'Key' }, { col: 'key', label: 'Key' },
{ col: 'origin', label: 'Origin' }, { col: 'origin', label: 'Origin' },
@ -235,10 +235,10 @@ export default function SourceDetail({ sources, setSources }) {
<th <th
key={col} key={col}
onClick={() => setFieldSort(s => ({ col, dir: s.col === col && s.dir === 'asc' ? 'desc' : 'asc' }))} onClick={() => setFieldSort(s => ({ col, dir: s.col === col && s.dir === 'asc' ? 'desc' : 'asc' }))}
className={`pb-1 font-medium cursor-pointer select-none hover:text-gray-600 ${center ? 'text-center' : ''}`} className={`pb-1 font-medium cursor-pointer select-none hover:text-ink-soft ${center ? 'text-center' : ''}`}
> >
{label} {label}
<span className="ml-1 text-gray-300"> <span className="ml-1 text-muted">
{fieldSort.col === col ? (fieldSort.dir === 'asc' ? '▲' : '▼') : '⇅'} {fieldSort.col === col ? (fieldSort.dir === 'asc' ? '▲' : '▼') : '⇅'}
</span> </span>
</th> </th>
@ -266,14 +266,14 @@ export default function SourceDetail({ sources, setSources }) {
const schemaEntry = schemaFields.find(sf => sf.name === f.key) const schemaEntry = schemaFields.find(sf => sf.name === f.key)
const inView = !!schemaEntry const inView = !!schemaEntry
return ( return (
<tr key={f.key} className="border-t border-gray-50"> <tr key={f.key} className="border-t border-line-soft">
<td className="py-1 font-mono text-gray-700">{f.key}</td> <td className="py-1 font-mono text-ink-soft">{f.key}</td>
<td className="py-1 text-gray-400">{f.origins.join(', ')}</td> <td className="py-1 text-muted">{f.origins.join(', ')}</td>
<td className="py-1"> <td className="py-1">
{inView && ( {inView && (
<div className="flex gap-1 items-center"> <div className="flex gap-1 items-center">
<select <select
className="border border-gray-200 rounded px-1 py-0.5 text-xs focus:outline-none focus:border-blue-400" className="border border-line rounded px-1 py-0.5 text-xs focus:outline-none focus:border-accent"
value={schemaEntry.type} value={schemaEntry.type}
onChange={e => setSchemaFields(sf => onChange={e => setSchemaFields(sf =>
sf.map(s => s.name === f.key ? { ...s, type: e.target.value } : s) sf.map(s => s.name === f.key ? { ...s, type: e.target.value } : s)
@ -282,7 +282,7 @@ export default function SourceDetail({ sources, setSources }) {
{FIELD_TYPES.map(t => <option key={t} value={t}>{t}</option>)} {FIELD_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
</select> </select>
<input <input
className="border border-gray-200 rounded px-1 py-0.5 text-xs font-mono w-32 focus:outline-none focus:border-blue-400" className="border border-line rounded px-1 py-0.5 text-xs font-mono w-32 focus:outline-none focus:border-accent"
value={schemaEntry.expression || ''} value={schemaEntry.expression || ''}
placeholder="{field} * {sign}" placeholder="{field} * {sign}"
onChange={e => setSchemaFields(sf => onChange={e => setSchemaFields(sf =>
@ -327,7 +327,7 @@ export default function SourceDetail({ sources, setSources }) {
{inView && ( {inView && (
<input <input
type="number" type="number"
className="w-12 border border-gray-200 rounded px-1 py-0.5 text-xs text-center focus:outline-none focus:border-blue-400" className="w-12 border border-line rounded px-1 py-0.5 text-xs text-center focus:outline-none focus:border-accent"
value={schemaEntry.seq ?? ''} value={schemaEntry.seq ?? ''}
onChange={e => setSchemaFields(sf => onChange={e => setSchemaFields(sf =>
sf.map(s => s.name === f.key ? { ...s, seq: parseInt(e.target.value) || 0 } : s) sf.map(s => s.name === f.key ? { ...s, seq: parseInt(e.target.value) || 0 } : s)
@ -341,8 +341,8 @@ export default function SourceDetail({ sources, setSources }) {
</tbody> </tbody>
</table> </table>
<div className="flex items-center gap-3 pt-3 mt-2 border-t border-gray-100 flex-wrap"> <div className="flex items-center gap-3 pt-3 mt-2 border-t border-line-soft flex-wrap">
<label className="flex items-center gap-1.5 text-xs text-gray-500 cursor-pointer"> <label className="flex items-center gap-1.5 text-xs text-muted cursor-pointer">
<input type="checkbox" checked={globalPicklist} onChange={e => setGlobalPicklist(e.target.checked)} /> <input type="checkbox" checked={globalPicklist} onChange={e => setGlobalPicklist(e.target.checked)} />
Global picklist Global picklist
</label> </label>
@ -362,7 +362,7 @@ export default function SourceDetail({ sources, setSources }) {
{generating ? 'Generating…' : 'Generate view'} {generating ? 'Generating…' : 'Generate view'}
</button> </button>
{viewName && ( {viewName && (
<code className="text-xs bg-gray-100 px-2 py-1 rounded text-gray-600">{viewName}</code> <code className="text-xs bg-raised px-2 py-1 rounded text-ink-soft">{viewName}</code>
)} )}
</> </>
)} )}
@ -377,7 +377,7 @@ export default function SourceDetail({ sources, setSources }) {
description="No fields yet — they are discovered from imported records. Import or sync data first." description="No fields yet — they are discovered from imported records. Import or sync data first."
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<label className="flex items-center gap-1.5 text-xs text-gray-500 cursor-pointer"> <label className="flex items-center gap-1.5 text-xs text-muted cursor-pointer">
<input type="checkbox" checked={globalPicklist} onChange={e => setGlobalPicklist(e.target.checked)} /> <input type="checkbox" checked={globalPicklist} onChange={e => setGlobalPicklist(e.target.checked)} />
Global picklist Global picklist
</label> </label>
@ -406,16 +406,16 @@ export default function SourceDetail({ sources, setSources }) {
> >
{reprocessing ? 'Reprocessing…' : 'Reprocess all records'} {reprocessing ? 'Reprocessing…' : 'Reprocess all records'}
</button> </button>
<span className="text-xs text-gray-400">Clears and reruns all transformation rules</span> <span className="text-xs text-muted">Clears and reruns all transformation rules</span>
</div> </div>
</Section> </Section>
{result && <p className="text-xs text-green-600">{result}</p>} {result && <p className="text-xs text-ok">{result}</p>}
{error && <p className="text-xs text-red-500">{error}</p>} {error && <p className="text-xs text-danger">{error}</p>}
<Section title="Delete source" description="Removes the source and every record, rule, and mapping belonging to it."> <Section title="Delete source" description="Removes the source and every record, rule, and mapping belonging to it.">
<button onClick={handleDelete} <button onClick={handleDelete}
className="text-sm border border-red-200 text-red-500 px-3 py-1.5 rounded hover:bg-red-50 hover:border-red-300"> className="text-sm border border-danger-line text-danger px-3 py-1.5 rounded hover:bg-danger-soft hover:border-danger-line">
Delete source Delete source
</button> </button>
</Section> </Section>

View File

@ -126,7 +126,7 @@ export default function SourceList({ sources, setSources, setSource }) {
return ( return (
<div className="p-6 max-w-5xl"> <div className="p-6 max-w-5xl">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h1 className="text-xl font-semibold text-gray-800">Sources</h1> <h1 className="text-xl font-semibold text-ink">Sources</h1>
{!creating && ( {!creating && (
<button <button
onClick={() => { setCreating(true); setCreateError('') }} onClick={() => { setCreating(true); setCreateError('') }}
@ -138,42 +138,42 @@ export default function SourceList({ sources, setSources, setSource }) {
</div> </div>
{!creating && sources.length === 0 && ( {!creating && sources.length === 0 && (
<p className="text-sm text-gray-400">No sources yet. Create one to get started.</p> <p className="text-sm text-muted">No sources yet. Create one to get started.</p>
)} )}
{!creating && sources.length > 0 && ( {!creating && sources.length > 0 && (
<div className="bg-white border border-gray-200 rounded divide-y divide-gray-100"> <div className="bg-surface border border-line rounded divide-y divide-line-soft">
{sources.map(s => ( {sources.map(s => (
<button <button
key={s.name} key={s.name}
onClick={() => { setSource(s.name); navigate(`/sources/${encodeURIComponent(s.name)}`) }} onClick={() => { setSource(s.name); navigate(`/sources/${encodeURIComponent(s.name)}`) }}
className="w-full text-left px-4 py-3 hover:bg-gray-50 flex items-center gap-3" className="w-full text-left px-4 py-3 hover:bg-raised flex items-center gap-3"
> >
<span className="text-sm font-medium text-gray-800 flex-1">{s.name}</span> <span className="text-sm font-medium text-ink flex-1">{s.name}</span>
{s.config?.simplefin?.account_id && ( {s.config?.simplefin?.account_id && (
<span className="text-xs bg-blue-50 text-blue-600 border border-blue-100 rounded px-1.5 py-0.5"> <span className="text-xs bg-accent-soft text-accent border border-accent-line rounded px-1.5 py-0.5">
bank feed bank feed
</span> </span>
)} )}
<span className="text-xs text-gray-400"> <span className="text-xs text-muted">
{(s.constraint_fields || []).join(', ') || 'no constraint'} {(s.constraint_fields || []).join(', ') || 'no constraint'}
</span> </span>
<span className="text-gray-300"></span> <span className="text-muted"></span>
</button> </button>
))} ))}
</div> </div>
)} )}
{creating && ( {creating && (
<div className="bg-white border border-gray-200 rounded p-4"> <div className="bg-surface border border-line rounded p-4">
<h2 className="text-sm font-semibold text-gray-700 mb-3">New source</h2> <h2 className="text-sm font-semibold text-ink-soft mb-3">New source</h2>
<div className="mb-4"> <div className="mb-4">
<input type="file" accept=".csv" ref={fileRef} onChange={handleSuggest} className="hidden" /> <input type="file" accept=".csv" ref={fileRef} onChange={handleSuggest} className="hidden" />
<button <button
type="button" type="button"
onClick={() => fileRef.current?.click()} onClick={() => fileRef.current?.click()}
className="text-sm border border-gray-300 rounded px-3 py-1.5 text-gray-600 hover:bg-gray-50 hover:border-gray-400" className="text-sm border border-line rounded px-3 py-1.5 text-ink-soft hover:bg-raised hover:border-line"
> >
{csvFileName || 'Choose CSV…'} {csvFileName || 'Choose CSV…'}
</button> </button>
@ -181,9 +181,9 @@ export default function SourceList({ sources, setSources, setSource }) {
<form onSubmit={handleCreate} className="space-y-3"> <form onSubmit={handleCreate} className="space-y-3">
<div> <div>
<label className="text-xs text-gray-500 block mb-1">Source name</label> <label className="text-xs text-muted block mb-1">Source name</label>
<input <input
className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400" className="w-full border border-line rounded px-3 py-1.5 text-sm focus:outline-none focus:border-accent"
value={form.name} value={form.name}
onChange={e => setForm(f => ({ ...f, name: e.target.value }))} onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
placeholder="e.g. chase, dcard" placeholder="e.g. chase, dcard"
@ -193,14 +193,14 @@ export default function SourceList({ sources, setSources, setSource }) {
{/* Bank feed optional; picking an account defaults the constraint {/* Bank feed optional; picking an account defaults the constraint
field to the transaction id, which is what dedupe needs */} field to the transaction id, which is what dedupe needs */}
<div> <div>
<label className="text-xs text-gray-500 block mb-1">Bank feed (optional)</label> <label className="text-xs text-muted block mb-1">Bank feed (optional)</label>
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
{bridgeAccounts === null ? ( {bridgeAccounts === null ? (
<button <button
type="button" type="button"
onClick={loadBridgeAccounts} onClick={loadBridgeAccounts}
disabled={bridgeLoading} disabled={bridgeLoading}
className="text-sm border border-gray-300 rounded px-3 py-1.5 text-gray-600 hover:bg-gray-50 hover:border-gray-400 disabled:opacity-50" className="text-sm border border-line rounded px-3 py-1.5 text-ink-soft hover:bg-raised hover:border-line disabled:opacity-50"
> >
{bridgeLoading ? 'Loading…' : 'Link SimpleFIN account…'} {bridgeLoading ? 'Loading…' : 'Link SimpleFIN account…'}
</button> </button>
@ -208,7 +208,7 @@ export default function SourceList({ sources, setSources, setSource }) {
<select <select
value={form.simplefin_account_id || ''} value={form.simplefin_account_id || ''}
onChange={e => handleSelectFeedAccount(e.target.value)} onChange={e => handleSelectFeedAccount(e.target.value)}
className="text-sm border border-gray-200 rounded px-3 py-1.5 bg-white text-gray-700" className="text-sm border border-line rounded px-3 py-1.5 bg-surface text-ink-soft"
> >
<option value="">No bank feed CSV import</option> <option value="">No bank feed CSV import</option>
{bridgeAccounts.map(a => ( {bridgeAccounts.map(a => (
@ -219,10 +219,10 @@ export default function SourceList({ sources, setSources, setSource }) {
</select> </select>
)} )}
</div> </div>
{bridgeError && <p className="text-xs text-orange-600 mt-1">{bridgeError}</p>} {bridgeError && <p className="text-xs text-warn mt-1">{bridgeError}</p>}
{form.simplefin_account_id && sampleInfo && ( {form.simplefin_account_id && sampleInfo && (
<div className="mt-2 bg-blue-50 border border-blue-100 rounded p-3 text-xs text-gray-600 space-y-1"> <div className="mt-2 bg-accent-soft border border-accent-line rounded p-3 text-xs text-ink-soft space-y-1">
<p> <p>
Read {sampleInfo.fetched} transaction{sampleInfo.fetched === 1 ? '' : 's'} from this Read {sampleInfo.fetched} transaction{sampleInfo.fetched === 1 ? '' : 's'} from this
account and found {sampleInfo.fields} field{sampleInfo.fields === 1 ? '' : 's'}. account and found {sampleInfo.fields} field{sampleInfo.fields === 1 ? '' : 's'}.
@ -231,16 +231,16 @@ export default function SourceList({ sources, setSources, setSource }) {
</p> </p>
{form.constraint_fields === 'id' && ( {form.constraint_fields === 'id' && (
<p> <p>
<span className="font-mono text-gray-700">id</span> is checked as the constraint <span className="font-mono text-ink-soft">id</span> is checked as the constraint
field because it is SimpleFIN&rsquo;s own transaction identifier. Syncs pull an field because it is SimpleFIN&rsquo;s own transaction identifier. Syncs pull an
overlapping window of days, so the same transaction arrives more than once overlapping window of days, so the same transaction arrives more than once
matching on <span className="font-mono text-gray-700">id</span> skips the repeats matching on <span className="font-mono text-ink-soft">id</span> skips the repeats
while still keeping genuinely separate charges that share a date, amount, and while still keeping genuinely separate charges that share a date, amount, and
description. description.
</p> </p>
)} )}
{sampleInfo.fetched === 0 && ( {sampleInfo.fetched === 0 && (
<p className="text-orange-600"> <p className="text-warn">
No transactions came back, so there was nothing to infer fields from. Sync first, No transactions came back, so there was nothing to infer fields from. Sync first,
then set the fields up here. then set the fields up here.
</p> </p>
@ -250,10 +250,10 @@ export default function SourceList({ sources, setSources, setSource }) {
</div> </div>
{form.fields.length > 0 && ( {form.fields.length > 0 && (
<div className="pt-2 border-t border-gray-100 space-y-2"> <div className="pt-2 border-t border-line-soft space-y-2">
<table className="w-full text-xs"> <table className="w-full text-xs">
<thead> <thead>
<tr className="text-left text-gray-400 border-b border-gray-100"> <tr className="text-left text-muted border-b border-line-soft">
<th className="pb-1 font-medium">Key</th> <th className="pb-1 font-medium">Key</th>
<th className="pb-1 font-medium">Type</th> <th className="pb-1 font-medium">Type</th>
<th className="pb-1 font-medium text-center">Constraint</th> <th className="pb-1 font-medium text-center">Constraint</th>
@ -267,12 +267,12 @@ export default function SourceList({ sources, setSources, setSource }) {
const inView = !!schemaEntry const inView = !!schemaEntry
const currentType = schemaEntry?.type || f.type const currentType = schemaEntry?.type || f.type
return ( return (
<tr key={f.name} className="border-t border-gray-50"> <tr key={f.name} className="border-t border-line-soft">
<td className="py-1 font-mono text-gray-700">{f.name}</td> <td className="py-1 font-mono text-ink-soft">{f.name}</td>
<td className="py-1"> <td className="py-1">
{inView && ( {inView && (
<select <select
className="border border-gray-200 rounded px-1 py-0.5 text-xs focus:outline-none focus:border-blue-400" className="border border-line rounded px-1 py-0.5 text-xs focus:outline-none focus:border-accent"
value={currentType} value={currentType}
onChange={e => setForm(ff => ({ onChange={e => setForm(ff => ({
...ff, ...ff,
@ -316,7 +316,7 @@ export default function SourceList({ sources, setSources, setSource }) {
{inView && ( {inView && (
<input <input
type="number" type="number"
className="w-12 border border-gray-200 rounded px-1 py-0.5 text-xs text-center focus:outline-none focus:border-blue-400" className="w-12 border border-line rounded px-1 py-0.5 text-xs text-center focus:outline-none focus:border-accent"
value={schemaEntry.seq ?? ''} value={schemaEntry.seq ?? ''}
onChange={e => setForm(ff => ({ onChange={e => setForm(ff => ({
...ff, ...ff,
@ -336,9 +336,9 @@ export default function SourceList({ sources, setSources, setSource }) {
{form.fields.length === 0 && ( {form.fields.length === 0 && (
<div> <div>
<label className="text-xs text-gray-500 block mb-1">Constraint fields (comma-separated)</label> <label className="text-xs text-muted block mb-1">Constraint fields (comma-separated)</label>
<input <input
className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400" className="w-full border border-line rounded px-3 py-1.5 text-sm focus:outline-none focus:border-accent"
value={form.constraint_fields} value={form.constraint_fields}
onChange={e => setForm(f => ({ ...f, constraint_fields: e.target.value }))} onChange={e => setForm(f => ({ ...f, constraint_fields: e.target.value }))}
placeholder="e.g. date, amount, description" placeholder="e.g. date, amount, description"
@ -347,7 +347,7 @@ export default function SourceList({ sources, setSources, setSource }) {
)} )}
<div className="flex gap-4"> <div className="flex gap-4">
<label className="flex items-center gap-1.5 text-xs text-gray-500 cursor-pointer"> <label className="flex items-center gap-1.5 text-xs text-muted cursor-pointer">
<input <input
type="checkbox" type="checkbox"
checked={form.global_picklist !== false} checked={form.global_picklist !== false}
@ -356,7 +356,7 @@ export default function SourceList({ sources, setSources, setSource }) {
Global picklist Global picklist
</label> </label>
{form.fields.length > 0 && ( {form.fields.length > 0 && (
<label className="flex items-center gap-1.5 text-xs text-gray-500 cursor-pointer"> <label className="flex items-center gap-1.5 text-xs text-muted cursor-pointer">
<input <input
type="checkbox" type="checkbox"
checked={form.importSample !== false} checked={form.importSample !== false}
@ -367,7 +367,7 @@ export default function SourceList({ sources, setSources, setSource }) {
)} )}
</div> </div>
{createError && <p className="text-xs text-red-500">{createError}</p>} {createError && <p className="text-xs text-danger">{createError}</p>}
<div className="flex gap-2"> <div className="flex gap-2">
<button type="submit" disabled={createLoading} <button type="submit" disabled={createLoading}
@ -376,7 +376,7 @@ export default function SourceList({ sources, setSources, setSource }) {
</button> </button>
<button type="button" <button type="button"
onClick={() => { setCreating(false); setCreateError(''); setForm({ name: '', constraint_fields: '', fields: [], schema: [] }) }} onClick={() => { setCreating(false); setCreateError(''); setForm({ name: '', constraint_fields: '', fields: [], schema: [] }) }}
className="text-sm text-gray-500 px-3 py-1.5 rounded hover:bg-gray-100"> className="text-sm text-muted px-3 py-1.5 rounded hover:bg-raised">
Cancel Cancel
</button> </button>
</div> </div>

View File

@ -54,54 +54,54 @@ function CalibrateModal({ stack, sourceName, currentOffset, onClose, onApply })
return ( return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onMouseDown={e => { if (e.target === e.currentTarget) onClose() }}> <div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onMouseDown={e => { if (e.target === e.currentTarget) onClose() }}>
<div className="bg-white rounded-lg shadow-xl w-[420px] p-5" onClick={e => e.stopPropagation()}> <div className="bg-surface rounded-lg shadow-xl w-[420px] p-5" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<span className="text-sm font-semibold text-gray-700">Calibrate {sourceName}</span> <span className="text-sm font-semibold text-ink-soft">Calibrate {sourceName}</span>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"></button> <button onClick={onClose} className="text-muted hover:text-ink-soft"></button>
</div> </div>
{/* Date */} {/* Date */}
<div className="mb-4"> <div className="mb-4">
<label className="text-xs text-gray-500 block mb-1">As-of date</label> <label className="text-xs text-muted block mb-1">As-of date</label>
<input type="date" className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400" <input type="date" className="w-full border border-line rounded px-3 py-1.5 text-sm focus:outline-none focus:border-accent"
value={asOf} onChange={e => setAsOf(e.target.value)} /> value={asOf} onChange={e => setAsOf(e.target.value)} />
</div> </div>
{/* Reconciliation table */} {/* Reconciliation table */}
<div className="bg-gray-50 rounded border border-gray-200 mb-4 text-sm"> <div className="bg-raised rounded border border-line mb-4 text-sm">
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-200"> <div className="flex items-center justify-between px-3 py-2 border-b border-line">
<span className="text-gray-500 text-xs">Data sum at date</span> <span className="text-muted text-xs">Data sum at date</span>
<span className="font-mono text-gray-700"> <span className="font-mono text-ink-soft">
{loading ? <span className="text-gray-300"></span> : computed !== null ? fmt(computed) : <span className="text-gray-300"></span>} {loading ? <span className="text-muted"></span> : computed !== null ? fmt(computed) : <span className="text-muted"></span>}
</span> </span>
</div> </div>
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-200"> <div className="flex items-center justify-between px-3 py-2 border-b border-line">
<span className="text-gray-500 text-xs">Known balance</span> <span className="text-muted text-xs">Known balance</span>
<input <input
type="number" step="0.01" type="number" step="0.01"
className="font-mono text-right bg-transparent border-0 focus:outline-none w-36 text-sm text-gray-700 placeholder-gray-300" className="font-mono text-right bg-transparent border-0 focus:outline-none w-36 text-sm text-ink-soft placeholder-gray-300"
placeholder="enter balance" placeholder="enter balance"
value={known} onChange={e => setKnown(e.target.value)} value={known} onChange={e => setKnown(e.target.value)}
/> />
</div> </div>
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-200"> <div className="flex items-center justify-between px-3 py-2 border-b border-line">
<span className="text-gray-500 text-xs">Current offset</span> <span className="text-muted text-xs">Current offset</span>
<span className="font-mono text-gray-400">{fmt(currentOffset ?? 0)}</span> <span className="font-mono text-muted">{fmt(currentOffset ?? 0)}</span>
</div> </div>
<div className="flex items-center justify-between px-3 py-2 font-medium"> <div className="flex items-center justify-between px-3 py-2 font-medium">
<span className="text-gray-700 text-xs">Plug (offset needed)</span> <span className="text-ink-soft text-xs">Plug (offset needed)</span>
<span className={`font-mono ${plug !== null ? 'text-blue-700' : 'text-gray-300'}`}> <span className={`font-mono ${plug !== null ? 'text-accent' : 'text-muted'}`}>
{plug !== null ? fmt(plug) : '—'} {plug !== null ? fmt(plug) : '—'}
</span> </span>
</div> </div>
</div> </div>
{error && <p className="text-xs text-red-500 mb-3">{error}</p>} {error && <p className="text-xs text-danger mb-3">{error}</p>}
{/* Apply */} {/* Apply */}
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<input type="number" step="0.01" <input type="number" step="0.01"
className="flex-1 border border-gray-200 rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:border-blue-400" className="flex-1 border border-line rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:border-accent"
placeholder="offset to apply" placeholder="offset to apply"
value={applyOffset} onChange={e => setApplyOffset(e.target.value)} /> value={applyOffset} onChange={e => setApplyOffset(e.target.value)} />
<button onClick={() => onApply(parseFloat(applyOffset))} disabled={applyOffset === '' || isNaN(parseFloat(applyOffset))} <button onClick={() => onApply(parseFloat(applyOffset))} disabled={applyOffset === '' || isNaN(parseFloat(applyOffset))}
@ -434,12 +434,12 @@ function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated, onSql
<div className="space-y-5"> <div className="space-y-5">
{/* Label */} {/* Label */}
<div className="bg-white border border-gray-200 rounded p-4"> <div className="bg-surface border border-line rounded p-4">
<h3 className="text-sm font-semibold text-gray-700 mb-3">Configuration</h3> <h3 className="text-sm font-semibold text-ink-soft mb-3">Configuration</h3>
<div className="flex gap-3 items-end"> <div className="flex gap-3 items-end">
<div className="flex-1"> <div className="flex-1">
<label className="text-xs text-gray-500 block mb-1">Label <span className="text-gray-400">(optional)</span></label> <label className="text-xs text-muted block mb-1">Label <span className="text-muted">(optional)</span></label>
<input className="w-full border border-gray-200 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-blue-400" <input className="w-full border border-line rounded px-3 py-1.5 text-sm focus:outline-none focus:border-accent"
value={label} onChange={e => setLabel(e.target.value)} value={label} onChange={e => setLabel(e.target.value)}
onKeyDown={e => e.key === 'Enter' && saveLabel()} /> onKeyDown={e => e.key === 'Enter' && saveLabel()} />
</div> </div>
@ -448,13 +448,13 @@ function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated, onSql
{saving ? 'Saving…' : 'Save'} {saving ? 'Saving…' : 'Save'}
</button> </button>
</div> </div>
{error && <p className="text-xs text-red-500 mt-2">{error}</p>} {error && <p className="text-xs text-danger mt-2">{error}</p>}
</div> </div>
{/* Sources */} {/* Sources */}
<div className="bg-white border border-gray-200 rounded p-4"> <div className="bg-surface border border-line rounded p-4">
<h3 className="text-sm font-semibold text-gray-700 mb-1">Sources</h3> <h3 className="text-sm font-semibold text-ink-soft mb-1">Sources</h3>
<p className="text-xs text-gray-400 mb-3">Each source contributes rows to the combined view. Set the sign to flip the direction of amounts (e.g. credit card charges are positive in the source but should subtract from your balance). The offset adjusts the running balance use Calibrate to compute it from a known good balance.</p> <p className="text-xs text-muted mb-3">Each source contributes rows to the combined view. Set the sign to flip the direction of amounts (e.g. credit card charges are positive in the source but should subtract from your balance). The offset adjusts the running balance use Calibrate to compute it from a known good balance.</p>
<div className="space-y-2 mb-3"> <div className="space-y-2 mb-3">
{members.map((m, idx) => { {members.map((m, idx) => {
const cfg = srcCfg[m.source_name] || {} const cfg = srcCfg[m.source_name] || {}
@ -467,50 +467,50 @@ function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated, onSql
onDragOver={e => handleSrcDragOver(e, idx)} onDragOver={e => handleSrcDragOver(e, idx)}
onDrop={e => handleSrcDrop(e, idx)} onDrop={e => handleSrcDrop(e, idx)}
onDragEnd={() => { setSrcDragIdx(null); setSrcDragOverIdx(null) }} onDragEnd={() => { setSrcDragIdx(null); setSrcDragOverIdx(null) }}
className={`border border-gray-100 rounded px-3 py-2 text-xs space-y-2 ${srcDragOverIdx === idx && srcDragIdx !== idx ? 'bg-blue-50' : ''}`}> className={`border border-line-soft rounded px-3 py-2 text-xs space-y-2 ${srcDragOverIdx === idx && srcDragIdx !== idx ? 'bg-accent-soft' : ''}`}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-gray-300 cursor-grab select-none"></span> <span className="text-muted cursor-grab select-none"></span>
<span className="font-medium text-gray-700 flex-1">{m.source_name}</span> <span className="font-medium text-ink-soft flex-1">{m.source_name}</span>
<button onClick={() => removeSource(m.source_name)} className="text-red-300 hover:text-red-500">Remove</button> <button onClick={() => removeSource(m.source_name)} className="text-danger hover:text-danger">Remove</button>
</div> </div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5"> <div className="grid grid-cols-2 gap-x-4 gap-y-1.5">
<div> <div>
<label className="text-gray-400 block mb-0.5">Amount field</label> <label className="text-muted block mb-0.5">Amount field</label>
<select value={cfg.amount_field || ''} <select value={cfg.amount_field || ''}
onChange={e => handleSrcAmountField(m.source_name, e.target.value)} onChange={e => handleSrcAmountField(m.source_name, e.target.value)}
className="w-full border border-gray-200 rounded px-1.5 py-0.5 focus:outline-none focus:border-blue-400"> className="w-full border border-line rounded px-1.5 py-0.5 focus:outline-none focus:border-accent">
<option value=""> select </option> <option value=""> select </option>
{sf.map(f => <option key={f} value={f}>{f}</option>)} {sf.map(f => <option key={f} value={f}>{f}</option>)}
</select> </select>
</div> </div>
<div> <div>
<label className="text-gray-400 block mb-0.5">Sign</label> <label className="text-muted block mb-0.5">Sign</label>
<select value={cfg.sign ?? 1} <select value={cfg.sign ?? 1}
onChange={e => { setSrcSign(m.source_name, parseInt(e.target.value)); setMappingsDirty(true) }} onChange={e => { setSrcSign(m.source_name, parseInt(e.target.value)); setMappingsDirty(true) }}
className="w-full border border-gray-200 rounded px-1.5 py-0.5 focus:outline-none focus:border-blue-400"> className="w-full border border-line rounded px-1.5 py-0.5 focus:outline-none focus:border-accent">
<option value={1}>+1 (as-is)</option> <option value={1}>+1 (as-is)</option>
<option value={-1}>1 (flip)</option> <option value={-1}>1 (flip)</option>
</select> </select>
</div> </div>
<div> <div>
<label className="text-gray-400 block mb-0.5">Date field</label> <label className="text-muted block mb-0.5">Date field</label>
<select value={cfg.date_field || ''} <select value={cfg.date_field || ''}
onChange={e => handleSrcDateField(m.source_name, e.target.value)} onChange={e => handleSrcDateField(m.source_name, e.target.value)}
className="w-full border border-gray-200 rounded px-1.5 py-0.5 focus:outline-none focus:border-blue-400"> className="w-full border border-line rounded px-1.5 py-0.5 focus:outline-none focus:border-accent">
<option value=""> select </option> <option value=""> select </option>
{sf.map(f => <option key={f} value={f}>{f}</option>)} {sf.map(f => <option key={f} value={f}>{f}</option>)}
</select> </select>
</div> </div>
<div> <div>
<label className="text-gray-400 block mb-0.5">Balance offset</label> <label className="text-muted block mb-0.5">Balance offset</label>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<input type="number" step="0.01" value={cfg.offset ?? 0} <input type="number" step="0.01" value={cfg.offset ?? 0}
onChange={e => { setSrcOffset(m.source_name, parseFloat(e.target.value) || 0); setMappingsDirty(true) }} onChange={e => { setSrcOffset(m.source_name, parseFloat(e.target.value) || 0); setMappingsDirty(true) }}
className="flex-1 border border-gray-200 rounded px-1.5 py-0.5 font-mono focus:outline-none focus:border-blue-400" /> className="flex-1 border border-line rounded px-1.5 py-0.5 font-mono focus:outline-none focus:border-accent" />
<button onClick={() => handleCalibrate(m.source_name)} <button onClick={() => handleCalibrate(m.source_name)}
disabled={!canCalibrate} disabled={!canCalibrate}
title={!canCalibrate ? 'Set amount and date fields first' : 'Calibrate balance'} title={!canCalibrate ? 'Set amount and date fields first' : 'Calibrate balance'}
className="text-blue-400 hover:text-blue-600 underline disabled:opacity-40 disabled:cursor-not-allowed disabled:no-underline"> className="text-accent hover:text-accent underline disabled:opacity-40 disabled:cursor-not-allowed disabled:no-underline">
Calibrate Calibrate
</button> </button>
</div> </div>
@ -519,43 +519,43 @@ function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated, onSql
</div> </div>
) )
})} })}
{members.length === 0 && <p className="text-xs text-gray-400">No sources added yet.</p>} {members.length === 0 && <p className="text-xs text-muted">No sources added yet.</p>}
</div> </div>
{availableSources.length > 0 && ( {availableSources.length > 0 && (
<div className="flex gap-2"> <div className="flex gap-2">
<select className="flex-1 border border-gray-200 rounded px-2 py-1 text-sm focus:outline-none focus:border-blue-400" <select className="flex-1 border border-line rounded px-2 py-1 text-sm focus:outline-none focus:border-accent"
value={addingSrc} onChange={e => setAddingSrc(e.target.value)}> value={addingSrc} onChange={e => setAddingSrc(e.target.value)}>
<option value=""> add source </option> <option value=""> add source </option>
{availableSources.map(s => <option key={s.name} value={s.name}>{s.name}</option>)} {availableSources.map(s => <option key={s.name} value={s.name}>{s.name}</option>)}
</select> </select>
<button onClick={addSource} disabled={!addingSrc} <button onClick={addSource} disabled={!addingSrc}
className="text-sm bg-gray-100 px-3 py-1 rounded hover:bg-gray-200 text-gray-700 disabled:opacity-40">Add</button> className="text-sm bg-raised px-3 py-1 rounded hover:bg-raised text-ink-soft disabled:opacity-40">Add</button>
</div> </div>
)} )}
</div> </div>
{/* Output columns mapping grid */} {/* Output columns mapping grid */}
<div className="bg-white border border-gray-200 rounded p-4"> <div className="bg-surface border border-line rounded p-4">
<h3 className="text-sm font-semibold text-gray-700 mb-1">Output columns</h3> <h3 className="text-sm font-semibold text-ink-soft mb-1">Output columns</h3>
<p className="text-xs text-gray-400 mb-3"> <p className="text-xs text-muted mb-3">
Each row is a column in the combined view. Each source column shows which field from that source maps to it. Each row is a column in the combined view. Each source column shows which field from that source maps to it.
The first <span className="text-blue-500">numeric</span> field drives the running balance; the first <span className="text-green-600">date</span> field drives the ordering. The first <span className="text-accent">numeric</span> field drives the running balance; the first <span className="text-ok">date</span> field drives the ordering.
Both <span className="font-mono">source_balance</span> (per-source) and <span className="font-mono">net_balance</span> (combined) are always included in the generated view. Both <span className="font-mono">source_balance</span> (per-source) and <span className="font-mono">net_balance</span> (combined) are always included in the generated view.
Drag rows to reorder. Drag rows to reorder.
</p> </p>
{members.length === 0 ? ( {members.length === 0 ? (
<p className="text-xs text-gray-400 mb-3">Add sources above first.</p> <p className="text-xs text-muted mb-3">Add sources above first.</p>
) : ( ) : (
<div className="overflow-x-auto mb-3"> <div className="overflow-x-auto mb-3">
<table className="w-full text-xs border-collapse"> <table className="w-full text-xs border-collapse">
<thead> <thead>
<tr className="border-b border-gray-200"> <tr className="border-b border-line">
<th className="w-5 pb-2"></th> <th className="w-5 pb-2"></th>
<th className="text-left text-gray-400 font-normal pb-2 pr-4">Column</th> <th className="text-left text-muted font-normal pb-2 pr-4">Column</th>
<th className="text-left text-gray-400 font-normal pb-2 pr-4">Type</th> <th className="text-left text-muted font-normal pb-2 pr-4">Type</th>
{members.map(m => ( {members.map(m => (
<th key={m.source_name} className="text-left text-gray-400 font-normal pb-2 pr-3 min-w-36">{m.source_name}</th> <th key={m.source_name} className="text-left text-muted font-normal pb-2 pr-3 min-w-36">{m.source_name}</th>
))} ))}
<th className="w-5 pb-2"></th> <th className="w-5 pb-2"></th>
</tr> </tr>
@ -571,21 +571,21 @@ function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated, onSql
onDragOver={e => handleDragOver(e, idx)} onDragOver={e => handleDragOver(e, idx)}
onDrop={e => handleDrop(e, idx)} onDrop={e => handleDrop(e, idx)}
onDragEnd={() => { setDragIdx(null); setDragOverIdx(null) }} onDragEnd={() => { setDragIdx(null); setDragOverIdx(null) }}
className={`border-b border-gray-50 ${dragOverIdx === idx && dragIdx !== idx ? 'bg-blue-50' : ''}`}> className={`border-b border-line-soft ${dragOverIdx === idx && dragIdx !== idx ? 'bg-accent-soft' : ''}`}>
<td className="py-1.5 pr-1 text-gray-300 cursor-grab select-none"></td> <td className="py-1.5 pr-1 text-muted cursor-grab select-none"></td>
<td className="py-1.5 pr-4 font-mono text-gray-700 whitespace-nowrap"> <td className="py-1.5 pr-4 font-mono text-ink-soft whitespace-nowrap">
{f.name} {f.name}
{isAmount && <span className="ml-1.5 text-blue-500 font-sans font-normal">amount</span>} {isAmount && <span className="ml-1.5 text-accent font-sans font-normal">amount</span>}
{isDate && <span className="ml-1.5 text-green-600 font-sans font-normal">date</span>} {isDate && <span className="ml-1.5 text-ok font-sans font-normal">date</span>}
</td> </td>
<td className="py-1.5 pr-4 text-gray-400">{f.type}</td> <td className="py-1.5 pr-4 text-muted">{f.type}</td>
{members.map(m => ( {members.map(m => (
<td key={m.source_name} className="py-1.5 pr-3"> <td key={m.source_name} className="py-1.5 pr-3">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<select <select
value={getMappingValue(m.source_name, f.name)} value={getMappingValue(m.source_name, f.name)}
onChange={e => setMappingValue(m.source_name, f.name, e.target.value)} onChange={e => setMappingValue(m.source_name, f.name, e.target.value)}
className="border border-gray-200 rounded px-1.5 py-0.5 focus:outline-none focus:border-blue-400 min-w-0 flex-1"> className="border border-line rounded px-1.5 py-0.5 focus:outline-none focus:border-accent min-w-0 flex-1">
<option value=""> same name </option> <option value=""> same name </option>
{(srcFields[m.source_name] || []).map(sf => ( {(srcFields[m.source_name] || []).map(sf => (
<option key={sf} value={sf}>{sf}</option> <option key={sf} value={sf}>{sf}</option>
@ -595,13 +595,13 @@ function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated, onSql
</td> </td>
))} ))}
<td className="py-1.5"> <td className="py-1.5">
<button onClick={() => removeField(f.name)} className="text-red-300 hover:text-red-500"></button> <button onClick={() => removeField(f.name)} className="text-danger hover:text-danger"></button>
</td> </td>
</tr> </tr>
) )
})} })}
{fields.length === 0 && ( {fields.length === 0 && (
<tr><td colSpan={3 + members.length} className="py-3 text-gray-400 text-center">No columns defined yet add one below.</td></tr> <tr><td colSpan={3 + members.length} className="py-3 text-muted text-center">No columns defined yet add one below.</td></tr>
)} )}
</tbody> </tbody>
</table> </table>
@ -610,15 +610,15 @@ function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated, onSql
{/* Add field */} {/* Add field */}
<div className="flex gap-2 mb-3"> <div className="flex gap-2 mb-3">
<input className="flex-1 border border-gray-200 rounded px-2 py-1 text-sm focus:outline-none focus:border-blue-400" <input className="flex-1 border border-line rounded px-2 py-1 text-sm focus:outline-none focus:border-accent"
placeholder="column name" value={newField.name} placeholder="column name" value={newField.name}
onChange={e => setNewField(f => ({ ...f, name: e.target.value }))} onChange={e => setNewField(f => ({ ...f, name: e.target.value }))}
onKeyDown={e => e.key === 'Enter' && addField()} /> onKeyDown={e => e.key === 'Enter' && addField()} />
<select className="border border-gray-200 rounded px-2 py-1 text-sm focus:outline-none focus:border-blue-400" <select className="border border-line rounded px-2 py-1 text-sm focus:outline-none focus:border-accent"
value={newField.type} onChange={e => setNewField(f => ({ ...f, type: e.target.value }))}> value={newField.type} onChange={e => setNewField(f => ({ ...f, type: e.target.value }))}>
{FIELD_TYPES.map(t => <option key={t} value={t}>{t}</option>)} {FIELD_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
</select> </select>
<button onClick={addField} className="text-sm bg-gray-100 px-3 py-1 rounded hover:bg-gray-200 text-gray-700">Add</button> <button onClick={addField} className="text-sm bg-raised px-3 py-1 rounded hover:bg-raised text-ink-soft">Add</button>
</div> </div>
{mappingsDirty && ( {mappingsDirty && (
@ -630,12 +630,12 @@ function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated, onSql
</div> </div>
{/* Generate view + balance */} {/* Generate view + balance */}
<div className="bg-white border border-gray-200 rounded p-4"> <div className="bg-surface border border-line rounded p-4">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-gray-700">View</h3> <h3 className="text-sm font-semibold text-ink-soft">View</h3>
<div className="flex gap-2"> <div className="flex gap-2">
<button onClick={fetchBalance} <button onClick={fetchBalance}
className="text-sm bg-gray-100 text-gray-700 px-3 py-1.5 rounded hover:bg-gray-200"> className="text-sm bg-raised text-ink-soft px-3 py-1.5 rounded hover:bg-raised">
Refresh balance Refresh balance
</button> </button>
<button onClick={generateView} <button onClick={generateView}
@ -646,18 +646,18 @@ function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated, onSql
</div> </div>
{netBalance !== null && ( {netBalance !== null && (
<div className="mb-3 flex items-center gap-3"> <div className="mb-3 flex items-center gap-3">
<span className="text-xs text-gray-500">Current net balance</span> <span className="text-xs text-muted">Current net balance</span>
<span className="text-lg font-mono font-semibold text-gray-800"> <span className="text-lg font-mono font-semibold text-ink">
{Number(netBalance).toLocaleString(undefined, { minimumFractionDigits: 2 })} {Number(netBalance).toLocaleString(undefined, { minimumFractionDigits: 2 })}
</span> </span>
</div> </div>
)} )}
{balanceError && <p className="text-xs text-gray-400 mb-3">{balanceError}</p>} {balanceError && <p className="text-xs text-muted mb-3">{balanceError}</p>}
{viewResult && !viewResult.success && ( {viewResult && !viewResult.success && (
<p className="text-xs text-red-500">{viewResult.error}</p> <p className="text-xs text-danger">{viewResult.error}</p>
)} )}
{viewResult && viewResult.success && ( {viewResult && viewResult.success && (
<p className="text-xs text-green-600">View created: <span className="font-mono">{viewResult.view}</span></p> <p className="text-xs text-ok">View created: <span className="font-mono">{viewResult.view}</span></p>
)} )}
</div> </div>
@ -748,31 +748,31 @@ export default function Stacks({ sources, onStackStale, onStackViewGenerated, on
<div className="p-6"> <div className="p-6">
{/* Stack list — horizontal row of cards */} {/* Stack list — horizontal row of cards */}
<div className="flex items-center gap-2 mb-5 flex-wrap"> <div className="flex items-center gap-2 mb-5 flex-wrap">
<h1 className="text-sm font-semibold text-gray-800 mr-1">Stacks</h1> <h1 className="text-sm font-semibold text-ink mr-1">Stacks</h1>
{stacks.map(s => ( {stacks.map(s => (
<div key={s.name} <div key={s.name}
onClick={() => loadDetail(s.name)} onClick={() => loadDetail(s.name)}
className={`flex items-center gap-2 px-3 py-1.5 rounded border cursor-pointer text-xs group transition-colors ${selected === s.name ? 'border-blue-300 bg-blue-50 text-blue-700' : 'border-gray-200 bg-white text-gray-600 hover:border-gray-300 hover:bg-gray-50'}`}> className={`flex items-center gap-2 px-3 py-1.5 rounded border cursor-pointer text-xs group transition-colors ${selected === s.name ? 'border-accent-line bg-accent-soft text-accent' : 'border-line bg-surface text-ink-soft hover:border-line hover:bg-raised'}`}>
<span className="font-medium">{s.label || s.name}</span> <span className="font-medium">{s.label || s.name}</span>
<span className="text-gray-400">{s.source_count}s</span> <span className="text-muted">{s.source_count}s</span>
<Link to={`/stacks/${encodeURIComponent(s.name)}/pivot`} <Link to={`/stacks/${encodeURIComponent(s.name)}/pivot`}
onClick={e => e.stopPropagation()} onClick={e => e.stopPropagation()}
className="opacity-0 group-hover:opacity-100 text-blue-400 hover:text-blue-600">pivot</Link> className="opacity-0 group-hover:opacity-100 text-accent hover:text-accent">pivot</Link>
<button onClick={e => { e.stopPropagation(); deleteStack(s.name) }} <button onClick={e => { e.stopPropagation(); deleteStack(s.name) }}
className="opacity-0 group-hover:opacity-100 text-red-300 hover:text-red-500 leading-none"></button> className="opacity-0 group-hover:opacity-100 text-danger hover:text-danger leading-none"></button>
</div> </div>
))} ))}
{creating ? ( {creating ? (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<input autoFocus className="border border-blue-400 rounded px-2 py-1 text-xs focus:outline-none w-32" <input autoFocus className="border border-accent rounded px-2 py-1 text-xs focus:outline-none w-32"
placeholder="stack name" value={newName} onChange={e => setNewName(e.target.value)} placeholder="stack name" value={newName} onChange={e => setNewName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') createStack(); if (e.key === 'Escape') setCreating(false) }} /> onKeyDown={e => { if (e.key === 'Enter') createStack(); if (e.key === 'Escape') setCreating(false) }} />
<button onClick={createStack} className="text-xs bg-blue-600 text-white px-2 py-1 rounded">Create</button> <button onClick={createStack} className="text-xs bg-blue-600 text-white px-2 py-1 rounded">Create</button>
<button onClick={() => setCreating(false)} className="text-xs text-gray-400 px-1"></button> <button onClick={() => setCreating(false)} className="text-xs text-muted px-1"></button>
{error && <p className="text-xs text-red-500">{error}</p>} {error && <p className="text-xs text-danger">{error}</p>}
</div> </div>
) : ( ) : (
<button onClick={() => setCreating(true)} className="text-xs text-blue-500 hover:text-blue-700 px-2 py-1.5">+ New</button> <button onClick={() => setCreating(true)} className="text-xs text-accent hover:text-accent px-2 py-1.5">+ New</button>
)} )}
</div> </div>
@ -780,9 +780,9 @@ export default function Stacks({ sources, onStackStale, onStackViewGenerated, on
<div className="flex gap-6 items-start"> <div className="flex gap-6 items-start">
{/* Left: config panel */} {/* Left: config panel */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h2 className="text-base font-semibold text-gray-800 mb-4"> <h2 className="text-base font-semibold text-ink mb-4">
{stackDetail.label || stackDetail.name} {stackDetail.label || stackDetail.name}
{stackDetail.label && <span className="text-sm text-gray-400 font-normal ml-2">{stackDetail.name}</span>} {stackDetail.label && <span className="text-sm text-muted font-normal ml-2">{stackDetail.name}</span>}
</h2> </h2>
<StackPanel <StackPanel
key={stackDetail.name} key={stackDetail.name}
@ -797,9 +797,9 @@ export default function Stacks({ sources, onStackStale, onStackViewGenerated, on
{/* Right: SQL panel */} {/* Right: SQL panel */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="bg-white border border-gray-200 rounded p-4 sticky top-4"> <div className="bg-surface border border-line rounded p-4 sticky top-4">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-gray-700">Generated SQL</h3> <h3 className="text-sm font-semibold text-ink-soft">Generated SQL</h3>
<button <button
onClick={runSql} onClick={runSql}
disabled={!sqlDraft.trim() || sqlRunning} disabled={!sqlDraft.trim() || sqlRunning}
@ -809,17 +809,17 @@ export default function Stacks({ sources, onStackStale, onStackViewGenerated, on
</div> </div>
{sqlDraft ? ( {sqlDraft ? (
<textarea <textarea
className="w-full font-mono text-xs text-gray-700 bg-gray-50 border border-gray-200 rounded p-2 focus:outline-none focus:border-blue-400 resize-none leading-relaxed" className="w-full font-mono text-xs text-ink-soft bg-raised border border-line rounded p-2 focus:outline-none focus:border-accent resize-none leading-relaxed"
style={{ minHeight: '60vh' }} style={{ minHeight: '60vh' }}
value={sqlDraft} value={sqlDraft}
onChange={e => { setSqlDraft(e.target.value); setSqlResult(null) }} onChange={e => { setSqlDraft(e.target.value); setSqlResult(null) }}
spellCheck={false} spellCheck={false}
/> />
) : ( ) : (
<p className="text-xs text-gray-400">Generate a view to see the SQL here.</p> <p className="text-xs text-muted">Generate a view to see the SQL here.</p>
)} )}
{sqlResult && ( {sqlResult && (
<p className={`text-xs mt-2 ${sqlResult.success ? 'text-green-600' : 'text-red-500'}`}> <p className={`text-xs mt-2 ${sqlResult.success ? 'text-ok' : 'text-danger'}`}>
{sqlResult.success ? 'View updated successfully.' : sqlResult.error} {sqlResult.success ? 'View updated successfully.' : sqlResult.error}
</p> </p>
)} )}
@ -827,7 +827,7 @@ export default function Stacks({ sources, onStackStale, onStackViewGenerated, on
</div> </div>
</div> </div>
) : ( ) : (
<p className="text-sm text-gray-400">Select a stack or create one.</p> <p className="text-sm text-muted">Select a stack or create one.</p>
)} )}
</div> </div>
) )