hex everywhere, because the palettes were half broken

Reported as "the ellipsis doesn't show in the streaming bubble". It was the visible
corner of something much larger: every Tailwind semantic utility was broken under all
eleven ported palettes.

`@theme` mapped `--color-muted-foreground: hsl(var(--muted-foreground))`, which needs an
unwrapped triplet. A ported palette publishes `#6e6a86`, so the mapping produced
`hsl(#6e6a86)` — invalid — and the utility fell back to white or transparent:

  duck-dark    text-muted-foreground=rgb(148,163,184)   bg-muted=rgb(30,41,59)
  rose-pine    text-muted-foreground=rgb(255,255,255)   bg-muted=rgba(0,0,0,0)

The themes looked convincing because panels, the page and the header read their
variables DIRECTLY rather than through a utility class. Everything that went through
Tailwind was quietly wrong, and the thinking dots — `bg-muted-foreground/60` — were
simply invisible.

So: 123 HSL triplets converted to hex across the three hand-written themes, and 37
`hsl(var(--x))` mappings unwrapped. One format everywhere, which is also the format
every published palette ships in.

The triplets only ever existed so `bg-card/50` could get opacity. That was tested
earlier tonight and turned out unnecessary — Tailwind v4 emits
`color-mix(in oklab, var(--card) 50%, transparent)` for any colour format. Having proved
it, I then left the mappings wrapped, which is what broke the palettes.

