@zenodinh/pi-render 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +110 -2
- package/index.ts +279 -0
- package/package.json +66 -5
- package/src/commands/canvas.test.ts +150 -0
- package/src/commands/canvas.ts +57 -0
- package/src/core/code-theme.test.ts +266 -0
- package/src/core/code-theme.ts +275 -0
- package/src/core/log.test.ts +127 -0
- package/src/core/log.ts +57 -0
- package/src/core/paint.test.ts +322 -0
- package/src/core/paint.ts +64 -0
- package/src/core/registry.test.ts +307 -0
- package/src/core/registry.ts +135 -0
- package/src/core/settings.test.ts +183 -0
- package/src/core/settings.ts +119 -0
- package/src/core/types/code-theme.ts +24 -0
- package/src/core/types/host.ts +160 -0
- package/src/core/types/log.ts +28 -0
- package/src/core/types/paint.ts +54 -0
- package/src/core/types/registry.ts +44 -0
- package/src/core/types/settings.ts +31 -0
- package/src/core/types.ts +34 -0
- package/src/renderers/content/artifacts/artifacts.test.ts +199 -0
- package/src/renderers/content/artifacts/cache.ts +234 -0
- package/src/renderers/content/artifacts/cards.test.ts +216 -0
- package/src/renderers/content/artifacts/cards.ts +136 -0
- package/src/renderers/content/artifacts/engines-extra.test.ts +556 -0
- package/src/renderers/content/artifacts/engines.ts +396 -0
- package/src/renderers/content/artifacts/local-binary.test.ts +207 -0
- package/src/renderers/content/artifacts/local-binary.ts +80 -0
- package/src/renderers/content/artifacts/prereqs.ts +128 -0
- package/src/renderers/content/artifacts/server.ts +181 -0
- package/src/renderers/content/code-panel.ts +161 -0
- package/src/renderers/content/image-card.test.ts +170 -0
- package/src/renderers/content/image-card.ts +252 -0
- package/src/renderers/content/index.ts +79 -0
- package/src/renderers/content/json-panel.ts +116 -0
- package/src/renderers/content/panels.test.ts +188 -0
- package/src/renderers/content/table.test.ts +209 -0
- package/src/renderers/content/table.ts +174 -0
- package/src/renderers/content/transformer.test.ts +254 -0
- package/src/renderers/content/types.ts +20 -0
- package/src/renderers/tool/index.ts +113 -0
- package/src/renderers/tool/resolver.test.ts +257 -0
- package/src/renderers/tool/runtime.test.ts +313 -0
- package/src/renderers/tool/runtime.ts +267 -0
- package/src/renderers/tool/specs/bash.test.ts +110 -0
- package/src/renderers/tool/specs/bash.ts +168 -0
- package/src/renderers/tool/specs/codemode.test.ts +212 -0
- package/src/renderers/tool/specs/codemode.ts +248 -0
- package/src/renderers/tool/specs/edit.test.ts +260 -0
- package/src/renderers/tool/specs/edit.ts +213 -0
- package/src/renderers/tool/specs/ls.test.ts +173 -0
- package/src/renderers/tool/specs/ls.ts +136 -0
- package/src/renderers/tool/specs/read.test.ts +340 -0
- package/src/renderers/tool/specs/read.ts +296 -0
- package/src/renderers/tool/specs/search.test.ts +197 -0
- package/src/renderers/tool/specs/search.ts +325 -0
- package/src/renderers/tool/specs/write.test.ts +145 -0
- package/src/renderers/tool/specs/write.ts +142 -0
- package/src/renderers/tool/types.ts +45 -0
- package/themes/dracula-soft.json +81 -0
- package/themes/one-dark.json +80 -0
- package/themes/themes.test.ts +251 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
|
|
3
|
+
"name": "dracula-soft",
|
|
4
|
+
"appearance": "dark",
|
|
5
|
+
"vars": {
|
|
6
|
+
"text": "#f6f6f4",
|
|
7
|
+
"muted": "#7b7f8b",
|
|
8
|
+
"purple": "#bf9eee",
|
|
9
|
+
"pink": "#f286c4",
|
|
10
|
+
"cyan": "#97e1f1",
|
|
11
|
+
"green": "#62e884",
|
|
12
|
+
"red": "#ee6666",
|
|
13
|
+
"orange": "#ffb86c",
|
|
14
|
+
"yellow": "#e7ee98",
|
|
15
|
+
"selection": "#44475a",
|
|
16
|
+
"surface": "#343746"
|
|
17
|
+
},
|
|
18
|
+
"colors": {
|
|
19
|
+
"accent": "purple",
|
|
20
|
+
"border": "muted",
|
|
21
|
+
"borderAccent": "pink",
|
|
22
|
+
"borderMuted": "selection",
|
|
23
|
+
"success": "green",
|
|
24
|
+
"error": "red",
|
|
25
|
+
"warning": "orange",
|
|
26
|
+
"muted": "muted",
|
|
27
|
+
"dim": "#646773",
|
|
28
|
+
"text": "text",
|
|
29
|
+
"thinkingText": "muted",
|
|
30
|
+
"selectedBg": "selection",
|
|
31
|
+
"scrollbarTrack": "surface",
|
|
32
|
+
"scrollbarThumb": "muted",
|
|
33
|
+
"searchMatchBg": "#52554c",
|
|
34
|
+
"searchMatchText": "muted",
|
|
35
|
+
"userMessageBg": "surface",
|
|
36
|
+
"userMessageText": "text",
|
|
37
|
+
"customMessageBg": "#403d53",
|
|
38
|
+
"customMessageText": "muted",
|
|
39
|
+
"customMessageLabel": "purple",
|
|
40
|
+
"toolPendingBg": "#2e313e",
|
|
41
|
+
"toolSuccessBg": "#324c44",
|
|
42
|
+
"toolErrorBg": "#4c353f",
|
|
43
|
+
"toolTitle": "text",
|
|
44
|
+
"toolOutput": "muted",
|
|
45
|
+
"mdHeading": "purple",
|
|
46
|
+
"mdLink": "cyan",
|
|
47
|
+
"mdLinkUrl": "muted",
|
|
48
|
+
"mdCode": "green",
|
|
49
|
+
"mdCodeBlock": "yellow",
|
|
50
|
+
"mdCodeBlockBorder": "muted",
|
|
51
|
+
"mdQuote": "muted",
|
|
52
|
+
"mdQuoteBorder": "muted",
|
|
53
|
+
"mdHr": "muted",
|
|
54
|
+
"mdListBullet": "cyan",
|
|
55
|
+
"toolDiffAdded": "green",
|
|
56
|
+
"toolDiffRemoved": "red",
|
|
57
|
+
"toolDiffContext": "muted",
|
|
58
|
+
"syntaxComment": "muted",
|
|
59
|
+
"syntaxKeyword": "pink",
|
|
60
|
+
"syntaxFunction": "green",
|
|
61
|
+
"syntaxVariable": "orange",
|
|
62
|
+
"syntaxString": "yellow",
|
|
63
|
+
"syntaxNumber": "purple",
|
|
64
|
+
"syntaxType": "cyan",
|
|
65
|
+
"syntaxOperator": "muted",
|
|
66
|
+
"syntaxPunctuation": "muted",
|
|
67
|
+
"thinkingOff": "#53516c",
|
|
68
|
+
"thinkingMinimal": "#776b97",
|
|
69
|
+
"thinkingLow": "#9b84c3",
|
|
70
|
+
"thinkingMedium": "#bf9eee",
|
|
71
|
+
"thinkingHigh": "#d4a7c3",
|
|
72
|
+
"thinkingXhigh": "#eaaf97",
|
|
73
|
+
"thinkingMax": "#ffb86c",
|
|
74
|
+
"bashMode": "cyan"
|
|
75
|
+
},
|
|
76
|
+
"export": {
|
|
77
|
+
"pageBg": "#282a36",
|
|
78
|
+
"cardBg": "surface",
|
|
79
|
+
"infoBg": "#52554c"
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
|
|
3
|
+
"name": "one-dark",
|
|
4
|
+
"appearance": "dark",
|
|
5
|
+
"vars": {
|
|
6
|
+
"text": "#abb2bf",
|
|
7
|
+
"muted": "#7f848e",
|
|
8
|
+
"blue": "#61afef",
|
|
9
|
+
"purple": "#c678dd",
|
|
10
|
+
"green": "#98c379",
|
|
11
|
+
"red": "#e06c75",
|
|
12
|
+
"orange": "#d19a66",
|
|
13
|
+
"yellow": "#e5c07b",
|
|
14
|
+
"selection": "#4e5666",
|
|
15
|
+
"surface": "#2c313c"
|
|
16
|
+
},
|
|
17
|
+
"colors": {
|
|
18
|
+
"accent": "blue",
|
|
19
|
+
"border": "muted",
|
|
20
|
+
"borderAccent": "blue",
|
|
21
|
+
"borderMuted": "#3e4452",
|
|
22
|
+
"success": "green",
|
|
23
|
+
"error": "red",
|
|
24
|
+
"warning": "yellow",
|
|
25
|
+
"muted": "muted",
|
|
26
|
+
"dim": "#636b78",
|
|
27
|
+
"text": "text",
|
|
28
|
+
"thinkingText": "muted",
|
|
29
|
+
"selectedBg": "selection",
|
|
30
|
+
"scrollbarTrack": "surface",
|
|
31
|
+
"scrollbarThumb": "selection",
|
|
32
|
+
"searchMatchBg": "#524d44",
|
|
33
|
+
"searchMatchText": "muted",
|
|
34
|
+
"userMessageBg": "surface",
|
|
35
|
+
"userMessageText": "text",
|
|
36
|
+
"customMessageBg": "#41384f",
|
|
37
|
+
"customMessageText": "muted",
|
|
38
|
+
"customMessageLabel": "purple",
|
|
39
|
+
"toolPendingBg": "#323842",
|
|
40
|
+
"toolSuccessBg": "#3c4740",
|
|
41
|
+
"toolErrorBg": "#493840",
|
|
42
|
+
"toolTitle": "text",
|
|
43
|
+
"toolOutput": "muted",
|
|
44
|
+
"mdHeading": "red",
|
|
45
|
+
"mdLink": "blue",
|
|
46
|
+
"mdLinkUrl": "muted",
|
|
47
|
+
"mdCode": "green",
|
|
48
|
+
"mdCodeBlock": "orange",
|
|
49
|
+
"mdCodeBlockBorder": "muted",
|
|
50
|
+
"mdQuote": "muted",
|
|
51
|
+
"mdQuoteBorder": "muted",
|
|
52
|
+
"mdHr": "muted",
|
|
53
|
+
"mdListBullet": "yellow",
|
|
54
|
+
"toolDiffAdded": "green",
|
|
55
|
+
"toolDiffRemoved": "red",
|
|
56
|
+
"toolDiffContext": "muted",
|
|
57
|
+
"syntaxComment": "muted",
|
|
58
|
+
"syntaxKeyword": "purple",
|
|
59
|
+
"syntaxFunction": "blue",
|
|
60
|
+
"syntaxVariable": "red",
|
|
61
|
+
"syntaxString": "green",
|
|
62
|
+
"syntaxNumber": "orange",
|
|
63
|
+
"syntaxType": "yellow",
|
|
64
|
+
"syntaxOperator": "#56b6c2",
|
|
65
|
+
"syntaxPunctuation": "muted",
|
|
66
|
+
"thinkingOff": "#5c5a74",
|
|
67
|
+
"thinkingMinimal": "#5e769d",
|
|
68
|
+
"thinkingLow": "#5f93c6",
|
|
69
|
+
"thinkingMedium": "#61afef",
|
|
70
|
+
"thinkingHigh": "#8db5c8",
|
|
71
|
+
"thinkingXhigh": "#b9baa2",
|
|
72
|
+
"thinkingMax": "#e5c07b",
|
|
73
|
+
"bashMode": "green"
|
|
74
|
+
},
|
|
75
|
+
"export": {
|
|
76
|
+
"pageBg": "#282c34",
|
|
77
|
+
"cardBg": "surface",
|
|
78
|
+
"infoBg": "#524d44"
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// Fixture provenance — schema: recorded-from @earendil-works/pi-coding-agent@1.0.4
|
|
2
|
+
// (dist/modes/interactive/theme/theme-schema.json, located with findPackageJSON), read 2026-10-06.
|
|
3
|
+
// Fixture provenance — palettes: recorded-from @shikijs/themes 4.4.3 (dracula-soft, one-dark-pro), read 2026-10-06.
|
|
4
|
+
|
|
5
|
+
// shape: type predicates + plain functions — trigger #7 (fixed-shape record) narrowed from `unknown`;
|
|
6
|
+
// a class, enum, namespace, or registry would be ceremony over two data files.
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { findPackageJSON } from "node:module";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { describe, expect, test } from "vitest";
|
|
12
|
+
|
|
13
|
+
const HOST_PACKAGE = "@earendil-works/pi-coding-agent";
|
|
14
|
+
const SCHEMA_ENTRY = "dist/modes/interactive/theme/theme-schema.json";
|
|
15
|
+
const THEME_FILE_NAMES = ["dracula-soft.json", "one-dark.json"] as const;
|
|
16
|
+
const HEX_COLOR = /^#[0-9a-f]{6}$/;
|
|
17
|
+
const COLOR_FUNCTION = /^ok(lch|hsl)\(/i;
|
|
18
|
+
const EXPORT_TOKENS = ["pageBg", "cardBg", "infoBg"];
|
|
19
|
+
const THINKING_TOKENS = [
|
|
20
|
+
"thinkingOff",
|
|
21
|
+
"thinkingMinimal",
|
|
22
|
+
"thinkingLow",
|
|
23
|
+
"thinkingMedium",
|
|
24
|
+
"thinkingHigh",
|
|
25
|
+
"thinkingXhigh",
|
|
26
|
+
"thinkingMax",
|
|
27
|
+
];
|
|
28
|
+
const TEXT_BACKGROUND_TOKENS = [
|
|
29
|
+
"selectedBg",
|
|
30
|
+
"userMessageBg",
|
|
31
|
+
"customMessageBg",
|
|
32
|
+
"toolPendingBg",
|
|
33
|
+
"toolSuccessBg",
|
|
34
|
+
"toolErrorBg",
|
|
35
|
+
"searchMatchBg",
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
function readJson(path: string): unknown {
|
|
39
|
+
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
40
|
+
return parsed;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
44
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function asRecord(value: unknown, label: string): Record<string, unknown> {
|
|
48
|
+
if (!isRecord(value)) throw new Error(`${label}: expected an object, got ${typeof value}`);
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function asStringArray(value: unknown, label: string): string[] {
|
|
53
|
+
if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string")) {
|
|
54
|
+
throw new Error(`${label}: expected a string array`);
|
|
55
|
+
}
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function asString(value: unknown, label: string): string {
|
|
60
|
+
if (typeof value !== "string") throw new Error(`${label}: expected a string, got ${typeof value}`);
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const hostPackageJson = findPackageJSON(HOST_PACKAGE, import.meta.url);
|
|
65
|
+
if (hostPackageJson === undefined) {
|
|
66
|
+
throw new Error(`${HOST_PACKAGE} is not resolvable — the pinned host devDependency supplies theme-schema.json`);
|
|
67
|
+
}
|
|
68
|
+
const schema = asRecord(readJson(join(dirname(hostPackageJson), SCHEMA_ENTRY)), "theme-schema.json");
|
|
69
|
+
const schemaProperties = asRecord(schema.properties, "theme-schema.properties");
|
|
70
|
+
const colorValueSpec = asRecord(
|
|
71
|
+
asRecord(schema.$defs, "theme-schema.$defs").colorValue,
|
|
72
|
+
"theme-schema.$defs.colorValue",
|
|
73
|
+
);
|
|
74
|
+
const colorTokens = Object.keys(
|
|
75
|
+
asRecord(asRecord(schemaProperties.colors, "theme-schema.properties.colors").properties, "colors.properties"),
|
|
76
|
+
);
|
|
77
|
+
const exportTokens = Object.keys(
|
|
78
|
+
asRecord(asRecord(schemaProperties.export, "theme-schema.properties.export").properties, "export.properties"),
|
|
79
|
+
);
|
|
80
|
+
const requiredTopLevel = asStringArray(schema.required, "theme-schema.required");
|
|
81
|
+
const appearances = asStringArray(
|
|
82
|
+
asRecord(schemaProperties.appearance, "theme-schema.properties.appearance").enum,
|
|
83
|
+
"theme-schema.properties.appearance.enum",
|
|
84
|
+
);
|
|
85
|
+
const colorValueBranches = (Array.isArray(colorValueSpec.oneOf) ? colorValueSpec.oneOf : []).map((branch, index) =>
|
|
86
|
+
asRecord(branch, `theme-schema.$defs.colorValue.oneOf[${index}]`),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
/** Spec: theme-schema.json $defs.colorValue — a string (#RGB/#RRGGBB, OKLCH, OKHSL, var name, ""), or a 0-255 index. */
|
|
90
|
+
function isColorValue(value: unknown): boolean {
|
|
91
|
+
return colorValueBranches.some((branch) => {
|
|
92
|
+
if (branch.type === "string") return typeof value === "string";
|
|
93
|
+
if (branch.type !== "integer" || typeof value !== "number" || !Number.isInteger(value)) return false;
|
|
94
|
+
const minimum = typeof branch.minimum === "number" ? branch.minimum : Number.NEGATIVE_INFINITY;
|
|
95
|
+
const maximum = typeof branch.maximum === "number" ? branch.maximum : Number.POSITIVE_INFINITY;
|
|
96
|
+
return value >= minimum && value <= maximum;
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface Theme {
|
|
101
|
+
readonly label: string;
|
|
102
|
+
readonly doc: Record<string, unknown>;
|
|
103
|
+
readonly colors: Record<string, unknown>;
|
|
104
|
+
readonly vars: Record<string, unknown>;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function readTheme(file: string): Theme {
|
|
108
|
+
const doc = asRecord(readJson(fileURLToPath(new URL(file, import.meta.url))), file);
|
|
109
|
+
return {
|
|
110
|
+
label: file,
|
|
111
|
+
doc,
|
|
112
|
+
colors: asRecord(doc.colors, `${file}.colors`),
|
|
113
|
+
vars: doc.vars === undefined ? {} : asRecord(doc.vars, `${file}.vars`),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Host literal grammar (theme.js resolveVarRefs): "#…", "", a palette index, okhsl()/oklch() — anything else names a var. */
|
|
118
|
+
function resolveColor(theme: Theme, value: unknown): unknown {
|
|
119
|
+
if (typeof value === "string" && value !== "" && !value.startsWith("#") && !COLOR_FUNCTION.test(value)) {
|
|
120
|
+
return theme.vars[value];
|
|
121
|
+
}
|
|
122
|
+
return value;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function hexOf(theme: Theme, value: unknown, label: string): string {
|
|
126
|
+
const resolved = resolveColor(theme, value);
|
|
127
|
+
if (typeof resolved !== "string" || !HEX_COLOR.test(resolved)) {
|
|
128
|
+
throw new Error(`${theme.label}.${label}: expected a #rrggbb color, got ${JSON.stringify(resolved)}`);
|
|
129
|
+
}
|
|
130
|
+
return resolved;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function exportColor(theme: Theme, key: string): unknown {
|
|
134
|
+
return asRecord(theme.doc.export, `${theme.label}.export`)[key];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function relativeLuminance(hex: string): number {
|
|
138
|
+
const rgb = Number.parseInt(hex.slice(1), 16);
|
|
139
|
+
const channel = (byte: number): number => {
|
|
140
|
+
const ratio = byte / 255;
|
|
141
|
+
return ratio <= 0.04045 ? ratio / 12.92 : ((ratio + 0.055) / 1.055) ** 2.4;
|
|
142
|
+
};
|
|
143
|
+
return 0.2126 * channel((rgb >> 16) & 0xff) + 0.7152 * channel((rgb >> 8) & 0xff) + 0.0722 * channel(rgb & 0xff);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function contrastRatio(a: string, b: string): number {
|
|
147
|
+
const lighter = Math.max(relativeLuminance(a), relativeLuminance(b));
|
|
148
|
+
const darker = Math.min(relativeLuminance(a), relativeLuminance(b));
|
|
149
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
describe("host theme definitions", () => {
|
|
153
|
+
for (const theme of THEME_FILE_NAMES.map(readTheme)) {
|
|
154
|
+
test(`${theme.label} validates against the host theme schema`, () => {
|
|
155
|
+
// spec: schema requires {name, colors}; name matches ^[^/]+$; appearance ∈ {dark, light}; no key outside
|
|
156
|
+
// properties; colors carries the schema's own 56 token names, each a $defs.colorValue.
|
|
157
|
+
// fails_when: the host would reject the file — a missing/extra/mistyped token, an unknown key, a bad name or appearance.
|
|
158
|
+
for (const key of requiredTopLevel) {
|
|
159
|
+
expect(Object.hasOwn(theme.doc, key), `missing top-level "${key}"`).toBe(true);
|
|
160
|
+
}
|
|
161
|
+
const schemaTopLevel = new Set(Object.keys(schemaProperties));
|
|
162
|
+
expect(Object.keys(theme.doc).filter((key) => !schemaTopLevel.has(key))).toEqual([]);
|
|
163
|
+
expect(asString(theme.doc.name, "name")).not.toContain("/");
|
|
164
|
+
expect(appearances).toContain(theme.doc.appearance);
|
|
165
|
+
|
|
166
|
+
expect(colorTokens).toHaveLength(56);
|
|
167
|
+
expect(Object.keys(theme.colors).sort()).toEqual([...colorTokens].sort());
|
|
168
|
+
for (const token of colorTokens) {
|
|
169
|
+
expect(isColorValue(theme.colors[token]), `${theme.label}.colors.${token}`).toBe(true);
|
|
170
|
+
}
|
|
171
|
+
for (const [name, value] of Object.entries(theme.vars)) {
|
|
172
|
+
expect(isColorValue(value), `${theme.label}.vars.${name}`).toBe(true);
|
|
173
|
+
}
|
|
174
|
+
const exportSection = Object.entries(asRecord(theme.doc.export, `${theme.label}.export`));
|
|
175
|
+
expect(exportSection.map(([key]) => key).filter((key) => !exportTokens.includes(key))).toEqual([]);
|
|
176
|
+
for (const [key, value] of exportSection) {
|
|
177
|
+
expect(isColorValue(value), `${theme.label}.export.${key}`).toBe(true);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test(`${theme.label} resolves every color through its vars`, () => {
|
|
182
|
+
// spec: each value is a form the host parses (#rrggbb / "" / 0-255 / okhsl|oklch) or the name of a declared var;
|
|
183
|
+
// the host throws "Variable reference not found" for an undeclared name.
|
|
184
|
+
// fails_when: a token or export color names a var that does not exist.
|
|
185
|
+
for (const token of colorTokens) {
|
|
186
|
+
hexOf(theme, theme.colors[token], token);
|
|
187
|
+
}
|
|
188
|
+
for (const token of EXPORT_TOKENS) {
|
|
189
|
+
hexOf(theme, exportColor(theme, token), `export.${token}`);
|
|
190
|
+
}
|
|
191
|
+
const referenced = new Set<string>();
|
|
192
|
+
const collect = (value: unknown): void => {
|
|
193
|
+
if (typeof value === "string" && value !== "" && !value.startsWith("#") && !COLOR_FUNCTION.test(value)) {
|
|
194
|
+
referenced.add(value);
|
|
195
|
+
expect(Object.hasOwn(theme.vars, value), `undeclared var "${value}"`).toBe(true);
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
for (const token of colorTokens) collect(theme.colors[token]);
|
|
199
|
+
for (const token of EXPORT_TOKENS) collect(exportColor(theme, token));
|
|
200
|
+
expect(Object.keys(theme.vars).filter((name) => !referenced.has(name))).toEqual([]);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test(`${theme.label} is dark with readable text on every background`, () => {
|
|
204
|
+
// spec: export.pageBg is the page the theme assumes; text > muted > dim in contrast against it, and text stays
|
|
205
|
+
// legible on every *Bg token the renderer draws content over.
|
|
206
|
+
// fails_when: a light page background, or a foreground that vanishes against the page or a message/tool background.
|
|
207
|
+
const page = hexOf(theme, exportColor(theme, "pageBg"), "export.pageBg");
|
|
208
|
+
expect(relativeLuminance(page)).toBeLessThan(0.1);
|
|
209
|
+
|
|
210
|
+
const text = hexOf(theme, theme.colors.text, "text");
|
|
211
|
+
const onPage = contrastRatio(text, page);
|
|
212
|
+
const onMuted = contrastRatio(hexOf(theme, theme.colors.muted, "muted"), page);
|
|
213
|
+
const onDim = contrastRatio(hexOf(theme, theme.colors.dim, "dim"), page);
|
|
214
|
+
expect(onPage).toBeGreaterThanOrEqual(4.5);
|
|
215
|
+
expect(onMuted).toBeGreaterThanOrEqual(3);
|
|
216
|
+
expect(onDim).toBeGreaterThanOrEqual(1.5);
|
|
217
|
+
expect(onPage).toBeGreaterThan(onMuted);
|
|
218
|
+
expect(onMuted).toBeGreaterThan(onDim);
|
|
219
|
+
|
|
220
|
+
for (const token of TEXT_BACKGROUND_TOKENS) {
|
|
221
|
+
const background = hexOf(theme, theme.colors[token], token);
|
|
222
|
+
expect(contrastRatio(text, background), `text on ${token}`).toBeGreaterThanOrEqual(3);
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test(`${theme.label} steps its thinking tiers monotonically`, () => {
|
|
227
|
+
// spec: the renderer maps thinking level to a border color; off → max must read brighter tier by tier.
|
|
228
|
+
// fails_when: a lower tier renders brighter than a higher one, two tiers collapse to one color, or the ladder
|
|
229
|
+
// fails to span the range.
|
|
230
|
+
const tierColors = THINKING_TOKENS.map((token) => hexOf(theme, theme.colors[token], token));
|
|
231
|
+
expect(new Set(tierColors).size).toBe(THINKING_TOKENS.length);
|
|
232
|
+
const luminances = tierColors.map(relativeLuminance);
|
|
233
|
+
const steps = luminances.slice(1).map((luminance, index) => luminance - (luminances[index] ?? 0));
|
|
234
|
+
expect(steps.every((step) => step > 0)).toBe(true);
|
|
235
|
+
expect(Math.max(...luminances) - Math.min(...luminances)).toBeGreaterThan(0.2);
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
test("the two themes are distinct dark palettes", () => {
|
|
240
|
+
// spec: dracula-soft and one-dark are independent community palettes — no shared token value, different page background.
|
|
241
|
+
// fails_when: one file is a copy of the other, so the theme list ships the same look twice.
|
|
242
|
+
const first = readTheme("dracula-soft.json");
|
|
243
|
+
const second = readTheme("one-dark.json");
|
|
244
|
+
const shared = colorTokens.filter(
|
|
245
|
+
(token) => hexOf(first, first.colors[token], token) === hexOf(second, second.colors[token], token),
|
|
246
|
+
);
|
|
247
|
+
expect(shared).toEqual([]);
|
|
248
|
+
expect(first.doc.appearance).toBe(second.doc.appearance);
|
|
249
|
+
expect(exportColor(first, "pageBg")).not.toBe(exportColor(second, "pageBg"));
|
|
250
|
+
});
|
|
251
|
+
});
|