chore(deps)!: tailwind 3 -> 4 via @tailwindcss/vite (4i)
Official codemod (run in a clean worktree; docker-owned data/ blocks it in-tree): tailwind.config.js inlined into client/index.css as CSS-first @theme/@utility (config file deleted — v4 auto-detects content, which also retires the .tsx-content-glob failure class from the TS migration), 33 template files' class renames applied, tailwindcss-animate loads via the v4 @plugin bridge. Build runs through @tailwindcss/vite; postcss config is now plugin-free. tailwind-merge -> 3 (v4 class model). Visual-regression gate: only diffs were a ~2px global preflight shift + the version string (baseline predated 0.41.1); re-baselined both login screenshots in this commit after review. webauthn.probe confined to the probe project (it enables WebAuthn for the seeded user, racing the parallel UI projects' password logins). e2e 27 passed, probe 18/18, PROD SMOKE PASS, typecheck/lint/tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
d8365d0e22
commit
522eaaf101
|
|
@ -77,7 +77,7 @@ function RuleChip({
|
||||||
<div className="flex items-center gap-1.5 min-w-0">
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
<Tag className="h-3 w-3 shrink-0 text-primary" />
|
<Tag className="h-3 w-3 shrink-0 text-primary" />
|
||||||
<span
|
<span
|
||||||
className="text-xs font-medium text-foreground truncate max-w-[10rem]"
|
className="text-xs font-medium text-foreground truncate max-w-40"
|
||||||
title={rule.merchant}
|
title={rule.merchant}
|
||||||
>
|
>
|
||||||
{rule.merchant}
|
{rule.merchant}
|
||||||
|
|
|
||||||
|
|
@ -177,7 +177,7 @@ export function CalendarFeedManager({
|
||||||
|
|
||||||
{!loading && active && (
|
{!loading && active && (
|
||||||
<>
|
<>
|
||||||
<div className="rounded-lg border border-amber-500/25 bg-amber-500/[0.08] px-3 py-2 text-xs leading-5 text-amber-800 dark:text-amber-200">
|
<div className="rounded-lg border border-amber-500/25 bg-amber-500/8 px-3 py-2 text-xs leading-5 text-amber-800 dark:text-amber-200">
|
||||||
Anyone with this URL can see the bill events in this feed. Regenerate or revoke it if it
|
Anyone with this URL can see the bill events in this feed. Regenerate or revoke it if it
|
||||||
was shared somewhere it should not be.
|
was shared somewhere it should not be.
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,7 @@ export function ReleaseNotesDialog() {
|
||||||
src={RELEASE_NOTES.image.src}
|
src={RELEASE_NOTES.image.src}
|
||||||
alt={RELEASE_NOTES.image.alt}
|
alt={RELEASE_NOTES.image.alt}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
className="aspect-[16/10] w-full object-contain"
|
className="aspect-16/10 w-full object-contain"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</figure>
|
</figure>
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ export default function OnboardingWizard({ onComplete }: { onComplete: () => voi
|
||||||
<div className="mx-auto grid min-h-[calc(100vh-64px-5rem)] w-full max-w-5xl items-center gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(23rem,28rem)]">
|
<div className="mx-auto grid min-h-[calc(100vh-64px-5rem)] w-full max-w-5xl items-center gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(23rem,28rem)]">
|
||||||
<section className="space-y-6">
|
<section className="space-y-6">
|
||||||
<div className="max-w-xl">
|
<div className="max-w-xl">
|
||||||
<BrandMark className="h-auto w-[15rem] drop-shadow-[0_14px_36px_rgba(0,0,0,0.18)]" />
|
<BrandMark className="h-auto w-60 drop-shadow-[0_14px_36px_rgba(0,0,0,0.18)]" />
|
||||||
<h1 className="mt-6 text-3xl font-semibold tracking-tight sm:text-4xl">
|
<h1 className="mt-6 text-3xl font-semibold tracking-tight sm:text-4xl">
|
||||||
Set up {BRAND.name} with a clean privacy line.
|
Set up {BRAND.name} with a clean privacy line.
|
||||||
</h1>
|
</h1>
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,7 @@ export default function PaymentHistoryList({
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-start justify-between gap-3 rounded-lg border px-3 py-2.5',
|
'flex items-start justify-between gap-3 rounded-lg border px-3 py-2.5',
|
||||||
historyOnly
|
historyOnly
|
||||||
? 'border-amber-500/25 bg-amber-500/[0.06] opacity-85'
|
? 'border-amber-500/25 bg-amber-500/6 opacity-85'
|
||||||
: 'border-border/60 bg-background/35',
|
: 'border-border/60 bg-background/35',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -79,11 +79,11 @@ function PaymentsExport() {
|
||||||
<div className="mt-3 flex flex-wrap items-end gap-3">
|
<div className="mt-3 flex flex-wrap items-end gap-3">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="pe-from" className="text-xs">From</Label>
|
<Label htmlFor="pe-from" className="text-xs">From</Label>
|
||||||
<Input id="pe-from" type="date" value={from} onChange={e => setFrom(e.target.value)} className="mt-1 h-9 w-[9.5rem]" />
|
<Input id="pe-from" type="date" value={from} onChange={e => setFrom(e.target.value)} className="mt-1 h-9 w-38" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="pe-to" className="text-xs">To</Label>
|
<Label htmlFor="pe-to" className="text-xs">To</Label>
|
||||||
<Input id="pe-to" type="date" value={to} onChange={e => setTo(e.target.value)} className="mt-1 h-9 w-[9.5rem]" />
|
<Input id="pe-to" type="date" value={to} onChange={e => setTo(e.target.value)} className="mt-1 h-9 w-38" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="block text-xs text-muted-foreground">Format</span>
|
<span className="block text-xs text-muted-foreground">Format</span>
|
||||||
|
|
|
||||||
|
|
@ -140,7 +140,7 @@ function SuggestedMatchesPanel({ suggestions, loading, actionId, onAccept, onRej
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 flex items-center gap-2 rounded-md border border-emerald-500/15 bg-emerald-500/[0.045] px-2.5 py-2">
|
<div className="mt-3 flex items-center gap-2 rounded-md border border-emerald-500/15 bg-emerald-500/4.5 px-2.5 py-2">
|
||||||
<Link2 className="h-3.5 w-3.5 shrink-0 text-emerald-600 dark:text-emerald-400" />
|
<Link2 className="h-3.5 w-3.5 shrink-0 text-emerald-600 dark:text-emerald-400" />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate text-sm font-medium">{bill.name || `Bill ${suggestion.billId}`}</p>
|
<p className="truncate text-sm font-medium">{bill.name || `Bill ${suggestion.billId}`}</p>
|
||||||
|
|
|
||||||
|
|
@ -14,13 +14,13 @@ export const BrandBlock = React.memo(function BrandBlock({
|
||||||
aria-label={BRAND.name}
|
aria-label={BRAND.name}
|
||||||
className="group flex min-w-0 items-center gap-3 rounded-2xl focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
className="group flex min-w-0 items-center gap-3 rounded-2xl focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||||
>
|
>
|
||||||
<BrandMark className="h-12 w-auto max-w-[6.75rem] drop-shadow-[0_1px_2px_rgba(0,0,0,0.24)] transition-transform duration-200 group-hover:scale-[1.015]" />
|
<BrandMark className="h-12 w-auto max-w-27 drop-shadow-[0_1px_2px_rgba(0,0,0,0.24)] transition-transform duration-200 group-hover:scale-[1.015]" />
|
||||||
<BrandWordmark className="sr-only" />
|
<BrandWordmark className="sr-only" />
|
||||||
<span className="hidden min-w-0 flex-col leading-none xl:flex">
|
<span className="hidden min-w-0 flex-col leading-none xl:flex">
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-primary/80">
|
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-primary/80">
|
||||||
{adminMode ? 'Admin workspace' : 'Home workspace'}
|
{adminMode ? 'Admin workspace' : 'Home workspace'}
|
||||||
</span>
|
</span>
|
||||||
<span className="mt-1 max-w-[12rem] truncate text-xs text-muted-foreground">
|
<span className="mt-1 max-w-48 truncate text-xs text-muted-foreground">
|
||||||
{adminMode ? 'System care and access' : 'Bills, timing, and clarity'}
|
{adminMode ? 'System care and access' : 'Bills, timing, and clarity'}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ export const NavPill = React.memo(function NavPill({
|
||||||
<TooltipProvider delayDuration={300}>
|
<TooltipProvider delayDuration={300}>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<span className="ml-0.5 flex h-4 min-w-[1rem] items-center justify-center rounded-full bg-rose-500 px-1 text-[10px] font-bold leading-none text-white">
|
<span className="ml-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-rose-500 px-1 text-[10px] font-bold leading-none text-white">
|
||||||
{badge > 99 ? '99+' : badge}
|
{badge > 99 ? '99+' : badge}
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
|
|
|
||||||
|
|
@ -124,7 +124,7 @@ function TrackerMenu({
|
||||||
<TooltipProvider delayDuration={300}>
|
<TooltipProvider delayDuration={300}>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<span className="flex h-4 min-w-[1rem] items-center justify-center rounded-full bg-rose-500 px-1 text-[10px] font-bold leading-none text-white">
|
<span className="flex h-4 min-w-4 items-center justify-center rounded-full bg-rose-500 px-1 text-[10px] font-bold leading-none text-white">
|
||||||
{badge > 99 ? '99+' : badge}
|
{badge > 99 ? '99+' : badge}
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
|
|
@ -309,8 +309,8 @@ export default function Sidebar({
|
||||||
}, [adminMode]);
|
}, [adminMode]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-40 border-b border-border/65 bg-card/90 shadow-sm shadow-black/10 backdrop-blur-xl supports-[backdrop-filter]:bg-card/82">
|
<header className="sticky top-0 z-40 border-b border-border/65 bg-card/90 shadow-sm shadow-black/10 backdrop-blur-xl supports-backdrop-filter:bg-card/82">
|
||||||
<div className="mx-auto flex h-[4.5rem] w-full max-w-[1500px] items-center gap-4 px-4 sm:px-6 lg:px-8">
|
<div className="mx-auto flex h-18 w-full max-w-[1500px] items-center gap-4 px-4 sm:px-6 lg:px-8">
|
||||||
<BrandBlock adminMode={adminMode} />
|
<BrandBlock adminMode={adminMode} />
|
||||||
|
|
||||||
<nav
|
<nav
|
||||||
|
|
|
||||||
|
|
@ -148,7 +148,7 @@ function PlanDetail({ plan }: { plan: SnowballPlan }) {
|
||||||
{/* Per-debt snapshot table */}
|
{/* Per-debt snapshot table */}
|
||||||
{debts.length > 0 && (
|
{debts.length > 0 && (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<p className="text-[10px] font-bold uppercase tracking-[0.1em] text-muted-foreground/50">
|
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground/50">
|
||||||
Debt snapshot at start of plan
|
Debt snapshot at start of plan
|
||||||
</p>
|
</p>
|
||||||
<div className="rounded-lg border border-border/50 overflow-hidden">
|
<div className="rounded-lg border border-border/50 overflow-hidden">
|
||||||
|
|
|
||||||
|
|
@ -213,7 +213,7 @@ export default function PlanStatusBanner({
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Collapsible open={open} onOpenChange={setOpen}>
|
<Collapsible open={open} onOpenChange={setOpen}>
|
||||||
<div className="mb-4 rounded-xl border border-emerald-400/25 bg-emerald-500/[0.05] dark:bg-emerald-400/[0.04] shadow-sm overflow-hidden">
|
<div className="mb-4 rounded-xl border border-emerald-400/25 bg-emerald-500/5 dark:bg-emerald-400/4 shadow-sm overflow-hidden">
|
||||||
{/* Header row. The name/progress area and the chevron are the collapsible
|
{/* Header row. The name/progress area and the chevron are the collapsible
|
||||||
toggles; the action buttons are siblings (not nested inside a trigger
|
toggles; the action buttons are siblings (not nested inside a trigger
|
||||||
button) so they don't trip axe nested-interactive (a11y QA-B14-02). */}
|
button) so they don't trip axe nested-interactive (a11y QA-B14-02). */}
|
||||||
|
|
|
||||||
|
|
@ -222,14 +222,14 @@ export default function CashFlowCard({
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute top-0 left-0 right-0 h-[3px] bg-gradient-to-r',
|
'absolute top-0 left-0 right-0 h-[3px] bg-linear-to-r',
|
||||||
positive
|
positive
|
||||||
? 'from-emerald-500 via-teal-400 to-emerald-300'
|
? 'from-emerald-500 via-teal-400 to-emerald-300'
|
||||||
: 'from-rose-500 via-rose-400 to-orange-300',
|
: 'from-rose-500 via-rose-400 to-orange-300',
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid gap-5 px-5 py-4 md:grid-cols-[200px,minmax(0,1fr),220px] md:items-center">
|
<div className="grid gap-5 px-5 py-4 md:grid-cols-[200px_minmax(0,1fr)_220px] md:items-center">
|
||||||
{/* ── Number ── */}
|
{/* ── Number ── */}
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-2 flex items-center gap-2">
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
|
|
||||||
|
|
@ -120,8 +120,8 @@ function DriftRow({ row, refresh }: { row: DriftBill; refresh: () => void }) {
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-7 gap-1.5 px-2.5 text-xs',
|
'h-7 gap-1.5 px-2.5 text-xs',
|
||||||
isUp
|
isUp
|
||||||
? 'border-amber-400/40 text-amber-600 hover:border-amber-400/70 hover:bg-amber-500/[0.08] hover:text-amber-500 dark:text-amber-400'
|
? 'border-amber-400/40 text-amber-600 hover:border-amber-400/70 hover:bg-amber-500/8 hover:text-amber-500 dark:text-amber-400'
|
||||||
: 'border-teal-400/40 text-teal-600 hover:border-teal-400/70 hover:bg-teal-500/[0.08] hover:text-teal-500 dark:text-teal-400',
|
: 'border-teal-400/40 text-teal-600 hover:border-teal-400/70 hover:bg-teal-500/8 hover:text-teal-500 dark:text-teal-400',
|
||||||
)}
|
)}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
onClick={handleUpdate}
|
onClick={handleUpdate}
|
||||||
|
|
@ -182,7 +182,7 @@ export default function DriftInsightPanel({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Collapsible open={isOpen} onOpenChange={setOpen}>
|
<Collapsible open={isOpen} onOpenChange={setOpen}>
|
||||||
<div className="rounded-xl border border-amber-400/30 bg-amber-500/[0.06] shadow-sm overflow-hidden dark:bg-amber-400/[0.05]">
|
<div className="rounded-xl border border-amber-400/30 bg-amber-500/6 shadow-sm overflow-hidden dark:bg-amber-400/5">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-2 px-4 py-3">
|
<div className="flex items-center gap-2 px-4 py-3">
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
|
|
|
||||||
|
|
@ -226,7 +226,7 @@ export default function OverdueCommandCenter({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Collapsible open={isOpen} onOpenChange={setOpen}>
|
<Collapsible open={isOpen} onOpenChange={setOpen}>
|
||||||
<div className="surface-premium overflow-hidden border-amber-500/30 bg-amber-500/[0.055] dark:border-amber-300/25 dark:bg-amber-400/[0.05]">
|
<div className="surface-premium overflow-hidden border-amber-500/30 bg-amber-500/5.5 dark:border-amber-300/25 dark:bg-amber-400/5">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-2 px-3 py-3 sm:px-4">
|
<div className="flex items-center gap-2 px-3 py-3 sm:px-4">
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
|
|
|
||||||
|
|
@ -282,7 +282,7 @@ export function TrackerBucket({
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex min-w-[7rem] items-center gap-2">
|
<div className="flex min-w-28 items-center gap-2">
|
||||||
<div className="h-1.5 w-16 overflow-hidden rounded-full bg-border sm:w-24">
|
<div className="h-1.5 w-16 overflow-hidden rounded-full bg-border sm:w-24">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
|
||||||
|
|
@ -549,7 +549,7 @@ export function TrackerRow({
|
||||||
setEditingDue(false);
|
setEditingDue(false);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="tracker-number w-12 rounded border border-border bg-transparent px-1 py-0.5 text-sm font-medium text-foreground outline-none focus:ring-[2px] focus:ring-ring/50"
|
className="tracker-number w-12 rounded border border-border bg-transparent px-1 py-0.5 text-sm font-medium text-foreground outline-none focus:ring-2 focus:ring-ring/50"
|
||||||
title="Day of month (1–31)"
|
title="Day of month (1–31)"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -586,7 +586,7 @@ export function TrackerRow({
|
||||||
setEditingExpected(false);
|
setEditingExpected(false);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="tracker-number mx-auto w-24 rounded border border-border bg-transparent px-1 py-0.5 text-center text-sm font-semibold text-foreground outline-none focus:ring-[2px] focus:ring-ring/50"
|
className="tracker-number mx-auto w-24 rounded border border-border bg-transparent px-1 py-0.5 text-center text-sm font-semibold text-foreground outline-none focus:ring-2 focus:ring-ring/50"
|
||||||
/>
|
/>
|
||||||
) : effectiveActual != null ? (
|
) : effectiveActual != null ? (
|
||||||
<button
|
<button
|
||||||
|
|
@ -679,7 +679,7 @@ export function TrackerRow({
|
||||||
<PaymentProgress
|
<PaymentProgress
|
||||||
row={row}
|
row={row}
|
||||||
threshold={threshold}
|
threshold={threshold}
|
||||||
className="mx-auto max-w-[7.5rem]"
|
className="mx-auto max-w-30"
|
||||||
onOpen={() => setPaymentLedgerOpen(true)}
|
onOpen={() => setPaymentLedgerOpen(true)}
|
||||||
onMarkFullAmount={!isSkipped ? handleMarkFullAmount : undefined}
|
onMarkFullAmount={!isSkipped ? handleMarkFullAmount : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,8 @@ type Tone = 'neutral' | 'good' | 'warn' | 'danger' | 'info';
|
||||||
const toneClasses: Record<Tone, string> = {
|
const toneClasses: Record<Tone, string> = {
|
||||||
neutral: 'border-border/75 bg-card/95 text-muted-foreground',
|
neutral: 'border-border/75 bg-card/95 text-muted-foreground',
|
||||||
good: 'border-emerald-500/25 bg-emerald-500/[0.07] text-emerald-700 dark:text-emerald-300',
|
good: 'border-emerald-500/25 bg-emerald-500/[0.07] text-emerald-700 dark:text-emerald-300',
|
||||||
warn: 'border-amber-500/30 bg-amber-500/[0.08] text-amber-700 dark:text-amber-300',
|
warn: 'border-amber-500/30 bg-amber-500/8 text-amber-700 dark:text-amber-300',
|
||||||
danger: 'border-rose-500/30 bg-rose-500/[0.08] text-rose-700 dark:text-rose-300',
|
danger: 'border-rose-500/30 bg-rose-500/8 text-rose-700 dark:text-rose-300',
|
||||||
info: 'border-sky-500/25 bg-sky-500/[0.07] text-sky-700 dark:text-sky-300',
|
info: 'border-sky-500/25 bg-sky-500/[0.07] text-sky-700 dark:text-sky-300',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -50,7 +50,7 @@ export function PageHeader({
|
||||||
<div className="flex min-w-0 items-start gap-3">
|
<div className="flex min-w-0 items-start gap-3">
|
||||||
{glyph && <BrandGlyph name={glyph} className="mt-0.5 hidden sm:inline-block" />}
|
{glyph && <BrandGlyph name={glyph} className="mt-0.5 hidden sm:inline-block" />}
|
||||||
{Icon && !glyph && (
|
{Icon && !glyph && (
|
||||||
<span className="mt-0.5 hidden h-10 w-10 shrink-0 place-items-center rounded-xl border border-primary/20 bg-primary/[0.08] text-primary shadow-sm shadow-primary/10 sm:grid">
|
<span className="mt-0.5 hidden h-10 w-10 shrink-0 place-items-center rounded-xl border border-primary/20 bg-primary/8 text-primary shadow-sm shadow-primary/10 sm:grid">
|
||||||
<Icon className="h-5 w-5" />
|
<Icon className="h-5 w-5" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -136,7 +136,7 @@ export function MetricCard({
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
{Icon && (
|
{Icon && (
|
||||||
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-lg border border-current/15 bg-current/[0.08] text-current">
|
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-lg border border-current/15 bg-current/8 text-current">
|
||||||
<Icon className="h-3.5 w-3.5 opacity-90" />
|
<Icon className="h-3.5 w-3.5 opacity-90" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ function DropdownMenuSubContent({
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.SubContent
|
<DropdownMenuPrimitive.SubContent
|
||||||
className={cn(
|
className={cn(
|
||||||
'z-50 min-w-[8rem] overflow-hidden rounded-xl border border-border/70 bg-popover/95 p-1 text-popover-foreground shadow-xl backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
'z-50 min-w-32 overflow-hidden rounded-xl border border-border/70 bg-popover/95 p-1 text-popover-foreground shadow-xl backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
role="menu"
|
role="menu"
|
||||||
|
|
@ -60,7 +60,7 @@ function DropdownMenuContent({
|
||||||
<DropdownMenuPrimitive.Content
|
<DropdownMenuPrimitive.Content
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
'z-50 min-w-[10rem] overflow-hidden rounded-xl border border-border/70 bg-popover/95 p-1 text-popover-foreground shadow-xl backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
'z-50 min-w-40 overflow-hidden rounded-xl border border-border/70 bg-popover/95 p-1 text-popover-foreground shadow-xl backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
role="menu"
|
role="menu"
|
||||||
|
|
@ -80,7 +80,7 @@ function DropdownMenuItem({
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Item
|
<DropdownMenuPrimitive.Item
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative flex cursor-pointer select-none items-center gap-2 rounded-lg px-2.5 py-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0',
|
'relative flex cursor-pointer select-none items-center gap-2 rounded-lg px-2.5 py-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||||
inset && 'pl-8',
|
inset && 'pl-8',
|
||||||
destructive && 'text-destructive focus:bg-destructive/10 focus:text-destructive',
|
destructive && 'text-destructive focus:bg-destructive/10 focus:text-destructive',
|
||||||
className,
|
className,
|
||||||
|
|
@ -100,7 +100,7 @@ function DropdownMenuCheckboxItem({
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.CheckboxItem
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative flex cursor-pointer select-none items-center rounded-lg py-2 pl-8 pr-2.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50',
|
'relative flex cursor-pointer select-none items-center rounded-lg py-2 pl-8 pr-2.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
role="menuitemcheckbox"
|
role="menuitemcheckbox"
|
||||||
|
|
@ -125,7 +125,7 @@ function DropdownMenuRadioItem({
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.RadioItem
|
<DropdownMenuPrimitive.RadioItem
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative flex cursor-pointer select-none items-center rounded-lg py-2 pl-8 pr-2.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50',
|
'relative flex cursor-pointer select-none items-center rounded-lg py-2 pl-8 pr-2.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
role="menuitemradio"
|
role="menuitemradio"
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ function SelectContent({
|
||||||
<SelectPrimitive.Content
|
<SelectPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-xl border border-border/80 bg-popover/95 text-popover-foreground shadow-xl shadow-black/25 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
'relative z-50 max-h-96 min-w-32 overflow-hidden rounded-xl border border-border/80 bg-popover/95 text-popover-foreground shadow-xl shadow-black/25 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||||
position === 'popper' &&
|
position === 'popper' &&
|
||||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||||
className,
|
className,
|
||||||
|
|
@ -91,7 +91,7 @@ function SelectContent({
|
||||||
className={cn(
|
className={cn(
|
||||||
'p-1',
|
'p-1',
|
||||||
position === 'popper' &&
|
position === 'popper' &&
|
||||||
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]',
|
'h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width)',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
@ -122,7 +122,7 @@ function SelectItem({
|
||||||
<SelectPrimitive.Item
|
<SelectPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative flex w-full cursor-pointer select-none items-center rounded-lg py-1.5 pl-2 pr-8 text-sm font-medium outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50',
|
'relative flex w-full cursor-pointer select-none items-center rounded-lg py-1.5 pl-2 pr-8 text-sm font-medium outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
role="option"
|
role="option"
|
||||||
|
|
|
||||||
|
|
@ -66,8 +66,8 @@ function TableHead({ className, ref, ...props }: ComponentProps<'th'>) {
|
||||||
'h-10 px-4 text-left align-middle',
|
'h-10 px-4 text-left align-middle',
|
||||||
'text-xs font-semibold uppercase tracking-normal',
|
'text-xs font-semibold uppercase tracking-normal',
|
||||||
'text-muted-foreground',
|
'text-muted-foreground',
|
||||||
'[&:has([role=checkbox])]:pr-0',
|
'has-[[role=checkbox]]:pr-0',
|
||||||
'[&>[role=checkbox]]:translate-y-[2px]',
|
'*:[[role=checkbox]]:translate-y-[2px]',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -82,8 +82,8 @@ function TableCell({ className, ref, ...props }: ComponentProps<'td'>) {
|
||||||
className={cn(
|
className={cn(
|
||||||
'px-5 py-3 align-middle',
|
'px-5 py-3 align-middle',
|
||||||
'text-sm font-medium text-foreground',
|
'text-sm font-medium text-foreground',
|
||||||
'[&:has([role=checkbox])]:pr-0',
|
'has-[[role=checkbox]]:pr-0',
|
||||||
'[&>[role=checkbox]]:translate-y-[2px]',
|
'*:[[role=checkbox]]:translate-y-[2px]',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ function DropdownMenuItem({
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative flex cursor-pointer select-none items-center gap-2 rounded-lg px-2.5 py-2 text-sm outline-none',
|
'relative flex cursor-pointer select-none items-center gap-2 rounded-lg px-2.5 py-2 text-sm outline-none',
|
||||||
'transition-colors focus:bg-accent focus:text-accent-foreground',
|
'transition-colors focus:bg-accent focus:text-accent-foreground',
|
||||||
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
'data-disabled:pointer-events-none data-disabled:opacity-50',
|
||||||
inset && 'pl-8',
|
inset && 'pl-8',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
413
client/index.css
413
client/index.css
|
|
@ -5,29 +5,294 @@
|
||||||
|
|
||||||
/* ── Georgia for digits only ─────────────────────────────── */
|
/* ── Georgia for digits only ─────────────────────────────── */
|
||||||
|
|
||||||
@font-face {
|
@import 'tailwindcss';
|
||||||
font-family: 'GeorgiaDigits';
|
|
||||||
src: local('Georgia');
|
@plugin 'tailwindcss-animate';
|
||||||
unicode-range:
|
|
||||||
U+0030- U+0039,
|
@custom-variant dark (&:is(.dark *));
|
||||||
/* 0–9 */ U+002C,
|
|
||||||
/* , thousands separator */ U+002E,
|
@utility container {
|
||||||
/* . decimal point */ U+002D,
|
margin-inline: auto;
|
||||||
/* - minus / hyphen */ U+002B,
|
padding-inline: 2rem;
|
||||||
/* + plus */ U+0025,
|
@media (width >= --theme(--breakpoint-sm)) {
|
||||||
/* % percent */ U+0024,
|
max-width: none;
|
||||||
/* $ dollar */ U+00A2,
|
}
|
||||||
/* ¢ cent */ U+00A3,
|
@media (width >= 1400px) {
|
||||||
/* £ pound */ U+00A5,
|
max-width: 1400px;
|
||||||
/* ¥ yen */ U+20AC; /* € euro */
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-border: oklch(var(--border));
|
||||||
|
--color-input: oklch(var(--input));
|
||||||
|
--color-ring: oklch(var(--ring));
|
||||||
|
--color-background: oklch(var(--background));
|
||||||
|
--color-foreground: oklch(var(--foreground));
|
||||||
|
|
||||||
|
--color-primary: oklch(var(--primary));
|
||||||
|
--color-primary-foreground: oklch(var(--primary-foreground));
|
||||||
|
|
||||||
|
--color-secondary: oklch(var(--secondary));
|
||||||
|
--color-secondary-foreground: oklch(var(--secondary-foreground));
|
||||||
|
|
||||||
|
--color-destructive: oklch(var(--destructive));
|
||||||
|
--color-destructive-foreground: oklch(var(--destructive-foreground));
|
||||||
|
|
||||||
|
--color-muted: oklch(var(--muted));
|
||||||
|
--color-muted-foreground: oklch(var(--muted-foreground));
|
||||||
|
|
||||||
|
--color-accent: oklch(var(--accent));
|
||||||
|
--color-accent-foreground: oklch(var(--accent-foreground));
|
||||||
|
|
||||||
|
--color-popover: oklch(var(--popover));
|
||||||
|
--color-popover-foreground: oklch(var(--popover-foreground));
|
||||||
|
|
||||||
|
--color-card: oklch(var(--card));
|
||||||
|
--color-card-foreground: oklch(var(--card-foreground));
|
||||||
|
|
||||||
|
--color-sidebar: oklch(var(--sidebar));
|
||||||
|
--color-sidebar-foreground: oklch(var(--sidebar-foreground));
|
||||||
|
--color-sidebar-primary: oklch(var(--sidebar-primary));
|
||||||
|
--color-sidebar-accent: oklch(var(--sidebar-accent));
|
||||||
|
--color-sidebar-border: oklch(var(--sidebar-border));
|
||||||
|
--color-sidebar-ring: oklch(var(--sidebar-ring));
|
||||||
|
|
||||||
|
--font-sans: var(--font-sans), ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--font-serif: var(--font-serif), ui-serif, serif;
|
||||||
|
--font-mono: var(--font-mono), ui-monospace, SFMono-Regular, monospace;
|
||||||
|
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
|
||||||
|
--animate-accordion-down: accordion-down 0.2s ease-out;
|
||||||
|
--animate-accordion-up: accordion-up 0.2s ease-out;
|
||||||
|
--animate-collapsible-down: collapsible-down 0.2s ease-out;
|
||||||
|
--animate-collapsible-up: collapsible-up 0.2s ease-out;
|
||||||
|
|
||||||
|
@keyframes accordion-down {
|
||||||
|
from {
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
height: var(--radix-accordion-content-height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes accordion-up {
|
||||||
|
from {
|
||||||
|
height: var(--radix-accordion-content-height);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes collapsible-down {
|
||||||
|
from {
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
height: var(--radix-collapsible-content-height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes collapsible-up {
|
||||||
|
from {
|
||||||
|
height: var(--radix-collapsible-content-height);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
The default border color has changed to `currentcolor` in Tailwind CSS v4,
|
||||||
|
so we've added these compatibility styles to make sure everything still
|
||||||
|
looks the same as it did with Tailwind CSS v3.
|
||||||
|
|
||||||
|
If we ever want to remove these styles, we need to add an explicit border
|
||||||
|
color utility to any element that depends on these defaults.
|
||||||
|
*/
|
||||||
|
@layer base {
|
||||||
|
*,
|
||||||
|
::after,
|
||||||
|
::before,
|
||||||
|
::backdrop,
|
||||||
|
::file-selector-button {
|
||||||
|
border-color: var(--color-gray-200, currentcolor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Utilities ───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
@utility surface {
|
||||||
|
/* Generic surface */
|
||||||
|
@apply rounded-xl border border-border/75 bg-card/95 shadow-xs shadow-black/10;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility surface-elevated {
|
||||||
|
/* Elevated surface */
|
||||||
|
@apply surface backdrop-blur-xs;
|
||||||
|
box-shadow:
|
||||||
|
0 1px 2px rgb(0 0 0 / 0.05),
|
||||||
|
0 10px 30px rgb(15 23 42 / 0.07);
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px oklch(var(--border) / 0.7),
|
||||||
|
0 14px 34px rgb(0 0 0 / 0.42);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility dark {
|
||||||
|
& .surface-elevated {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px oklch(var(--border) / 0.7),
|
||||||
|
0 14px 34px rgb(0 0 0 / 0.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
& .surface-premium {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px oklch(var(--border) / 0.55),
|
||||||
|
0 12px 30px rgb(0 0 0 / 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Faded muted-text variants: keep them readable in dark mode — Tailwind's
|
||||||
|
raw /40–/70 alphas dissolve into the background. */
|
||||||
|
& .text-muted-foreground\/40 {
|
||||||
|
color: oklch(var(--muted-foreground) / 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
& .text-muted-foreground\/50 {
|
||||||
|
color: oklch(var(--muted-foreground) / 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
& .text-muted-foreground\/60 {
|
||||||
|
color: oklch(var(--muted-foreground) / 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
& .text-muted-foreground\/70 {
|
||||||
|
color: oklch(var(--muted-foreground) / 0.95);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility surface-premium {
|
||||||
|
@apply rounded-xl border border-border/75 bg-card/95 shadow-xs shadow-black/10 backdrop-blur-xs;
|
||||||
|
|
||||||
|
.dark & {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px oklch(var(--border) / 0.55),
|
||||||
|
0 12px 30px rgb(0 0 0 / 0.34);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility metric-card {
|
||||||
|
@apply relative min-h-31 overflow-hidden rounded-xl border border-border/75 bg-card/95 px-4 py-4 shadow-xs shadow-black/10 sm:px-5;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 0 auto;
|
||||||
|
height: 2px;
|
||||||
|
background: currentColor;
|
||||||
|
opacity: 0.24;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility stat-card {
|
||||||
|
/* Stat cards */
|
||||||
|
@apply surface-elevated p-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility table-surface {
|
||||||
|
/* Table */
|
||||||
|
@apply surface-premium overflow-hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility tracker-number {
|
||||||
|
font-family: 'GeorgiaDigits', Inter, Roboto, ui-sans-serif, system-ui, sans-serif;
|
||||||
|
font-variant-numeric: tabular-nums lining-nums;
|
||||||
|
font-feature-settings:
|
||||||
|
'tnum' 1,
|
||||||
|
'lnum' 1;
|
||||||
|
-webkit-font-smoothing: auto;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility metric-value {
|
||||||
|
@apply tracker-number font-bold tracking-tight text-foreground;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility text-muted-foreground/40 {
|
||||||
|
/* Faded muted-text variants: keep them readable in dark mode — Tailwind's
|
||||||
|
raw /40–/70 alphas dissolve into the background. */
|
||||||
|
.dark & {
|
||||||
|
color: oklch(var(--muted-foreground) / 0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility text-muted-foreground/50 {
|
||||||
|
.dark & {
|
||||||
|
color: oklch(var(--muted-foreground) / 0.85);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility text-muted-foreground/60 {
|
||||||
|
.dark & {
|
||||||
|
color: oklch(var(--muted-foreground) / 0.9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility text-muted-foreground/70 {
|
||||||
|
.dark & {
|
||||||
|
color: oklch(var(--muted-foreground) / 0.95);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility scrollbar-thin {
|
||||||
|
/* Custom Scrollbar */
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility scrollbar-thumb-muted {
|
||||||
|
scrollbar-color: oklch(var(--muted) / 0.3) transparent;
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background-color: oklch(var(--muted) / 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
&::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: oklch(var(--muted) / 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility scrollbar-track-transparent {
|
||||||
|
scrollbar-color: oklch(var(--muted) / 0.3) transparent;
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
@font-face {
|
||||||
|
font-family: 'GeorgiaDigits';
|
||||||
|
src: local('Georgia');
|
||||||
|
unicode-range:
|
||||||
|
U+0030- U+0039,
|
||||||
|
/* 0–9 */ U+002C,
|
||||||
|
/* , thousands separator */ U+002E,
|
||||||
|
/* . decimal point */ U+002D,
|
||||||
|
/* - minus / hyphen */ U+002B,
|
||||||
|
/* + plus */ U+0025,
|
||||||
|
/* % percent */ U+0024,
|
||||||
|
/* $ dollar */ U+00A2,
|
||||||
|
/* ¢ cent */ U+00A3,
|
||||||
|
/* £ pound */ U+00A5,
|
||||||
|
/* ¥ yen */ U+20AC; /* € euro */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Tailwind base ───────────────────────────────────────── */
|
/* ── Tailwind base ───────────────────────────────────────── */
|
||||||
|
|
||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--background: 0.985 0.006 235;
|
--background: 0.985 0.006 235;
|
||||||
|
|
@ -130,116 +395,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Utilities ───────────────────────────────────────────── */
|
|
||||||
|
|
||||||
@layer utilities {
|
|
||||||
/* Generic surface */
|
|
||||||
.surface {
|
|
||||||
@apply rounded-xl border border-border/75 bg-card/95 shadow-sm shadow-black/10;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Elevated surface */
|
|
||||||
.surface-elevated {
|
|
||||||
@apply surface backdrop-blur-sm;
|
|
||||||
box-shadow:
|
|
||||||
0 1px 2px rgb(0 0 0 / 0.05),
|
|
||||||
0 10px 30px rgb(15 23 42 / 0.07);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .surface-elevated {
|
|
||||||
box-shadow:
|
|
||||||
0 0 0 1px oklch(var(--border) / 0.7),
|
|
||||||
0 14px 34px rgb(0 0 0 / 0.42);
|
|
||||||
}
|
|
||||||
|
|
||||||
.surface-premium {
|
|
||||||
@apply rounded-xl border border-border/75 bg-card/95 shadow-sm shadow-black/10 backdrop-blur-sm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .surface-premium {
|
|
||||||
box-shadow:
|
|
||||||
0 0 0 1px oklch(var(--border) / 0.55),
|
|
||||||
0 12px 30px rgb(0 0 0 / 0.34);
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-card {
|
|
||||||
@apply relative min-h-[7.75rem] overflow-hidden rounded-xl border border-border/75 bg-card/95 px-4 py-4 shadow-sm shadow-black/10 sm:px-5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-card::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
inset: 0 0 auto;
|
|
||||||
height: 2px;
|
|
||||||
background: currentColor;
|
|
||||||
opacity: 0.24;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Stat cards */
|
|
||||||
.stat-card {
|
|
||||||
@apply surface-elevated p-6;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Table */
|
|
||||||
.table-surface {
|
|
||||||
@apply surface-premium overflow-hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tracker-number {
|
|
||||||
font-family: 'GeorgiaDigits', Inter, Roboto, ui-sans-serif, system-ui, sans-serif;
|
|
||||||
font-variant-numeric: tabular-nums lining-nums;
|
|
||||||
font-feature-settings:
|
|
||||||
'tnum' 1,
|
|
||||||
'lnum' 1;
|
|
||||||
-webkit-font-smoothing: auto;
|
|
||||||
text-rendering: optimizeLegibility;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-value {
|
|
||||||
@apply tracker-number font-bold tracking-tight text-foreground;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Faded muted-text variants: keep them readable in dark mode — Tailwind's
|
|
||||||
raw /40–/70 alphas dissolve into the background. */
|
|
||||||
.dark .text-muted-foreground\/40 {
|
|
||||||
color: oklch(var(--muted-foreground) / 0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .text-muted-foreground\/50 {
|
|
||||||
color: oklch(var(--muted-foreground) / 0.85);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .text-muted-foreground\/60 {
|
|
||||||
color: oklch(var(--muted-foreground) / 0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .text-muted-foreground\/70 {
|
|
||||||
color: oklch(var(--muted-foreground) / 0.95);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Custom Scrollbar */
|
|
||||||
.scrollbar-thin {
|
|
||||||
scrollbar-width: thin;
|
|
||||||
}
|
|
||||||
.scrollbar-thumb-muted {
|
|
||||||
scrollbar-color: oklch(var(--muted) / 0.3) transparent;
|
|
||||||
}
|
|
||||||
.scrollbar-track-transparent {
|
|
||||||
scrollbar-color: oklch(var(--muted) / 0.3) transparent;
|
|
||||||
}
|
|
||||||
.scrollbar-thumb-muted::-webkit-scrollbar-thumb {
|
|
||||||
background-color: oklch(var(--muted) / 0.3);
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
.scrollbar-track-transparent::-webkit-scrollbar-track {
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
.scrollbar-thumb-muted::-webkit-scrollbar-thumb:hover {
|
|
||||||
background-color: oklch(var(--muted) / 0.5);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
body {
|
body {
|
||||||
background: white !important;
|
background: white !important;
|
||||||
|
|
|
||||||
|
|
@ -51,16 +51,16 @@ export const TRACKER_SORT_DEFAULT_DIRS = Object.fromEntries(
|
||||||
);
|
);
|
||||||
|
|
||||||
export const ROW_STATUS_CLS: Record<string, string> = {
|
export const ROW_STATUS_CLS: Record<string, string> = {
|
||||||
paid: 'border-l-2 border-l-emerald-500/45 bg-emerald-500/[0.025] dark:bg-emerald-400/[0.018] [&>td:first-child]:border-l-2 [&>td:first-child]:border-l-emerald-500/45',
|
paid: 'border-l-2 border-l-emerald-500/45 bg-emerald-500/2.5 dark:bg-emerald-400/[0.018] [&>td:first-child]:border-l-2 [&>td:first-child]:border-l-emerald-500/45',
|
||||||
autodraft:
|
autodraft:
|
||||||
'border-l-2 border-l-teal-500/45 bg-teal-500/[0.025] dark:bg-teal-400/[0.018] [&>td:first-child]:border-l-2 [&>td:first-child]:border-l-teal-500/45',
|
'border-l-2 border-l-teal-500/45 bg-teal-500/2.5 dark:bg-teal-400/[0.018] [&>td:first-child]:border-l-2 [&>td:first-child]:border-l-teal-500/45',
|
||||||
upcoming:
|
upcoming:
|
||||||
'border-l-2 border-l-transparent [&>td:first-child]:border-l-2 [&>td:first-child]:border-l-transparent',
|
'border-l-2 border-l-transparent [&>td:first-child]:border-l-2 [&>td:first-child]:border-l-transparent',
|
||||||
due_soon:
|
due_soon:
|
||||||
'border-l-2 border-l-amber-400/65 bg-amber-400/[0.055] dark:bg-amber-300/[0.02] [&>td:first-child]:border-l-2 [&>td:first-child]:border-l-amber-400/65',
|
'border-l-2 border-l-amber-400/65 bg-amber-400/5.5 dark:bg-amber-300/2 [&>td:first-child]:border-l-2 [&>td:first-child]:border-l-amber-400/65',
|
||||||
late: 'border-l-2 border-l-orange-400/80 bg-orange-500/[0.09] ring-1 ring-inset ring-orange-400/20 dark:bg-orange-400/[0.07] dark:ring-orange-300/20 [&>td:first-child]:border-l-2 [&>td:first-child]:border-l-orange-400/80',
|
late: 'border-l-2 border-l-orange-400/80 bg-orange-500/9 ring-1 ring-inset ring-orange-400/20 dark:bg-orange-400/[0.07] dark:ring-orange-300/20 [&>td:first-child]:border-l-2 [&>td:first-child]:border-l-orange-400/80',
|
||||||
missed:
|
missed:
|
||||||
'border-l-2 border-l-rose-400/80 bg-rose-500/[0.1] ring-1 ring-inset ring-rose-400/20 dark:bg-rose-400/[0.075] dark:ring-rose-300/20 [&>td:first-child]:border-l-2 [&>td:first-child]:border-l-rose-400/80',
|
'border-l-2 border-l-rose-400/80 bg-rose-500/10 ring-1 ring-inset ring-rose-400/20 dark:bg-rose-400/7.5 dark:ring-rose-300/20 [&>td:first-child]:border-l-2 [&>td:first-child]:border-l-rose-400/80',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const STATUS_META = {
|
export const STATUS_META = {
|
||||||
|
|
|
||||||
|
|
@ -182,7 +182,7 @@ export default function AboutPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-xl border border-primary/20 bg-primary/[0.06] p-4">
|
<div className="rounded-xl border border-primary/20 bg-primary/6 p-4">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<Sparkles className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
|
<Sparkles className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -592,11 +592,11 @@ function CalendarGrid({
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex min-h-16 flex-col border-b border-r border-border/60 bg-card/70 p-1.5 text-left transition-colors sm:min-h-24 sm:p-2',
|
'flex min-h-16 flex-col border-b border-r border-border/60 bg-card/70 p-1.5 text-left transition-colors sm:min-h-24 sm:p-2',
|
||||||
'focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50',
|
'focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50',
|
||||||
hasActivity && 'bg-primary/[0.06] hover:bg-accent/70',
|
hasActivity && 'bg-primary/6 hover:bg-accent/70',
|
||||||
isPaidDay && 'bg-emerald-500/[0.10]',
|
isPaidDay && 'bg-emerald-500/10',
|
||||||
hasMissed &&
|
hasMissed &&
|
||||||
'border-rose-400/40 bg-rose-500/[0.18] ring-1 ring-inset ring-rose-400/30 dark:bg-rose-400/[0.12]',
|
'border-rose-400/40 bg-rose-500/18 ring-1 ring-inset ring-rose-400/30 dark:bg-rose-400/12',
|
||||||
isSelected && 'ring-2 ring-primary ring-inset bg-primary/[0.09]',
|
isSelected && 'ring-2 ring-primary ring-inset bg-primary/9',
|
||||||
)}
|
)}
|
||||||
aria-label={`View ${fmtDate(day.date)}`}
|
aria-label={`View ${fmtDate(day.date)}`}
|
||||||
>
|
>
|
||||||
|
|
@ -861,7 +861,7 @@ function DebtPayoffGlance({ projection }: { projection: SnowballProjection | nul
|
||||||
{snowball?.months_to_freedom ? (
|
{snowball?.months_to_freedom ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{targetDebt && (
|
{targetDebt && (
|
||||||
<div className="rounded-xl border border-emerald-500/25 bg-emerald-500/[0.08] p-3">
|
<div className="rounded-xl border border-emerald-500/25 bg-emerald-500/8 p-3">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-emerald-500/15 text-emerald-600 dark:text-emerald-300">
|
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-emerald-500/15 text-emerald-600 dark:text-emerald-300">
|
||||||
<Target className="h-5 w-5" />
|
<Target className="h-5 w-5" />
|
||||||
|
|
@ -886,7 +886,7 @@ function DebtPayoffGlance({ projection }: { projection: SnowballProjection | nul
|
||||||
{targetMonths ? `${targetMonths} mo` : '—'}
|
{targetMonths ? `${targetMonths} mo` : '—'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border border-sky-500/15 bg-sky-500/[0.08] p-3">
|
<div className="rounded-lg border border-sky-500/15 bg-sky-500/8 p-3">
|
||||||
<p className="text-xs text-muted-foreground">Debt-free</p>
|
<p className="text-xs text-muted-foreground">Debt-free</p>
|
||||||
<p className="font-semibold text-sky-600 dark:text-sky-300">
|
<p className="font-semibold text-sky-600 dark:text-sky-300">
|
||||||
{snowball.payoff_display}
|
{snowball.payoff_display}
|
||||||
|
|
@ -918,7 +918,7 @@ function DebtPayoffGlance({ projection }: { projection: SnowballProjection | nul
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!targetDebt && (
|
{!targetDebt && (
|
||||||
<div className="rounded-lg border border-sky-500/25 bg-sky-500/[0.08] p-3">
|
<div className="rounded-lg border border-sky-500/25 bg-sky-500/8 p-3">
|
||||||
<p className="text-sm font-medium text-sky-700 dark:text-sky-300">
|
<p className="text-sm font-medium text-sky-700 dark:text-sky-300">
|
||||||
Projection ready
|
Projection ready
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -950,7 +950,7 @@ function DebtPayoffGlance({ projection }: { projection: SnowballProjection | nul
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3 rounded-xl border border-sky-500/25 bg-sky-500/[0.08] p-3">
|
<div className="space-y-3 rounded-xl border border-sky-500/25 bg-sky-500/8 p-3">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-sky-500/15 text-sky-600 dark:text-sky-300">
|
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-sky-500/15 text-sky-600 dark:text-sky-300">
|
||||||
<Target className="h-5 w-5" />
|
<Target className="h-5 w-5" />
|
||||||
|
|
@ -1029,7 +1029,7 @@ function DayDetailDialog({
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-lg border border-border/70 bg-background/75 p-3',
|
'rounded-lg border border-border/70 bg-background/75 p-3',
|
||||||
(bill.status === 'late' || bill.status === 'missed') &&
|
(bill.status === 'late' || bill.status === 'missed') &&
|
||||||
'border-rose-400/40 bg-rose-500/[0.10] ring-1 ring-inset ring-rose-400/20',
|
'border-rose-400/40 bg-rose-500/10 ring-1 ring-inset ring-rose-400/20',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
|
@ -1142,7 +1142,7 @@ function CalendarSubscribeDialog({
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="rounded-lg border border-primary/20 bg-primary/[0.06] p-3">
|
<div className="rounded-lg border border-primary/20 bg-primary/6 p-3">
|
||||||
<p className="text-sm font-medium text-foreground">Best for keeping calendars updated</p>
|
<p className="text-sm font-medium text-foreground">Best for keeping calendars updated</p>
|
||||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||||
A subscription stays linked to Bill Tracker, so future due-date or amount changes can
|
A subscription stays linked to Bill Tracker, so future due-date or amount changes can
|
||||||
|
|
@ -1284,7 +1284,7 @@ export default function CalendarPage() {
|
||||||
>
|
>
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<ChevronLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<span className="min-w-[8rem] px-1 text-center text-sm font-semibold tabular-nums select-none">
|
<span className="min-w-32 px-1 text-center text-sm font-semibold tabular-nums select-none">
|
||||||
{monthLabel}
|
{monthLabel}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -304,7 +304,7 @@ export default function LoginPage() {
|
||||||
<div className="mx-auto w-full max-w-sm space-y-6">
|
<div className="mx-auto w-full max-w-sm space-y-6">
|
||||||
{/* Logo / Brand */}
|
{/* Logo / Brand */}
|
||||||
<div className="flex flex-col items-center text-center lg:hidden">
|
<div className="flex flex-col items-center text-center lg:hidden">
|
||||||
<BrandMark className="h-auto w-[78%] max-w-[12rem] drop-shadow-[0_10px_28px_rgba(0,0,0,0.18)]" />
|
<BrandMark className="h-auto w-[78%] max-w-48 drop-shadow-[0_10px_28px_rgba(0,0,0,0.18)]" />
|
||||||
<p className="mt-3 max-w-xs text-sm leading-6 text-muted-foreground">{BRAND.tagline}</p>
|
<p className="mt-3 max-w-xs text-sm leading-6 text-muted-foreground">{BRAND.tagline}</p>
|
||||||
</div>
|
</div>
|
||||||
{/* TOTP step — shown after password is accepted */}
|
{/* TOTP step — shown after password is accepted */}
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ function HistoryLine({ line, index }: { line: string; index: number }) {
|
||||||
src={image.src}
|
src={image.src}
|
||||||
alt={image.alt}
|
alt={image.alt}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
className="aspect-[16/10] w-full object-contain"
|
className="aspect-16/10 w-full object-contain"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</figure>
|
</figure>
|
||||||
|
|
|
||||||
|
|
@ -202,7 +202,7 @@ function IssueCard({ issue }: { issue: Issue }) {
|
||||||
>
|
>
|
||||||
{/* Title row */}
|
{/* Title row */}
|
||||||
<div className="px-4 pt-3 pb-1.5 flex items-start gap-3">
|
<div className="px-4 pt-3 pb-1.5 flex items-start gap-3">
|
||||||
<p className="flex-1 min-w-0 text-sm font-semibold leading-snug break-words">{title}</p>
|
<p className="flex-1 min-w-0 text-sm font-semibold leading-snug wrap-break-word">{title}</p>
|
||||||
<span className="shrink-0 font-mono text-[11px] text-muted-foreground/60 mt-px tabular-nums">
|
<span className="shrink-0 font-mono text-[11px] text-muted-foreground/60 mt-px tabular-nums">
|
||||||
#{issue.number}
|
#{issue.number}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -436,7 +436,7 @@ function DevLogEntry({ entry }: { entry: DevLogEntryData }) {
|
||||||
className="text-sm text-muted-foreground flex items-start gap-1.5"
|
className="text-sm text-muted-foreground flex items-start gap-1.5"
|
||||||
>
|
>
|
||||||
<span className="text-emerald-500 mt-0.5 shrink-0">✓</span>
|
<span className="text-emerald-500 mt-0.5 shrink-0">✓</span>
|
||||||
<span className="min-w-0 break-words">{work}</span>
|
<span className="min-w-0 wrap-break-word">{work}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
||||||
|
|
@ -962,7 +962,7 @@ export default function SnowballPage() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="surface-elevated min-h-[58px] rounded-xl border border-primary/25 bg-primary/[0.04] px-4 py-3 shadow-sm shadow-primary/5">
|
<div className="surface-elevated min-h-[58px] rounded-xl border border-primary/25 bg-primary/4 px-4 py-3 shadow-sm shadow-primary/5">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<Label
|
<Label
|
||||||
|
|
|
||||||
|
|
@ -894,7 +894,7 @@ function RecommendationCard({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider delayDuration={180}>
|
<TooltipProvider delayDuration={180}>
|
||||||
<div className="rounded-lg border border-primary/20 bg-primary/[0.05] p-4">
|
<div className="rounded-lg border border-primary/20 bg-primary/5 p-4">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex min-w-0 items-start justify-between gap-3">
|
<div className="flex min-w-0 items-start justify-between gap-3">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
|
|
|
||||||
|
|
@ -169,10 +169,10 @@ function HeaderStat({
|
||||||
}) {
|
}) {
|
||||||
const toneClass = {
|
const toneClass = {
|
||||||
neutral: 'border-border/70 bg-card/65 text-muted-foreground',
|
neutral: 'border-border/70 bg-card/65 text-muted-foreground',
|
||||||
good: 'border-emerald-500/25 bg-emerald-500/[0.08] text-emerald-700 dark:text-emerald-300',
|
good: 'border-emerald-500/25 bg-emerald-500/8 text-emerald-700 dark:text-emerald-300',
|
||||||
warn: 'border-amber-500/30 bg-amber-500/[0.08] text-amber-700 dark:text-amber-300',
|
warn: 'border-amber-500/30 bg-amber-500/8 text-amber-700 dark:text-amber-300',
|
||||||
danger: 'border-rose-500/30 bg-rose-500/[0.08] text-rose-700 dark:text-rose-300',
|
danger: 'border-rose-500/30 bg-rose-500/8 text-rose-700 dark:text-rose-300',
|
||||||
info: 'border-sky-500/25 bg-sky-500/[0.08] text-sky-700 dark:text-sky-300',
|
info: 'border-sky-500/25 bg-sky-500/8 text-sky-700 dark:text-sky-300',
|
||||||
}[tone];
|
}[tone];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -1176,7 +1176,7 @@ export default function TrackerPage() {
|
||||||
>
|
>
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<ChevronLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<span className="min-w-[8rem] px-1 text-center text-xs font-semibold tabular-nums select-none">
|
<span className="min-w-32 px-1 text-center text-xs font-semibold tabular-nums select-none">
|
||||||
{activeMonthLabel}
|
{activeMonthLabel}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -1495,7 +1495,7 @@ export default function TrackerPage() {
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'grid h-7 w-7 shrink-0 place-items-center rounded-lg border border-current/15 bg-current/[0.08]',
|
'grid h-7 w-7 shrink-0 place-items-center rounded-lg border border-current/15 bg-current/8',
|
||||||
Number(bankTracking.remaining ?? 0) >= 0
|
Number(bankTracking.remaining ?? 0) >= 0
|
||||||
? 'text-emerald-600 dark:text-emerald-300'
|
? 'text-emerald-600 dark:text-emerald-300'
|
||||||
: 'text-destructive',
|
: 'text-destructive',
|
||||||
|
|
@ -1901,7 +1901,7 @@ export default function TrackerPage() {
|
||||||
<div
|
<div
|
||||||
role="toolbar"
|
role="toolbar"
|
||||||
aria-label="Bulk actions for selected bills"
|
aria-label="Bulk actions for selected bills"
|
||||||
className="flex items-center gap-2 rounded-full border border-border bg-card/95 px-3 py-2 shadow-lg backdrop-blur supports-[backdrop-filter]:bg-card/80"
|
className="flex items-center gap-2 rounded-full border border-border bg-card/95 px-3 py-2 shadow-lg backdrop-blur supports-backdrop-filter:bg-card/80"
|
||||||
>
|
>
|
||||||
<span className="px-2 text-sm font-medium tabular-nums">
|
<span className="px-2 text-sm font-medium tabular-nums">
|
||||||
{selectedRows.length} selected
|
{selectedRows.length} selected
|
||||||
|
|
|
||||||
|
|
@ -21,20 +21,22 @@ No critical issues found. Core functionality is solid.
|
||||||
**Why it matters:** On small screens, the mobile navigation menu doesn't adapt to content width, causing horizontal scroll or content cutoff. This is a blocking accessibility issue for mobile users.
|
**Why it matters:** On small screens, the mobile navigation menu doesn't adapt to content width, causing horizontal scroll or content cutoff. This is a blocking accessibility issue for mobile users.
|
||||||
|
|
||||||
**Current behavior:**
|
**Current behavior:**
|
||||||
|
|
||||||
- Mobile menu uses fixed max-width container
|
- Mobile menu uses fixed max-width container
|
||||||
- Nav items with long text (e.g., "Notification Preferences") wrap poorly
|
- Nav items with long text (e.g., "Notification Preferences") wrap poorly
|
||||||
- No vertical scrolling within the mobile overlay
|
- No vertical scrolling within the mobile overlay
|
||||||
|
|
||||||
**Suggested fix:**
|
**Suggested fix:**
|
||||||
|
|
||||||
```jsx
|
```jsx
|
||||||
// In Sidebar.jsx mobile menu section:
|
// In Sidebar.jsx mobile menu section:
|
||||||
<div className={cn(
|
<div
|
||||||
'border-t border-border/60 bg-background/95 px-4 py-3 shadow-lg shadow-foreground/5 lg:hidden',
|
className={cn(
|
||||||
'max-h-[70vh] overflow-y-auto', // Add scrollable container
|
'border-t border-border/60 bg-background/95 px-4 py-3 shadow-lg shadow-foreground/5 lg:hidden',
|
||||||
)}>
|
'max-h-[70vh] overflow-y-auto', // Add scrollable container
|
||||||
<nav className="mx-auto grid max-w-[1500px] gap-1">
|
)}
|
||||||
{/* ... */}
|
>
|
||||||
</nav>
|
<nav className="mx-auto grid max-w-[1500px] gap-1">{/* ... */}</nav>
|
||||||
</div>
|
</div>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -49,6 +51,7 @@ No critical issues found. Core functionality is solid.
|
||||||
**Why it matters:** The entire page shows a full-page loader (`Loading…`) during initial data fetch, resulting in a blank white screen for 200–500ms. This feels slower than needed.
|
**Why it matters:** The entire page shows a full-page loader (`Loading…`) during initial data fetch, resulting in a blank white screen for 200–500ms. This feels slower than needed.
|
||||||
|
|
||||||
**Suggested fix:**
|
**Suggested fix:**
|
||||||
|
|
||||||
- Replace full-page loader with skeleton cards matching the layout
|
- Replace full-page loader with skeleton cards matching the layout
|
||||||
- Show placeholder content: 2-3 shimmering `SectionCard` components
|
- Show placeholder content: 2-3 shimmering `SectionCard` components
|
||||||
- Fade out skeletons when data arrives
|
- Fade out skeletons when data arrives
|
||||||
|
|
@ -62,22 +65,27 @@ No critical issues found. Core functionality is solid.
|
||||||
**Where:** `client/components/BillModal.jsx`
|
**Where:** `client/components/BillModal.jsx`
|
||||||
|
|
||||||
**Why it matters:** The `handleChange` function debounces validation but still triggers re-renders on every keystroke. This causes:
|
**Why it matters:** The `handleChange` function debounces validation but still triggers re-renders on every keystroke. This causes:
|
||||||
|
|
||||||
- Input field height changes when error messages appear/disappear
|
- Input field height changes when error messages appear/disappear
|
||||||
- Jarring UX during form entry
|
- Jarring UX during form entry
|
||||||
- Potential focus loss on fast typists
|
- Potential focus loss on fast typists
|
||||||
|
|
||||||
**Suggested fix:**
|
**Suggested fix:**
|
||||||
|
|
||||||
- Only show error messages after field blur or form submit attempt
|
- Only show error messages after field blur or form submit attempt
|
||||||
- Pre-allocate error message space (min-height: 12px)
|
- Pre-allocate error message space (min-height: 12px)
|
||||||
- Use `aria-live="polite"` for screen reader notifications
|
- Use `aria-live="polite"` for screen reader notifications
|
||||||
|
|
||||||
**Alternative:**
|
**Alternative:**
|
||||||
|
|
||||||
```jsx
|
```jsx
|
||||||
// Only validate on blur or submit, not on every change
|
// Only validate on blur or submit, not on every change
|
||||||
// Keep error state but don't re-render unless visibility changes
|
// Keep error state but don't re-render unless visibility changes
|
||||||
{errors.name && errorStateVisible && (
|
{
|
||||||
<span className="text-[10px] text-red-500 font-medium">{errors.name}</span>
|
errors.name && errorStateVisible && (
|
||||||
)}
|
<span className="text-[10px] text-red-500 font-medium">{errors.name}</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -91,20 +99,23 @@ No critical issues found. Core functionality is solid.
|
||||||
**Why it matters:** When users navigate to `/bills`, `/categories`, or `/summary`, the main "Tracker" dropdown remains unhighlighted. This creates ambiguity about current location.
|
**Why it matters:** When users navigate to `/bills`, `/categories`, or `/summary`, the main "Tracker" dropdown remains unhighlighted. This creates ambiguity about current location.
|
||||||
|
|
||||||
**Current behavior:**
|
**Current behavior:**
|
||||||
|
|
||||||
- Only the dropdown trigger is highlighted when on `/` (Overview)
|
- Only the dropdown trigger is highlighted when on `/` (Overview)
|
||||||
- Child routes like `/bills` don't indicate they're part of the Tracker group
|
- Child routes like `/bills` don't indicate they're part of the Tracker group
|
||||||
|
|
||||||
**Suggested fix:**
|
**Suggested fix:**
|
||||||
|
|
||||||
- Detect when any child route is active via `location.pathname.startsWith('/bills')` etc.
|
- Detect when any child route is active via `location.pathname.startsWith('/bills')` etc.
|
||||||
- Apply `bg-primary text-primary-foreground` style to the Tracker dropdown when any tracker subpage is active
|
- Apply `bg-primary text-primary-foreground` style to the Tracker dropdown when any tracker subpage is active
|
||||||
|
|
||||||
**Code hint:**
|
**Code hint:**
|
||||||
|
|
||||||
```jsx
|
```jsx
|
||||||
const isTrackerActive = trackerItems.some(item =>
|
const isTrackerActive = trackerItems.some((item) =>
|
||||||
item.end ? location.pathname === item.to
|
item.end ? location.pathname === item.to : location.pathname.startsWith(item.to),
|
||||||
: location.pathname.startsWith(item.to)
|
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
Already implemented in the code, but the `TrackerMenu` trigger needs styling update.
|
Already implemented in the code, but the `TrackerMenu` trigger needs styling update.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -116,18 +127,21 @@ Already implemented in the code, but the `TrackerMenu` trigger needs styling upd
|
||||||
**Why it matters:** Several complex cards (Backup, Email, Users) lack explicit error boundaries. If an API call fails mid-render or throws, the entire admin panel goes blank with no recovery path.
|
**Why it matters:** Several complex cards (Backup, Email, Users) lack explicit error boundaries. If an API call fails mid-render or throws, the entire admin panel goes blank with no recovery path.
|
||||||
|
|
||||||
**Suggested fix:**
|
**Suggested fix:**
|
||||||
|
|
||||||
- Wrap each major card in a `try/catch` or React Error Boundary
|
- Wrap each major card in a `try/catch` or React Error Boundary
|
||||||
- Show "Failed to load" state with retry button
|
- Show "Failed to load" state with retry button
|
||||||
- Example:
|
- Example:
|
||||||
|
|
||||||
```jsx
|
```jsx
|
||||||
function BackupSection() {
|
function BackupSection() {
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [data, setData] = useState(null);
|
const [data, setData] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.getBackups()
|
api
|
||||||
|
.getBackups()
|
||||||
.then(setData)
|
.then(setData)
|
||||||
.catch(err => setError(err));
|
.catch((err) => setError(err));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|
@ -151,8 +165,10 @@ function BackupSection() {
|
||||||
**Why it matters:** While `label` elements exist, they're not explicitly tied to inputs via `htmlFor`. Some components (e.g., theme cards) use buttons without labels, making screen reader navigation difficult.
|
**Why it matters:** While `label` elements exist, they're not explicitly tied to inputs via `htmlFor`. Some components (e.g., theme cards) use buttons without labels, making screen reader navigation difficult.
|
||||||
|
|
||||||
**Suggested fix:**
|
**Suggested fix:**
|
||||||
|
|
||||||
- Ensure all form inputs have explicit `id` and corresponding `label htmlFor`
|
- Ensure all form inputs have explicit `id` and corresponding `label htmlFor`
|
||||||
- Add `aria-label` or `aria-describedby` to interactive elements:
|
- Add `aria-label` or `aria-describedby` to interactive elements:
|
||||||
|
|
||||||
```jsx
|
```jsx
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -173,6 +189,7 @@ function BackupSection() {
|
||||||
**Why it matters:** The email input field accepts any string, including invalid formats like `test@localhost` or `not-an-email`. Validation only happens server-side, leading to delayed error feedback.
|
**Why it matters:** The email input field accepts any string, including invalid formats like `test@localhost` or `not-an-email`. Validation only happens server-side, leading to delayed error feedback.
|
||||||
|
|
||||||
**Suggested fix:**
|
**Suggested fix:**
|
||||||
|
|
||||||
- Add client-side email regex check before save
|
- Add client-side email regex check before save
|
||||||
- Show inline error if invalid: `^[\w.-]+@[\w.-]+\.\w+$`
|
- Show inline error if invalid: `^[\w.-]+@[\w.-]+\.\w+$`
|
||||||
- Debounce validation to avoid spamming errors during typing
|
- Debounce validation to avoid spamming errors during typing
|
||||||
|
|
@ -184,11 +201,13 @@ function BackupSection() {
|
||||||
**Where:** `client/components/BillModal.jsx`
|
**Where:** `client/components/BillModal.jsx`
|
||||||
|
|
||||||
**Why it matters:** The "Due day of month" input expects a number (1-31) instead of a standard date picker or calendar selection. This is confusing for:
|
**Why it matters:** The "Due day of month" input expects a number (1-31) instead of a standard date picker or calendar selection. This is confusing for:
|
||||||
|
|
||||||
- Users expecting a full date picker
|
- Users expecting a full date picker
|
||||||
- International users (some countries use DD/MM vs MM/DD)
|
- International users (some countries use DD/MM vs MM/DD)
|
||||||
- Edge cases like February 30th (which doesn't exist)
|
- Edge cases like February 30th (which doesn't exist)
|
||||||
|
|
||||||
**Suggested improvement:**
|
**Suggested improvement:**
|
||||||
|
|
||||||
- Consider using `react-datepicker` or similar for full date selection
|
- Consider using `react-datepicker` or similar for full date selection
|
||||||
- Alternatively, add a helper tooltip: "Enter day number only (e.g., 15 for the 15th)"
|
- Alternatively, add a helper tooltip: "Enter day number only (e.g., 15 for the 15th)"
|
||||||
- Add a validation example: "Due on the 15th → enter 15"
|
- Add a validation example: "Due on the 15th → enter 15"
|
||||||
|
|
@ -204,7 +223,8 @@ function BackupSection() {
|
||||||
**Why it matters:** The `backdrop-blur-xl` class relies on CSS `backdrop-filter`, which is unsupported in older browsers (e.g., Safari <14, some Android WebView versions). This results in a solid background instead of glassmorphism.
|
**Why it matters:** The `backdrop-blur-xl` class relies on CSS `backdrop-filter`, which is unsupported in older browsers (e.g., Safari <14, some Android WebView versions). This results in a solid background instead of glassmorphism.
|
||||||
|
|
||||||
**Suggested fix:**
|
**Suggested fix:**
|
||||||
- Add a CSS fallback: `bg-background/85 supports-[backdrop-filter]:bg-background/70`
|
|
||||||
|
- Add a CSS fallback: `bg-background/85 supports-backdrop-filter:bg-background/70`
|
||||||
- Already implemented ✅ — no changes needed
|
- Already implemented ✅ — no changes needed
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -216,7 +236,9 @@ function BackupSection() {
|
||||||
**Why it matters:** Modern apps often include a "remember me" option to reduce login friction on trusted devices. Without it, users must re-authenticate on every session.
|
**Why it matters:** Modern apps often include a "remember me" option to reduce login friction on trusted devices. Without it, users must re-authenticate on every session.
|
||||||
|
|
||||||
**Suggested fix:**
|
**Suggested fix:**
|
||||||
|
|
||||||
- Add a checkbox below the password field:
|
- Add a checkbox below the password field:
|
||||||
|
|
||||||
```jsx
|
```jsx
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input type="checkbox" id="remember" />
|
<input type="checkbox" id="remember" />
|
||||||
|
|
@ -225,6 +247,7 @@ function BackupSection() {
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
```
|
```
|
||||||
|
|
||||||
- Set `rememberMe` flag in localStorage or via cookie (if server supports it)
|
- Set `rememberMe` flag in localStorage or via cookie (if server supports it)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -236,6 +259,7 @@ function BackupSection() {
|
||||||
**Why it matters:** The theme toggle button doesn't indicate which theme is currently active. Users must click to discover the current state or remember manually.
|
**Why it matters:** The theme toggle button doesn't indicate which theme is currently active. Users must click to discover the current state or remember manually.
|
||||||
|
|
||||||
**Suggested fix:**
|
**Suggested fix:**
|
||||||
|
|
||||||
- Add subtle text label: "Light" / "Dark" next to the icon
|
- Add subtle text label: "Light" / "Dark" next to the icon
|
||||||
- Or use a tooltip: `aria-label="Current theme: Dark"`
|
- Or use a tooltip: `aria-label="Current theme: Dark"`
|
||||||
- Or change icon (sun/moon) based on theme (already done ✅)
|
- Or change icon (sun/moon) based on theme (already done ✅)
|
||||||
|
|
@ -249,6 +273,7 @@ function BackupSection() {
|
||||||
**Why it matters:** When there are no bills, the calendar shows a generic "No bills found" message. This doesn't guide users toward creating their first bill.
|
**Why it matters:** When there are no bills, the calendar shows a generic "No bills found" message. This doesn't guide users toward creating their first bill.
|
||||||
|
|
||||||
**Suggested fix:**
|
**Suggested fix:**
|
||||||
|
|
||||||
- Add a CTA button: "Create your first bill"
|
- Add a CTA button: "Create your first bill"
|
||||||
- Link directly to the modal with a pre-filled category or default values
|
- Link directly to the modal with a pre-filled category or default values
|
||||||
- Include a placeholder image or illustration
|
- Include a placeholder image or illustration
|
||||||
|
|
@ -264,6 +289,7 @@ function BackupSection() {
|
||||||
**Why it matters:** The `table-surface` utility (used in Settings and Profile pages) applies `mb-4` and internal padding, but the spacing isn't uniform across all pages. Some sections have excessive vertical space, others feel cramped.
|
**Why it matters:** The `table-surface` utility (used in Settings and Profile pages) applies `mb-4` and internal padding, but the spacing isn't uniform across all pages. Some sections have excessive vertical space, others feel cramped.
|
||||||
|
|
||||||
**Suggested fix:**
|
**Suggested fix:**
|
||||||
|
|
||||||
- Audit and standardize spacing tokens:
|
- Audit and standardize spacing tokens:
|
||||||
- `section-spacing` = `mb-6` (1.5rem)
|
- `section-spacing` = `mb-6` (1.5rem)
|
||||||
- `card-spacing` = `mb-4` (1rem)
|
- `card-spacing` = `mb-4` (1rem)
|
||||||
|
|
@ -277,11 +303,13 @@ function BackupSection() {
|
||||||
**Where:** Across all components
|
**Where:** Across all components
|
||||||
|
|
||||||
**Why it matters:** Different icon sets are used inconsistently:
|
**Why it matters:** Different icon sets are used inconsistently:
|
||||||
|
|
||||||
- `lucide-react` (primary)
|
- `lucide-react` (primary)
|
||||||
- Custom SVGs (logo)
|
- Custom SVGs (logo)
|
||||||
- Some components import icons but don't use them
|
- Some components import icons but don't use them
|
||||||
|
|
||||||
**Suggested fix:**
|
**Suggested fix:**
|
||||||
|
|
||||||
- Standardize on `lucide-react` for all icons
|
- Standardize on `lucide-react` for all icons
|
||||||
- Create a shared `icons/` directory with named exports if custom icons are needed
|
- Create a shared `icons/` directory with named exports if custom icons are needed
|
||||||
- Document icon usage in `CONTRIBUTING.md`
|
- Document icon usage in `CONTRIBUTING.md`
|
||||||
|
|
@ -290,13 +318,13 @@ function BackupSection() {
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
| Priority | Count | Key Impact |
|
| Priority | Count | Key Impact |
|
||||||
|----------|-------|------------|
|
| -------- | ----- | ---------------------------------------------------- |
|
||||||
| CRITICAL | 0 | — |
|
| CRITICAL | 0 | — |
|
||||||
| HIGH | 3 | Mobile accessibility, perceived performance, form UX |
|
| HIGH | 3 | Mobile accessibility, perceived performance, form UX |
|
||||||
| MEDIUM | 5 | Navigation clarity, error resilience, keyboard nav |
|
| MEDIUM | 5 | Navigation clarity, error resilience, keyboard nav |
|
||||||
| LOW | 4 | Convenience, consistency, discoverability |
|
| LOW | 4 | Convenience, consistency, discoverability |
|
||||||
| MEH | 2 | Minor polish, standardization |
|
| MEH | 2 | Minor polish, standardization |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -309,4 +337,4 @@ function BackupSection() {
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Generated by Scarlett (Frontend/UX Authority) on 2026-05-08*
|
_Generated by Scarlett (Frontend/UX Authority) on 2026-05-08_
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 260 KiB After Width: | Height: | Size: 261 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 90 KiB |
File diff suppressed because it is too large
Load Diff
|
|
@ -68,7 +68,7 @@
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
"react-router-dom": "^7.18.1",
|
"react-router-dom": "^7.18.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^2.5.4",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
|
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
|
||||||
},
|
},
|
||||||
|
|
@ -76,6 +76,7 @@
|
||||||
"@axe-core/playwright": "^4.10.1",
|
"@axe-core/playwright": "^4.10.1",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@playwright/test": "^1.50.1",
|
"@playwright/test": "^1.50.1",
|
||||||
|
"@tailwindcss/vite": "^4.3.2",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/node": "^26.1.0",
|
"@types/node": "^26.1.0",
|
||||||
|
|
@ -95,7 +96,7 @@
|
||||||
"lefthook": "^2.1.9",
|
"lefthook": "^2.1.9",
|
||||||
"postcss": "^8.4.47",
|
"postcss": "^8.4.47",
|
||||||
"prettier": "3.9.4",
|
"prettier": "3.9.4",
|
||||||
"tailwindcss": "^3.4.14",
|
"tailwindcss": "^4.3.2",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.62.1",
|
"typescript-eslint": "^8.62.1",
|
||||||
"vite": "^8.1.4",
|
"vite": "^8.1.4",
|
||||||
|
|
|
||||||
|
|
@ -44,13 +44,27 @@ module.exports = defineConfig({
|
||||||
name: 'chromium-desktop',
|
name: 'chromium-desktop',
|
||||||
use: { ...devices['Desktop Chrome'] },
|
use: { ...devices['Desktop Chrome'] },
|
||||||
dependencies: ['setup'],
|
dependencies: ['setup'],
|
||||||
testIgnore: [/auth\.setup\.js/, /api\.probe\.spec\.js/, /a11y\.authed\.spec\.js/],
|
testIgnore: [
|
||||||
|
/auth\.setup\.js/,
|
||||||
|
/api\.probe\.spec\.js/,
|
||||||
|
/a11y\.authed\.spec\.js/,
|
||||||
|
// probe-only: enables WebAuthn for the seeded user mid-test, which would
|
||||||
|
// race the password logins in these parallel UI projects
|
||||||
|
/webauthn\.probe\.spec\.js/,
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'chromium-mobile',
|
name: 'chromium-mobile',
|
||||||
use: { ...devices['Pixel 5'] },
|
use: { ...devices['Pixel 5'] },
|
||||||
dependencies: ['setup'],
|
dependencies: ['setup'],
|
||||||
testIgnore: [/auth\.setup\.js/, /api\.probe\.spec\.js/, /a11y\.authed\.spec\.js/],
|
testIgnore: [
|
||||||
|
/auth\.setup\.js/,
|
||||||
|
/api\.probe\.spec\.js/,
|
||||||
|
/a11y\.authed\.spec\.js/,
|
||||||
|
// probe-only: enables WebAuthn for the seeded user mid-test, which would
|
||||||
|
// race the password logins in these parallel UI projects
|
||||||
|
/webauthn\.probe\.spec\.js/,
|
||||||
|
],
|
||||||
},
|
},
|
||||||
// Find-mode diagnostics (adversarial API probe + authenticated a11y scan).
|
// Find-mode diagnostics (adversarial API probe + authenticated a11y scan).
|
||||||
// Own project so they don't run concurrently with / pollute the UI specs, and
|
// Own project so they don't run concurrently with / pollute the UI specs, and
|
||||||
|
|
|
||||||
|
|
@ -1 +1,3 @@
|
||||||
module.exports = { plugins: { tailwindcss: {}, autoprefixer: {} } };
|
// Tailwind v4 runs via @tailwindcss/vite (see vite.config.mjs); autoprefixing
|
||||||
|
// is built into v4, so PostCSS needs no plugins here anymore.
|
||||||
|
module.exports = { plugins: {} };
|
||||||
|
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
module.exports = {
|
|
||||||
darkMode: ['class'],
|
|
||||||
content: ['./index.html', './client/**/*.{js,jsx,ts,tsx}'],
|
|
||||||
theme: {
|
|
||||||
container: { center: true, padding: '2rem', screens: { '2xl': '1400px' } },
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
border: 'oklch(var(--border) / <alpha-value>)',
|
|
||||||
input: 'oklch(var(--input) / <alpha-value>)',
|
|
||||||
ring: 'oklch(var(--ring) / <alpha-value>)',
|
|
||||||
background: 'oklch(var(--background) / <alpha-value>)',
|
|
||||||
foreground: 'oklch(var(--foreground) / <alpha-value>)',
|
|
||||||
primary: {
|
|
||||||
DEFAULT: 'oklch(var(--primary) / <alpha-value>)',
|
|
||||||
foreground: 'oklch(var(--primary-foreground) / <alpha-value>)',
|
|
||||||
},
|
|
||||||
secondary: {
|
|
||||||
DEFAULT: 'oklch(var(--secondary) / <alpha-value>)',
|
|
||||||
foreground: 'oklch(var(--secondary-foreground) / <alpha-value>)',
|
|
||||||
},
|
|
||||||
destructive: {
|
|
||||||
DEFAULT: 'oklch(var(--destructive) / <alpha-value>)',
|
|
||||||
foreground: 'oklch(var(--destructive-foreground) / <alpha-value>)',
|
|
||||||
},
|
|
||||||
muted: {
|
|
||||||
DEFAULT: 'oklch(var(--muted) / <alpha-value>)',
|
|
||||||
foreground: 'oklch(var(--muted-foreground) / <alpha-value>)',
|
|
||||||
},
|
|
||||||
accent: {
|
|
||||||
DEFAULT: 'oklch(var(--accent) / <alpha-value>)',
|
|
||||||
foreground: 'oklch(var(--accent-foreground) / <alpha-value>)',
|
|
||||||
},
|
|
||||||
popover: {
|
|
||||||
DEFAULT: 'oklch(var(--popover) / <alpha-value>)',
|
|
||||||
foreground: 'oklch(var(--popover-foreground) / <alpha-value>)',
|
|
||||||
},
|
|
||||||
card: {
|
|
||||||
DEFAULT: 'oklch(var(--card) / <alpha-value>)',
|
|
||||||
foreground: 'oklch(var(--card-foreground) / <alpha-value>)',
|
|
||||||
},
|
|
||||||
sidebar: {
|
|
||||||
DEFAULT: 'oklch(var(--sidebar) / <alpha-value>)',
|
|
||||||
foreground: 'oklch(var(--sidebar-foreground) / <alpha-value>)',
|
|
||||||
primary: 'oklch(var(--sidebar-primary) / <alpha-value>)',
|
|
||||||
accent: 'oklch(var(--sidebar-accent) / <alpha-value>)',
|
|
||||||
border: 'oklch(var(--sidebar-border) / <alpha-value>)',
|
|
||||||
ring: 'oklch(var(--sidebar-ring) / <alpha-value>)',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
fontFamily: {
|
|
||||||
sans: ['var(--font-sans)', 'ui-sans-serif', 'system-ui', 'sans-serif'],
|
|
||||||
serif: ['var(--font-serif)', 'ui-serif', 'serif'],
|
|
||||||
mono: ['var(--font-mono)', 'ui-monospace', 'SFMono-Regular', 'monospace'],
|
|
||||||
},
|
|
||||||
borderRadius: {
|
|
||||||
lg: 'var(--radius)',
|
|
||||||
md: 'calc(var(--radius) - 2px)',
|
|
||||||
sm: 'calc(var(--radius) - 4px)',
|
|
||||||
},
|
|
||||||
keyframes: {
|
|
||||||
'accordion-down': { from: { height: '0' }, to: { height: 'var(--radix-accordion-content-height)' } },
|
|
||||||
'accordion-up': { from: { height: 'var(--radix-accordion-content-height)' }, to: { height: '0' } },
|
|
||||||
'collapsible-down': { from: { height: '0' }, to: { height: 'var(--radix-collapsible-content-height)' } },
|
|
||||||
'collapsible-up': { from: { height: 'var(--radix-collapsible-content-height)' }, to: { height: '0' } },
|
|
||||||
},
|
|
||||||
animation: {
|
|
||||||
'accordion-down': 'accordion-down 0.2s ease-out',
|
|
||||||
'accordion-up': 'accordion-up 0.2s ease-out',
|
|
||||||
'collapsible-down': 'collapsible-down 0.2s ease-out',
|
|
||||||
'collapsible-up': 'collapsible-up 0.2s ease-out',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: [require('tailwindcss-animate')],
|
|
||||||
};
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
import { VitePWA } from 'vite-plugin-pwa';
|
import { VitePWA } from 'vite-plugin-pwa';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
|
|
@ -12,6 +13,7 @@ const apiPort = process.env.API_PORT || process.env.PORT || 3000;
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
|
tailwindcss(),
|
||||||
// React Compiler (React 19): auto-memoizes components/hooks at build time, so
|
// React Compiler (React 19): auto-memoizes components/hooks at build time, so
|
||||||
// manual useMemo/useCallback become largely unnecessary. Safe here because the
|
// manual useMemo/useCallback become largely unnecessary. Safe here because the
|
||||||
// codebase is rules-of-hooks clean (enforced by eslint-plugin-react-hooks).
|
// codebase is rules-of-hooks clean (enforced by eslint-plugin-react-hooks).
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue