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.
- package/package.json +4 -4
- package/scripts/build-binaries.ts +4 -2
- package/src/__tests__/blind/c1-npm-package.test.ts +206 -0
- package/src/__tests__/blind/c10-colour-roles.test.ts +265 -0
- package/src/__tests__/blind/c11-cli-parse-refusal.test.ts +454 -0
- package/src/__tests__/blind/c12-skill-frontmatter.test.ts +323 -0
- package/src/__tests__/blind/c13-wrong-shape.test.ts +603 -0
- package/src/__tests__/blind/c14-cli-row-label.test.ts +259 -0
- package/src/__tests__/blind/c15-probe-ownership.test.ts +533 -0
- package/src/__tests__/blind/c16-nested-wrong-shape.test.ts +297 -0
- package/src/__tests__/blind/c17-enter-label.test.ts +625 -0
- package/src/__tests__/blind/c18-hook-shape.test.ts +354 -0
- package/src/__tests__/blind/c2-settings-parse.test.ts +350 -0
- package/src/__tests__/blind/c4-key-dispatch.test.ts +397 -0
- package/src/__tests__/blind/c5-keymap.test.ts +461 -0
- package/src/__tests__/blind/c6-request-gate.test.ts +133 -0
- package/src/__tests__/blind/c7-tab-tiers.test.ts +320 -0
- package/src/__tests__/blind/c8-text-width.test.ts +137 -0
- package/src/__tests__/blind/c9-cli-tool-version.test.ts +239 -0
- package/src/__tests__/blind/child-run.ts +114 -0
- package/src/__tests__/cli-tools-adapter.test.ts +5 -2
- package/src/__tests__/component-badge.test.ts +20 -36
- package/src/__tests__/footer-hints.test.ts +1 -1
- package/src/__tests__/manifest.test.ts +25 -17
- package/src/__tests__/marketplace-badge.test.ts +4 -4
- package/src/__tests__/models-screen-state.test.ts +36 -13
- package/src/__tests__/npm-package-name-api.test.ts +111 -0
- package/src/__tests__/npm-package-name.test.ts +280 -0
- package/src/__tests__/scope-squares.test.tsx +28 -114
- package/src/__tests__/shutdown.test.ts +578 -0
- package/src/__tests__/styles-manager.test.ts +4 -4
- package/src/__tests__/styles-screen-state.test.ts +26 -12
- package/src/__tests__/tabbar-layout.test.ts +44 -32
- package/src/__tests__/theme-adaptive-colors.test.ts +6 -59
- package/src/__tests__/tool-probe.test.ts +47 -22
- package/src/__tests__/upgrade-spawn.test.ts +355 -0
- package/src/__tests__/uppercase-keybindings.test.ts +25 -12
- package/src/cli/ansi.ts +2 -2
- package/src/cli/bootstrap.ts +33 -2
- package/src/cli/doctor.ts +105 -14
- package/src/cli/install.ts +8 -2
- package/src/cli/profile.ts +18 -1
- package/src/cli/router.ts +31 -18
- package/src/cli/update-view.ts +1 -1
- package/src/cli/upgrade.ts +37 -9
- package/src/data/cli-tools.ts +13 -2
- package/src/data/npm-package.ts +15 -0
- package/src/services/claude-settings.ts +151 -73
- package/src/services/cli-tool-commands.ts +15 -5
- package/src/services/cli-tool-version.ts +101 -0
- package/src/services/hook-registration.ts +23 -6
- package/src/services/manifest.ts +117 -52
- package/src/services/plugin-manager.ts +19 -5
- package/src/services/profile-adopt.ts +7 -2
- package/src/services/profile-startup.ts +87 -7
- package/src/services/profiles.ts +36 -23
- package/src/services/settings-file.ts +158 -4
- package/src/services/settings-manager.ts +19 -5
- package/src/services/skills-manager.ts +46 -0
- package/src/services/tool-probe.ts +272 -45
- package/src/services/toolchain.ts +39 -0
- package/src/services/update-engine.ts +5 -0
- package/src/services/update-plan.ts +9 -0
- package/src/services/version-check.ts +20 -10
- package/src/tui.tsx +48 -21
- package/src/ui/App.tsx +58 -197
- package/src/ui/adapters/cliToolsAdapter.ts +141 -12
- package/src/ui/adapters/pluginsAdapter.ts +10 -7
- package/src/ui/adapters/settingsAdapter.ts +0 -21
- package/src/ui/adapters/skillsAdapter.ts +27 -8
- package/src/ui/adapters/stylesAdapter.ts +15 -11
- package/src/ui/components/CategoryHeader.tsx +7 -1
- package/src/ui/components/EmptyFilterState.tsx +2 -2
- package/src/ui/components/FlagDetailEditor.tsx +0 -0
- package/src/ui/components/ScreenErrorBoundary.tsx +105 -0
- package/src/ui/components/TabBar.tsx +93 -21
- package/src/ui/components/layout/FooterHints.tsx +6 -27
- package/src/ui/components/layout/ScreenLayout.tsx +68 -42
- package/src/ui/components/layout/index.ts +1 -5
- package/src/ui/components/modals/ConfirmModal.tsx +2 -19
- package/src/ui/components/modals/HelpOverlay.tsx +186 -0
- package/src/ui/components/modals/MessageModal.tsx +3 -15
- package/src/ui/components/modals/ModalContainer.tsx +99 -60
- package/src/ui/components/primitives/ErrorNotice.tsx +38 -0
- package/src/ui/components/primitives/ListCategoryRow.tsx +18 -7
- package/src/ui/components/primitives/MetaText.tsx +1 -1
- package/src/ui/components/primitives/ScopeDetail.tsx +31 -26
- package/src/ui/components/primitives/ScopeSquares.tsx +23 -28
- package/src/ui/components/primitives/SelectableRow.tsx +24 -1
- package/src/ui/components/primitives/index.ts +4 -3
- package/src/ui/hooks/useProfileStartupModal.ts +15 -3
- package/src/ui/input/KeyRouter.tsx +180 -0
- package/src/ui/input/key-dispatch.ts +149 -0
- package/src/ui/keymap.ts +898 -0
- package/src/ui/renderers/cliToolRenderers.tsx +128 -66
- package/src/ui/renderers/gitignoreRenderers.tsx +41 -43
- package/src/ui/renderers/markdownRenderer.tsx +2 -2
- package/src/ui/renderers/mcpRenderers.tsx +28 -14
- package/src/ui/renderers/modelRenderers.tsx +12 -13
- package/src/ui/renderers/modelVisuals.tsx +0 -2
- package/src/ui/renderers/pluginRenderers.tsx +47 -54
- package/src/ui/renderers/profileRenderers.tsx +32 -37
- package/src/ui/renderers/settingsRenderers.tsx +84 -55
- package/src/ui/renderers/skillRenderers.tsx +237 -190
- package/src/ui/renderers/styleRenderers.tsx +13 -2
- package/src/ui/runtime/shutdown.ts +268 -0
- package/src/ui/screens/AliasScreen.tsx +118 -100
- package/src/ui/screens/CliToolsScreen.tsx +98 -87
- package/src/ui/screens/EnvVarsScreen.tsx +44 -42
- package/src/ui/screens/GitignoreScreen.tsx +50 -55
- package/src/ui/screens/McpRegistryScreen.tsx +52 -86
- package/src/ui/screens/McpScreen.tsx +47 -33
- package/src/ui/screens/ModelsScreen.tsx +59 -104
- package/src/ui/screens/PluginsScreen.tsx +123 -127
- package/src/ui/screens/ProfilesScreen.tsx +49 -46
- package/src/ui/screens/SkillsScreen.tsx +184 -204
- package/src/ui/screens/StylesScreen.tsx +104 -157
- package/src/ui/state/AppContext.tsx +6 -0
- package/src/ui/state/DimensionsContext.tsx +1 -10
- package/src/ui/state/reducer.ts +5 -8
- package/src/ui/state/types.ts +11 -9
- package/src/ui/text-width.ts +341 -0
- package/src/ui/theme-mode.ts +34 -71
- package/src/ui/theme.ts +50 -28
- package/src/utils/request-gate.ts +37 -0
- package/src/ui/components/ScopeIndicator.tsx +0 -63
- package/src/ui/components/SearchInput.tsx +0 -57
- package/src/ui/hooks/index.ts +0 -2
- package/src/ui/hooks/useKeyboard.ts +0 -24
- 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 {
|
|
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
|
|
7
|
+
* The scope bar is three positional squares: user, project, local.
|
|
13
8
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
|
|
39
|
-
|
|
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("
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
91
|
-
theme.
|
|
92
|
-
|
|
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("
|
|
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.
|
|
74
|
+
theme.selection.dim,
|
|
161
75
|
theme.selection.fg,
|
|
162
76
|
]);
|
|
163
77
|
});
|