diff --git a/apps/app/src/app.css b/apps/app/src/app.css index 1f74c8639..e89db5dc4 100644 --- a/apps/app/src/app.css +++ b/apps/app/src/app.css @@ -37,7 +37,7 @@ * { scrollbar-width: thin; - scrollbar-color: color-mix(in oklch, var(--foreground) 20%, transparent) + scrollbar-color: color-mix(in oklab, var(--foreground) 20%, transparent) transparent; } @@ -52,12 +52,12 @@ } *::-webkit-scrollbar-thumb { - background-color: color-mix(in oklch, var(--foreground) 20%, transparent); + background-color: color-mix(in oklab, var(--foreground) 20%, transparent); border-radius: 9999px; } *::-webkit-scrollbar-thumb:hover { - background-color: color-mix(in oklch, var(--foreground) 40%, transparent); + background-color: color-mix(in oklab, var(--foreground) 40%, transparent); } /* diff --git a/apps/app/src/components/ui/theme.css b/apps/app/src/components/ui/theme.css index fcb80474d..8f931fd8d 100644 --- a/apps/app/src/components/ui/theme.css +++ b/apps/app/src/components/ui/theme.css @@ -348,8 +348,8 @@ * reads consistently on canvas, cards, and recessed surfaces alike. Over the * canvas these match an opaque ink-over-canvas mix exactly; on elevated * surfaces they composite for real, visible contrast. */ - --state-hover: color-mix(in oklch, var(--ink) 5.9%, transparent); - --state-active: color-mix(in oklch, var(--ink) 11.8%, transparent); + --state-hover: color-mix(in oklab, var(--ink) 5.9%, transparent); + --state-active: color-mix(in oklab, var(--ink) 11.8%, transparent); --destructive: oklch(0.45 0.19 25.8625); --destructive-foreground: oklch(1 0 0); /* Text-only destructive. Light is identical to --destructive (8.04:1); the @@ -377,22 +377,22 @@ --ring: var(--primary); /* Inset panel tints on the ink ramp: recessed a clear sunken step, raised a * faint lift, both translucent so they read on card or page alike. */ - --surface-recessed: color-mix(in oklch, var(--ink) 6%, transparent); - --surface-raised: color-mix(in oklch, var(--ink) 2.5%, transparent); - --surface-scrim: color-mix(in oklch, var(--canvas) 92%, transparent); + --surface-recessed: color-mix(in oklab, var(--ink) 6%, transparent); + --surface-raised: color-mix(in oklab, var(--ink) 2.5%, transparent); + --surface-scrim: color-mix(in oklab, var(--canvas) 92%, transparent); --surface-destructive: color-mix( - in oklch, + in oklab, var(--destructive) 6%, transparent ); --surface-destructive-border: color-mix( - in oklch, + in oklab, var(--destructive) 25%, transparent ); - --surface-selected: color-mix(in oklch, var(--primary) 16%, transparent); + --surface-selected: color-mix(in oklab, var(--primary) 16%, transparent); --surface-selected-border: color-mix( - in oklch, + in oklab, var(--primary) 35%, transparent ); @@ -520,8 +520,8 @@ --file-accent: oklch(0.72 0.09 250); /* Interactive-state fills: translucent ink so the same hover/active step * reads consistently on canvas, cards, and recessed surfaces alike. */ - --state-hover: color-mix(in oklch, var(--ink) 13.8%, transparent); - --state-active: color-mix(in oklch, var(--ink) 22.5%, transparent); + --state-hover: color-mix(in oklab, var(--ink) 13.8%, transparent); + --state-active: color-mix(in oklab, var(--ink) 22.5%, transparent); --destructive: oklch(0.56 0.19 22.1703); --destructive-foreground: oklch(1 0 0); /* Lighter/less-saturated than the fill so error TEXT clears AA (~4.8:1) on the @@ -545,22 +545,22 @@ --input: color-mix(in oklch, var(--ink) 32.6%, var(--canvas)); --ring: var(--primary); /* Same model as light; ink is light here, so these lift the dark canvas. */ - --surface-recessed: color-mix(in oklch, var(--ink) 6%, transparent); - --surface-raised: color-mix(in oklch, var(--ink) 2.5%, transparent); - --surface-scrim: color-mix(in oklch, var(--canvas) 92%, transparent); + --surface-recessed: color-mix(in oklab, var(--ink) 6%, transparent); + --surface-raised: color-mix(in oklab, var(--ink) 2.5%, transparent); + --surface-scrim: color-mix(in oklab, var(--canvas) 92%, transparent); --surface-destructive: color-mix( - in oklch, + in oklab, var(--destructive) 8%, transparent ); --surface-destructive-border: color-mix( - in oklch, + in oklab, var(--destructive) 30%, transparent ); - --surface-selected: color-mix(in oklch, var(--primary) 12%, transparent); + --surface-selected: color-mix(in oklab, var(--primary) 12%, transparent); --surface-selected-border: color-mix( - in oklch, + in oklab, var(--primary) 35%, transparent ); diff --git a/apps/app/src/components/ui/theme.test.ts b/apps/app/src/components/ui/theme.test.ts index f14c713bd..322a959b0 100644 --- a/apps/app/src/components/ui/theme.test.ts +++ b/apps/app/src/components/ui/theme.test.ts @@ -26,13 +26,14 @@ function modeBlock(scheme: "light" | "dark"): string { /** * token -> ink mix percentage, for tokens derived from the anchors. The base is - * either the canvas (opaque steps) or `transparent` (translucent interactive/ - * overlay steps); over the canvas both resolve to the same step, so the mix - * percentage is the comparable "contrast from canvas" either way. + * either the canvas (opaque steps, mixed in oklch) or `transparent` (translucent + * interactive/overlay steps, mixed in oklab — see the guard below); over the + * canvas both resolve to the same step, so the mix percentage is the comparable + * "contrast from canvas" either way. */ function rampSteps(block: string): Map { const re = - /--([a-z-]+):\s*color-mix\(in oklch, var\(--ink\) ([\d.]+)%, (?:var\(--canvas\)|transparent)\);/g; + /--([a-z-]+):\s*color-mix\(in okl(?:ch|ab), var\(--ink\) ([\d.]+)%, (?:var\(--canvas\)|transparent)\);/g; const steps = new Map(); for (const match of block.matchAll(re)) { steps.set(match[1], Number(match[2])); @@ -197,6 +198,22 @@ describe("theme.css neutral ramp", () => { const dark = [...rampSteps(modeBlock("dark")).keys()].sort(); expect(light).toEqual(dark); }); + + it("derives translucent (transparent-mixed) tokens in oklab, not oklch", () => { + // Mixing a color with `transparent` in a *polar* space (oklch) drops the + // result hue to `none`, which renders as hue 0 (red). The chroma survives, + // so any palette whose canvas/ink/primary isn't pure gray got a pink-tinted + // header (--surface-scrim), hover, and selection — the default palette only + // escaped because its anchors are chroma-0. Rectangular spaces (oklab) carry + // the hue through, so translucency must mix in oklab. Opaque color->canvas + // mixes can stay oklch. This guard keeps every future palette correct by + // construction, since palettes only set opaque anchors and never touch these + // derived tokens. + const offenders = [ + ...css.matchAll(/color-mix\(\s*in oklch\b[^;]*?\btransparent\b/g), + ].map((match) => match[0].replace(/\s+/g, " ")); + expect(offenders).toEqual([]); + }); }); describe("theme.css Cadence text tokens", () => {