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();
});
});