From 3ab76fbcdfbd1170d7f3f708f281349322bfe68f Mon Sep 17 00:00:00 2001 From: null Date: Sat, 11 Jul 2026 14:41:04 -0500 Subject: [PATCH] =?UTF-8?q?feat(mobile):=20go=20fully=20native=20=E2=80=94?= =?UTF-8?q?=20native=20Tracker=20home=20over=20the=20JSON=20API=20(no=20We?= =?UTF-8?q?bView)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/App.tsx | 73 +++++----- src/LoginScreen.tsx | 2 +- src/MainApp.tsx | 110 +++++++++++++++ src/TrackerScreen.tsx | 183 +++++++++++++++++++++++++ src/api.ts | 83 +++++++++++- src/app.css | 309 ++++++++++++++++++++++++++++++++++++++++++ src/main.tsx | 1 + 7 files changed, 727 insertions(+), 34 deletions(-) create mode 100644 src/MainApp.tsx create mode 100644 src/TrackerScreen.tsx create mode 100644 src/app.css diff --git a/src/App.tsx b/src/App.tsx index 7db9ea4..3415112 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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('checking'); + const [mode, setMode] = useState('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 window.location.replace(LOCAL_URL)} />; + case 'boot': + return { setBooted(true); setStage('app'); }} />; case 'setup': return ; @@ -87,9 +96,9 @@ export default function App() { case 'login': return ( setStage('setup')} + onBack={() => setStage(mode === 'local' ? 'app' : 'setup')} /> ); @@ -97,11 +106,13 @@ export default function App() { return ; case 'biometric-lock': - return setStage('redirecting')} onFallback={handleBiometricFallback} />; + return setStage('app')} onFallback={handleBiometricFallback} />; + + case 'app': + return ; default: - // 'checking' / 'redirecting' — brief blank while we check preferences - // or hand off to the WebView. + // 'checking' — brief blank while we read stored preferences. return (
diff --git a/src/LoginScreen.tsx b/src/LoginScreen.tsx index 8898292..f2ad501 100644 --- a/src/LoginScreen.tsx +++ b/src/LoginScreen.tsx @@ -134,7 +134,7 @@ export default function LoginScreen({ serverUrl, onSuccess, onBack }: Props) {

Sign in

-

Sign in to {serverUrl}

+

Sign in to {serverUrl.includes('127.0.0.1') ? 'this device' : serverUrl}

diff --git a/src/MainApp.tsx b/src/MainApp.tsx new file mode 100644 index 0000000..cf556f9 --- /dev/null +++ b/src/MainApp.tsx @@ -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: , + }, + { + id: 'calendar', + label: 'Calendar', + icon: , + }, + { + id: 'insights', + label: 'Insights', + icon: , + }, + { + id: 'account', + label: 'Account', + icon: , + }, +]; + +function Placeholder({ title, note }: { title: string; note: string }) { + return ( +
+

{title}

+

{note}

+
+ ); +} + +export default function MainApp({ baseUrl, onUnauthenticated, onSignOut }: Props) { + const [tab, setTab] = useState('tracker'); + const [user, setUser] = useState(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 ( +
+
+ + Bill Tracker + + +
+ +
+ {tab === 'tracker' && } + {tab === 'calendar' && } + {tab === 'insights' && } + {tab === 'account' && ( +
+
{initial}
+

{user?.username ?? 'Signed in'}

+

{user?.role === 'admin' ? 'Administrator' : 'Signed in to this device'}

+ +
+ )} +
+ + +
+ ); +} diff --git a/src/TrackerScreen.tsx b/src/TrackerScreen.tsx new file mode 100644 index 0000000..3873091 --- /dev/null +++ b/src/TrackerScreen.tsx @@ -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 = ( + + + +); + +export default function TrackerScreen({ baseUrl, onUnauthenticated }: Props) { + const [ym, setYm] = useState<{ year: number; month: number } | null>(null); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [busyId, setBusyId] = useState(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 ( +
+
+
+ ); + } + + if (error && !data) { + return ( +
+

Couldn't load your bills

+

{error}

+ +
+ ); + } + + 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 ( + <> +
+
Monthly tracker
+

{MONTHS[d.month - 1]} {d.year}

+
{MONTHS_SHORT[d.month - 1]} 1 – {MONTHS_SHORT[d.month - 1]} {lastDay}
+
+ +
+
+ Paid + {s.count_paid} +
+
0 ? ' stat--warn' : '')}> + Needs action + {unpaid} +
+
+ Bills + {rows.length} +
+
+ Remaining + {money(s.total_remaining)} +
+
+ +
+
+ + {MONTHS[d.month - 1]} {d.year} + +
+
+ + {rows.length === 0 ? ( +
+

No bills this month

+

When you add bills, they'll show up here with what's paid and what's due.

+
+ ) : ( +
+ {rows.map(b => { + const tone = billTone(b); + return ( +
+ +
+ {b.name} + + {b.category_name ? `${b.category_name} · ` : ''}due {shortDate(b.due_date)} + +
+
+
{money(b.expected_amount)}
+
{statusLabel(b)}
+
+
+ ); + })} +
+ )} + + ); +} diff --git a/src/api.ts b/src/api.ts index bd888f4..cbc1143 100644 --- a/src/api.ts +++ b/src/api.ts @@ -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> { +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>> { 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; // 1–12 + today: string; + summary: TrackerSummary; + rows: Bill[]; +} + +/** Authed mutation: fetch the CSRF token, then echo it as x-csrf-token. */ +async function mutate(baseUrl: string, method: string, path: string, body?: unknown): Promise> { + const csrf = await fetchCsrfToken(baseUrl); + return request(`${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> { + const q = year && month ? `?year=${year}&month=${month}` : ''; + return request(`${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> { + 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> { + return mutate(baseUrl, 'POST', '/api/bills/', bill); +} + +/** POST /api/auth/logout */ +export function logout(baseUrl: string): Promise> { + return mutate(baseUrl, 'POST', '/api/auth/logout', {}); +} diff --git a/src/app.css b/src/app.css new file mode 100644 index 0000000..8f03387 --- /dev/null +++ b/src/app.css @@ -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; } diff --git a/src/main.tsx b/src/main.tsx index 2239905..a58631c 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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(