Two patterns the app had been rebuilding per call site. NativeSelect: eight `<select>` elements had each been hand-styled, arriving at four different answers — `h-8 rounded border px-1 text-xs`, `h-9 rounded border px-2 text-sm`, `h-9 flex-1 rounded ...`, and one correct `h-9 rounded-md border-input px-2`. Measured, that is two radii (4px against the system's 6px), two border colours (the row hairline against the 3:1 field border) and three text indents against Input's 12px. In the purchase dialog a select sat directly beside the Total input in a visibly different control language. It stays a native select on purpose: the platform picker on a phone and type-to-select on a keyboard are worth more here than a prettier menu, and entry speed is a stated product principle. The chevron is a background image from a new `--budget-select-chevron` token rather than a positioned icon, because that needs no wrapper element — so `className` lands on the select itself and every call site's existing `w-full`, `flex-1`, `h-8` and `w-40` keeps behaving as it did. The token is declared per theme since a background-image stroke cannot be `currentColor`. Segmented: four screens had built the same view switch from two loose buttons, the selected one filled and the other outlined, separated by a gap. That reads as one primary action beside one unrelated button rather than as two states of a single control. One track, no gap, `aria-pressed` per segment. Neither is wired up yet; the call sites follow. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
255 lines
10 KiB
CSS
Executable File
255 lines
10 KiB
CSS
Executable File
@import "../styles/globals.css";
|
|
|
|
/* The @font-face rules are deliberately NOT here. Bun's CSS bundler resolves
|
|
every url() from disk and inlines woff2 as base64 — relative OR absolute,
|
|
and `loader: { ".woff2": "file" }` does not change it. That made this file
|
|
303 KB of render-blocking bytes and defeated the unicode-range subsetting,
|
|
since a subset embedded in the stylesheet downloads whether or not one
|
|
character needs it. They live in src/fontCss.ts and are injected at boot by
|
|
src/frontend.tsx, which is the only arrangement that is identical in dev and
|
|
in a subpath production build without a placeholder file. The cost is that
|
|
the faces are requested after the JS parses rather than from the HTML;
|
|
`font-display: swap` keeps text readable throughout. */
|
|
|
|
/*
|
|
* ============================================================================
|
|
* Budget 2.0 design foundation
|
|
* ============================================================================
|
|
*
|
|
* Two layers, deliberately separate:
|
|
*
|
|
* 1. `--budget-*` semantic tokens, defined once per theme below. These are
|
|
* the app's own vocabulary — paper, ink, rule, surplus, overspend — and
|
|
* are the ONLY colours the app's own components should name.
|
|
* 2. shadcn's variable set, pointed at layer 1. Re-theming shadcn rather
|
|
* than fighting it means every primitive (dialog, select, badge, button)
|
|
* inherits both themes for free.
|
|
*
|
|
* WHY THE PAIRS MATTER. AGENTS.md's rule is "never define a bare `--accent`",
|
|
* and it was paid for with an app-wide contrast bug: shadcn uses `--accent` as
|
|
* a hover BACKGROUND and `--accent-foreground` as the text on it, so setting
|
|
* one without the other repaints the background and leaves the text behind.
|
|
* Every shadcn pair below is therefore redefined TOGETHER, in both themes.
|
|
*
|
|
* The brand teal is `--primary`, NOT `--accent`. That is the mapping the
|
|
* earlier bug got wrong: shadcn's `--accent` is a subtle hover tint, and a
|
|
* saturated teal there turns every hover into a coloured slab with dark text
|
|
* on it. `--accent` stays a tint of the surface.
|
|
*
|
|
* Every text pair here was measured, not eyeballed: the weakest is 5.50:1
|
|
* (muted on paper, light theme) against a 4.5:1 requirement, and field
|
|
* borders clear the 3:1 non-text requirement on both of their backgrounds.
|
|
*/
|
|
|
|
:root {
|
|
/* --- Layer 1: the app's own vocabulary (light) --- */
|
|
--budget-paper: #f3f5f7; /* page ground: cool statement paper, NOT cream */
|
|
--budget-raised: #ffffff; /* cards and rows sitting above the paper */
|
|
--budget-ink: #16212b; /* primary text: ink navy, not black */
|
|
--budget-ink-muted: #546575; /* labels, captions, secondary figures */
|
|
--budget-rule: #dce3e9; /* hairlines between rows */
|
|
--budget-rule-strong: #768899; /* field borders; needs 3:1, hairlines do not */
|
|
--budget-accent: #0d6b60; /* muted teal: links, primary actions, spend fill */
|
|
--budget-accent-ink: #ffffff; /* text ON the teal */
|
|
--budget-tint: #e8edf1; /* hover wash, table header fill */
|
|
--surplus: #1f6640; /* carried-forward surplus */
|
|
--overspend: #a5382a; /* negative balances only */
|
|
|
|
/* The chevron a native <select> draws for itself is the OS widget's, not
|
|
ours, so `appearance: none` removes it and this paints it back. It has to
|
|
be a data URI in a token rather than an inline SVG in the component: the
|
|
stroke colour cannot be `currentColor` inside a background-image, so the
|
|
glyph is re-declared per theme instead. Same path and stroke weight as
|
|
lucide's chevron-down, which is the icon family the rest of the app uses. */
|
|
--budget-select-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23546575' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
|
|
|
|
/* Elevation is light and cool, never a grey drop shadow. */
|
|
--budget-shadow-card: 0 1px 2px rgb(22 33 43 / 0.04), 0 8px 24px -12px rgb(22 33 43 / 0.12);
|
|
--budget-shadow-raised: 0 1px 1px rgb(22 33 43 / 0.05), 0 2px 6px -2px rgb(22 33 43 / 0.08);
|
|
|
|
--font-display: "Inter", ui-sans-serif, system-ui, sans-serif;
|
|
--font-body: "Inter", ui-sans-serif, system-ui, sans-serif;
|
|
--font-money: "JetBrains Mono", ui-monospace, "SF Mono", monospace;
|
|
|
|
/* --- Layer 2: shadcn, pointed at layer 1. Pairs, always. --- */
|
|
--radius: 0.5rem; /* crisper than shadcn's 0.625rem default */
|
|
|
|
--background: var(--budget-paper);
|
|
--foreground: var(--budget-ink);
|
|
--card: var(--budget-raised);
|
|
--card-foreground: var(--budget-ink);
|
|
--popover: var(--budget-raised);
|
|
--popover-foreground: var(--budget-ink);
|
|
--primary: var(--budget-accent);
|
|
--primary-foreground: var(--budget-accent-ink);
|
|
--secondary: var(--budget-tint);
|
|
--secondary-foreground: var(--budget-ink);
|
|
--muted: var(--budget-tint);
|
|
--muted-foreground: var(--budget-ink-muted);
|
|
--accent: var(--budget-tint); /* a TINT. see the note above. */
|
|
--accent-foreground: var(--budget-ink); /* its pair, never omitted. */
|
|
--destructive: var(--overspend);
|
|
--border: var(--budget-rule);
|
|
--input: var(--budget-rule-strong);
|
|
--ring: var(--budget-accent);
|
|
}
|
|
|
|
.dark {
|
|
/* --- Layer 1 (dark). Deep slate, never pure black: a pure-black ground
|
|
makes the hairlines and the carry meter float. --- */
|
|
--budget-paper: #0c1319;
|
|
--budget-raised: #141e28;
|
|
--budget-ink: #e8eef4;
|
|
--budget-ink-muted: #94a7b8;
|
|
--budget-rule: #22303e;
|
|
--budget-rule-strong: #647c92;
|
|
--budget-accent: #3ab5a4; /* lifted: the light teal is unreadable on slate */
|
|
--budget-accent-ink: #08110f; /* dark text on the lifted teal, not white */
|
|
--budget-tint: #1c2836;
|
|
--surplus: #57bb83;
|
|
--overspend: #f0866a;
|
|
|
|
/* Same glyph, lifted to the dark theme's muted ink. */
|
|
--budget-select-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2394a7b8' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
|
|
|
|
/* Dark elevation reads as a lighter surface plus a hairline, not a shadow:
|
|
shadows are invisible on a dark ground. */
|
|
--budget-shadow-card: 0 1px 0 rgb(255 255 255 / 0.04) inset, 0 12px 32px -16px rgb(0 0 0 / 0.7);
|
|
--budget-shadow-raised: 0 1px 0 rgb(255 255 255 / 0.03) inset;
|
|
|
|
/* --- Layer 2 (dark). Same pairs, same rule. --- */
|
|
--background: var(--budget-paper);
|
|
--foreground: var(--budget-ink);
|
|
--card: var(--budget-raised);
|
|
--card-foreground: var(--budget-ink);
|
|
--popover: var(--budget-raised);
|
|
--popover-foreground: var(--budget-ink);
|
|
--primary: var(--budget-accent);
|
|
--primary-foreground: var(--budget-accent-ink);
|
|
--secondary: var(--budget-tint);
|
|
--secondary-foreground: var(--budget-ink);
|
|
--muted: var(--budget-tint);
|
|
--muted-foreground: var(--budget-ink-muted);
|
|
--accent: var(--budget-tint);
|
|
--accent-foreground: var(--budget-ink);
|
|
--destructive: var(--overspend);
|
|
--border: var(--budget-rule);
|
|
--input: var(--budget-rule-strong);
|
|
--ring: var(--budget-accent);
|
|
}
|
|
|
|
/* Makes `font-sans` / `font-mono` utilities and shadcn primitives inherit the
|
|
shipped faces instead of the OS stack. */
|
|
@theme inline {
|
|
--font-sans: var(--font-body);
|
|
--font-mono: var(--font-money);
|
|
--color-budget-accent: var(--budget-accent);
|
|
--color-budget-paper: var(--budget-paper);
|
|
--color-budget-raised: var(--budget-raised);
|
|
--color-budget-rule: var(--budget-rule);
|
|
--color-budget-ink: var(--budget-ink);
|
|
--color-budget-ink-muted: var(--budget-ink-muted);
|
|
--color-surplus: var(--surplus);
|
|
--color-overspend: var(--overspend);
|
|
}
|
|
|
|
@layer base {
|
|
:root {
|
|
color-scheme: light;
|
|
/* The parts the browser draws for itself. `accent-color` is what tints a
|
|
native radio or checkbox — left unset it is the platform blue, which is
|
|
the one colour on screen belonging to no theme here. */
|
|
accent-color: var(--budget-accent);
|
|
}
|
|
|
|
.dark {
|
|
color-scheme: dark;
|
|
}
|
|
|
|
::selection {
|
|
background: var(--budget-accent);
|
|
color: var(--budget-accent-ink);
|
|
}
|
|
|
|
body {
|
|
/* Was `grid place-items-center`, which centred short pages VERTICALLY —
|
|
the month view floated in the middle of the viewport on an empty month.
|
|
The app shell owns horizontal centring now, with a max width. */
|
|
@apply m-0 min-w-[320px] min-h-screen bg-background text-foreground antialiased;
|
|
font-family: var(--font-body);
|
|
font-feature-settings: "cv11"; /* Inter's single-storey a-alternates off */
|
|
}
|
|
|
|
h1,
|
|
h2,
|
|
h3,
|
|
h4,
|
|
h5,
|
|
h6 {
|
|
font-family: var(--font-display);
|
|
font-weight: 640;
|
|
letter-spacing: -0.019em;
|
|
text-wrap: balance;
|
|
}
|
|
|
|
/* One visible, consistent focus treatment. Keyboard users get a ring on the
|
|
accent colour, which clears 3:1 on every surface in both themes.
|
|
Deliberately the ONLY focus indicator: shadcn's primitives ship a second,
|
|
softer `ring` box-shadow of their own, and the two together read as a halo
|
|
inside a line. Those are stripped in button.tsx and input.tsx instead of
|
|
dropping this, because this one rule covers links, table buttons and
|
|
anything else that never goes through a primitive.
|
|
It sets no border-radius on purpose — an earlier version pinned 2px here,
|
|
which squared the corners of every control that had no radius utility of
|
|
its own. The outline follows each element's own radius. */
|
|
:focus-visible {
|
|
outline: 2px solid var(--budget-accent);
|
|
outline-offset: 2px;
|
|
}
|
|
}
|
|
|
|
@layer utilities {
|
|
/* Applied to every money figure: mono face, tabular digits so columns of
|
|
numbers align on the decimal without a monospaced layout everywhere. */
|
|
.font-money {
|
|
font-family: var(--font-money);
|
|
font-variant-numeric: tabular-nums;
|
|
font-feature-settings: "zero"; /* slashed zero: 0 vs O at a glance */
|
|
letter-spacing: -0.01em;
|
|
}
|
|
|
|
.text-overspend {
|
|
color: var(--overspend);
|
|
}
|
|
|
|
.text-surplus {
|
|
color: var(--surplus);
|
|
}
|
|
|
|
/* The one card treatment, so elevation cannot drift screen to screen. */
|
|
.budget-card {
|
|
background: var(--budget-raised);
|
|
border: 1px solid var(--budget-rule);
|
|
border-radius: var(--radius);
|
|
box-shadow: var(--budget-shadow-card);
|
|
}
|
|
}
|
|
|
|
@keyframes spin {
|
|
from {
|
|
transform: rotate(0);
|
|
}
|
|
to {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
|
|
@media (prefers-reduced-motion) {
|
|
*,
|
|
::before,
|
|
::after {
|
|
animation: none !important;
|
|
transition: none !important;
|
|
}
|
|
}
|