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