feat(mobile): Seamless brand reskin + two-doors setup on a design-token system

Bring the native shell up to the web app's brand so the setup->app handoff is
seamless, and make it token-driven instead of hard-coded hex.

- src/theme.css: design tokens as the single source of truth — a primitive brand
  ramp (the greens from the web app's mark.svg) mapped to semantic roles
  (--color-*, --radius-*, --space-*). Change the brand in one place; every screen
  follows. index.css now references only tokens (no raw hex).
- src/LogoMark.tsx: shared brand mark component (the real bar-chart+checkmark),
  replacing the indigo crosshair and de-duplicating inline SVG.
- SetupScreen rebuilt as a two-doors chooser for ease of use: 'Run on this phone'
  (private/offline, one tap) and 'Connect to a server' (reveals the URL field only
  when chosen), keeping the insecure-http warning.
- De-hardcoded the rest: App splash + biometric/loading/lock screens now use
  classes and the accent token instead of inline styles and #6366f1.

Verified on the x86_64 emulator: new setup renders in-brand, 'Connect to a server'
reveals the field, 'Run on this phone' boots the local server and loads the app.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-11 14:22:41 -05:00
parent 7f7c0ef7b0
commit 27a76e92b8
8 changed files with 404 additions and 152 deletions

View File

@ -103,7 +103,7 @@ export default function App() {
// 'checking' / 'redirecting' — brief blank while we check preferences // 'checking' / 'redirecting' — brief blank while we check preferences
// or hand off to the WebView. // or hand off to the WebView.
return ( return (
<div style={{ background: '#09090b', height: '100dvh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}> <div className="app-splash">
<div className="spinner" /> <div className="spinner" />
</div> </div>
); );

View File

@ -46,7 +46,7 @@ export default function BiometricLock({ onUnlocked, onFallback }: Props) {
return ( return (
<div className="setup-root"> <div className="setup-root">
<div className="setup-card" style={{ alignItems: 'center', textAlign: 'center' }}> <div className="setup-card center">
{checking ? ( {checking ? (
<div className="spinner" /> <div className="spinner" />
) : ( ) : (

View File

@ -35,13 +35,13 @@ export default function BiometricSetup({ onDone }: Props) {
return ( return (
<div className="setup-root"> <div className="setup-root">
<div className="setup-card" style={{ alignItems: 'center', textAlign: 'center' }}> <div className="setup-card center">
<div className="logo-mark" aria-hidden="true"> <div className="logo-mark icon-accent" aria-hidden="true">
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="40" height="40" rx="10" fill="#6366f1" fillOpacity="0.15" /> <rect width="40" height="40" rx="10" fill="currentColor" fillOpacity="0.15" />
<path <path
d="M20 12a6 6 0 0 0-6 6v2a6 6 0 0 0 .5 2.4M20 12a6 6 0 0 1 6 6v2c0 3-1 5-2 6.5M14 20v2c0 2.5.7 4.3 1.8 5.8M26 20v2c0 1.3-.2 2.6-.6 3.8M17.5 27.5c.8.9 1.8 1.7 2.9 2.2M11 14a9 9 0 0 1 18 0" d="M20 12a6 6 0 0 0-6 6v2a6 6 0 0 0 .5 2.4M20 12a6 6 0 0 1 6 6v2c0 3-1 5-2 6.5M14 20v2c0 2.5.7 4.3 1.8 5.8M26 20v2c0 1.3-.2 2.6-.6 3.8M17.5 27.5c.8.9 1.8 1.7 2.9 2.2M11 14a9 9 0 0 1 18 0"
stroke="#6366f1" stroke="currentColor"
strokeWidth="1.6" strokeWidth="1.6"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"

View File

@ -86,7 +86,7 @@ export default function LoadingScreen({ onReady }: Props) {
return ( return (
<div className="setup-root"> <div className="setup-root">
<div className="setup-card" style={{ alignItems: 'center', textAlign: 'center' }}> <div className="setup-card center">
{error ? ( {error ? (
<> <>
<h1 className="setup-title">Couldn't start local server</h1> <h1 className="setup-title">Couldn't start local server</h1>

36
src/LogoMark.tsx Normal file
View File

@ -0,0 +1,36 @@
interface Props {
/** Pixel size of the (square) mark. Defaults to the CSS .logo-mark size. */
size?: number;
className?: string;
}
/**
* The Bill Tracker brand mark a calm green bar chart with a checkmark.
* Single source for the logo across the native shell (kept in sync with the web
* app's client/public/brand/mark.svg). Shared so no screen hand-inlines the SVG.
*/
export default function LogoMark({ size, className = 'logo-mark' }: Props) {
return (
<span className={className} style={size ? { width: size, height: size } : undefined} aria-hidden="true">
<svg viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Bill Tracker">
<defs>
<linearGradient id="bt-bars" x1="48" x2="206" y1="210" y2="50" gradientUnits="userSpaceOnUse">
<stop offset="0" stopColor="#1e9f93" />
<stop offset="0.58" stopColor="#40c878" />
<stop offset="1" stopColor="#96e6a1" />
</linearGradient>
<linearGradient id="bt-ink" x1="34" x2="222" y1="34" y2="222" gradientUnits="userSpaceOnUse">
<stop offset="0" stopColor="#172025" />
<stop offset="1" stopColor="#0f1417" />
</linearGradient>
</defs>
<rect width="256" height="256" rx="58" fill="url(#bt-ink)" />
<rect x="61" y="123" width="28" height="59" rx="8" fill="url(#bt-bars)" opacity="0.86" />
<rect x="101" y="95" width="28" height="87" rx="8" fill="url(#bt-bars)" opacity="0.94" />
<rect x="141" y="69" width="28" height="113" rx="8" fill="url(#bt-bars)" />
<path d="M62 80.5 97.5 116 190 45" fill="none" stroke="#96e6a1" strokeLinecap="round" strokeLinejoin="round" strokeWidth="18" />
<path d="M62 80.5 97.5 116 190 45" fill="none" stroke="#40c878" strokeLinecap="round" strokeLinejoin="round" strokeWidth="10" />
</svg>
</span>
);
}

View File

@ -1,4 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import LogoMark from './LogoMark';
interface Props { interface Props {
onConnect: (url: string) => void; onConnect: (url: string) => void;
@ -33,7 +34,22 @@ function isInsecureUrl(url: string): boolean {
} }
} }
const PhoneIcon = (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="6" y="2" width="12" height="20" rx="3" />
<path d="M11 18h2" />
</svg>
);
const ServerIcon = (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="12" rx="2" />
<path d="M8 20h8M12 16v4" />
</svg>
);
export default function SetupScreen({ onConnect, onLocalMode }: Props) { export default function SetupScreen({ onConnect, onLocalMode }: Props) {
const [mode, setMode] = useState<'choose' | 'server'>('choose');
const [url, setUrl] = useState(''); const [url, setUrl] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const [insecureAck, setInsecureAck] = useState(false); const [insecureAck, setInsecureAck] = useState(false);
@ -63,71 +79,87 @@ export default function SetupScreen({ onConnect, onLocalMode }: Props) {
return ( return (
<div className="setup-root"> <div className="setup-root">
<div className="setup-card"> <div className="setup-card">
{/* Logo mark */} <div className="brand">
<div className="logo-mark" aria-hidden="true"> <LogoMark />
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg"> <div className="wordmark">Bill <b>Tracker</b></div>
<rect width="40" height="40" rx="10" fill="#6366f1" fillOpacity="0.15" /> <p className="setup-subtitle">Calm command for household money.</p>
<path d="M20 8v4M20 28v4M8 20h4M28 20h4" stroke="#6366f1" strokeWidth="2" strokeLinecap="round" />
<rect x="13" y="13" width="14" height="14" rx="3" stroke="#6366f1" strokeWidth="2" />
<path d="M16 20h8M20 16v8" stroke="#6366f1" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</div> </div>
<h1 className="setup-title">Bill Tracker</h1> {mode === 'choose' ? (
<p className="setup-subtitle">Connect to your server to get started.</p> <>
<div className="choices">
{/* Local first: the simplest, most private option is one tap. */}
<button type="button" className="door" onClick={onLocalMode}>
<span className="door__icon">{PhoneIcon}</span>
<span className="door__body">
<span className="door__title">Run on this phone</span>
<span className="door__desc">Private and offline. Nothing leaves your device.</span>
</span>
<span className="door__chev" aria-hidden="true"></span>
</button>
<div className="form-group"> <button
<label className="form-label" htmlFor="server-url">Server URL</label> type="button"
<input className="door"
id="server-url" onClick={() => { setMode('server'); setError(''); }}
className="form-input" >
type="url" <span className="door__icon">{ServerIcon}</span>
inputMode="url" <span className="door__body">
autoCapitalize="none" <span className="door__title">Connect to a server</span>
autoCorrect="off" <span className="door__desc">Your self-hosted Bill Tracker.</span>
spellCheck={false} </span>
placeholder="https://bills.yourdomain.com" <span className="door__chev" aria-hidden="true"></span>
value={url} </button>
onChange={e => { setUrl(e.target.value); setError(''); setInsecureAck(false); }} </div>
onKeyDown={handleKey} <p className="setup-footer">You can switch modes later by clearing the app's storage.</p>
disabled={connecting} </>
/> ) : (
{error && <p className="form-error">{error}</p>} <>
{insecureAck && ( <div className="form-group">
<p className="form-error"> <label className="form-label" htmlFor="server-url">Server URL</label>
This is an insecure <strong>http://</strong> address — your password will be sent <input
unencrypted. Only continue on a network you trust. Tap again to connect anyway. id="server-url"
</p> className="form-input"
)} type="url"
<p className="form-hint"> inputMode="url"
The address of your Bill Tracker server. Must be reachable from this device. autoCapitalize="none"
</p> autoCorrect="off"
</div> spellCheck={false}
placeholder="https://bills.yourdomain.com"
value={url}
onChange={e => { setUrl(e.target.value); setError(''); setInsecureAck(false); }}
onKeyDown={handleKey}
disabled={connecting}
autoFocus
/>
{error && <p className="form-error">{error}</p>}
{insecureAck && (
<p className="form-error">
This is an insecure <strong>http://</strong> address — your password will be sent
unencrypted. Only continue on a network you trust. Tap again to connect anyway.
</p>
)}
<p className="form-hint">
The address of your Bill Tracker server. Must be reachable from this device.
</p>
</div>
<button <button
className="btn-primary" className="btn-primary"
onClick={handleConnect} onClick={handleConnect}
disabled={connecting || !url.trim()} disabled={connecting || !url.trim()}
> >
{connecting ? 'Connecting…' : insecureAck ? 'Connect anyway' : 'Continue'} {connecting ? 'Connecting…' : insecureAck ? 'Connect anyway' : 'Continue'}
</button> </button>
<button
<div className="divider"> className="btn-secondary"
<span>or</span> onClick={() => { setMode('choose'); setError(''); setInsecureAck(false); }}
</div> disabled={connecting}
>
<button Back
className="btn-secondary" </button>
style={{ cursor: 'pointer' }} </>
onClick={onLocalMode} )}
disabled={connecting}
>
<span>Run on this phone</span>
</button>
<p className="setup-footer">
To change your server later, go to <strong>Android Settings Apps Bill Tracker Clear Storage</strong>.
</p>
</div> </div>
</div> </div>
); );

View File

@ -1,3 +1,9 @@
@import './theme.css';
/* Native shell styles. Every value below resolves to a token from theme.css
no raw hex, so the whole shell rethemes from one place. All screens share
these classes, so a change here cascades to setup, login, loading, biometric. */
*, *::before, *::after { *, *::before, *::after {
box-sizing: border-box; box-sizing: border-box;
margin: 0; margin: 0;
@ -6,19 +12,28 @@
html, body, #root { html, body, #root {
height: 100%; height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, sans-serif; font-family: var(--font-sans);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
/* ── Setup screen ──────────────────────────────────────────────── */ body {
background: var(--color-bg);
color: var(--color-text);
}
.setup-root { /* ── App shell / splash ─────────────────────────────────────────── */
.setup-root,
.app-splash {
min-height: 100dvh; min-height: 100dvh;
background: #09090b; background: var(--color-bg);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 1.5rem; }
.setup-root {
padding: var(--space-5) var(--space-4);
} }
.setup-card { .setup-card {
@ -26,33 +41,147 @@ html, body, #root {
max-width: 400px; max-width: 400px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.25rem; gap: var(--space-4);
}
.setup-card.center {
align-items: center;
text-align: center;
}
/* ── Brand block ────────────────────────────────────────────────── */
.brand {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
margin: var(--space-4) 0 var(--space-2);
} }
.logo-mark { .logo-mark {
width: 56px; width: 56px;
height: 56px; height: 56px;
margin: 0 auto 0.5rem; border-radius: 26%;
overflow: hidden;
display: block;
} }
.logo-mark svg { .logo-mark svg {
width: 100%; width: 100%;
height: 100%; height: 100%;
display: block;
}
.wordmark {
font-size: 1.5rem;
font-weight: 800;
letter-spacing: -0.02em;
color: var(--color-text);
}
.wordmark b {
color: var(--color-primary);
font-weight: 800;
} }
.setup-title { .setup-title {
text-align: center; text-align: center;
font-size: 1.5rem; font-size: 1.5rem;
font-weight: 700; font-weight: 800;
color: #fafafa; color: var(--color-text);
letter-spacing: -0.02em; letter-spacing: -0.02em;
text-wrap: balance;
} }
.setup-subtitle { .setup-subtitle {
text-align: center; text-align: center;
font-size: 0.875rem; font-size: 0.875rem;
color: #71717a; color: var(--color-text-muted);
margin-top: -0.75rem; line-height: 1.5;
}
/* Accent-colored icon wrapper (e.g. biometric fingerprint) */
.icon-accent {
color: var(--color-primary);
}
/* ── Choice "doors" (first-run mode picker) ─────────────────────── */
.choices {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.door {
display: flex;
align-items: center;
gap: var(--space-3);
width: 100%;
text-align: left;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-4);
color: var(--color-text);
cursor: pointer;
transition: border-color 0.15s, background 0.15s, transform 0.05s;
-webkit-tap-highlight-color: transparent;
}
.door:hover {
border-color: var(--color-border-strong);
}
.door:active {
transform: scale(0.99);
}
.door:focus-visible {
outline: 2px solid var(--color-ring);
outline-offset: 2px;
}
.door__icon {
width: 42px;
height: 42px;
border-radius: var(--radius-md);
background: var(--color-primary-tint);
color: var(--color-primary);
display: grid;
place-items: center;
flex: 0 0 auto;
}
.door__icon svg {
width: 21px;
height: 21px;
}
.door__body {
display: flex;
flex-direction: column;
gap: 2px;
}
.door__title {
font-size: 0.9375rem;
font-weight: 700;
color: var(--color-text);
}
.door__desc {
font-size: 0.8125rem;
color: var(--color-text-muted);
line-height: 1.4;
}
.door__chev {
margin-left: auto;
color: var(--color-text-faint);
font-size: 1.25rem;
line-height: 1;
} }
/* ── Form ───────────────────────────────────────────────────────── */ /* ── Form ───────────────────────────────────────────────────────── */
@ -60,7 +189,7 @@ html, body, #root {
.form-group { .form-group {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.375rem; gap: var(--space-1);
} }
.form-label { .form-label {
@ -68,29 +197,29 @@ html, body, #root {
font-weight: 600; font-weight: 600;
letter-spacing: 0.06em; letter-spacing: 0.06em;
text-transform: uppercase; text-transform: uppercase;
color: #a1a1aa; color: var(--color-text-muted);
} }
.form-input { .form-input {
width: 100%; width: 100%;
padding: 0.75rem 1rem; padding: var(--space-3) var(--space-4);
background: #18181b; background: var(--color-surface-2);
border: 1px solid #27272a; border: 1px solid var(--color-border);
border-radius: 0.625rem; border-radius: var(--radius-md);
color: #fafafa; color: var(--color-text);
font-size: 0.9375rem; font-size: 0.9375rem;
outline: none; outline: none;
transition: border-color 0.15s; transition: border-color 0.15s, box-shadow 0.15s;
-webkit-appearance: none; -webkit-appearance: none;
} }
.form-input::placeholder { .form-input::placeholder {
color: #52525b; color: var(--color-text-faint);
} }
.form-input:focus { .form-input:focus {
border-color: #6366f1; border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15); box-shadow: 0 0 0 3px var(--color-ring);
} }
.form-input:disabled { .form-input:disabled {
@ -99,37 +228,43 @@ html, body, #root {
.form-error { .form-error {
font-size: 0.8125rem; font-size: 0.8125rem;
color: #f87171; color: var(--color-danger);
line-height: 1.5;
} }
.form-hint { .form-hint {
font-size: 0.75rem; font-size: 0.75rem;
color: #52525b; color: var(--color-text-faint);
line-height: 1.5; line-height: 1.5;
} }
/* ── Buttons ────────────────────────────────────────────────────── */ /* ── Buttons ────────────────────────────────────────────────────── */
.btn-primary { .btn-primary,
.btn-secondary {
width: 100%; width: 100%;
padding: 0.8125rem 1rem; padding: 0.8125rem 1rem;
background: #6366f1; border-radius: var(--radius-md);
color: #fff;
border: none;
border-radius: 0.625rem;
font-size: 0.9375rem; font-size: 0.9375rem;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: background 0.15s, opacity 0.15s; transition: background 0.15s, border-color 0.15s, color 0.15s, opacity 0.15s;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
} }
.btn-primary {
background: var(--color-primary);
color: var(--color-on-primary);
border: none;
font-weight: 700;
}
.btn-primary:hover:not(:disabled) { .btn-primary:hover:not(:disabled) {
background: #4f46e5; background: var(--color-primary-hover);
} }
.btn-primary:active:not(:disabled) { .btn-primary:active:not(:disabled) {
background: #4338ca; background: var(--color-primary-active);
} }
.btn-primary:disabled { .btn-primary:disabled {
@ -138,26 +273,18 @@ html, body, #root {
} }
.btn-secondary { .btn-secondary {
width: 100%;
padding: 0.8125rem 1rem;
background: transparent; background: transparent;
color: #a1a1aa; color: var(--color-text-dim);
border: 1px solid #27272a; border: 1px solid var(--color-border);
border-radius: 0.625rem;
font-size: 0.9375rem;
font-weight: 500;
cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 0.625rem; gap: var(--space-2);
transition: border-color 0.15s, color 0.15s;
-webkit-tap-highlight-color: transparent;
} }
.btn-secondary:hover:not(:disabled) { .btn-secondary:hover:not(:disabled) {
border-color: #3f3f46; border-color: var(--color-border-strong);
color: #d4d4d8; color: var(--color-text);
} }
.btn-secondary:disabled { .btn-secondary:disabled {
@ -165,63 +292,47 @@ html, body, #root {
cursor: not-allowed; cursor: not-allowed;
} }
.badge { .btn-primary:focus-visible,
font-size: 0.6875rem; .btn-secondary:focus-visible,
font-weight: 600; .form-input:focus-visible {
text-transform: uppercase; outline: 2px solid var(--color-ring);
letter-spacing: 0.05em; outline-offset: 2px;
background: #27272a;
color: #71717a;
padding: 0.1875rem 0.5rem;
border-radius: 9999px;
} }
/* ── Divider ─────────────────────────────────────────────────────── */ /* ── Footer note ────────────────────────────────────────────────── */
.divider {
display: flex;
align-items: center;
gap: 0.75rem;
color: #3f3f46;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.divider::before,
.divider::after {
content: '';
flex: 1;
height: 1px;
background: #27272a;
}
/* ── Footer note ─────────────────────────────────────────────────── */
.setup-footer { .setup-footer {
font-size: 0.75rem; font-size: 0.75rem;
color: #3f3f46; color: var(--color-text-faint);
text-align: center; text-align: center;
line-height: 1.6; line-height: 1.6;
padding: 0 0.25rem; padding: 0 var(--space-1);
} }
.setup-footer strong { .setup-footer strong {
color: #52525b; color: var(--color-text-muted);
font-weight: 500; font-weight: 500;
} }
/* ── Spinner (shown briefly while checking preferences) ─────────── */ /* ── Spinner ────────────────────────────────────────────────────── */
.spinner { .spinner {
width: 28px; width: 28px;
height: 28px; height: 28px;
border: 2px solid #27272a; border: 2px solid var(--color-border);
border-top-color: #6366f1; border-top-color: var(--color-primary);
border-radius: 50%; border-radius: 50%;
animation: spin 0.7s linear infinite; animation: spin 0.7s linear infinite;
} }
@keyframes spin { @media (prefers-reduced-motion: reduce) {
to { transform: rotate(360deg); } .spinner {
animation-duration: 1.6s;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
} }

73
src/theme.css Normal file
View File

@ -0,0 +1,73 @@
/*
Design tokens the single source of truth for the native shell's look.
Change the brand HERE and every screen updates; components never hard-code a
raw color, radius, or spacing value. Two tiers:
1. primitives the raw brand ramp (from the web app's mark.svg) + neutrals
2. semantic the roles components actually reference (--color-*, --radius-*, --space-*)
The "Seamless" direction: the web app's dark ink ground + spring-green accent,
so the native shell and the app it opens read as one product.
*/
:root {
/* ── 1. Primitives ───────────────────────────────────────────────────────── */
/* Brand ramp — the greens straight out of client/public/brand/mark.svg */
--bt-green-300: #96e6a1; /* mint highlight */
--bt-green-400: #57e08e;
--bt-green-500: #40c878; /* spring — primary */
--bt-green-600: #2fae67;
--bt-green-700: #1e9f93; /* teal depth */
/* Green-tinted neutrals (chosen, not a default grey — biased toward the ink in mark.svg) */
--bt-ink-900: #0e1315; /* app background */
--bt-ink-850: #111917; /* raised surface */
--bt-ink-800: #141b1e;
--bt-ink-750: #182024; /* input field */
--bt-line-800: #1e2925;
--bt-line-700: #25322e; /* hairline border */
--bt-line-600: #2b3934; /* stronger border */
--bt-fg: #eef3f0;
--bt-fg-dim: #b6c7be;
--bt-fg-muted:#8ea69c;
--bt-fg-faint:#5c6d66;
--bt-red-400: #f2777a; /* danger */
--bt-amber-400: #e0b64b; /* warning */
/* ── 2. Semantic roles (what components use) ─────────────────────────────── */
--color-bg: var(--bt-ink-900);
--color-surface: var(--bt-ink-850);
--color-surface-2: var(--bt-ink-750);
--color-border: var(--bt-line-700);
--color-border-strong: var(--bt-line-600);
--color-text: var(--bt-fg);
--color-text-dim: var(--bt-fg-dim);
--color-text-muted: var(--bt-fg-muted);
--color-text-faint: var(--bt-fg-faint);
--color-primary: var(--bt-green-500);
--color-primary-hover: var(--bt-green-600);
--color-primary-active: var(--bt-green-700);
--color-on-primary: #052012;
--color-primary-tint: color-mix(in srgb, var(--bt-green-500) 14%, transparent);
--color-ring: color-mix(in srgb, var(--bt-green-500) 32%, transparent);
--color-danger: var(--bt-red-400);
--color-warn: var(--bt-amber-400);
/* ── Shape & rhythm ──────────────────────────────────────────────────────── */
--radius-sm: 0.5rem;
--radius-md: 0.75rem;
--radius-lg: 1rem;
--radius-pill: 999px;
--space-1: 0.375rem;
--space-2: 0.625rem;
--space-3: 0.875rem;
--space-4: 1.25rem;
--space-5: 1.75rem;
--space-6: 2.25rem;
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, sans-serif;
color-scheme: dark;
}