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>
381 lines
16 KiB
CSS
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);
|
|
}
|
|
}
|