gentle-shell-bridge 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +91 -722
  3. package/docs/contract.md +92 -35
  4. package/package.json +13 -1
  5. package/src/api.ts +1 -1
  6. package/.codegraph/.gitignore +0 -5
  7. package/PRD.md +0 -144
  8. package/examples/bash-card.ts +0 -1694
  9. package/gentle-shell-bridge-0.3.0.tgz +0 -0
  10. package/odd/tasks/palette-sections.md +0 -30
  11. package/odd/tasks/plugin-keymap.md +0 -32
  12. package/odd/tasks/plugin-surface-contract.md +0 -55
  13. package/test/api.test.ts +0 -395
  14. package/test/apply-on-move.test.ts +0 -477
  15. package/test/arm.test.ts +0 -220
  16. package/test/bridge-ready.test.ts +0 -309
  17. package/test/bus.test.ts +0 -165
  18. package/test/button-rows.test.ts +0 -409
  19. package/test/capture.test.ts +0 -356
  20. package/test/card-style-rows.test.ts +0 -510
  21. package/test/card-style-transform.test.ts +0 -379
  22. package/test/card-style-wiring.test.ts +0 -258
  23. package/test/card-styles-e2e.test.ts +0 -491
  24. package/test/card-styles.test.ts +0 -324
  25. package/test/card-transcript-path.test.ts +0 -178
  26. package/test/config.test.ts +0 -93
  27. package/test/e2e.test.ts +0 -443
  28. package/test/example-bash-identity.test.ts +0 -372
  29. package/test/example-bash-matrix.test.ts +0 -856
  30. package/test/example-designs.test.ts +0 -734
  31. package/test/example-neon-transform.test.ts +0 -283
  32. package/test/example-plain-style.test.ts +0 -1268
  33. package/test/extension.test.ts +0 -160
  34. package/test/fake-events.ts +0 -58
  35. package/test/fake-host.ts +0 -211
  36. package/test/fake-sidebar.ts +0 -516
  37. package/test/fake-transcript.ts +0 -486
  38. package/test/frame-telemetry.test.ts +0 -417
  39. package/test/hook.test.ts +0 -196
  40. package/test/manifest.test.ts +0 -105
  41. package/test/new-category.test.ts +0 -979
  42. package/test/notices.test.ts +0 -328
  43. package/test/plugin-integration-lines.test.ts +0 -979
  44. package/test/plugin-surface.test.ts +0 -152
  45. package/test/presence.test.ts +0 -459
  46. package/test/rail-surfaces.test.ts +0 -457
  47. package/test/rail-width.test.ts +0 -423
  48. package/test/regression.test.ts +0 -483
  49. package/test/repaint-api.test.ts +0 -200
  50. package/test/reserved-categories.test.ts +0 -361
  51. package/test/row-labels.test.ts +0 -284
  52. package/test/row-names.test.ts +0 -473
  53. package/test/row-origin.test.ts +0 -395
  54. package/test/section-interaction.test.ts +0 -364
  55. package/test/section-panel.test.ts +0 -403
  56. package/test/sections-rows.test.ts +0 -291
  57. package/test/sidebar-backend.test.ts +0 -471
  58. package/test/sidebar-e2e.test.ts +0 -331
  59. package/test/sidebar-hover.test.ts +0 -276
  60. package/test/sidebar-independent.test.ts +0 -349
  61. package/test/sidebar-render.test.ts +0 -281
  62. package/test/sidebar-sections.test.ts +0 -245
  63. package/test/status-card-styles.test.ts +0 -201
  64. package/test/status-section-frame.test.ts +0 -181
  65. package/test/status-sections.test.ts +0 -243
  66. package/test/status.test.ts +0 -209
  67. package/test/store.test.ts +0 -192
  68. package/test/surface-repaint-cost.test.ts +0 -520
  69. package/test/tool-design-chain.test.ts +0 -483
  70. package/test/tool-design-cost.test.ts +0 -473
  71. package/test/tool-design-rows.test.ts +0 -359
  72. package/test/tool-designs.test.ts +0 -745
  73. package/test/transform-pipeline.test.ts +0 -465
  74. package/test/transforms-bus.test.ts +0 -233
  75. package/test/verifier-width-regressions.test.ts +0 -164
  76. package/tsconfig.json +0 -18
