CSS theming (light and dark)¶
Theming is token-first, with co-located structural overrides.
There are eight themes. Four are light: light (Matcha, with an
optional gradient: a still wash of green and lavender, warm with sunlit
yellow, behind the page, see-through cards, veiled chrome), basic
(after the 2017 LawPay UI: white ground, navy buttons, azure accents;
formerly oxford, and the --oxford-* palette keeps the source name),
nord-light (Nord's Snow Storm surfaces, Polar Night text, frost blue
links, frost cyan selection) and letterhead (after a firm engagement
letter: bone paper, hairline rules, fountain-pen blue). Four are dark:
dark (Gruvbox), cosmic (Nord), kosmic (Nord under the landing
page's animated night sky) and everforest (green-gray surfaces, cream
text, signature-green links).
Cosmic, kosmic and everforest are the dark theme structurally. They share the
dark token block and every dark structural rule, and only repoint the
--gb-* ramp (theme blocks in colors.css, palettes in palette.css).
Consequence: every "dark" selector is scoped to all four themes as
:is([data-theme="dark"], [data-theme="cosmic"], [data-theme="kosmic"], [data-theme="everforest"]).
When you add a new dark structural rule, use that :is(...) selector so
all the darks stay in sync.
The non-Matcha lights are the light theme structurally. They inherit
every :root (light) default and their blocks in colors.css only
repoint colour tokens. Light structural rules are the unscoped defaults,
so they get them for free; a rule scoped to [data-theme="light"] alone
does NOT apply to them. Scope to
:is([data-theme="light"], [data-theme="nord-light"], [data-theme="basic"], [data-theme="letterhead"])
(or the subset that needs it) when a light structural rule must reach the
other light themes. Retired themes: sky, kosmos, kosmos-dark,
latte, mocha, everforest-light, and matcha-mist / matcha-lavender,
which became Matcha's gradient; renamed: oxford to basic
(theme.js migrates stored settings).
Matcha's gradient is Matcha plus an atmosphere. Appearance's
Gradient (none, cool or warm) is a per-device setting like Kosmic's
sky motion: theme.js keeps it in localStorage under gradient and,
under Matcha alone, sets data-wash on <html> to cool or warm
(before first paint in base.html); none, and every other theme, carry
no attribute. The [data-theme="light"][data-wash] block in colors.css
adds the light (--mist-*: the wash, the sun, the sheens, the veil, the
see-through card colour) and darkens text, borders and selection a touch
to hold against it; [data-wash="cool"] swaps in the wash without the
sun. The rules that lay the wash behind the page and clear the surfaces
over it live together in static/css/matcha-gradient.css, scoped the same
way, rather than spread across the component files: they are one effect,
and every card surface that lets the light through is listed there.
Floating surfaces (dialogs, menus, the phone drawer and top bar) stay
solid and take a sheen instead, so nothing under them shows through. A
stored matcha-lavender (or matcha-mist) theme migrates to Matcha with
the gradient set, cool from the old low intensity and warm otherwise.
Kosmic is cosmic plus the sky. It shares cosmic's Nord ramp block,
and its own block in colors.css only makes the ground surfaces
(--background-body, --background-sidebar) translucent and table cells
(--background-td) clear, so the sky shows through. Raised surfaces
stay opaque. The sky itself is templates/components/kosmic-sky.html
(included by base.html, hidden under every other theme) and
static/css/kosmic.css. The markup and the images in
static/images/kosmic/ are generated from the landing page's
Sky.astro by scripts/build-kosmic-sky.mjs; rerun it when that sky
changes rather than editing the output.
All theme colour variables are authored in oklch (palette.css ramps and
any literal colours in colors.css theme blocks): no hex. Derivation
formulas (color-mix) and component styles consume tokens as before.
-
Tokens are the primary mechanism.
static/css/colors.cssdefines every semantic token in:root(light values) and re-defines the same tokens in a single:is([data-theme="dark"], [data-theme="cosmic"], [data-theme="kosmic"], [data-theme="everforest"]) { … }block (darkgb-*values). Component stylesheets consumevar(--token)and are theme-agnostic: they flip automatically when the tokens change underneath them. -
When dark only needs a different value → change a token. Add or repoint the token in the
[data-theme="dark"]block ofcolors.css. Do not write a scoped rule for a plain colour swap. -
When dark needs a structurally different rule → scoped + co-located. Some changes can't be a value swap because the colour moves to a different property (e.g. filled → outline buttons/badges: background drops out and the hue moves onto border + text; or task accent rails vs row fills). Those need a real rule. Put it in a
[data-theme="dark"] .component { … }block at the bottom of that component's own CSS file, under aDark mode —comment header. Keep the colour decision in a token (see the--label-*-linetokens for an example) and only the structural rule in the component. -
Tokens are for decisions; derivations are for relationships. A colour someone chose (link hue, selection wash, urgency) is a per-theme token. A colour that exists only in service of another colour (the border of a fill, the focus ring of an accent, a hover step) is a
color-mix()formula off its source. Formulas cannot forget a theme, which is how fixed button borders rotted invisibly in eight themes. Current derivations:--focus-ring(accent normalized to mid-tone),--field-focus(ring diluted toward the field ground), the focus halo, and the modal button borders (fill mixed 20% toward--color-darker: darkens in light themes, lightens in dark ones). A theme may still override a derived value with a scoped rule as an escape hatch. -
The brand is a two-token gradient. The sidebar wordmark and kappa, the mobile topbar wordmark, the auth-card wordmark, and the painted favicon all run from
--brand-grad-fromto--brand-grad-to. The leading (left) end is--brand-ink, the stronger colour (set once in:root); the far end is the per-theme decision: each light block sets--brand-grad-to, and the darks get it by repointing--gb-bright-yellow. A new theme must set it (or set it tovar(--brand-ink)for a flat brand), or it inherits Matcha's violet. The inline cuts fill from the shared SVG gradients intemplates/components/kosmos-brand-gradients.html. The favicon is painted bytheme.json every page that carriesdata-favicon, the logged-out and error pages included; on dev (data-favicon="brand-dev")--nord11red leads the gradient in place of the brand ink. -
Do not create a monolithic
dark.css. Co-location keeps each component's light and dark story in one file. The only thing centralized is the token block incolors.css.
Files with co-located dark (shared by all four darks) blocks include buttons.css,
badges.css, sidebar.css, detail.css, interface.css, viewer.css,
apps/tasks.css, apps/calendar.css, apps/matters.css,
apps/case/ai.css, apps/case/highlights.css, apps/notes-editor.css. Grep
:is([data-theme="dark"], [data-theme="cosmic"], [data-theme="kosmic"], [data-theme="everforest"]) for the authoritative list.
Related: never use a new font-size below 1rem. The smaller sizes
already in the stylesheets are deliberate exceptions (the calendar's month
event chips, .btn-sm, badges and other dense chrome). Leave them as they
are; do not raise them to the floor.