feat(mobile): go fully native — native Tracker home over the JSON API (no WebView)

Replace the WebView SPA handoff with the app's own native screens that call the
same JSON API — hitting the on-device server in local mode or the remote server
in server mode (same code, base URL switches). Uses the api.ts client built earlier.

- api.ts: typed getTracker/toggleBillPaid/createBill/logout + a shared authed
  mutate() helper (fetches + echoes the CSRF token); SessionUser type.
- TrackerScreen.tsx: native monthly Tracker — month header, Paid/Needs-action/Bills/
  Remaining stat tiles, prev/next month nav, and bill rows with a one-tap mark-paid
  toggle that re-fetches. Loading/error/empty states. Currency + dates via Intl in
  the WebView (Chromium — full ICU, unlike the embedded server).
- MainApp.tsx + app.css: native app shell (top bar + avatar) and a bottom tab bar
  (Tracker built; Calendar/Insights/Account are placeholders/account for now).
- App.tsx: renders MainApp instead of window.location.replace to the SPA; native
  auth for both modes (local login shows 'this device'); 401 bounces to login.

Verified on the x86_64 emulator: two-doors setup -> native login -> native Tracker
with live bills -> tap to mark paid -> tiles + row update. No WebView.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-11 14:41:04 -05:00
parent 27a76e92b8
commit 3ab76fbcdf
7 changed files with 727 additions and 34 deletions

View File

