magus-cli 7.3.0 → 7.4.0

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 (130) hide show
  1. package/package.json +4 -4
  2. package/scripts/build-binaries.ts +4 -2
  3. package/src/__tests__/blind/c1-npm-package.test.ts +206 -0
  4. package/src/__tests__/blind/c10-colour-roles.test.ts +265 -0
  5. package/src/__tests__/blind/c11-cli-parse-refusal.test.ts +454 -0
  6. package/src/__tests__/blind/c12-skill-frontmatter.test.ts +323 -0
  7. package/src/__tests__/blind/c13-wrong-shape.test.ts +603 -0
  8. package/src/__tests__/blind/c14-cli-row-label.test.ts +259 -0
  9. package/src/__tests__/blind/c15-probe-ownership.test.ts +533 -0
  10. package/src/__tests__/blind/c16-nested-wrong-shape.test.ts +297 -0
  11. package/src/__tests__/blind/c17-enter-label.test.ts +625 -0
  12. package/src/__tests__/blind/c18-hook-shape.test.ts +354 -0
  13. package/src/__tests__/blind/c2-settings-parse.test.ts +350 -0
  14. package/src/__tests__/blind/c4-key-dispatch.test.ts +397 -0
  15. package/src/__tests__/blind/c5-keymap.test.ts +461 -0
  16. package/src/__tests__/blind/c6-request-gate.test.ts +133 -0
  17. package/src/__tests__/blind/c7-tab-tiers.test.ts +320 -0
  18. package/src/__tests__/blind/c8-text-width.test.ts +137 -0
  19. package/src/__tests__/blind/c9-cli-tool-version.test.ts +239 -0
  20. package/src/__tests__/blind/child-run.ts +114 -0
  21. package/src/__tests__/cli-tools-adapter.test.ts +5 -2
  22. package/src/__tests__/component-badge.test.ts +20 -36
  23. package/src/__tests__/footer-hints.test.ts +1 -1
  24. package/src/__tests__/manifest.test.ts +25 -17
  25. package/src/__tests__/marketplace-badge.test.ts +4 -4
  26. package/src/__tests__/models-screen-state.test.ts +36 -13
  27. package/src/__tests__/npm-package-name-api.test.ts +111 -0
  28. package/src/__tests__/npm-package-name.test.ts +280 -0
  29. package/src/__tests__/scope-squares.test.tsx +28 -114
  30. package/src/__tests__/shutdown.test.ts +578 -0
  31. package/src/__tests__/styles-manager.test.ts +4 -4
  32. package/src/__tests__/styles-screen-state.test.ts +26 -12
  33. package/src/__tests__/tabbar-layout.test.ts +44 -32
  34. package/src/__tests__/theme-adaptive-colors.test.ts +6 -59
  35. package/src/__tests__/tool-probe.test.ts +47 -22
  36. package/src/__tests__/upgrade-spawn.test.ts +355 -0
  37. package/src/__tests__/uppercase-keybindings.test.ts +25 -12
  38. package/src/cli/ansi.ts +2 -2
  39. package/src/cli/bootstrap.ts +33 -2
  40. package/src/cli/doctor.ts +105 -14
  41. package/src/cli/install.ts +8 -2
  42. package/src/cli/profile.ts +18 -1
  43. package/src/cli/router.ts +31 -18
  44. package/src/cli/update-view.ts +1 -1
  45. package/src/cli/upgrade.ts +37 -9
  46. package/src/data/cli-tools.ts +13 -2
  47. package/src/data/npm-package.ts +15 -0
  48. package/src/services/claude-settings.ts +151 -73
  49. package/src/services/cli-tool-commands.ts +15 -5
  50. package/src/services/cli-tool-version.ts +101 -0
  51. package/src/services/hook-registration.ts +23 -6
  52. package/src/services/manifest.ts +117 -52
  53. package/src/services/plugin-manager.ts +19 -5
  54. package/src/services/profile-adopt.ts +7 -2
  55. package/src/services/profile-startup.ts +87 -7
  56. package/src/services/profiles.ts +36 -23
  57. package/src/services/settings-file.ts +158 -4
  58. package/src/services/settings-manager.ts +19 -5
  59. package/src/services/skills-manager.ts +46 -0
  60. package/src/services/tool-probe.ts +272 -45
  61. package/src/services/toolchain.ts +39 -0
  62. package/src/services/update-engine.ts +5 -0
  63. package/src/services/update-plan.ts +9 -0
  64. package/src/services/version-check.ts +20 -10
  65. package/src/tui.tsx +48 -21
  66. package/src/ui/App.tsx +58 -197
  67. package/src/ui/adapters/cliToolsAdapter.ts +141 -12
  68. package/src/ui/adapters/pluginsAdapter.ts +10 -7
  69. package/src/ui/adapters/settingsAdapter.ts +0 -21
  70. package/src/ui/adapters/skillsAdapter.ts +27 -8
  71. package/src/ui/adapters/stylesAdapter.ts +15 -11
  72. package/src/ui/components/CategoryHeader.tsx +7 -1
  73. package/src/ui/components/EmptyFilterState.tsx +2 -2
  74. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  75. package/src/ui/components/ScreenErrorBoundary.tsx +105 -0
  76. package/src/ui/components/TabBar.tsx +93 -21
  77. package/src/ui/components/layout/FooterHints.tsx +6 -27
  78. package/src/ui/components/layout/ScreenLayout.tsx +68 -42
  79. package/src/ui/components/layout/index.ts +1 -5
  80. package/src/ui/components/modals/ConfirmModal.tsx +2 -19
  81. package/src/ui/components/modals/HelpOverlay.tsx +186 -0
  82. package/src/ui/components/modals/MessageModal.tsx +3 -15
  83. package/src/ui/components/modals/ModalContainer.tsx +99 -60
  84. package/src/ui/components/primitives/ErrorNotice.tsx +38 -0
  85. package/src/ui/components/primitives/ListCategoryRow.tsx +18 -7
  86. package/src/ui/components/primitives/MetaText.tsx +1 -1
  87. package/src/ui/components/primitives/ScopeDetail.tsx +31 -26
  88. package/src/ui/components/primitives/ScopeSquares.tsx +23 -28
  89. package/src/ui/components/primitives/SelectableRow.tsx +24 -1
  90. package/src/ui/components/primitives/index.ts +4 -3
  91. package/src/ui/hooks/useProfileStartupModal.ts +15 -3
  92. package/src/ui/input/KeyRouter.tsx +180 -0
  93. package/src/ui/input/key-dispatch.ts +149 -0
  94. package/src/ui/keymap.ts +898 -0
  95. package/src/ui/renderers/cliToolRenderers.tsx +128 -66
  96. package/src/ui/renderers/gitignoreRenderers.tsx +41 -43
  97. package/src/ui/renderers/markdownRenderer.tsx +2 -2
  98. package/src/ui/renderers/mcpRenderers.tsx +28 -14
  99. package/src/ui/renderers/modelRenderers.tsx +12 -13
  100. package/src/ui/renderers/modelVisuals.tsx +0 -2
  101. package/src/ui/renderers/pluginRenderers.tsx +47 -54
  102. package/src/ui/renderers/profileRenderers.tsx +32 -37
  103. package/src/ui/renderers/settingsRenderers.tsx +84 -55
  104. package/src/ui/renderers/skillRenderers.tsx +237 -190
  105. package/src/ui/renderers/styleRenderers.tsx +13 -2
  106. package/src/ui/runtime/shutdown.ts +268 -0
  107. package/src/ui/screens/AliasScreen.tsx +118 -100
  108. package/src/ui/screens/CliToolsScreen.tsx +98 -87
  109. package/src/ui/screens/EnvVarsScreen.tsx +44 -42
  110. package/src/ui/screens/GitignoreScreen.tsx +50 -55
  111. package/src/ui/screens/McpRegistryScreen.tsx +52 -86
  112. package/src/ui/screens/McpScreen.tsx +47 -33
  113. package/src/ui/screens/ModelsScreen.tsx +59 -104
  114. package/src/ui/screens/PluginsScreen.tsx +123 -127
  115. package/src/ui/screens/ProfilesScreen.tsx +49 -46
  116. package/src/ui/screens/SkillsScreen.tsx +184 -204
  117. package/src/ui/screens/StylesScreen.tsx +104 -157
  118. package/src/ui/state/AppContext.tsx +6 -0
  119. package/src/ui/state/DimensionsContext.tsx +1 -10
  120. package/src/ui/state/reducer.ts +5 -8
  121. package/src/ui/state/types.ts +11 -9
  122. package/src/ui/text-width.ts +341 -0
  123. package/src/ui/theme-mode.ts +34 -71
  124. package/src/ui/theme.ts +50 -28
  125. package/src/utils/request-gate.ts +37 -0
  126. package/src/ui/components/ScopeIndicator.tsx +0 -63
  127. package/src/ui/components/SearchInput.tsx +0 -57
  128. package/src/ui/hooks/index.ts +0 -2
  129. package/src/ui/hooks/useKeyboard.ts +0 -24
  130. package/src/ui/hooks/useKeyboardHandler.ts +0 -63
