Skip to content

Commit ccb97d3

Browse files
committed
fix(design-system): sample the titlebar color off what paints it
Moving the mode into the value made a token declaration stop being a colour: `getComputedStyle(root).getPropertyValue('--background')` now reads back `light-dark(oklch(1.000 0 0), oklch(0.205 0.004 286))`, a recipe that only resolves where it is used. The Windows titlebar sampler fed that string to a canvas, and both of its guards passed it: `CSS.supports('color', …)` accepts a light-dark() pair, and the rejected `fillStyle` assignment left the canvas at its default opaque black, whose alpha is 255. Measured in Chromium 151, light and dark both sampled `#000000` — so the native control strip went black in every palette, putting the light-mode symbol colour `#1c1d21` on it at 1.25:1 and, in dark mode, seaming a black strip against a `#171719` app background. That seam is the whole reason this code samples instead of hard-coding a pair. The colour comes from `body` now, which is where `--background` is painted; `getComputedStyle().backgroundColor` is a used value, so the engine has already picked the branch — the same read `readModalBackdropColor` already makes. The helper takes the element rather than a string, so an unresolved declaration has nowhere to enter, and `CSS.supports` goes with it. Its replacement is a transparent starting fillStyle: an assignment the canvas cannot parse now reads back at alpha 0 and takes the fallback, for any input, instead of sampling the black it defaulted to. ink-ladder-contract gains the rule rather than the instance — no product source reads a custom property as a colour at all. There were none besides this one. Generated-by: Claude Code
1 parent eee8278 commit ccb97d3

3 files changed

Lines changed: 44 additions & 12 deletions

File tree

DESIGN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ Use the system UI stack with explicit platform CJK fallbacks; Geist Variable is
177177

178178
The palette is cool-neutral and quiet; color is generated to spec, not picked by eye.
179179

180-
**The Mode Lives in the Value Rule.** A colour that differs between light and dark carries both sides in its own value — `light-dark(light, dark)`, declared once — and never in a `.dark` selector. This is not tidiness: Astryx inverts a surface by setting `color-scheme: dark` on it (every toast body, every overlay scrim), and a selector matched on `<html>` cannot follow. A token declared under `.dark` keeps the page's mode on a surface that just declared itself the other one, which is how the error toast came to paint light-mode grey prose on its red plate at 1.04:1. A `light-dark()` value resolves against whatever `color-scheme` is in force where it is *used*, so it follows the page and the inverted surface both — including for tokens nobody has thought to check. Two alphas have nowhere to express a mode and stay on `.dark`; a shadow is not an exception, since only its colour differs and the pair goes inside the recipe. `color-scheme` is set on `<html>` beside the class — before the first paint, and on every change — and Astryx's `<Theme>` re-declares it on its own wrapper from that class, so the app's subtree turns over on that React commit: one repaint later than the root, and as a whole rather than half at a time. Contract-tested over every token `makaTheme.ts` aliases and everything those transitively read.
180+
**The Mode Lives in the Value Rule.** A colour that differs between light and dark carries both sides in its own value — `light-dark(light, dark)`, declared once — and never in a `.dark` selector. This is not tidiness: Astryx inverts a surface by setting `color-scheme: dark` on it (every toast body, every overlay scrim), and a selector matched on `<html>` cannot follow. A token declared under `.dark` keeps the page's mode on a surface that just declared itself the other one, which is how the error toast came to paint light-mode grey prose on its red plate at 1.04:1. A `light-dark()` value resolves against whatever `color-scheme` is in force where it is *used*, so it follows the page and the inverted surface both — including for tokens nobody has thought to check. Two alphas have nowhere to express a mode and stay on `.dark`; a shadow is not an exception, since only its colour differs and the pair goes inside the recipe. `color-scheme` is set on `<html>` beside the class — before the first paint, and on every change — and Astryx's `<Theme>` re-declares it on its own wrapper from that class, so the app's subtree turns over on that React commit: one repaint later than the root, and as a whole rather than half at a time. The corollary for JavaScript: a token now reads back as a recipe, not a colour, so nothing may take `getPropertyValue('--x')` and treat the result as one — code that needs a colour reads `getComputedStyle` off whatever paints it, the way the Windows titlebar samples `body`. That failure is silent in both directions: `CSS.supports('color', …)` accepts a `light-dark()` pair, and a canvas ignores the `fillStyle` assignment and keeps its default opaque black. Contract-tested over every token `makaTheme.ts` aliases and everything those transitively read.
181181

182182
- **Brand mark** is fixed `#71a8fd`; it identifies Maka and is never the general CTA color.
183183
- **Interaction accent** follows the active palette for focus, selection, and live state; **links and accent-colored text use the solid tier** (§3). Astryx's own semantic components are the exception — `Badge` and `StatusDot` carry fixed literals inherited from the neutral theme and follow neither the palette nor the families below.

