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
@@ -0,0 +1,249 @@
1
+ /**
2
+ * canvas/tree.mjs — the layer tree, as data.
3
+ *
4
+ * No DOM, and no imports at all. The canvas decides how a tree looks; this file
5
+ * decides what the tree *is*: which row is inside which, which rows are visible
6
+ * under the current expansion, what a selection has to open to be seen, and which
7
+ * page a selection names.
8
+ *
9
+ * That split is the point. Everything here runs under `node --test` — a browser
10
+ * is not needed to know that collapsing a node hides its grandchildren, or that
11
+ * selecting a deeply nested element opens exactly the path down to it. The DOM
12
+ * half of the tree (`renderLayerTree` in `app.mjs`) is then thin enough to read.
13
+ *
14
+ * The input rows are what `pageTree()` in `lib/render.mjs` already produces:
15
+ * document order, one row per element, with a `depth`:
16
+ *
17
+ * { id, type, tag, name, depth, hidden, childCount, text }
18
+ */
19
+
20
+ /**
21
+ * A view is the expansion state, in three parts:
22
+ *
23
+ * `all` — the standing intent: everything open, everything closed, or
24
+ * "nobody has said" and each node decides for itself (which, on
25
+ * a fresh view, means closed)
26
+ * `expanded` — nodes opened against that intent
27
+ * `collapsed` — nodes closed against it
28
+ *
29
+ * A tree opens closed. An imported design can be thousands of rows, and one that
30
+ * greets you as a wall of text is one nobody reads; a page list is a map, and a
31
+ * map you can see the shape of is worth more than the first level of detail.
32
+ * The two buttons at the top of the panel are the two ways out of that, and
33
+ * selecting something on the canvas opens exactly the path down to it.
34
+ */
35
+ export function newView() {
36
+ return { all: "closed", expanded: new Set(), collapsed: new Set() };
37
+ }
38
+
39
+ /**
40
+ * Depth-first rows -> nested nodes, in document order.
41
+ *
42
+ * A row's parent is the nearest row above it with a smaller depth, which is how
43
+ * the flat form carries the structure. A malformed jump (0 -> 3 with no 1 or 2)
44
+ * attaches to the nearest ancestor that exists instead of throwing: the file is
45
+ * the authority, and a layer list is not the place to refuse to draw one.
46
+ */
47
+ export function nest(rows) {
48
+ const roots = [];
49
+ const stack = [];
50
+ for (const row of rows) {
51
+ const depth = Math.max(0, row.depth | 0);
52
+ const node = { ...row, depth, children: [] };
53
+ const parent = depth === 0 ? null : nearestAncestor(stack, depth - 1);
54
+ (parent ? parent.children : roots).push(node);
55
+ stack.length = depth;
56
+ stack[depth] = node;
57
+ }
58
+ return roots;
59
+ }
60
+
61
+ function nearestAncestor(stack, depth) {
62
+ for (let d = Math.min(depth, stack.length - 1); d >= 0; d -= 1) {
63
+ if (stack[d]) return stack[d];
64
+ }
65
+ return null;
66
+ }
67
+
68
+ /** Every node in document order, open or closed. */
69
+ export function walk(nodes, visit) {
70
+ for (const node of nodes) {
71
+ visit(node);
72
+ walk(node.children, visit);
73
+ }
74
+ }
75
+
76
+ /** id -> node, for the whole tree. */
77
+ export function byId(nodes) {
78
+ const index = new Map();
79
+ walk(nodes, (node) => index.set(node.id, node));
80
+ return index;
81
+ }
82
+
83
+ /** The ancestors of `id`, root first, ending with `id` itself. */
84
+ export function pathTo(nodes, id) {
85
+ let found = null;
86
+ const walkPath = (list, trail) => {
87
+ for (const node of list) {
88
+ const next = [...trail, node.id];
89
+ if (node.id === id) { found = next; return; }
90
+ walkPath(node.children, next);
91
+ if (found) return;
92
+ }
93
+ };
94
+ walkPath(nodes, []);
95
+ return found ?? [];
96
+ }
97
+
98
+ export function hasChildren(node) {
99
+ return (node.children?.length ?? 0) > 0;
100
+ }
101
+
102
+ /**
103
+ * Is this node open?
104
+ *
105
+ * Exceptions first, then the standing intent. A node the user closed stays
106
+ * closed even under "expand all"; a node they opened stays open even under
107
+ * "collapse all" — which is what makes both buttons useful rather than absolute
108
+ * (collapse everything, then open the one branch you are working on), and what
109
+ * makes the two directions of a single toggle both stick.
110
+ */
111
+ export function isOpen(node, view) {
112
+ if (!hasChildren(node)) return false;
113
+ if (view.collapsed.has(node.id)) return false;
114
+ if (view.expanded.has(node.id)) return true;
115
+ return view.all === "open";
116
+ }
117
+
118
+ /** Open every node, discarding the per-node exceptions. */
119
+ export function expandAll(view) {
120
+ view.all = "open";
121
+ view.expanded.clear();
122
+ view.collapsed.clear();
123
+ }
124
+
125
+ /** Close every node, discarding the per-node exceptions. */
126
+ export function collapseAll(view) {
127
+ view.all = "closed";
128
+ view.expanded.clear();
129
+ view.collapsed.clear();
130
+ }
131
+
132
+ /** Open or close one node, in place, remembering the choice. */
133
+ export function toggleNode(node, view) {
134
+ if (!hasChildren(node)) return false;
135
+ if (isOpen(node, view)) {
136
+ view.expanded.delete(node.id);
137
+ view.collapsed.add(node.id);
138
+ } else {
139
+ view.collapsed.delete(node.id);
140
+ view.expanded.add(node.id);
141
+ }
142
+ return isOpen(node, view);
143
+ }
144
+
145
+ /**
146
+ * Open everything above `id`, so a selected row can actually be seen.
147
+ *
148
+ * A selection is often made from the canvas, and a row that is selected while
149
+ * its ancestors are closed is a row the user cannot find — the panel looks like
150
+ * it lost the selection rather than like it folded it away.
151
+ */
152
+ export function openPath(nodes, id, view) {
153
+ const path = pathTo(nodes, id);
154
+ for (const ancestor of path.slice(0, -1)) {
155
+ view.collapsed.delete(ancestor);
156
+ view.expanded.add(ancestor);
157
+ }
158
+ return path;
159
+ }
160
+
161
+ /**
162
+ * Where a pointer at `path` lands, given the rung the panel is standing on.
163
+ *
164
+ * `path` is the chain from the page down to the element under the pointer, so
165
+ * `path[0]` is the page and `path[d]` is the container `d` rungs down. The rung
166
+ * is a *depth*, not an element: hovering a different section at the same depth
167
+ * has to land on that section, which is what "the solid box follows the mouse"
168
+ * means. And the pointer is always either inside one of the level's children —
169
+ * the thing a click selects — or on the level itself, when it is over padding or
170
+ * a gap.
171
+ *
172
+ * `path` may be shorter than the rung (the pointer is on the page itself, or on
173
+ * a page with no elements): then the deepest rung that exists is the level.
174
+ */
175
+ export function rungAt(path, levelDepth) {
176
+ if (path.length === 0) return null;
177
+ const depth = Math.max(0, Math.min(levelDepth, path.length - 1));
178
+ const insideChild = path.length > depth + 1;
179
+ return {
180
+ depth,
181
+ levelId: path[depth],
182
+ underId: insideChild ? path[depth + 1] : path[depth],
183
+ insideChild,
184
+ };
185
+ }
186
+
187
+ /** The rows to draw, in document order: everything not inside a closed node. */
188
+ export function visibleNodes(nodes, view) {
189
+ const out = [];
190
+ const visit = (list) => {
191
+ for (const node of list) {
192
+ out.push(node);
193
+ if (hasChildren(node) && isOpen(node, view)) visit(node.children);
194
+ }
195
+ };
196
+ visit(nodes);
197
+ return out;
198
+ }
199
+
200
+ /**
201
+ * The page a selection names, or null when it names none.
202
+ *
203
+ * A canvas shows every page at once, and a page is a separate shadow root, so an id
204
+ * is not unique in the document: a selected element says which element, never which
205
+ * page. Only the page itself can answer the question `Export` has to ask, and that is
206
+ * the same id in both fields — an element whose id equals its page's id *is* the page
207
+ * mount, so the equality is exact rather than a guess.
208
+ *
209
+ * The page also has to be in the model: after an undo that removed it, the selection
210
+ * outlives what it names, and a stale id is not exportable.
211
+ */
212
+ export function selectedPage(pages, selection, selectionPage) {
213
+ if (!selection || selection !== selectionPage) return null;
214
+ return (pages ?? []).find((page) => page.id === selection) ?? null;
215
+ }
216
+
217
+ /**
218
+ * The page roots of a document model, each with its element tree nested under it.
219
+ *
220
+ * The page is a rung of the tree, not a label above it, so its elements start one
221
+ * level deeper than `pageTree()` reported them. Without the shift a page and its
222
+ * top-level elements would both be depth 0, and every depth-keyed decision —
223
+ * the default open limit, `aria-level`, the indent — would be off by one.
224
+ */
225
+ export function pageRoots(pages, rowsOf) {
226
+ return pages.map((page) => {
227
+ const children = shiftDepth(nest(rowsOf(page)), 1);
228
+ return {
229
+ id: page.id,
230
+ kind: "page",
231
+ depth: 0,
232
+ name: page.name,
233
+ type: "page",
234
+ tag: "section",
235
+ hidden: false,
236
+ childCount: children.length,
237
+ children,
238
+ };
239
+ });
240
+ }
241
+
242
+ /** Re-number a nested tree's depths, in place, and return it. */
243
+ function shiftDepth(nodes, by) {
244
+ for (const node of nodes) {
245
+ node.depth += by;
246
+ shiftDepth(node.children, by);
247
+ }
248
+ return nodes;
249
+ }
@@ -0,0 +1,8 @@
1
+ # Bundle layer contributed by justskill-design.
2
+ #
3
+ # The row loads this package's main entry (`dsh/index.js`) as a plain Cordis
4
+ # host plugin. The `dsh.client` declaration in package.json makes the Web app
5
+ # load `dsh/client.js` as the browser half.
6
+ - insert:
7
+ - id: justskill-design
8
+ name: 'justskill-design'
package/design.mjs ADDED
@@ -0,0 +1,172 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * justSkill v2 — the single entry point.
4
+ *
5
+ * node design.mjs <verb> [args]
6
+ *
7
+ * Verbs load lazily, so `--help` works before the module a verb needs exists,
8
+ * and a session that only validates never imports the pen reader.
9
+ *
10
+ * Exit codes (docs/format-v3.md §10):
11
+ * 0 success
12
+ * 1 refused or invalid
13
+ * 2 unreadable file or bad arguments
14
+ */
15
+
16
+ import fs from "node:fs";
17
+ import process from "node:process";
18
+ import { fileURLToPath } from "node:url";
19
+
20
+ import { isMainModule } from "./lib/node/cli.mjs";
21
+ import { assertNodeVersion } from "./lib/node/fs.mjs";
22
+
23
+ /**
24
+ * The verb table, exported so a test can derive it instead of copying it.
25
+ *
26
+ * `test/contract.test.mjs` checks three things against this object: every verb has a
27
+ * module that exports `USAGE`, every verb is named in `SKILL.md`, and no document names
28
+ * a verb that is not here. A hand-written list in the test would pass while the CLI and
29
+ * the docs drifted apart, which is the failure the check exists to catch.
30
+ */
31
+ export const VERBS = {
32
+ new: () => import("./lib/node/verbs/new.mjs"),
33
+ validate: () => import("./lib/node/verbs/validate.mjs"),
34
+ format: () => import("./lib/node/verbs/format.mjs"),
35
+ ls: () => import("./lib/node/verbs/ls.mjs"),
36
+ show: () => import("./lib/node/verbs/show.mjs"),
37
+ add: () => import("./lib/node/verbs/add.mjs"),
38
+ set: () => import("./lib/node/verbs/set.mjs"),
39
+ remove: () => import("./lib/node/verbs/remove.mjs"),
40
+ move: () => import("./lib/node/verbs/move.mjs"),
41
+ rename: () => import("./lib/node/verbs/rename.mjs"),
42
+ migrate: () => import("./lib/node/verbs/migrate.mjs"),
43
+ tokens: () => import("./lib/node/verbs/tokens.mjs"),
44
+ export: () => import("./lib/node/verbs/export.mjs"),
45
+ import: () => import("./lib/node/verbs/import.mjs"),
46
+ serve: () => import("./lib/node/verbs/serve.mjs"),
47
+ };
48
+
49
+ export const USAGE = `justSkill — design web pages as a validated HTML file
50
+
51
+ Usage: node design.mjs <verb> [args]
52
+
53
+ Verbs:
54
+ new <file> [--name N] [--title T] [--tokens neutral|brand|dark | --tokens-file P] [--size WxH]
55
+ validate <file> [--json] [--strict]
56
+ format <file> [--check] [--dry-run]
57
+ ls <file> [--pages] [--in <data-id>] [--depth n] [--json]
58
+ show <file> <data-id> [--json]
59
+ add <file> --html-file f | --html - [--in <data-id>] [--as <id>] [--index n]
60
+ set <file> <data-id> [--style 'a:b;c:d'] [--text T] [--attr name=value] [--unset name]
61
+ remove <file> <data-id>
62
+ move <file> <data-id> [--before X | --after X | --index n | --into P]
63
+ rename <file> <old-id> <new-id>
64
+ migrate <file>
65
+ tokens <file> ls | set <组.名> <值> | rm <组.名>
66
+ export <file> [--page <id>] [--all] [-o path] [--resolve-tokens] [--stdout]
67
+ import pen <file> [-o out.html] [--report ledger.json]
68
+ serve [<file>] [--port 0] [--ttl 30] [--no-open]
69
+
70
+ Shared flags: --json --quiet --dry-run --force --help
71
+
72
+ Exit codes: 0 success, 1 refused or invalid, 2 unreadable file or bad arguments.
73
+
74
+ Each line above lists the flags that matter most, not all of them; "export" alone has
75
+ nine. Run "node design.mjs <verb> --help" for the full set of one verb.`;
76
+
77
+ function print(text, stream = process.stdout) {
78
+ stream.write(text.endsWith("\n") ? text : `${text}\n`);
79
+ }
80
+
81
+ function version() {
82
+ try {
83
+ const file = fileURLToPath(new URL("./package.json", import.meta.url));
84
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
85
+ return typeof parsed.version === "string" ? parsed.version : "0.0.0";
86
+ } catch {
87
+ return "0.0.0";
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Map a thrown error to an exit code and a message on stderr.
93
+ *
94
+ * @param {any} error
95
+ * @returns {number}
96
+ */
97
+ function reportError(error) {
98
+ const message = error && error.message ? error.message : String(error);
99
+ const name = error && error.name;
100
+ if (name === "CliError") {
101
+ print(message, process.stderr);
102
+ return Number.isInteger(error.exitCode) ? error.exitCode : 2;
103
+ }
104
+ if (name === "FsError") {
105
+ print(`ERROR ${message}`, process.stderr);
106
+ return error.code === "fs/no-write" ? 1 : 2;
107
+ }
108
+ if (name === "SpansError" || name === "DetectError") {
109
+ print(`ERROR ${message}`, process.stderr);
110
+ return 2;
111
+ }
112
+ print(`ERROR ${message}`, process.stderr);
113
+ if (process.env.SD_TRACE && error && error.stack) print(error.stack, process.stderr);
114
+ return 1;
115
+ }
116
+
117
+ /**
118
+ * Run one command line.
119
+ *
120
+ * @param {string[]} [argv] defaults to `process.argv.slice(2)`
121
+ * @returns {Promise<number>} the exit code
122
+ */
123
+ export async function main(argv = process.argv.slice(2)) {
124
+ const args = Array.isArray(argv) ? argv.map(String) : [];
125
+ const verb = args[0];
126
+
127
+ try {
128
+ assertNodeVersion();
129
+ } catch (error) {
130
+ return reportError(error);
131
+ }
132
+
133
+ if (verb === undefined) {
134
+ print(USAGE, process.stderr);
135
+ return 2;
136
+ }
137
+ if (verb === "help" || verb === "--help" || verb === "-h") {
138
+ print(USAGE);
139
+ return 0;
140
+ }
141
+ if (verb === "--version" || verb === "-v") {
142
+ print(version());
143
+ return 0;
144
+ }
145
+ if (verb.startsWith("-")) {
146
+ print(`Unknown option: ${verb}\n\n${USAGE}`, process.stderr);
147
+ return 2;
148
+ }
149
+
150
+ const load = Object.prototype.hasOwnProperty.call(VERBS, verb) ? VERBS[verb] : null;
151
+ if (!load) {
152
+ print(`Unknown verb: ${verb}\n\n${USAGE}`, process.stderr);
153
+ return 2;
154
+ }
155
+
156
+ try {
157
+ const mod = await load();
158
+ const run = typeof mod.default === "function" ? mod.default : mod.run;
159
+ if (typeof run !== "function") {
160
+ print(`The ${verb} verb exports no run()`, process.stderr);
161
+ return 1;
162
+ }
163
+ const code = await run(args.slice(1));
164
+ return Number.isInteger(code) ? code : 0;
165
+ } catch (error) {
166
+ return reportError(error);
167
+ }
168
+ }
169
+
170
+ if (isMainModule(import.meta.url)) {
171
+ process.exitCode = await main();
172
+ }
Binary file