diff --git a/src/LogoMark.tsx b/src/LogoMark.tsx
new file mode 100644
index 0000000..7a38823
--- /dev/null
+++ b/src/LogoMark.tsx
@@ -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 (
+
+ );
+}
diff --git a/src/SetupScreen.tsx b/src/SetupScreen.tsx
index 8147667..4acaa9d 100644
--- a/src/SetupScreen.tsx
+++ b/src/SetupScreen.tsx
@@ -1,4 +1,5 @@
import { useState } from 'react';
+import LogoMark from './LogoMark';
interface Props {
onConnect: (url: string) => void;
@@ -33,7 +34,22 @@ function isInsecureUrl(url: string): boolean {
}
}
+const PhoneIcon = (
+
+);
+
export default function SetupScreen({ onConnect, onLocalMode }: Props) {
+ const [mode, setMode] = useState<'choose' | 'server'>('choose');
const [url, setUrl] = useState('');
const [error, setError] = useState('');
const [insecureAck, setInsecureAck] = useState(false);
@@ -63,71 +79,87 @@ export default function SetupScreen({ onConnect, onLocalMode }: Props) {
return (
- {/* Logo mark */}
-
-
-
-
-
-
-
+
+
+
Bill Tracker
+
Calm command for household money.
-
Bill Tracker
-
Connect to your server to get started.
+ {mode === 'choose' ? (
+ <>
+
+ {/* Local first: the simplest, most private option is one tap. */}
+
+ {PhoneIcon}
+
+ Run on this phone
+ Private and offline. Nothing leaves your device.
+
+ ›
+
-
-
Server URL
-
{ setUrl(e.target.value); setError(''); setInsecureAck(false); }}
- onKeyDown={handleKey}
- disabled={connecting}
- />
- {error &&
{error}
}
- {insecureAck && (
-
- This is an insecure http:// address — your password will be sent
- unencrypted. Only continue on a network you trust. Tap again to connect anyway.
-
- )}
-
- The address of your Bill Tracker server. Must be reachable from this device.
-
-
+
{ setMode('server'); setError(''); }}
+ >
+ {ServerIcon}
+
+ Connect to a server
+ Your self-hosted Bill Tracker.
+
+ ›
+
+
+
You can switch modes later by clearing the app's storage.
+ >
+ ) : (
+ <>
+
+
Server URL
+
{ setUrl(e.target.value); setError(''); setInsecureAck(false); }}
+ onKeyDown={handleKey}
+ disabled={connecting}
+ autoFocus
+ />
+ {error &&
{error}
}
+ {insecureAck && (
+
+ This is an insecure http:// address — your password will be sent
+ unencrypted. Only continue on a network you trust. Tap again to connect anyway.
+
+ )}
+
+ The address of your Bill Tracker server. Must be reachable from this device.
+
+
-
- {connecting ? 'Connecting…' : insecureAck ? 'Connect anyway' : 'Continue'}
-
-
-
- or
-
-
-
- Run on this phone
-
-
-
- To change your server later, go to Android Settings → Apps → Bill Tracker → Clear Storage .
-
+
+ {connecting ? 'Connecting…' : insecureAck ? 'Connect anyway' : 'Continue'}
+
+
{ setMode('choose'); setError(''); setInsecureAck(false); }}
+ disabled={connecting}
+ >
+ Back
+
+ >
+ )}
);
diff --git a/src/index.css b/src/index.css
index d119c66..1b35fc1 100644
--- a/src/index.css
+++ b/src/index.css
@@ -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 {
box-sizing: border-box;
margin: 0;
@@ -6,19 +12,28 @@
html, body, #root {
height: 100%;
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, sans-serif;
+ font-family: var(--font-sans);
-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;
- background: #09090b;
+ background: var(--color-bg);
display: flex;
align-items: center;
justify-content: center;
- padding: 1.5rem;
+}
+
+.setup-root {
+ padding: var(--space-5) var(--space-4);
}
.setup-card {
@@ -26,33 +41,147 @@ html, body, #root {
max-width: 400px;
display: flex;
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 {
width: 56px;
height: 56px;
- margin: 0 auto 0.5rem;
+ border-radius: 26%;
+ overflow: hidden;
+ display: block;
}
.logo-mark svg {
width: 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 {
text-align: center;
font-size: 1.5rem;
- font-weight: 700;
- color: #fafafa;
+ font-weight: 800;
+ color: var(--color-text);
letter-spacing: -0.02em;
+ text-wrap: balance;
}
.setup-subtitle {
text-align: center;
font-size: 0.875rem;
- color: #71717a;
- margin-top: -0.75rem;
+ color: var(--color-text-muted);
+ 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 ───────────────────────────────────────────────────────── */
@@ -60,7 +189,7 @@ html, body, #root {
.form-group {
display: flex;
flex-direction: column;
- gap: 0.375rem;
+ gap: var(--space-1);
}
.form-label {
@@ -68,29 +197,29 @@ html, body, #root {
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
- color: #a1a1aa;
+ color: var(--color-text-muted);
}
.form-input {
width: 100%;
- padding: 0.75rem 1rem;
- background: #18181b;
- border: 1px solid #27272a;
- border-radius: 0.625rem;
- color: #fafafa;
+ padding: var(--space-3) var(--space-4);
+ background: var(--color-surface-2);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ color: var(--color-text);
font-size: 0.9375rem;
outline: none;
- transition: border-color 0.15s;
+ transition: border-color 0.15s, box-shadow 0.15s;
-webkit-appearance: none;
}
.form-input::placeholder {
- color: #52525b;
+ color: var(--color-text-faint);
}
.form-input:focus {
- border-color: #6366f1;
- box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15);
+ border-color: var(--color-primary);
+ box-shadow: 0 0 0 3px var(--color-ring);
}
.form-input:disabled {
@@ -99,37 +228,43 @@ html, body, #root {
.form-error {
font-size: 0.8125rem;
- color: #f87171;
+ color: var(--color-danger);
+ line-height: 1.5;
}
.form-hint {
font-size: 0.75rem;
- color: #52525b;
+ color: var(--color-text-faint);
line-height: 1.5;
}
-/* ── Buttons ─────────────────────────────────────────────────────── */
+/* ── Buttons ────────────────────────────────────────────────────── */
-.btn-primary {
+.btn-primary,
+.btn-secondary {
width: 100%;
padding: 0.8125rem 1rem;
- background: #6366f1;
- color: #fff;
- border: none;
- border-radius: 0.625rem;
+ border-radius: var(--radius-md);
font-size: 0.9375rem;
font-weight: 600;
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;
}
+.btn-primary {
+ background: var(--color-primary);
+ color: var(--color-on-primary);
+ border: none;
+ font-weight: 700;
+}
+
.btn-primary:hover:not(:disabled) {
- background: #4f46e5;
+ background: var(--color-primary-hover);
}
.btn-primary:active:not(:disabled) {
- background: #4338ca;
+ background: var(--color-primary-active);
}
.btn-primary:disabled {
@@ -138,26 +273,18 @@ html, body, #root {
}
.btn-secondary {
- width: 100%;
- padding: 0.8125rem 1rem;
background: transparent;
- color: #a1a1aa;
- border: 1px solid #27272a;
- border-radius: 0.625rem;
- font-size: 0.9375rem;
- font-weight: 500;
- cursor: pointer;
+ color: var(--color-text-dim);
+ border: 1px solid var(--color-border);
display: flex;
align-items: center;
justify-content: center;
- gap: 0.625rem;
- transition: border-color 0.15s, color 0.15s;
- -webkit-tap-highlight-color: transparent;
+ gap: var(--space-2);
}
.btn-secondary:hover:not(:disabled) {
- border-color: #3f3f46;
- color: #d4d4d8;
+ border-color: var(--color-border-strong);
+ color: var(--color-text);
}
.btn-secondary:disabled {
@@ -165,63 +292,47 @@ html, body, #root {
cursor: not-allowed;
}
-.badge {
- font-size: 0.6875rem;
- font-weight: 600;
- text-transform: uppercase;
- letter-spacing: 0.05em;
- background: #27272a;
- color: #71717a;
- padding: 0.1875rem 0.5rem;
- border-radius: 9999px;
+.btn-primary:focus-visible,
+.btn-secondary:focus-visible,
+.form-input:focus-visible {
+ outline: 2px solid var(--color-ring);
+ outline-offset: 2px;
}
-/* ── Divider ─────────────────────────────────────────────────────── */
-
-.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 ─────────────────────────────────────────────────── */
+/* ── Footer note ────────────────────────────────────────────────── */
.setup-footer {
font-size: 0.75rem;
- color: #3f3f46;
+ color: var(--color-text-faint);
text-align: center;
line-height: 1.6;
- padding: 0 0.25rem;
+ padding: 0 var(--space-1);
}
.setup-footer strong {
- color: #52525b;
+ color: var(--color-text-muted);
font-weight: 500;
}
-/* ── Spinner (shown briefly while checking preferences) ─────────── */
+/* ── Spinner ────────────────────────────────────────────────────── */
.spinner {
width: 28px;
height: 28px;
- border: 2px solid #27272a;
- border-top-color: #6366f1;
+ border: 2px solid var(--color-border);
+ border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
-@keyframes spin {
- to { transform: rotate(360deg); }
+@media (prefers-reduced-motion: reduce) {
+ .spinner {
+ animation-duration: 1.6s;
+ }
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
}
diff --git a/src/theme.css b/src/theme.css
new file mode 100644
index 0000000..6b418e9
--- /dev/null
+++ b/src/theme.css
@@ -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;
+}