dsh-claude-slides 2.6.0 → 2.7.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/lib/index.js CHANGED
@@ -1,16 +1,189 @@
1
- // dsh-claude-slides 2.6.0: built from baoyu-design.
1
+ // dsh-claude-slides 2.7.0: built from baoyu-design.
2
2
  var define_PLUGIN_default = { name: "claude-slides", provider: "dsh-claude-slides", skills: [{ name: "claude-slides", description: "Make and revise slide decks in the Slides artifact format (project/deck.json plus one HTML file per slide) as local files, then open them in a local slide editor where the user can present, retype, drag, and export PDF, HTML or PowerPoint. Use when the user asks for an editable slide deck or presentation in this format, names claude-slides or the Slides type, or wants to revise a folder that holds project/deck.json. A one-off single-file HTML deck or prototype is baoyu-design's.", comments: true }] };
3
- import { spawn } from "node:child_process";
3
+
4
+ import { spawn as spawn2 } from "node:child_process";
4
5
  import { randomUUID } from "node:crypto";
5
6
  import { statSync } from "node:fs";
6
7
  import { readFile } from "node:fs/promises";
7
- import { delimiter, dirname, isAbsolute, join, sep } from "node:path";
8
+ import { delimiter, dirname, isAbsolute, join as join2, sep } from "node:path";
8
9
  import { fileURLToPath } from "node:url";