apps/desktop/src/main/__tests__/ink-ladder-contract.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,4 +193,28 @@ describe('mode expression', () => {
193193
'an inverted surface has one ink tier: secondary takes the same on-color as primary',
194194
);
195195
});
196+
197+
it('never reads a palette token as a colour', async () => {
198+
// A token's declared value stopped being a colour when the mode moved into
199+
// it: `light-dark()` resolves at the use site, so the declaration is a
200+
// recipe. Handing one to something that wants a colour fails silently —
201+
// `CSS.supports('color', …)` says yes and a canvas fillStyle ignores the
202+
// assignment, which is how the Windows titlebar sampled opaque black.
203+
// Read `getComputedStyle(element)` off whatever paints the token instead.
204+
const files = (await Promise.all(SOURCE_ROOTS.map(sourceFilesUnder))).flat();
205+
const offenders: string[] = [];
206+
for (const file of files) {
207+
if (!file.endsWith('.ts') && !file.endsWith('.tsx')) continue;
208+
const source = withoutComments(await readFile(file, 'utf8'));
209+
for (const match of source.matchAll(/getPropertyValue\(\s*['"`](--[a-z0-9-]+)/g)) {
210+
offenders.push(`${relative(REPO_ROOT, file)}${match[1]}`);
211+
}
212+
}
213+
214+
assert.deepEqual(
215+
offenders,
216+
[],
217+
'a custom property reads back as its declaration, not as a resolved value',
218+
);
219+
});
196220
});

apps/desktop/src/renderer/theme.ts

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -211,11 +211,11 @@ export function applyThemePalette(palette: ThemePalette): void {
211211

212212
function syncTitleBarOverlay(root: HTMLElement): void {
213213
// The native Windows overlay sits on top of the renderer's content surface.
214-
// Sample the actual resolved --background color instead of approximating it
214+
// Sample the actual painted --background color instead of approximating it
215215
// with one hard-coded light and dark pair; this also follows every palette.
216216
const isDark = root.classList.contains(DARK_CLASS);
217-
const backgroundColor = cssColorToHex(
218-
getComputedStyle(root).getPropertyValue('--background'),
217+
const backgroundColor = paintedBackgroundToHex(
218+
document.body,
219219
isDark ? '#1c1d21' : '#ffffff',
220220
);
221221
void window.maka?.appWindow
@@ -231,9 +231,7 @@ function syncTitleBarOverlay(root: HTMLElement): void {
231231
/**
232232
* The color the titlebar strip appears under an open modal: the dialog
233233
* backdrop scrim composited over `--background`. The scrim is sampled from
234-
* the open modal's own ::backdrop — the engine has already resolved its
235-
* `var()` indirection and `light-dark()` branch, which neither a token read
236-
* nor a canvas fillStyle can do — so the dim tracks theme and palette
234+
* the open modal's own ::backdrop, so the dim tracks theme and palette
237235
* automatically.
238236
*/
239237
function dimmedTitlebarColor(backgroundHex: string, isDark: boolean): string {
@@ -254,17 +252,27 @@ function readModalBackdropColor(): { r: number; g: number; b: number; a: number
254252
return parseCssRgbColor(getComputedStyle(dialog, '::backdrop').backgroundColor);
255253
}
256254

257-
function cssColorToHex(value: string, fallback: string): string {
258-
const color = value.trim();
259-
if (!color || !CSS.supports('color', color)) return fallback;
260-
255+
/**
256+
* The opaque color an element is painted, as hex. Takes the element rather
257+
* than a color string because a palette token's declared value is not a
258+
* color: `--background` is a `light-dark()` pair that only becomes one where
259+
* it is used (DESIGN.md §8), and a canvas cannot resolve that — nor anything
260+
* else that needs an element's context. Reading `background-color` off the
261+
* element that paints it hands the canvas an already-resolved color.
262+
*/
263+
function paintedBackgroundToHex(element: Element, fallback: string): string {
261264
const canvas = document.createElement('canvas');
262265
canvas.width = 1;
263266
canvas.height = 1;
264267
const context = canvas.getContext('2d', { willReadFrequently: true });
265268
if (!context) return fallback;
266269

267-
context.fillStyle = color;
270+
// A fillStyle the canvas cannot parse is ignored, leaving the previous value
271+
// in place — so start transparent. Anything unparseable then reads back at
272+
// alpha 0 and takes the fallback, rather than sampling the opaque black that
273+
// fillStyle defaults to.
274+
context.fillStyle = 'rgba(0, 0, 0, 0)';
275+
context.fillStyle = getComputedStyle(element).backgroundColor;
268276
context.fillRect(0, 0, 1, 1);
269277
const [red, green, blue, alpha] = context.getImageData(0, 0, 1, 1).data;
270278
if (alpha !== 255) return fallback;

0 commit comments

Comments
 (0)