@@ -0,0 +1,280 @@
1
+ /**
2
+ * magus talks to npm by its PACKAGE name, never by its COMMAND name.
3
+ *
4
+ * The npm package is `package.json` `name` (`magus-cli`). The command users
5
+ * type is `magus`. The bare npm name belongs to an unrelated 2015 package
6
+ * whose latest release is 0.0.2, and npm never transfers names, so the split
7
+ * is permanent. Addressing npm by the command name made the update banner go
8
+ * silent and pointed the CLI Tools row at the wrong package.
9
+ *
10
+ * This file uses only exports that existed before the fix, so on the old tree
11
+ * it fails by VALUE (wrong URL, wrong packageName, wrong argv), not merely at
12
+ * import. Written black-box from the API contract.
13
+ *
14
+ * No network: every checkForUpdates call runs against a stubbed fetch, always
15
+ * with force = true, because the result is cached for the session.
16
+ */
17
+
18
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
19
+ import { readFileSync } from "node:fs";
20
+ import { join } from "node:path";
21
+ import { cliTools } from "../data/cli-tools.js";
22
+ import {
23
+ cliToolInstall,
24
+ cliToolUninstall,
25
+ cliToolUpdate,
26
+ } from "../services/cli-tool-commands.js";
27
+ import { cliToolToBin } from "../services/resolver.js";
28
+ import { specForCatalogTool } from "../services/tool-probe.js";
29
+ import {
30
+ checkForUpdates,
31
+ formatUpdateMessage,
32
+ getCurrentVersion,
33
+ } from "../services/version-check.js";
34
+
35
+ // Read at test time: the single source of truth the requirements name.
36
+ const PKG = JSON.parse(
37
+ readFileSync(join(import.meta.dir, "..", "..", "package.json"), "utf8"),
38
+ ) as { name: string; version: string; bin: Record<string, string> };
39
+
40
+ /** The command users type. Also, unluckily, an unrelated npm package. */
41
+ const COMMAND = "magus";
42
+ /** The npm package, as the contract states it. */
43
+ const PACKAGE = "magus-cli";
44
+ const REGISTRY = "https://registry.npmjs.org";
45
+
46
+ function magusEntry() {
47
+ const entry = cliTools.find((t) => t.name === COMMAND);
48
+ if (!entry) throw new Error("cliTools has no entry named magus");
49
+ return entry;
50
+ }
51
+
52
+ function jsonResponse(body: unknown, status = 200): Response {
53
+ return new Response(JSON.stringify(body), {
54
+ status,
55
+ headers: { "content-type": "application/json" },
56
+ });
57
+ }
58
+
59
+ function urlOf(input: unknown): string {
60
+ return input instanceof Request ? input.url : String(input);
61
+ }
62
+
63
+ describe("checkForUpdates asks npm about the package, not the command", () => {
64
+ const realFetch = globalThis.fetch;
65
+ let urls: string[];
66
+
67
+ beforeEach(() => {
68
+ urls = [];
69
+ });
70
+
71
+ afterEach(() => {
72
+ globalThis.fetch = realFetch;
73
+ });
74
+
75
+ function stubFetch(answer: (url: string) => Response | Promise<Response>) {
76
+ globalThis.fetch = (async (input: unknown) => {
77
+ const url = urlOf(input);
78
+ urls.push(url);
79
+ return answer(url);
80
+ }) as typeof fetch;
81
+ }
82
+
83
+ test("fetches the registry `latest` document of package.json `name`, once", async () => {
84
+ stubFetch(() => jsonResponse({ version: "999.0.0" }));
85
+
86
+ await checkForUpdates(true);
87
+
88
+ expect(urls).toHaveLength(1);
89
+ expect(urls[0]).toBe(`${REGISTRY}/${PKG.name}/latest`);
90
+ expect(urls[0]).toBe(`${REGISTRY}/${PACKAGE}/latest`);
91
+ // The first path segment is the package, never the command.
92
+ const segment = new URL(urls[0]!).pathname.split("/")[1];
93
+ expect(segment).toBe(PKG.name);
94
+ expect(segment).not.toBe(COMMAND);
95
+ });
96
+
97
+ test("with the registry as observed (bare name at 0.0.2, package ahead), an update is reported", async () => {
98
+ // A registry that answers per package, the way npm does: the unrelated
99
+ // package sits at 0.0.2, the real one is ahead of the running version.
100
+ const registry: Record<string, string> = {
101
+ magus: "0.0.2",
102
+ [PKG.name]: "999.0.0",
103
+ };
104
+ stubFetch((url) => {
105
+ const name = new URL(url).pathname.split("/")[1] ?? "";
106
+ const version = registry[name];
107
+ return version
108
+ ? jsonResponse({ version })
109
+ : jsonResponse({ error: "Not found" }, 404);
110
+ });
111
+
112
+ const r = await checkForUpdates(true);
113
+
114
+ expect(r.currentVersion).toBe(PKG.version);
115
+ expect(r.latestVersion).toBe("999.0.0");
116
+ expect(r.updateAvailable).toBe(true);
117
+ });
118
+
119
+ test("a major release above the running version is a major update", async () => {
120
+ stubFetch(() => jsonResponse({ version: "999.0.0" }));
121
+
122
+ const r = await checkForUpdates(true);
123
+
124
+ expect(r.updateAvailable).toBe(true);
125
+ expect(r.updateType).toBe("major");
126
+ expect(r.latestVersion).toBe("999.0.0");
127
+ });
128
+
129
+ test("the unrelated package's 0.0.2 is not an update", async () => {
130
+ stubFetch(() => jsonResponse({ version: "0.0.2" }));
131
+
132
+ const r = await checkForUpdates(true);
133
+
134
+ expect(r.updateAvailable).toBe(false);
135
+ });
136
+
137
+ test("a non-ok registry response reports no update and keeps the running version", async () => {
138
+ stubFetch(() => jsonResponse({ error: "boom" }, 500));
139
+
140
+ const r = await checkForUpdates(true);
141
+
142
+ expect(r.latestVersion).toBe(r.currentVersion);
143
+ expect(r.updateAvailable).toBe(false);
144
+ });
145
+
146
+ test("a failed fetch reports no update and keeps the running version", async () => {
147
+ stubFetch(() => {
148
+ throw new Error("network down");
149
+ });
150
+
151
+ const r = await checkForUpdates(true);
152
+
153
+ expect(r.latestVersion).toBe(r.currentVersion);
154
+ expect(r.updateAvailable).toBe(false);
155
+ });
156
+
157
+ test("the running version is package.json `version`", () => {
158
+ expect(getCurrentVersion()).toBe(PKG.version);
159
+ });
160
+ });
161
+
162
+ describe("formatUpdateMessage", () => {
163
+ // Kept as a guard on the hint text; not V1 evidence (amendment L5).
164
+ test("names `magus upgrade` as the way to update", () => {
165
+ expect(
166
+ formatUpdateMessage({
167
+ currentVersion: "1.0.0",
168
+ latestVersion: "1.2.0",
169
+ updateAvailable: true,
170
+ updateType: "minor",
171
+ } as any),
172
+ ).toBe("Update available: v1.0.0 → v1.2.0 (minor). Run: magus upgrade");
173
+ });
174
+
175
+ test("is empty when no update is available", () => {
176
+ expect(
177
+ formatUpdateMessage({
178
+ currentVersion: "1.0.0",
179
+ latestVersion: "1.0.0",
180
+ updateAvailable: false,
181
+ } as any),
182
+ ).toBe("");
183
+ });
184
+ });
185
+
186
+ describe("the CLI Tools entry for magus", () => {
187
+ test("targets the npm package and keeps the command", () => {
188
+ const entry = magusEntry();
189
+
190
+ expect(entry.packageName).toBe(PKG.name);
191
+ expect(entry.packageName).toBe(PACKAGE);
192
+ // R6: everything that names the command is unchanged.
193
+ expect(entry.name).toBe(COMMAND);
194
+ expect(entry.displayName).toBe(COMMAND);
195
+ expect(entry.checkCommand).toBe("magus --version");
196
+ expect(entry.packageManager).toBe("bun");
197
+ });
198
+
199
+ test("install targets the package", () => {
200
+ expect(cliToolInstall(magusEntry())).toEqual({
201
+ cmd: "bun",
202
+ args: ["install", "-g", PACKAGE],
203
+ });
204
+ });
205
+
206
+ test("update targets `<package>@latest` with bun and with npm", () => {
207
+ const entry = magusEntry();
208
+ expect(cliToolUpdate(entry, "bun")).toEqual({
209
+ cmd: "bun",
210
+ args: ["install", "-g", `${PACKAGE}@latest`],
211
+ });
212
+ expect(cliToolUpdate(entry, "npm")).toEqual({
213
+ cmd: "npm",
214
+ args: ["install", "-g", `${PACKAGE}@latest`],
215
+ });
216
+ });
217
+
218
+ test("uninstall targets the package with bun and with npm", () => {
219
+ const entry = magusEntry();
220
+ expect(cliToolUninstall(entry, "bun")).toEqual({
221
+ cmd: "bun",
222
+ args: ["remove", "-g", PACKAGE],
223
+ });
224
+ expect(cliToolUninstall(entry, "npm")).toEqual({
225
+ cmd: "npm",
226
+ args: ["uninstall", "-g", PACKAGE],
227
+ });
228
+ });
229
+
230
+ test("no derived command addresses the bare command name on npm", () => {
231
+ const entry = magusEntry();
232
+ const commands = [
233
+ cliToolInstall(entry),
234
+ cliToolUpdate(entry, "bun"),
235
+ cliToolUpdate(entry, "npm"),
236
+ cliToolUninstall(entry, "bun"),
237
+ cliToolUninstall(entry, "npm"),
238
+ ];
239
+ for (const c of commands) {
240
+ expect(c).not.toBeNull();
241
+ const args = c!.args as string[];
242
+ const target = args[args.indexOf("-g") + 1] ?? "";
243
+ // `<package>` or `<package>@<tag>`, never the command name.
244
+ expect(target.split("@")[0]).toBe(PKG.name);
245
+ expect(args).not.toContain(COMMAND);
246
+ }
247
+ });
248
+ });
249
+
250
+ describe("pins and probes resolve to the package", () => {
251
+ test("a pinned version installs `<package>@<version>` and checks the command", () => {
252
+ expect(cliToolToBin(COMMAND, PKG.version)).toMatchObject({
253
+ name: COMMAND,
254
+ via: "bun",
255
+ package: PACKAGE,
256
+ check: "magus --version",
257
+ version: PKG.version,
258
+ });
259
+ });
260
+
261
+ test("`latest` carries no version", () => {
262
+ const bin = cliToolToBin(COMMAND, "latest");
263
+ expect(bin).toMatchObject({
264
+ name: COMMAND,
265
+ via: "bun",
266
+ package: PACKAGE,
267
+ check: "magus --version",
268
+ });
269
+ expect(bin.version).toBeUndefined();
270
+ });
271
+
272
+ test("the catalogue spec (the dist-tags probe input) names the package", () => {
273
+ expect(specForCatalogTool(magusEntry(), null, undefined)).toMatchObject({
274
+ name: COMMAND,
275
+ via: "bun",
276
+ package: PACKAGE,
277
+ check: "magus --version",
278
+ });
279
+ });
280
+ });
@@ -1,25 +1,16 @@
1
- import { afterEach, describe, expect, test } from "bun:test";
1
+ import { describe, expect, test } from "bun:test";
2
2
  import type React from "react";
