justskill-design 0.0.1 → 3.2.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.
Files changed (67) hide show
  1. package/README.en.md +235 -0
  2. package/README.md +184 -11
  3. package/SKILL.md +435 -0
  4. package/canvas/app.mjs +2870 -0
  5. package/canvas/groups.mjs +204 -0
  6. package/canvas/index.html +141 -0
  7. package/canvas/styles.css +1032 -0
  8. package/canvas/tree.mjs +249 -0
  9. package/cordis.patch.yml +8 -0
  10. package/design.mjs +172 -0
  11. package/docs/dsh-workbench.png +0 -0
  12. package/dsh/client.js +864 -0
  13. package/dsh/index.js +354 -0
  14. package/dsh/scan.js +139 -0
  15. package/dsh/skill.js +41 -0
  16. package/lib/base-css.mjs +92 -0
  17. package/lib/detect.mjs +123 -0
  18. package/lib/diagnostics.mjs +382 -0
  19. package/lib/export-html.mjs +309 -0
  20. package/lib/export-svg.mjs +111 -0
  21. package/lib/fragment.mjs +702 -0
  22. package/lib/geometry.mjs +827 -0
  23. package/lib/icons.mjs +106 -0
  24. package/lib/levenshtein.mjs +133 -0
  25. package/lib/node/cli.mjs +387 -0
  26. package/lib/node/export-svg.mjs +75 -0
  27. package/lib/node/fs.mjs +309 -0
  28. package/lib/node/op-json.mjs +75 -0
  29. package/lib/node/raster-run.mjs +115 -0
  30. package/lib/node/server.mjs +1264 -0
  31. package/lib/node/static.mjs +206 -0
  32. package/lib/node/verbs/add.mjs +220 -0
  33. package/lib/node/verbs/export.mjs +161 -0
  34. package/lib/node/verbs/format.mjs +106 -0
  35. package/lib/node/verbs/import.mjs +126 -0
  36. package/lib/node/verbs/ls.mjs +98 -0
  37. package/lib/node/verbs/migrate.mjs +236 -0
  38. package/lib/node/verbs/move.mjs +76 -0
  39. package/lib/node/verbs/new.mjs +284 -0
  40. package/lib/node/verbs/remove.mjs +41 -0
  41. package/lib/node/verbs/rename.mjs +87 -0
  42. package/lib/node/verbs/serve.mjs +164 -0
  43. package/lib/node/verbs/set.mjs +150 -0
  44. package/lib/node/verbs/show.mjs +122 -0
  45. package/lib/node/verbs/tokens.mjs +167 -0
  46. package/lib/node/verbs/validate.mjs +130 -0
  47. package/lib/node/write-verbs.mjs +215 -0
  48. package/lib/normalize.mjs +288 -0
  49. package/lib/ops.mjs +2063 -0
  50. package/lib/parse.mjs +997 -0
  51. package/lib/raster.mjs +186 -0
  52. package/lib/readers/pen.mjs +1684 -0
  53. package/lib/receipt.mjs +203 -0
  54. package/lib/render.mjs +503 -0
  55. package/lib/serialize.mjs +441 -0
  56. package/lib/spans.mjs +177 -0
  57. package/lib/svg-path.mjs +483 -0
  58. package/lib/table.mjs +1047 -0
  59. package/lib/tokens.mjs +295 -0
  60. package/lib/validate.mjs +929 -0
  61. package/package.json +68 -6
  62. package/references/cookbook.md +156 -0
  63. package/references/images.md +171 -0
  64. package/references/product-flow.md +408 -0
  65. package/references/schema.md +90 -0
  66. package/references/styling.md +181 -0
  67. package/scripts/install.mjs +95 -0
