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/lib/tokens.mjs ADDED
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Design tokens: the one place that knows what a token name means.
3
+ *
4
+ * A token lives in the `:root{}` of a `<style>` block, which makes it the only part
5
+ * of a design file that is not an element. Nothing can address it by `data-id`, so
6
+ * every rule about it — the seven groups, the name shape, the unit a numeric group
7
+ * carries, the reference chain — has to be written down somewhere, and this is that
8
+ * place. The alternative is what the tree already suffered once: the same rule
9
+ * written twice, drifting apart.
10
+ *
11
+ * This module is pure and browser-loadable: no `node:*`, no `process`. It reads a
12
+ * parsed document and answers questions. It does not write; the writing is an op
13
+ * (`set-token` / `unset-token` in `lib/ops.mjs`), because every change in this tool
14
+ * goes through one funnel.
15
+ *
16
+ * What a token is, per `docs/format-v3.md` §4.2 and `docs/plan.md` §4.2:
17
+ *
18
+ * - `:root{ --color-accent:#2563eb }` inside a `<style>` in `<head>`.
19
+ * - The name is `--<group>-<name>`, and the group is one of seven.
20
+ * - `space`, `radius` and `size` carry a unit. `color` is a color. `font`,
21
+ * `shadow` and `border` are free-form CSS values, and this module does not
22
+ * invent a rule for them.
23
+ * - A token may reference a token. The chain stops at depth 16, and a cycle is
24
+ * refused rather than followed.
25
+ */
26
+
27
+ import {
28
+ TOKEN_GROUPS,
29
+ TOKEN_GROUP_UNIT,
30
+ TOKEN_NAME_PATTERN,
31
+ TableError,
32
+ normalizeColor,
33
+ normalizeLength,
34
+ } from "./table.mjs";
35
+
36
+ /** How far a `var(--a)` chain may be followed before it is left as written. */
37
+ export const TOKEN_MAX_DEPTH = 16;
38
+
39
+ /**
40
+ * Replace every `var(--name)` in a CSS value with the token's value, and follow
41
+ * the chain. A name that the map does not hold stays as written, unless the
42
+ * declaration carries a fallback, which CSS would use in that case.
43
+ *
44
+ * This belongs here, with the depth limit and the cycle rule it obeys. It used to
45
+ * live in `lib/render.mjs`, and `export-html.mjs` then grew its own copy with no
46
+ * depth limit at all: a token cycle emptied the stack during `export` while
47
+ * `validate` called the same file valid.
48
+ *
49
+ * `seen` holds the names on the current chain. A repeat leaves the reference as
50
+ * written, so a cycle cannot recurse.
51
+ */
52
+ export function resolveTokens(cssValue, tokens, depth = 0, seen = null) {
53
+ if (depth > TOKEN_MAX_DEPTH) return cssValue;
54
+ const text = String(cssValue ?? "");
55
+ if (!text.includes("var(")) return text;
56
+ return text.replace(/var\(\s*(--[A-Za-z0-9_-]+)\s*(?:,\s*([^()]*))?\)/g, (whole, name, fallback) => {
57
+ const token = tokens.get(name);
58
+ if (!token) return fallback !== undefined ? fallback.trim() : whole;
59
+ if (seen && seen.has(name)) return fallback !== undefined ? fallback.trim() : whole;
60
+ const next = seen ? new Set(seen) : new Set();
61
+ next.add(name);
62
+ return resolveTokens(token.value, tokens, depth + 1, next);
63
+ });
64
+ }
65
+
66
+ /** `var(--name)` or `var(--name, fallback)`. The fallback may itself hold a `var()`. */
67
+ const TOKEN_REFERENCE = /var\(\s*(--[A-Za-z0-9_-]+)/g;
68
+
69
+ /** The group of a token name: the text between `--` and the first hyphen. */
70
+ export function groupOfToken(name) {
71
+ return String(name ?? "").slice(2).split("-")[0];
72
+ }
73
+
74
+ /**
75
+ * What is wrong with a token name, or `null` when nothing is.
76
+ *
77
+ * One function, because there are two callers and they must not disagree: the
78
+ * validator, which reports a name written in a file, and `resolveTokenName`, which
79
+ * refuses a name typed on the command line.
80
+ *
81
+ * The rule has two halves and only one of them used to be checked.
82
+ * `references/schema.md` — the page the skill hands a model — says the group "must be
83
+ * one of" the seven, and the groups are a closed set in `plan.md` §4.2. The check was
84
+ * the shape alone, so `--accent-500` passed validation while `tokens set accent.500`
85
+ * was refused: the file was called valid and its tokens were unaddressable, with
86
+ * nothing saying so. A test named "a token name outside the seven groups is reported"
87
+ * passed only because its sample, `--brand`, happened to break the shape too.
88
+ *
89
+ * @returns {{group:string, message:string, hint:string}|null}
90
+ */
91
+ export function tokenNameProblem(name) {
92
+ const raw = String(name ?? "");
93
+ const group = groupOfToken(raw);
94
+ const known = TOKEN_GROUPS.includes(group);
95
+ if (TOKEN_NAME_PATTERN.test(raw) && known) return null;
96
+ if (!TOKEN_NAME_PATTERN.test(raw) && known) {
97
+ return {
98
+ group,
99
+ message: `The token ${raw} does not follow --<group>-<name>.`,
100
+ hint: `The group is ${group}. Add a name after it, such as --${group}-base.`,
101
+ };
102
+ }
103
+ if (!TOKEN_NAME_PATTERN.test(raw)) {
104
+ return {
105
+ group,
106
+ message: `The token ${raw} does not follow --<group>-<name>.`,
107
+ hint: `The group must be one of: ${TOKEN_GROUPS.join(", ")}.`,
108
+ };
109
+ }
110
+ return {
111
+ group,
112
+ message: `The token ${raw} uses the group ${group}, which is not one of the ${TOKEN_GROUPS.length}.`,
113
+ hint: `The groups are: ${TOKEN_GROUPS.join(", ")}.`,
114
+ };
115
+ }
116
+
117
+ /**
118
+ * A token name as it is written in the file, from either spelling.
119
+ *
120
+ * The file spells it `--color-accent`. The CLI spells it `color.accent`, because
121
+ * `docs/plan.md` §5.1 documents `tokens set <组.名> <值>` and because a leading `--`
122
+ * reads as a flag in a shell. Both are accepted here and produce the one name the
123
+ * file uses, so neither spelling can grow its own behaviour.
124
+ *
125
+ * @param {string} spec `color.accent` or `--color-accent`
126
+ * @returns {string} `--color-accent`
127
+ * @throws {TableError} `style/token-name` for a group that is not one of the seven
128
+ */
129
+ export function resolveTokenName(spec) {
130
+ const raw = String(spec ?? "").trim();
131
+ const groups = `Write <group>.<name>, such as color.accent. The groups are: ${TOKEN_GROUPS.join(", ")}.`;
132
+ if (raw === "") throw new TableError("style/token-name", "A token name is required.", { hint: groups });
133
+
134
+ if (raw.startsWith("--")) {
135
+ const problem = tokenNameProblem(raw);
136
+ if (problem !== null) throw new TableError("style/token-name", problem.message, { hint: problem.hint });
137
+ return raw;
138
+ }
139
+
140
+ const dot = raw.indexOf(".");
141
+ if (dot === -1) {
142
+ throw new TableError("style/token-name", `The token ${raw} does not name a group.`, { hint: groups });
143
+ }
144
+ const group = raw.slice(0, dot);
145
+ if (!TOKEN_GROUPS.includes(group)) {
146
+ throw new TableError("style/token-name", `${group} is not a token group.`, {
147
+ hint: `The group must be one of: ${TOKEN_GROUPS.join(", ")}.`,
148
+ });
149
+ }
150
+ const name = `--${group}-${raw.slice(dot + 1)}`;
151
+ const problem = tokenNameProblem(name);
152
+ if (problem !== null) throw new TableError("style/token-name", problem.message, { hint: problem.hint });
153
+ return name;
154
+ }
155
+
156
+ /**
157
+ * The value a token declaration should carry.
158
+ *
159
+ * Only the two rules the contract states are enforced, because a rule that is not
160
+ * written down cannot be enforced by surprise:
161
+ *
162
+ * - a numeric group carries a unit, so a bare `16` becomes `16px` and a value
163
+ * that is not a length is refused. Storing `16` would produce `var(--space-md)`
164
+ * → `16`, which is invalid CSS, so the page would silently lose the property.
165
+ * - a color is normalized to the hex the rest of the file uses.
166
+ *
167
+ * `font`, `shadow` and `border` are written as given. `docs/format-v3.md` §4.2 sets
168
+ * no rule for them, and a normalizer invented here would be a rule nobody asked for.
169
+ *
170
+ * @returns {{value:string, changed:boolean}}
171
+ */
172
+ export function normalizeTokenValue(group, value) {
173
+ const raw = String(value ?? "").trim();
174
+ if (raw === "") throw new TableError("style/empty-value", "A token needs a value.", { hint: "--space-md:16px" });
175
+ const unit = TOKEN_GROUP_UNIT[group];
176
+ if (unit === null || unit === undefined) {
177
+ if (group !== "color") return { value: raw, changed: false };
178
+ const color = normalizeColor(raw);
179
+ return { value: color, changed: color !== raw };
180
+ }
181
+ const normalized = normalizeLength(raw);
182
+ return { value: normalized, changed: normalized !== raw };
183
+ }
184
+
185
+ /** Every token name a value references, in order. `var(--a, var(--b))` names both. */
186
+ export function tokenReferenceNames(value) {
187
+ const text = String(value ?? "");
188
+ if (!text.includes("var(")) return [];
189
+ const out = [];
190
+ TOKEN_REFERENCE.lastIndex = 0;
191
+ let m;
192
+ while ((m = TOKEN_REFERENCE.exec(text)) !== null) out.push(m[1]);
193
+ return out;
194
+ }
195
+
196
+ /**
197
+ * Every token in the document, and where it is written.
198
+ *
199
+ * One site per name: the first declaration wins the slot, and a later declaration of
200
+ * the same name is reported instead of quietly replacing it. `format-v3.md` §4.1
201
+ * says both style blocks are collected and that the later block must not overwrite
202
+ * the earlier one, so a name declared twice is a conflict the caller has to resolve,
203
+ * not a value this function gets to choose.
204
+ *
205
+ * `offset` is a **file** offset: the block's text starts at `block.textStart`, and a
206
+ * declaration's `start` is relative to that text. Both live here so the addition
207
+ * happens once.
208
+ *
209
+ * @returns {{sites:Map<string,object>, duplicates:Array<{name:string, offsets:number[]}>, blocks:object[]}}
210
+ */
211
+ export function tokenSites(doc) {
212
+ const sites = new Map();
213
+ const duplicates = [];
214
+ const blocks = [];
215
+ for (const block of doc?.styleBlocks || []) {
216
+ // `inHead` is part of the test because format-v3 §4.1 defines a token block that
217
+ // way ("`<style>` 在 `<head>` 内、内容含 `:root{` 时是 `tokens`"). A `<style>` in
218
+ // `<body>` is a `style/style-in-body` error, and reading tokens out of a block the
219
+ // contract does not recognise would let a broken file look like a working one.
220
+ if (block.inHead !== true) continue;
221
+ if (block.role !== "tokens" && block.role !== "tokens+page-frame") continue;
222
+ blocks.push(block);
223
+ for (const declaration of block.declarations || []) {
224
+ if (!declaration.property.startsWith("--")) continue;
225
+ const offset = (block.textStart ?? 0) + declaration.start;
226
+ const known = sites.get(declaration.property);
227
+ if (known) {
228
+ known.duplicates.push(offset);
229
+ const reported = duplicates.find((entry) => entry.name === declaration.property);
230
+ if (reported) reported.offsets.push(offset);
231
+ else duplicates.push({ name: declaration.property, offsets: [known.offset, offset] });
232
+ continue;
233
+ }
234
+ sites.set(declaration.property, {
235
+ name: declaration.property,
236
+ value: declaration.value,
237
+ offset,
238
+ block,
239
+ declaration,
240
+ duplicates: [],
241
+ });
242
+ }
243
+ }
244
+ return { sites, duplicates, blocks };
245
+ }
246
+
247
+ /**
248
+ * Token reference cycles, each reported once.
249
+ *
250
+ * A cycle is not an edge case: `--a:var(--b); --b:var(--a)` makes any consumer that
251
+ * follows the chain recurse until the stack dies — that is what
252
+ * `export --resolve-tokens` did, with `REFUSED Maximum call stack size exceeded` as
253
+ * the whole report, while `validate` called the same file valid. `docs/plan.md` §4.2
254
+ * already promised "循环被拒绝"; this is where the promise is kept.
255
+ *
256
+ * Depth-first with three colours, so a name is walked once and every distinct cycle
257
+ * is reported from its own entry point. A self-reference is a cycle of one.
258
+ *
259
+ * @returns {Array<{names:string[], offsets:number[]}>}
260
+ */
261
+ export function findTokenCycles(sites) {
262
+ const WHITE = 0;
263
+ const GREY = 1;
264
+ const BLACK = 2;
265
+ const colour = new Map();
266
+ const stack = [];
267
+ const found = [];
268
+ const seen = new Set();
269
+
270
+ const visit = (name) => {
271
+ const state = colour.get(name) || WHITE;
272
+ if (state === BLACK) return;
273
+ if (state === GREY) {
274
+ const at = stack.indexOf(name);
275
+ const names = stack.slice(at);
276
+ const key = [...names].sort().join("|");
277
+ if (!seen.has(key)) {
278
+ seen.add(key);
279
+ found.push({ names, offsets: names.map((n) => sites.get(n)?.offset).filter((o) => o !== undefined) });
280
+ }
281
+ return;
282
+ }
283
+ colour.set(name, GREY);
284
+ stack.push(name);
285
+ const site = sites.get(name);
286
+ for (const referenced of tokenReferenceNames(site?.value)) {
287
+ if (sites.has(referenced)) visit(referenced);
288
+ }
289
+ stack.pop();
290
+ colour.set(name, BLACK);
291
+ };
292
+
293
+ for (const name of sites.keys()) visit(name);
294
+ return found;
295
+ }