@penpair/core 0.1.3 → 0.3.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/dist/adapters.d.ts +16 -1
- package/dist/adapters.js +62 -19
- package/dist/approvals.d.ts +32 -0
- package/dist/approvals.js +213 -0
- package/dist/atomic-output.d.ts +5 -0
- package/dist/atomic-output.js +33 -0
- package/dist/audit-rules.d.ts +60 -0
- package/dist/audit-rules.js +443 -0
- package/dist/backup-note.d.ts +4 -0
- package/dist/backup-note.js +49 -0
- package/dist/baseline-coverage.d.ts +25 -0
- package/dist/baseline-coverage.js +248 -0
- package/dist/baselines-config.d.ts +25 -0
- package/dist/baselines-config.js +150 -11
- package/dist/baselines.d.ts +37 -8
- package/dist/baselines.js +362 -59
- package/dist/cli-arguments.d.ts +7 -0
- package/dist/cli-arguments.js +47 -0
- package/dist/cli-main.d.ts +13 -1
- package/dist/cli-main.js +2107 -247
- package/dist/client.d.ts +30 -6
- package/dist/client.js +160 -35
- package/dist/command-result.d.ts +159 -0
- package/dist/command-result.js +468 -0
- package/dist/coordination.d.ts +15 -0
- package/dist/coordination.js +184 -0
- package/dist/credentials.d.ts +21 -0
- package/dist/credentials.js +105 -0
- package/dist/deep-check.js +57 -9
- package/dist/design-spec.d.ts +55 -0
- package/dist/design-spec.js +275 -0
- package/dist/endpoint.d.ts +8 -1
- package/dist/endpoint.js +133 -21
- package/dist/errors.d.ts +2 -1
- package/dist/errors.js +8 -4
- package/dist/export.d.ts +18 -0
- package/dist/export.js +113 -10
- package/dist/file-archive.d.ts +16 -0
- package/dist/file-archive.js +58 -0
- package/dist/file-guard.d.ts +8 -0
- package/dist/file-guard.js +58 -0
- package/dist/formats.d.ts +4 -0
- package/dist/formats.js +229 -0
- package/dist/index.d.ts +34 -1
- package/dist/index.js +34 -1
- package/dist/inspection.d.ts +106 -0
- package/dist/inspection.js +657 -0
- package/dist/instance/config-schema.d.ts +422 -0
- package/dist/instance/config-schema.js +435 -0
- package/dist/instance/config.d.ts +29 -0
- package/dist/instance/config.js +191 -29
- package/dist/instance/connect.d.ts +10 -1
- package/dist/instance/connect.js +178 -30
- package/dist/instance/connection.d.ts +39 -4
- package/dist/instance/connection.js +198 -14
- package/dist/instance/gitignore.js +5 -2
- package/dist/instance/init.js +32 -7
- package/dist/instance/runner.js +32 -10
- package/dist/instance/setup.js +46 -13
- package/dist/instance/tty.js +28 -8
- package/dist/journal.d.ts +87 -0
- package/dist/journal.js +423 -0
- package/dist/kit-smoke.d.ts +31 -0
- package/dist/kit-smoke.js +145 -0
- package/dist/kit-source.d.ts +2 -0
- package/dist/kit-source.js +279 -0
- package/dist/kit.d.ts +25 -0
- package/dist/kit.js +158 -0
- package/dist/layout-policy.d.ts +2 -0
- package/dist/layout-policy.js +86 -0
- package/dist/layout.d.ts +21 -0
- package/dist/layout.js +36 -0
- package/dist/live-smoke.d.ts +8 -0
- package/dist/live-smoke.js +23 -0
- package/dist/local-profile.js +2 -1
- package/dist/mcp-server.d.ts +12 -0
- package/dist/mcp-server.js +415 -0
- package/dist/native-api.d.ts +15 -0
- package/dist/native-api.js +78 -0
- package/dist/onboard.d.ts +10 -0
- package/dist/onboard.js +41 -0
- package/dist/operation.d.ts +22 -3
- package/dist/operation.js +341 -31
- package/dist/page-session.d.ts +23 -0
- package/dist/page-session.js +142 -0
- package/dist/png.d.ts +5 -0
- package/dist/png.js +22 -0
- package/dist/portable-tokens.d.ts +22 -0
- package/dist/portable-tokens.js +331 -0
- package/dist/probe.d.ts +6 -2
- package/dist/probe.js +64 -11
- package/dist/project-input.d.ts +6 -0
- package/dist/project-input.js +65 -0
- package/dist/project-snippets.js +29 -12
- package/dist/project-status.d.ts +24 -0
- package/dist/project-status.js +188 -0
- package/dist/registry.d.ts +2 -1
- package/dist/registry.js +224 -53
- package/dist/run-file.d.ts +26 -0
- package/dist/run-file.js +199 -0
- package/dist/shots.d.ts +43 -0
- package/dist/shots.js +172 -0
- package/dist/skill-instructions.d.ts +13 -0
- package/dist/skill-instructions.js +144 -0
- package/dist/skills.d.ts +7 -0
- package/dist/skills.js +163 -46
- package/dist/snippet.d.ts +3 -2
- package/dist/snippet.js +33 -9
- package/dist/snippets/archive.d.ts +9 -0
- package/dist/snippets/archive.js +10 -4
- package/dist/snippets/read.d.ts +1 -1
- package/dist/snippets/read.js +78 -41
- package/dist/structure-snapshot.d.ts +85 -0
- package/dist/structure-snapshot.js +525 -0
- package/dist/token-edit.d.ts +53 -0
- package/dist/token-edit.js +352 -0
- package/dist/token-projection.d.ts +56 -0
- package/dist/token-projection.js +286 -0
- package/dist/tokens-diff.d.ts +19 -6
- package/dist/tokens-diff.js +182 -18
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/skills/penpair-design/SKILL.md +20 -45
- package/skills/penpair-design/references/penpot-plugin-gotchas.md +58 -2
- package/skills/penpair-design/references/project-facts.md +3 -1
- package/skills/penpair-design/references/write-protocol.md +15 -3
- package/skills/penpair-handoff/SKILL.md +16 -0
- package/skills/penpair-handoff/references/comparison-companion.md +13 -0
- package/skills/penpair-handoff/references/handoff-flow.md +13 -0
- package/skills/penpair-recipes/SKILL.md +21 -0
- package/skills/penpair-recipes/references/build-flow.md +11 -0
- package/skills/penpair-setup/SKILL.md +8 -2
- package/dist/cli.d.ts +0 -2
- package/dist/cli.js +0 -3
- package/dist/docker/lifecycle.d.ts +0 -96
- package/dist/docker/lifecycle.js +0 -273
- package/dist/docker/secrets.d.ts +0 -11
- package/dist/docker/secrets.js +0 -38
- package/dist/docker/template.d.ts +0 -8
- package/dist/docker/template.js +0 -158
- package/dist/docker-profile.d.ts +0 -114
- package/dist/docker-profile.js +0 -18
- package/dist/instance/lifecycle.d.ts +0 -129
- package/dist/instance/lifecycle.js +0 -336
- package/dist/instance/secrets.d.ts +0 -11
- package/dist/instance/secrets.js +0 -38
- package/dist/instance/template.d.ts +0 -8
- package/dist/instance/template.js +0 -158
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import { snippet } from "./snippet.js";
|
|
2
|
+
export const tokenTypes = [
|
|
3
|
+
"borderRadius",
|
|
4
|
+
"shadow",
|
|
5
|
+
"color",
|
|
6
|
+
"dimension",
|
|
7
|
+
"fontFamilies",
|
|
8
|
+
"fontSizes",
|
|
9
|
+
"fontWeights",
|
|
10
|
+
"letterSpacing",
|
|
11
|
+
"number",
|
|
12
|
+
"opacity",
|
|
13
|
+
"rotation",
|
|
14
|
+
"sizing",
|
|
15
|
+
"spacing",
|
|
16
|
+
"borderWidth",
|
|
17
|
+
"textCase",
|
|
18
|
+
"textDecoration",
|
|
19
|
+
"typography",
|
|
20
|
+
];
|
|
21
|
+
export function validateEditableToken(value) {
|
|
22
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
23
|
+
throw new Error("Token must be an object");
|
|
24
|
+
}
|
|
25
|
+
const token = value;
|
|
26
|
+
if (Object.keys(token).some((key) => ![
|
|
27
|
+
"name",
|
|
28
|
+
"type",
|
|
29
|
+
"value",
|
|
30
|
+
"description",
|
|
31
|
+
].includes(key))) {
|
|
32
|
+
throw new Error("Unknown token field");
|
|
33
|
+
}
|
|
34
|
+
if (typeof token.name !== "string" ||
|
|
35
|
+
!token.name.trim() ||
|
|
36
|
+
token.name.length > 200 ||
|
|
37
|
+
/[{}\x00-\x1f]/.test(token.name)) {
|
|
38
|
+
throw new Error("Token name must be nonempty, at most 200 characters and contain no braces or control characters");
|
|
39
|
+
}
|
|
40
|
+
if (!tokenTypes.includes(token.type)) {
|
|
41
|
+
throw new Error("Unsupported token type");
|
|
42
|
+
}
|
|
43
|
+
if (!((typeof token.value === "string" && token.value.length > 0) ||
|
|
44
|
+
(token.value &&
|
|
45
|
+
typeof token.value === "object" &&
|
|
46
|
+
!Array.isArray(token.value) &&
|
|
47
|
+
Object.keys(token.value).length > 0 &&
|
|
48
|
+
Object.values(token.value).every((v) => typeof v === "string")))) {
|
|
49
|
+
throw new Error("Token value must be a nonempty string or composite string-valued object");
|
|
50
|
+
}
|
|
51
|
+
if (token.description !== undefined && typeof token.description !== "string") {
|
|
52
|
+
throw new Error("Token description must be a string");
|
|
53
|
+
}
|
|
54
|
+
if (Buffer.byteLength(JSON.stringify(token)) > 16384) {
|
|
55
|
+
throw new Error("Token exceeds 16 KiB");
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
name: token.name,
|
|
59
|
+
type: token.type,
|
|
60
|
+
value: token.value,
|
|
61
|
+
description: token.description ?? "",
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
export function validateTokenEdit(edit) {
|
|
65
|
+
if (![
|
|
66
|
+
"add-set",
|
|
67
|
+
"set",
|
|
68
|
+
"remove",
|
|
69
|
+
"promote",
|
|
70
|
+
].includes(edit.kind)) {
|
|
71
|
+
throw new Error("Unknown token edit");
|
|
72
|
+
}
|
|
73
|
+
for (const name of [
|
|
74
|
+
edit.set,
|
|
75
|
+
...(edit.kind === "promote"
|
|
76
|
+
? [
|
|
77
|
+
edit.to,
|
|
78
|
+
]
|
|
79
|
+
: []),
|
|
80
|
+
]) {
|
|
81
|
+
if (typeof name !== "string" || !name.trim() || name.length > 200 || /[\x00-\x1f]/.test(name)) {
|
|
82
|
+
throw new Error("Set names must be nonempty and at most 200 characters without control characters");
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (edit.kind === "set") {
|
|
86
|
+
return {
|
|
87
|
+
...edit,
|
|
88
|
+
token: validateEditableToken(edit.token),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
if (edit.kind === "remove" && (typeof edit.name !== "string" || !edit.name.trim())) {
|
|
92
|
+
throw new Error("Remove requires a token name");
|
|
93
|
+
}
|
|
94
|
+
if (edit.kind === "promote" && edit.set === edit.to) {
|
|
95
|
+
throw new Error("Promotion requires distinct source and destination sets");
|
|
96
|
+
}
|
|
97
|
+
return edit;
|
|
98
|
+
}
|
|
99
|
+
/** Pure desired-state planner. Kept self-contained so the delivered mutation rechecks the same facts. */
|
|
100
|
+
export function planTokenEdit(before, edit) {
|
|
101
|
+
const canonical = (value) => {
|
|
102
|
+
if (Array.isArray(value)) {
|
|
103
|
+
return "[" + value.map(canonical).join(",") + "]";
|
|
104
|
+
}
|
|
105
|
+
if (value && typeof value === "object") {
|
|
106
|
+
return ("{" +
|
|
107
|
+
Object.keys(value)
|
|
108
|
+
.sort()
|
|
109
|
+
.map((key) => JSON.stringify(key) + ":" + canonical(value[key]))
|
|
110
|
+
.join(",") +
|
|
111
|
+
"}");
|
|
112
|
+
}
|
|
113
|
+
return JSON.stringify(value);
|
|
114
|
+
};
|
|
115
|
+
const after = JSON.parse(JSON.stringify(before));
|
|
116
|
+
if (!Array.isArray(after.sets) ||
|
|
117
|
+
!Array.isArray(after.themes) ||
|
|
118
|
+
!Array.isArray(after.bindings)) {
|
|
119
|
+
throw new Error("Missing token observations");
|
|
120
|
+
}
|
|
121
|
+
const names = new Set();
|
|
122
|
+
for (const set of after.sets) {
|
|
123
|
+
if (names.has(set.name)) {
|
|
124
|
+
throw new Error("Ambiguous set: " + set.name);
|
|
125
|
+
}
|
|
126
|
+
names.add(set.name);
|
|
127
|
+
const tokens = new Set();
|
|
128
|
+
for (const token of set.tokens) {
|
|
129
|
+
if (tokens.has(token.name)) {
|
|
130
|
+
throw new Error("Ambiguous token: " + token.name);
|
|
131
|
+
}
|
|
132
|
+
tokens.add(token.name);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const impact = [];
|
|
136
|
+
const set = after.sets.find((s) => s.name === edit.set);
|
|
137
|
+
if (edit.kind === "add-set") {
|
|
138
|
+
if (!set) {
|
|
139
|
+
after.sets.push({
|
|
140
|
+
name: edit.set,
|
|
141
|
+
active: false,
|
|
142
|
+
tokens: [],
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
if (!set) {
|
|
148
|
+
throw new Error("Missing set: " + edit.set);
|
|
149
|
+
}
|
|
150
|
+
if (edit.kind === "set") {
|
|
151
|
+
const token = set.tokens.find((t) => t.name === edit.token.name);
|
|
152
|
+
if (token && token.type !== edit.token.type) {
|
|
153
|
+
throw new Error("Type replacement requires a separate reviewed removal");
|
|
154
|
+
}
|
|
155
|
+
if (token) {
|
|
156
|
+
Object.assign(token, edit.token);
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
set.tokens.push(edit.token);
|
|
160
|
+
}
|
|
161
|
+
for (const row of before.bindings) {
|
|
162
|
+
if (Object.values(row.tokens).includes(edit.token.name)) {
|
|
163
|
+
impact.push(row.page + "/" + row.id);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
else if (edit.kind === "remove") {
|
|
168
|
+
// Conservatively refuse even when another active set might supply the same name.
|
|
169
|
+
if (set.tokens.some((t) => t.name === edit.name)) {
|
|
170
|
+
const pattern = "{" + edit.name + "}";
|
|
171
|
+
for (const source of after.sets) {
|
|
172
|
+
for (const token of source.tokens) {
|
|
173
|
+
if (!(source.name === set.name && token.name === edit.name) &&
|
|
174
|
+
JSON.stringify(token.value).includes(pattern)) {
|
|
175
|
+
throw new Error("Retained reference from " + source.name + "/" + token.name);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (before.bindings.some((row) => Object.values(row.tokens).includes(edit.name))) {
|
|
180
|
+
throw new Error("Token is bound to a retained shape");
|
|
181
|
+
}
|
|
182
|
+
set.tokens = set.tokens.filter((t) => t.name !== edit.name);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
const target = after.sets.find((s) => s.name === edit.to);
|
|
187
|
+
if (!target) {
|
|
188
|
+
throw new Error("Missing promotion destination: " + edit.to);
|
|
189
|
+
}
|
|
190
|
+
for (const token of set.tokens) {
|
|
191
|
+
const existing = target.tokens.find((t) => t.name === token.name);
|
|
192
|
+
if (existing && canonical(existing) !== canonical(token)) {
|
|
193
|
+
throw new Error("Promotion conflicts with destination token: " + token.name);
|
|
194
|
+
}
|
|
195
|
+
if (!existing) {
|
|
196
|
+
target.tokens.push(token);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
impact.push("Copies tokens; source, themes and active selection are preserved");
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
for (const set of after.sets) {
|
|
203
|
+
set.tokens.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
before,
|
|
207
|
+
after,
|
|
208
|
+
changed: canonical(before) !== canonical(after),
|
|
209
|
+
impact,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
// Snapshot getter-backed API fields explicitly; refuse partial/oversized observations before any edit.
|
|
213
|
+
const observe = `
|
|
214
|
+
const observe = () => {
|
|
215
|
+
const catalog=penpot.library.local.tokens;
|
|
216
|
+
if (!catalog || !Array.isArray(catalog.sets) || !Array.isArray(catalog.themes)) throw new Error("Token API unavailable");
|
|
217
|
+
let count=0;
|
|
218
|
+
const sets=catalog.sets.map(s=>({name:s.name,active:!!s.active,tokens:s.tokens.map(t=>{
|
|
219
|
+
if(++count>10000)throw new Error("Token edit inspection exceeds 10000 tokens");
|
|
220
|
+
if(typeof t.name!=="string"||typeof t.type!=="string"||t.value===undefined)throw new Error("Incomplete token observation");
|
|
221
|
+
return {name:t.name,type:t.type,value:t.value,description:t.description||""};
|
|
222
|
+
}).sort((a,b)=>a.name<b.name?-1:a.name>b.name?1:0)}));
|
|
223
|
+
const themes=catalog.themes.map(t=>({group:t.group,name:t.name,active:!!t.active,sets:(t.activeSets||[]).map(s=>typeof s==="string"?s:s.name)}));
|
|
224
|
+
const bindings=[],stack=[],seen=new Set();
|
|
225
|
+
for(const page of penpot.currentFile.pages)for(const shape of page.root.children||[])stack.push({page:page.id,shape});
|
|
226
|
+
let visited=0;
|
|
227
|
+
while(stack.length){if(++visited>20000)throw new Error("Token edit binding inspection exceeds 20000 shapes");const {page,shape}=stack.pop();if(!shape.id||seen.has(shape.id))throw new Error("Invalid shape identity");seen.add(shape.id);
|
|
228
|
+
const tokens=shape.tokens||{};if(Object.values(tokens).some(v=>typeof v!=="string"))throw new Error("Unknown shape binding");
|
|
229
|
+
if(Object.keys(tokens).length)bindings.push({page,id:shape.id,tokens});for(const child of shape.children||[])stack.push({page,shape:child});}
|
|
230
|
+
const state=JSON.parse(JSON.stringify({sets,themes,bindings}));
|
|
231
|
+
if(JSON.stringify(state).length>500000)throw new Error("Token edit observation exceeds bounded document size; narrow using a reviewed project operation");
|
|
232
|
+
return state;
|
|
233
|
+
};`;
|
|
234
|
+
const canonical = (value) => {
|
|
235
|
+
if (Array.isArray(value)) {
|
|
236
|
+
return "[" + value.map(canonical).join(",") + "]";
|
|
237
|
+
}
|
|
238
|
+
if (value && typeof value === "object") {
|
|
239
|
+
return ("{" +
|
|
240
|
+
Object.keys(value)
|
|
241
|
+
.sort()
|
|
242
|
+
.map((key) => JSON.stringify(key) + ":" + canonical(value[key]))
|
|
243
|
+
.join(",") +
|
|
244
|
+
"}");
|
|
245
|
+
}
|
|
246
|
+
return JSON.stringify(value);
|
|
247
|
+
};
|
|
248
|
+
function comparable(state) {
|
|
249
|
+
return {
|
|
250
|
+
...state,
|
|
251
|
+
sets: state.sets.map((set) => ({
|
|
252
|
+
...set,
|
|
253
|
+
tokens: set.tokens.map((token) => {
|
|
254
|
+
let value = token.value;
|
|
255
|
+
if (token.type === "fontFamilies") {
|
|
256
|
+
value = Array.isArray(value)
|
|
257
|
+
? value
|
|
258
|
+
: [
|
|
259
|
+
value,
|
|
260
|
+
];
|
|
261
|
+
}
|
|
262
|
+
if (token.type === "typography" &&
|
|
263
|
+
value &&
|
|
264
|
+
typeof value === "object" &&
|
|
265
|
+
!Array.isArray(value)) {
|
|
266
|
+
const { fontFamilies, fontFamily, ...rest } = value;
|
|
267
|
+
const family = fontFamily ?? fontFamilies;
|
|
268
|
+
value = {
|
|
269
|
+
...rest,
|
|
270
|
+
...(family === undefined
|
|
271
|
+
? {}
|
|
272
|
+
: {
|
|
273
|
+
fontFamily: Array.isArray(family)
|
|
274
|
+
? family
|
|
275
|
+
: [
|
|
276
|
+
family,
|
|
277
|
+
],
|
|
278
|
+
}),
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
return {
|
|
282
|
+
...token,
|
|
283
|
+
value,
|
|
284
|
+
};
|
|
285
|
+
}),
|
|
286
|
+
})),
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
const stateFrom = (result) => {
|
|
290
|
+
if (result.ok !== true || !result.state) {
|
|
291
|
+
throw new Error("No independent token-state evidence");
|
|
292
|
+
}
|
|
293
|
+
return result.state;
|
|
294
|
+
};
|
|
295
|
+
export const tokenEditOperation = {
|
|
296
|
+
name: "tokens-edit",
|
|
297
|
+
page: () => undefined,
|
|
298
|
+
inspect(edit) {
|
|
299
|
+
edit = validateTokenEdit(edit);
|
|
300
|
+
return snippet("tokens-edit-inspect", `${observe}\nconst state=observe();const plan=(${planTokenEdit.toString()})(state,CFG.edit);return {ok:true,readOnly:true,state,plan};`, {
|
|
301
|
+
edit,
|
|
302
|
+
});
|
|
303
|
+
},
|
|
304
|
+
apply(edit, inspected) {
|
|
305
|
+
edit = validateTokenEdit(edit);
|
|
306
|
+
return snippet("tokens-edit-apply", `${observe}
|
|
307
|
+
const canonical=${canonical.toString()}, before=observe();
|
|
308
|
+
if(!CFG.before)throw new Error("Missing pinned inspection");
|
|
309
|
+
if(canonical(before)!==canonical(CFG.before))throw new Error("Token source changed after inspection");
|
|
310
|
+
const plan=(${planTokenEdit.toString()})(before,CFG.edit),catalog=penpot.library.local.tokens;
|
|
311
|
+
if(plan.changed){
|
|
312
|
+
if(CFG.edit.kind==="add-set")catalog.addSet({name:CFG.edit.set});
|
|
313
|
+
else {
|
|
314
|
+
const set=catalog.sets.find(s=>s.name===CFG.edit.set);
|
|
315
|
+
if(CFG.edit.kind==="remove"){const token=set.tokens.find(t=>t.name===CFG.edit.name);if(token)token.remove();}
|
|
316
|
+
else if(CFG.edit.kind==="set"){
|
|
317
|
+
const token=set.tokens.find(t=>t.name===CFG.edit.token.name);
|
|
318
|
+
if(token){token.value=CFG.edit.token.value;token.description=CFG.edit.token.description;}
|
|
319
|
+
else{const added=set.addToken(CFG.edit.token);added.description=CFG.edit.token.description;}
|
|
320
|
+
}else{
|
|
321
|
+
const target=catalog.sets.find(s=>s.name===CFG.edit.to);
|
|
322
|
+
for(const token of set.tokens)if(!target.tokens.some(t=>t.name===token.name)){const added=target.addToken({name:token.name,type:token.type,value:token.value});added.description=token.description||"";}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return {ok:true,changed:plan.changed};`, {
|
|
327
|
+
edit,
|
|
328
|
+
before: inspected?.state ?? null,
|
|
329
|
+
});
|
|
330
|
+
},
|
|
331
|
+
readback: () => snippet("tokens-edit-readback", `${observe}\nreturn {ok:true,readOnly:true,state:observe()};`),
|
|
332
|
+
verify({ inspect, readback }) {
|
|
333
|
+
try {
|
|
334
|
+
const plan = inspect.plan;
|
|
335
|
+
if (!plan?.after) {
|
|
336
|
+
return [
|
|
337
|
+
"Missing original desired-state plan",
|
|
338
|
+
];
|
|
339
|
+
}
|
|
340
|
+
return canonical(comparable(stateFrom(readback))) === canonical(comparable(plan.after))
|
|
341
|
+
? []
|
|
342
|
+
: [
|
|
343
|
+
"Independent token state differs from the inspected plan",
|
|
344
|
+
];
|
|
345
|
+
}
|
|
346
|
+
catch (error) {
|
|
347
|
+
return [
|
|
348
|
+
String(error),
|
|
349
|
+
];
|
|
350
|
+
}
|
|
351
|
+
},
|
|
352
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export interface DeclaredToken {
|
|
2
|
+
name: string;
|
|
3
|
+
type: string;
|
|
4
|
+
value: unknown;
|
|
5
|
+
}
|
|
6
|
+
export interface ProjectionCatalog {
|
|
7
|
+
sets: Array<{
|
|
8
|
+
id?: string;
|
|
9
|
+
name: string;
|
|
10
|
+
tokens: DeclaredToken[];
|
|
11
|
+
}>;
|
|
12
|
+
themes: Array<{
|
|
13
|
+
id?: string;
|
|
14
|
+
group: string;
|
|
15
|
+
name: string;
|
|
16
|
+
sets: string[];
|
|
17
|
+
}>;
|
|
18
|
+
}
|
|
19
|
+
export interface ResolvedDeclaredToken {
|
|
20
|
+
name: string;
|
|
21
|
+
type: string;
|
|
22
|
+
value?: unknown;
|
|
23
|
+
unknown?: string;
|
|
24
|
+
}
|
|
25
|
+
/** A small arithmetic grammar, never JavaScript evaluation. Unsupported units/functions are explicit unknowns. */
|
|
26
|
+
export declare function numericExpression(source: string): number;
|
|
27
|
+
/** Catalog order is authoritative; theme membership does not reorder sets. No live API is touched. */
|
|
28
|
+
export declare function resolveDeclaredTokens(catalog: Pick<ProjectionCatalog, "sets">, selectedSets: string[]): ResolvedDeclaredToken[];
|
|
29
|
+
export interface ProjectedThemePreview {
|
|
30
|
+
schemaVersion: 1;
|
|
31
|
+
kind: "projected-theme-preview";
|
|
32
|
+
sourceHash: string;
|
|
33
|
+
theme: {
|
|
34
|
+
id: string | null;
|
|
35
|
+
group: string;
|
|
36
|
+
name: string;
|
|
37
|
+
};
|
|
38
|
+
sets: Array<{
|
|
39
|
+
id: string | null;
|
|
40
|
+
name: string;
|
|
41
|
+
order: number;
|
|
42
|
+
}>;
|
|
43
|
+
resolved: Array<{
|
|
44
|
+
name: string;
|
|
45
|
+
type: string;
|
|
46
|
+
value: unknown;
|
|
47
|
+
}>;
|
|
48
|
+
unknowns: Array<{
|
|
49
|
+
name: string;
|
|
50
|
+
type: string;
|
|
51
|
+
reason: string;
|
|
52
|
+
}>;
|
|
53
|
+
complete: boolean;
|
|
54
|
+
nativeBindings: false;
|
|
55
|
+
}
|
|
56
|
+
export declare function projectTheme(catalog: ProjectionCatalog, themeName: string): ProjectedThemePreview;
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { observationHash } from "./journal.js";
|
|
2
|
+
/** A small arithmetic grammar, never JavaScript evaluation. Unsupported units/functions are explicit unknowns. */
|
|
3
|
+
export function numericExpression(source) {
|
|
4
|
+
if (source.length > 2000) {
|
|
5
|
+
throw new Error("Expression budget exceeded");
|
|
6
|
+
}
|
|
7
|
+
const tokens = source.match(/(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?|[()+*/-]/gi) ?? [];
|
|
8
|
+
if (tokens.join("") !== source.replace(/\s+/g, "") || tokens.length > 500) {
|
|
9
|
+
throw new Error("Unsupported numeric expression");
|
|
10
|
+
}
|
|
11
|
+
let index = 0;
|
|
12
|
+
let depth = 0;
|
|
13
|
+
const atom = () => {
|
|
14
|
+
if (++depth > 64) {
|
|
15
|
+
throw new Error("Expression depth exceeded");
|
|
16
|
+
}
|
|
17
|
+
const token = tokens[index++];
|
|
18
|
+
let value;
|
|
19
|
+
if (token === "(") {
|
|
20
|
+
value = sum();
|
|
21
|
+
if (tokens[index++] !== ")") {
|
|
22
|
+
throw new Error("Unclosed expression");
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
else if (token === "-") {
|
|
26
|
+
value = -atom();
|
|
27
|
+
}
|
|
28
|
+
else if (token === "+") {
|
|
29
|
+
value = atom();
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
if (!token || !/^[\d.]/.test(token)) {
|
|
33
|
+
throw new Error("Missing number");
|
|
34
|
+
}
|
|
35
|
+
value = Number(token);
|
|
36
|
+
}
|
|
37
|
+
depth--;
|
|
38
|
+
if (!Number.isFinite(value)) {
|
|
39
|
+
throw new Error("Nonfinite expression");
|
|
40
|
+
}
|
|
41
|
+
return value;
|
|
42
|
+
};
|
|
43
|
+
const product = () => {
|
|
44
|
+
let value = atom();
|
|
45
|
+
while ([
|
|
46
|
+
"*",
|
|
47
|
+
"/",
|
|
48
|
+
].includes(tokens[index] ?? "")) {
|
|
49
|
+
const op = tokens[index++];
|
|
50
|
+
const right = atom();
|
|
51
|
+
value = op === "*" ? value * right : value / right;
|
|
52
|
+
}
|
|
53
|
+
return value;
|
|
54
|
+
};
|
|
55
|
+
const sum = () => {
|
|
56
|
+
let value = product();
|
|
57
|
+
while ([
|
|
58
|
+
"+",
|
|
59
|
+
"-",
|
|
60
|
+
].includes(tokens[index] ?? "")) {
|
|
61
|
+
const op = tokens[index++];
|
|
62
|
+
const right = product();
|
|
63
|
+
value = op === "+" ? value + right : value - right;
|
|
64
|
+
}
|
|
65
|
+
return value;
|
|
66
|
+
};
|
|
67
|
+
const value = sum();
|
|
68
|
+
if (index !== tokens.length || !Number.isFinite(value)) {
|
|
69
|
+
throw new Error("Invalid numeric expression");
|
|
70
|
+
}
|
|
71
|
+
return value;
|
|
72
|
+
}
|
|
73
|
+
const numericTypes = new Set([
|
|
74
|
+
"borderRadius",
|
|
75
|
+
"dimension",
|
|
76
|
+
"fontSizes",
|
|
77
|
+
"letterSpacing",
|
|
78
|
+
"number",
|
|
79
|
+
"opacity",
|
|
80
|
+
"rotation",
|
|
81
|
+
"sizing",
|
|
82
|
+
"spacing",
|
|
83
|
+
"borderWidth",
|
|
84
|
+
]);
|
|
85
|
+
const supportedTypes = new Set([
|
|
86
|
+
...numericTypes,
|
|
87
|
+
"color",
|
|
88
|
+
"fontFamilies",
|
|
89
|
+
"fontWeights",
|
|
90
|
+
"textDecoration",
|
|
91
|
+
"textCase",
|
|
92
|
+
"typography",
|
|
93
|
+
"shadow",
|
|
94
|
+
]);
|
|
95
|
+
/** Catalog order is authoritative; theme membership does not reorder sets. No live API is touched. */
|
|
96
|
+
export function resolveDeclaredTokens(catalog, selectedSets) {
|
|
97
|
+
if (!catalog ||
|
|
98
|
+
!Array.isArray(catalog.sets) ||
|
|
99
|
+
catalog.sets.length > 1000 ||
|
|
100
|
+
!Array.isArray(selectedSets) ||
|
|
101
|
+
new Set(selectedSets).size !== selectedSets.length) {
|
|
102
|
+
throw new Error("Invalid token projection catalog or membership");
|
|
103
|
+
}
|
|
104
|
+
const names = new Set();
|
|
105
|
+
let count = 0;
|
|
106
|
+
for (const set of catalog.sets) {
|
|
107
|
+
if (!set ||
|
|
108
|
+
typeof set.name !== "string" ||
|
|
109
|
+
!set.name ||
|
|
110
|
+
names.has(set.name) ||
|
|
111
|
+
!Array.isArray(set.tokens)) {
|
|
112
|
+
throw new Error("Token sets need unique names and token arrays");
|
|
113
|
+
}
|
|
114
|
+
names.add(set.name);
|
|
115
|
+
const tokens = new Set();
|
|
116
|
+
for (const token of set.tokens) {
|
|
117
|
+
if (++count > 10000) {
|
|
118
|
+
throw new Error("Projection token budget exceeded");
|
|
119
|
+
}
|
|
120
|
+
if (!token ||
|
|
121
|
+
typeof token.name !== "string" ||
|
|
122
|
+
!token.name ||
|
|
123
|
+
tokens.has(token.name) ||
|
|
124
|
+
typeof token.type !== "string" ||
|
|
125
|
+
!("value" in token)) {
|
|
126
|
+
throw new Error("Ambiguous or malformed token declaration");
|
|
127
|
+
}
|
|
128
|
+
tokens.add(token.name);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (selectedSets.some((name) => !names.has(name))) {
|
|
132
|
+
throw new Error("Selected theme references a missing token set");
|
|
133
|
+
}
|
|
134
|
+
const effective = new Map(catalog.sets
|
|
135
|
+
.filter((set) => selectedSets.includes(set.name))
|
|
136
|
+
.flatMap((set) => set.tokens.map((token) => [
|
|
137
|
+
token.name,
|
|
138
|
+
token,
|
|
139
|
+
])));
|
|
140
|
+
let resolutionWork = 0;
|
|
141
|
+
const resolve = (name, visiting = new Set()) => {
|
|
142
|
+
if (++resolutionWork > 100000) {
|
|
143
|
+
throw new Error("Projection resolution budget exceeded");
|
|
144
|
+
}
|
|
145
|
+
if (visiting.has(name) || visiting.size >= 64) {
|
|
146
|
+
throw new Error("Cyclic or over-deep reference");
|
|
147
|
+
}
|
|
148
|
+
const token = effective.get(name);
|
|
149
|
+
if (!token) {
|
|
150
|
+
throw new Error("Missing referenced token: " + name);
|
|
151
|
+
}
|
|
152
|
+
if (!supportedTypes.has(token.type)) {
|
|
153
|
+
throw new Error("Unsupported token type: " + token.type);
|
|
154
|
+
}
|
|
155
|
+
const next = new Set(visiting);
|
|
156
|
+
next.add(name);
|
|
157
|
+
let members = 0;
|
|
158
|
+
const value = (input, depth = 0) => {
|
|
159
|
+
if (++resolutionWork > 100000) {
|
|
160
|
+
throw new Error("Projection resolution budget exceeded");
|
|
161
|
+
}
|
|
162
|
+
if (++members > 10000 || depth > 64) {
|
|
163
|
+
throw new Error("Composite projection budget exceeded");
|
|
164
|
+
}
|
|
165
|
+
if (typeof input === "string") {
|
|
166
|
+
if (input.length > 10000) {
|
|
167
|
+
throw new Error("Token value length budget exceeded");
|
|
168
|
+
}
|
|
169
|
+
const alias = /^\{([^{}]+)\}$/.exec(input);
|
|
170
|
+
if (alias) {
|
|
171
|
+
return resolve(alias[1], next);
|
|
172
|
+
}
|
|
173
|
+
return input.replace(/\{([^{}]+)\}/g, (_match, reference) => {
|
|
174
|
+
const resolved = resolve(reference, next);
|
|
175
|
+
if (typeof resolved !== "number" && typeof resolved !== "string") {
|
|
176
|
+
throw new Error("Composite interpolation unsupported");
|
|
177
|
+
}
|
|
178
|
+
return String(resolved);
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
if (Array.isArray(input)) {
|
|
182
|
+
return input.map((entry) => value(entry, depth + 1));
|
|
183
|
+
}
|
|
184
|
+
if (input && typeof input === "object") {
|
|
185
|
+
return Object.fromEntries(Object.entries(input).map(([key, entry]) => [
|
|
186
|
+
key,
|
|
187
|
+
value(entry, depth + 1),
|
|
188
|
+
]));
|
|
189
|
+
}
|
|
190
|
+
if (input === null ||
|
|
191
|
+
typeof input === "boolean" ||
|
|
192
|
+
(typeof input === "number" && Number.isFinite(input))) {
|
|
193
|
+
return input;
|
|
194
|
+
}
|
|
195
|
+
throw new Error("Unsupported/nonfinite token value");
|
|
196
|
+
};
|
|
197
|
+
const resolved = value(token.value);
|
|
198
|
+
return numericTypes.has(token.type)
|
|
199
|
+
? typeof resolved === "number"
|
|
200
|
+
? resolved
|
|
201
|
+
: numericExpression(String(resolved))
|
|
202
|
+
: resolved;
|
|
203
|
+
};
|
|
204
|
+
return [
|
|
205
|
+
...effective.values(),
|
|
206
|
+
].map((token) => {
|
|
207
|
+
try {
|
|
208
|
+
return {
|
|
209
|
+
name: token.name,
|
|
210
|
+
type: token.type,
|
|
211
|
+
value: resolve(token.name),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
catch (error) {
|
|
215
|
+
return {
|
|
216
|
+
name: token.name,
|
|
217
|
+
type: token.type,
|
|
218
|
+
unknown: error instanceof Error ? error.message : String(error),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
export function projectTheme(catalog, themeName) {
|
|
224
|
+
if (!catalog ||
|
|
225
|
+
!Array.isArray(catalog.themes) ||
|
|
226
|
+
catalog.themes.length > 1000 ||
|
|
227
|
+
typeof themeName !== "string" ||
|
|
228
|
+
!themeName) {
|
|
229
|
+
throw new Error("Projection needs a catalog and explicit theme name");
|
|
230
|
+
}
|
|
231
|
+
for (const theme of catalog.themes) {
|
|
232
|
+
if (!theme ||
|
|
233
|
+
typeof theme.name !== "string" ||
|
|
234
|
+
typeof theme.group !== "string" ||
|
|
235
|
+
!Array.isArray(theme.sets)) {
|
|
236
|
+
throw new Error("Malformed declared theme");
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
const matches = catalog.themes.filter((theme) => theme.name === themeName || `${theme.group}/${theme.name}` === themeName);
|
|
240
|
+
if (matches.length !== 1) {
|
|
241
|
+
throw new Error("Theme name is missing or ambiguous; use a unique group/name");
|
|
242
|
+
}
|
|
243
|
+
const theme = matches[0];
|
|
244
|
+
const values = resolveDeclaredTokens(catalog, theme.sets);
|
|
245
|
+
const resolved = [];
|
|
246
|
+
const unknowns = [];
|
|
247
|
+
for (const token of values) {
|
|
248
|
+
if (token.unknown) {
|
|
249
|
+
unknowns.push({
|
|
250
|
+
name: token.name,
|
|
251
|
+
type: token.type,
|
|
252
|
+
reason: token.unknown,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
resolved.push({
|
|
257
|
+
name: token.name,
|
|
258
|
+
type: token.type,
|
|
259
|
+
value: token.value,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return {
|
|
264
|
+
schemaVersion: 1,
|
|
265
|
+
kind: "projected-theme-preview",
|
|
266
|
+
sourceHash: observationHash(catalog),
|
|
267
|
+
theme: {
|
|
268
|
+
id: theme.id ?? null,
|
|
269
|
+
group: theme.group,
|
|
270
|
+
name: theme.name,
|
|
271
|
+
},
|
|
272
|
+
sets: catalog.sets.flatMap((set, order) => theme.sets.includes(set.name)
|
|
273
|
+
? [
|
|
274
|
+
{
|
|
275
|
+
id: set.id ?? null,
|
|
276
|
+
name: set.name,
|
|
277
|
+
order,
|
|
278
|
+
},
|
|
279
|
+
]
|
|
280
|
+
: []),
|
|
281
|
+
resolved,
|
|
282
|
+
unknowns,
|
|
283
|
+
complete: unknowns.length === 0,
|
|
284
|
+
nativeBindings: false,
|
|
285
|
+
};
|
|
286
|
+
}
|