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:
parent
7f7c0ef7b0
commit
27a76e92b8
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
279
src/index.css
279
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 {
|
*, *::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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue