diff --git a/docs/adr/016-host-portable-ui-affordances.md b/docs/adr/016-host-portable-ui-affordances.md index c49939a..3d00789 100644 --- a/docs/adr/016-host-portable-ui-affordances.md +++ b/docs/adr/016-host-portable-ui-affordances.md @@ -126,4 +126,37 @@ sharing cost is larger, and a future parity test as a panel overlay, per Rule 2 above. - [ADR 013](./013-settings-access-doctrine.md) — settings access (read side) is unaffected. This ADR governs the UI affordance that *invokes* - settings access (the Meridian Actions kebab). + settings access (see the Update below for its current form). + +## Update (2026-05-29) — branded title action supersedes the kebab + +Rule 1's *implementation* evolved. The `$(kebab-vertical)` "Meridian Actions" +quick-pick was replaced by a **dedicated, branded title-bar action**: each view +now contributes `meridian.openSettings` directly to its `view/title` with the +Meridian brand glyph (themed `media/glyph-{light,dark}.svg`) and the label +"Meridian Settings". The quick-pick indirection in `tree-setup.ts` and its +redundant in-menu Refresh entry were removed (Git and Hygiene already carry an +inline Refresh). + +This preserves Rule 1's principle — a visually distinct, non-gear glyph that +does not duplicate the host cog — and strengthens it: the affordance now also +*names its owner*, so on Cursor, where the host cog mis-resolves to Cursor's +top-level settings, the Meridian-labeled action is unmistakably the correct +target. + +Residual, unchanged: the host-synthesized cog cannot be suppressed and still +mis-routes on Cursor. The branded action mitigates but does not eliminate a +muscle-memory click on the host cog; that remains a host defect to report +upstream. + +Themed glyph note: contributed command icons given as file paths are *not* +auto-tinted by the host (unlike the activity-bar container icon, which is +rendered as a mask — that is why the bare-fill `media/icon.svg` works there but +the command needs explicit `{ light, dark }` colour variants). The variants +reuse `icon.svg`'s path verbatim; its cube reads as edges because the inner +faces are wound opposite the outer silhouette (nonzero fill-rule cuts them +out), so the path and fill-rule must not be altered when re-colouring. + +Earlier references in this ADR to the "`$(kebab-vertical)`" glyph (Rule 1) and +the "Meridian Actions kebab" (Decision/Consequences) describe the superseded +implementation; the doctrine they illustrate is unchanged. diff --git a/media/glyph-dark.svg b/media/glyph-dark.svg new file mode 100644 index 0000000..807d96c --- /dev/null +++ b/media/glyph-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/media/glyph-light.svg b/media/glyph-light.svg new file mode 100644 index 0000000..a9df45b --- /dev/null +++ b/media/glyph-light.svg @@ -0,0 +1,3 @@ + + + diff --git a/package-lock.json b/package-lock.json index 5b376c4..99bed3b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "meridian", - "version": "1.8.0", + "version": "2.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meridian", - "version": "1.8.0", + "version": "2.4.0", "license": "MIT", "dependencies": { "chart.js": "^4.5.1", diff --git a/package.json b/package.json index 9cbe9aa..4371c76 100644 --- a/package.json +++ b/package.json @@ -161,26 +161,11 @@ }, { "command": "meridian.openSettings", - "title": "Open Settings", - "icon": "$(settings-gear)", - "category": "Meridian" - }, - { - "command": "meridian.reports.showActions", - "title": "Meridian: Reports Actions", - "icon": "$(kebab-vertical)", - "category": "Meridian" - }, - { - "command": "meridian.git.showActions", - "title": "Meridian: Git View Actions", - "icon": "$(kebab-vertical)", - "category": "Meridian" - }, - { - "command": "meridian.hygiene.showActions", - "title": "Meridian: Hygiene View Actions", - "icon": "$(kebab-vertical)", + "title": "Meridian Settings", + "icon": { + "light": "media/glyph-light.svg", + "dark": "media/glyph-dark.svg" + }, "category": "Meridian" } ], @@ -397,27 +382,27 @@ { "command": "meridian.git.refresh", "when": "view == meridian.git.view", - "group": "navigation" + "group": "navigation@1" }, { "command": "meridian.hygiene.refresh", "when": "view == meridian.hygiene.view", - "group": "navigation" + "group": "navigation@1" }, { - "command": "meridian.reports.showActions", + "command": "meridian.openSettings", "when": "view == meridian.reports.view", - "group": "navigation@99" + "group": "navigation@1" }, { - "command": "meridian.git.showActions", + "command": "meridian.openSettings", "when": "view == meridian.git.view", - "group": "navigation@99" + "group": "navigation@2" }, { - "command": "meridian.hygiene.showActions", + "command": "meridian.openSettings", "when": "view == meridian.hygiene.view", - "group": "navigation@99" + "group": "navigation@2" } ], "view/item/context": [ diff --git a/src/domains/hygiene/analytics-ui/script.js b/src/domains/hygiene/analytics-ui/script.js index 95a7698..6cf1c37 100644 --- a/src/domains/hygiene/analytics-ui/script.js +++ b/src/domains/hygiene/analytics-ui/script.js @@ -4,7 +4,6 @@ * Message protocol: * extension → webview: { type: "init", payload: HygieneAnalyticsReport } * webview → extension: { type: "refresh" } - * { type: "openSettings" } */ const vscode = typeof acquireVsCodeApi !== "undefined" ? acquireVsCodeApi() : null; diff --git a/src/presentation/tree-setup.ts b/src/presentation/tree-setup.ts index 1adb1db..5b5c689 100644 --- a/src/presentation/tree-setup.ts +++ b/src/presentation/tree-setup.ts @@ -73,53 +73,17 @@ export function setupTreeProviders( if (!item) return; void vscode.commands.executeCommand(reportMap[item.reportId].cmd); }), - // Explicit settings command — host-portable replacement for VS Code's - // implicit view→extension settings affordance, which Cursor doesn't honor - // (the click falls through to the unfiltered Settings page). Surfaced as - // "Open Settings" in each Meridian view's "..." overflow menu (group - // 1_settings) so VS Code's native title-bar cog isn't visually duplicated; - // Cursor users get a working entry one extra click away. + // Branded "Meridian Settings" title action — host-portable replacement for + // VS Code's implicit view→extension settings cog, which Cursor doesn't honor + // (its click falls through to Cursor's top-level settings). Contributed to + // each view's title bar (package.json view/title) with the Meridian brand + // glyph + "Meridian Settings" label so it reads as distinct from the host + // cog rather than duplicating it, and filters Settings to this extension. + // See ADR 016. vscode.commands.registerCommand("meridian.openSettings", () => { void vscode.commands.executeCommand("workbench.action.openSettings", "@ext:scscodes.meridian"); }), - // Meridian Actions kebab — one per view. Distinct $(kebab-vertical) glyph - // (not gear) so it doesn't visually duplicate the host-synthesized settings - // cog on either VS Code or Cursor. See ADR 016. - vscode.commands.registerCommand("meridian.reports.showActions", - () => showActionsQuickPick("reports")), - vscode.commands.registerCommand("meridian.git.showActions", - () => showActionsQuickPick("git")), - vscode.commands.registerCommand("meridian.hygiene.showActions", - () => showActionsQuickPick("hygiene")), ); return { gitTree, hygieneTree, reportsTree }; } - -type ViewKey = "reports" | "git" | "hygiene"; - -interface ActionItem extends vscode.QuickPickItem { - command: string; -} - -const VIEW_ACTIONS: Record = { - reports: [ - { label: "$(gear) Open Settings", command: "meridian.openSettings" }, - ], - git: [ - { label: "$(refresh) Refresh Git View", command: "meridian.git.refresh" }, - { label: "$(gear) Open Settings", command: "meridian.openSettings" }, - ], - hygiene: [ - { label: "$(refresh) Refresh Hygiene View", command: "meridian.hygiene.refresh" }, - { label: "$(gear) Open Settings", command: "meridian.openSettings" }, - ], -}; - -/** Exported for unit testing; not intended as a public surface. */ -export async function showActionsQuickPick(viewKey: ViewKey): Promise { - const pick = await vscode.window.showQuickPick(VIEW_ACTIONS[viewKey], { - placeHolder: `Meridian — ${viewKey} actions`, - }); - if (pick) await vscode.commands.executeCommand(pick.command); -} diff --git a/tests/manifest.test.ts b/tests/manifest.test.ts index fc8e25d..cf80262 100644 --- a/tests/manifest.test.ts +++ b/tests/manifest.test.ts @@ -38,9 +38,6 @@ const INFRASTRUCTURE_COMMANDS = new Set([ "meridian.reports.open", "meridian.reports.refresh", "meridian.openSettings", - "meridian.reports.showActions", - "meridian.git.showActions", - "meridian.hygiene.showActions", ]); // ── Load package.json ───────────────────────────────────────────────────────── @@ -49,7 +46,7 @@ const pkg = JSON.parse( fs.readFileSync(path.resolve(__dirname, "../package.json"), "utf-8") ); -const manifestCommands = pkg.contributes.commands as { command: string; title: string; icon?: string }[]; +const manifestCommands = pkg.contributes.commands as { command: string; title: string; icon?: string | { light: string; dark: string } }[]; const manifestCommandIds = new Set(manifestCommands.map(c => c.command)); const manifestCommandMap = new Map(manifestCommands.map(c => [c.command, c])); @@ -131,6 +128,15 @@ describe("Manifest — VS Code commands surface", () => { expect(unexpected, `Infrastructure commands (view lifecycle) must not be in commandPalette:\n ${unexpected.join("\n ")}`).toEqual([]); }); + it("meridian.openSettings is wired into view/title for all three views", () => { + const viewTitle = pkg.contributes.menus["view/title"] as { command: string; when: string }[]; + const views = ["meridian.reports.view", "meridian.git.view", "meridian.hygiene.view"]; + const missing = views.filter( + v => !viewTitle.some(e => e.command === "meridian.openSettings" && e.when.includes(v)) + ); + expect(missing, `openSettings missing from view/title for: ${missing.join(", ")}`).toEqual([]); + }); + }); // ── Suite B: settings surface ───────────────────────────────────────────────── diff --git a/tests/viewActions.test.ts b/tests/viewActions.test.ts deleted file mode 100644 index eb6f907..0000000 --- a/tests/viewActions.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const { showQuickPickMock, executeCommandMock } = vi.hoisted(() => ({ - showQuickPickMock: vi.fn(), - executeCommandMock: vi.fn(), -})); - -vi.mock("vscode", () => ({ - window: { showQuickPick: showQuickPickMock }, - commands: { executeCommand: executeCommandMock, registerCommand: vi.fn() }, - TreeItem: class { constructor(public label: string, public collapsibleState?: number) {} }, - TreeItemCollapsibleState: { None: 0, Collapsed: 1, Expanded: 2 }, - ThemeIcon: class { constructor(public id: string) {} }, - EventEmitter: class { event = vi.fn(); fire = vi.fn(); }, - Uri: { file: (p: string) => ({ fsPath: p }) }, - Range: class { constructor() {} }, -})); - -import { showActionsQuickPick } from "../src/presentation/tree-setup"; - -describe("showActionsQuickPick", () => { - beforeEach(() => { - showQuickPickMock.mockReset(); - executeCommandMock.mockReset(); - }); - - it("git view offers Refresh + Open Settings", async () => { - showQuickPickMock.mockResolvedValue(undefined); - await showActionsQuickPick("git"); - const items = showQuickPickMock.mock.calls[0]![0] as { command: string }[]; - expect(items.map(i => i.command)).toEqual([ - "meridian.git.refresh", - "meridian.openSettings", - ]); - }); - - it("hygiene view offers Refresh + Open Settings", async () => { - showQuickPickMock.mockResolvedValue(undefined); - await showActionsQuickPick("hygiene"); - const items = showQuickPickMock.mock.calls[0]![0] as { command: string }[]; - expect(items.map(i => i.command)).toEqual([ - "meridian.hygiene.refresh", - "meridian.openSettings", - ]); - }); - - it("reports view offers only Open Settings", async () => { - showQuickPickMock.mockResolvedValue(undefined); - await showActionsQuickPick("reports"); - const items = showQuickPickMock.mock.calls[0]![0] as { command: string }[]; - expect(items.map(i => i.command)).toEqual(["meridian.openSettings"]); - }); - - it("selecting an item executes its underlying command", async () => { - showQuickPickMock.mockResolvedValue({ - label: "$(refresh) Refresh Git View", - command: "meridian.git.refresh", - }); - await showActionsQuickPick("git"); - expect(executeCommandMock).toHaveBeenCalledWith("meridian.git.refresh"); - }); - - it("dismissing the quick-pick is a no-op", async () => { - showQuickPickMock.mockResolvedValue(undefined); - await showActionsQuickPick("git"); - expect(executeCommandMock).not.toHaveBeenCalled(); - }); -});