claudeup 4.35.1 → 4.37.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/src/__tests__/catalog-cache-store.test.ts +271 -0
- package/src/__tests__/catalog-notice.test.ts +155 -0
- package/src/__tests__/github-budget.test.ts +200 -0
- package/src/__tests__/plugin-manager-fallback.test.ts +200 -8
- package/src/__tests__/scope-squares.test.tsx +165 -0
- package/src/__tests__/theme-adaptive-colors.test.ts +307 -0
- package/src/__tests__/uppercase-keybindings.test.ts +101 -0
- package/src/main.tsx +21 -5
- package/src/opentui.d.ts +21 -12
- package/src/services/catalog-cache-store.ts +218 -0
- package/src/services/github-budget.ts +274 -0
- package/src/services/marketplace-catalog-git.ts +170 -0
- package/src/services/marketplace-catalog.ts +95 -0
- package/src/services/marketplace-fetcher.ts +310 -87
- package/src/services/plugin-manager.ts +103 -92
- package/src/ui/App.tsx +19 -12
- package/src/ui/adapters/catalogNotice.ts +122 -0
- package/src/ui/adapters/pluginsAdapter.ts +174 -168
- package/src/ui/adapters/settingsAdapter.ts +119 -116
- package/src/ui/adapters/skillsAdapter.ts +203 -196
- package/src/ui/components/CategoryHeader.tsx +9 -8
- package/src/ui/components/EmptyFilterState.tsx +10 -5
- package/src/ui/components/FlagDetailEditor.tsx +0 -0
- package/src/ui/components/ScopeIndicator.tsx +10 -6
- package/src/ui/components/ScrollableList.tsx +3 -2
- package/src/ui/components/SearchInput.tsx +2 -1
- package/src/ui/components/StyledText.tsx +5 -4
- package/src/ui/components/TabBar.tsx +4 -3
- package/src/ui/components/layout/FooterHints.tsx +37 -30
- package/src/ui/components/layout/Panel.tsx +6 -5
- package/src/ui/components/layout/ProgressBar.tsx +7 -6
- package/src/ui/components/layout/ScopeTabs.tsx +6 -5
- package/src/ui/components/layout/ScreenLayout.tsx +46 -26
- package/src/ui/components/layout/index.ts +3 -3
- package/src/ui/components/modals/ConfirmModal.tsx +12 -11
- package/src/ui/components/modals/InputModal.tsx +14 -6
- package/src/ui/components/modals/LoadingModal.tsx +6 -5
- package/src/ui/components/modals/MessageModal.tsx +9 -8
- package/src/ui/components/modals/SelectModal.tsx +11 -7
- package/src/ui/components/modals/VersionMismatchModal.tsx +14 -16
- package/src/ui/components/primitives/ActionHints.tsx +26 -26
- package/src/ui/components/primitives/DetailSection.tsx +13 -12
- package/src/ui/components/primitives/KeyValueLine.tsx +9 -8
- package/src/ui/components/primitives/ListCategoryRow.tsx +25 -27
- package/src/ui/components/primitives/MetaText.tsx +3 -3
- package/src/ui/components/primitives/ScopeDetail.tsx +48 -48
- package/src/ui/components/primitives/ScopeSquares.tsx +47 -22
- package/src/ui/components/primitives/SelectableRow.tsx +22 -16
- package/src/ui/hooks/useGitignoreModal.ts +78 -74
- package/src/ui/registry.ts +11 -11
- package/src/ui/renderers/cliToolRenderers.tsx +260 -203
- package/src/ui/renderers/gitignoreRenderers.tsx +43 -42
- package/src/ui/renderers/mcpRenderers.tsx +121 -117
- package/src/ui/renderers/pluginRenderers.tsx +566 -471
- package/src/ui/renderers/profileRenderers.tsx +346 -300
- package/src/ui/renderers/settingsRenderers.tsx +183 -176
- package/src/ui/renderers/skillRenderers.tsx +410 -326
- package/src/ui/screens/AliasScreen.tsx +1336 -1309
- package/src/ui/screens/CliToolsScreen.tsx +92 -40
- package/src/ui/screens/EnvVarsScreen.tsx +19 -13
- package/src/ui/screens/GitignoreScreen.tsx +510 -493
- package/src/ui/screens/McpRegistryScreen.tsx +28 -21
- package/src/ui/screens/McpScreen.tsx +12 -3
- package/src/ui/screens/PluginsScreen.tsx +152 -33
- package/src/ui/screens/ProfilesScreen.tsx +39 -23
- package/src/ui/screens/SkillsScreen.tsx +832 -688
- package/src/ui/state/reducer.ts +11 -2
- package/src/ui/state/types.ts +16 -1
- package/src/ui/theme-mode.ts +73 -0
- package/src/ui/theme.ts +147 -53
- package/src/utils/config-dir.ts +47 -0
|
@@ -1,13 +1,43 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach } from "bun:test";
|
|
1
|
+
import { describe, it, expect, beforeEach, afterAll, beforeAll } from "bun:test";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
2
5
|
import {
|
|
3
|
-
resolveMarketplacePlugins,
|
|
4
6
|
clearMarketplaceCache,
|
|
7
|
+
fetchMarketplacePlugins,
|
|
8
|
+
getMarketplaceFetchFailures,
|
|
9
|
+
resolveMarketplacePlugins,
|
|
5
10
|
} from "../services/marketplace-fetcher";
|
|
11
|
+
import { resetGitHubBudget } from "../services/github-budget";
|
|
6
12
|
import type {
|
|
7
13
|
LocalMarketplace,
|
|
8
14
|
LocalMarketplacePlugin,
|
|
9
15
|
} from "../services/local-marketplace";
|
|
10
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Point the marketplace-clone lookup at an EMPTY temp directory.
|
|
19
|
+
*
|
|
20
|
+
* `resolveMarketplacePlugins` now consults a git remote ref before falling back
|
|
21
|
+
* to the checked-out clone. Without this, these tests found the operator's real
|
|
22
|
+
* `magus` clone, performed a live `git fetch`, and asserted against whatever
|
|
23
|
+
* happened to be published — 17 real plugins instead of the 2 in the fixture, and
|
|
24
|
+
* three seconds per test.
|
|
25
|
+
*/
|
|
26
|
+
let prevConfigDir: string | undefined;
|
|
27
|
+
let emptyConfigDir: string;
|
|
28
|
+
|
|
29
|
+
beforeAll(() => {
|
|
30
|
+
emptyConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), "mp-fallback-"));
|
|
31
|
+
prevConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
|
32
|
+
process.env.CLAUDE_CONFIG_DIR = emptyConfigDir;
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterAll(() => {
|
|
36
|
+
if (prevConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR;
|
|
37
|
+
else process.env.CLAUDE_CONFIG_DIR = prevConfigDir;
|
|
38
|
+
fs.rmSync(emptyConfigDir, { recursive: true, force: true });
|
|
39
|
+
});
|
|
40
|
+
|
|
11
41
|
/**
|
|
12
42
|
* Regression coverage for the "deprecated everything" bug.
|
|
13
43
|
*
|
|
@@ -33,6 +63,10 @@ describe("resolveMarketplacePlugins — offline fallback", () => {
|
|
|
33
63
|
// Session-level cache could otherwise carry over a previous test's
|
|
34
64
|
// successful fetch and short-circuit the fallback path.
|
|
35
65
|
clearMarketplaceCache();
|
|
66
|
+
// The per-host cooldown is deliberately NOT cleared by clearMarketplaceCache
|
|
67
|
+
// — it reflects GitHub's state, not ours — so a 429 in one test would
|
|
68
|
+
// otherwise gate every fetch in the next for the full backoff.
|
|
69
|
+
resetGitHubBudget();
|
|
36
70
|
});
|
|
37
71
|
|
|
38
72
|
it("falls back to the local cache when the remote fetch returns empty", async () => {
|
|
@@ -64,18 +98,45 @@ describe("resolveMarketplacePlugins — offline fallback", () => {
|
|
|
64
98
|
local,
|
|
65
99
|
);
|
|
66
100
|
|
|
67
|
-
expect(resolved).toHaveLength(2);
|
|
68
|
-
expect(resolved.map((p) => p.name).sort()).toEqual([
|
|
101
|
+
expect(resolved.plugins).toHaveLength(2);
|
|
102
|
+
expect(resolved.plugins.map((p) => p.name).sort()).toEqual([
|
|
69
103
|
"code-analysis",
|
|
70
104
|
"dev",
|
|
71
105
|
]);
|
|
72
|
-
const codeAnalysis = resolved.find(
|
|
106
|
+
const codeAnalysis = resolved.plugins.find(
|
|
107
|
+
(p) => p.name === "code-analysis",
|
|
108
|
+
);
|
|
73
109
|
expect(codeAnalysis?.version).toBe("5.3.0");
|
|
74
110
|
expect(codeAnalysis?.description).toBe("Codebase investigation tools");
|
|
75
111
|
expect(codeAnalysis?.category).toBe("development");
|
|
76
112
|
expect(codeAnalysis?.author?.name).toBe("Jack Rudenko");
|
|
77
113
|
});
|
|
78
114
|
|
|
115
|
+
it("labels the fallback answer as local-clone, not remote", async () => {
|
|
116
|
+
// The regression this guards: the fallback plugins were returned in the
|
|
117
|
+
// same shape as a successful remote fetch, so the caller compared installed
|
|
118
|
+
// versions against a clone nothing refreshes and reported "up to date".
|
|
119
|
+
// The source must travel with the data.
|
|
120
|
+
const local = new Map<string, LocalMarketplace>([
|
|
121
|
+
[
|
|
122
|
+
"magus",
|
|
123
|
+
buildLocalMarketplace("magus", [
|
|
124
|
+
{ name: "terminal", version: "4.1.4", description: "" },
|
|
125
|
+
]),
|
|
126
|
+
],
|
|
127
|
+
]);
|
|
128
|
+
const resolved = await resolveMarketplacePlugins(
|
|
129
|
+
"magus",
|
|
130
|
+
"not a valid repo string",
|
|
131
|
+
local,
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
expect(resolved.source).toBe("local-clone");
|
|
135
|
+
expect(resolved.failure).toBeDefined();
|
|
136
|
+
expect(resolved.failure?.kind).toBe("invalid-repo");
|
|
137
|
+
expect(resolved.failure?.marketplace).toBe("magus");
|
|
138
|
+
});
|
|
139
|
+
|
|
79
140
|
it("returns empty when both remote and cache are empty", async () => {
|
|
80
141
|
const local = new Map<string, LocalMarketplace>();
|
|
81
142
|
const resolved = await resolveMarketplacePlugins(
|
|
@@ -83,7 +144,9 @@ describe("resolveMarketplacePlugins — offline fallback", () => {
|
|
|
83
144
|
"not a valid repo string",
|
|
84
145
|
local,
|
|
85
146
|
);
|
|
86
|
-
expect(resolved).toEqual([]);
|
|
147
|
+
expect(resolved.plugins).toEqual([]);
|
|
148
|
+
expect(resolved.source).toBe("none");
|
|
149
|
+
expect(resolved.failure).toBeDefined();
|
|
87
150
|
});
|
|
88
151
|
|
|
89
152
|
it("returns empty when remote fails AND cache exists but has no plugins", async () => {
|
|
@@ -95,7 +158,8 @@ describe("resolveMarketplacePlugins — offline fallback", () => {
|
|
|
95
158
|
"not a valid repo string",
|
|
96
159
|
local,
|
|
97
160
|
);
|
|
98
|
-
expect(resolved).toEqual([]);
|
|
161
|
+
expect(resolved.plugins).toEqual([]);
|
|
162
|
+
expect(resolved.source).toBe("none");
|
|
99
163
|
});
|
|
100
164
|
|
|
101
165
|
it("does not consult the cache when the remote returns plugins", async () => {
|
|
@@ -115,6 +179,134 @@ describe("resolveMarketplacePlugins — offline fallback", () => {
|
|
|
115
179
|
"not a valid repo string",
|
|
116
180
|
local,
|
|
117
181
|
);
|
|
118
|
-
expect(resolved).toEqual([]);
|
|
182
|
+
expect(resolved.plugins).toEqual([]);
|
|
183
|
+
expect(resolved.source).toBe("none");
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The defect class these cover: a failed check that renders as a passed one.
|
|
189
|
+
*
|
|
190
|
+
* `fetchMarketplacePlugins` used to return `[]` for a rate limit, a timeout, a
|
|
191
|
+
* bad repo string and a genuinely empty marketplace alike. Every caller then had
|
|
192
|
+
* to guess, and all of them guessed "fine".
|
|
193
|
+
*/
|
|
194
|
+
describe("fetchMarketplacePlugins — failures are values", () => {
|
|
195
|
+
beforeEach(() => {
|
|
196
|
+
clearMarketplaceCache();
|
|
197
|
+
resetGitHubBudget();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("classifies a rate limit distinctly from a network problem", async () => {
|
|
201
|
+
const original = globalThis.fetch;
|
|
202
|
+
globalThis.fetch = (async () =>
|
|
203
|
+
new Response("rate limited", {
|
|
204
|
+
status: 429,
|
|
205
|
+
statusText: "Too Many Requests",
|
|
206
|
+
})) as typeof globalThis.fetch;
|
|
207
|
+
try {
|
|
208
|
+
const result = await fetchMarketplacePlugins("magus", "MadAppGang/magus");
|
|
209
|
+
expect(result.plugins).toEqual([]);
|
|
210
|
+
expect(result.failure?.kind).toBe("rate-limited");
|
|
211
|
+
expect(result.failure?.httpStatus).toBe(429);
|
|
212
|
+
expect(result.failure?.detail).toContain("rate limit");
|
|
213
|
+
} finally {
|
|
214
|
+
globalThis.fetch = original;
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("records the failure so the UI can report it without re-fetching", async () => {
|
|
219
|
+
const original = globalThis.fetch;
|
|
220
|
+
let calls = 0;
|
|
221
|
+
globalThis.fetch = (async () => {
|
|
222
|
+
calls++;
|
|
223
|
+
return new Response("nope", { status: 500, statusText: "Server Error" });
|
|
224
|
+
}) as typeof globalThis.fetch;
|
|
225
|
+
try {
|
|
226
|
+
await fetchMarketplacePlugins("magus", "MadAppGang/magus");
|
|
227
|
+
|
|
228
|
+
const failures = getMarketplaceFetchFailures();
|
|
229
|
+
expect(failures.map((f) => f.marketplace)).toContain("magus");
|
|
230
|
+
expect(failures.find((f) => f.marketplace === "magus")?.kind).toBe("http");
|
|
231
|
+
|
|
232
|
+
// Second call must be served from the negative cache. Re-attempting on
|
|
233
|
+
// every screen mount is what cost 30-45s of "Loading..." per tab switch.
|
|
234
|
+
await fetchMarketplacePlugins("magus", "MadAppGang/magus");
|
|
235
|
+
expect(calls).toBe(1);
|
|
236
|
+
} finally {
|
|
237
|
+
globalThis.fetch = original;
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("reports no failures when the fetch succeeds", async () => {
|
|
242
|
+
const original = globalThis.fetch;
|
|
243
|
+
globalThis.fetch = (async () =>
|
|
244
|
+
Response.json({
|
|
245
|
+
metadata: { version: "9.0.3" },
|
|
246
|
+
plugins: [{ name: "terminal", version: "4.1.6" }],
|
|
247
|
+
})) as typeof globalThis.fetch;
|
|
248
|
+
try {
|
|
249
|
+
const result = await fetchMarketplacePlugins("magus", "MadAppGang/magus");
|
|
250
|
+
expect(result.failure).toBeUndefined();
|
|
251
|
+
expect(result.plugins).toHaveLength(1);
|
|
252
|
+
expect(getMarketplaceFetchFailures()).toEqual([]);
|
|
253
|
+
} finally {
|
|
254
|
+
globalThis.fetch = original;
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("does not send a request while the rate-limit window is still open", async () => {
|
|
259
|
+
// Deliberate: an explicit refresh clears OUR caches but not GitHub's limit.
|
|
260
|
+
// Retrying inside a known window cannot succeed, and on some endpoints a
|
|
261
|
+
// rejected request extends the penalty — so the screen waits and counts down
|
|
262
|
+
// instead. `clearMarketplaceCache()` must not be a way around that.
|
|
263
|
+
const original = globalThis.fetch;
|
|
264
|
+
let calls = 0;
|
|
265
|
+
globalThis.fetch = (async () => {
|
|
266
|
+
calls++;
|
|
267
|
+
return new Response("nope", { status: 429 });
|
|
268
|
+
}) as typeof globalThis.fetch;
|
|
269
|
+
try {
|
|
270
|
+
await fetchMarketplacePlugins("magus", "MadAppGang/magus");
|
|
271
|
+
expect(calls).toBe(1);
|
|
272
|
+
|
|
273
|
+
clearMarketplaceCache();
|
|
274
|
+
const second = await fetchMarketplacePlugins("magus", "MadAppGang/magus");
|
|
275
|
+
|
|
276
|
+
expect(calls).toBe(1); // no second request went out
|
|
277
|
+
expect(second.failure?.kind).toBe("rate-limited");
|
|
278
|
+
expect(second.failure?.retryAt).toBeGreaterThan(Date.now());
|
|
279
|
+
} finally {
|
|
280
|
+
globalThis.fetch = original;
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("clears the recorded failure once the window passes and the fetch succeeds", async () => {
|
|
285
|
+
const original = globalThis.fetch;
|
|
286
|
+
globalThis.fetch = (async () =>
|
|
287
|
+
new Response("nope", { status: 429 })) as typeof globalThis.fetch;
|
|
288
|
+
try {
|
|
289
|
+
await fetchMarketplacePlugins("magus", "MadAppGang/magus");
|
|
290
|
+
expect(getMarketplaceFetchFailures()).toHaveLength(1);
|
|
291
|
+
|
|
292
|
+
// The cooldown expiring is what unblocks the retry — this stands in for the
|
|
293
|
+
// clock reaching `retryAt`, which is when the screen refetches by itself.
|
|
294
|
+
resetGitHubBudget();
|
|
295
|
+
clearMarketplaceCache();
|
|
296
|
+
globalThis.fetch = (async () =>
|
|
297
|
+
Response.json({
|
|
298
|
+
plugins: [{ name: "terminal", version: "4.2.0" }],
|
|
299
|
+
})) as typeof globalThis.fetch;
|
|
300
|
+
|
|
301
|
+
const recovered = await fetchMarketplacePlugins(
|
|
302
|
+
"magus",
|
|
303
|
+
"MadAppGang/magus",
|
|
304
|
+
);
|
|
305
|
+
expect(recovered.failure).toBeUndefined();
|
|
306
|
+
expect(recovered.plugins).toHaveLength(1);
|
|
307
|
+
expect(getMarketplaceFetchFailures()).toEqual([]);
|
|
308
|
+
} finally {
|
|
309
|
+
globalThis.fetch = original;
|
|
310
|
+
}
|
|
119
311
|
});
|
|
120
312
|
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import type React from "react";
|
|
3
|
+
import { ScopeSquares } from "../ui/components/primitives/ScopeSquares.js";
|
|
4
|
+
import {
|
|
5
|
+
SCOPE_OFF_FALLBACK,
|
|
6
|
+
SCOPE_OFF_FILL,
|
|
7
|
+
resetThemeMode,
|
|
8
|
+
setThemeMode,
|
|
9
|
+
} from "../ui/theme-mode.js";
|
|
10
|
+
import { CONTRAST_REFERENCE, theme } from "../ui/theme.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The scope bar is three filled segments; only the fill says lit or unlit.
|
|
14
|
+
*
|
|
15
|
+
* Its unlit fill was `theme.colors.muted` (#6B7280) — the same mid-dark grey as
|
|
16
|
+
* de-emphasised text. On a light page that is a *dark* block, so an unlit segment
|
|
17
|
+
* carried the same visual weight as a lit one and the bar read as three similar
|
|
18
|
+
* blobs: "non installed state is dark as well - confusing".
|
|
19
|
+
*
|
|
20
|
+
* An unlit segment has to recede toward the page, which is exactly what a fixed
|
|
21
|
+
* colour cannot do — a pale fill that sits quietly on cream is a bright block on
|
|
22
|
+
* near-black. So the fill follows the detected terminal, and these tests pin both
|
|
23
|
+
* that behaviour and its fallback.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
interface SpanLike {
|
|
27
|
+
props: { fg: string; children: string };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function segments(el: React.ReactElement): SpanLike[] {
|
|
31
|
+
const children = (el.props as { children: unknown }).children;
|
|
32
|
+
return (Array.isArray(children) ? children : [children]) as SpanLike[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const glyphs = (el: React.ReactElement) =>
|
|
36
|
+
segments(el).map((c) => c.props.children);
|
|
37
|
+
const fills = (el: React.ReactElement) => segments(el).map((c) => c.props.fg);
|
|
38
|
+
|
|
39
|
+
function relativeLuminance(hex: string): number {
|
|
40
|
+
const n = hex.replace("#", "");
|
|
41
|
+
const ch = [0, 2, 4].map((i) => Number.parseInt(n.slice(i, i + 2), 16) / 255);
|
|
42
|
+
const lin = ch.map((c) =>
|
|
43
|
+
c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4,
|
|
44
|
+
);
|
|
45
|
+
return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
afterEach(() => resetThemeMode());
|
|
49
|
+
|
|
50
|
+
describe("every segment is a filled block", () => {
|
|
51
|
+
test("lit and unlit alike use the filled glyph", () => {
|
|
52
|
+
setThemeMode("light");
|
|
53
|
+
const el = ScopeSquares({ user: true, project: false, local: true });
|
|
54
|
+
expect(glyphs(el)).toEqual(["■", "■", "■"]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("local is omitted when the caller does not pass it", () => {
|
|
58
|
+
setThemeMode("light");
|
|
59
|
+
expect(glyphs(ScopeSquares({ user: true, project: false }))).toEqual([
|
|
60
|
+
"■",
|
|
61
|
+
"■",
|
|
62
|
+
]);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("fill distinguishes lit from unlit", () => {
|
|
67
|
+
test("each lit segment keeps its own scope hue", () => {
|
|
68
|
+
setThemeMode("light");
|
|
69
|
+
const el = ScopeSquares({ user: true, project: true, local: true });
|
|
70
|
+
expect(fills(el)).toEqual([
|
|
71
|
+
theme.scopes.user,
|
|
72
|
+
theme.scopes.project,
|
|
73
|
+
theme.scopes.local,
|
|
74
|
+
]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("unlit segments take the page-relative off fill", () => {
|
|
78
|
+
setThemeMode("light");
|
|
79
|
+
const el = ScopeSquares({ user: false, project: false, local: false });
|
|
80
|
+
expect(new Set(fills(el))).toEqual(new Set([SCOPE_OFF_FILL.light]));
|
|
81
|
+
|
|
82
|
+
setThemeMode("dark");
|
|
83
|
+
const dark = ScopeSquares({ user: false, project: false, local: false });
|
|
84
|
+
expect(new Set(fills(dark))).toEqual(new Set([SCOPE_OFF_FILL.dark]));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("a mixed bar shows lit and unlit fills side by side", () => {
|
|
88
|
+
setThemeMode("light");
|
|
89
|
+
const el = ScopeSquares({ user: false, project: true, local: false });
|
|
90
|
+
expect(fills(el)).toEqual([
|
|
91
|
+
SCOPE_OFF_FILL.light,
|
|
92
|
+
theme.scopes.project,
|
|
93
|
+
SCOPE_OFF_FILL.light,
|
|
94
|
+
]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("an undetected terminal still gets a visible fill, never nothing", () => {
|
|
98
|
+
resetThemeMode();
|
|
99
|
+
const el = ScopeSquares({ user: false, project: false, local: false });
|
|
100
|
+
expect(new Set(fills(el))).toEqual(new Set([SCOPE_OFF_FALLBACK]));
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("the unlit fill recedes toward the page it is on", () => {
|
|
105
|
+
/**
|
|
106
|
+
* How far a fill sits from the page, relative to the NEAREST lit segment.
|
|
107
|
+
*
|
|
108
|
+
* The naive property — "unlit is closer to the page than a lit colour" — is
|
|
109
|
+
* too weak, and measurably so: the old #6B7280 satisfied it on a light page
|
|
110
|
+
* (0.766 vs 0.774) while still looking wrong. What made the bar read as three
|
|
111
|
+
* similar blobs was that unlit sat **99% as far** from the page as a lit
|
|
112
|
+
* segment: essentially the same visual weight. Ratio, not order, is the
|
|
113
|
+
* property that captures the defect.
|
|
114
|
+
*/
|
|
115
|
+
const pageDistanceRatio = (fill: string, page: string) => {
|
|
116
|
+
const d = (hex: string) =>
|
|
117
|
+
Math.abs(relativeLuminance(hex) - relativeLuminance(page));
|
|
118
|
+
const nearestLit = Math.min(...Object.values(theme.scopes).map(d));
|
|
119
|
+
return d(fill) / nearestLit;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/** Unlit must be at most half as far from the page as the nearest lit segment. */
|
|
123
|
+
const MAX_RATIO = 0.5;
|
|
124
|
+
|
|
125
|
+
test("light page: unlit recedes (measured 0.32)", () => {
|
|
126
|
+
expect(
|
|
127
|
+
pageDistanceRatio(SCOPE_OFF_FILL.light, CONTRAST_REFERENCE.light),
|
|
128
|
+
).toBeLessThan(MAX_RATIO);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("dark page: unlit recedes (measured 0.22)", () => {
|
|
132
|
+
expect(
|
|
133
|
+
pageDistanceRatio(SCOPE_OFF_FILL.dark, CONTRAST_REFERENCE.dark),
|
|
134
|
+
).toBeLessThan(MAX_RATIO);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("the old mid-grey fails the property on BOTH pages", () => {
|
|
138
|
+
// Pins why the value changed, and guards against anyone reinstating it:
|
|
139
|
+
// 0.99 on cream and 1.10 on near-black — an unlit segment as heavy as a lit
|
|
140
|
+
// one, or heavier.
|
|
141
|
+
expect(
|
|
142
|
+
pageDistanceRatio("#6B7280", CONTRAST_REFERENCE.light),
|
|
143
|
+
).toBeGreaterThan(MAX_RATIO);
|
|
144
|
+
expect(
|
|
145
|
+
pageDistanceRatio("#6B7280", CONTRAST_REFERENCE.dark),
|
|
146
|
+
).toBeGreaterThan(MAX_RATIO);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("selected rows", () => {
|
|
151
|
+
test("unlit segments recede into the selection block, lit take its ink", () => {
|
|
152
|
+
setThemeMode("light");
|
|
153
|
+
const el = ScopeSquares({
|
|
154
|
+
user: true,
|
|
155
|
+
project: false,
|
|
156
|
+
local: true,
|
|
157
|
+
selected: true,
|
|
158
|
+
});
|
|
159
|
+
expect(fills(el)).toEqual([
|
|
160
|
+
theme.selection.fg,
|
|
161
|
+
theme.selection.bg,
|
|
162
|
+
theme.selection.fg,
|
|
163
|
+
]);
|
|
164
|
+
});
|
|
165
|
+
});
|