diff --git a/docs/herdr-insights-development.md b/docs/herdr-insights-development.md index 1fdda10..55e6fd4 100644 --- a/docs/herdr-insights-development.md +++ b/docs/herdr-insights-development.md @@ -69,8 +69,10 @@ dashboard. The installer preflights the mod tree, checks a temporary buffered co before replacing live files, generates the dashboard's local Node path, and records ownership in `install-manifest.json`. Install/uninstall share an exclusive lock; interrupted operations require confirming the process stopped before removing it. -The toolbar is a personal layout clone wrapping Herdr's original `tabs` block; -no built-in mod source is cloned, patched, or vendored. See +The toolbar is a personal layout clone wrapping the tab-bar block (Herdr's `tabs` +or a personal clone of it); no built-in mod source is cloned, patched, or +vendored. Windows and Linux place the button before the bar so the window +buttons the bar draws stay last; macOS places it after. See [INSTALL.md](../herdr-insights/INSTALL.md) for the clone, click-state, and ownership rules. A clone does not inherit future structural changes to its source layout. A normal install has one `install-manifest.json` and the dashboard diff --git a/herdr-insights/INSTALL.md b/herdr-insights/INSTALL.md index f50d68b..da546aa 100644 --- a/herdr-insights/INSTALL.md +++ b/herdr-insights/INSTALL.md @@ -14,9 +14,15 @@ npm run install:local --prefix herdr-insights The installer checks `mods/insights` before copying it to the app home, which defaults to `$HERDR_APP_HOME` or `~/.config/herdr-app`. First install creates and -selects a personal layout whose **Insights** button sits beside the native tabs. -Reinstall updates owned files without rewriting layouts. Shipped tabs, keys, and -usage data are preserved. +selects a personal layout whose **Insights** button sits in the tab bar row. +The tab bar may be Herdr's `tabs` or a personal clone of it (`tabs-graph`, ...); +the clone is wrapped, never edited. Windows and Linux have no system window +buttons, so the tab bar draws minimize/maximize/close at its own end: there the +button is placed before the bar so those stay last. On macOS the button follows +the bar, clear of the traffic lights. Reinstall updates owned files without +rewriting layouts, so an existing install keeps its original placement: uninstall +and install again to move the button. Shipped tabs, keys, and usage data are +preserved. Success requires one `herdr-app check passed` message **and exit code 0**. Do not run developer tests or change tabs during installation. Open the dashboard diff --git a/herdr-insights/README.md b/herdr-insights/README.md index 6353f10..ffba835 100644 --- a/herdr-insights/README.md +++ b/herdr-insights/README.md @@ -24,8 +24,12 @@ npm run install:local --prefix herdr-insights ``` With Herdr running, the installer checks the `insights` mod and adds an -**Insights** button beside the native tabs; clicking it reuses the current -space's dashboard tab, opening one only when absent. It installs `mods/insights/` +**Insights** button to the tab bar row; clicking it reuses the current +space's dashboard tab, opening one only when absent. On Windows and Linux the +button comes before the tab bar, because that bar draws minimize/maximize/close +at its own end and those stay last; on macOS it follows the bar, clear of the +traffic lights. The bar can be Herdr's `tabs` or a personal clone of it +(`tabs-graph`, ...): both are wrapped, never edited. It installs `mods/insights/` and records ownership in `data/insights/`. Shipped files, tabs, keys, terminals, and usage data are unchanged. See [INSTALL.md](INSTALL.md) for the layout and click-state rules. Set `HERDR_APP_HOME` to use a different app home. @@ -50,8 +54,9 @@ One native mod, `mods/insights`, provides both views. A pane whose content is `{mod: "insights", props: {mode: "toolbar"}}` renders only the button; any other pane shows the dashboard. Toolbar mode starts no Node helper, scans, tasks, timers, or editors. No layouts or key bindings ship; the button lives in a -personal layout clone composed by the installer, which wraps the native tabs -without modifying them (see [INSTALL.md](INSTALL.md)). +personal layout clone composed by the installer, which wraps the tab-bar block — +Herdr's `tabs` or a personal clone such as `tabs-graph` — without modifying it +(see [INSTALL.md](INSTALL.md)). [RECIPE.md](RECIPE.md) is the self-contained rebuild brief required by Herdr. Developer tests, fixtures, and release validation live outside this distribution; diff --git a/herdr-insights/scripts/toolbar.mjs b/herdr-insights/scripts/toolbar.mjs index b1b2b68..1407286 100644 --- a/herdr-insights/scripts/toolbar.mjs +++ b/herdr-insights/scripts/toolbar.mjs @@ -55,8 +55,12 @@ async function command(appHome, args) { return result.stdout; } -/** Preserve the original tabs mod and its updates; compose one native button beside it. */ -export function withInsightsToolbar(layout) { +/** + * Preserve the original tab bar and its updates; compose one native button beside it. + * The bar may be Herdr's `tabs` or a personal clone of it: both are mod blocks, and + * neither is patched. `platform` is injectable so tests cover both placements. + */ +export function withInsightsToolbar(layout, { platform = process.platform } = {}) { const copy = structuredClone(layout); const nodes = []; function walk(node) { @@ -67,19 +71,22 @@ export function withInsightsToolbar(layout) { if (nodes.some((node) => node.id === BUTTON_ID || node.id === "insights-original-tabs")) { throw new Error("This layout already contains Insights toolbar blocks."); } - const matches = nodes.filter((node) => node.id === "tabs" && node.type === "block" && node.content?.mod === "tabs"); + const matches = nodes.filter((node) => node.id === "tabs" && node.type === "block" && typeof node.content?.mod === "string"); if (matches.length !== 1) { - throw new Error('Expected one native "tabs" block. This layout cannot be integrated safely.'); + throw new Error('Expected one "tabs" block showing a tab bar. This layout cannot be integrated safely.'); } const tabs = matches[0]; const content = tabs.content; delete tabs.content; tabs.type = "row"; tabs.resizable = false; - tabs.children = [ - { id: "insights-original-tabs", type: "block", size: { weight: 1 }, content }, - { id: BUTTON_ID, type: "block", size: { px: 104 }, content: { mod: "insights", props: { mode: "toolbar" } } }, - ]; + const bar = { id: "insights-original-tabs", type: "block", size: { weight: 1 }, content }; + const button = { id: BUTTON_ID, type: "block", size: { px: 104 }, content: { mod: "insights", props: { mode: "toolbar" } } }; + // Windows and Linux have no system window buttons: the tab bar draws + // minimize/maximize/close at its own end (window().buttons), so the Insights + // button goes first and those stay last. macOS keeps its traffic lights on the + // left, where a leading button would collide with them, so the button follows. + tabs.children = platform === "darwin" ? [bar, button] : [button, bar]; return copy; } @@ -139,7 +146,7 @@ export async function applyToolbar(plan) { try { await command(appHome, ["do", "layout.switch", JSON.stringify({ name: source })]); } catch { /* retain original failure */ } throw error; } - console.log(`Added Insights beside Files/Changes in personal layout ${layout}; Herdr's tabs mod is unchanged.`); + console.log(`Added the Insights button to personal layout ${layout}; the tab bar and Herdr's tabs mod are unchanged.`); } export async function prepareToolbarRemoval(appHome) { diff --git a/tests/herdr-insights/install-fixtures.mjs b/tests/herdr-insights/install-fixtures.mjs index 9e94dfc..7e9e855 100644 --- a/tests/herdr-insights/install-fixtures.mjs +++ b/tests/herdr-insights/install-fixtures.mjs @@ -30,6 +30,13 @@ export const TOOLBAR_LAYOUT = JSON.stringify({ id: "root", type: "column", child export const SINGLE_TOOLBAR_CONTENT = { mod: "insights", props: { mode: "toolbar" } }; +// A personal clone of the tab bar (`mod.clone tabs --as tabs-graph`): same block +// ID, but the content is not Herdr's own `tabs` mod. +export const CLONED_TOOLBAR_LAYOUT = JSON.stringify({ id: "root", type: "column", children: [ + { id: "tabs", type: "block", size: { px: 42 }, window_drag: true, content: { mod: "tabs-graph" } }, + { id: "main", type: "block", content: { tab: "active" } }, +] }) + "\n"; + export const APP_FILES = { "data/insights/insights.json": '{"version":1,"keep":"local"}\n', "data/insights/local-note.txt": "local data\n", diff --git a/tests/herdr-insights/toolbar-install.test.mjs b/tests/herdr-insights/toolbar-install.test.mjs index 1906711..36349f0 100644 --- a/tests/herdr-insights/toolbar-install.test.mjs +++ b/tests/herdr-insights/toolbar-install.test.mjs @@ -6,6 +6,7 @@ import test from "node:test"; import { APP_FILES, + CLONED_TOOLBAR_LAYOUT, PACKAGE_ROOT, SINGLE_TOOLBAR_CONTENT, TOOLBAR_LAYOUT, @@ -70,6 +71,27 @@ test("standalone distribution installs one mod and a single-mod toolbar without assert.equal(log.filter((entry) => entry.args[1] === "mod.open").length, 0, "no floating window"); }); +test("a personal tab-bar clone installs, and the tab bar keeps the window buttons last", async (t) => { + const root = await temporaryRoot(t); + const sourceRoot = await createSource(root); + const appHome = path.join(root, "app"); + await writeTree(appHome, { ...APP_FILES, "layouts/main.json": CLONED_TOOLBAR_LAYOUT }); + const fake = await createFakeRunner(root); + assertSuccess(runInstall({ appHome, fake, sourceRoot })); + const layout = await readJson(path.join(appHome, "layouts/main-insights.json")); + const row = layout.children[0]; + assert.equal(row.id, "tabs"); + assert.equal(row.type, "row"); + const ids = row.children.map((child) => child.id); + assert.deepEqual(ids, process.platform === "darwin" + ? ["insights-original-tabs", "insights-toolbar-button"] + : ["insights-toolbar-button", "insights-original-tabs"]); + assert.deepEqual(row.children.find((child) => child.id === "insights-original-tabs").content, + { mod: "tabs-graph" }, "the clone is preserved, not replaced by Herdr's tabs"); + assert.deepEqual(row.children.find((child) => child.id === "insights-toolbar-button").content, + SINGLE_TOOLBAR_CONTENT); +}); + test("one mod reinstalls idempotently and uninstall preserves data and local edits", async (t) => { const root = await temporaryRoot(t); const sourceRoot = await createSource(root); diff --git a/tests/herdr-insights/toolbar-layout.test.mjs b/tests/herdr-insights/toolbar-layout.test.mjs index 44fd3a1..329ddb6 100644 --- a/tests/herdr-insights/toolbar-layout.test.mjs +++ b/tests/herdr-insights/toolbar-layout.test.mjs @@ -4,6 +4,17 @@ import { collectSourceFiles, PACKAGE_ROOT } from "../../herdr-insights/scripts/l import { withInsightsToolbar } from "../../herdr-insights/scripts/toolbar.mjs"; import { SINGLE_TOOLBAR_CONTENT } from "./install-fixtures.mjs"; +const BUTTON = { + id: "insights-toolbar-button", + type: "block", + size: { px: 104 }, + content: SINGLE_TOOLBAR_CONTENT, +}; + +function preservedBar(tabs) { + return { id: "insights-original-tabs", type: "block", size: { weight: 1 }, content: tabs.content }; +} + test("the Insights source includes its toolbar icon", async () => { const source = await collectSourceFiles(PACKAGE_ROOT); assert.ok(source.files.some((file) => file.relative === "insights.svg")); @@ -13,7 +24,7 @@ test("layout composition keeps the original tabs code and points the toolbar at const tabs = { id: "tabs", type: "block", size: { px: 42 }, window_drag: true, content: { mod: "tabs", props: { custom: true } } }; const original = { id: "root", type: "column", theme: "ink", children: [tabs, { id: "main", type: "block", content: { tab: "active" } }] }; const snapshot = structuredClone(original); - const integrated = withInsightsToolbar(original); + const integrated = withInsightsToolbar(original, { platform: "darwin" }); assert.deepEqual(original, snapshot, "input must not be mutated"); assert.equal(integrated.theme, "ink"); assert.deepEqual(integrated.children[1], original.children[1]); @@ -24,15 +35,31 @@ test("layout composition keeps the original tabs code and points the toolbar at assert.equal(row.window_drag, true); assert.equal(row.resizable, false); assert.equal(row.content, undefined); - assert.deepEqual(row.children[0].content, tabs.content); - assert.deepEqual(row.children[0].size, { weight: 1 }); - assert.deepEqual(row.children[1], { - id: "insights-toolbar-button", - type: "block", - size: { px: 104 }, - content: SINGLE_TOOLBAR_CONTENT, - }); + assert.deepEqual(row.children, [preservedBar(tabs), BUTTON], "macOS keeps the button after the tab bar"); assert.throws(() => withInsightsToolbar(integrated), /already contains/); assert.throws(() => withInsightsToolbar({ id: "root", type: "row", children: [] }), /cannot be integrated safely/); assert.throws(() => withInsightsToolbar({ id: "root", type: "row", children: [tabs, tabs] }), /cannot be integrated safely/); }); + +test("Windows and Linux put the button before the tab bar so the window buttons stay last", () => { + for (const platform of ["win32", "linux"]) { + const tabs = { id: "tabs", type: "block", size: { px: 42 }, window_drag: true, content: { mod: "tabs" } }; + const integrated = withInsightsToolbar({ id: "root", type: "column", children: [tabs] }, { platform }); + const row = integrated.children[0]; + assert.deepEqual(row.children, [BUTTON, preservedBar(tabs)], platform); + assert.equal(row.children.at(-1).id, "insights-original-tabs", + "the tab bar, which draws minimize/maximize/close, must be the last item"); + } +}); + +test("a personal tab-bar clone is composed exactly like the native one", () => { + const tabs = { id: "tabs", type: "block", size: { px: 42 }, window_drag: true, content: { mod: "tabs-graph" } }; + const integrated = withInsightsToolbar({ id: "root", type: "column", children: [tabs] }, { platform: "win32" }); + assert.deepEqual(integrated.children[0].children, [BUTTON, preservedBar(tabs)]); +}); + +test("a tabs block that is not a tab-bar mod is refused", () => { + const tabs = { id: "tabs", type: "block", content: { tab: "active" } }; + assert.throws(() => withInsightsToolbar({ id: "root", type: "column", children: [tabs] }), + /cannot be integrated safely/); +});