@timmo001/groundwork-tokens 0.3.0 → 0.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/generated/Themes.qml +1 -1
- package/generated/opentui.ts +24 -24
- package/generated/themes.css +58 -32
- package/generated/themes.json +25 -25
- package/generated/themes.ts +25 -25
- package/package.json +4 -1
- package/src/generate.ts +20 -0
- package/src/omarchy.test.ts +63 -0
- package/src/omarchy.ts +66 -0
- package/src/page.ts +307 -0
- package/src/runtime.ts +43 -0
- package/src/webmcp.test.ts +111 -0
- package/src/webmcp.ts +249 -0
- package/themes/bridge.json +2 -2
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests the WebMCP theme tools against a fake model context: what they
|
|
3
|
+
* register, and that set-theme applies and keeps a theme by id, by document
|
|
4
|
+
* and from an Omarchy colors.toml.
|
|
5
|
+
*
|
|
6
|
+
* @module
|
|
7
|
+
*/
|
|
8
|
+
import { expect, test } from "bun:test";
|
|
9
|
+
import type { ThemeStore } from "./runtime.ts";
|
|
10
|
+
import {
|
|
11
|
+
registerThemeTools,
|
|
12
|
+
type ModelContext,
|
|
13
|
+
type ToolArguments,
|
|
14
|
+
} from "./webmcp.ts";
|
|
15
|
+
|
|
16
|
+
type Tool = Parameters<ModelContext["registerTool"]>[0];
|
|
17
|
+
|
|
18
|
+
const setup = () => {
|
|
19
|
+
const tools = new Map<string, Tool>();
|
|
20
|
+
const applied: string[] = [];
|
|
21
|
+
let stored: string | null = null;
|
|
22
|
+
|
|
23
|
+
const store: ThemeStore = {
|
|
24
|
+
load: async () => stored,
|
|
25
|
+
save: async (text) => {
|
|
26
|
+
stored = text;
|
|
27
|
+
},
|
|
28
|
+
clear: async () => {
|
|
29
|
+
stored = null;
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const registered = registerThemeTools({
|
|
34
|
+
store,
|
|
35
|
+
apply: ({ document }) => applied.push(document.id),
|
|
36
|
+
context: {
|
|
37
|
+
registerTool: (tool) => {
|
|
38
|
+
tools.set(tool.name, tool);
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const call = async (name: string, input: ToolArguments = {}) => {
|
|
44
|
+
const result = await tools.get(name)?.execute(input);
|
|
45
|
+
|
|
46
|
+
return JSON.stringify(result);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
return { registered, tools, applied, call, stored: () => stored };
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
test("registers the three tools", () => {
|
|
53
|
+
const { registered, tools } = setup();
|
|
54
|
+
|
|
55
|
+
expect(registered).toBe(true);
|
|
56
|
+
expect([...tools.keys()]).toEqual(["get-theme", "list-themes", "set-theme"]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("does nothing without a model context", () => {
|
|
60
|
+
expect(
|
|
61
|
+
registerThemeTools({
|
|
62
|
+
store: {
|
|
63
|
+
load: async () => null,
|
|
64
|
+
save: async () => {},
|
|
65
|
+
clear: async () => {},
|
|
66
|
+
},
|
|
67
|
+
apply: () => {},
|
|
68
|
+
}),
|
|
69
|
+
).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("set-theme applies and keeps a built-in theme by id", async () => {
|
|
73
|
+
const { applied, call, stored } = setup();
|
|
74
|
+
|
|
75
|
+
expect(await call("list-themes")).toContain("glazing");
|
|
76
|
+
expect(await call("set-theme", { id: "glazing" })).toContain(
|
|
77
|
+
"Applied Glazing",
|
|
78
|
+
);
|
|
79
|
+
expect(applied).toEqual(["glazing"]);
|
|
80
|
+
expect(stored()).toContain('"id": "glazing"');
|
|
81
|
+
expect(await call("get-theme")).toContain("glazing");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("set-theme takes an Omarchy colors.toml", async () => {
|
|
85
|
+
const { applied, call } = setup();
|
|
86
|
+
|
|
87
|
+
const toml = `mode = "dark"
|
|
88
|
+
accent = "#7aa2f7"
|
|
89
|
+
muted = "#414868"
|
|
90
|
+
background = "#1a1b26"
|
|
91
|
+
dark_background = "#13141c"
|
|
92
|
+
lighter_background = "#24283b"
|
|
93
|
+
foreground = "#a9b1d6"
|
|
94
|
+
bright_foreground = "#c0caf5"
|
|
95
|
+
red = "#f7768e"
|
|
96
|
+
yellow = "#e0af68"
|
|
97
|
+
green = "#9ece6a"
|
|
98
|
+
blue = "#7aa2f7"
|
|
99
|
+
magenta = "#ad8ee6"`;
|
|
100
|
+
|
|
101
|
+
expect(await call("set-theme", { theme: toml })).toContain("Applied Omarchy");
|
|
102
|
+
expect(applied).toEqual(["omarchy"]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("set-theme explains a bad id or input instead of throwing", async () => {
|
|
106
|
+
const { applied, call } = setup();
|
|
107
|
+
|
|
108
|
+
expect(await call("set-theme", { id: "nope" })).toContain("list-themes");
|
|
109
|
+
expect(await call("set-theme", { theme: "{}" })).toContain("isn't a theme");
|
|
110
|
+
expect(applied).toEqual([]);
|
|
111
|
+
});
|
package/src/webmcp.ts
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme tools for WebMCP: lets an agent in the browser read, list and set the
|
|
3
|
+
* page's theme through the page's own code, so the UI the user sees changes
|
|
4
|
+
* with it. It does nothing where the browser has no `document.modelContext`.
|
|
5
|
+
* Spec: https://github.com/webmachinelearning/webmcp
|
|
6
|
+
*
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
import { Result, Schema } from "effect";
|
|
10
|
+
import base from "../themes/base.json" with { type: "json" };
|
|
11
|
+
import bridge from "../themes/bridge.json" with { type: "json" };
|
|
12
|
+
import glazing from "../themes/glazing.json" with { type: "json" };
|
|
13
|
+
import slate from "../themes/slate.json" with { type: "json" };
|
|
14
|
+
import snag from "../themes/snag.json" with { type: "json" };
|
|
15
|
+
import survey from "../themes/survey.json" with { type: "json" };
|
|
16
|
+
import { omarchyTheme } from "./omarchy.ts";
|
|
17
|
+
import {
|
|
18
|
+
parseTheme,
|
|
19
|
+
runtimeTheme,
|
|
20
|
+
type RuntimeTheme,
|
|
21
|
+
type ThemeStore,
|
|
22
|
+
} from "./runtime.ts";
|
|
23
|
+
import { SolvedTheme, type Theme } from "./schema.ts";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The part of WebMCP's `ModelContext` these tools use. Declared here rather
|
|
27
|
+
* than taken from a types package, since the API is still moving.
|
|
28
|
+
*/
|
|
29
|
+
export interface ModelContext {
|
|
30
|
+
readonly registerTool: (
|
|
31
|
+
tool: ModelContextTool,
|
|
32
|
+
options?: { readonly signal?: AbortSignal },
|
|
33
|
+
) => Promise<void> | void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A tool's text result, in the shape MCP uses. */
|
|
37
|
+
export interface ToolResult {
|
|
38
|
+
readonly content: readonly { readonly type: "text"; readonly text: string }[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ModelContextTool {
|
|
42
|
+
readonly name: string;
|
|
43
|
+
readonly description: string;
|
|
44
|
+
readonly inputSchema?: object;
|
|
45
|
+
/** Takes the agent's arguments, which the tool still decodes itself. */
|
|
46
|
+
readonly execute: (input: ToolArguments) => Promise<ToolResult>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The arguments these tools take. Agents can send anything, so they're decoded. */
|
|
50
|
+
export interface ToolArguments {
|
|
51
|
+
readonly id?: string;
|
|
52
|
+
readonly theme?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
declare global {
|
|
56
|
+
interface Document {
|
|
57
|
+
/** WebMCP's tool registry, where the browser has it. */
|
|
58
|
+
readonly modelContext?: ModelContext;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** What `set-theme` takes: a built-in theme's id, or a theme's text. */
|
|
63
|
+
const SetThemeInput = Schema.Struct({
|
|
64
|
+
id: Schema.optionalKey(Schema.String),
|
|
65
|
+
theme: Schema.optionalKey(Schema.String),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
/** The core themes, in the order the docs list them. */
|
|
69
|
+
export const coreThemes: readonly SolvedTheme[] = [
|
|
70
|
+
base,
|
|
71
|
+
glazing,
|
|
72
|
+
survey,
|
|
73
|
+
slate,
|
|
74
|
+
bridge,
|
|
75
|
+
snag,
|
|
76
|
+
].map((document) => Schema.decodeUnknownSync(SolvedTheme)(document));
|
|
77
|
+
|
|
78
|
+
export interface ThemeToolsOptions {
|
|
79
|
+
/** Where the user's theme document is kept. */
|
|
80
|
+
readonly store: ThemeStore;
|
|
81
|
+
/** Applies a solved theme to the page, as the app's own picker would. */
|
|
82
|
+
readonly apply: (theme: {
|
|
83
|
+
readonly text: string;
|
|
84
|
+
readonly document: Theme;
|
|
85
|
+
readonly runtime: RuntimeTheme;
|
|
86
|
+
}) => void;
|
|
87
|
+
/** Themes an agent can pick by id. Defaults to the core themes. */
|
|
88
|
+
readonly themes?: readonly Theme[];
|
|
89
|
+
/** Unregisters the tools when aborted. */
|
|
90
|
+
readonly signal?: AbortSignal;
|
|
91
|
+
/** The model context to register on. Defaults to `document.modelContext`. */
|
|
92
|
+
readonly context?: ModelContext;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const text = (message: string): ToolResult => ({
|
|
96
|
+
content: [{ type: "text", text: message }],
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
/** The roles a theme misses its contrast target on, for the agent to report. */
|
|
100
|
+
const misses = (runtime: RuntimeTheme) =>
|
|
101
|
+
runtime.checks
|
|
102
|
+
.filter((result) => !result.passes)
|
|
103
|
+
.map(
|
|
104
|
+
(result) =>
|
|
105
|
+
`${result.role} in ${result.mode} (${result.ratio.toFixed(2)})`,
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
/** Reads a theme from JSON text or an Omarchy `colors.toml`. */
|
|
109
|
+
const readTheme = (input: string) => {
|
|
110
|
+
const parsed = parseTheme(input);
|
|
111
|
+
|
|
112
|
+
if (Result.isSuccess(parsed)) return parsed;
|
|
113
|
+
|
|
114
|
+
return input.trimStart().startsWith("{") ? parsed : omarchyTheme(input);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Registers `get-theme`, `list-themes` and `set-theme`. Returns whether the
|
|
119
|
+
* browser offered a model context to register them on.
|
|
120
|
+
*/
|
|
121
|
+
export const registerThemeTools = (options: ThemeToolsOptions): boolean => {
|
|
122
|
+
const context =
|
|
123
|
+
options.context ??
|
|
124
|
+
("document" in globalThis ? document.modelContext : undefined);
|
|
125
|
+
|
|
126
|
+
if (context === undefined) return false;
|
|
127
|
+
|
|
128
|
+
const themes = options.themes ?? coreThemes;
|
|
129
|
+
|
|
130
|
+
const registration =
|
|
131
|
+
options.signal === undefined ? {} : { signal: options.signal };
|
|
132
|
+
|
|
133
|
+
const apply = async (document: Theme) => {
|
|
134
|
+
const source = JSON.stringify(document, null, 2);
|
|
135
|
+
const runtime = runtimeTheme(document);
|
|
136
|
+
|
|
137
|
+
options.apply({ text: source, document, runtime });
|
|
138
|
+
await options.store.save(source);
|
|
139
|
+
|
|
140
|
+
const missed = misses(runtime);
|
|
141
|
+
|
|
142
|
+
return text(
|
|
143
|
+
missed.length === 0
|
|
144
|
+
? `Applied ${document.name}. Every role meets its contrast target.`
|
|
145
|
+
: `Applied ${document.name}, with ${missed.length} roles below target: ${missed.join(", ")}.`,
|
|
146
|
+
);
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
void context.registerTool(
|
|
150
|
+
{
|
|
151
|
+
name: "get-theme",
|
|
152
|
+
description:
|
|
153
|
+
"Returns the user's own theme document on this page, if they have one, and any roles that miss their contrast target.",
|
|
154
|
+
execute: async () => {
|
|
155
|
+
const stored = await options.store.load();
|
|
156
|
+
|
|
157
|
+
if (stored === null)
|
|
158
|
+
return text(
|
|
159
|
+
"No custom theme is set; the page uses its built-in theme.",
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
const parsed = readTheme(stored);
|
|
163
|
+
|
|
164
|
+
if (Result.isFailure(parsed))
|
|
165
|
+
return text(
|
|
166
|
+
"The stored theme no longer decodes, so the page ignores it.",
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
const report = {
|
|
170
|
+
document: parsed.success,
|
|
171
|
+
misses: misses(runtimeTheme(parsed.success)),
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
return text(JSON.stringify(report, null, 2));
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
registration,
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
void context.registerTool(
|
|
181
|
+
{
|
|
182
|
+
name: "list-themes",
|
|
183
|
+
description:
|
|
184
|
+
"Lists the built-in themes by id and name, to pick one with set-theme.",
|
|
185
|
+
execute: async () =>
|
|
186
|
+
text(
|
|
187
|
+
JSON.stringify(
|
|
188
|
+
themes.map((theme) => ({ id: theme.id, name: theme.name })),
|
|
189
|
+
null,
|
|
190
|
+
2,
|
|
191
|
+
),
|
|
192
|
+
),
|
|
193
|
+
},
|
|
194
|
+
registration,
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
void context.registerTool(
|
|
198
|
+
{
|
|
199
|
+
name: "set-theme",
|
|
200
|
+
description:
|
|
201
|
+
"Applies a theme to this page and keeps it. Give the id of a built-in theme, or a Groundwork theme document as JSON, or the text of an Omarchy colors.toml.",
|
|
202
|
+
inputSchema: {
|
|
203
|
+
type: "object",
|
|
204
|
+
properties: {
|
|
205
|
+
id: {
|
|
206
|
+
type: "string",
|
|
207
|
+
description: "A built-in theme's id, from list-themes.",
|
|
208
|
+
},
|
|
209
|
+
theme: {
|
|
210
|
+
type: "string",
|
|
211
|
+
description:
|
|
212
|
+
"A Groundwork theme document as JSON, or an Omarchy colors.toml's text.",
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
execute: async (input) => {
|
|
217
|
+
const decoded = Schema.decodeUnknownResult(SetThemeInput)(input);
|
|
218
|
+
|
|
219
|
+
if (Result.isFailure(decoded))
|
|
220
|
+
return text("Give id or theme as a string.");
|
|
221
|
+
|
|
222
|
+
const { id, theme } = decoded.success;
|
|
223
|
+
|
|
224
|
+
if (id !== undefined) {
|
|
225
|
+
const found = themes.find((item) => item.id === id);
|
|
226
|
+
|
|
227
|
+
return found === undefined
|
|
228
|
+
? text(
|
|
229
|
+
`No built-in theme has the id "${id}". Call list-themes for the ids.`,
|
|
230
|
+
)
|
|
231
|
+
: apply(found);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (theme === undefined) return text("Give either id or theme.");
|
|
235
|
+
|
|
236
|
+
const parsed = readTheme(theme);
|
|
237
|
+
|
|
238
|
+
return Result.isSuccess(parsed)
|
|
239
|
+
? apply(parsed.success)
|
|
240
|
+
: text(
|
|
241
|
+
`That isn't a theme document or an Omarchy colors.toml: ${String(parsed.failure).slice(0, 400)}`,
|
|
242
|
+
);
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
registration,
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
return true;
|
|
249
|
+
};
|
package/themes/bridge.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"kind": "solved",
|
|
3
3
|
"id": "bridge",
|
|
4
4
|
"name": "Bridge",
|
|
5
|
-
"description": "
|
|
5
|
+
"description": "System Bridge's violet on neutral charcoal. Medium radii, soft borders and pill chips.",
|
|
6
6
|
"examples": ["System Bridge"],
|
|
7
7
|
"background": "#1e1e22",
|
|
8
|
-
"accent": "#
|
|
8
|
+
"accent": "#a78bfa",
|
|
9
9
|
"style": {
|
|
10
10
|
"font": "Inter",
|
|
11
11
|
"mono": "JetBrains Mono",
|