Verified across six themes: `bg-muted` and `bg-muted-foreground/60` resolve in every one
of them, and Duck's values are unchanged through the conversion — `bg-muted` is
`rgb(30,41,59)` before and after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 01:08:54 +00:00
co-authored by Claude Opus 5
parent 94a55fe679
commit 8fa3f7da3e
4 changed files with 177 additions and 164 deletions
+50 -37
View File
@@ -14,7 +14,7 @@
── How a value reaches a component ──
component .bg-surface
@theme (here) --color-surface: hsl(var(--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
@@ -57,64 +57,77 @@
}
}
/*
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: hsl(var(--border));
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-brand: hsl(var(--brand));
--color-brand-foreground: hsl(var(--brand-foreground));
--color-brand-muted: hsl(var(--brand-muted));
--color-brand: var(--brand);
--color-brand-foreground: var(--brand-foreground);
--color-brand-muted: var(--brand-muted);
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: hsl(var(--secondary));
--color-secondary-foreground: hsl(var(--secondary-foreground));
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-destructive: hsl(var(--destructive));
--color-destructive-foreground: hsl(var(--destructive-foreground));
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-success: hsl(var(--success));
--color-success-foreground: hsl(var(--success-foreground));
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
--color-warning: hsl(var(--warning));
--color-warning-foreground: hsl(var(--warning-foreground));
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
--color-info: hsl(var(--info));
--color-info-foreground: hsl(var(--info-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: hsl(var(--busy));
--color-busy-foreground: hsl(var(--busy-foreground));
--color-busy-ink: hsl(var(--busy-ink));
--color-busy: var(--busy);
--color-busy-foreground: var(--busy-foreground);
--color-busy-ink: var(--busy-ink);
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-popover: hsl(var(--popover));
--color-popover-foreground: hsl(var(--popover-foreground));
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-cta: hsl(var(--cta));
--color-cta-foreground: hsl(var(--cta-foreground));
--color-ctahover: hsl(var(--ctahover));
--color-cta: var(--cta);
--color-cta-foreground: var(--cta-foreground);
--color-ctahover: var(--ctahover);
--color-naturegreen: hsl(var(--naturegreen));
--color-naturegreen-foreground: hsl(var(--naturegreen-foreground));
--color-naturegreen: var(--naturegreen);
--color-naturegreen-foreground: var(--naturegreen-foreground);
/*
── The brand slots ────────────────────────────────────────────────────────────────────────────
+43 -43
View File
@@ -44,7 +44,7 @@
--duck-orange: var(--brand-warm);
/* ── The page ─────────────────────────────────────────────────────────────────────────────────── */
--page-bg: hsl(222.2 84% 4.9%);
--page-bg: #020817;
--pixel-grid-color: rgba(200, 230, 210, 0.08);
/* ── The wallpaper, the header and the mark ───────────────────────────────────────────────────
@@ -75,7 +75,7 @@
--panel-blur: 24px;
--panel-radius: 0.75rem;
--panel-header-bg: rgba(0, 0, 0, 0.15);
--panel-header-fg: hsl(var(--foreground));
--panel-header-fg: var(--foreground);
--panel-accent: var(--brand-primary);
--panel-grid-color: rgba(200, 230, 210, 0.06);
@@ -87,71 +87,71 @@
--card-grid-color: rgba(200, 230, 210, 0.06);
/* ── Semantic colour ──────────────────────────────────────────────────────────────────────────── */
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--background: #020817;
--foreground: #f8fafc;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--card: #020817;
--card-foreground: #f8fafc;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--popover: #020817;
--popover-foreground: #f8fafc;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--primary: #f8fafc;
--primary-foreground: #0f172a;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--secondary: #1e293b;
--secondary-foreground: #f8fafc;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--muted: #1e293b;
--muted-foreground: #94a3b8;
--accent: 211 82% 64%;
--accent-foreground: 0 0% 100%;
--accent: #58a1ee;
--accent-foreground: #ffffff;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--destructive: #7f1d1d;
--destructive-foreground: #f8fafc;
--success: 142.1 76.2% 36.3%;
--success-foreground: 355.7 100% 97.3%;
--success: #16a34a;
--success-foreground: #fff1f2;
--brand: 145 60% 45%;
--brand-foreground: 0 0% 100%;
--brand-muted: 145 30% 25%;
--brand: #2eb867;
--brand-foreground: #ffffff;
--brand-muted: #2d533d;
--warning: 43 96% 56%;
--warning-foreground: 0 0% 0%;
--warning: #fbbd23;
--warning-foreground: #000000;
/* Lifted well above the light value — 50% lightness violet on a near-black background reads as muddy
grey at 12px, which is exactly the size it is used at. */
--info: 263 90% 70%;
--info-foreground: 0 0% 100%;
--info: #a26ef7;
--info-foreground: #ffffff;
/* Busy: running, queued, in flight. See base.css. */
--busy: 43 96% 56%;
--busy-foreground: 20 14% 8%;
--busy-ink: 43 96% 70%;
--busy: #fbbd23;
--busy-foreground: #171312;
--busy-ink: #fcd269;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
--border: #1e293b;
--input: #1e293b;
--ring: #cbd5e1;
/* The original dark block never overrode these, so the light values were in force. Written out because
a theme that silently inherits from another is the thing this file exists to stop. */
--cta: 211 74.77% 45.58%;
--cta-foreground: 0 0% 100%;
--ctahover: 211 91.53% 34.82%;
--cta: #1d71cb;
--cta-foreground: #ffffff;
--ctahover: #0856aa;
--naturegreen: 145 79% 38%;
--naturegreen-foreground: 0 0% 100%;
--naturegreen: #14ad54;
--naturegreen-foreground: #ffffff;
--radius: 0.5rem;
/* ── Data visualisation and status ────────────────────────────────────────────────────────────── */
--chart-primary: 211 82% 64%;
--chart-secondary: 211 82% 84%;
--status-success: 142 76% 36%;
--status-warning: 43 96% 56%;
--status-in-progress: 211 82% 64%;
--chart-primary: #58a1ee;
--chart-secondary: #b5d5f8;
--status-success: #16a249;
--status-warning: #fbbd23;
--status-in-progress: #58a1ee;
/* ── Chrome ───────────────────────────────────────────────────────────────────────────────────── */
--scrollbar-thumb: #444;
+43 -43
View File
@@ -46,7 +46,7 @@
/* ── The page ─────────────────────────────────────────────────────────────────────────────────
A complete `background` value, so a theme can be an image or a gradient rather than a colour. */
--page-bg: hsl(0 0% 100%);
--page-bg: #ffffff;
--pixel-grid-color: rgba(20, 83, 45, 0.1);
/* ── The wallpaper, the header and the mark ───────────────────────────────────────────────────
@@ -82,7 +82,7 @@
--panel-blur: 24px;
--panel-radius: 0.75rem;
--panel-header-bg: rgba(0, 0, 0, 0.05);
--panel-header-fg: hsl(var(--foreground));
--panel-header-fg: var(--foreground);
--panel-accent: var(--brand-primary);
--panel-grid-color: rgba(20, 83, 45, 0.08);
@@ -94,70 +94,70 @@
--card-grid-color: rgba(20, 83, 45, 0.08);
/* ── Semantic colour (HSL triplets — see base.css for why they are unwrapped) ─────────────────── */
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--background: #ffffff;
--foreground: #020817;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--card: #ffffff;
--card-foreground: #020817;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--popover: #ffffff;
--popover-foreground: #020817;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--primary: #0f172a;
--primary-foreground: #f8fafc;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--secondary: #f1f5f9;
--secondary-foreground: #0f172a;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--muted: #f1f5f9;
--muted-foreground: #64748b;
--accent: 211 74.77% 45.58%;
--accent-foreground: 0 0% 100%;
--accent: #1d71cb;
--accent-foreground: #ffffff;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--destructive: #ef4444;
--destructive-foreground: #f8fafc;
--success: 142.1 76.2% 36.3%;
--success-foreground: 355.7 100% 97.3%;
--success: #16a34a;
--success-foreground: #fff1f2;
--brand: 145 79% 38%;
--brand-foreground: 0 0% 100%;
--brand-muted: 145 40% 90%;
--brand: #14ad54;
--brand-foreground: #ffffff;
--brand-muted: #dbf0e4;
--warning: 43 96% 56%;
--warning-foreground: 0 0% 0%;
--warning: #fbbd23;
--warning-foreground: #000000;
/* The fifth state tone. "In progress" and "merged" are neither good news nor bad, and painting them
with --success makes a merged PR and an open one look like the same thing. Violet because it has to
stay legible next to success green and destructive red at pill size. */
--info: 263 70% 50%;
--info-foreground: 0 0% 100%;
--info: #6b26d9;
--info-foreground: #ffffff;
/* Busy: running, queued, in flight. See base.css. */
--busy: 43 96% 50%;
--busy-foreground: 20 14% 8%;
--busy-ink: 32 81% 29%;
--busy: #fab505;
--busy-foreground: #171312;
--busy-ink: #864e0e;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--border: #e2e8f0;
--input: #e2e8f0;
--ring: #020817;
--cta: 211 74.77% 45.58%;
--cta-foreground: 0 0% 100%;
--ctahover: 211 91.53% 34.82%;
--cta: #1d71cb;
--cta-foreground: #ffffff;
--ctahover: #0856aa;
--naturegreen: 145 79% 38%;
--naturegreen-foreground: 0 0% 100%;
--naturegreen: #14ad54;
--naturegreen-foreground: #ffffff;
--radius: 0.5rem;
/* ── Data visualisation and status ────────────────────────────────────────────────────────────── */
--chart-primary: 211 82% 64%;
--chart-secondary: 211 82% 84%;
--status-success: 142 76% 36%;
--status-warning: 43 96% 56%;
--status-in-progress: 211 82% 64%;
--chart-primary: #58a1ee;
--chart-secondary: #b5d5f8;
--status-success: #16a249;
--status-warning: #fbbd23;
--status-in-progress: #58a1ee;
/* ── Chrome that used hardcoded greys ─────────────────────────────────────────────────────────
Scrollbars were `#444`/`#555`/`rgb(128 128 128 / .3)` in the stylesheet, which meant the code editor
+41 -41
View File
@@ -93,69 +93,69 @@
Greys only. The state tones (success/warning/destructive/info) are greys too, which is wrong for a
real theme and right for this one: it proves the app is asking for a token rather than reaching for a
green it likes. A theme that wanted real state colour would simply set them. */
--background: 0 0% 100%;
--foreground: 0 0% 0%;
--background: #ffffff;
--foreground: #000000;
--card: 0 0% 100%;
--card-foreground: 0 0% 0%;
--card: #ffffff;
--card-foreground: #000000;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 0%;
--popover: #ffffff;
--popover-foreground: #000000;
--primary: 0 0% 0%;
--primary-foreground: 0 0% 100%;
--primary: #000000;
--primary-foreground: #ffffff;
--secondary: 0 0% 95%;
--secondary-foreground: 0 0% 0%;
--secondary: #f2f2f2;
--secondary-foreground: #000000;
--muted: 0 0% 95%;
--muted-foreground: 0 0% 35%;
--muted: #f2f2f2;
--muted-foreground: #595959;
--accent: 0 0% 90%;
--accent-foreground: 0 0% 0%;
--accent: #e6e6e6;
--accent-foreground: #000000;
--destructive: 0 0% 25%;
--destructive-foreground: 0 0% 100%;
--destructive: #404040;
--destructive-foreground: #ffffff;
--success: 0 0% 25%;
--success-foreground: 0 0% 100%;
--success: #404040;
--success-foreground: #ffffff;
--brand: 0 0% 0%;
--brand-foreground: 0 0% 100%;
--brand-muted: 0 0% 90%;
--brand: #000000;
--brand-foreground: #ffffff;
--brand-muted: #e6e6e6;
--warning: 0 0% 45%;
--warning-foreground: 0 0% 100%;
--warning: #737373;
--warning-foreground: #ffffff;
--info: 0 0% 35%;
--info-foreground: 0 0% 100%;
--info: #595959;
--info-foreground: #ffffff;
/* Busy: running, queued, in flight. See base.css. */
--busy: 0 0% 45%;
--busy-foreground: 0 0% 100%;
--busy-ink: 0 0% 25%;
--busy: #737373;
--busy-foreground: #ffffff;
--busy-ink: #404040;
--border: 0 0% 0%;
--input: 0 0% 0%;
--ring: 0 0% 0%;
--border: #000000;
--input: #000000;
--ring: #000000;
--cta: 0 0% 0%;
--cta-foreground: 0 0% 100%;
--ctahover: 0 0% 20%;
--cta: #000000;
--cta-foreground: #ffffff;
--ctahover: #333333;
--naturegreen: 0 0% 0%;
--naturegreen-foreground: 0 0% 100%;
--naturegreen: #000000;
--naturegreen-foreground: #ffffff;
/* Square everything. Rounded corners surviving under Plain means a hardcoded `rounded-xl`, which is a
shape leak rather than a colour one — worth seeing too. */
--radius: 0;
/* ── Data visualisation and status ────────────────────────────────────────────────────────────── */
--chart-primary: 0 0% 20%;
--chart-secondary: 0 0% 60%;
--status-success: 0 0% 25%;
--status-warning: 0 0% 45%;
--status-in-progress: 0 0% 35%;
--chart-primary: #333333;
--chart-secondary: #999999;
--status-success: #404040;
--status-warning: #737373;
--status-in-progress: #595959;
/* ── Chrome ───────────────────────────────────────────────────────────────────────────────────── */
--scrollbar-thumb: #000000;