@@ -1,331 +0,0 @@
1
- /**
2
- * End-to-end over the PUBLIC API (S9, S12, S13).
3
- *
4
- * Everything a plugin may touch is imported from `src/index.ts`; the only other non-test import is
5
- * the Pi entry Pi itself loads (`src/extension.ts`). Internal seams appear here for teardown only,
6
- * exactly as `test/e2e.test.ts` uses them, and the API-surface test proves they are not public.
7
- *
8
- * The host is the fake: a menu host for the `Sections` rows and the fake sidebar for the rail. So
9
- * these tests prove the wiring, the ordering and the shared state — not the real TUI, which is
10
- * listed as unverified in PRD.md and README.md.
11
- */
12
-
13
- import assert from "node:assert/strict";
14
- import { mkdtempSync, readFileSync, rmSync } from "node:fs";
15
- import { tmpdir } from "node:os";
16
- import { join } from "node:path";
17
- import test from "node:test";
18
-
19
- // The public surface, exactly as a plugin reaches it.
20
- import {
21
- VERSION,
22
- registerRows,
23
- registerSection,
24
- type CustomizeRow,
25
- type RowProvider,
26
- type SidebarSection,
27
- } from "../src/index.ts";
28
- // The real Pi entry, armed and commanded exactly as Pi loads it.
29
- import gentleShellBridge, {
30
- STATUS_COMMAND,
31
- type BridgeCommandContext,
32
- type BridgeCommandHandler,
33
- type BridgeExtensionApi,
34
- type BridgeSessionHandler,
35
- } from "../src/extension.ts";
36
- // Teardown-only seams.
37
- import { resetBusForTests } from "../src/bus.ts";
38
- import { disableCaptureBackend, resetCaptureStateForTests } from "../src/capture.ts";
39
- import { resetHookStateForTests } from "../src/hook.ts";
40
- import { resetRowBuildStateForTests } from "../src/rows.ts";
41
- import {
42
- disableSidebarBackend,
43
- resetSidebarStateForTests,
44
- } from "../src/sidebar.ts";
45
- import { resetSectionFailuresForTests } from "../src/sidebar-render.ts";
46
- import { resetStoreStateForTests } from "../src/store.ts";
47
- import { createFakeHost, type FakeHost, type FakeUiTarget } from "./fake-host.ts";
48
- import {
49
- cardBody,
50
- cardTitles,
51
- fakeState,
52
- fakeTui,
53
- registerNativeFooter,
54
- resetSidebar,
55
- } from "./fake-sidebar.ts";
56
-
57
- const HOOK_KEY = Symbol.for("gentle-pi.customize-row-providers");
58
- const hookSlot = (): Record<symbol, unknown> => globalThis as unknown as Record<symbol, unknown>;
59
-
60
- let configHome: string;
61
- let previousConfigHome: string | undefined;
62
-
63
- test.before(() => {
64
- configHome = mkdtempSync(join(tmpdir(), "gsb-sidebar-e2e-"));
65
- previousConfigHome = process.env.GENTLE_PI_CONFIG_HOME;
66
- process.env.GENTLE_PI_CONFIG_HOME = configHome;
67
- });
68
-
69
- test.after(() => {
70
- if (previousConfigHome === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
71
- else process.env.GENTLE_PI_CONFIG_HOME = previousConfigHome;
72
- rmSync(configHome, { recursive: true, force: true });
73
- });
74
-
75
- test.beforeEach(() => {
76
- rmSync(join(configHome, "gentle-shell-bridge"), { recursive: true, force: true });
77
- resetBusForTests();
78
- resetSidebar();
79
- resetSidebarStateForTests();
80
- resetSectionFailuresForTests();
81
- resetStoreStateForTests();
82
- resetCaptureStateForTests();
83
- resetHookStateForTests();
84
- resetRowBuildStateForTests();
85
- disableCaptureBackend();
86
- disableSidebarBackend();
87
- delete hookSlot()[HOOK_KEY];
88
- });
89
-
90
- test.afterEach(() => {
91
- disableCaptureBackend();
92
- disableSidebarBackend();
93
- delete hookSlot()[HOOK_KEY];
94
- });
95
-
96
- interface Entry {
97
- sessionHandlers: BridgeSessionHandler[];
98
- commands: Array<{ name: string; handler: BridgeCommandHandler }>;
99
- }
100
-
101
- const loadEntry = (): Entry => {
102
- const sessionHandlers: BridgeSessionHandler[] = [];
103
- const commands: Entry["commands"] = [];
104
- const pi: BridgeExtensionApi = {
105
- // `gentle-pi` is present, so `session_start` arms the row side and the rail (S9/T12).
106
- getCommands: () => [{ name: "gentle:customize" }],
107
- on(_event, handler) {
108
- sessionHandlers.push(handler);
109
- },
110
- registerCommand(name, command) {
111
- commands.push({ name, handler: command.handler });
112
- },
113
- };
114
- gentleShellBridge(pi);
115
- return { sessionHandlers, commands };
116
- };
117
-
118
- /** Pi fires `session_start`: the entry arms the row side and installs the sidebar widget. */
119
- const armThroughEntry = (host: FakeHost): Entry => {
120
- const entry = loadEntry();
121
- entry.sessionHandlers[0]({}, { hasUI: true, ui: host.ui });
122
- return entry;
123
- };
124
-
125
- const printStatus = (entry: Entry): string => {
126
- const printed: string[] = [];
127
- const ctx: BridgeCommandContext = { ui: { notify: (message: string) => printed.push(message) } };
128
- entry.commands.find((command) => command.name === STATUS_COMMAND)?.handler("", ctx);
129
- return printed.join("\n");
130
- };
131
-
132
- type UiWithWidget = FakeUiTarget & {
133
- setWidget?: (key: string, factory: (tui: unknown, theme: unknown) => unknown) => void;
134
- };
135
-
136
- interface WidgetCall {
137
- key: string;
138
- component: unknown;
139
- }
140
-
141
- /** Model Pi's `ui.setWidget(key, factory)`: it runs the factory with the real `tui` immediately. */
142
- const installWidgetSurface = (host: FakeHost): WidgetCall[] => {
143
- const calls: WidgetCall[] = [];
144
- const ui = host.ui as UiWithWidget;
145
- ui.setWidget = (key, factory) => {
146
- calls.push({ key, component: factory(fakeTui(), { name: "fake-theme" }) });
147
- };
148
- return calls;
149
- };
150
-
151
- const musicSection = (overrides: Partial<SidebarSection> = {}): SidebarSection => ({
152
- id: "loon.music",
153
- title: "MUSIC",
154
- meta: () => "1 playing",
155
- lines: () => ["track one", "album two"],
156
- ...overrides,
157
- });
158
-
159
- const sectionLine = (label: string): CustomizeRow[] => [
160
- {
161
- category: "Cards",
162
- label,
163
- choice: { setting: "Loon banner source", value: "on", current: () => true },
164
- action: () => {},
165
- },
166
- {
167
- category: "Cards",
168
- label,
169
- choice: { setting: "Loon banner source", value: "off", current: () => false },
170
- action: () => {},
171
- },
172
- ];
173
-
174
- test("one registerSection call reaches the rail and the Sections menu through the entry", () => {
175
- const host = createFakeHost();
176
- const widgets = installWidgetSurface(host);
177
- registerNativeFooter(); // the host's own rail, registered BEFORE our arm
178
- registerSection(musicSection());
179
-
180
- const entry = armThroughEntry(host);
181
- host.buildMenu();
182
-
183
- assert.equal(widgets.length, 1, "the entry installed exactly one sidebar widget factory");
184
- assert.deepEqual(cardTitles(), ["FOOTER", "MUSIC"], "the host frames our section after its own");
185
- assert.deepEqual(cardBody("MUSIC"), ["track one", "album two"]);
186
-
187
- const line = host.toggleLines().find((candidate) => candidate.startsWith("music "));
188
- assert.ok(line !== undefined, "the toggle exists without the plugin writing a row");
189
- assert.ok(line.includes("● shown") && line.includes("○ hidden"), line);
190
- assert.ok(host.rendered().includes("music"), "the row reads as the section label");
191
-
192
- const printed = printStatus(entry);
193
- assert.match(printed, /sections: 1/u);
194
- assert.match(printed, /sidebar: intercepted \(rail wrapped\)/u);
195
- });
196
-
197
- test("the same holds when the host registers its rail AFTER the arm", () => {
198
- const host = createFakeHost();
199
- installWidgetSurface(host);
200
- // The host's rail STATE exists from startup; what arrives late is the footer PART. That is the
201
- // ordering S4 is about, and the reason the interception patches the map instead of the part.
202
- fakeState();
203
- registerSection(musicSection());
204
-
205
- armThroughEntry(host);
206
- registerNativeFooter(); // the host's own rail, registered after our interception
207
-
208
- assert.deepEqual(cardTitles(), ["FOOTER", "MUSIC"]);
209
- });
210
-
211
- test("the widget the bridge installs renders no rows", () => {
212
- const host = createFakeHost();
213
- const widgets = installWidgetSurface(host);
214
- registerNativeFooter();
215
-
216
- armThroughEntry(host);
217
-
218
- const component = widgets[0]?.component as { render(width: number): string[] } | undefined;
219
- assert.ok(component !== undefined);
220
- assert.deepEqual(component.render(40), [], "the widget exists only to receive the tui");
221
- });
222
-
223
- test("toggling through the menu hides the section on the next frame and stops building it", async () => {
224
- const host = createFakeHost();
225
- installWidgetSurface(host);
226
- registerNativeFooter();
227
- let linesCalls = 0;
228
- registerSection(
229
- musicSection({
230
- lines: () => {
231
- linesCalls += 1;
232
- return ["track one", "album two"];
233
- },
234
- }),
235
- );
236
-
237
- armThroughEntry(host);
238
- host.buildMenu();
239
- assert.deepEqual(cardTitles(), ["FOOTER", "MUSIC"]);
240
-
241
- await host.pressRow(
242
- (row) => row.choice?.setting === "music" && row.choice.value === "hidden",
243
- );
244
- linesCalls = 0;
245
-
246
- assert.deepEqual(cardTitles(), ["FOOTER"], "the next frame drops the card");
247
- assert.equal(linesCalls, 0, "and the hidden section's content is not built");
248
- assert.deepEqual(
249
- JSON.parse(readFileSync(join(configHome, "gentle-shell-bridge", "bridge-sections.json"), "utf8")),
250
- { schemaVersion: 1, values: { sections: { "loon.music": false } } },
251
- );
252
- });
253
-
254
- test("disposing the section removes the card and, on a fresh menu, its rows", () => {
255
- const host = createFakeHost();
256
- installWidgetSurface(host);
257
- registerNativeFooter();
258
- const dispose = registerSection(musicSection());
259
-
260
- armThroughEntry(host);
261
- host.buildMenu();
262
- assert.deepEqual(cardTitles(), ["FOOTER", "MUSIC"]);
263
- assert.ok(host.toggleLines().some((line) => line.startsWith("music ")));
264
-
265
- dispose();
266
-
267
- assert.deepEqual(cardTitles(), ["FOOTER"], "the rail drops it immediately");
268
-
269
- // A menu that received rows once keeps them (the capture marks the array), so a fresh menu is the
270
- // honest way to observe the next build.
271
- const second = createFakeHost();
272
- installWidgetSurface(second);
273
- armThroughEntry(second);
274
- second.buildMenu();
275
- assert.deepEqual(second.toggleLines(), [], "no toggle left for a disposed section");
276
- assert.equal(
277
- second.rendered().some((label) => label.includes("MUSIC")),
278
- false,
279
- "and no row label either",
280
- );
281
- });
282
-
283
- test("the row side still behaves in the same process, alongside the sections", () => {
284
- const host = createFakeHost();
285
- installWidgetSurface(host);
286
- registerNativeFooter();
287
- const provider: RowProvider = {
288
- id: "loon.banner-source",
289
- category: "Cards",
290
- rows: () => sectionLine("Loon banner source"),
291
- };
292
- registerRows(provider);
293
- registerSection(musicSection());
294
-
295
- armThroughEntry(host);
296
- host.buildMenu();
297
-
298
- const injected = host.liveRows().slice(4);
299
- assert.deepEqual(
300
- injected.slice(0, 2).map((row) => row.category),
301
- ["Cards", "Cards"],
302
- "provider rows keep their place and order",
303
- );
304
- assert.deepEqual(
305
- injected.slice(2).map((row) => row.category),
306
- ["Sections", "Sections"],
307
- "the bridge's own rows follow",
308
- );
309
- const lines = host.toggleLines();
310
- assert.ok(lines.some((line) => line.startsWith("Loon banner source")));
311
- assert.ok(lines.some((line) => line.startsWith("music ")));
312
- });
313
-
314
- test("a UI without setWidget is reported as unarmed and never throws", () => {
315
- const host = createFakeHost(); // no setWidget on this UI: Pi always has one, the test does not
316
- registerSection(musicSection());
317
-
318
- const entry = armThroughEntry(host); // must not throw
319
- host.buildMenu();
320
-
321
- assert.deepEqual(cardTitles(), [], "no rail interception was possible");
322
- assert.ok(host.rendered().includes("music"), "the row side still works: the toggle is visible");
323
-
324
- const printed = printStatus(entry);
325
- assert.match(printed, /sidebar: not armed/u);
326
- assert.match(printed, /sections: 1/u);
327
- });
328
-
329
- test("the public version is the released one", () => {
330
- assert.equal(VERSION, "0.3.0");
331
- });
@@ -1,276 +0,0 @@
1
- /**
2
- * S21/T40–T41: hover reaches the bridge's own rail rows.
3
- *
4
- * The fake now models the host's part-level mouse dispatch as it really is. The `footer` part is a
5
- * `NativePointerRegion` (`extensions/gentle-shell.ts:2766`) whose `onHover` answers EVERY `move`
6
- * inside the part's frame (`lib/native-pointer-region.ts:80-88`,
7
- * `extensions/gentle-shell.ts:2732-2741`): it claims the host's shared pointer slot
8
- * (`SidebarState.hover`, `lib/shell-sidebar.ts:31,72-78`) for a row it owns and clears it otherwise,
9
- * returning `{ handled: true }` either way. A move over a row the bridge appended is therefore
10
- * consumed by the host's region and never reaches the bridge's wrapper, which is why these tests
11
- * failed before the fix: the wrapper consulted the host first and only asked the contributor when
12
- * the host declined, and the host never declines a move.
13
- *
14
- * Every dispatch below is the event the layout really hands a part — `y` in the PART's frame, `width`
15
- * the frame it rendered (`lib/shell-sidebar-layout.ts:245-259`) — so the offset arithmetic is the
16
- * bridge's real code, not a test-only shortcut.
17
- */
18
-
19
- import assert from "node:assert/strict";
20
- import { mkdtempSync, rmSync } from "node:fs";
21
- import { tmpdir } from "node:os";
22
- import { join } from "node:path";
23
- import test from "node:test";
24
-
25
- import { registerSection, resetBusForTests, resetSectionsForTests } from "../src/bus.ts";
26
- import { setRepaintHost, resetRepaintForTests } from "../src/repaint.ts";
27
- import { resetSectionPanelForTests, setSectionTheme } from "../src/section-panel.ts";
28
- import { getSectionHoverReport, resetSectionPointerForTests } from "../src/section-pointer.ts";
29
- import {
30
- SIDEBAR_RAIL_KEY,
31
- disableSidebarBackend,
32
- installSidebarBackend,
33
- resetSidebarStateForTests,
34
- } from "../src/sidebar.ts";
35
- import {
36
- installSectionRail,
37
- resetSectionFailuresForTests,
38
- } from "../src/sidebar-render.ts";
39
- import { resetStoreStateForTests } from "../src/store.ts";
40
- import type { SidebarSection } from "../src/types.ts";
41
- import {
42
- cacheRevision,
43
- fakeState,
44
- fakeTerminal,
45
- fakeTui,
46
- registerNativeFooter,
47
- renderSidebar,
48
- resetSidebar,
49
- setHostHover,
50
- } from "./fake-sidebar.ts";
51
-
52
- let configHome: string;
53
- let previousConfigHome: string | undefined;
54
-
55
- test.before(() => {
56
- configHome = mkdtempSync(join(tmpdir(), "gsb-sidebar-hover-"));
57
- previousConfigHome = process.env.GENTLE_PI_CONFIG_HOME;
58
- process.env.GENTLE_PI_CONFIG_HOME = configHome;
59
- });
60
-
61
- test.after(() => {
62
- if (previousConfigHome === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
63
- else process.env.GENTLE_PI_CONFIG_HOME = previousConfigHome;
64
- rmSync(configHome, { recursive: true, force: true });
65
- });
66
-
67
- test.beforeEach(() => {
68
- rmSync(join(configHome, "gentle-shell-bridge"), { recursive: true, force: true });
69
- resetBusForTests();
70
- resetSectionsForTests();
71
- resetSidebar();
72
- resetSidebarStateForTests();
73
- resetSectionFailuresForTests();
74
- resetSectionPanelForTests();
75
- resetSectionPointerForTests();
76
- resetRepaintForTests();
77
- resetStoreStateForTests();
78
- disableSidebarBackend();
79
- renders = 0;
80
- });
81
-
82
- const section = (id: string, extra: Partial<SidebarSection> = {}): SidebarSection => ({
83
- id,
84
- title: id.toUpperCase(),
85
- lines: () => [`body ${id}`],
86
- ...extra,
87
- });
88
-
89
- let renders = 0;
90
-
91
- /** Arm the rail with a `tui` whose `requestRender` and cache revision a test can count. */
92
- function arm(): void {
93
- const tui = {
94
- terminal: fakeTerminal(),
95
- requestRender: () => {
96
- renders += 1;
97
- },
98
- };
99
- setRepaintHost(tui);
100
- registerNativeFooter();
101
- installSidebarBackend(() => fakeTui());
102
- installSectionRail();
103
- }
104
-
105
- const rail = () => fakeState().parts.get(SIDEBAR_RAIL_KEY);
106
- const rowOf = (text: string, width = 40): string | undefined =>
107
- (rail()?.render(width) ?? []).find((row) => row.includes(text));
108
-
109
- /** The native footer renders three rows, so the contribution's first row is 3. */
110
- const NATIVE_ROWS = 3;
111
-
112
- /** The section HEADER row, and the first inner row of a body, in the part's own frame. */
113
- const HEADER_ROW = NATIVE_ROWS;
114
- const FIRST_BODY_ROW = NATIVE_ROWS + 1;
115
-
116
- const move = (y: number): unknown =>
117
- rail()?.handleMouse?.({ type: "move", button: "none", x: 4, y, width: 40, height: 40 });
118
- const click = (y: number): unknown =>
119
- rail()?.handleMouse?.({ type: "click", button: "left", x: 4, y, width: 40, height: 40 });
120
-
121
- /** The host's hover role (`HOVER_ROLE = "warning"`, `lib/shell-hover.ts:33`), as SGR 33. */
122
- const HOVER_SGR = "\u001b[33m";
123
-
124
- const ROLE_SGR: Record<string, number> = {
125
- text: 37,
126
- muted: 90,
127
- dim: 2,
128
- accent: 95,
129
- success: 32,
130
- error: 31,
131
- warning: 33,
132
- };
133
-
134
- function fakeTheme(): { fg(role: string, text: string): string; bold(text: string): string } {
135
- return {
136
- fg: (role, text) => `\u001b[${String(ROLE_SGR[role] ?? 0)}m${text}\u001b[39m`,
137
- bold: (text) => `\u001b[1m${text}\u001b[22m`,
138
- };
139
- }
140
-
141
- test("a move over the section HEADER paints it with the host's hover role", () => {
142
- setSectionTheme(fakeTheme());
143
- registerSection(section("music", { title: "MUSIC" }));
144
- arm();
145
- renderSidebar();
146
-
147
- assert.ok(!(rowOf("MUSIC") ?? "").includes(HOVER_SGR), "the header starts idle");
148
- const digestBefore = rail()?.digest?.();
149
- const revisionBefore = cacheRevision();
150
-
151
- assert.deepEqual(move(HEADER_ROW), { handled: true, render: true }, "the header answers the move");
152
- assert.equal(renders, 1, "exactly one differential frame is asked for");
153
- assert.equal(cacheRevision(), revisionBefore + 1, "and the host's sidebar revision moved");
154
- assert.ok((rowOf("MUSIC") ?? "").includes(HOVER_SGR), "the header takes the host's hover role");
155
-
156
- const hover = fakeState().hover;
157
- assert.ok(hover !== undefined, "the shared pointer slot carries our claim");
158
- assert.equal(getSectionHoverReport().mechanism, "host-state");
159
- assert.notEqual(rail()?.digest?.(), digestBefore, "the hover moves the digest, so no stale frame");
160
- });
161
-
162
- test("a move over an inner CLICKABLE row paints it, and never runs its action", () => {
163
- setSectionTheme(fakeTheme());
164
- const calls: string[] = [];
165
- registerSection(
166
- section("music", {
167
- title: "MUSIC",
168
- lines: () => [
169
- { label: "play", meta: "enter", onClick: () => calls.push("play") },
170
- { label: "inert" },
171
- ],
172
- }),
173
- );
174
- arm();
175
- renderSidebar();
176
-
177
- assert.ok(!(rowOf("play") ?? "").includes(HOVER_SGR), "the row starts idle");
178
-
179
- assert.deepEqual(move(FIRST_BODY_ROW), { handled: true, render: true }, "the row answers the move");
180
- assert.equal(renders, 1, "one frame");
181
- assert.ok((rowOf("play") ?? "").includes(HOVER_SGR), "the row takes the host's hover role");
182
- assert.equal(fakeState().hover !== undefined, true, "the shared pointer slot carries our claim");
183
- assert.deepEqual(calls, [], "hovering never runs a row's work");
184
-
185
- assert.deepEqual(move(FIRST_BODY_ROW), { handled: true }, "the same row again is not a new hover");
186
- assert.equal(renders, 1, "so it asks for no frame");
187
-
188
- assert.deepEqual(move(FIRST_BODY_ROW + 1), { handled: true, render: true }, "an inert row clears it");
189
- assert.equal(renders, 2);
190
- assert.ok(!(rowOf("play") ?? "").includes(HOVER_SGR), "the row is painted idle again");
191
- });
192
-
193
- test("the host's own row keeps the pointer: the bridge never steals it", () => {
194
- setSectionTheme(fakeTheme());
195
- registerSection(
196
- section("music", {
197
- title: "MUSIC",
198
- lines: () => [{ label: "play", onClick: () => {} }],
199
- }),
200
- );
201
- arm();
202
- renderSidebar();
203
-
204
- // Row 1 is the native footer's own body row: the host's region claims it, and a fix that asked the
205
- // contributor first for EVERY row would take the pointer from the card the user says works today.
206
- assert.deepEqual(
207
- move(1),
208
- { handled: true, render: true },
209
- "the host's region answered, exactly as it does for its own cards",
210
- );
211
- assert.equal(renders, 0, "no bridge frame is asked for");
212
- assert.equal(getSectionHoverReport().mechanism, "not used", "the bridge never claimed the pointer");
213
- assert.ok(fakeState().hover !== undefined, "the host's own claim holds the slot");
214
- assert.ok(!(rowOf("MUSIC") ?? "").includes(HOVER_SGR), "and none of our rows is painted");
215
- assert.ok(!(rowOf("play") ?? "").includes(HOVER_SGR));
216
- });
217
-
218
- test("moving from our row to the host's row hands the pointer back, one surface at a time", () => {
219
- setSectionTheme(fakeTheme());
220
- registerSection(
221
- section("music", {
222
- title: "MUSIC",
223
- lines: () => [{ label: "play", onClick: () => {} }],
224
- }),
225
- );
226
- arm();
227
- renderSidebar();
228
-
229
- move(FIRST_BODY_ROW);
230
- assert.ok((rowOf("play") ?? "").includes(HOVER_SGR), "our row is painted while it owns the pointer");
231
- const claimed = fakeState().hover?.owner;
232
-
233
- move(0);
234
- assert.notEqual(fakeState().hover?.owner, claimed, "the host's own target took the pointer");
235
- assert.ok(!(rowOf("play") ?? "").includes(HOVER_SGR), "and our row is released, not left painted");
236
- });
237
-
238
- test("the host's own pointer-left step clears our row and repaints once", () => {
239
- setSectionTheme(fakeTheme());
240
- registerSection(section("music", { title: "MUSIC" }));
241
- arm();
242
- renderSidebar();
243
- move(HEADER_ROW);
244
- assert.equal(renders, 1);
245
-
246
- // `setSidebarHover(tui)` with no owner: what the layout does when the pointer leaves the rail
247
- // (`lib/shell-sidebar-layout.ts:280,447`). It runs the current owner's `clear()`.
248
- setHostHover();
249
-
250
- assert.equal(renders, 2, "the clear repaints once");
251
- assert.equal(fakeState().hover, undefined, "the slot is empty");
252
- assert.ok(!(rowOf("MUSIC") ?? "").includes(HOVER_SGR), "the header is idle again");
253
- });
254
-
255
- test("a click on an inner action row still runs once, and only on that row", () => {
256
- const calls: string[] = [];
257
- registerSection(
258
- section("music", {
259
- title: "MUSIC",
260
- lines: () => [
261
- { label: "play", onClick: () => calls.push("play") },
262
- { label: "inert" },
263
- ],
264
- }),
265
- );
266
- arm();
267
- renderSidebar();
268
-
269
- assert.deepEqual(click(FIRST_BODY_ROW), { handled: true, render: true });
270
- assert.deepEqual(click(FIRST_BODY_ROW), { handled: true, render: true });
271
- assert.deepEqual(calls, ["play", "play"], "one click, one call — never doubled by the host");
272
-
273
- assert.equal(click(FIRST_BODY_ROW + 1), undefined, "an inert row declines");
274
- assert.equal(click(HEADER_ROW + 9), undefined, "a row past the contribution is not ours");
275
- assert.deepEqual(calls, ["play", "play"]);
276
- });