@ -7,69 +7,69 @@ import LoadingScreen from './LoadingScreen';
import LoginScreen from './LoginScreen';
import BiometricSetup from './BiometricSetup';
import BiometricLock from './BiometricLock';
import MainApp from './MainApp';
type Stage =
| 'checking'
| 'setup'
| 'login'
| 'biometric-setup'
| 'biometric-lock'
| 'local'
| 'redirecting';
type Stage = 'checking' | 'setup' | 'boot' | 'login' | 'biometric-setup' | 'biometric-lock' | 'app';
type Mode = 'local' | 'server';
export default function App() {
const [stage, setStage] = useState<Stage>('checking');
const [mode, setMode] = useState<Mode>('server');
const [serverUrl, setServerUrl] = useState('');
const [booted, setBooted] = useState(false);
// API base — the on-device server in local mode, or the remote server.
const baseUrl = mode === 'local' ? LOCAL_URL : serverUrl;
useEffect(() => {
Preferences.get({ key: PREF_SERVER_URL })
.then(async ({ value }) => {
if (value === LOCAL_MODE_SENTINEL) {
setStage('local');
setMode('local');
setStage('boot');
return;
}
if (value) {
setMode('server');
setServerUrl(value);
const { value: biometricEnabled } = await Preferences.get({ key: PREF_BIOMETRIC });
setStage(biometricEnabled === 'true' ? 'biometric-lock' : 'redirecting');
const { value: bio } = await Preferences.get({ key: PREF_BIOMETRIC });
setStage(bio === 'true' ? 'biometric-lock' : 'app');
return;
}
setStage('setup');
})
// A failed preferences read must not leave us stuck on the spinner —
// fall back to first-run setup so the user can (re)connect.
.catch(() => setStage('setup'));
}, []);
useEffect(() => {
if (stage === 'redirecting') {
window.location.replace(serverUrl);
}
}, [stage, serverUrl]);
function handleConnect(url: string) {
setMode('server');
setServerUrl(url);
setStage('login');
}
async function handleLocalMode() {
await Preferences.set({ key: PREF_SERVER_URL, value: LOCAL_MODE_SENTINEL });
setStage('local');
setMode('local');
setStage(booted ? 'app' : 'boot');
}
async function handleLoginSuccess() {
await Preferences.set({ key: PREF_SERVER_URL, value: serverUrl });
if (mode === 'server') {
await Preferences.set({ key: PREF_SERVER_URL, value: serverUrl });
}
// Offer biometric unlock once, if the device supports it and it's undecided.
try {
const { isAvailable } = await BiometricAuth.checkBiometry();
setStage(isAvailable ? 'biometric-setup' : 'redirecting');
const { value: bioPref } = await Preferences.get({ key: PREF_BIOMETRIC });
setStage(isAvailable && bioPref == null ? 'biometric-setup' : 'app');
} catch {
setStage('redirecting');
setStage('app');
}
}
async function handleBiometricSetupDone(enabled: boolean) {
await Preferences.set({ key: PREF_BIOMETRIC, value: enabled ? 'true' : 'false' });
setStage('redirecting');
setStage('app');
}
async function handleBiometricFallback() {
@ -77,9 +77,18 @@ export default function App() {
setStage('login');
}
function handleUnauthenticated() {
setStage('login');
}
async function handleSignOut() {
await Preferences.remove({ key: PREF_BIOMETRIC });
setStage('login');
}
switch (stage) {
case 'local':
return <LoadingScreen onReady={() => window.location.replace(LOCAL_URL)} />;
case 'boot':
return <LoadingScreen onReady={() => { setBooted(true); setStage('app'); }} />;
case 'setup':
return <SetupScreen onConnect={handleConnect} onLocalMode={handleLocalMode} />;
@ -87,9 +96,9 @@ export default function App() {
case 'login':
return (
<LoginScreen
serverUrl={serverUrl}
serverUrl={baseUrl}
onSuccess={handleLoginSuccess}
onBack={() => setStage('setup')}
onBack={() => setStage(mode === 'local' ? 'app' : 'setup')}
/>
);
@ -97,11 +106,13 @@ export default function App() {
return <BiometricSetup onDone={handleBiometricSetupDone} />;
case 'biometric-lock':
return <BiometricLock onUnlocked={() => setStage('redirecting')} onFallback={handleBiometricFallback} />;
return <BiometricLock onUnlocked={() => setStage('app')} onFallback={handleBiometricFallback} />;
case 'app':
return <MainApp baseUrl={baseUrl} onUnauthenticated={handleUnauthenticated} onSignOut={handleSignOut} />;
default:
// 'checking' / 'redirecting' — brief blank while we check preferences
// or hand off to the WebView.
// 'checking' — brief blank while we read stored preferences.
return (
<div className="app-splash">
<div className="spinner" />

View File

@ -134,7 +134,7 @@ export default function LoginScreen({ serverUrl, onSuccess, onBack }: Props) {
<div className="setup-root">
<div className="setup-card">
<h1 className="setup-title">Sign in</h1>
<p className="setup-subtitle">Sign in to {serverUrl}</p>
<p className="setup-subtitle">Sign in to {serverUrl.includes('127.0.0.1') ? 'this device' : serverUrl}</p>
<div className="form-group">
<label className="form-label" htmlFor="login-username">Username</label>

110
src/MainApp.tsx Normal file
View File

@ -0,0 +1,110 @@
import { useEffect, useState } from 'react';
import LogoMark from './LogoMark';
import TrackerScreen from './TrackerScreen';
import { getSession, logout, type SessionUser } from './api';
interface Props {
/** API base — the remote server, or the on-device server in local mode. */
baseUrl: string;
/** Session is invalid — bounce back to the login screen. */
onUnauthenticated: () => void;
/** Signed out — return to first-run/login. */
onSignOut: () => void;
}
type Tab = 'tracker' | 'calendar' | 'insights' | 'account';
const TABS: { id: Tab; label: string; icon: React.ReactNode }[] = [
{
id: 'tracker',
label: 'Tracker',
icon: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 12h4l3 8 4-16 3 8h4" /></svg>,
},
{
id: 'calendar',
label: 'Calendar',
icon: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="4" width="18" height="17" rx="2" /><path d="M3 9h18M8 2v4M16 2v4" /></svg>,
},
{
id: 'insights',
label: 'Insights',
icon: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 3v18h18" /><path d="M7 15l4-5 3 3 4-6" /></svg>,
},
{
id: 'account',
label: 'Account',
icon: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></svg>,
},
];
function Placeholder({ title, note }: { title: string; note: string }) {
return (
<div className="placeholder">
<h2>{title}</h2>
<p>{note}</p>
</div>
);
}
export default function MainApp({ baseUrl, onUnauthenticated, onSignOut }: Props) {
const [tab, setTab] = useState<Tab>('tracker');
const [user, setUser] = useState<SessionUser | null>(null);
const [signingOut, setSigningOut] = useState(false);
useEffect(() => {
getSession(baseUrl).then(res => {
if (res.status === 401) { onUnauthenticated(); return; }
const u = res.data?.user ?? (res.data as SessionUser | undefined);
if (u?.username) setUser(u);
});
}, [baseUrl, onUnauthenticated]);
async function handleSignOut() {
setSigningOut(true);
await logout(baseUrl);
onSignOut();
}
const initial = (user?.username || '?').charAt(0).toUpperCase();
return (
<div className="app">
<header className="app-topbar">
<LogoMark size={30} />
<span className="app-topbar__word">Bill <b>Tracker</b></span>
<span className="app-topbar__spacer" />
<button className="avatar" onClick={() => setTab('account')} aria-label="Account">{initial}</button>
</header>
<main className="app-main" key={tab}>
{tab === 'tracker' && <TrackerScreen baseUrl={baseUrl} onUnauthenticated={onUnauthenticated} />}
{tab === 'calendar' && <Placeholder title="Calendar" note="A month-at-a-glance view of when bills are due is coming here next." />}
{tab === 'insights' && <Placeholder title="Insights" note="Spending trends and this-vs-last-month comparisons are coming here next." />}
{tab === 'account' && (
<div className="app-state">
<div className="avatar" style={{ width: 56, height: 56, fontSize: '1.5rem' }}>{initial}</div>
<h2>{user?.username ?? 'Signed in'}</h2>
<p>{user?.role === 'admin' ? 'Administrator' : 'Signed in to this device'}</p>
<button className="btn-secondary" style={{ maxWidth: 220 }} onClick={handleSignOut} disabled={signingOut}>
{signingOut ? 'Signing out…' : 'Sign out'}
</button>
</div>
)}
</main>
<nav className="tabbar">
{TABS.map(t => (
<button
key={t.id}
className={'tab' + (tab === t.id ? ' is-active' : '')}
onClick={() => setTab(t.id)}
aria-current={tab === t.id ? 'page' : undefined}
>
{t.icon}
<span>{t.label}</span>
</button>
))}
</nav>
</div>
);
}

183
src/TrackerScreen.tsx Normal file
View File

@ -0,0 +1,183 @@
import { useCallback, useEffect, useState } from 'react';
import { getTracker, toggleBillPaid, type TrackerData, type Bill } from './api';
interface Props {
baseUrl: string;
/** Called when the API reports the session is no longer valid (401). */
onUnauthenticated: () => void;
}
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
const MONTHS_SHORT = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const money = (n: number) => n.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
/** "2026-07-01" → "Jul 1" without tripping over timezones. */
function shortDate(iso: string): string {
const [, m, d] = iso.split('-').map(Number);
return `${MONTHS_SHORT[(m || 1) - 1]} ${d}`;
}
function billTone(b: Bill): 'paid' | 'overdue' | 'upcoming' {
if (b.is_settled || b.status === 'paid') return 'paid';
if (b.status === 'missed') return 'overdue';
return 'upcoming';
}
function statusLabel(b: Bill): string {
const tone = billTone(b);
if (tone === 'paid') return 'Paid';
if (tone === 'overdue') return 'Overdue';
return 'Upcoming';
}
const CheckIcon = (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 6 9 17l-5-5" />
</svg>
);
export default function TrackerScreen({ baseUrl, onUnauthenticated }: Props) {
const [ym, setYm] = useState<{ year: number; month: number } | null>(null);
const [data, setData] = useState<TrackerData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [busyId, setBusyId] = useState<number | null>(null);
const load = useCallback(
async (target?: { year: number; month: number }) => {
setLoading(true);
setError('');
const res = await getTracker(baseUrl, target?.year, target?.month);
if (res.status === 401) {
onUnauthenticated();
return;
}
if (!res.ok || !res.data) {
setError(res.error || 'Could not load your bills.');
setLoading(false);
return;
}
setData(res.data);
setYm({ year: res.data.year, month: res.data.month });
setLoading(false);
},
[baseUrl, onUnauthenticated],
);
useEffect(() => { load(); }, [load]);
function nav(delta: number) {
if (!ym) return;
let month = ym.month + delta;
let year = ym.year;
if (month < 1) { month = 12; year -= 1; }
if (month > 12) { month = 1; year += 1; }
load({ year, month });
}
async function toggle(bill: Bill) {
setBusyId(bill.id);
const res = await toggleBillPaid(baseUrl, bill.id);
setBusyId(null);
if (res.status === 401) { onUnauthenticated(); return; }
if (res.ok) load(ym ?? undefined);
}
if (loading && !data) {
return (
<div className="app-state">
<div className="spinner" />
</div>
);
}
if (error && !data) {
return (
<div className="app-state">
<h2>Couldn't load your bills</h2>
<p>{error}</p>
<button className="btn-primary" style={{ maxWidth: 200 }} onClick={() => load(ym ?? undefined)}>Try again</button>
</div>
);
}
const d = data!;
const rows = d.rows || [];
const s = d.summary;
const unpaid = rows.length - s.count_paid;
const lastDay = new Date(d.year, d.month, 0).getDate();
return (
<>
<div>
<div className="tracker-kicker">Monthly tracker</div>
<h1 className="tracker-title">{MONTHS[d.month - 1]} {d.year}</h1>
<div className="tracker-range">{MONTHS_SHORT[d.month - 1]} 1 {MONTHS_SHORT[d.month - 1]} {lastDay}</div>
</div>
<div className="stat-grid">
<div className="stat stat--paid">
<span className="stat__label">Paid</span>
<span className="stat__value">{s.count_paid}</span>
</div>
<div className={'stat' + (unpaid > 0 ? ' stat--warn' : '')}>
<span className="stat__label">Needs action</span>
<span className="stat__value">{unpaid}</span>
</div>
<div className="stat">
<span className="stat__label">Bills</span>
<span className="stat__value">{rows.length}</span>
</div>
<div className="stat">
<span className="stat__label">Remaining</span>
<span className="stat__value">{money(s.total_remaining)}</span>
</div>
</div>
<div className="month-nav">
<div className="month-nav__pager">
<button onClick={() => nav(-1)} aria-label="Previous month"></button>
<span>{MONTHS[d.month - 1]} {d.year}</span>
<button onClick={() => nav(1)} aria-label="Next month"></button>
</div>
</div>
{rows.length === 0 ? (
<div className="app-state">
<h2>No bills this month</h2>
<p>When you add bills, they'll show up here with what's paid and what's due.</p>
</div>
) : (
<div className="bill-list">
{rows.map(b => {
const tone = billTone(b);
return (
<div key={b.id} className={`bill bill--${tone}`}>
<button
className="bill__toggle"
onClick={() => toggle(b)}
disabled={busyId === b.id}
aria-pressed={tone === 'paid'}
aria-label={tone === 'paid' ? `Mark ${b.name} unpaid` : `Mark ${b.name} paid`}
>
{CheckIcon}
</button>
<div className="bill__body">
<span className="bill__name">{b.name}</span>
<span className="bill__meta">
{b.category_name ? `${b.category_name} · ` : ''}due {shortDate(b.due_date)}
</span>
</div>
<div className="bill__right">
<div className="bill__amount">{money(b.expected_amount)}</div>
<div className="bill__status">{statusLabel(b)}</div>
</div>
</div>
);
})}
</div>
)}
</>
);
}

View File

@ -122,7 +122,86 @@ export async function totpChallenge(
);
}
/** GET /api/auth/me — used to re-validate a session (e.g. after biometric unlock). */
export function getSession(baseUrl: string): Promise<ApiResult<{ user?: { id: number } }>> {
export interface SessionUser {
id: number;
username: string;
role: string;
name?: string;
}
/** GET /api/auth/me — current session; 401 when not signed in. */
export function getSession(baseUrl: string): Promise<ApiResult<{ user?: SessionUser } & Partial<SessionUser>>> {
return request(`${baseUrl}/api/auth/me`, { method: 'GET' });
}
// ── Domain: the monthly Tracker (subset of /api/tracker we render) ────────────
export interface Bill {
id: number;
name: string;
category_name: string | null;
due_date: string; // YYYY-MM-DD
due_day: number;
bucket: string;
expected_amount: number;
balance: number;
total_paid: number;
status: string; // 'paid' | 'missed' | 'upcoming' | 'partial' | 'autodraft' | …
is_settled: boolean;
has_payment: boolean;
autopay_enabled: boolean;
}
export interface TrackerSummary {
total_expected: number;
total_paid: number;
remaining: number;
total_remaining: number;
overdue: number;
count_paid: number;
count_upcoming: number;
count_late: number;
remaining_label: string;
}
export interface TrackerData {
year: number;
month: number; // 112
today: string;
summary: TrackerSummary;
rows: Bill[];
}
/** Authed mutation: fetch the CSRF token, then echo it as x-csrf-token. */
async function mutate<T>(baseUrl: string, method: string, path: string, body?: unknown): Promise<ApiResult<T>> {
const csrf = await fetchCsrfToken(baseUrl);
return request<T>(`${baseUrl}${path}`, {
method,
headers: { 'Content-Type': 'application/json', ...(csrf ? { [CSRF_HEADER]: csrf } : {}) },
body: body === undefined ? undefined : JSON.stringify(body),
});
}
/** GET /api/tracker — a month's bills + summary (defaults to the current month). */
export function getTracker(baseUrl: string, year?: number, month?: number): Promise<ApiResult<TrackerData>> {
const q = year && month ? `?year=${year}&month=${month}` : '';
return request<TrackerData>(`${baseUrl}/api/tracker${q}`, { method: 'GET' });
}
/** POST /api/bills/:id/toggle-paid — flip a bill's paid state for the month. */
export function toggleBillPaid(baseUrl: string, billId: number): Promise<ApiResult<unknown>> {
return mutate(baseUrl, 'POST', `/api/bills/${billId}/toggle-paid`, {});
}
/** POST /api/bills/ — create a bill. */
export function createBill(
baseUrl: string,
bill: { name: string; due_day: number; expected_amount: number },
): Promise<ApiResult<Bill>> {
return mutate<Bill>(baseUrl, 'POST', '/api/bills/', bill);
}
/** POST /api/auth/logout */
export function logout(baseUrl: string): Promise<ApiResult<unknown>> {
return mutate(baseUrl, 'POST', '/api/auth/logout', {});
}

309
src/app.css Normal file
View File

@ -0,0 +1,309 @@
/* Native main-app screens (Tracker, tabs). Token-driven like the shell see
theme.css. Kept separate from index.css (the auth/setup shell) for clarity. */
.app {
display: flex;
flex-direction: column;
height: 100dvh;
background: var(--color-bg);
color: var(--color-text);
}
/* ── Top bar ─────────────────────────────────────────────────────── */
.app-topbar {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
padding-top: max(var(--space-3), env(safe-area-inset-top));
border-bottom: 1px solid var(--color-border);
background: var(--color-bg);
}
.app-topbar .logo-mark {
width: 30px;
height: 30px;
}
.app-topbar__word {
font-size: 1.05rem;
font-weight: 800;
letter-spacing: -0.01em;
}
.app-topbar__word b { color: var(--color-primary); }
.app-topbar__spacer { flex: 1; }
.avatar {
width: 34px;
height: 34px;
border-radius: 50%;
background: var(--color-primary-tint);
color: var(--color-primary);
border: 1px solid var(--color-border);
display: grid;
place-items: center;
font-size: 0.9rem;
font-weight: 700;
cursor: pointer;
}
/* ── Scroll region ───────────────────────────────────────────────── */
.app-main {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: var(--space-4) var(--space-4) var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-4);
}
/* ── Tracker: month header ───────────────────────────────────────── */
.tracker-kicker {
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--color-text-faint);
}
.tracker-title {
font-size: 1.9rem;
font-weight: 800;
letter-spacing: -0.02em;
line-height: 1.05;
margin-top: 2px;
}
.tracker-range {
font-size: 0.85rem;
color: var(--color-text-muted);
margin-top: 2px;
}
/* ── Stat tiles ──────────────────────────────────────────────────── */
.stat-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-2);
}
.stat {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-3);
background: var(--color-surface);
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.stat__label {
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--color-text-muted);
display: flex;
align-items: center;
gap: 0.4rem;
}
.stat__label::before {
content: '';
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-text-faint);
}
.stat__value {
font-size: 1.4rem;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.stat--paid { border-color: color-mix(in srgb, var(--color-primary) 40%, var(--color-border)); }
.stat--paid .stat__label { color: var(--color-primary); }
.stat--paid .stat__label::before { background: var(--color-primary); }
.stat--warn { border-color: color-mix(in srgb, var(--color-warn) 40%, var(--color-border)); }
.stat--warn .stat__label { color: var(--color-warn); }
.stat--warn .stat__label::before { background: var(--color-warn); }
.stat--warn .stat__value { color: var(--color-warn); }
/* ── Month nav + add ─────────────────────────────────────────────── */
.month-nav {
display: flex;
align-items: center;
gap: var(--space-2);
}
.month-nav__pager {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 0.55rem 0.75rem;
font-size: 0.95rem;
font-weight: 700;
}
.month-nav__pager button {
background: none;
border: none;
color: var(--color-text-muted);
font-size: 1.2rem;
line-height: 1;
padding: 0 0.5rem;
cursor: pointer;
}
.month-nav__pager button:disabled { opacity: 0.35; }
.icon-btn {
width: 42px;
height: 42px;
flex: 0 0 auto;
border-radius: 50%;
display: grid;
place-items: center;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.icon-btn--primary {
background: var(--color-primary);
color: var(--color-on-primary);
border: none;
font-size: 1.5rem;
font-weight: 700;
}
.icon-btn--primary:active { background: var(--color-primary-active); }
.icon-btn--ghost {
background: transparent;
color: var(--color-text-dim);
border: 1px solid var(--color-border);
font-size: 0.8rem;
font-weight: 700;
}
/* ── Bill rows ───────────────────────────────────────────────────── */
.bill-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.bill {
display: flex;
align-items: center;
gap: var(--space-3);
border: 1px solid var(--color-border);
border-left: 3px solid var(--color-text-faint);
border-radius: var(--radius-md);
padding: var(--space-3);
background: var(--color-surface);
}
.bill--paid { border-left-color: var(--color-primary); }
.bill--overdue { border-left-color: var(--color-warn); }
.bill__toggle {
width: 30px;
height: 30px;
border-radius: 50%;
flex: 0 0 auto;
display: grid;
place-items: center;
cursor: pointer;
border: 1.6px solid var(--color-border-strong);
background: transparent;
color: transparent;
-webkit-tap-highlight-color: transparent;
transition: background 0.12s, border-color 0.12s, color 0.12s;
}
.bill__toggle svg { width: 16px; height: 16px; }
.bill--paid .bill__toggle {
background: var(--color-primary-tint);
border-color: color-mix(in srgb, var(--color-primary) 50%, transparent);
color: var(--color-primary);
}
.bill__toggle:disabled { opacity: 0.5; }
.bill__body { min-width: 0; display: flex; flex-direction: column; gap: 1px; }
.bill__name {
font-size: 0.95rem;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bill__meta { font-size: 0.75rem; color: var(--color-text-muted); }
.bill__right { margin-left: auto; text-align: right; flex: 0 0 auto; }
.bill__amount {
font-size: 0.95rem;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.bill__status {
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.bill--paid .bill__status { color: var(--color-primary); }
.bill--overdue .bill__status { color: var(--color-warn); }
.bill--upcoming .bill__status { color: var(--color-text-faint); }
/* ── States ──────────────────────────────────────────────────────── */
.app-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-3);
text-align: center;
color: var(--color-text-muted);
padding: var(--space-5);
}
.app-state h2 { font-size: 1.05rem; font-weight: 700; color: var(--color-text); }
.app-state p { font-size: 0.875rem; max-width: 34ch; }
.placeholder {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-2);
color: var(--color-text-muted);
text-align: center;
padding: var(--space-5);
}
.placeholder__icon {
width: 48px;
height: 48px;
color: var(--color-text-faint);
}
.placeholder h2 { font-size: 1.1rem; font-weight: 700; color: var(--color-text); }
.placeholder p { font-size: 0.875rem; max-width: 30ch; }
/* ── Bottom tab bar ──────────────────────────────────────────────── */
.tabbar {
flex: 0 0 auto;
display: flex;
justify-content: space-around;
align-items: stretch;
border-top: 1px solid var(--color-border);
background: var(--color-surface);
padding-bottom: env(safe-area-inset-bottom);
}
.tab {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
padding: 0.55rem 0.25rem;
background: none;
border: none;
color: var(--color-text-faint);
font-size: 0.7rem;
font-weight: 600;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.tab svg { width: 22px; height: 22px; }
.tab.is-active { color: var(--color-primary); }
.tab:focus-visible { outline: 2px solid var(--color-ring); outline-offset: -2px; }

View File

@ -1,6 +1,7 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import './app.css';
import App from './App.tsx';
createRoot(document.getElementById('root')!).render(