diff --git a/frontend/src/styles/agent-mark.test.ts b/frontend/src/styles/agent-mark.test.ts new file mode 100644 index 0000000..c01949e --- /dev/null +++ b/frontend/src/styles/agent-mark.test.ts @@ -0,0 +1,87 @@ +/** + * Guards the Agent marks on the environment overview. + * + * The overview shows the mark on a transparent box, so unlike every other place + * a mark appears there is no chip tint and no border separating it from the + * card -- the glyph itself is the only thing carrying contrast. That makes two + * things load-bearing, neither of which any other gate covers: + * + * - the box stays transparent, and + * - --agent-mark-fg is full black on light and full white on dark. + * + * jsdom does not resolve var(), so getComputedStyle cannot see any of this; the + * assertions read the stylesheets as text instead. The CLI and desktop rules are + * checked together because they must agree: a row styled one way and its + * neighbour the other would read as two different kinds of object. + */ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +/** Comments carry `--token` names and `:` in prose, so they must go first. */ +function sheet(name: string): string { + const css = readFileSync(fileURLToPath(new URL(name, import.meta.url)), "utf8"); + return css.replace(/\/\*[\s\S]*?\*\//g, " "); +} + +/** + * The declarations of the last block whose prelude, with whitespace collapsed, + * is exactly `selector`. Last rather than first because a later block at equal + * specificity is what actually applies. + */ +function declarations(css: string, selector: string): Map { + const want = selector.replace(/\s+/g, " ").trim(); + const found = new Map(); + let seen = false; + for (const block of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) { + if (block[1].replace(/\s+/g, " ").trim() !== want) continue; + seen = true; + found.clear(); + for (const part of block[2].split(";")) { + const split = part.indexOf(":"); + if (split < 0) continue; + found.set(part.slice(0, split).trim(), part.slice(split + 1).trim().replace(/\s+/g, " ")); + } + } + expect(seen, `${selector} should exist`).toBe(true); + return found; +} + +const OVERVIEW_MARK_BOXES = [".agent-manage-identity .agent-icon", ".desktop-app-icon"]; + +describe("overview Agent marks", () => { + it("paints the mark on a transparent box in both card types", () => { + const app = sheet("app.css"); + for (const selector of OVERVIEW_MARK_BOXES) { + const rules = declarations(app, selector); + expect(rules.get("background"), selector).toBe("transparent"); + expect(rules.get("color"), selector).toBe("var(--agent-mark-fg)"); + // Neutralised, not removed: dropping the border would hand its 2px to the + // glyph, because .agent-icon sizes the 34px box as border-box. + expect(rules.get("border") ?? rules.get("border-color"), selector).toContain("transparent"); + expect(rules.get("width"), selector).toBe("34px"); + expect(rules.get("height"), selector).toBe("34px"); + } + }); + + it("resolves the mark to black on light and white on dark", () => { + // The dark palette is declared twice in tokens.css -- once for the media + // query, once for .theme-dark -- and both copies must carry the override or + // forcing a theme would fall back to the light value. + const tokens = sheet("tokens.css"); + expect(declarations(tokens, ":root").get("--agent-mark-fg")).toBe("#000000"); + for (const selector of [":root:not(.theme-light)", ":root.theme-dark"]) { + expect(declarations(tokens, selector).get("--agent-mark-fg"), selector).toBe("#ffffff"); + } + }); + + it("keeps the tinted chip for marks outside the overview", () => { + // .agent-icon is shared with the onboarding Agent list, which does want a + // bordered chip. Only the two overview rules opt out, so a change that + // flattened the base rule instead would be caught here. + const base = declarations(sheet("app.css"), ".agent-icon, .choice-icon, .progress-icon"); + expect(base.get("background")).toBe("var(--surface-subtle)"); + expect(base.get("color")).toBe("var(--icon-fg)"); + }); +}); diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 3384dbb..77ab678 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -1089,13 +1089,19 @@ gap: 9px; } +/* The mark sits directly on the card: no chip, no tint. The border and + background from .agent-icon are neutralised rather than dropped, so the 34px + box keeps its geometry (border-box would otherwise hand the extra 2px to the + glyph). Colour comes from --agent-mark-fg, which is black or white per theme + -- on a transparent box the mark is the only thing carrying contrast, and + --icon-fg was mixed for a tinted chip. */ .agent-manage-identity .agent-icon { width: 34px; height: 34px; border-radius: 8px; - border-color: color-mix(in srgb, var(--agent-accent) 24%, var(--border)); - background: var(--agent-soft); - color: var(--agent-accent); + border-color: transparent; + background: transparent; + color: var(--agent-mark-fg); } .agent-manage-identity-copy { @@ -1336,15 +1342,18 @@ gap: 3px; } +/* Matches .agent-manage-identity .agent-icon above: transparent box, mark in + black or white. The two must stay in step or the CLI and desktop Agents in + the same overview would read as different kinds of object. */ .desktop-app-icon { width: 34px; height: 34px; display: grid; place-items: center; - border: 1px solid color-mix(in srgb, var(--agent-accent) 24%, var(--border)); + border: 1px solid transparent; border-radius: 8px; - color: var(--agent-accent); - background: var(--agent-soft); + color: var(--agent-mark-fg); + background: transparent; } .desktop-app-identity strong { font-size: 16px; line-height: 1.3; } diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css index 4c035db..49e523a 100644 --- a/frontend/src/styles/tokens.css +++ b/frontend/src/styles/tokens.css @@ -16,6 +16,11 @@ --border-faint: rgba(60, 60, 67, 0.08); --icon-border: rgba(60, 60, 67, 0.12); --icon-fg: #3a3a3c; + /* Full black/white rather than --icon-fg, and only for the Agent marks on the + overview. They sit on a transparent box there, so nothing tints them and + nothing separates them from the card -- the mark itself has to carry the + contrast. Elsewhere the marks stay on --icon-fg inside a bordered chip. */ + --agent-mark-fg: #000000; --footer-bg: rgba(250, 250, 251, 0.96); --overlay-panel: rgba(255, 255, 255, 0.7); --blue: #007aff; @@ -64,6 +69,7 @@ --border-faint: rgba(255, 255, 255, 0.08); --icon-border: rgba(255, 255, 255, 0.12); --icon-fg: #d1d1d6; + --agent-mark-fg: #ffffff; --footer-bg: rgba(28, 28, 30, 0.96); --overlay-panel: rgba(0, 0, 0, 0.25); --blue: #0a84ff; @@ -96,6 +102,7 @@ --border-faint: rgba(255, 255, 255, 0.08); --icon-border: rgba(255, 255, 255, 0.12); --icon-fg: #d1d1d6; + --agent-mark-fg: #ffffff; --footer-bg: rgba(28, 28, 30, 0.96); --overlay-panel: rgba(0, 0, 0, 0.25); --blue: #0a84ff;