diff --git a/packages/extension/.gitignore b/packages/extension/.gitignore index 850676dd..8e2699c7 100644 --- a/packages/extension/.gitignore +++ b/packages/extension/.gitignore @@ -1 +1,2 @@ dev/pulseplot_harness/main.js +dev/brand_harness/main.js diff --git a/packages/extension/dev/brand_harness/index.html b/packages/extension/dev/brand_harness/index.html new file mode 100644 index 00000000..5faa66eb --- /dev/null +++ b/packages/extension/dev/brand_harness/index.html @@ -0,0 +1,131 @@ + + + + + brand harness — light-mode yellow + + + + + +
+ + +
+ +

Brand swatch — fill · 1px edge · dark ink

+
+
+ +

Metrics — hero flag (the Run Inspector's one hero) vs neutral

+
+ +

Buttons — hover to see the ink border/tint

+
+ +

Sparkline — ink stroke (neutral on light, lemon on dark)

+
+ +

Catalog card — "proposed" metrics carry a dashed ink border

+
+ + + + diff --git a/packages/extension/dev/brand_harness/main.ts b/packages/extension/dev/brand_harness/main.ts new file mode 100644 index 00000000..96d7e3a5 --- /dev/null +++ b/packages/extension/dev/brand_harness/main.ts @@ -0,0 +1,91 @@ +// Brand accent dev harness — renders the real webview components against +// stubbed VS Code theme vars so the light/dark brand treatment can be eyeballed +// without a live run. NOT shipped in the VSIX (dev/ is excluded; see +// .vscodeignore). Run: `pnpm run dev:brand`. +// +// The solver (applyBrandAccent) reads --vscode-editor-background + +// --vscode-foreground from index.html and sets --color-accent-{fill,edge,ink} +// at :root; toggling .dark on mutates an attribute, so its +// MutationObserver re-solves — the components react exactly as in the webview. + +import { applyBrandAccent, solveBrandAccent } from "../../media/ui/brand_accent"; +import { metric } from "../../media/ui/components/metric"; +import { button } from "../../media/ui/atoms/button"; +import { sparkline } from "../../media/ui/components/sparkline"; +import { catalogcard, type CatalogEntry } from "../../media/ui/components/catalogcard"; + +// --- brand swatch: fill + edge + on-accent, straight from the tokens +function swatch(label: string): HTMLElement { + const d = document.createElement("div"); + d.textContent = label; + d.style.cssText = + "display:inline-flex;align-items:center;padding:7px 14px;border-radius:6px;" + + "font-weight:600;font-size:14px;background:var(--color-accent-fill);" + + "color:var(--color-on-accent);border:var(--border-width) solid var(--color-accent-edge);"; + return d; +} +const swatches = document.getElementById("swatches")!; +swatches.append(swatch("Solve complete"), swatch("CZ · 0.9994"), swatch("Promoted")); + +// --- metrics: the flagged hero, a pending hero, and neutral (catalog-style) +const metricsHost = document.getElementById("metrics")!; +const heroFlag = metric("fidelity", { variant: "hero", flag: true }); +heroFlag.value("0.9994"); +const heroPending = metric("fidelity", { variant: "hero", flag: true }); // stays "–": no flag until it lands +const heroNeutral = metric("gate time", { variant: "hero" }); // catalog-style hero: no flag +heroNeutral.value("20 ns"); +const small = metric("objective", { variant: "small" }); +small.value("3.2e-4"); +metricsHost.append(heroFlag.el, heroPending.el, heroNeutral.el, small.el); + +// --- buttons (hover states are the point) +const buttonsHost = document.getElementById("buttons")!; +buttonsHost.append( + button("■ Stop", () => {}).el, + button("↓ Save pulse", () => {}).el, + button("↗ Open run dir", () => {}).el, +); + +// --- sparkline: a converging (descending) objective trace +const sparkHost = document.getElementById("sparkline")!; +const spark = sparkline(60); +sparkHost.append(spark.el); +for (let i = 0; i < 60; i++) spark.update(10 ** (1 - (i / 60) * 4) * (1 + 0.15 * Math.sin(i))); + +// --- catalog card: fidelity + gate-time heroes (neutral) + proposed dashed metrics +const entry: CatalogEntry = { + schema_version: "1", + run_id: "run-demo-0715", + lab_id: "lab-demo", + fidelity: 0.99942, + pulse_path: "catalog/pulses/transmon/cz.jld2", + gate: "CZ", + created_at: "2026-07-15", + params: { system: "transmon", T: 20 }, + proposed: { tags: ["demo"], iterations: 320, wall_seconds: 45, system_name: "Demo transmon" }, +}; +document.getElementById("catalog")!.append(catalogcard(entry, { onAction: () => {} }).el); + +// --- boot the solver + wire the theme toggle +applyBrandAccent(); +const nowLabel = document.getElementById("now")!; +const refresh = (): void => { + const cs = getComputedStyle(document.body); + const r = solveBrandAccent( + cs.getPropertyValue("--vscode-editor-background"), + cs.getPropertyValue("--vscode-foreground"), + ); + nowLabel.textContent = `${r.isLight ? "LIGHT" : "DARK"} · fill ${r.fill} · edge ${r.edge} · ink ${r.ink}`; + document.getElementById("swatch-cap")!.textContent = r.isLight + ? "light: true lemon fill, 1px dark hairline edge, black text" + : "dark: true lemon fill, no edge (it separates itself), black text"; +}; +refresh(); +document.getElementById("theme")!.addEventListener("click", () => { + document.body.classList.toggle("dark"); + refresh(); +}); +if (location.hash.includes("dark")) { + document.body.classList.add("dark"); + refresh(); +} diff --git a/packages/extension/media/brand.css b/packages/extension/media/brand.css index 76a4e138..30834010 100644 --- a/packages/extension/media/brand.css +++ b/packages/extension/media/brand.css @@ -12,9 +12,18 @@ } :root { - /* color */ - --color-accent: #fff676; - --color-on-accent: #000000; + /* color — the brand yellow, split into three honest roles. brand_accent.ts + * overrides fill/edge/ink at :root per the live theme; these are the DARK + * defaults (the dev harness + no-JS render on the dark surface). The rule: + * yellow is a FILL, never an ink. On light surfaces the lemon can't define + * its own edge (1.1:1 vs white), so the edge comes from a dark hairline; on + * dark it separates itself (14.8:1), so the edge is transparent. */ + --brand-yellow: #fff676; + --color-accent-fill: var(--brand-yellow); /* the swatch — true lemon, both themes */ + --color-on-accent: #000000; /* dark ink on the swatch — 18.7:1, never yellow text */ + --color-accent-edge: transparent; /* dark default; light → dark hairline (theme fg) */ + --color-accent-ink: var(--brand-yellow); /* thin lines that can't be fills; light → neutral fg */ + --color-accent: var(--color-accent-ink); /* back-compat alias — strokes get legible ink, never gold */ --color-ok: var(--vscode-testing-iconPassed); --color-fail: var(--vscode-errorForeground); --color-run: var(--vscode-progressBar-background); @@ -44,7 +53,6 @@ --border-radius: 4px; --border-radius-round: 1000px; --border-color: var(--vscode-widget-border, var(--vscode-panel-border)); - --border-color-hero: color-mix(in srgb, var(--color-accent) 45%, var(--vscode-panel-border)); /* background */ --bg-box: var(--vscode-editorWidget-background, transparent); diff --git a/packages/extension/media/ui/atoms/button.ts b/packages/extension/media/ui/atoms/button.ts index 238f36ba..ce5173b4 100644 --- a/packages/extension/media/ui/atoms/button.ts +++ b/packages/extension/media/ui/atoms/button.ts @@ -11,10 +11,12 @@ defineStyle( border: var(--border-width) solid var(--border-color); border-radius: var(--border-radius); padding: var(--space-xs) var(--space-sm); cursor: pointer; display: inline-flex; align-items: center; gap: var(--space-xs); } - .btn:hover:not(:disabled) { border-color: var(--color-accent); - background: color-mix(in srgb, var(--color-accent) 8%, transparent); } - .btn:active:not(:disabled) { background: color-mix(in srgb, var(--color-accent) 16%, transparent); } - .btn:focus-visible { outline: 1px solid var(--vscode-focusBorder, var(--color-accent)); outline-offset: 1px; } + /* Hover/active use the ink role (lemon on dark, neutral fg on light) — a + hover border can't be a fill, and the raw lemon would vanish on light. */ + .btn:hover:not(:disabled) { border-color: var(--color-accent-ink); + background: color-mix(in srgb, var(--color-accent-ink) 8%, transparent); } + .btn:active:not(:disabled) { background: color-mix(in srgb, var(--color-accent-ink) 16%, transparent); } + .btn:focus-visible { outline: 1px solid var(--vscode-focusBorder, var(--color-accent-ink)); outline-offset: 1px; } .btn:disabled { opacity: 0.4; cursor: default; } @media (prefers-reduced-motion: no-preference) { .btn { transition: border-color 0.15s ease-out, background 0.15s ease-out; } diff --git a/packages/extension/media/ui/brand_accent.ts b/packages/extension/media/ui/brand_accent.ts index 3d998a99..410391e0 100644 --- a/packages/extension/media/ui/brand_accent.ts +++ b/packages/extension/media/ui/brand_accent.ts @@ -1,21 +1,28 @@ // 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). +// sings on dark themes (14.8:1 vs the editor bg) and vanishes on light ones +// (1.1:1 vs white). The rule that resolves this: yellow is a FILL, never an +// ink. So each webview computes the DEPLOYED accent from the active theme at +// boot and ships the brand lemon EXACTLY on every theme as a fill — what +// changes per-theme is the swatch's EDGE: +// • dark → the lemon separates itself; the edge is transparent. +// • light → the lemon can't define its own edge, so a thin dark hairline +// (the theme's own foreground) draws it. A dimmed-down yellow +// can't do this job: it clears the page but not the lemon fill it +// bounds (~2.7:1), so the edge reads mushy. The theme foreground +// is guaranteed dark (it's readable body text on a light bg), so +// it clears both the page and the fill by a wide margin. +// For the few thin lines that genuinely can't be fills (sparkline stroke), an +// `ink` role carries the lemon on dark and a neutral legible foreground on +// light. --color-on-accent is black — yellow is never text. // // 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). +// applier — sets the tokens 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 +const DARK_FALLBACK_FG = "#1e1e1e"; // used when the theme foreground can't be read type RGB = [number, number, number]; // 0..1 @@ -42,47 +49,9 @@ const toHex = (rgb: RGB): string => .join("") .toUpperCase(); -// -- OKLCH (Björn Ottosson's OKLab) ----------------------------------------- +// -- WCAG contrast ----------------------------------------------------------- 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); @@ -96,40 +65,37 @@ export function contrast(a: RGB, b: RGB): number { // -- 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. */ + /** The swatch fill — true brand lemon on every theme. */ + fill: string; + /** Text/icons on the fill — dark, contrast-picked (~19:1). Yellow is never ink. */ onAccent: string; - /** True when the LINE accent shipped as the unmodified brand hex (dark themes). */ - brandExact: boolean; + /** The swatch edge — a dark hairline on light themes (the fill can't define its + * own edge at 1.1:1 vs white); transparent on dark (the fill separates itself). */ + edge: string; + /** Thin lines that genuinely can't be fills (sparkline stroke): the brand lemon + * on dark, a neutral legible foreground ink on light. */ + ink: string; + /** True on light themes — the fill needs a drawn edge. */ + isLight: boolean; } -export function solveBrandAccent(background: string): BrandAccent { - const bg = parseColor(background) ?? parseColor("#1e1e1e")!; +/** Resolve the brand accent for a theme. `foreground` is the theme's own text + * color (--vscode-foreground); on light themes it becomes the swatch edge and + * the thin-line ink. */ +export function solveBrandAccent(background: string, foreground?: string): BrandAccent { + const bg = parseColor(background) ?? parseColor(DARK_FALLBACK_FG)!; const brand = parseColor(BRAND_HEX)!; const onAccent = contrast([0, 0, 0], brand) >= contrast([1, 1, 1], brand) ? "#000000" : "#FFFFFF"; + const isLight = relativeLuminance(bg) > 0.5; - 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; + if (!isLight) { + // Dark theme: the lemon sings on its own — no drawn edge, lemon ink. + return { fill: BRAND_HEX, onAccent, edge: "transparent", ink: BRAND_HEX, isLight }; } - const rgb = oklchToSrgbClamped({ L: lo, C, h }); - return { accent: toHex(rgb), accentFill: BRAND_HEX, onAccent, brandExact: false }; + // Light theme: the edge/ink is the theme's own foreground — guaranteed dark, + // so it clears both the page and the lemon fill it bounds. + const fg = toHex(parseColor(foreground ?? "") ?? parseColor(DARK_FALLBACK_FG)!); + return { fill: BRAND_HEX, onAccent, edge: fg, ink: fg, isLight }; } // -- DOM applier ------------------------------------------------------------- @@ -138,11 +104,16 @@ export function solveBrandAccent(background: string): BrandAccent { * 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); + const cs = getComputedStyle(document.body); + const bg = cs.getPropertyValue("--vscode-editor-background"); + const fg = cs.getPropertyValue("--vscode-foreground"); + const { fill, onAccent, edge, ink } = solveBrandAccent(bg, fg); + const root = document.documentElement.style; + root.setProperty("--color-accent-fill", fill); + root.setProperty("--color-on-accent", onAccent); + root.setProperty("--color-accent-edge", edge); + root.setProperty("--color-accent-ink", ink); + root.setProperty("--color-accent", ink); // back-compat alias — strokes get legible ink }; apply(); new MutationObserver(apply).observe(document.body, { attributes: true }); diff --git a/packages/extension/media/ui/components/catalogcard.ts b/packages/extension/media/ui/components/catalogcard.ts index e6d15be8..057ea628 100644 --- a/packages/extension/media/ui/components/catalogcard.ts +++ b/packages/extension/media/ui/components/catalogcard.ts @@ -37,7 +37,7 @@ defineStyle( .catalogcard .cc-kv .v { font-family: var(--text-mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .catalogcard .proposed { border-bottom: 1px dashed var(--color-dim); opacity: 0.75; } - .catalogcard .metric.proposed { border-style: dashed; border-bottom: var(--border-width) dashed var(--border-color-hero); opacity: 0.8; } + .catalogcard .metric.proposed { border-style: dashed; border-bottom: var(--border-width) dashed var(--color-accent-ink); opacity: 0.8; } .catalogcard .cc-metrics { display: flex; flex-wrap: wrap; gap: var(--space-sm); } .catalogcard .cc-metrics .metric { flex: 1 1 120px; min-width: 0; } .catalogcard .cc-siblings { display: flex; gap: var(--space-sm); overflow-x: auto; diff --git a/packages/extension/media/ui/components/metric.ts b/packages/extension/media/ui/components/metric.ts index f2efae30..f1535fd2 100644 --- a/packages/extension/media/ui/components/metric.ts +++ b/packages/extension/media/ui/components/metric.ts @@ -13,13 +13,20 @@ defineStyle( .metric .v { font-family: var(--text-mono); font-size: var(--text-value); } /* counter = a compact integer (iteration): no growth, tight. */ .metric-counter { flex: 0 0 auto; } - /* hero = the number that matters: accent border, larger value. The card - lights up (faint accent wash) once its number lands. */ - .metric-hero { border-color: var(--border-color-hero); } + /* hero = the number that matters: larger value. (The old accent border + + 7% wash washed out on light themes, so the brand moment moved to the + opt-in flag below.) */ .metric-hero .v { font-size: var(--text-hero); font-weight: 600; font-variant-numeric: tabular-nums; } - .metric-hero:not(.metric-pending) { - background: color-mix(in srgb, var(--color-accent) 7%, var(--bg-box)); } + /* flag = the singular brand hero (the Run Inspector's fidelity): a solid + lemon square appears on the label once the value lands. It's a FILL with a + 1px edge — a dark hairline on light, transparent on dark — so the true + brand lemon reads on both themes. Yellow is never used as text here. */ + .metric-flagged .label-k { display: inline-flex; align-items: center; gap: var(--space-xs); } + .metric-flagged:not(.metric-pending) .label-k::before { + content: ""; flex: 0 0 auto; width: var(--square-dot); height: var(--square-dot); + border-radius: 2px; background: var(--color-accent-fill); + border: var(--border-width) solid var(--color-accent-edge); } /* pending = no number yet (the "–" placeholder): the value recedes so empty cards don't compete with live ones; full ink returns with the first value. */ .metric-pending .v { color: var(--color-dim); font-weight: 400; } @@ -35,9 +42,9 @@ export interface Metric { clear(): void; } -export function metric(labelText: string, opts: { variant?: MetricVariant } = {}): Metric { +export function metric(labelText: string, opts: { variant?: MetricVariant; flag?: boolean } = {}): Metric { const el = document.createElement("div"); - el.className = `metric metric-${opts.variant ?? "small"} metric-pending`; + el.className = `metric metric-${opts.variant ?? "small"} metric-pending${opts.flag ? " metric-flagged" : ""}`; const l = text("label-k", labelText); const v = text("v", "–"); el.append(l.el, v.el); diff --git a/packages/extension/media/ui/components/pulseplot.ts b/packages/extension/media/ui/components/pulseplot.ts index a4148693..5b97b7f0 100644 --- a/packages/extension/media/ui/components/pulseplot.ts +++ b/packages/extension/media/ui/components/pulseplot.ts @@ -57,7 +57,7 @@ defineStyle( .pulseplot .pp-plot { position: relative; flex: 1; min-height: 0; } .pulseplot .pp-plot > svg { width: 100%; height: 100%; min-height: 0; display: block; } .pulseplot .pp-key:focus-visible, - .pulseplot .pp-plot:focus-visible { outline: 1px solid var(--vscode-focusBorder, var(--color-accent)); + .pulseplot .pp-plot:focus-visible { outline: 1px solid var(--vscode-focusBorder, var(--color-accent-ink)); outline-offset: 1px; } /* Hover/keyboard readout — a crosshair snaps to the nearest knot; the readout lists EVERY drive at that time (the pointer never has to hit a 2px line). */ diff --git a/packages/extension/media/ui/components/sparkline.ts b/packages/extension/media/ui/components/sparkline.ts index 110aab90..0bda0977 100644 --- a/packages/extension/media/ui/components/sparkline.ts +++ b/packages/extension/media/ui/components/sparkline.ts @@ -47,7 +47,7 @@ export function sparkline(capacity = 60): Sparkline { svg.classList.add("sparkline"); const poly = document.createElementNS(SVGNS, "polyline"); poly.setAttribute("fill", "none"); - poly.setAttribute("stroke", "var(--color-accent)"); + poly.setAttribute("stroke", "var(--color-accent-ink)"); poly.setAttribute("stroke-width", "1.5"); svg.append(poly); diff --git a/packages/extension/media/ui/views/inspector.ts b/packages/extension/media/ui/views/inspector.ts index bcb45c8b..e911add7 100644 --- a/packages/extension/media/ui/views/inspector.ts +++ b/packages/extension/media/ui/views/inspector.ts @@ -90,7 +90,7 @@ function createPanel(post: (msg: unknown) => void, runId?: string): Panel { // The hero is the OUTCOME (fidelity) — the number that matters — pending until // the run finishes. The live optimization signal (objective + its convergence // trace) sits beside it, never inside it. - const fidelity = metric("fidelity", { variant: "hero" }); + const fidelity = metric("fidelity", { variant: "hero", flag: true }); const objective = metric("objective", { variant: "small" }); const iteration = metric("iteration", { variant: "counter" }); const feasibility = metric("feasibility", { variant: "small" }); diff --git a/packages/extension/package.json b/packages/extension/package.json index 77f6532a..a4ee6b09 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -248,7 +248,8 @@ "build:exemplars": "node scripts/build_exemplars.mjs", "healthcheck": "node scripts/healthcheck.mjs", "package": "pnpm --filter @amicode/amico-run build && pnpm run build && pnpm run build:exemplars && pnpm run fetch:opencode && vsce package --no-dependencies --allow-missing-repository -o amicode.vsix", - "dev:pulseplot": "esbuild dev/pulseplot_harness/main.ts --bundle --format=iife --outfile=dev/pulseplot_harness/main.js && open dev/pulseplot_harness/index.html" + "dev:pulseplot": "esbuild dev/pulseplot_harness/main.ts --bundle --format=iife --outfile=dev/pulseplot_harness/main.js && open dev/pulseplot_harness/index.html", + "dev:brand": "esbuild dev/brand_harness/main.ts --bundle --format=iife --outfile=dev/brand_harness/main.js && open dev/brand_harness/index.html" }, "devDependencies": { "@amicode/amico-run": "workspace:*", diff --git a/packages/extension/test/brand_accent.test.ts b/packages/extension/test/brand_accent.test.ts index d4916fac..759e1e55 100644 --- a/packages/extension/test/brand_accent.test.ts +++ b/packages/extension/test/brand_accent.test.ts @@ -1,46 +1,71 @@ import { describe, it, expect } from "vitest"; -import { parseColor, srgbToOklch, oklchToSrgb, contrast, solveBrandAccent } from "../media/ui/brand_accent"; +import { parseColor, relativeLuminance, 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. +// Theme-calculated Harmoniqs yellow. The rule: yellow is a FILL, never an ink. +// The lemon fill ships brand-exact on every theme; what changes per-theme is +// the EDGE — transparent on dark (the lemon separates itself), a dark hairline +// (the theme foreground) on light (the lemon can't define its own edge). A +// thin-line `ink` role carries lemon on dark / neutral foreground on light. + +const BRAND = "#FFF676"; +const C = (a: string, b: string) => contrast(parseColor(a)!, parseColor(b)!); 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)"]) { + it("the FILL is the brand lemon on every theme, with dark on-accent text (~19:1)", () => { + for (const bg of ["#1e1e1e", "#000000", "#ffffff", "#f3f3f3", "rgb(30, 30, 30)"]) { const r = solveBrandAccent(bg); - expect(r.accent).toBe("#FFF676"); - expect(r.brandExact).toBe(true); + expect(r.fill).toBe(BRAND); + expect(r.onAccent).toBe("#000000"); + expect(C(r.onAccent, r.fill)).toBeGreaterThan(4.5); // black on lemon ≈ 18.7:1 } }); - 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("dark themes draw NO edge — the lemon fill separates itself; ink stays lemon", () => { + for (const bg of ["#1e1e1e", "#000000", "rgb(30, 30, 30)"]) { + const r = solveBrandAccent(bg, "#cccccc"); + expect(r.isLight).toBe(false); + expect(r.edge).toBe("transparent"); + expect(r.ink).toBe(BRAND); + // sanity: the lemon really does clear the non-text UI target on dark + expect(C(r.fill, bg)).toBeGreaterThanOrEqual(3); + } }); - 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("light themes draw a dark hairline that clears BOTH the page and the lemon fill", () => { + // the mushy-edge failure this fixes: the old dimmed gold #A09829 cleared + // white (2.99:1) but NOT the lemon fill it bounded (2.66:1). + for (const [bg, fg] of [ + ["#ffffff", "#3b3b3b"], + ["#f3f3f3", "#616161"], + ["#faf9f4", "#1f1f1f"], + ]) { + const r = solveBrandAccent(bg, fg); + expect(r.isLight).toBe(true); + expect(r.edge).not.toBe("transparent"); + expect(C(r.edge, bg)).toBeGreaterThanOrEqual(3); // vs the page behind it + expect(C(r.edge, r.fill)).toBeGreaterThanOrEqual(3); // vs the fill it bounds } }); - 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("light themes carry a neutral, legible thin-line ink — the muddy gold is retired", () => { + const r = solveBrandAccent("#ffffff", "#3b3b3b"); + expect(r.ink).toBe(r.edge); // both are the theme foreground + expect(r.ink).not.toBe(BRAND); // never the invisible lemon on a light bg + expect(C(r.ink, "#ffffff")).toBeGreaterThanOrEqual(3); }); - it("mid-gray themes that already clear 3:1 stay brand-exact", () => { - expect(solveBrandAccent("#808080").brandExact).toBe(true); + it("light/dark is decided by background luminance (threshold 0.5)", () => { + expect(relativeLuminance(parseColor("#ffffff")!)).toBeGreaterThan(0.5); + expect(relativeLuminance(parseColor("#1e1e1e")!)).toBeLessThan(0.5); + expect(solveBrandAccent("#ffffff").isLight).toBe(true); + expect(solveBrandAccent("#1e1e1e").isLight).toBe(false); + }); + + it("falls back to a dark hairline when the theme foreground can't be read", () => { + const r = solveBrandAccent("#ffffff"); // no foreground passed + expect(r.isLight).toBe(true); + expect(C(r.edge, "#ffffff")).toBeGreaterThanOrEqual(3); + expect(C(r.edge, r.fill)).toBeGreaterThanOrEqual(3); }); it("parses the color formats getComputedStyle actually returns", () => { @@ -50,11 +75,6 @@ describe("solveBrandAccent — the theme-calculated Harmoniqs yellow", () => { 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)); + expect(() => solveBrandAccent("#ffffff", "not-a-color")).not.toThrow(); }); });