9
- const PLUGIN = define_PLUGIN_default;
10
- const name = PLUGIN.name;
11
- const inject = ["skills"];
12
- const BUNDLED_SKILL_RANK = 600;
13
- const packageRoot = fileURLToPath(new URL("../", import.meta.url));
10
+
11
+ import { spawn } from "node:child_process";
12
+ import { createHash } from "node:crypto";
13
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
14
+ import { homedir } from "node:os";
15
+ import { basename, join } from "node:path";
16
+
17
+ var listPath = (skill) => `/api/baoyu-design.${skill}.artifacts`;
18
+ var openPath = (skill) => `/api/baoyu-design.${skill}.artifacts.open`;
19
+
20
+ var TITLES = {
21
+ "claude-slides": ["project/deck.json", "title"],
22
+ "claude-design": ["project/canvas.json", "title"],
23
+ "claude-design-system": ["project/design-system.json", "title"],
24
+ "claude-dashboard": ["db/dash/meta.json", "title"],
25
+ "claude-docs": ["doc.json", "name"],
26
+ "claude-motion": ["db/film/meta.json", "title"]
27
+ };
28
+ var OPEN_TIMEOUT_MS = 3e4;
29
+ function dshHome() {
30
+ const set = process.env.DSH_HOME?.trim();
31
+ return set ? set : join(homedir(), ".dsh");
32
+ }
33
+ function artifactsDir() {
34
+ return join(dshHome(), "baoyu-design", "artifacts");
35
+ }
36
+ function recordFile(root) {
37
+ return join(artifactsDir(), `${createHash("sha256").update(root).digest("hex").slice(0, 24)}.json`);
38
+ }
39
+ function artifactTitle(skill, root) {
40
+ const at = TITLES[skill];
41
+ if (at === void 0) return null;
42
+ try {
43
+ const value = JSON.parse(readFileSync(join(root, at[0]), "utf8"))[at[1]];
44
+ const title = typeof value === "string" ? value.trim() : "";
45
+ if (title && title !== "project") return title;
46
+ } catch {
47
+ }
48
+ return basename(root);
49
+ }
50
+ function isRecord(value) {
51
+ const r = value;
52
+ return typeof r === "object" && r !== null && r.v === 1 && typeof r.root === "string" && typeof r.skill === "string" && typeof r.plugin === "string" && typeof r.sessionId === "string" && typeof r.title === "string" && typeof r.openedAt === "string";
53
+ }
54
+ function readRecord(file) {
55
+ try {
56
+ const value = JSON.parse(readFileSync(file, "utf8"));
57
+ return isRecord(value) ? value : null;
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+ function recordOpened(skill, plugin, sessionId, root, now = /* @__PURE__ */ new Date()) {
63
+ const file = recordFile(root);
64
+ const before = readRecord(file);
65
+ const record = {
66
+ v: 1,
67
+ root,
68
+ skill,
69
+ plugin,
70
+ sessionId,
71
+ title: artifactTitle(skill, root) ?? before?.title ?? basename(root),
72
+ openedAt: now.toISOString(),
73
+ firstOpenedAt: before?.root === root ? before.firstOpenedAt ?? before.openedAt : now.toISOString()
74
+ };
75
+ mkdirSync(artifactsDir(), { recursive: true });
76
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
77
+ writeFileSync(tmp, `${JSON.stringify(record, null, 2)}
78
+ `);
79
+ renameSync(tmp, file);
80
+ return record;
81
+ }
82
+ function listArtifacts() {
83
+ let names;
84
+ try {
85
+ names = readdirSync(artifactsDir());
86
+ } catch {
87
+ return [];
88
+ }
89
+ const views = [];
90
+ for (const name2 of names) {
91
+ if (!name2.endsWith(".json")) continue;
92
+ const record = readRecord(join(artifactsDir(), name2));
93
+ if (record === null || !existsSync(record.root)) continue;
94
+ views.push({
95
+ root: record.root,
96
+ skill: record.skill,
97
+ plugin: record.plugin,
98
+ sessionId: record.sessionId,
99
+ title: artifactTitle(record.skill, record.root) ?? record.title,
100
+ openedAt: record.openedAt
101
+ });
102
+ }
103
+ return views.sort((a, b) => a.openedAt < b.openedAt ? 1 : a.openedAt > b.openedAt ? -1 : 0);
104
+ }
105
+ function findArtifact(root) {
106
+ const record = readRecord(recordFile(root));
107
+ return record?.root === root ? record : null;
108
+ }
109
+ function scriptEnv(node) {
110
+ const env = { ...process.env };
111
+ if (node.env) {
112
+ const [name2, value] = node.env.split("=");
113
+ if (name2) env[name2] = value ?? "";
114
+ }
115
+ return env;
116
+ }
117
+ function runOpen(node, script, root) {
118
+ return new Promise((resolve, reject) => {
119
+ const child = spawn(node.path, [script, "open", root], { env: scriptEnv(node), stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
120
+ let stdout = "";
121
+ let stderr = "";
122
+ const timer = setTimeout(() => {
123
+ child.kill();
124
+ reject(new Error("the editor did not start in time"));
125
+ }, OPEN_TIMEOUT_MS);
126
+ child.stdout?.setEncoding("utf8").on("data", (chunk) => stdout += chunk);
127
+ child.stderr?.setEncoding("utf8").on("data", (chunk) => stderr += chunk);
128
+ child.on("error", (error) => {
129
+ clearTimeout(timer);
130
+ reject(error);
131
+ });
132
+ child.on("close", (code) => {
133
+ clearTimeout(timer);
134
+ const out = firstJson(stdout);
135
+ if (code === 0 && typeof out?.url === "string") return resolve({ url: out.url, reused: out.reused === true });
136
+ const failure = firstJson(stderr);
137
+ const message = typeof failure?.message === "string" ? failure.message : stderr.trim() || `open exited with ${code ?? "a signal"}`;
138
+ reject(new Error(message));
139
+ });
140
+ });
141
+ }
142
+ function firstJson(text) {
143
+ const start = text.indexOf("{");
144
+ const end = text.lastIndexOf("}");
145
+ if (start < 0 || end <= start) return null;
146
+ try {
147
+ const value = JSON.parse(text.slice(start, end + 1));
148
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
149
+ } catch {
150
+ return null;
151
+ }
152
+ }
153
+ var json = (body, status = 200) => Response.json(body, { status, headers: { "cache-control": "no-store" } });
154
+ function registerRoutes(routes, skill, opened) {
155
+ routes.register({
156
+ path: listPath(skill.name),
157
+ methods: ["GET"],
158
+ requestBody: "buffered",
159
+ fetch: () => Promise.resolve(json({ artifacts: listArtifacts() }))
160
+ });
161
+ routes.register({
162
+ path: openPath(skill.name),
163
+ methods: ["POST"],
164
+ requestBody: "buffered",
165
+ fetch: async (request) => {
166
+ const root = new URL(request.url).searchParams.get("root") ?? "";
167
+ const record = root ? findArtifact(root) : null;
168
+ if (record === null || record.skill !== skill.name) return json({ error: "not a recorded artifact of this skill" }, 404);
169
+ if (!existsSync(root)) return json({ error: "the folder is gone" }, 404);
170
+ try {
171
+ const { url, reused } = await runOpen(skill.node, skill.script, root);
172
+ const again = recordOpened(skill.name, skill.plugin, record.sessionId, root);
173
+ opened(again);
174
+ return json({ url, reused, sessionId: again.sessionId, title: again.title });
175
+ } catch (error) {
176
+ return json({ error: error.message }, 500);
177
+ }
178
+ }
179
+ });
180
+ }
181
+
182
+ var PLUGIN = define_PLUGIN_default;
183
+ var name = PLUGIN.name;
184
+ var inject = ["skills"];
185
+ var BUNDLED_SKILL_RANK = 600;
186
+ var packageRoot = fileURLToPath(new URL("../", import.meta.url));
14
187
  function isFile(path) {
15
188
  try {
16
189
  return statSync(path).isFile();
@@ -33,18 +206,18 @@ function resolveNode(config = {}) {
33
206
  if (typeof resourcesPath === "string") roots.push(resourcesPath);
34
207
  let dir = dirname(process.execPath);
35
208
  for (let i = 0; i < 6; i++) {
36
- roots.push(join(dir, "Resources"), join(dir, "resources"));
209
+ roots.push(join2(dir, "Resources"), join2(dir, "resources"));
37
210
  const parent = dirname(dir);
38
211
  if (parent === dir) break;
39
212
  dir = parent;
40
213
  }
41
214
  for (const root of roots) {
42
- const candidate = join(root, ...runtime);
215
+ const candidate = join2(root, ...runtime);
43
216
  if (isFile(candidate)) return { path: candidate, from: "desktop runtime" };
44
217
  }
45
218
  for (const entry of (process.env.PATH ?? "").split(delimiter)) {
46
219
  if (entry === "" || !isAbsolute(entry)) continue;
47
- const candidate = join(entry, exe);
220
+ const candidate = join2(entry, exe);
48
221
  if (isFile(candidate)) return { path: candidate, from: "PATH" };
49
222
  }
50
223
  return { path: process.execPath, env: "ELECTRON_RUN_AS_NODE=1", from: "electron" };
@@ -53,7 +226,7 @@ function shellQuote(path) {
53
226
  return `"${path.replace(/(["\\$`])/g, "\\$1")}"`;
54
227
  }
55
228
  function runtimeSection(skill, skillDir, node) {
56
- const script = join(skillDir, "scripts", "artifact.mjs");
229
+ const script = join2(skillDir, "scripts", "artifact.mjs");
57
230
  const run = (...args) => [node.env, shellQuote(node.path), shellQuote(script), ...args].filter(Boolean).join(" ");
58
231
  const written = skill.scriptAlias === void 0 ? "`node <this skill's folder>/scripts/artifact.mjs <command>`" : `\`node $${skill.scriptAlias} <command>\` (\`${skill.scriptAlias}\` being \`<this skill's folder>/scripts/artifact.mjs\`)`;
59
232
  const lines = [
@@ -97,14 +270,15 @@ function runtimeSection(skill, skillDir, node) {
97
270
  }
98
271
  return lines.join("\n");
99
272
  }
100
- const COMMENT_SOURCE = "baoyu-design-comment";
101
- const SUMMARY_MAX = 120;
102
- const SESSION_CHECK_MS = 6e4;
103
- const RETRY_MS = 5e3;
104
- const MAX_QUICK_FAILURES = 3;
105
- const IDLE_CHECK_MS = 500;
106
- const TURN_START_MS = 3e3;
107
- const TURN_MAX_MS = 30 * 6e4;
273
+ var COMMENT_SOURCE = "baoyu-design-comment";
274
+ var SUMMARY_MAX = 120;
275
+ var SESSION_CHECK_MS = 6e4;
276
+ var RETRY_MS = 5e3;
277
+ var MAX_QUICK_FAILURES = 3;
278
+ var IDLE_CHECK_MS = 500;
279
+ var AGENT_GRACE_MS = 3e4;
280
+ var TURN_START_MS = 3e3;
281
+ var TURN_MAX_MS = 30 * 6e4;
108
282
  function jsonObject(text) {
109
283
  const trimmed = text.trim();
110
284
  const starts = [0];
@@ -122,6 +296,40 @@ function jsonObject(text) {
122
296
  }
123
297
  return null;
124
298
  }
299
+ function jsonEnd(text, start) {
300
+ let depth = 0;
301
+ let inString = false;
302
+ for (let i = start; i < text.length; i++) {
303
+ const c = text[i];
304
+ if (inString) {
305
+ if (c === "\\") i++;
306
+ else if (c === '"') inString = false;
307
+ } else if (c === '"') inString = true;
308
+ else if (c === "{" || c === "[") depth++;
309
+ else if ((c === "}" || c === "]") && --depth === 0) return i;
310
+ }
311
+ return -1;
312
+ }
313
+ function jsonObjects(text) {
314
+ const found = [];
315
+ const starts = text.startsWith("{") ? [0] : [];
316
+ for (let i = text.indexOf("\n{"); i >= 0; i = text.indexOf("\n{", i + 1)) starts.push(i + 1);
317
+ let after = 0;
318
+ for (const start of starts) {
319
+ if (start < after) continue;
320
+ const end = jsonEnd(text, start);
321
+ if (end < 0) continue;
322
+ try {
323
+ const value = JSON.parse(text.slice(start, end + 1));
324
+ if (value && typeof value === "object" && !Array.isArray(value)) {
325
+ found.push(value);
326
+ after = end + 1;
327
+ }
328
+ } catch {
329
+ }
330
+ }
331
+ return found;
332
+ }
125
333
  function bashStdout(result) {
126
334
  const value = result.value;
127
335
  if (typeof value === "string") return value;
@@ -132,7 +340,19 @@ function bashStdout(result) {
132
340
  const text = result.content.map((block) => block.type === "text" ? block.text ?? "" : "").join("\n");
133
341
  return text || null;
134
342
  }
135
- const bound = (text) => text.length <= SUMMARY_MAX ? text : `${text.slice(0, SUMMARY_MAX - 1)}…`;
343
+ function openedEditor(script, exec, result) {
344
+ if (exec.name !== "bash" || exec.agent === void 0 || result.isError) return null;
345
+ const args = exec.arguments;
346
+ const command = args?.command;
347
+ if (typeof command !== "string" || args?.run_in_background === true) return null;
348
+ const at = command.indexOf(script);
349
+ if (at < 0 || !/(?:^|[\s"'])open(?:[\s"']|$)/.test(command.slice(at + script.length))) return null;
350
+ const stdout = bashStdout(result);
351
+ const opened = stdout === null ? void 0 : jsonObjects(stdout).findLast((o) => typeof o.root === "string" && typeof o.url === "string");
352
+ if (typeof opened?.root !== "string" || typeof opened.url !== "string") return null;
353
+ return { session: String(exec.agent.id), root: opened.root, url: opened.url };
354
+ }
355
+ var bound = (text) => text.length <= SUMMARY_MAX ? text : `${text.slice(0, SUMMARY_MAX - 1)}…`;
136
356
  function commentMessage(skill, plugin, out, root) {
137
357
  const title = typeof out.title === "string" && out.title ? out.title : root.split(/[\\/]/).pop() || root;
138
358
  const url = typeof out.url === "string" ? out.url : "";
@@ -183,7 +403,7 @@ function commentMessage(skill, plugin, out, root) {
183
403
  };
184
404
  return Object.freeze(message);
185
405
  }
186
- class CommentWatcher {
406
+ var CommentWatcher = class {
187
407
  watches = /* @__PURE__ */ new Map();
188
408
  check;
189
409
  ctx;
@@ -204,23 +424,10 @@ class CommentWatcher {
204
424
  agent(session) {
205
425
  return this.ctx.get("agents")?.get(session);
206
426
  }
207
- /** The agent ran this skill's `open`: see whether its output names an editor, and wait for its comments. */
208
- observe(exec, result) {
209
- if (exec.name !== "bash" || exec.agent === void 0 || result.isError) return;
210
- const args = exec.arguments;
211
- const command = args?.command;
212
- if (typeof command !== "string" || args?.run_in_background === true) return;
213
- const at = command.indexOf(this.script);
214
- if (at < 0 || !/(?:^|[\s"'])open(?:[\s"']|$)/.test(command.slice(at + this.script.length))) return;
215
- const stdout = bashStdout(result);
216
- const opened = stdout === null ? null : jsonObject(stdout);
217
- if (typeof opened?.root !== "string" || typeof opened.url !== "string") return;
218
- this.watch(String(exec.agent.id), opened.root);
219
- }
220
427
  watch(session, root) {
221
428
  const key = `${session}\0${root}`;
222
429
  if (this.watches.has(key)) return;
223
- const watch = { child: null, stopped: false, quickFailures: 0 };
430
+ const watch = { child: null, stopped: false, quickFailures: 0, graceUntil: Date.now() + AGENT_GRACE_MS };
224
431
  this.watches.set(key, watch);
225
432
  this.log("info", `waiting for comments on ${root}`);
226
433
  this.run(key, session, root, watch);
@@ -235,15 +442,15 @@ class CommentWatcher {
235
442
  this.log("info", `stopped waiting for comments (${why})`);
236
443
  }
237
444
  run(key, session, root, watch) {
445
+ watch.retry = void 0;
238
446
  if (watch.stopped) return;
239
- if (this.agent(session) === void 0) return this.stop(key, watch, "the session is gone");
240
- const env = { ...process.env };
241
- if (this.node.env) {
242
- const [name2, value] = this.node.env.split("=");
243
- if (name2) env[name2] = value ?? "";
447
+ if (this.agent(session) === void 0) {
448
+ if (Date.now() >= watch.graceUntil) return this.stop(key, watch, "the session is gone");
449
+ watch.retry = setTimeout(() => this.run(key, session, root, watch), IDLE_CHECK_MS);
450
+ return;
244
451
  }
245
452
  const started = Date.now();
246
- const child = spawn(this.node.path, [this.script, "comments", root, "--wait"], { env, stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
453
+ const child = spawn2(this.node.path, [this.script, "comments", root, "--wait"], { env: scriptEnv(this.node), stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
247
454
  watch.child = child;
248
455
  let stdout = "";
249
456
  let stderr = "";
@@ -302,14 +509,14 @@ class CommentWatcher {
302
509
  dropGoneSessions() {
303
510
  for (const [key, watch] of [...this.watches]) {
304
511
  const session = key.slice(0, key.indexOf("\0"));
305
- if (this.agent(session) === void 0) this.stop(key, watch, "the session is gone");
512
+ if (this.agent(session) === void 0 && Date.now() >= watch.graceUntil) this.stop(key, watch, "the session is gone");
306
513
  }
307
514
  }
308
515
  dispose() {
309
516
  clearInterval(this.check);
310
517
  for (const [key, watch] of [...this.watches]) this.stop(key, watch, "the plugin stopped");
311
518
  }
312
- }
519
+ };
313
520
  function skillBody(raw, path) {
314
521
  const frontmatter = /^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/u.exec(raw);
315
522
  if (frontmatter === null) throw new Error(`${PLUGIN.name}: ${path} has no YAML frontmatter`);
@@ -319,8 +526,8 @@ function apply(ctx, config = {}) {
319
526
  const node = resolveNode(config ?? {});
320
527
  const skills = new Map(PLUGIN.skills.map((skill) => [skill.name, skill]));
321
528
  const candidates = PLUGIN.skills.map(({ name: skillName, description }) => {
322
- const directory = join(packageRoot, "skills", skillName);
323
- const path = join(directory, "SKILL.md");
529
+ const directory = join2(packageRoot, "skills", skillName);
530
+ const path = join2(directory, "SKILL.md");
324
531
  if (!isFile(path)) throw new Error(`${PLUGIN.name}: ${path} is missing`);
325
532
  return {
326
533
  name: skillName,
@@ -347,11 +554,24 @@ function apply(ctx, config = {}) {
347
554
  };
348
555
  ctx.skills.registerProvider(() => provider);
349
556
  for (const skill of PLUGIN.skills) {
350
- if (!skill.comments) continue;
351
- const script = join(packageRoot, "skills", skill.name, "scripts", "artifact.mjs");
352
- const watcher = new CommentWatcher(ctx, skill, script, node);
353
- ctx.on("tools/result", (exec, result) => watcher.observe(exec, result));
354
- ctx.effect(() => () => watcher.dispose(), `${PLUGIN.name}: comment waits`);
557
+ const script = join2(packageRoot, "skills", skill.name, "scripts", "artifact.mjs");
558
+ const watcher = skill.comments ? new CommentWatcher(ctx, skill, script, node) : null;
559
+ if (watcher) ctx.effect(() => () => watcher.dispose(), `${PLUGIN.name}: comment waits`);
560
+ ctx.on("tools/result", (exec, result) => {
561
+ const opened = openedEditor(script, exec, result);
562
+ if (opened === null) return;
563
+ watcher?.watch(opened.session, opened.root);
564
+ try {
565
+ recordOpened(skill.name, PLUGIN.provider, opened.session, opened.root);
566
+ } catch (error) {
567
+ ctx.logger?.warn?.(`${PLUGIN.name}: could not note ${opened.root} for the Artifacts page: ${error.message}`);
568
+ }
569
+ });
570
+ ctx.inject?.(["connection"], (sub) => {
571
+ registerRoutes(sub.connection.fetch, { name: skill.name, plugin: PLUGIN.provider, script, node }, (record) => {
572
+ watcher?.watch(record.sessionId, record.root);
573
+ });
574
+ });
355
575
  }
356
576
  }
357
577
  export {
@@ -360,7 +580,9 @@ export {
360
580
  commentMessage,
361
581
  inject,
362
582
  jsonObject,
583
+ jsonObjects,
363
584
  name,
585
+ openedEditor,
364
586
  resolveNode,
365
587
  runtimeSection
366
588
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-claude-slides",
3
- "version": "2.6.0",
3
+ "version": "2.7.0",
4
4
  "description": "DeepSeek Harness plugin: the claude-slides skill from baoyu-design. The agent writes editable slide decks as files and opens them in a local slide editor.",
5
5
  "keywords": [
6
6
  "dsh",
@@ -50,11 +50,17 @@
50
50
  "client": {
51
51
  "platform": "web",
52
52
  "inject": [
53
+ "@deepseek-ai/dsh-api-session-controller",
54
+ "@deepseek-ai/dsh-api-workspace-controller",
55
+ "@deepseek-ai/dsh-client-connection",
53
56
  "@deepseek-ai/dsh-client-locale",
54
57
  "@deepseek-ai/dsh-client-ui-chat",
55
58
  "@deepseek-ai/dsh-client-ui-conversation",
59
+ "@deepseek-ai/dsh-client-ui-layout",
56
60
  "@deepseek-ai/dsh-client-ui-renderer",
57
- "@deepseek-ai/dsh-client-ui-sidebar-right"
61
+ "@deepseek-ai/dsh-client-ui-sidebar",
62
+ "@deepseek-ai/dsh-client-ui-sidebar-right",
63
+ "@deepseek-ai/dsh-client-ui-workspace"
58
64
  ]
59
65
  }
60
66
  }
@@ -25,7 +25,7 @@ If no deck exists yet: `<root>` = a new folder `designs/<slug>/` named for the d
25
25
  working directory, or where the user says; the deck's name goes in `title` (REQUIRED).
26
26
 
27
27
  Tell the user what happens to the deck, never the mechanism.
28
- Asked to send it to apps like Canva: there is no Send to here; the editor's download menu saves PDF, HTML and PowerPoint (.pptx) files the user can import there.
28
+ Asked to send it to apps like Canva: there is no Send to here; the editor's download menu saves PDF, HTML and PowerPoint (.pptx) files the user can import there (in a browser that drops downloads, such as DeepSeek Harness's, into the folder's `exports/`; a toast names the path).
29
29
  Naming slide 7 to the user? Link it too: `open`'s `url` + `#slide-7` (number, not id).
30
30
 
31
31
  ## The deck's files
@@ -81,6 +81,8 @@ leave alone stays as it is, and removing a slide is deleting its file. The rest
81
81
  - `check <root>` → `problems` (0: done); when any, `deckProblems` and per slide `problems` with the fix, slide `notes` (hints; fix the "排版" and "警告" ones), and `designSystemNotes` when an install looks incomplete; it exits 1 while problems remain. `notChecked` names what it can't see: text sizes and contrast stay yours.
82
82
  - `open <root>` → `{url, pid, stop, …}`: starts the editor page on this machine in the background (once; later calls reuse it, `reused: true`) and prints its address, 127.0.0.1:4710 (the next free port up when 4710 is taken; `--port n` starts from n, `--port 0` takes any). `stop <root>` ends it (`serve <root>` runs it in the foreground instead). The page saves the user's edits into the same files, and shows yours as soon as you write them. `.artifact-server.json` and `.artifact-server.log` beside `project/` are its own: leave them; so is `comments.json` (the comments, see Comments), which only the comment commands below change.
83
83
  - `share <root>` (or `open <root> --lan`): opens the page to the local network and prints a link per role (Sharing, below).
84
+ - `archive <root> [--out file.zip|dir]` → `{file, files, bytes, open}`: the whole folder, history and comments too, as one zip beside it (`--out`: another file or folder), to back it up, move it to another computer or send it to someone; unzipped anywhere, `open` carries on. The owner's Download folder button in the page (between History and Share) does the same. The server's own files and the share links stay out.
85
+ - The Theme tab links to a read-only page of the system in use, `<editor>/__ds/<folder>/`: the copy under `project/ds/<folder>/` as this deck carries it (its tokens, its components with their `README.md` and types, its `README.md`). Anyone who can open the deck can open it, and it changes only when the copy does.
84
86
 
85
87
  ## The slide format
86
88
 
@@ -180,7 +182,7 @@ People comment in the open editor: Comment (or C), then a click on anything on a
180
182
  - `share <root> --disable <role>` / `--enable <role>` turns a link off or on (`<role>`: one, `editor,viewer`, or `all`); off, it holds everyone who came by it until it is on again. `--reset <role>` gives the role a new link: the old one stops working and the people who came by it are out. `--role <guest>=<role>` gives someone who joined another role (`<guest>`: an id from the `guests` it prints). `--off` stops sharing on the network.
181
183
  - Who is here: while someone else has the deck open, their avatars sit before History; each one's card says which slide they are on and whether they are editing, and a click goes there. The slide rail shows their badges and the slide their cursors and selections. Someone starting to edit the slide you are on gets you a short note; nothing is locked (two people on one slide: see History). After you change a file you show there as Agent, on the slide you changed, for about 20 s.
182
184
  - Only the owner's page sends to you (Send to Agent); what guests comment is saved for you to read with `comments <root>`.
183
- - The links and the people who joined are in `<root>/.sharing.json`: leave it. It is plain http on the local network: anyone there with a link gets in.
185
+ - The links and the people who joined are in `<root>/.sharing.json`: leave it. It is plain http on the local network: anyone there can read what passes, and a link is the permission: whoever has one gets its role, so send each only to the people meant to have it, and `--reset` a role's link that went further (the Share panel says so too). When this computer has a Tailscale address (100.64.0.0/10), `share` also prints `tailscale`: the same links at that address, for people on the owner's tailnet from any network (the Share panel: Copy Tailscale link in a link's More menu). Nothing else reaches past the local network; there is no tunnel.
184
186
 
185
187
  ## History
186
188
 
@@ -192,6 +194,24 @@ The server keeps the deck's versions in `<root>/history/` (leave it): who change
192
194
  - Two people on one slide: when the page has unsaved edits on a slide someone else just saved, the two are merged by line first: changes to different lines both stay, saved as one version, and nothing asks (while the person is still editing in that slide, the merge waits until they leave it). Only when both changed the same lines does a bar name who and which slide, and nothing is saved until the person picks Use theirs (the page's edits on it go), Keep mine (saved over theirs) or Keep both (the slide takes theirs; the page's version becomes a new slide after it). A version saved over someone's change records whose change it replaced, and that person gets a notice on the page (Compare, Bring back, Dismiss). Compare shows only what that save replaced, and Bring back puts only that back, line by line in the slide's file: the other person's other changes to the slide stay, and History says "Partly restored". Your changes on disk count too: someone editing that slide gets the bar with "Agent" and may keep theirs, so check the file (or History) when it matters.
193
195
  - Asked who changed something, read `<root>/history/index.json`: `versions`, oldest first, each `{n, at, by, user: {name}, changes}` (`by`: `"human"`, `"agent"` for you, `"system"` for the first; `changes`: path → sha256, null for a delete; paths like `project/slides/<id>.html`), with `restoredFrom` or `overwrote` when it brought a version back or replaced someone's change; a content is `<root>/history/objects/<sha256>`, the file's bytes. To put something back, point the person to History (it records who did it), or write the old content yourself, which makes a version of yours.
194
196
 
197
+ ## Keeping it in git
198
+
199
+ `open` writes `<root>/.gitignore` when the folder has none. It leaves out what only this computer's server needs:
200
+ `.artifact-server.json`, `.artifact-server.log`, `.sharing.json` (the share links), `history/` (the versions; git
201
+ keeps its own), save temp files and `.DS_Store`. Everything else, `project/` and `comments.json` included, is the
202
+ deck and goes in git. A `.gitignore` already there is the person's: leave it. Moving or backing up the deck is
203
+ copying or cloning the folder (`archive` puts it,
204
+ history included, in one zip); `open` there starts it again.
205
+
206
+ Commit, push or set up git only when asked. Asked to check the deck before each commit: write
207
+ `.git/hooks/pre-commit` (and `chmod +x` it) in the repository that holds `<root>`, with this skill's folder as an
208
+ absolute path and `<root>` relative to the repository's top (a hook already there: add the line to it):
209
+
210
+ ```sh
211
+ #!/bin/sh
212
+ node "<this skill's folder>/scripts/artifact.mjs" check "<root>" > /dev/null || { echo "The deck has problems: run artifact.mjs check <root> to see them." >&2; exit 1; }
213
+ ```
214
+
195
215
  ## The references
196
216
 
197
217
  Under `artifact-type/reference/` in this skill's folder: format.md, fonts.md, layout.md, styles.md, images.md, photo-library.md, diagrams.md
@@ -83,7 +83,7 @@ A live embed, `<x-embed style="position:absolute; left; top; width; height">…a
83
83
 
84
84
  **Tables.** `<table>` of `<tr>` with `<th>`/`<td>` (plain text; ≤100 rows × 24 columns); a first `<th>` row is the header. Column widths: `width:N%` on EVERY cell of the first row (any missing = equal columns). `text-align` on a cell aligns its column; `color` works on a cell; set `font-family`, `font-size`, or `color` on the `<table>`, or it inherits them. Cells pad 0.35em 0.6em unless a cell sets `padding` (one per table); never `padding:… 0` on a text cell — cells are ruled; text would sit on a line. A row ≈ 2.1 × the font size per line of text; every row must fit under the heading — if not, use 24–28px or split the table. Columns never grow for one long word: make each column at least as wide as its longest word (0.6 × font size per character); 5+ columns at 32px in a half-width table do not fit: 24–26px or abbreviate.
85
85
 
86
- **Tables, banding & rotated headers.** A `<tr>` may carry `background:COLOR`; no background on cells, no colspan/rowspan; rows are as tall as their content and a table as tall as its rows (a height on it is ignored). Merged group headers: a painted flex row above the table whose parts use the same `width:N%` shares as the columns. Row groups: a pinned painted spine beside the table. Do not rotate text inside a cell: rotation is about the box center (45° labels: the recipe in diagrams.md). A bottom-to-top header over a column of width C centered at cx, in a header zone of height Z (≥ the longest label ≈ 0.6 × font size per character + 24px), is `<p style="position:absolute; left:{cx−Z/2}px; top:{zoneTop+Z/2−C/2}px; width:{Z}px; height:{C}px; line-height:{C/font-size}; transform:rotate(-90deg); white-space:nowrap; text-align:left">`. (the unitless `line-height` centers the line in the C-high box; `text-align:left` anchors every label at the table edge; cx−Z/2 < 0 on the first column is clamped — leave margin or shorten). With 12 columns or fewer, prefer abbreviated or wrapped headers to rotation.
86
+ **Tables, banding & rotated headers.** A `<tr>` may carry `background:COLOR`; no background on cells, no colspan/rowspan; rows are as tall as their content and a table as tall as its rows (a height on it is ignored). Merged group headers: a painted flex row above the table whose parts use the columns' `width:N%`, summing to 100. Row groups: a pinned painted spine beside the table. Do not rotate text inside a cell: rotation is about the box center (45° labels: the recipe in diagrams.md). A bottom-to-top header over a column of width C centered at cx, in a header zone of height Z (≥ the longest label ≈ 0.6 × font size per character + 24px), is `<p style="position:absolute; left:{cx−Z/2}px; top:{zoneTop+Z/2−C/2}px; width:{Z}px; height:{C}px; line-height:{C/font-size}; transform:rotate(-90deg); white-space:nowrap; text-align:left">`. (the unitless `line-height` centers the line in the C-high box; `text-align:left` anchors every label at the table edge; cx−Z/2 < 0 on the first column is clamped — leave margin or shorten). With 12 columns or fewer, prefer abbreviated or wrapped headers to rotation.
87
87
 
88
88
  **Shapes & icons.** A painted `<div>` IS a rectangle; `<hr>` is a line (`border-top` = its stroke and thickness, else `color`; `width` as usual). `<x-shape kind="ellipse|diamond|arrow-right|arrow-left|arrow-up|arrow-down|line" style="background COLOR; border (stroke); box-shadow; opacity; width; height">` — each kind fills its box, so an arrow is a block glyph whose head is the last 40% of its length: keep it near 2:1 (lint note past 3:1). Connectors: `<x-connector x1 y1 x2 y2 head="end|both|none" head-start|head-end="none|open|filled" route="straight|hv|vh|elbow" style="color; border-width (2px default, ≤32); border-style:dashed|dotted">` — a line, constant-size heads (`hv` = across then down, `vh` = down then across, `elbow` = 3 segments), in canvas coordinates in a `<section>` or relative to a `position:relative` `<div>` (px or %). A pinned layer (counts toward the 24); no `left`/`top`/`width`/`height`, no fill; a missing coordinate errors. Without coordinates it is a sized flow child (`<x-connector style="width:96px">` between cards) whose line runs corner to corner from `from="tl|tr|bl|br"` (default `tl`; a flat box: a straight arrow) (diagrams.md). Shapes, icons, and connectors hold no content. `<x-icon name="…" style="color; width; height">` — name is one of
89
89
  Activity Book Chart Chat Check CheckCircle Clock Cloud Code Database Globe GraduationCap Home Key Lightbulb Lightning Link Lock PaperPlane Play Search Settings Star ThumbsUp Tool Trust Users Verified Warning Wrench (exact).