From 154650c843f4b060f04c3fa71e19339ec0b2067c Mon Sep 17 00:00:00 2001 From: kate bonner Date: Mon, 6 Jul 2026 12:00:10 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20theme-calculated=20Harmoniqs=20yellow?= =?UTF-8?q?=20=E2=80=94=20OKLCH-solved=20brand=20accent=20(brand-wide)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit brand_accent.ts computes the deployed accent from the active theme at webview boot: the canonical #FFF676 ships EXACTLY wherever contrast vs the theme's editor background clears 3:1 (all dark themes); light themes get the closest-to-brand gold by binary-searching lightness with hue + chroma held (gamut-clamped). Two tokens with different jobs: lines (--color-accent, contrast-solved: borders/rings/marks) and fills (--color-accent-fill, always the brand lemon — black text on it ≈ 19:1; a 3:1-darkened gold passes WCAG math but reads muddy under text). --color-on-accent is contrast-picked; yellow is never text. Recomputed live on theme switch. Inspector + catalog-card webviews apply at boot; brand.css statics remain the no-JS fallback. Pill atom gains a dot-less badge variant (dot = process state; badges describe things). Co-Authored-By: Claude Fable 5 --- packages/extension/media/ui/atoms/pill.ts | 14 +- packages/extension/media/ui/brand_accent.ts | 141 ++++++++++++++++++ .../extension/src/catalog_card_webview.ts | 3 + packages/extension/src/inspector_webview.ts | 3 + packages/extension/test/brand_accent.test.ts | 60 ++++++++ 5 files changed, 219 insertions(+), 2 deletions(-) create mode 100644 packages/extension/media/ui/brand_accent.ts create mode 100644 packages/extension/test/brand_accent.test.ts diff --git a/packages/extension/media/ui/atoms/pill.ts b/packages/extension/media/ui/atoms/pill.ts index d7b3ab26..c150ab9c 100644 --- a/packages/extension/media/ui/atoms/pill.ts +++ b/packages/extension/media/ui/atoms/pill.ts @@ -1,4 +1,7 @@ // Pill atom — a status indicator. State is a class applied here, in TS. +// Variations: the default carries a status dot (live run states — the dot +// pulses while running); `dot: false` yields a plain badge (labels like +// "recommended" that describe a THING, not a process). import { defineStyle } from "../style"; @@ -10,6 +13,7 @@ defineStyle("pill", ` display: inline-flex; align-items: center; gap: var(--space-sm); } .pill::before { content: ""; width: var(--square-dot); height: var(--square-dot); border-radius: 50%; background: currentColor; } + .pill.no-dot::before { content: none; } .pill.idle { color: var(--color-dim); } .pill.running { color: var(--color-run); } .pill.running::before { animation: pill-pulse 1.1s ease-in-out infinite; } @@ -20,15 +24,21 @@ defineStyle("pill", ` export type PillState = "idle" | "running" | "done" | "failed"; +export interface PillOptions { + /** Status dot before the label (default true). Badges pass false. */ + dot?: boolean; +} + export interface PillAtom { el: HTMLSpanElement; set(state: PillState, label: string): void; } -export function pill(state: PillState = "idle", label = state): PillAtom { +export function pill(state: PillState = "idle", label: string = state, opts: PillOptions = {}): PillAtom { const el = document.createElement("span"); + const variant = opts.dot === false ? " no-dot" : ""; const set = (s: PillState, l: string) => { - el.className = "pill " + s; + el.className = "pill " + s + variant; el.textContent = l; }; set(state, label); diff --git a/packages/extension/media/ui/brand_accent.ts b/packages/extension/media/ui/brand_accent.ts new file mode 100644 index 00000000..b4e95d63 --- /dev/null +++ b/packages/extension/media/ui/brand_accent.ts @@ -0,0 +1,141 @@ +// Brand accent solver — the Harmoniqs yellow, theme-calculated. +// +// #FFF676 is the canonical brand accent (brand.css). At ~96% lightness it +// sings on dark themes and vanishes on light ones, so each webview computes +// the DEPLOYED accent from the active theme at boot: hold the brand's OKLCH +// hue + chroma, and if contrast against the theme's editor background already +// meets target, ship the brand hex EXACTLY (dark themes — decision: brand- +// exact wherever physics allows); otherwise walk lightness down to the +// closest-to-brand value that passes (light themes get a deeper gold). +// --color-on-accent is picked black/white by contrast on the computed fill — +// yellow itself is never text (fills + borders only). +// +// Pure math up top (unit-tested in node); applyBrandAccent() is the DOM +// applier — sets --color-accent/--color-on-accent at :root and recomputes on +// theme switches (VS Code mutates body attributes when the theme changes). + +const BRAND_HEX = "#FFF676"; +const CONTRAST_TARGET = 3.0; // WCAG non-text UI component minimum + +type RGB = [number, number, number]; // 0..1 + +export function parseColor(s: string): RGB | undefined { + const t = s.trim(); + const hex = t.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i)?.[1]; + if (hex) { + const h = hex.length === 3 ? [...hex].map((c) => c + c).join("") : hex; + return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255) as RGB; + } + const rgb = t.match(/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/i); + if (rgb) return [+rgb[1] / 255, +rgb[2] / 255, +rgb[3] / 255] as RGB; + return undefined; +} + +const toHex = (rgb: RGB): string => + "#" + rgb.map((c) => Math.round(Math.min(1, Math.max(0, c)) * 255).toString(16).padStart(2, "0")).join("").toUpperCase(); + +// -- OKLCH (Björn Ottosson's OKLab) ----------------------------------------- + +const lin = (c: number): number => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)); +const gam = (c: number): number => (c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055); + +export function srgbToOklch([r, g, b]: RGB): { L: number; C: number; h: number } { + const [lr, lg, lb] = [lin(r), lin(g), lin(b)]; + const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb); + const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb); + const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb); + const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s; + const a = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s; + const bb = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s; + return { L, C: Math.hypot(a, bb), h: (Math.atan2(bb, a) * 180) / Math.PI }; +} + +export function oklchToSrgb({ L, C, h }: { L: number; C: number; h: number }): RGB { + const a = C * Math.cos((h * Math.PI) / 180); + const b = C * Math.sin((h * Math.PI) / 180); + const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3; + const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3; + const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3; + return [ + gam(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s), + gam(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s), + gam(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s), + ] as RGB; +} + +/** In-gamut conversion: reduce chroma until every channel lands in sRGB. */ +function oklchToSrgbClamped(c: { L: number; C: number; h: number }): RGB { + let C = c.C; + for (let i = 0; i < 20; i++) { + const rgb = oklchToSrgb({ ...c, C }); + if (rgb.every((v) => v >= -0.001 && v <= 1.001)) return rgb; + C *= 0.85; + } + return oklchToSrgb({ ...c, C: 0 }); +} + +// -- WCAG contrast ----------------------------------------------------------- + +export function relativeLuminance([r, g, b]: RGB): number { + return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b); +} + +export function contrast(a: RGB, b: RGB): number { + const [hi, lo] = [relativeLuminance(a), relativeLuminance(b)].sort((x, y) => y - x); + return (hi + 0.05) / (lo + 0.05); +} + +// -- The solve --------------------------------------------------------------- + +export interface BrandAccent { + /** Lines: borders, focus rings, ☑ marks — solved to ≥3:1 vs the theme bg. */ + accent: string; + /** Fills: button backgrounds — stays the brand lemon on EVERY theme (black + * text on #FFF676 is ~19:1); on light themes the component's boundary + * comes from a border in `accent`, never from darkening the fill (a + * 3:1-darkened gold passes WCAG math but reads muddy under text). */ + accentFill: string; + /** Text on accentFill, contrast-picked. */ + onAccent: string; + /** True when the LINE accent shipped as the unmodified brand hex (dark themes). */ + brandExact: boolean; +} + +export function solveBrandAccent(background: string): BrandAccent { + const bg = parseColor(background) ?? parseColor("#1e1e1e")!; + const brand = parseColor(BRAND_HEX)!; + const onAccent = + contrast([0, 0, 0], brand) >= contrast([1, 1, 1], brand) ? "#000000" : "#FFFFFF"; + + if (contrast(brand, bg) >= CONTRAST_TARGET) { + return { accent: BRAND_HEX, accentFill: BRAND_HEX, onAccent, brandExact: true }; + } + // Light theme: hold brand hue+chroma, binary-search the HIGHEST lightness + // that still meets target — the closest-to-brand gold that survives. This + // is the LINE color only; the fill stays brand. + const { C, h, L: brandL } = srgbToOklch(brand); + let lo = 0.15, hi = brandL; + for (let i = 0; i < 40; i++) { + const mid = (lo + hi) / 2; + if (contrast(oklchToSrgbClamped({ L: mid, C, h }), bg) >= CONTRAST_TARGET) lo = mid; + else hi = mid; + } + const rgb = oklchToSrgbClamped({ L: lo, C, h }); + return { accent: toHex(rgb), accentFill: BRAND_HEX, onAccent, brandExact: false }; +} + +// -- DOM applier ------------------------------------------------------------- + +/** Compute the accent from the live theme and pin it at :root; re-solve when + * VS Code swaps themes (body attributes mutate). Call once per webview boot. */ +export function applyBrandAccent(): void { + const apply = (): void => { + const bg = getComputedStyle(document.body).getPropertyValue("--vscode-editor-background"); + const { accent, accentFill, onAccent } = solveBrandAccent(bg); + document.documentElement.style.setProperty("--color-accent", accent); + document.documentElement.style.setProperty("--color-accent-fill", accentFill); + document.documentElement.style.setProperty("--color-on-accent", onAccent); + }; + apply(); + new MutationObserver(apply).observe(document.body, { attributes: true }); +} diff --git a/packages/extension/src/catalog_card_webview.ts b/packages/extension/src/catalog_card_webview.ts index fa4f26ed..a8df0376 100644 --- a/packages/extension/src/catalog_card_webview.ts +++ b/packages/extension/src/catalog_card_webview.ts @@ -2,8 +2,11 @@ // (window.__CARD_DATA__, hydrated from the real run dir by the save-to-catalog // flow); the baked fixture below is the fallback for hostless debugging. +import { applyBrandAccent } from "../media/ui/brand_accent"; import { catalogcard, type CatalogEntry, type CardPulse } from "../media/ui/components/catalogcard"; +applyBrandAccent(); // theme-calculated Harmoniqs yellow (brand-wide contract) + declare function acquireVsCodeApi(): { postMessage(msg: unknown): void }; declare global { interface Window { __CARD_DATA__?: { entry: CatalogEntry; pulse?: CardPulse } } } diff --git a/packages/extension/src/inspector_webview.ts b/packages/extension/src/inspector_webview.ts index 47ed85e5..7066d0ce 100644 --- a/packages/extension/src/inspector_webview.ts +++ b/packages/extension/src/inspector_webview.ts @@ -2,8 +2,11 @@ // inspector.ts). No static markup: the view builds its own DOM from atoms/ // components; brand.css + layout.css are linked by the shell (run_inspector.ts). +import { applyBrandAccent } from "../media/ui/brand_accent"; import { createInspectorView } from "../media/ui/views/inspector"; +applyBrandAccent(); // theme-calculated Harmoniqs yellow (brand-wide contract) + declare function acquireVsCodeApi(): { postMessage(msg: unknown): void; }; diff --git a/packages/extension/test/brand_accent.test.ts b/packages/extension/test/brand_accent.test.ts new file mode 100644 index 00000000..1e18ea23 --- /dev/null +++ b/packages/extension/test/brand_accent.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from "vitest"; +import { parseColor, srgbToOklch, oklchToSrgb, contrast, solveBrandAccent } from "../media/ui/brand_accent"; + +// Theme-calculated Harmoniqs yellow: brand-exact wherever the theme allows +// (dark), contrast-solved to the closest-to-brand gold where it doesn't +// (light). Yellow is never text: on-accent is picked by contrast on the fill. + +describe("solveBrandAccent — the theme-calculated Harmoniqs yellow", () => { + it("dark themes ship the canonical hex EXACTLY", () => { + for (const bg of ["#1e1e1e", "#000000", "rgb(30, 30, 30)"]) { + const r = solveBrandAccent(bg); + expect(r.accent).toBe("#FFF676"); + expect(r.brandExact).toBe(true); + } + }); + + it("light themes get a contrast-solved gold LINE: ≥3:1, brand hue held, lightness reduced", () => { + const r = solveBrandAccent("#ffffff"); + expect(r.brandExact).toBe(false); + const solved = parseColor(r.accent)!; + expect(contrast(solved, parseColor("#ffffff")!)).toBeGreaterThanOrEqual(2.98); // binary-search tolerance + const brand = srgbToOklch(parseColor("#FFF676")!); + const got = srgbToOklch(solved); + expect(Math.abs(got.h - brand.h)).toBeLessThan(8); // hue is the brand carrier + expect(got.L).toBeLessThan(brand.L); + }); + + it("the FILL stays brand lemon on every theme — text readability beats fill-vs-bg contrast", () => { + for (const bg of ["#1e1e1e", "#ffffff", "#f3f3f3"]) { + const r = solveBrandAccent(bg); + expect(r.accentFill).toBe("#FFF676"); + // black text on the lemon fill is always high-contrast (~19:1) + expect(contrast(parseColor(r.onAccent)!, parseColor(r.accentFill)!)).toBeGreaterThan(4.5); + } + }); + + it("on-accent text is picked by contrast on the fill (black on the lemon)", () => { + expect(solveBrandAccent("#1e1e1e").onAccent).toBe("#000000"); + expect(solveBrandAccent("#ffffff").onAccent).toBe("#000000"); + }); + + it("mid-gray themes that already clear 3:1 stay brand-exact", () => { + expect(solveBrandAccent("#808080").brandExact).toBe(true); + }); + + it("parses the color formats getComputedStyle actually returns", () => { + expect(parseColor("#FFF676")).toBeDefined(); + expect(parseColor("rgb(255, 246, 118)")).toBeDefined(); + expect(parseColor("rgba(255, 246, 118, 1)")).toBeDefined(); + expect(parseColor("")).toBeUndefined(); + // garbage input falls back inside solveBrandAccent rather than throwing + expect(() => solveBrandAccent("not-a-color")).not.toThrow(); + }); + + it("OKLCH round-trips the brand hex within a hair", () => { + const rgb = parseColor("#FFF676")!; + const back = oklchToSrgb(srgbToOklch(rgb)); + back.forEach((c, i) => expect(Math.abs(c - rgb[i])).toBeLessThan(0.005)); + }); +});