claudeup 4.35.0 → 4.36.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__/git-worktree.test.ts +108 -0
- 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/__tests__/worktree-registry-resolution.test.ts +107 -0
- package/src/main.tsx +21 -5
- package/src/opentui.d.ts +21 -12
- package/src/services/claude-settings.ts +37 -7
- package/src/services/git-worktree.ts +129 -0
- package/src/services/plugin-manager.ts +10 -1
- package/src/ui/App.tsx +19 -12
- 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 +27 -24
- 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 +528 -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 +17 -7
- package/src/ui/screens/ProfilesScreen.tsx +39 -23
- package/src/ui/screens/SkillsScreen.tsx +832 -688
- package/src/ui/theme-mode.ts +73 -0
- package/src/ui/theme.ts +147 -53
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claudeup",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.36.0",
|
|
4
4
|
"description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/main.tsx",
|
|
@@ -64,8 +64,8 @@
|
|
|
64
64
|
"typescript": "^5.6.3"
|
|
65
65
|
},
|
|
66
66
|
"optionalDependencies": {
|
|
67
|
-
"claudeup-darwin-arm64": "4.
|
|
68
|
-
"claudeup-darwin-x64": "4.
|
|
69
|
-
"claudeup-linux-x64": "4.
|
|
67
|
+
"claudeup-darwin-arm64": "4.36.0",
|
|
68
|
+
"claudeup-darwin-x64": "4.36.0",
|
|
69
|
+
"claudeup-linux-x64": "4.36.0"
|
|
70
70
|
}
|
|
71
71
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterAll,
|
|
3
|
+
beforeAll,
|
|
4
|
+
beforeEach,
|
|
5
|
+
describe,
|
|
6
|
+
expect,
|
|
7
|
+
test,
|
|
8
|
+
} from "bun:test";
|
|
9
|
+
import { execFileSync } from "node:child_process";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import fs from "fs-extra";
|
|
13
|
+
import {
|
|
14
|
+
clearWorktreeCache,
|
|
15
|
+
inheritablePaths,
|
|
16
|
+
} from "../services/git-worktree.js";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Exercised against real repositories rather than a mocked `git`, because the
|
|
20
|
+
* whole point of the function is agreeing with git about what a worktree is.
|
|
21
|
+
* A stub would have happily confirmed a wrong parse of `worktree list`.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
// Isolate from the operator's git config: a global `core.excludesFile`,
|
|
25
|
+
// `init.defaultBranch`, or commit signing would otherwise leak into these repos.
|
|
26
|
+
const GIT_CFG = [
|
|
27
|
+
"-c",
|
|
28
|
+
"user.name=test",
|
|
29
|
+
"-c",
|
|
30
|
+
"user.email=test@example.com",
|
|
31
|
+
"-c",
|
|
32
|
+
"commit.gpgsign=false",
|
|
33
|
+
"-c",
|
|
34
|
+
"init.defaultBranch=main",
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
const git = (cwd: string, ...args: string[]) =>
|
|
38
|
+
execFileSync("git", [...GIT_CFG, ...args], { cwd, encoding: "utf8" }).trim();
|
|
39
|
+
|
|
40
|
+
let base: string;
|
|
41
|
+
let mainTree: string;
|
|
42
|
+
let linked: string;
|
|
43
|
+
|
|
44
|
+
beforeAll(async () => {
|
|
45
|
+
// realpath: on macOS os.tmpdir() is a symlink, and git reports real paths.
|
|
46
|
+
base = await fs.realpath(
|
|
47
|
+
await fs.mkdtemp(path.join(os.tmpdir(), "claudeup-worktree-")),
|
|
48
|
+
);
|
|
49
|
+
mainTree = path.join(base, "repo");
|
|
50
|
+
linked = path.join(base, "linked");
|
|
51
|
+
|
|
52
|
+
await fs.ensureDir(mainTree);
|
|
53
|
+
git(mainTree, "init", "-q");
|
|
54
|
+
await fs.writeFile(path.join(mainTree, "README.md"), "hello\n");
|
|
55
|
+
git(mainTree, "add", "-A");
|
|
56
|
+
git(mainTree, "commit", "-qm", "initial");
|
|
57
|
+
git(mainTree, "worktree", "add", "-q", "-b", "feature", linked);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
afterAll(async () => {
|
|
61
|
+
if (base) await fs.remove(base);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
clearWorktreeCache();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("inheritablePaths", () => {
|
|
69
|
+
test("a linked worktree inherits from the main working tree", async () => {
|
|
70
|
+
expect(await inheritablePaths(linked)).toEqual([mainTree]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("the main working tree inherits nothing — it holds its own rows", async () => {
|
|
74
|
+
expect(await inheritablePaths(mainTree)).toEqual([]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("a subdirectory of a checkout inherits from the repository root", async () => {
|
|
78
|
+
const sub = path.join(mainTree, "packages", "app");
|
|
79
|
+
await fs.ensureDir(sub);
|
|
80
|
+
expect(await inheritablePaths(sub)).toEqual([mainTree]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("a subdirectory of a linked worktree inherits both roots, nearest first", async () => {
|
|
84
|
+
const sub = path.join(linked, "packages", "app");
|
|
85
|
+
await fs.ensureDir(sub);
|
|
86
|
+
expect(await inheritablePaths(sub)).toEqual([linked, mainTree]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("a directory outside any repository inherits nothing", async () => {
|
|
90
|
+
const loose = path.join(base, "not-a-repo");
|
|
91
|
+
await fs.ensureDir(loose);
|
|
92
|
+
// Guard: a stray repo above the temp dir would silently invalidate this.
|
|
93
|
+
const out = await inheritablePaths(loose);
|
|
94
|
+
expect(out.every((p) => !p.startsWith(base))).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("a missing directory yields nothing rather than throwing", async () => {
|
|
98
|
+
expect(await inheritablePaths(path.join(base, "gone"))).toEqual([]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("results are cached per path and cleared on demand", async () => {
|
|
102
|
+
const first = await inheritablePaths(linked);
|
|
103
|
+
expect(await inheritablePaths(linked)).toBe(first); // same array identity
|
|
104
|
+
clearWorktreeCache();
|
|
105
|
+
expect(await inheritablePaths(linked)).not.toBe(first);
|
|
106
|
+
expect(await inheritablePaths(linked)).toEqual([mainTree]);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { RGBA } from "@opentui/core";
|
|
4
|
+
import fs from "fs-extra";
|
|
5
|
+
import { CONTRAST_REFERENCE, brand, theme } from "../ui/theme.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* claudeup uses its own vivid accents on the terminal's own canvas.
|
|
9
|
+
*
|
|
10
|
+
* The bug this file exists for: `theme.colors.text` was the string `"white"`,
|
|
11
|
+
* which OpenTUI resolves to rgb(255,255,255) — not "the terminal's white". On a
|
|
12
|
+
* light theme every installed plugin was drawn white-on-cream and vanished,
|
|
13
|
+
* while the *un*installed ones, drawn in grey, stayed readable. The list read
|
|
14
|
+
* exactly backwards.
|
|
15
|
+
*
|
|
16
|
+
* The first fix pushed everything onto ANSI slots 0-15. That made it legible and
|
|
17
|
+
* flattened it: a theme rendering slots 2 and 3 as neighbouring olives leaves the
|
|
18
|
+
* scope squares indistinguishable, and slot-8 chips melt into a light page. So
|
|
19
|
+
* accents are now chosen by us — which means WE are responsible for their
|
|
20
|
+
* contrast, and these tests are how that responsibility is discharged.
|
|
21
|
+
*
|
|
22
|
+
* Invariants:
|
|
23
|
+
* 1. Backgrounds and body text stay the terminal's (it is the only colour that
|
|
24
|
+
* can clear a text-grade ratio against that terminal's own background).
|
|
25
|
+
* 2. Every accent clears 3:1 against BOTH reference backgrounds.
|
|
26
|
+
* 3. Ink on our own badges clears 4.5:1 against that badge.
|
|
27
|
+
* 4. The three scope colours are far enough apart in hue to read as squares.
|
|
28
|
+
* 5. No component invents a colour outside the palette.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
// ─── Contrast maths (WCAG 2.1 relative luminance) ─────────────────────────────
|
|
32
|
+
|
|
33
|
+
function relativeLuminance(hex: string): number {
|
|
34
|
+
const n = hex.replace("#", "");
|
|
35
|
+
const channels = [0, 2, 4].map(
|
|
36
|
+
(i) => Number.parseInt(n.slice(i, i + 2), 16) / 255,
|
|
37
|
+
);
|
|
38
|
+
const linear = channels.map((c) =>
|
|
39
|
+
c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4,
|
|
40
|
+
);
|
|
41
|
+
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function contrastRatio(a: string, b: string): number {
|
|
45
|
+
const [hi, lo] = [relativeLuminance(a), relativeLuminance(b)].sort(
|
|
46
|
+
(x, y) => y - x,
|
|
47
|
+
);
|
|
48
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Perceptual distance (CIE76 ΔE) in L*a*b*.
|
|
53
|
+
*
|
|
54
|
+
* Contrast ratio is the wrong tool for "are these two swatches telling apart":
|
|
55
|
+
* it only compares luminance, and the scope colours are deliberately close in
|
|
56
|
+
* luminance — that is precisely what lets each of them clear 3:1 against both a
|
|
57
|
+
* light and a dark background. Measured, they sit at ratios of 1.00-1.03 to each
|
|
58
|
+
* other while being obviously different colours. ΔE sees the hue difference that
|
|
59
|
+
* a luminance ratio is blind to.
|
|
60
|
+
*/
|
|
61
|
+
function deltaE(aHex: string, bHex: string): number {
|
|
62
|
+
const toLab = (hex: string): [number, number, number] => {
|
|
63
|
+
const n = hex.replace("#", "");
|
|
64
|
+
const [r, g, b] = [0, 2, 4].map((i) => {
|
|
65
|
+
const c = Number.parseInt(n.slice(i, i + 2), 16) / 255;
|
|
66
|
+
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
67
|
+
});
|
|
68
|
+
// sRGB -> XYZ (D65), then XYZ -> Lab
|
|
69
|
+
const x = (r * 0.4124 + g * 0.3576 + b * 0.1805) / 0.95047;
|
|
70
|
+
const y = r * 0.2126 + g * 0.7152 + b * 0.0722;
|
|
71
|
+
const z = (r * 0.0193 + g * 0.1192 + b * 0.9505) / 1.08883;
|
|
72
|
+
const f = (t: number) =>
|
|
73
|
+
t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116;
|
|
74
|
+
const [fx, fy, fz] = [f(x), f(y), f(z)];
|
|
75
|
+
return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)];
|
|
76
|
+
};
|
|
77
|
+
const [l1, a1, b1] = toLab(aHex);
|
|
78
|
+
const [l2, a2, b2] = toLab(bHex);
|
|
79
|
+
return Math.hypot(l1 - l2, a1 - a2, b1 - b2);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** WCAG threshold for UI components and large text. 4.5 is unreachable against
|
|
83
|
+
* both a light and a dark background at once — the luminance bands do not overlap. */
|
|
84
|
+
const UI_CONTRAST = 3;
|
|
85
|
+
const INK_CONTRAST = 4.5;
|
|
86
|
+
|
|
87
|
+
describe("contrast maths", () => {
|
|
88
|
+
// Guard the ruler before measuring with it: a broken luminance function would
|
|
89
|
+
// make every assertion below meaningless in whichever direction it was broken.
|
|
90
|
+
test("known reference ratios are correct", () => {
|
|
91
|
+
expect(contrastRatio("#FFFFFF", "#000000")).toBeCloseTo(21, 1);
|
|
92
|
+
expect(contrastRatio("#000000", "#FFFFFF")).toBeCloseTo(21, 1);
|
|
93
|
+
expect(contrastRatio("#777777", "#777777")).toBeCloseTo(1, 5);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ─── The palette ──────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
const ACCENTS = Object.entries(brand).filter(([name]) => name !== "ink");
|
|
100
|
+
|
|
101
|
+
describe("accents are legible on light AND dark terminals", () => {
|
|
102
|
+
test("the palette is non-empty (guards the loops below)", () => {
|
|
103
|
+
expect(ACCENTS.length).toBeGreaterThanOrEqual(6);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
for (const [name, hex] of ACCENTS) {
|
|
107
|
+
test(`${name} clears ${UI_CONTRAST}:1 on both backgrounds`, () => {
|
|
108
|
+
const onLight = contrastRatio(hex, CONTRAST_REFERENCE.light);
|
|
109
|
+
const onDark = contrastRatio(hex, CONTRAST_REFERENCE.dark);
|
|
110
|
+
// Reported together so a failure names the side that broke.
|
|
111
|
+
expect({
|
|
112
|
+
name,
|
|
113
|
+
onLight: onLight >= UI_CONTRAST,
|
|
114
|
+
onDark: onDark >= UI_CONTRAST,
|
|
115
|
+
}).toEqual({ name, onLight: true, onDark: true });
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
test("badge ink clears 4.5:1 on every badge colour we paint", () => {
|
|
120
|
+
const failures: string[] = [];
|
|
121
|
+
for (const [tone, colors] of Object.entries(theme.category)) {
|
|
122
|
+
const r = contrastRatio(brand.ink, colors.bg as string);
|
|
123
|
+
if (r < INK_CONTRAST) failures.push(`category.${tone} ${r.toFixed(2)}`);
|
|
124
|
+
}
|
|
125
|
+
for (const key of ["defaultBg", "primaryBg", "dangerBg"] as const) {
|
|
126
|
+
const r = contrastRatio(brand.ink, theme.hints[key]);
|
|
127
|
+
if (r < INK_CONTRAST) failures.push(`hints.${key} ${r.toFixed(2)}`);
|
|
128
|
+
}
|
|
129
|
+
const sel = contrastRatio(theme.selection.fg, theme.selection.bg);
|
|
130
|
+
if (sel < INK_CONTRAST) failures.push(`selection ${sel.toFixed(2)}`);
|
|
131
|
+
expect(failures).toEqual([]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("the three scope colours are perceptually far apart", () => {
|
|
135
|
+
// They render as single-character squares, where a subtle shift is not
|
|
136
|
+
// readable at all. Deferring to ANSI put project and local on slots 2 and 3,
|
|
137
|
+
// which one real theme drew as two near-identical olives — the reason this
|
|
138
|
+
// rule exists. ΔE > 25 is comfortably past "different colour" (~2.3 is the
|
|
139
|
+
// just-noticeable difference); the bar is set high because these are tiny.
|
|
140
|
+
const scopes = Object.entries(theme.scopes) as [string, string][];
|
|
141
|
+
const tooClose: string[] = [];
|
|
142
|
+
for (let i = 0; i < scopes.length; i++) {
|
|
143
|
+
for (let j = i + 1; j < scopes.length; j++) {
|
|
144
|
+
const d = deltaE(scopes[i][1], scopes[j][1]);
|
|
145
|
+
if (d < 25)
|
|
146
|
+
tooClose.push(`${scopes[i][0]}~${scopes[j][0]} ΔE=${d.toFixed(1)}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
expect(tooClose).toEqual([]);
|
|
150
|
+
expect(new Set(scopes.map(([, hex]) => hex)).size).toBe(scopes.length);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("ΔE agrees that identical colours are identical (guards the metric)", () => {
|
|
154
|
+
expect(deltaE(brand.success, brand.success)).toBeCloseTo(0, 5);
|
|
155
|
+
expect(deltaE("#000000", "#FFFFFF")).toBeGreaterThan(90);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("the canvas stays the terminal's", () => {
|
|
160
|
+
test("normal text is the terminal's own foreground", () => {
|
|
161
|
+
// The original regression: this was `"white"`.
|
|
162
|
+
expect(theme.colors.text).toBeInstanceOf(RGBA);
|
|
163
|
+
expect((theme.colors.text as RGBA).intent).toBe("default");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("surfaces use the terminal background, never an absolute fill", () => {
|
|
167
|
+
expect(theme.surface.bg).toBeInstanceOf(RGBA);
|
|
168
|
+
expect((theme.surface.bg as RGBA).intent).toBe("default");
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// ─── Source guard ─────────────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
const UI_DIR = path.join(import.meta.dir, "..", "ui");
|
|
175
|
+
/** The palette layer. Colour literals are these files' whole job. */
|
|
176
|
+
const PALETTE_FILES = new Set([
|
|
177
|
+
path.join(UI_DIR, "theme.ts"),
|
|
178
|
+
path.join(UI_DIR, "theme-mode.ts"),
|
|
179
|
+
]);
|
|
180
|
+
|
|
181
|
+
async function uiSourceFiles(dir: string): Promise<string[]> {
|
|
182
|
+
const out: string[] = [];
|
|
183
|
+
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
|
|
184
|
+
const full = path.join(dir, entry.name);
|
|
185
|
+
if (entry.isDirectory()) out.push(...(await uiSourceFiles(full)));
|
|
186
|
+
else if (/\.tsx?$/.test(entry.name) && !PALETTE_FILES.has(full))
|
|
187
|
+
out.push(full);
|
|
188
|
+
}
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const CSS_NAMES =
|
|
193
|
+
"white|black|gray|grey|red|green|yellow|blue|cyan|magenta|orange|purple";
|
|
194
|
+
|
|
195
|
+
/** A line that is purely a comment. Prose describing a colour is not a defect. */
|
|
196
|
+
function isComment(line: string): boolean {
|
|
197
|
+
const t = line.trim();
|
|
198
|
+
return t.startsWith("//") || t.startsWith("*") || t.startsWith("/*");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
describe("components draw only from the palette", () => {
|
|
202
|
+
test("no colour literal outside theme.ts", async () => {
|
|
203
|
+
const files = await uiSourceFiles(UI_DIR);
|
|
204
|
+
expect(files.length).toBeGreaterThan(10); // guard: the walk found real files
|
|
205
|
+
|
|
206
|
+
const offenders: string[] = [];
|
|
207
|
+
for (const file of files) {
|
|
208
|
+
const text = await fs.readFile(file, "utf8");
|
|
209
|
+
text.split("\n").forEach((line, i) => {
|
|
210
|
+
if (isComment(line)) return;
|
|
211
|
+
const hex = /#[0-9a-fA-F]{6}\b/.test(line);
|
|
212
|
+
const named = new RegExp(
|
|
213
|
+
`\\b(fg|bg|backgroundColor|borderColor|titleColor|textColor|color)\\s*[=:]\\s*"(${CSS_NAMES})"`,
|
|
214
|
+
).test(line);
|
|
215
|
+
if (hex || named) {
|
|
216
|
+
offenders.push(
|
|
217
|
+
`${path.relative(UI_DIR, file)}:${i + 1} ${line.trim()}`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
expect(offenders).toEqual([]);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("no <text> element renders without naming a colour", async () => {
|
|
226
|
+
// OpenTUI's fallback for an unstyled cell is truecolor white, NOT the
|
|
227
|
+
// terminal's foreground. Measured on the real TUI: after the palette was
|
|
228
|
+
// first fixed, `tmux capture-pane -e` still showed 139 truecolor-white runs,
|
|
229
|
+
// and the one carrying a visible glyph was a bare `<text>Plugins: 17</text>`.
|
|
230
|
+
// A correct palette cannot save an element that never asks for a colour.
|
|
231
|
+
const files = await uiSourceFiles(UI_DIR);
|
|
232
|
+
const offenders: string[] = [];
|
|
233
|
+
for (const file of files) {
|
|
234
|
+
const text = await fs.readFile(file, "utf8");
|
|
235
|
+
text.split("\n").forEach((line, i) => {
|
|
236
|
+
if (isComment(line)) return;
|
|
237
|
+
if (/<text>/.test(line)) {
|
|
238
|
+
offenders.push(
|
|
239
|
+
`${path.relative(UI_DIR, file)}:${i + 1} ${line.trim()}`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
expect(offenders).toEqual([]);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("no element falls back to `undefined` for a colour", async () => {
|
|
248
|
+
// `fg={selected ? x : undefined}` reads as "leave it alone", but OpenTUI's
|
|
249
|
+
// fallback for an unstyled cell is truecolor white — so every unselected
|
|
250
|
+
// SelectableRow rendered white-on-cream and vanished on a light theme. Same
|
|
251
|
+
// root cause as the original bug, reached by omission rather than by a wrong
|
|
252
|
+
// literal, which is why the `<text>` guard alone did not catch it.
|
|
253
|
+
const files = await uiSourceFiles(UI_DIR);
|
|
254
|
+
const offenders: string[] = [];
|
|
255
|
+
for (const file of files) {
|
|
256
|
+
const text = await fs.readFile(file, "utf8");
|
|
257
|
+
text.split("\n").forEach((line, i) => {
|
|
258
|
+
if (isComment(line)) return;
|
|
259
|
+
if (/\bfg=\{[^}]*\bundefined\b/.test(line)) {
|
|
260
|
+
offenders.push(
|
|
261
|
+
`${path.relative(UI_DIR, file)}:${i + 1} ${line.trim()}`,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
// `bg` legitimately falls back to the terminal page — that is how a row sits
|
|
267
|
+
// on the terminal's own background — so only `fg` is checked. The pattern is
|
|
268
|
+
// anchored to fg itself: filtering whole lines flagged
|
|
269
|
+
// `fg={x} bg={cond ? y : undefined}`, which is correct code.
|
|
270
|
+
expect(offenders).toEqual([]);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("a background we paint never carries the terminal's ink", async () => {
|
|
274
|
+
// `theme.colors.text` is the TERMINAL's foreground. It is the right choice on
|
|
275
|
+
// the terminal's own page and the wrong one on a block we painted: on a light
|
|
276
|
+
// theme it is dark, so `bg={accent} fg={colors.text}` renders dark-on-purple.
|
|
277
|
+
// Reported from the real UI as the top tab bar showing "black on purple".
|
|
278
|
+
// It was not one tab — 16 sites shared it, across tabs, badges and chips.
|
|
279
|
+
//
|
|
280
|
+
// The rule: if we own the background, we own the ink (theme.hints.fg).
|
|
281
|
+
const files = await uiSourceFiles(UI_DIR);
|
|
282
|
+
const offenders: string[] = [];
|
|
283
|
+
for (const file of files) {
|
|
284
|
+
const text = await fs.readFile(file, "utf8");
|
|
285
|
+
text.split("\n").forEach((line, i) => {
|
|
286
|
+
if (isComment(line)) return;
|
|
287
|
+
if (/\bbg=\{[^}]+\}\s+fg=\{theme\.colors\.text\}/.test(line)) {
|
|
288
|
+
offenders.push(
|
|
289
|
+
`${path.relative(UI_DIR, file)}:${i + 1} ${line.trim()}`,
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
expect(offenders).toEqual([]);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("the comment filter does not swallow a real offender", () => {
|
|
298
|
+
// The guards skip comment lines, because a doc comment that merely mentions
|
|
299
|
+
// `<text>` or a hex value is not a defect — one legitimately did, and failed
|
|
300
|
+
// the build. A filter that silently hid real code would be worse than the
|
|
301
|
+
// false positive it replaced, so pin both directions.
|
|
302
|
+
expect(isComment(" // <text>foo</text>")).toBe(true);
|
|
303
|
+
expect(isComment(' * fg="white" in a docblock')).toBe(true);
|
|
304
|
+
expect(isComment(" <text>real</text>")).toBe(false);
|
|
305
|
+
expect(isComment(' <span fg="white">real</span>')).toBe(false);
|
|
306
|
+
});
|
|
307
|
+
});
|