diff --git a/packages/extension/esbuild.config.mjs b/packages/extension/esbuild.config.mjs index d336deef..435af26a 100644 --- a/packages/extension/esbuild.config.mjs +++ b/packages/extension/esbuild.config.mjs @@ -43,6 +43,7 @@ const targets = [ sourcemap: true, minify: false, logLevel: "info", + loader: { ".svg": "text" }, }, // bottom-panel Run Inspector webview bundle { @@ -55,6 +56,7 @@ const targets = [ sourcemap: true, minify: false, logLevel: "info", + loader: { ".svg": "text" }, }, ]; diff --git a/packages/extension/media/amico-tab-dark.svg b/packages/extension/media/amico-tab-dark.svg new file mode 100644 index 00000000..25f716f7 --- /dev/null +++ b/packages/extension/media/amico-tab-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/extension/media/amico-tab-light.svg b/packages/extension/media/amico-tab-light.svg new file mode 100644 index 00000000..ec08e9f0 --- /dev/null +++ b/packages/extension/media/amico-tab-light.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/extension/media/amico.svg b/packages/extension/media/amico.svg index f61916dd..4aa29774 100644 --- a/packages/extension/media/amico.svg +++ b/packages/extension/media/amico.svg @@ -1,19 +1,22 @@ - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/extension/media/amico_reduced.svg b/packages/extension/media/amico_reduced.svg new file mode 100644 index 00000000..d25562e3 --- /dev/null +++ b/packages/extension/media/amico_reduced.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/extension/media/ui/atoms/icon.ts b/packages/extension/media/ui/atoms/icon.ts deleted file mode 100644 index 8d1fa808..00000000 --- a/packages/extension/media/ui/atoms/icon.ts +++ /dev/null @@ -1,36 +0,0 @@ -// Icon atoms. The mark is the Harmoniqs H-robot SILHOUETTE — same glyph + -// animation language as the fork's AmicoSpinner (spinner.tsx): the screen slit -// is knocked out via fill-rule evenodd, NO face pixels (illegible at small -// sizes — the full-face Mark is for large canvases only), currentColor so it -// rides the theme, gentle pulse-opacity, static under prefers-reduced-motion. - -import { defineStyle } from "../style"; - -defineStyle( - "mark", - ` - .mark { display: inline-flex; align-items: center; } - .mark svg { width: 20px; height: 17.5px; display: block; color: var(--vscode-foreground); } - @media (prefers-reduced-motion: no-preference) { - .mark svg { animation: mark-pulse 1.2s ease-in-out infinite both; } - } - @keyframes mark-pulse { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; } } -`, -); - -const SVG_NS = "http://www.w3.org/2000/svg"; - -export function mark(): HTMLSpanElement { - const el = document.createElement("span"); - el.className = "mark"; - const svg = document.createElementNS(SVG_NS, "svg"); - svg.setAttribute("viewBox", "0 0 64 56"); - svg.setAttribute("fill", "currentColor"); - svg.setAttribute("aria-label", "Amicode"); - const body = document.createElementNS(SVG_NS, "path"); - body.setAttribute("fill-rule", "evenodd"); - body.setAttribute("d", "M2 2h16v14h28V2h16v52H46V40H18v14H2Z M9 19h46v18H9Z"); - svg.append(body); - el.append(svg); - return el; -} diff --git a/packages/extension/media/ui/atoms/logo.ts b/packages/extension/media/ui/atoms/logo.ts new file mode 100644 index 00000000..656519ab --- /dev/null +++ b/packages/extension/media/ui/atoms/logo.ts @@ -0,0 +1,55 @@ +// Logo atom — the Amico mark, fed by the two canonical SVG files. +// +// variant "full" (default) → media/amico.svg (detailed mark) +// variant "reduced" → media/amico_reduced.svg (outer bracket only, +// legible at small sizes) +// +// Both files are imported as raw text (esbuild loader {".svg":"text"}, see +// esbuild.config.mjs + the vitest transform) and carry fill="currentColor" on +// their root — so the mark's color is driven by the host element's CSS +// `color`, which defaults to `var(--vscode-foreground)` and therefore tracks +// the active VS Code theme. Pass `fill` to override (any CSS color, e.g. a +// brand token or another --vscode-* variable). +// +// The .svg files are the single source of truth for the geometry — no path +// data is duplicated as a TS literal (which is exactly what silently drifted +// before: a hardcoded copy here went stale when amico.svg was redesigned). + +import { defineStyle } from "../style"; +import fullSvg from "../../amico.svg"; +import reducedSvg from "../../amico_reduced.svg"; + +defineStyle( + "logo", + ` + .logo { display: inline-flex; align-items: center; color: var(--vscode-foreground); } + .logo svg { width: 18px; height: 18px; display: block; } +`, +); + +export type LogoVariant = "full" | "reduced"; + +export interface LogoOptions { + /** Which mark to render. Defaults to "full" (amico.svg). */ + variant?: LogoVariant; + /** + * CSS color for the mark. Omit to inherit `var(--vscode-foreground)` (tracks + * the VS Code theme); pass any CSS color to override. + */ + fill?: string; +} + +// Strip a leading declaration — amico.svg (exported from a design +// tool) carries one; injecting it into innerHTML leaves a stray bogus-comment +// node. amico_reduced.svg has none, so this is a no-op there. +const clean = (svg: string) => svg.replace(/^\s*<\?xml[^>]*\?>\s*/, ""); + +const SVG: Record = { full: clean(fullSvg), reduced: clean(reducedSvg) }; + +export function logo(opts: LogoOptions = {}): HTMLSpanElement { + const el = document.createElement("span"); + el.className = "logo"; + el.innerHTML = SVG[opts.variant ?? "full"]; // static, bundle-time-embedded markup — no user data + if (opts.fill) el.style.color = opts.fill; // else CSS default: var(--vscode-foreground) + return el; +} diff --git a/packages/extension/media/ui/svg.d.ts b/packages/extension/media/ui/svg.d.ts new file mode 100644 index 00000000..cc1b508e --- /dev/null +++ b/packages/extension/media/ui/svg.d.ts @@ -0,0 +1,7 @@ +// Raw SVG text imports (esbuild loader: {".svg": "text"}, see esbuild.config.mjs) +// — lets icon components inject a canonical .svg file's markup directly +// instead of hand-duplicating its path data as a separate TS literal. +declare module "*.svg" { + const content: string; + export default content; +} diff --git a/packages/extension/media/ui/views/inspector.ts b/packages/extension/media/ui/views/inspector.ts index 52cf193c..a2cd2be1 100644 --- a/packages/extension/media/ui/views/inspector.ts +++ b/packages/extension/media/ui/views/inspector.ts @@ -11,7 +11,7 @@ // reshape (freeze 2: the runId-keyed protocol, not the DOM). import { defineStyle } from "../style"; -import { mark } from "../atoms/icon"; +import { logo } from "../atoms/logo"; import { pill } from "../atoms/pill"; import { text } from "../atoms/text"; import { button } from "../atoms/button"; @@ -73,7 +73,7 @@ function createPanel(post: (msg: unknown) => void, runId?: string): Panel { const brand = document.createElement("div"); brand.className = "row gap-sm brand"; - brand.append(mark(), text("", "Run Inspector").el); + brand.append(logo({ variant: "reduced" }), text("", "Run Inspector").el); const topbar = document.createElement("div"); topbar.className = "row wrap"; diff --git a/packages/extension/package.json b/packages/extension/package.json index 723b8678..49650edf 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -30,14 +30,14 @@ { "id": "amicode", "title": "Amicode", - "icon": "media/amico.svg" + "icon": "media/amico_reduced.svg" } ], "panel": [ { "id": "amicode-panel", "title": "Amicode", - "icon": "media/amico.svg" + "icon": "media/amico_reduced.svg" } ] }, diff --git a/packages/extension/src/chat_panel.ts b/packages/extension/src/chat_panel.ts index 7f043f07..b6a7af0a 100644 --- a/packages/extension/src/chat_panel.ts +++ b/packages/extension/src/chat_panel.ts @@ -31,6 +31,26 @@ function themeKindToScheme(kind: vscode.ColorThemeKind): "light" | "dark" { return kind === vscode.ColorThemeKind.Light || kind === vscode.ColorThemeKind.HighContrastLight ? "light" : "dark"; } +// Theme-adaptive tab icon for the chat WebviewPanel. The `logo` atom +// (media/ui/atoms/logo.ts) themes via fill="currentColor", but that only +// resolves inside a live webview DOM — a native tab icon is a static image +// with no DOM, so currentColor renders dark (the bug we hit). VS Code's only +// theme-adaptive path for a native icon is a literal {light, dark} URI pair, +// and — verified empirically via a probe — the files MUST live inside the +// extension folder (an icon in globalStorageUri renders as nothing). Since a +// shipped .vsix's extension folder is read-only, the two colored files can't +// be generated at runtime; they're committed under media/, derived from +// amico_reduced.svg (small context → reduced) with currentColor swapped for a +// theme-appropriate foreground gray. A unit test keeps them in sync with the +// source geometry. `light` is shown on light themes (dark mark), `dark` on +// dark themes (light mark). +function tabIconPath(ctx: vscode.ExtensionContext): { light: vscode.Uri; dark: vscode.Uri } { + return { + light: vscode.Uri.joinPath(ctx.extensionUri, "media", "amico-tab-light.svg"), + dark: vscode.Uri.joinPath(ctx.extensionUri, "media", "amico-tab-dark.svg"), + }; +} + export class ChatPanel { private static current?: ChatPanel; private readonly disposables: vscode.Disposable[] = []; @@ -155,7 +175,7 @@ export class ChatPanel { // itself — we only host one extension-local asset (the loading splash). localResourceRoots: [vscode.Uri.joinPath(ctx.extensionUri, "media")], }); - panel.iconPath = vscode.Uri.joinPath(ctx.extensionUri, "media", "amico.svg"); + panel.iconPath = tabIconPath(ctx); ChatPanel.current = new ChatPanel(panel, opencodeUrl); return ChatPanel.current; } diff --git a/packages/extension/test/chat_panel_icon.test.ts b/packages/extension/test/chat_panel_icon.test.ts new file mode 100644 index 00000000..434d9bd3 --- /dev/null +++ b/packages/extension/test/chat_panel_icon.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +// The chat WebviewPanel's native tab icon can't use the logo atom's +// fill="currentColor" theming (a native tab icon is a static image with no DOM +// to resolve currentColor against — it renders dark). VS Code's only +// theme-adaptive path is a committed {light, dark} pair inside the extension +// folder (an icon in globalStorageUri renders as nothing — verified via probe). +// These two files are DERIVED from media/amico_reduced.svg with the fill +// swapped; this test keeps them in lockstep with the source so a future edit +// to the mark can't silently leave the tab icons stale. + +const mediaDir = join(__dirname, "..", "media"); +const read = (f: string) => readFileSync(join(mediaDir, f), "utf8"); +const pathD = (svg: string) => svg.match(/]*\bd="([^"]+)"/)?.[1]; + +describe("chat tab icons — committed light/dark pair, in sync with amico_reduced.svg", () => { + const source = read("amico_reduced.svg"); + const light = read("amico-tab-light.svg"); + const dark = read("amico-tab-dark.svg"); + + it("carry theme-foreground fills, no leftover currentColor", () => { + expect(light).toContain('fill="#424242"'); // dark mark for light themes + expect(dark).toContain('fill="#CCCCCC"'); // light mark for dark themes + expect(light).not.toContain("currentColor"); + expect(dark).not.toContain("currentColor"); + }); + + it("share the exact geometry of the source reduced mark (no drift)", () => { + const d = pathD(source); + expect(d).toBeTruthy(); + expect(pathD(light)).toBe(d); + expect(pathD(dark)).toBe(d); + }); +}); diff --git a/packages/extension/test/logo.test.ts b/packages/extension/test/logo.test.ts new file mode 100644 index 00000000..a6e60063 --- /dev/null +++ b/packages/extension/test/logo.test.ts @@ -0,0 +1,53 @@ +// @vitest-environment happy-dom +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { logo } from "../media/ui/atoms/logo"; + +// Pins the logo atom's contract: +// - two variants, each derived from its real .svg file on disk (imported as +// raw text at build time — no path data duplicated as a TS literal, the +// exact thing that silently drifted before); +// - "full" (default) carries the detailed mark's internal accents, +// "reduced" is the bare outer bracket; +// - color is theme-responsive by default (fill="currentColor" on the root, +// host color unset → inherits .logo's var(--vscode-foreground)) and +// overridable via `fill`. + +const mediaDir = join(__dirname, "..", "media"); +const outerPathD = (svgSource: string) => svgSource.match(/]*\bd="([^"]+)"/)?.[1]; + +describe("logo() atom", () => { + it("defaults to the full variant — the detailed mark from amico.svg", () => { + const svg = logo().querySelector("svg")!; + expect(svg).not.toBeNull(); + + const d = outerPathD(readFileSync(join(mediaDir, "amico.svg"), "utf8")); + expect(d).toBeTruthy(); + expect(svg.querySelector("path")?.getAttribute("d")).toBe(d); + // The full mark carries internal circuit-pattern accents (rect/polygon); + // their presence is what distinguishes it from the reduced variant. + expect(svg.querySelectorAll("rect, polygon").length).toBeGreaterThan(0); + }); + + it('variant "reduced" is the bare bracket from amico_reduced.svg — no accents', () => { + const svg = logo({ variant: "reduced" }).querySelector("svg")!; + + const d = outerPathD(readFileSync(join(mediaDir, "amico_reduced.svg"), "utf8")); + expect(d).toBeTruthy(); + expect(svg.querySelector("path")?.getAttribute("d")).toBe(d); + expect(svg.querySelectorAll("rect, polygon").length).toBe(0); + }); + + it("is theme-responsive by default: currentColor root, no inline color override", () => { + const el = logo(); + expect(el.classList.contains("logo")).toBe(true); // .logo sets color: var(--vscode-foreground) + expect(el.style.color).toBe(""); // nothing hardcoded — inherits the theme var + expect(el.querySelector("svg")?.getAttribute("fill")).toBe("currentColor"); + }); + + it("fill overrides the host color that drives the mark", () => { + const el = logo({ fill: "var(--vscode-button-foreground)" }); + expect(el.style.color).toBe("var(--vscode-button-foreground)"); + }); +}); diff --git a/packages/extension/vitest.config.ts b/packages/extension/vitest.config.ts index 96b1b7f4..2dc1e206 100644 --- a/packages/extension/vitest.config.ts +++ b/packages/extension/vitest.config.ts @@ -1,5 +1,6 @@ import { defineConfig } from "vitest/config"; import path from "node:path"; +import fs from "node:fs"; // Alias the `vscode` module to a minimal stub so node-side modules that import it // (file_watcher.ts, etc.) can be unit-tested without the VS Code host. Only kicks @@ -8,4 +9,17 @@ export default defineConfig({ resolve: { alias: { vscode: path.resolve(process.cwd(), "test/__mocks__/vscode.ts") }, }, + plugins: [ + // Match esbuild.config.mjs's `loader: {".svg": "text"}`: Vite's own default + // .svg handling returns a URL string, not raw markup, which would make + // icon.ts's canonical-file import silently no-op under test (a broken + // string assigned to innerHTML) while still passing under the real build. + { + name: "svg-as-raw-text", + transform(_code: string, id: string) { + if (!id.endsWith(".svg")) return undefined; + return `export default ${JSON.stringify(fs.readFileSync(id, "utf8"))};`; + }, + }, + ], });