3
3
  import { ScopeSquares } from "../ui/components/primitives/ScopeSquares.js";
4
- import {
5
- SCOPE_OFF_FILL,
6
- resetThemeMode,
7
- setThemeMode,
8
- } from "../ui/theme-mode.js";
9
- import { CONTRAST_REFERENCE, theme } from "../ui/theme.js";
4
+ import { theme } from "../ui/theme.js";
10
5
 
11
6
  /**
12
- * The scope bar is three filled segments; only the fill says lit or unlit.
7
+ * The scope bar is three positional squares: user, project, local.
13
8
  *
14
- * Its unlit fill was `theme.colors.muted` (#6B7280) — the same mid-dark grey as
15
- * de-emphasised text. On a light page that is a *dark* block, so an unlit segment
16
- * carried the same visual weight as a lit one and the bar read as three similar
17
- * blobs: "non installed state is dark as well - confusing".
18
- *
19
- * An unlit segment has to recede toward the page, which is exactly what a fixed
20
- * colour cannot do — a pale fill that sits quietly on cream is a bright block on
21
- * near-black. So the fill follows the resolved mode, and these tests pin both
22
- * that behaviour and the dark default that applies before resolution.
9
+ * It used to be three FILLED blocks told apart only by fill colour — a scope
10
+ * hue when lit, a page-relative grey when not — so without colour (a
11
+ * monochrome terminal, a colour-blind reader, a theme drawing two hues alike)
12
+ * every state was the same shape. Now position names the scope and SHAPE says
13
+ * the state: `■` installed, `□` not. Colour only repeats what the shape says.
23
14
  */
