From 99b465e402692d3e4bc5a0fa0cb3f7aeb2b4dcca Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 16 Sep 2026 09:59:25 +0200 Subject: [PATCH] feat(theme): semantic token system for light and dark --- src/app/globals.css | 155 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 153 insertions(+), 2 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index 6af7376..ab32af1 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,9 +1,160 @@ @import "tailwindcss"; +/* + * Dark mode is class-based, not media-based, because the user picks it + * explicitly and the choice persists. See components/theme/theme-script.tsx. + */ +@custom-variant dark (&:where(.dark, .dark *)); + +/* + * Semantic tokens. Named for the job they do, never for the colour they are: + * `--surface` survives a palette change, `--grey-100` does not. + * + * Every text token below clears 4.5:1 against the surface it is used on, in + * both themes. If you add one, check it — the references this design borrows + * from both fail that bar, and pale-grey-on-white is exactly how a board + * becomes unreadable on a client's mid-range laptop. + */ :root { - color-scheme: dark; + /* Structure: a tinted page with a raised panel floating on it. */ + --page: #e9ecf1; + --panel: #ffffff; + --surface: #f6f7f9; + --surface-raised: #eef0f4; + + --border-subtle: #e2e5ea; + --border-strong: #cbd1d9; + + --text-primary: #101319; + --text-secondary: #4a5260; + --text-muted: #6b7382; + + /* Cool brand blue: primary actions, links, selection. */ + --brand: #2f6dad; + --brand-hover: #275d95; + --brand-fg: #ffffff; + --brand-soft: #e7eff8; + + /* + * The warm accent. Reserved for exactly three things: an overdue date, the + * Inbox count badge, and critical priority. It reads as urgent only while + * it stays rare. + */ + --accent: #c94a24; + --accent-fg: #ffffff; + --accent-soft: #fbeae3; + + --priority-critical: #c94a24; + --priority-high: #a8660a; + --priority-medium: #2f6dad; + --priority-low: #6b7382; + + --shadow-panel: 0 1px 2px rgba(16, 19, 25, 0.04), + 0 16px 40px -12px rgba(16, 19, 25, 0.16); + --shadow-tile: 0 1px 2px rgba(16, 19, 25, 0.06); + --shadow-drag: 0 12px 28px -6px rgba(16, 19, 25, 0.28); +} + +.dark { + --page: #08090b; + --panel: #131519; + --surface: #1a1d22; + --surface-raised: #22262d; + + --border-subtle: #262a31; + --border-strong: #383d47; + + --text-primary: #f1f3f6; + --text-secondary: #aab2bf; + --text-muted: #838c9b; + + --brand: #5e9ee0; + --brand-hover: #7cb0e8; + --brand-fg: #08090b; + --brand-soft: #16263a; + + --accent: #ff8a5c; + --accent-fg: #1a0e08; + --accent-soft: #2e1a12; + + --priority-critical: #ff8a5c; + --priority-high: #e0a23c; + --priority-medium: #5e9ee0; + --priority-low: #838c9b; + + --shadow-panel: 0 1px 2px rgba(0, 0, 0, 0.4), + 0 16px 40px -12px rgba(0, 0, 0, 0.6); + --shadow-tile: 0 1px 2px rgba(0, 0, 0, 0.3); + --shadow-drag: 0 12px 28px -6px rgba(0, 0, 0, 0.7); +} + +/* + * Expose the tokens to Tailwind. `@theme inline` keeps the var() reference + * live, so `bg-surface` resolves at runtime and follows the theme class. + * Without `inline`, Tailwind would bake in the :root value and dark mode + * would silently do nothing. + */ +@theme inline { + --color-page: var(--page); + --color-panel: var(--panel); + --color-surface: var(--surface); + --color-surface-raised: var(--surface-raised); + + --color-border-subtle: var(--border-subtle); + --color-border-strong: var(--border-strong); + + --color-text-primary: var(--text-primary); + --color-text-secondary: var(--text-secondary); + --color-text-muted: var(--text-muted); + + --color-brand: var(--brand); + --color-brand-hover: var(--brand-hover); + --color-brand-fg: var(--brand-fg); + --color-brand-soft: var(--brand-soft); + + --color-accent: var(--accent); + --color-accent-fg: var(--accent-fg); + --color-accent-soft: var(--accent-soft); + + --color-priority-critical: var(--priority-critical); + --color-priority-high: var(--priority-high); + --color-priority-medium: var(--priority-medium); + --color-priority-low: var(--priority-low); + + --font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif; + + --radius-tile: 10px; + --radius-panel: 18px; +} + +* { + border-color: var(--border-subtle); } body { - @apply bg-neutral-950 text-neutral-100 antialiased; + background: var(--page); + color: var(--text-primary); + font-family: var(--font-sans); + -webkit-font-smoothing: antialiased; +} + +/* + * Numbers in stats, dates and counts must not jitter as they change. + * Applied wherever a figure updates in place. + */ +.tabular { + font-variant-numeric: tabular-nums; + font-feature-settings: "tnum"; +} + +/* A consistent focus ring, since every interactive element needs one and + Tailwind's default differs per element. */ +.focus-ring { + outline: 2px solid var(--brand); + outline-offset: 2px; +} + +:focus-visible { + outline: 2px solid var(--brand); + outline-offset: 2px; } \ No newline at end of file