Files
platform/src/workspaces/theme/css/base.css
T
pastilhasandClaude Opus 5 9f243f569f native controls follow the theme
Nothing set `color-scheme`, so the browser drew every control it renders itself as if the page were
light. On a dark theme a <select>'s dropdown opened as a white sheet with the page's own pale text on
it — unreadable, and unreachable by any utility class, because that list is painted by the browser and
is not in the document.

`color-scheme` is the only thing that reaches it. It also fixes, for free, the other controls with no
styleable interior: date and colour pickers, the spinner on a number input, and default scrollbars
where a theme has not replaced them.

The `dark` class decides it, because that is the signal that exists — 203 of the 231 themes set
applyDarkClass, and the provider puts it on <html> beside data-theme, so the two always agree about the
same element. Both are specificity 0-1-0 and themes import after this file, so a theme that disagrees
can say so. This is a default, not a decision taken away.

Explicit colours on select/option/optgroup underneath, because color-scheme gives the browser's generic
dark rather than the theme's. Where a browser honours them — Firefox, Chrome on Linux and Windows — the
list becomes the theme's own popover surface. Where it does not, Safari and Chrome on macOS use a
genuinely native menu and the scheme carries it, which is a great deal better than white on white.

Base-layer rules, so a utility still wins and `bg-transparent` on a select behaves as it did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 02:38:59 +01:00

381 lines
16 KiB
CSS