24
15
 
25
16
  interface SpanLike {
@@ -35,129 +26,52 @@ const glyphs = (el: React.ReactElement) =>
35
26
  segments(el).map((c) => c.props.children);
36
27
  const fills = (el: React.ReactElement) => segments(el).map((c) => c.props.fg);
37
28
 
38
- function relativeLuminance(hex: string): number {
39
- const n = hex.replace("#", "");
40
- const ch = [0, 2, 4].map((i) => Number.parseInt(n.slice(i, i + 2), 16) / 255);
41
- const lin = ch.map((c) =>
42
- c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4,
43
- );
44
- return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2];
45
- }
46
-
47
- afterEach(() => resetThemeMode());
48
-
49
- describe("every segment is a filled block", () => {
50
- test("lit and unlit alike use the filled glyph", () => {
51
- setThemeMode("light");
29
+ describe("shape says the state", () => {
30
+ test("installed is filled, not installed is hollow, by position", () => {
52
31
  const el = ScopeSquares({ user: true, project: false, local: true });
53
- expect(glyphs(el)).toEqual(["■", "■", "■"]);
32
+ expect(glyphs(el)).toEqual(["■", "□", "■"]);
54
33
  });
55
34
 
56
35
  test("local is omitted when the caller does not pass it", () => {
57
- setThemeMode("light");
58
36
  expect(glyphs(ScopeSquares({ user: true, project: false }))).toEqual([
59
37
  "■",
60
- "■",
61
- ]);
62
- });
63
- });
64
-
65
- describe("fill distinguishes lit from unlit", () => {
66
- test("each lit segment keeps its own scope hue", () => {
67
- setThemeMode("light");
68
- const el = ScopeSquares({ user: true, project: true, local: true });
69
- expect(fills(el)).toEqual([
70
- theme.scopes.user,
71
- theme.scopes.project,
72
- theme.scopes.local,
38
+ "□",
73
39
  ]);
74
40
  });
75
41
 
76
- test("unlit segments take the page-relative off fill", () => {
77
- setThemeMode("light");
78
- const el = ScopeSquares({ user: false, project: false, local: false });
79
- expect(new Set(fills(el))).toEqual(new Set([SCOPE_OFF_FILL.light]));
80
-
81
- setThemeMode("dark");
82
- const dark = ScopeSquares({ user: false, project: false, local: false });
83
- expect(new Set(fills(dark))).toEqual(new Set([SCOPE_OFF_FILL.dark]));
42
+ test("the two states never share a glyph", () => {
43
+ const on = glyphs(ScopeSquares({ user: true, project: true, local: true }));
44
+ const off = glyphs(
45
+ ScopeSquares({ user: false, project: false, local: false }),
46
+ );
47
+ expect(new Set(on)).toEqual(new Set(["■"]));
48
+ expect(new Set(off)).toEqual(new Set(["□"]));
84
49
  });
50
+ });
85
51
 
86
- test("a mixed bar shows lit and unlit fills side by side", () => {
87
- setThemeMode("light");
52
+ describe("colour repeats the shape", () => {
53
+ test("a filled square wears success, a hollow one muted", () => {
88
54
  const el = ScopeSquares({ user: false, project: true, local: false });
89
55
  expect(fills(el)).toEqual([
90
- SCOPE_OFF_FILL.light,
91
- theme.scopes.project,
92
- SCOPE_OFF_FILL.light,
56
+ theme.colors.muted,
57
+ theme.colors.success,
58
+ theme.colors.muted,
93
59
  ]);
94
60
  });
95
-
96
- test("before resolution the fill is the dark pair", () => {
97
- resetThemeMode();
98
- const el = ScopeSquares({ user: false, project: false, local: false });
99
- expect(new Set(fills(el))).toEqual(new Set([SCOPE_OFF_FILL.dark]));
100
- });
101
- });
102
-
103
- describe("the unlit fill recedes toward the page it is on", () => {
104
- /**
105
- * How far a fill sits from the page, relative to the NEAREST lit segment.
106
- *
107
- * The naive property — "unlit is closer to the page than a lit colour" — is
108
- * too weak, and measurably so: the old #6B7280 satisfied it on a light page
109
- * (0.766 vs 0.774) while still looking wrong. What made the bar read as three
110
- * similar blobs was that unlit sat **99% as far** from the page as a lit
111
- * segment: essentially the same visual weight. Ratio, not order, is the
112
- * property that captures the defect.
113
- */
114
- const pageDistanceRatio = (fill: string, page: string) => {
115
- const d = (hex: string) =>
116
- Math.abs(relativeLuminance(hex) - relativeLuminance(page));
117
- const nearestLit = Math.min(...Object.values(theme.scopes).map(d));
118
- return d(fill) / nearestLit;
119
- };
120
-
121
- /** Unlit must be at most half as far from the page as the nearest lit segment. */
122
- const MAX_RATIO = 0.5;
123
-
124
- test("light page: unlit recedes (measured 0.32)", () => {
125
- expect(
126
- pageDistanceRatio(SCOPE_OFF_FILL.light, CONTRAST_REFERENCE.light),
127
- ).toBeLessThan(MAX_RATIO);
128
- });
129
-
130
- test("dark page: unlit recedes (measured 0.22)", () => {
131
- expect(
132
- pageDistanceRatio(SCOPE_OFF_FILL.dark, CONTRAST_REFERENCE.dark),
133
- ).toBeLessThan(MAX_RATIO);
134
- });
135
-
136
- test("the old mid-grey fails the property on BOTH pages", () => {
137
- // Pins why the value changed, and guards against anyone reinstating it:
138
- // 0.99 on cream and 1.10 on near-black — an unlit segment as heavy as a lit
139
- // one, or heavier.
140
- expect(
141
- pageDistanceRatio("#6B7280", CONTRAST_REFERENCE.light),
142
- ).toBeGreaterThan(MAX_RATIO);
143
- expect(
144
- pageDistanceRatio("#6B7280", CONTRAST_REFERENCE.dark),
145
- ).toBeGreaterThan(MAX_RATIO);
146
- });
147
61
  });
148
62
 
149
63
  describe("selected rows", () => {
150
- test("unlit segments recede into the selection block, lit take its ink", () => {
151
- setThemeMode("light");
64
+ test("filled squares take the selection ink, hollow ones its dim ink", () => {
152
65
  const el = ScopeSquares({
153
66
  user: true,
154
67
  project: false,
155
68
  local: true,
156
69
  selected: true,
157
70
  });
71
+ expect(glyphs(el)).toEqual(["■", "□", "■"]);
158
72
  expect(fills(el)).toEqual([
159
73
  theme.selection.fg,
160
- theme.selection.bg,
74
+ theme.selection.dim,
161
75
  theme.selection.fg,
162
76
  ]);
163
77
  });