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
// or hand off to the WebView.
return (
<div style={{ background: '#09090b', height: '100dvh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div className="app-splash">
<div className="spinner" />
</div>
);

View File

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

View File

@ -35,13 +35,13 @@ export default function BiometricSetup({ onDone }: Props) {
return (
<div className="setup-root">
<div className="setup-card" style={{ alignItems: 'center', textAlign: 'center' }}>
<div className="logo-mark" aria-hidden="true">
<div className="setup-card center">
<div className="logo-mark icon-accent" aria-hidden="true">
<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
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"
strokeLinecap="round"
strokeLinejoin="round"

View File

@ -86,7 +86,7 @@ export default function LoadingScreen({ onReady }: Props) {
return (
<div className="setup-root">
<div className="setup-card" style={{ alignItems: 'center', textAlign: 'center' }}>
<div className="setup-card center">
{error ? (
<>
<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 LogoMark from './LogoMark';
interface Props {
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) {
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 (
<div className="setup-root">
<div className="setup-card">
{/* Logo mark */}
<div className="logo-mark" aria-hidden="true">
<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" />
<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 className="brand">
<LogoMark />
<div className="wordmark">Bill <b>Tracker</b></div>
<p className="setup-subtitle">Calm command for household money.</p>
</div>
<h1 className="setup-title">Bill Tracker</h1>
<p className="setup-subtitle">Connect to your server to get started.</p>
{mode === 'choose' ? (
<>
<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">
<label className="form-label" htmlFor="server-url">Server URL</label>
<input
id="server-url"
className="form-input"
type="url"
inputMode="url"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
placeholder="https://bills.yourdomain.com"
value={url}
onChange={e => { setUrl(e.target.value); setError(''); setInsecureAck(false); }}
onKeyDown={handleKey}
disabled={connecting}
/>
{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
type="button"
className="door"
onClick={() => { setMode('server'); setError(''); }}
>
<span className="door__icon">{ServerIcon}</span>
<span className="door__body">
<span className="door__title">Connect to a server</span>
<span className="door__desc">Your self-hosted Bill Tracker.</span>
</span>
<span className="door__chev" aria-hidden="true"></span>
</button>
</div>
<p className="setup-footer">You can switch modes later by clearing the app's storage.</p>
</>
) : (
<>
<div className="form-group">
<label className="form-label" htmlFor="server-url">Server URL</label>
<input
id="server-url"
className="form-input"
type="url"
inputMode="url"
autoCapitalize="none"
autoCorrect="off"
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
className="btn-primary"
onClick={handleConnect}
disabled={connecting || !url.trim()}
>
{connecting ? 'Connecting…' : insecureAck ? 'Connect anyway' : 'Continue'}
</button>
<div className="divider">
<span>or</span>
</div>
<button
className="btn-secondary"
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>
<button
className="btn-primary"
onClick={handleConnect}
disabled={connecting || !url.trim()}
>
{connecting ? 'Connecting…' : insecureAck ? 'Connect anyway' : 'Continue'}
</button>
<button
className="btn-secondary"
onClick={() => { setMode('choose'); setError(''); setInsecureAck(false); }}
disabled={connecting}
>
Back
</button>
</>
)}
</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 {
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);
}
}

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;
}