/*
THE STRUCTURE OF THE LOOK — never its values.
============================================================================================
This file says WHAT can be themed. A theme file says what it looks like. Nothing here names a colour,
and nothing here belongs to any particular theme: swapping every theme in the repository must not
require editing this file, and adding a variable here is a change to the contract that every theme has
to answer.
The split exists because it is the whole feature. Before it, `globals.css` mixed three things — Tailwind
wiring, the token contract, and the Duck brand's hex values — so "change how the app looks" and "change
how theming works" were the same edit, and neither could be done by anyone who did not own the app.
── How a value reaches a component ──
component .bg-surface
@theme (here) --color-surface: var(--surface)
theme file --surface: 0 0% 100%
The indirection through `var()` is load-bearing: Tailwind resolves `@theme` at BUILD time, so a theme
that set `--color-surface` directly could never be swapped without a rebuild. Resolving to a custom
property means the browser reads it at paint, and changing `data-theme` on <html> re-paints the app.
── Two spellings, and why ──
Colours are HSL TRIPLETS without the function (`0 0% 100%`), so `hsl(var(--x) / 0.5)` gives opacity for
free — that is what makes `bg-surface/50` work, and it is why every shadcn-derived token in this file
looks like that.
Surfaces (panel, dock, card, page) are COMPLETE CSS VALUES instead, because a theme needs to set things
a triplet cannot express: `rgba()`, a gradient, an image, `none`. A panel background that could only ever
be a flat colour would rule out the glass look the app has today.
*/
@import 'tailwindcss';
@plugin 'tailwindcss-animate';
/*
Dark is still a CLASS, even though light and dark are now separate themes.
Two themes was the simplification the owner chose, and it is the right one — but 396 `dark:` utilities
are written into 33 components, and they all resolve against this variant. A theme that wants them
active declares `applyDarkClass` in its manifest and the provider adds the class; a theme that does not
simply never sees them. So the concept is gone from the app while the utilities keep working, and
removing those 396 becomes a later cleanup rather than a prerequisite.
*/
@custom-variant dark (&:is(.dark *));
@utility container {
margin-inline: auto;
padding-inline: 2rem;
@media (width >= --theme(--breakpoint-sm)) {
max-width: none;
}
@media (width >= 1400px) {
max-width: 1400px;
}
}
/*
Values are HEX, not unwrapped HSL triplets.
============================================================================================
This block used to read `--color-card: hsl(var(--card))`, which requires every theme to publish an
unwrapped triplet (`0 0% 100%`). That was done so `bg-card/50` could get opacity — and it turned out to
be unnecessary: Tailwind v4 emits `color-mix(in oklab, var(--card) 50%, transparent)` for any colour
format, with a `@supports` fallback. Tested before changing it.
It was also actively wrong the moment a theme arrived in hex: `hsl(#6e6a86)` is invalid, so every
semantic utility silently fell back to white or transparent. Eleven ported palettes looked correct only
because panels and the page read their variables directly rather than through a utility class.
*/
@theme {
/* ── Semantic colour, the whole vocabulary a component is allowed to use ──────────────────────── */
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-brand: var(--brand);
--color-brand-foreground: var(--brand-foreground);
--color-brand-muted: var(--brand-muted);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
--color-info: var(--info);
--color-info-foreground: var(--info-foreground);
/* The sixth state, and the reason it exists: `running` is neither good news nor a warning. The chat
tray, the send button and the queue all wore amber for it, and the token set had nothing to offer —
`--status-in-progress` is blue, and `warning` would make a working job look like a failing one.
`-ink` is the readable-on-a-tint variant: the fill is bright enough to sit behind black text, which
makes it far too light to BE text. Every tone will want this eventually; busy needed it first. */
--color-busy: var(--busy);
--color-busy-foreground: var(--busy-foreground);
--color-busy-ink: var(--busy-ink);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-cta: var(--cta);
--color-cta-foreground: var(--cta-foreground);
--color-ctahover: var(--ctahover);
--color-naturegreen: var(--naturegreen);
--color-naturegreen-foreground: var(--naturegreen-foreground);
/*
── The brand slots ────────────────────────────────────────────────────────────────────────────
These are the six utilities that were `duck-*`, kept under their old names for now so ~1,470 existing
usages keep compiling, but pointed at variables a theme owns rather than at brand hex.
They are DEPRECATED as spellings. `duck-dark` is the clearest case of why: it is `#14532d` in light and
near-white in dark, so it was never a brand green — it is ink, and 946 components are using it to mean
"text colour". A theme called anything other than Duck cannot sensibly define `--duck-dark`.
The rename to semantic names is the largest mechanical piece of this refactor and is deliberately not
done in the same change as the extraction: one of them can be verified by screenshot, and doing both at
once would make a visual difference impossible to attribute.
*/
--color-duck-yellow: var(--brand-accent);
--color-duck-orange: var(--brand-warm);
--color-duck-teal: var(--brand-primary);
--color-duck-forest: var(--brand-deep);
--color-duck-dark: var(--ink);
--color-duck-beige: var(--surface-warm);
--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 forwards;
--animate-accordion-up: accordion-up 0.2s ease-out forwards;
@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;
}
}
}
/*
Tailwind v4 changed the default border colour to `currentcolor`. Kept so an element with a border and no
explicit colour looks as it did under v3 — removing it means auditing every bordered element in the app.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
* {
@apply border-border;
}
/*
The page itself is a themed surface, not just a colour.
`--page-bg` is a complete `background` value so a theme can be a photograph, a gradient, or nothing at
all. Duck is a wallpaper; Plain is white. Both are the same line here.
*/
body {
@apply text-foreground;
background: var(--page-bg);
background-attachment: fixed;
background-size: cover;
}
/*
NATIVE CONTROLS FOLLOW THE THEME.
------------------------------------------------------------------------------------------
Nothing here set `color-scheme`, so the browser drew every control it renders itself as if the page
were light — on a dark theme, a `<select>`'s dropdown opened as a white sheet with the page's own
pale text on it. Unreadable, and unreachable by any utility class, because that list is painted by
the browser and is not in the document.
`color-scheme` is the only thing that reaches it. It also fixes, for free, the other controls with
no styleable interior: date and colour pickers, the spinner on a number input, and default
scrollbars where a theme has not replaced them.
── Why the `dark` class decides it ──
It is the signal that exists. 203 of the 231 themes set `applyDarkClass`, and the provider puts the
class on <html> beside `data-theme`, so the two are always in agreement about the same element.
Both are specificity 0-1-0 and themes are imported after this file, so a theme that disagrees can
simply say so — this is a default, not a decision taken away.
── And the explicit colours underneath ──
`color-scheme` gives the browser's generic dark, not the theme's. Where a browser honours colours on
`<option>` — Firefox, and Chrome on Linux and Windows — these make the list the theme's own popover
surface instead. Where it does not (Safari, Chrome on macOS, which use a genuinely native menu), the
scheme above is what carries it, and generic dark is a great deal better than white-on-white.
A utility still wins: these are base-layer rules, so `bg-transparent` on a select behaves exactly as
it did.
*/
:root {
color-scheme: light;
}
.dark {
color-scheme: dark;
}
select,
option,
optgroup {
background-color: var(--popover);
color: var(--popover-foreground);
}
}
/*
DERIVED SURFACES — the defaults that make a 29-value theme a complete one.
============================================================================================
A theme is required to supply the 29 standard tokens: shadcn's 20 plus the state tones shadcn lacks
(success, warning, info, busy). Everything below is then computed from them, so a palette-only theme —
Tokyo Night, Gruvbox, anything published in the shadcn vocabulary — renders a complete application
without knowing that panels, docks, glass or scrollbars exist.
── How an override works ──
These live on `:root`; a theme lives on `[data-theme='x']`, and both land on the same <html> element. A
theme's own declaration is more specific, so it simply wins. Nothing here has to know which themes exist,
and a theme only declares what it actually cares about.
That is the difference between a contract of 29 and a contract of 84. Duck has a TEXTURE — glass over a
photograph — and declares its surfaces. Tokyo Night has a palette and declares none of them.
`color-mix` rather than opacity variants because these are complete CSS values: a surface may be a
gradient or an image in a theme that wants one, and an alpha channel cannot express that.
*/
@layer base {
:root {
/*
State tones, which shadcn's vocabulary does not have.
Defaulted rather than required, because green-means-good is a convention rather than a palette
decision, and a theme published elsewhere will never have thought about them. A theme that cares
overrides; every ported palette gets something legible for free.
This is what drops the required contract from 29 to 20 — the twenty tokens every shadcn palette
already ships.
*/
--success: #16a34a;
--success-foreground: #ffffff;
--warning: #f59e0b;
--warning-foreground: #1c1917;
--info: #6366f1;
--info-foreground: #ffffff;
--busy: #f59e0b;
--busy-foreground: #1c1917;
--busy-ink: #b45309;
/* The page */
--page-bg: var(--background);
--page-bg-src: '';
--pixel-grid-color: color-mix(in srgb, var(--border) 35%, transparent);
/* Panels — the cornerstone of the layout */
--panel-bg: color-mix(in srgb, var(--card) 92%, transparent);
--panel-border: var(--border);
--panel-border-bare: var(--border);
--panel-raised-bg: var(--card);
--panel-raised-border: var(--border);
--panel-radius: var(--radius);
--panel-blur: 0px;
--panel-header-bg: color-mix(in srgb, var(--muted) 60%, transparent);
--panel-header-fg: var(--foreground);
/*
Panel chrome states. These were literal blacks in the component — `text-black/60` on the zoom
readout, `hover:bg-black/10` on every control, `border-black/10` under the header — which is legible
on Duck's pale glass and invisible on any dark theme. Derived from the header's own ink so they
follow whatever it is.
*/
--panel-header-fg-dim: color-mix(in srgb, var(--panel-header-fg) 60%, transparent);
--panel-header-border: color-mix(in srgb, var(--panel-header-fg) 15%, transparent);
--panel-control-hover: color-mix(in srgb, var(--panel-header-fg) 12%, transparent);
--panel-accent: var(--primary);
--panel-accent-fg: var(--primary-foreground);
--panel-grid-color: color-mix(in srgb, var(--border) 25%, transparent);
/* The header */
--header-bg: color-mix(in srgb, var(--card) 85%, transparent);
--header-border: var(--border);
--header-fg: var(--foreground);
--header-blur: 0px;
/* Other surfaces */
--glass-bg: color-mix(in srgb, var(--card) 75%, transparent);
--glass-border: var(--border);
--dock-bg: color-mix(in srgb, var(--card) 85%, transparent);
--dock-border: var(--border);
--dock-tooltip-bg: var(--popover);
--card-bg: var(--card);
--card-grid-color: color-mix(in srgb, var(--border) 25%, transparent);
/* Chrome */
--scrollbar-thumb: color-mix(in srgb, var(--muted-foreground) 60%, transparent);
--scrollbar-thumb-hover: var(--muted-foreground);
--scrollbar-thin-thumb: color-mix(in srgb, var(--muted-foreground) 40%, transparent);
--scrollbar-thin-thumb-hover: color-mix(in srgb, var(--muted-foreground) 70%, transparent);
/* The mark. Empty is correct: a theme that ships no logo should show none rather than someone else's. */
--logo-src: '';
/* Brand slots, until the `duck-*` utilities are renamed. A palette theme should never think about these. */
--brand-primary: var(--primary);
--brand-accent: var(--accent);
--brand-warm: var(--destructive);
--brand-deep: var(--primary);
--ink: var(--foreground);
--surface-warm: var(--muted);
--duck-teal: var(--brand-primary);
--duck-dark: var(--ink);
--duck-yellow: var(--brand-accent);
--duck-forest: var(--brand-deep);
--duck-beige: var(--surface-warm);
--duck-orange: var(--brand-warm);
/* Officer leftovers, kept alive until their few call sites go. */
--cta: var(--primary);
--cta-foreground: var(--primary-foreground);
--ctahover: var(--primary);
--naturegreen: var(--success);
--naturegreen-foreground: var(--success-foreground);
--brand: var(--primary);
--brand-foreground: var(--primary-foreground);
--brand-muted: color-mix(in srgb, var(--primary) 20%, transparent);
/* Data, when a theme has not said. */
--chart-primary: var(--primary);
--chart-secondary: color-mix(in srgb, var(--primary) 50%, transparent);
--status-success: var(--success);
--status-warning: var(--warning);
--status-in-progress: var(--info);
}
}