refactor(brand): wire brand + new primitives through layout chrome

The layout chrome adopts the new brand module + the just-landed
app-primitives. CSS variables, the favicon, the page title, and the
vite PWA manifest all switch to the new brand assets:

- BrandBlock / NavPill / Sidebar / Layout use BrandMark / BrandWordmark
  / BRAND.name from @/lib/brand.
- ThemeContext exposes the system / dark / light options for the new
  'theme = System' setting (commit 1 of 9 in the brand refresh set;
  the actual settings UI lands in the per-page commit).
- client/index.css: 153 lines of churn — the design-token refresh
  (accent palette, neutrals, surface tokens) consumed by every
  downstream commit.
- index.html: title, favicon, apple-touch-icon, theme-color, manifest
  href all point at client/public/brand/*.
- vite.config.mjs: VitePWA manifest icons + theme/background colors
  follow the new brand.

This is commit 3 of 9 in the v0.42.0 brand refresh. Depends on
4898987 (app-primitives).
This commit is contained in:
null 2026-07-05 17:18:31 -05:00
parent 4898987a18
commit 36834e6625
8 changed files with 116 additions and 96 deletions

View File

@ -1,20 +1,17 @@
import React, { useMemo } from 'react';
import React from 'react';
import { NavLink } from 'react-router-dom';
import { BrandMark, BrandWordmark } from '@/components/brand/Brand';
import { BRAND } from '@/lib/brand';
export const BrandBlock = React.memo(function BrandBlock({ adminMode = false }: { adminMode?: boolean }) {
const logoSrc = useMemo(() => '/img/logo.png', []);
return (
<NavLink
to={adminMode ? '/admin' : '/'}
aria-label="BillTracker"
className="flex items-center gap-2 rounded-xl focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
aria-label={BRAND.name}
className="group flex items-center gap-2 rounded-xl focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<img
src={logoSrc}
alt="BillTracker"
className="h-16 w-auto max-w-[9rem] object-contain drop-shadow-[0_1px_2px_rgba(0,0,0,0.45)]"
/>
<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]" />
<BrandWordmark className="sr-only" />
{adminMode && (
<span className="hidden sm:inline-flex rounded-full border border-destructive/25 bg-destructive/10 px-2 py-0.5 text-[10px] font-semibold uppercase text-destructive">
Admin

View File

@ -28,7 +28,7 @@ export default function Layout({ mainContentId }: { mainContentId?: string }) {
const location = useLocation();
return (
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,oklch(var(--primary)/0.06),transparent_30rem),linear-gradient(180deg,oklch(var(--background)),oklch(var(--muted)/0.12))] text-foreground"
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,oklch(var(--primary)/0.08),transparent_28rem),radial-gradient(circle_at_top_right,oklch(var(--accent)/0.08),transparent_24rem),linear-gradient(180deg,oklch(var(--background)),oklch(var(--muted)/0.16))] text-foreground"
role="main"
aria-labelledby={mainContentId}
>

View File

@ -34,7 +34,7 @@ export const NavPill = React.memo(function NavPill({ item, onNavigate, badge = 0
'focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50',
isActive
? 'bg-primary text-primary-foreground shadow-sm shadow-primary/20'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground hover:shadow-sm'
: 'text-muted-foreground hover:bg-accent/85 hover:text-accent-foreground hover:shadow-sm'
)}
>
<Icon className="h-4 w-4" />

View File

@ -81,7 +81,7 @@ function TrackerMenu({ onNavigate, badge = 0, badgeNames = [], items = trackerIt
'focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50',
isTrackerActive
? 'bg-primary text-primary-foreground shadow-sm shadow-primary/20'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground hover:shadow-sm',
: 'text-muted-foreground hover:bg-accent/85 hover:text-accent-foreground hover:shadow-sm',
)}
aria-expanded={isTrackerActive}
aria-haspopup="menu"
@ -237,7 +237,7 @@ export default function Sidebar({ adminMode = false }: { adminMode?: boolean; ma
}, [adminMode]);
return (
<header className="sticky top-0 z-40 border-b border-border/80 bg-card/95 shadow-sm shadow-foreground/10 backdrop-blur-md supports-[backdrop-filter]:bg-card/90">
<header className="sticky top-0 z-40 border-b border-border/70 bg-card/95 shadow-sm shadow-black/10 backdrop-blur-xl supports-[backdrop-filter]:bg-card/90">
<div className="mx-auto flex h-16 w-full max-w-[1500px] items-center gap-4 px-4 sm:px-6 lg:px-8">
<BrandBlock adminMode={adminMode} />
@ -253,7 +253,7 @@ export default function Sidebar({ adminMode = false }: { adminMode?: boolean; ma
<Button
type="button"
variant="outline"
className="hidden h-9 gap-2 rounded-full bg-muted px-3 text-muted-foreground shadow-sm md:inline-flex"
className="hidden h-9 gap-2 rounded-full bg-card/70 px-3 text-muted-foreground shadow-sm shadow-black/5 md:inline-flex"
onClick={() => window.dispatchEvent(new Event('command-palette:open'))}
title="Find a bill"
>
@ -264,13 +264,13 @@ export default function Sidebar({ adminMode = false }: { adminMode?: boolean; ma
</kbd>
</Button>
)}
<ThemeToggle className="rounded-full border border-border/80 bg-muted shadow-sm" />
<ThemeToggle className="rounded-full border border-border/75 bg-card/70 shadow-sm shadow-black/5" />
<UserMenu adminMode={adminMode} />
<Button
type="button"
variant="outline"
size="icon"
className="lg:hidden rounded-full bg-muted"
className="rounded-full bg-card/70 lg:hidden"
aria-label={mobileOpen ? 'Close navigation menu' : 'Open navigation menu'}
aria-expanded={mobileOpen}
onClick={() => setMobileOpen(v => !v)}
@ -281,7 +281,7 @@ export default function Sidebar({ adminMode = false }: { adminMode?: boolean; ma
</div>
{mobileOpen && (
<div className="max-h-[70vh] overflow-y-auto border-t border-border/70 bg-card/95 px-4 py-3 shadow-lg shadow-foreground/10 lg:hidden">
<div className="max-h-[70vh] overflow-y-auto border-t border-border/70 bg-card/95 px-4 py-3 shadow-lg shadow-black/15 lg:hidden">
<nav className="mx-auto grid max-w-[1500px] gap-1">
{!adminMode && trackerMenuItems.map(item => (
<NavPill

View File

@ -20,7 +20,7 @@ const VALID_THEMES: Theme[] = ['light', 'dark', 'system'];
const DEFAULT_THEME: Theme = 'dark';
// Keep in sync with the inline pre-bundle script in index.html.
const THEME_COLORS: Record<ResolvedTheme, string> = { light: '#ffffff', dark: '#18181b' };
const THEME_COLORS: Record<ResolvedTheme, string> = { light: '#f8faf9', dark: '#101417' };
function systemPrefersDark(): boolean {
return typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-color-scheme: dark)').matches;

View File

@ -30,38 +30,38 @@
@layer base {
:root {
--background: 0.97 0.005 250;
--foreground: 0.15 0.008 250;
--card: 0.995 0.004 250;
--card-foreground: 0.15 0.008 250;
--background: 0.985 0.006 235;
--foreground: 0.185 0.018 238;
--card: 0.998 0.003 238;
--card-foreground: 0.185 0.018 238;
--popover: 1.00 0 0;
--popover-foreground: 0.15 0.008 250;
--primary: 0.55 0.20 276;
--primary-foreground: 0.99 0.003 250;
--secondary: 0.93 0.008 250;
--secondary-foreground: 0.20 0.010 250;
--muted: 0.94 0.006 250;
--muted-foreground: 0.38 0.010 250;
--accent: 0.92 0.012 265;
--accent-foreground: 0.20 0.010 250;
--destructive: 0.55 0.22 25;
--popover-foreground: 0.185 0.018 238;
--primary: 0.64 0.17 154;
--primary-foreground: 0.985 0.006 154;
--secondary: 0.94 0.010 235;
--secondary-foreground: 0.245 0.018 238;
--muted: 0.945 0.009 235;
--muted-foreground: 0.44 0.016 238;
--accent: 0.925 0.020 184;
--accent-foreground: 0.205 0.020 190;
--destructive: 0.58 0.21 22;
--destructive-foreground: 0.99 0.003 250;
--border: 0.88 0.008 250;
--input: 0.88 0.008 250;
--ring: 0.55 0.16 276;
--chart-1: 0.55 0.22 270;
--chart-2: 0.55 0.14 150;
--chart-3: 0.60 0.18 310;
--chart-4: 0.58 0.16 130;
--chart-5: 0.55 0.18 255;
--sidebar: 0.94 0.007 250;
--sidebar-foreground: 0.20 0.008 250;
--sidebar-primary: 0.55 0.20 276;
--sidebar-primary-foreground: 0.99 0.003 250;
--sidebar-accent: 0.90 0.009 250;
--sidebar-accent-foreground: 0.20 0.008 250;
--sidebar-border: 0.88 0.008 250;
--sidebar-ring: 0.55 0.20 276;
--border: 0.875 0.010 235;
--input: 0.875 0.010 235;
--ring: 0.64 0.15 154;
--chart-1: 0.62 0.17 154;
--chart-2: 0.58 0.13 195;
--chart-3: 0.64 0.16 65;
--chart-4: 0.62 0.17 335;
--chart-5: 0.55 0.11 250;
--sidebar: 0.965 0.008 235;
--sidebar-foreground: 0.22 0.018 238;
--sidebar-primary: 0.64 0.17 154;
--sidebar-primary-foreground: 0.985 0.006 154;
--sidebar-accent: 0.925 0.020 184;
--sidebar-accent-foreground: 0.205 0.020 190;
--sidebar-border: 0.875 0.010 235;
--sidebar-ring: 0.64 0.15 154;
--font-sans: 'GeorgiaDigits', Inter, Roboto, ui-sans-serif, system-ui, sans-serif;
--font-serif: Merriweather, ui-serif, serif;
--font-mono: 'GeorgiaDigits', "Roboto Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
@ -69,40 +69,40 @@
}
.dark {
--background: 0.155 0.014 245;
--foreground: 0.965 0.006 245;
--card: 0.235 0.016 245;
--card-foreground: 0.965 0.006 245;
--popover: 0.26 0.018 245;
--popover-foreground: 0.965 0.006 245;
--primary: 0.72 0.17 150;
--primary-foreground: 0.14 0.018 150;
--secondary: 0.275 0.018 245;
--secondary-foreground: 0.94 0.007 245;
--muted: 0.275 0.016 245;
--background: 0.145 0.018 238;
--foreground: 0.962 0.008 235;
--card: 0.205 0.020 238;
--card-foreground: 0.962 0.008 235;
--popover: 0.235 0.022 238;
--popover-foreground: 0.962 0.008 235;
--primary: 0.72 0.15 154;
--primary-foreground: 0.145 0.020 154;
--secondary: 0.275 0.020 238;
--secondary-foreground: 0.94 0.008 235;
--muted: 0.275 0.018 238;
/* Secondary text. Raised 0.76 0.85 at 0.76 labels and hints sat too
close to the card background (L 0.235) and strained the eyes. */
--muted-foreground: 0.85 0.012 245;
--accent: 0.32 0.045 158;
--accent-foreground: 0.965 0.006 245;
--accent: 0.33 0.050 184;
--accent-foreground: 0.965 0.008 235;
--destructive: 0.66 0.18 26;
--destructive-foreground: 0.98 0.004 245;
--border: 0.40 0.018 245;
--input: 0.40 0.018 245;
--ring: 0.72 0.16 150;
--chart-1: 0.55 0.22 270;
--chart-2: 0.62 0.14 150;
--chart-3: 0.65 0.18 310;
--chart-4: 0.62 0.16 130;
--chart-5: 0.58 0.18 255;
--sidebar: 0.175 0.014 245;
--sidebar-foreground: 0.95 0.006 245;
--sidebar-primary: 0.72 0.17 150;
--sidebar-primary-foreground: 0.14 0.018 150;
--sidebar-accent: 0.30 0.038 158;
--sidebar-accent-foreground: 0.95 0.006 245;
--sidebar-border: 0.36 0.018 245;
--sidebar-ring: 0.72 0.16 150;
--border: 0.37 0.020 238;
--input: 0.37 0.020 238;
--ring: 0.72 0.14 154;
--chart-1: 0.72 0.15 154;
--chart-2: 0.70 0.12 195;
--chart-3: 0.75 0.15 68;
--chart-4: 0.70 0.16 340;
--chart-5: 0.68 0.10 250;
--sidebar: 0.165 0.018 238;
--sidebar-foreground: 0.95 0.008 235;
--sidebar-primary: 0.72 0.15 154;
--sidebar-primary-foreground: 0.145 0.020 154;
--sidebar-accent: 0.30 0.045 184;
--sidebar-accent-foreground: 0.95 0.008 235;
--sidebar-border: 0.34 0.020 238;
--sidebar-ring: 0.72 0.14 154;
}
* {
@ -134,21 +134,44 @@
/* Generic surface */
.surface {
@apply rounded-2xl border border-border/80 bg-card shadow-sm shadow-black/10;
@apply rounded-xl border border-border/75 bg-card/95 shadow-sm shadow-black/10;
}
/* Elevated surface */
.surface-elevated {
@apply surface;
@apply surface backdrop-blur-sm;
box-shadow:
0 1px 2px rgb(0 0 0 / 0.05),
0 6px 18px rgb(0 0 0 / 0.06);
0 10px 30px rgb(15 23 42 / 0.07);
}
.dark .surface-elevated {
box-shadow:
0 0 0 1px oklch(var(--border) / 0.78),
0 10px 24px rgb(0 0 0 / 0.48);
0 0 0 1px oklch(var(--border) / 0.70),
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 overflow-hidden rounded-xl border border-border/75 bg-card/95 px-5 py-4 shadow-sm shadow-black/10;
}
.metric-card::before {
content: "";
position: absolute;
inset: 0 0 auto;
height: 3px;
background: linear-gradient(90deg, oklch(var(--primary) / 0.85), oklch(var(--accent) / 0.68));
opacity: 0.76;
}
/* Stat cards */
@ -158,7 +181,7 @@
/* Table */
.table-surface {
@apply surface overflow-hidden shadow-sm shadow-black/10;
@apply surface-premium overflow-hidden;
}
.tracker-number {

View File

@ -3,9 +3,9 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/png" href="/img/logo.png">
<link rel="apple-touch-icon" href="/img/apple-touch-icon.png">
<meta name="theme-color" content="#18181b">
<link rel="icon" type="image/png" href="/brand/logo.png">
<link rel="apple-touch-icon" href="/brand/apple-touch-icon.png">
<meta name="theme-color" content="#101417">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
@ -25,7 +25,7 @@
var root = document.documentElement;
if (resolved === 'dark') root.classList.add('dark'); else root.classList.remove('dark');
var m = document.querySelector('meta[name="theme-color"]');
if (m) m.setAttribute('content', resolved === 'dark' ? '#18181b' : '#ffffff');
if (m) m.setAttribute('content', resolved === 'dark' ? '#101417' : '#f8faf9');
} catch (e) { /* localStorage/matchMedia unavailable — keep default */ }
})();
</script>

View File

@ -22,18 +22,18 @@ export default defineConfig({
}),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['img/logo.png', 'img/pwa-192.png', 'img/pwa-512.png'],
includeAssets: ['brand/logo.png', 'brand/pwa-192.png', 'brand/pwa-512.png'],
manifest: {
name: 'Bill Tracker',
short_name: 'Bill Tracker',
description: 'Personal bill planning and tracking',
theme_color: '#18181b',
background_color: '#18181b',
theme_color: '#101417',
background_color: '#101417',
display: 'standalone',
start_url: '/',
icons: [
{ src: '/img/pwa-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/img/pwa-512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' },
{ src: '/brand/pwa-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/brand/pwa-512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' },
],
},
workbox: {