package/dsh/index.js ADDED
@@ -0,0 +1,354 @@
1
+ /**
2
+ * dsh/index.js — host half of the justSkill Design plugin.
3
+ *
4
+ * Three responsibilities, each deliberately small:
5
+ *
6
+ * 1. Register the skill that already lives in this package, so a plugin
7
+ * install is equivalent to dropping the directory into a skills root.
8
+ * 2. Scan registered workspaces for design files. The browser cannot read
9
+ * the filesystem, so the picker asks the Host.
10
+ * 3. Start one canvas service per design file on a loopback port and hand
11
+ * the browser its tokenized URL. The canvas itself is unchanged — the
12
+ * plugin only decides which file it serves.
13
+ */
14
+
15
+ import { realpath, stat } from "node:fs/promises";
16
+ import { isAbsolute, resolve } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+ import { dirname } from "node:path";
19
+
20
+ import { startServer } from "../lib/node/server.mjs";
21
+ import { readDesignFile, scanWorkspace } from "./scan.js";
22
+ import { readSkill } from "./skill.js";
23
+
24
+ export const name = "justskill-design";
25
+ export const inject = ["skills"];
26
+
27
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
28
+ const CACHE_TTL_MS = 15_000;
29
+ const SCAN_BUDGET_MS = 6_000;
30
+ const WORKSPACE_BUDGET_MS = 2_500;
31
+ const MAX_WORKSPACES = 64;
32
+ const PENDING_TTL_MS = 60_000;
33
+
34
+ /** Successful JSON response; no store, the panel re-reads deliberately. */
35
+ function ok(value) {
36
+ return Response.json({ ok: true, value }, {
37
+ headers: { "cache-control": "no-store" },
38
+ });
39
+ }
40
+
41
+ /** Refusal response with a stable code the client can branch on. */
42
+ function fail(status, code, message) {
43
+ return Response.json({ ok: false, error: { code, message } }, {
44
+ status,
45
+ headers: { "cache-control": "no-store" },
46
+ });
47
+ }
48
+
49
+ async function readJson(request) {
50
+ try {
51
+ const body = await request.json();
52
+ return body !== null && typeof body === "object" && !Array.isArray(body) ? body : null;
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ /** Canonicalize one workspace directory; returns null when it is not one. */
59
+ async function canonicalWorkspace(entry) {
60
+ if (entry === null || typeof entry !== "object") return null;
61
+ if (typeof entry.path !== "string" || entry.path.trim() === "") return null;
62
+ if (!isAbsolute(entry.path)) return null;
63
+ try {
64
+ const path = await realpath(entry.path);
65
+ const info = await stat(path);
66
+ if (!info.isDirectory()) return null;
67
+ return {
68
+ id: typeof entry.id === "string" ? entry.id : path,
69
+ path,
70
+ title: typeof entry.title === "string" ? entry.title : "",
71
+ };
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * One canvas server per design file, owned by the plugin fiber. A second open
79
+ * for the same file joins the first start instead of racing it.
80
+ */
81
+ class CanvasPool {
82
+ #entries = new Map();
83
+
84
+ async open(file) {
85
+ const current = this.#entries.get(file);
86
+ if (current?.server !== undefined) return current.server;
87
+ if (current?.starting !== undefined) return current.starting;
88
+
89
+ const starting = (async () => {
90
+ const server = await startServer({
91
+ file,
92
+ port: 0,
93
+ ttlMinutes: 30,
94
+ open: false,
95
+ });
96
+ const entry = { server };
97
+ this.#entries.set(file, entry);
98
+ server.closed.then(() => {
99
+ if (this.#entries.get(file) === entry) this.#entries.delete(file);
100
+ }, () => {
101
+ if (this.#entries.get(file) === entry) this.#entries.delete(file);
102
+ });
103
+ return server;
104
+ })();
105
+
106
+ this.#entries.set(file, { starting });
107
+ try {
108
+ return await starting;
109
+ } catch (error) {
110
+ this.#entries.delete(file);
111
+ throw error;
112
+ }
113
+ }
114
+
115
+ close(file) {
116
+ const entry = this.#entries.get(file);
117
+ this.#entries.delete(file);
118
+ try {
119
+ entry?.server?.close("plugin closed the canvas");
120
+ } catch {
121
+ /* already closing */
122
+ }
123
+ }
124
+
125
+ closeAll() {
126
+ const entries = [...this.#entries.values()];
127
+ this.#entries.clear();
128
+ for (const entry of entries) {
129
+ try {
130
+ entry.server?.close("plugin disposed");
131
+ } catch {
132
+ /* already closing */
133
+ }
134
+ }
135
+ }
136
+ }
137
+
138
+ export function apply(ctx) {
139
+ const skill = readSkill(ROOT);
140
+ ctx.effect(() => ctx.skills.register({
141
+ ...skill,
142
+ source: "bundled",
143
+ }), "justskill-design: skill");
144
+
145
+ const pool = new CanvasPool();
146
+ ctx.effect(() => () => pool.closeAll(), "justskill-design: canvas servers");
147
+
148
+ // Session-scoped handoff from the Host tool to the browser watcher. The tool
149
+ // cannot touch the right-sidebar UI, so it leaves a request the client claims
150
+ // and clears after the tab is open.
151
+ const pending = new Map();
152
+ const purgePending = () => {
153
+ const now = Date.now();
154
+ for (const [sessionId, entry] of pending) {
155
+ if (now - entry.at > PENDING_TTL_MS) pending.delete(sessionId);
156
+ }
157
+ };
158
+
159
+ const cache = new Map();
160
+
161
+ ctx.inject(["connection"], (scope) => {
162
+ const connection = scope.get("connection");
163
+ if (connection === undefined || typeof connection.fetch?.register !== "function") {
164
+ ctx.logger?.warn?.("justskill-design: connection.fetch is unavailable; the workbench Host API stays off");
165
+ return;
166
+ }
167
+
168
+ const route = (path, handler, label) => {
169
+ scope.effect(() => connection.fetch.register({
170
+ path,
171
+ methods: ["POST"],
172
+ requestBody: "buffered",
173
+ fetch: handler,
174
+ }), label);
175
+ };
176
+
177
+ route("/api/justskill-design/scan", async (request) => {
178
+ const body = await readJson(request);
179
+ if (body === null) return fail(400, "bad-request", "body must be a JSON object");
180
+ if (!Array.isArray(body.workspaces)) return fail(400, "bad-request", "workspaces must be an array");
181
+ if (body.workspaces.length > MAX_WORKSPACES) {
182
+ return fail(400, "too-many-workspaces", `at most ${MAX_WORKSPACES} workspaces per scan`);
183
+ }
184
+
185
+ const valid = [];
186
+ for (const entry of body.workspaces) {
187
+ const workspace = await canonicalWorkspace(entry);
188
+ if (workspace !== null) valid.push(workspace);
189
+ }
190
+
191
+ const refresh = body.refresh === true;
192
+ const deadline = Date.now() + SCAN_BUDGET_MS;
193
+ const results = [];
194
+ for (const workspace of valid) {
195
+ const cached = cache.get(workspace.path);
196
+ if (!refresh && cached !== undefined && Date.now() - cached.at < CACHE_TTL_MS) {
197
+ results.push({ ...workspace, ...cached.value, cached: true });
198
+ continue;
199
+ }
200
+ const local = Math.min(Date.now() + WORKSPACE_BUDGET_MS, deadline);
201
+ const value = await scanWorkspace(workspace, { deadline: local });
202
+ cache.set(workspace.path, { at: Date.now(), value });
203
+ results.push({ ...workspace, ...value });
204
+ }
205
+
206
+ return ok({
207
+ workspaces: results,
208
+ scannedAt: Date.now(),
209
+ incomplete: results.some((entry) => entry.incomplete === true),
210
+ });
211
+ }, "justskill-design: workspace scan route");
212
+
213
+ route("/api/justskill-design/canvas", async (request) => {
214
+ const body = await readJson(request);
215
+ if (body === null) return fail(400, "bad-request", "body must be a JSON object");
216
+ if (typeof body.file !== "string" || body.file.trim() === "") {
217
+ return fail(400, "bad-request", "file must be a non-empty string");
218
+ }
219
+ if (!isAbsolute(body.file)) return fail(400, "bad-request", "file must be absolute");
220
+
221
+ let file;
222
+ try {
223
+ file = await realpath(body.file);
224
+ } catch {
225
+ return fail(404, "file-not-found", `no such file: ${body.file}`);
226
+ }
227
+
228
+ const meta = await readDesignFile(file);
229
+ if (meta === null) {
230
+ return fail(422, "not-a-design-file", `${file} has no design-schema marker`);
231
+ }
232
+
233
+ try {
234
+ const server = await pool.open(file);
235
+ return ok({ url: server.url, file, port: server.port });
236
+ } catch (error) {
237
+ return fail(500, "canvas-start-failed", error instanceof Error ? error.message : String(error));
238
+ }
239
+ }, "justskill-design: canvas open route");
240
+
241
+ route("/api/justskill-design/canvas/close", async (request) => {
242
+ const body = await readJson(request);
243
+ if (body === null) return fail(400, "bad-request", "body must be a JSON object");
244
+ if (typeof body.file !== "string" || body.file.trim() === "") {
245
+ return fail(400, "bad-request", "file must be a non-empty string");
246
+ }
247
+ let file;
248
+ try {
249
+ file = await realpath(body.file);
250
+ } catch {
251
+ return ok({ closed: false });
252
+ }
253
+ pool.close(file);
254
+ return ok({ closed: true });
255
+ }, "justskill-design: canvas close route");
256
+
257
+ route("/api/justskill-design/pending", async (request) => {
258
+ const body = await readJson(request);
259
+ if (body === null) return fail(400, "bad-request", "body must be a JSON object");
260
+ if (typeof body.sessionId !== "string" || body.sessionId === "") {
261
+ return fail(400, "bad-request", "sessionId must be a non-empty string");
262
+ }
263
+ purgePending();
264
+ const entry = pending.get(body.sessionId);
265
+ return ok({ pending: entry === undefined ? null : { file: entry.file } });
266
+ }, "justskill-design: pending canvas route");
267
+
268
+ route("/api/justskill-design/pending/clear", async (request) => {
269
+ const body = await readJson(request);
270
+ if (body === null) return fail(400, "bad-request", "body must be a JSON object");
271
+ if (typeof body.sessionId !== "string" || body.sessionId === "") {
272
+ return fail(400, "bad-request", "sessionId must be a non-empty string");
273
+ }
274
+ purgePending();
275
+ const entry = pending.get(body.sessionId);
276
+ if (entry !== undefined && (typeof body.file !== "string" || body.file === "" || body.file === entry.file)) {
277
+ pending.delete(body.sessionId);
278
+ }
279
+ return ok({ cleared: true });
280
+ }, "justskill-design: pending canvas clear route");
281
+ });
282
+
283
+ ctx.inject(["tools"], (scope) => {
284
+ const tools = scope.get("tools");
285
+ if (tools === undefined || typeof tools.register !== "function") return;
286
+
287
+ scope.effect(() => tools.register({
288
+ name: "justskill_canvas",
289
+ description:
290
+ "Open the justSkill design canvas in the DSH right-sidebar Canvas tab. Use this " +
291
+ "whenever the user asks to open, show or focus the design canvas, and before " +
292
+ "designing page by page. Never run `node design.mjs serve` in DeepSeek Harness; " +
293
+ "the canvas is the right-sidebar tab.",
294
+ parameters: {
295
+ type: "object",
296
+ additionalProperties: false,
297
+ properties: {
298
+ file: {
299
+ type: "string",
300
+ description: "Absolute path to the design file. Defaults to design.html in the session workspace.",
301
+ },
302
+ },
303
+ },
304
+ output: {
305
+ schema: {
306
+ type: "object",
307
+ additionalProperties: false,
308
+ properties: {
309
+ requested: { type: "boolean" },
310
+ file: { type: "string" },
311
+ },
312
+ required: ["requested", "file"],
313
+ },
314
+ render(_args, value) {
315
+ return [{ type: "text", text: `justskill_canvas → ${value.file}` }];
316
+ },
317
+ },
318
+ timeoutMs: 15_000,
319
+ isConcurrencySafe() {
320
+ return false;
321
+ },
322
+ async execute(args, exec) {
323
+ const agent = exec === undefined ? undefined : exec.agent;
324
+ const sessionId = agent === undefined ? undefined : agent.id;
325
+ const cwd = agent === undefined ? undefined : agent.session?.header?.cwd;
326
+ if (typeof sessionId !== "string" || sessionId === "") {
327
+ throw new Error("justskill_canvas: no calling session");
328
+ }
329
+ if (typeof cwd !== "string" || cwd === "") {
330
+ throw new Error("justskill_canvas: no session workspace");
331
+ }
332
+ const requested = args !== null && typeof args === "object" && typeof args.file === "string"
333
+ ? args.file
334
+ : "";
335
+ const candidate = requested === ""
336
+ ? resolve(cwd, "design.html")
337
+ : isAbsolute(requested) ? requested : resolve(cwd, requested);
338
+ let file;
339
+ try {
340
+ file = await realpath(candidate);
341
+ } catch {
342
+ throw new Error(`justskill_canvas: no such file: ${candidate}`);
343
+ }
344
+ const meta = await readDesignFile(file);
345
+ if (meta === null) {
346
+ throw new Error(`justskill_canvas: ${file} has no design-schema marker`);
347
+ }
348
+ purgePending();
349
+ pending.set(sessionId, { file, at: Date.now() });
350
+ return { requested: true, file };
351
+ },
352
+ }), "justskill-design: canvas tool");
353
+ });
354
+ }
package/dsh/scan.js ADDED
@@ -0,0 +1,139 @@
1
+ /**
2
+ * dsh/scan.js — find justSkill design files inside a workspace.
3
+ *
4
+ * A design file is identified by the marker the format already carries:
5
+ * `<meta name="design-schema" content="...">`. No new marker is introduced and
6
+ * no filename convention is required, so a file named by the user keeps
7
+ * working. The scan is deliberately bounded: a workspace can be arbitrary
8
+ * large, and the workbench is a picker, not an indexer.
9
+ */
10
+
11
+ import { open, readFile, readdir, stat } from "node:fs/promises";
12
+ import { basename, join } from "node:path";
13
+ import { pathToFileURL } from "node:url";
14
+
15
+ /** A design file always carries this meta tag; the format owns the spelling. */
16
+ export const DESIGN_MARKER = /<meta\b[^>]*\bname\s*=\s*["']design-schema["'][^>]*>/i;
17
+
18
+ /** Directories that never contain user-facing design files. */
19
+ export const SKIP_DIRS = new Set([
20
+ ".git", ".hg", ".svn", ".idea", ".vscode", ".next", ".nuxt", ".svelte-kit",
21
+ ".cache", ".turbo", ".parcel-cache", ".venv", ".tox", ".pytest_cache",
22
+ "node_modules", "bower_components", "dist", "build", "out", "coverage",
23
+ "vendor", "target", "bin", "obj", "Pods", "DerivedData",
24
+ ]);
25
+
26
+ /** Limits that keep one workspace from stalling the panel. */
27
+ export const LIMITS = {
28
+ maxDepth: 6,
29
+ maxFiles: 2000,
30
+ maxReadBytes: 512 * 1024,
31
+ headBytes: 8 * 1024,
32
+ budgetMs: 2500,
33
+ };
34
+
35
+ const HTML = /\.html?$/i;
36
+ const TITLE = /<title\b[^>]*>([\s\S]*?)<\/title>/i;
37
+ const PAGE = /\bdata-type\s*=\s*["']page["']/g;
38
+
39
+ /**
40
+ * Read one candidate file and return its design metadata, or null when it is
41
+ * not a design file.
42
+ *
43
+ * @param {string} file absolute path
44
+ * @returns {Promise<null | {path: string, uri: string, name: string, title: string | null, pages: number | null, size: number, mtime: number}>}
45
+ */
46
+ export async function readDesignFile(file) {
47
+ let info;
48
+ try {
49
+ info = await stat(file);
50
+ } catch {
51
+ return null;
52
+ }
53
+ if (!info.isFile()) return null;
54
+
55
+ let text;
56
+ if (info.size <= LIMITS.maxReadBytes) {
57
+ try {
58
+ text = await readFile(file, "utf8");
59
+ } catch {
60
+ return null;
61
+ }
62
+ } else {
63
+ let handle;
64
+ try {
65
+ handle = await open(file, "r");
66
+ const buffer = Buffer.alloc(Math.min(LIMITS.headBytes, info.size));
67
+ await handle.read(buffer, 0, buffer.length, 0);
68
+ text = buffer.toString("utf8");
69
+ } catch {
70
+ return null;
71
+ } finally {
72
+ await handle?.close().catch(() => {});
73
+ }
74
+ }
75
+
76
+ if (!DESIGN_MARKER.test(text)) return null;
77
+ const title = TITLE.exec(text)?.[1]?.replace(/\s+/g, " ").trim() || null;
78
+ const pages = info.size <= LIMITS.maxReadBytes ? (text.match(PAGE)?.length ?? 0) : null;
79
+ return {
80
+ path: file,
81
+ uri: pathToFileURL(file).href,
82
+ name: basename(file),
83
+ title,
84
+ pages,
85
+ size: info.size,
86
+ mtime: info.mtimeMs,
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Scan one workspace, depth-first, bounded by depth, file count and time.
92
+ *
93
+ * @param {{path: string}} workspace canonical workspace directory
94
+ * @param {{deadline?: number, maxDepth?: number, maxFiles?: number}} [options]
95
+ * @returns {Promise<{path: string, files: Array<object>, incomplete: boolean}>}
96
+ */
97
+ export async function scanWorkspace(workspace, options = {}) {
98
+ const root = workspace.path;
99
+ const deadline = options.deadline ?? Date.now() + LIMITS.budgetMs;
100
+ const maxDepth = options.maxDepth ?? LIMITS.maxDepth;
101
+ const maxFiles = options.maxFiles ?? LIMITS.maxFiles;
102
+ const files = [];
103
+ let incomplete = false;
104
+
105
+ const expired = () => Date.now() > deadline;
106
+
107
+ async function walk(directory, depth) {
108
+ if (expired() || files.length >= maxFiles) {
109
+ incomplete = true;
110
+ return;
111
+ }
112
+ let entries;
113
+ try {
114
+ entries = await readdir(directory, { withFileTypes: true });
115
+ } catch {
116
+ return; // unreadable directory: skip, never fail the whole panel
117
+ }
118
+ for (const entry of entries) {
119
+ if (expired() || files.length >= maxFiles) {
120
+ incomplete = true;
121
+ return;
122
+ }
123
+ if (entry.isSymbolicLink()) continue;
124
+ const file = join(directory, entry.name);
125
+ if (entry.isDirectory()) {
126
+ if (depth >= maxDepth || SKIP_DIRS.has(entry.name)) continue;
127
+ await walk(file, depth + 1);
128
+ continue;
129
+ }
130
+ if (!entry.isFile() || !HTML.test(entry.name)) continue;
131
+ const meta = await readDesignFile(file);
132
+ if (meta !== null) files.push(meta);
133
+ }
134
+ }
135
+
136
+ await walk(root, 0);
137
+ files.sort((left, right) => right.mtime - left.mtime);
138
+ return { path: root, files, incomplete };
139
+ }
package/dsh/skill.js ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * dsh/skill.js — turn this package's SKILL.md into a DSH skill contribution.
3
+ *
4
+ * The skill stays the source of truth: README, CLI, references and the canvas
5
+ * are all resolved relative to the package root, so a plugin install and a
6
+ * `~/.agents/skills` symlink see exactly the same files. Only the YAML
7
+ * frontmatter is removed: DSH expects the Markdown body, not the metadata.
8
+ */
9
+
10
+ import { readFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+
13
+ export const SKILL_NAME = "justskill-design";
14
+
15
+ /**
16
+ * The catalog description DSH shows next to the skill name. DSH truncates the
17
+ * catalog at 500 characters (dsh-tool-skill), and this stays comfortably under
18
+ * it while keeping the "what triggers it" part first.
19
+ */
20
+ export const SKILL_DESCRIPTION =
21
+ "把产品想法推进到受用户审批的页面设计,再按设计写代码。用于产品想法、功能增删、页面/应用界面设计、改版、调整间距色彩排版、导出设计、导入 .pen 等场景。设计就是一份受校验的 HTML 文件,用户可以在画布上实时审阅和调整。";
22
+
23
+ /**
24
+ * Read SKILL.md and return the registration DSH expects.
25
+ *
26
+ * @param {string} root package root containing SKILL.md
27
+ * @returns {{name: string, description: string, content: string, path: string, resourceBase: {kind: "directory", path: string}}}
28
+ */
29
+ export function readSkill(root) {
30
+ const file = join(root, "SKILL.md");
31
+ const raw = readFileSync(file, "utf8");
32
+ const content = raw.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "").trimStart();
33
+ if (content.trim() === "") throw new Error("SKILL.md has no body after its frontmatter");
34
+ return {
35
+ name: SKILL_NAME,
36
+ description: SKILL_DESCRIPTION,
37
+ content,
38
+ path: file,
39
+ resourceBase: { kind: "directory", path: root },
40
+ };
41
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * lib/base-css.mjs — the rules a design file states about itself.
3
+ *
4
+ * A design file is a standalone HTML document. Inline styles decide the layout,
5
+ * and three browser defaults decide what those inline styles MEAN:
6
+ *
7
+ * `box-sizing` is `content-box`, so `width:375px;padding:0 24px` is 423px
8
+ * wide, not 375px.
9
+ * `margin` on `p`, `h1` and the other text tags is not zero, so a
10
+ * one-line label is 16px taller than its author expects.
11
+ * SVG geometry defaults to `fill:black`, so a stroke-based glyph paints as
12
+ * a solid silhouette.
13
+ *
14
+ * The rules are deliberately keyed on the element, not on a `data-*` attribute.
15
+ * A `data-*` selector would be shorter — one rule instead of 28 declarations —
16
+ * and it would be wrong: `export` drops every design attribute, so a rule keyed
17
+ * on `data-kind` stops matching in the deliverable and a circle ships as a
18
+ * square. What a node looks like has to live in the node's own `style`.
19
+ *
20
+ * The file cannot leave those to chance and then be read the same way by three
21
+ * renderers. So the file states them once, and every reader of the file gets the
22
+ * same answer: the browser, the canvas, and `export`.
23
+ *
24
+ * The one exception is `:root{…}` inside a shadow root. `:root` matches no
25
+ * element there, so the canvas puts the token values on `:host` instead. See
26
+ * `pageCss` in `lib/render.mjs`.
27
+ *
28
+ * Byte fidelity: these rules are text inside a `<style>` block, which is the
29
+ * page-frame layer. The serializer keeps them as bytes, and the parser does not
30
+ * read them.
31
+ */
32
+
33
+ /** The rules, one per line, in the order they must appear. */
34
+ export const BASE_RULES = [
35
+ "*,*::before,*::after{box-sizing:border-box}",
36
+ "*{margin:0}",
37
+ "img{display:block}",
38
+ // A built-in glyph carries no `fill` and no `stroke`, so these two live on the
39
+ // glyph's own `<svg>`: a stroke needs a colour from somewhere, and `color` on
40
+ // the element is what tints it. `:not([fill])` lets a solid icon opt out.
41
+ "svg:not([data-fill]) > :not([fill]):not([stroke]){fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}",
42
+ ];
43
+
44
+ /** The base rules as CSS text, ready to sit in front of the token block. */
45
+ export function baseCss(rules = BASE_RULES) {
46
+ return rules.join("\n");
47
+ }
48
+
49
+ /**
50
+ * True when this CSS text already states a box model.
51
+ *
52
+ * The check is on `box-sizing`, because that is the rule that changes what a
53
+ * `width` means. A file that omits it is not broken — it is a file whose widths
54
+ * are content widths, which is a legitimate thing to write and a bad thing to
55
+ * write by accident.
56
+ */
57
+ export function hasBaseRules(cssText) {
58
+ return /box-sizing\s*:/.test(String(cssText ?? ""));
59
+ }
60
+
61
+ /** The base rules found in a `<style>` block, or "" when there are none. */
62
+ export function baseRulesOf(cssText) {
63
+ const text = String(cssText ?? "");
64
+ if (!hasBaseRules(text)) return "";
65
+ const lines = [];
66
+ for (const rule of text.split("\n")) {
67
+ const trimmed = rule.trim();
68
+ if (!trimmed || trimmed.startsWith(":root")) continue;
69
+ if (/^[^{]*\{[^}]*\}$/.test(trimmed)) lines.push(trimmed);
70
+ }
71
+ return lines.join("\n");
72
+ }
73
+
74
+ /**
75
+ * The base rules this CSS text does not state.
76
+ *
77
+ * A file written before a rule existed is a file that renders differently from
78
+ * the format it claims, and the difference is silent: the box model holds, so
79
+ * nothing looks broken, and one shape or one glyph is simply wrong. So the file
80
+ * is told which rule it is missing.
81
+ *
82
+ * Whitespace and quotes are free: `svg:not([data-fill]) > :not([fill])` with a
83
+ * space before the brace states the same rule as the canonical line.
84
+ */
85
+ export function missingBaseRules(cssText) {
86
+ const text = squash(String(cssText ?? ""));
87
+ return BASE_RULES.filter((rule) => !text.includes(squash(rule)));
88
+ }
89
+
90
+ function squash(text) {
91
+ return text.replace(/\s+/g, "").replace(/["']/g, "");
92
+ }