foss-design 0.9.11 → 0.9.13

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 (26) hide show
  1. package/dist/cli.js +1064 -176
  2. package/dist/viewer/assets/index-BOLNePag.css +2 -0
  3. package/dist/viewer/assets/index-BrtsfBgT.js +163 -0
  4. package/dist/viewer/index.html +2 -2
  5. package/dist/viewer-lib/index.js +4280 -1822
  6. package/dist/viewer-lib/styles.css +140 -5
  7. package/dist/viewer-lib/types/src/shared/drawings.d.ts +153 -0
  8. package/dist/viewer-lib/types/viewer/src/lib/drawings.d.ts +61 -0
  9. package/dist/viewer-lib/types/viewer/src/lib/source.d.ts +6 -0
  10. package/dist/viewer-lib/types/viewer/src/lib/viewer.d.ts +3 -0
  11. package/dist/viewer-lib/types/viewer/src/library.d.ts +1 -0
  12. package/dist/viewer-lib/types/viewer/src/pages/canvas/board.d.ts +16 -0
  13. package/dist/viewer-lib/types/viewer/src/pages/canvas/camera.d.ts +7 -0
  14. package/dist/viewer-lib/types/viewer/src/pages/canvas/canvas-bar.d.ts +14 -3
  15. package/dist/viewer-lib/types/viewer/src/pages/canvas/drawing/editor.d.ts +68 -0
  16. package/dist/viewer-lib/types/viewer/src/pages/canvas/drawing/strokes.d.ts +15 -0
  17. package/dist/viewer-lib/types/viewer/src/pages/canvas/drawing-capture.d.ts +18 -0
  18. package/dist/viewer-lib/types/viewer/src/pages/canvas/layout.d.ts +2 -0
  19. package/dist/viewer-lib/types/viewer/src/pages/canvas/markup.d.ts +26 -0
  20. package/dist/viewer-lib/types/viewer/src/pages/canvas/view-state.d.ts +2 -0
  21. package/dist/viewer-lib/types/viewer/src/pages/canvas/viewport.d.ts +4 -2
  22. package/dist/viewer-lib/types/viewer/src/ui/combobox.d.ts +25 -0
  23. package/dist/viewer-lib/types/viewer/src/ui/icon-paths.d.ts +8 -0
  24. package/package.json +4 -1
  25. package/dist/viewer/assets/index-Cy6JlR85.css +0 -2
  26. package/dist/viewer/assets/index-mvGp1q3N.js +0 -159
package/dist/cli.js CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  // src/cli/index.ts
4
4
  import { spawnSync as spawnSync3 } from "child_process";
5
- import fs38 from "fs";
6
- import path37 from "path";
5
+ import fs39 from "fs";
6
+ import path38 from "path";
7
7
  import { parseArgs } from "util";
8
8
 
9
9
  // src/core/paths.ts
@@ -348,8 +348,8 @@ function routeError(pattern) {
348
348
  }
349
349
  return null;
350
350
  }
351
- function split(path38) {
352
- return path38.split("/").filter(Boolean);
351
+ function split(path39) {
352
+ return path39.split("/").filter(Boolean);
353
353
  }
354
354
  function compileRoute(pattern) {
355
355
  return {
@@ -368,8 +368,8 @@ function decode2(part) {
368
368
  return part;
369
369
  }
370
370
  }
371
- function scoreRoute(route2, path38) {
372
- const parts = split(path38.split(/[?#]/)[0] ?? "").map(decode2);
371
+ function scoreRoute(route2, path39) {
372
+ const parts = split(path39.split(/[?#]/)[0] ?? "").map(decode2);
373
373
  const walk2 = (s, p) => {
374
374
  if (s === route2.segments.length) return p === parts.length ? 0 : null;
375
375
  const segment = route2.segments[s];
@@ -387,11 +387,11 @@ function scoreRoute(route2, path38) {
387
387
  };
388
388
  return walk2(0, 0);
389
389
  }
390
- function matchRoute(entries, path38) {
390
+ function matchRoute(entries, path39) {
391
391
  let best = null;
392
392
  for (const entry of entries) {
393
393
  for (const route2 of entry.routes) {
394
- const score = scoreRoute(route2, path38);
394
+ const score = scoreRoute(route2, path39);
395
395
  if (score !== null && (!best || score > best.score)) best = { score, value: entry.value };
396
396
  }
397
397
  }
@@ -518,9 +518,9 @@ var CanvasSchema = z.strictObject({
518
518
  pages: z.array(z.unknown()).min(1, "add at least one page")
519
519
  });
520
520
  var ID_PATTERN = /^[a-z0-9][a-z0-9_-]*$/i;
521
- function formatPath(base, path38) {
521
+ function formatPath(base, path39) {
522
522
  let out = base;
523
- for (const part of path38) out += typeof part === "number" ? `[${part}]` : out ? `.${String(part)}` : String(part);
523
+ for (const part of path39) out += typeof part === "number" ? `[${part}]` : out ? `.${String(part)}` : String(part);
524
524
  return out;
525
525
  }
526
526
  function describeZodError(error, base = "") {
@@ -1943,10 +1943,10 @@ function linkShippedPackages(paths, appDir = null) {
1943
1943
  allow.add(path10.dirname(target2));
1944
1944
  fromApp.push(name);
1945
1945
  }
1946
- const record2 = path10.join(modules, APP_LINKS);
1946
+ const record3 = path10.join(modules, APP_LINKS);
1947
1947
  let before = [];
1948
1948
  try {
1949
- before = JSON.parse(fs10.readFileSync(record2, "utf8"));
1949
+ before = JSON.parse(fs10.readFileSync(record3, "utf8"));
1950
1950
  } catch {
1951
1951
  }
1952
1952
  for (const name of before) {
@@ -1962,7 +1962,7 @@ function linkShippedPackages(paths, appDir = null) {
1962
1962
  }
1963
1963
  if (fromApp.length || before.length) {
1964
1964
  fs10.mkdirSync(modules, { recursive: true });
1965
- fs10.writeFileSync(record2, JSON.stringify(fromApp.sort()));
1965
+ fs10.writeFileSync(record3, JSON.stringify(fromApp.sort()));
1966
1966
  }
1967
1967
  const target = fs10.realpathSync(PKG_ROOT);
1968
1968
  linkTo(path10.join(modules, "foss-design-cli"), target);
@@ -2394,14 +2394,14 @@ async function buildSite(paths, out, options = {}) {
2394
2394
  for (const canvas of canvases) writeJson(path13.join(out, "api", "canvas", `${canvas.doc.id}.json`), canvas.doc);
2395
2395
  if (system) {
2396
2396
  writeJson(path13.join(out, "api", "system.json"), system.doc);
2397
- const texts = {};
2397
+ const texts2 = {};
2398
2398
  for (const component of system.doc.components) {
2399
2399
  for (const rel of [component.specimen, ...component.sources]) {
2400
2400
  const file = path13.join(paths.root, rel);
2401
- if (!(rel in texts) && fs13.existsSync(file)) texts[rel] = fs13.readFileSync(file, "utf8");
2401
+ if (!(rel in texts2) && fs13.existsSync(file)) texts2[rel] = fs13.readFileSync(file, "utf8");
2402
2402
  }
2403
2403
  }
2404
- writeJson(path13.join(out, "api", "sources.json"), texts);
2404
+ writeJson(path13.join(out, "api", "sources.json"), texts2);
2405
2405
  }
2406
2406
  fs13.rmSync(staging, { recursive: true, force: true });
2407
2407
  const errors = [
@@ -2821,7 +2821,7 @@ var CloudClient = class {
2821
2821
  }
2822
2822
  host;
2823
2823
  token;
2824
- async send(method, path38, init) {
2824
+ async send(method, path39, init) {
2825
2825
  const headers = {
2826
2826
  "User-Agent": `foss-design/${packageVersion()} (${os.platform()})`,
2827
2827
  Accept: "application/json"
@@ -2829,7 +2829,7 @@ var CloudClient = class {
2829
2829
  if (this.token) headers.Authorization = `Bearer ${this.token}`;
2830
2830
  if (init.type) headers["Content-Type"] = init.type;
2831
2831
  try {
2832
- return await fetch(`${this.host}/api${path38}`, {
2832
+ return await fetch(`${this.host}/api${path39}`, {
2833
2833
  method,
2834
2834
  headers,
2835
2835
  body: init.body,
@@ -2850,8 +2850,8 @@ var CloudClient = class {
2850
2850
  throw errorFor(this.host, response.status, body, response.headers.get("retry-after"));
2851
2851
  }
2852
2852
  /** JSON in, JSON out; any non-2xx answer throws a CloudError. */
2853
- async request(method, path38, body) {
2854
- const response = await this.send(method, path38, {
2853
+ async request(method, path39, body) {
2854
+ const response = await this.send(method, path39, {
2855
2855
  body: body === void 0 ? void 0 : JSON.stringify(body),
2856
2856
  type: body === void 0 ? void 0 : "application/json",
2857
2857
  timeout: JSON_TIMEOUT
@@ -2863,7 +2863,7 @@ var CloudClient = class {
2863
2863
  try {
2864
2864
  return JSON.parse(text2);
2865
2865
  } catch {
2866
- throw new CloudError(`${this.host} answered ${method} ${path38} with something other than JSON`, 0, "bad_response");
2866
+ throw new CloudError(`${this.host} answered ${method} ${path39} with something other than JSON`, 0, "bad_response");
2867
2867
  }
2868
2868
  }
2869
2869
  /** POST /device/code. */
@@ -3135,7 +3135,7 @@ function requireLink(paths) {
3135
3135
  return link;
3136
3136
  }
3137
3137
  function writeLink(paths, link) {
3138
- const sorted = (record2) => Object.fromEntries(Object.entries(record2).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
3138
+ const sorted = (record3) => Object.fromEntries(Object.entries(record3).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
3139
3139
  const file = linkFile(paths);
3140
3140
  const temp = `${file}.${process.pid}.tmp`;
3141
3141
  const data = {
@@ -3446,17 +3446,17 @@ async function openFrame(page, url, options = {}) {
3446
3446
  let errors = [];
3447
3447
  let outdated = () => {
3448
3448
  };
3449
- const record2 = (text2) => {
3449
+ const record3 = (text2) => {
3450
3450
  errors.push(text2);
3451
3451
  if (OUTDATED_DEP.test(text2)) outdated();
3452
3452
  };
3453
- page.on("pageerror", (error) => record2(error.message));
3453
+ page.on("pageerror", (error) => record3(error.message));
3454
3454
  page.on("console", (message) => {
3455
3455
  if (message.type() !== "error") return;
3456
3456
  let text2 = message.text().replace(/^(?:\s*%[osdifcO])+\s*/, "");
3457
3457
  const where = message.location().url;
3458
3458
  if (/^Failed to load resource/.test(text2) && where && !text2.includes(where)) text2 += ` (${where})`;
3459
- record2(text2);
3459
+ record3(text2);
3460
3460
  });
3461
3461
  let ready = false;
3462
3462
  let loadedAt = 0;
@@ -4404,18 +4404,252 @@ Still using go():
4404
4404
 
4405
4405
  // src/cli/commands/preview.ts
4406
4406
  import { spawn } from "child_process";
4407
- import fs30 from "fs";
4407
+ import fs31 from "fs";
4408
4408
 
4409
4409
  // src/server/index.ts
4410
- import fs29 from "fs";
4410
+ import fs30 from "fs";
4411
4411
  import http2 from "http";
4412
- import path28 from "path";
4412
+ import path29 from "path";
4413
4413
  import sirv2 from "sirv";
4414
4414
  import { createServer, searchForWorkspaceRoot } from "vite";
4415
4415
 
4416
4416
  // src/server/api.ts
4417
4417
  import fs27 from "fs";
4418
4418
  import path26 from "path";
4419
+
4420
+ // src/shared/drawings.ts
4421
+ var INKS = ["ink", "red", "blue", "green"];
4422
+ var STROKE_KINDS = ["pen", "arrow", "rect", "text"];
4423
+ var MAX_STROKES = 4e3;
4424
+ var MAX_ERASED = 2e4;
4425
+ var MAX_NUMBERS = 12e3;
4426
+ var MAX_TEXT = 2e3;
4427
+ var MAX_COORD = 1e5;
4428
+ var MAX_MESSAGE = 256 * 1024;
4429
+ var KEY = /^(board|markup):[a-z0-9][a-z0-9_-]{0,127}$/i;
4430
+ var STROKE_ID = /^[a-z0-9_-]{1,40}$/i;
4431
+ var markupKey = (item) => `markup:${item}`;
4432
+ var isDrawingKey = (value) => typeof value === "string" && KEY.test(value);
4433
+ var emptyDrawing = () => ({ strokes: [], erased: [] });
4434
+ var record2 = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : null;
4435
+ function parseStroke(value) {
4436
+ const data = record2(value);
4437
+ if (!data) return null;
4438
+ const { id: id2, kind, ink, size: size2, points, text: text2 } = data;
4439
+ if (typeof id2 !== "string" || !STROKE_ID.test(id2)) return null;
4440
+ if (!STROKE_KINDS.includes(kind) || !INKS.includes(ink)) return null;
4441
+ if (typeof size2 !== "number" || !(size2 >= 0.5 && size2 <= 400)) return null;
4442
+ if (!Array.isArray(points) || points.length > MAX_NUMBERS) return null;
4443
+ if (!points.every((n2) => typeof n2 === "number" && Number.isFinite(n2) && Math.abs(n2) <= MAX_COORD)) return null;
4444
+ const need = kind === "pen" ? points.length >= 3 && points.length % 3 === 0 : points.length === (kind === "text" ? 2 : 4);
4445
+ if (!need) return null;
4446
+ if (kind === "text" && (typeof text2 !== "string" || !text2.trim() || text2.length > MAX_TEXT)) return null;
4447
+ const stroke = { id: id2, kind, ink, size: size2, points };
4448
+ if (kind === "text") stroke.text = text2;
4449
+ return stroke;
4450
+ }
4451
+ function parseIds(value, max) {
4452
+ if (!Array.isArray(value) || value.length > max) return null;
4453
+ return value.every((id2) => typeof id2 === "string" && STROKE_ID.test(id2)) ? value : null;
4454
+ }
4455
+ function parseOps(value) {
4456
+ const data = record2(value);
4457
+ if (!data) return null;
4458
+ const ops = {};
4459
+ if (data.add !== void 0) {
4460
+ if (!Array.isArray(data.add) || data.add.length > MAX_STROKES) return null;
4461
+ const add = data.add.map(parseStroke);
4462
+ if (add.some((stroke) => !stroke)) return null;
4463
+ ops.add = add;
4464
+ }
4465
+ if (data.erase !== void 0) {
4466
+ const erase = parseIds(data.erase, MAX_ERASED);
4467
+ if (!erase) return null;
4468
+ ops.erase = erase;
4469
+ }
4470
+ return ops;
4471
+ }
4472
+ function parseDrawing(value) {
4473
+ const data = record2(value);
4474
+ if (!data) return null;
4475
+ const erased = parseIds(data.erased ?? [], MAX_ERASED);
4476
+ if (!erased || !Array.isArray(data.strokes) || data.strokes.length > MAX_STROKES) return null;
4477
+ const strokes = data.strokes.map(parseStroke);
4478
+ if (strokes.some((stroke) => !stroke)) return null;
4479
+ return { strokes, erased };
4480
+ }
4481
+ function parseDrawings(value) {
4482
+ const out = {};
4483
+ for (const [key, drawing] of Object.entries(record2(value) ?? {})) {
4484
+ const parsed = isDrawingKey(key) ? parseDrawing(drawing) : null;
4485
+ if (parsed) out[key] = parsed;
4486
+ }
4487
+ return out;
4488
+ }
4489
+ function applyOps(drawing, ops) {
4490
+ const erased = new Set(drawing.erased);
4491
+ let changed = false;
4492
+ const newlyErased = [];
4493
+ for (const id2 of ops.erase ?? []) {
4494
+ if (erased.has(id2)) continue;
4495
+ erased.add(id2);
4496
+ newlyErased.push(id2);
4497
+ changed = true;
4498
+ }
4499
+ const strokes = newlyErased.length ? drawing.strokes.filter((stroke) => !erased.has(stroke.id)) : [...drawing.strokes];
4500
+ const ids = new Set(strokes.map((stroke) => stroke.id));
4501
+ for (const stroke of ops.add ?? []) {
4502
+ if (erased.has(stroke.id) || ids.has(stroke.id) || strokes.length >= MAX_STROKES) continue;
4503
+ strokes.push(stroke);
4504
+ ids.add(stroke.id);
4505
+ changed = true;
4506
+ }
4507
+ if (!changed) return null;
4508
+ const erasedList = [...drawing.erased, ...newlyErased];
4509
+ return { strokes, erased: erasedList.length > MAX_ERASED ? erasedList.slice(-MAX_ERASED) : erasedList };
4510
+ }
4511
+ function diffDrawings(from, to) {
4512
+ const toErased = new Set(to.erased);
4513
+ const toIds = new Set(to.strokes.map((stroke) => stroke.id));
4514
+ const add = from.strokes.filter((stroke) => !toIds.has(stroke.id) && !toErased.has(stroke.id));
4515
+ const erase = from.erased.filter((id2) => !toErased.has(id2));
4516
+ if (!add.length && !erase.length) return null;
4517
+ return { ...add.length ? { add } : {}, ...erase.length ? { erase } : {} };
4518
+ }
4519
+ var TEXT_LINE_HEIGHT = 1.25;
4520
+ function textBox(stroke) {
4521
+ const lines = (stroke.text ?? "").split("\n");
4522
+ const longest = Math.max(...lines.map((line) => line.length), 1);
4523
+ return {
4524
+ x: stroke.points[0],
4525
+ y: stroke.points[1],
4526
+ w: longest * stroke.size * 0.56,
4527
+ h: lines.length * stroke.size * TEXT_LINE_HEIGHT
4528
+ };
4529
+ }
4530
+ function strokeBounds(stroke) {
4531
+ if (stroke.kind === "text") return textBox(stroke);
4532
+ const step = stroke.kind === "pen" ? 3 : 2;
4533
+ const pad = stroke.kind === "arrow" ? Math.max(10, stroke.size * 4.5) : stroke.size;
4534
+ let minX = Number.POSITIVE_INFINITY;
4535
+ let minY = Number.POSITIVE_INFINITY;
4536
+ let maxX = Number.NEGATIVE_INFINITY;
4537
+ let maxY = Number.NEGATIVE_INFINITY;
4538
+ for (let i = 0; i + 1 < stroke.points.length; i += step) {
4539
+ minX = Math.min(minX, stroke.points[i]);
4540
+ minY = Math.min(minY, stroke.points[i + 1]);
4541
+ maxX = Math.max(maxX, stroke.points[i]);
4542
+ maxY = Math.max(maxY, stroke.points[i + 1]);
4543
+ }
4544
+ return { x: minX - pad, y: minY - pad, w: maxX - minX + pad * 2, h: maxY - minY + pad * 2 };
4545
+ }
4546
+ var union = (a, b) => {
4547
+ const x = Math.min(a.x, b.x);
4548
+ const y = Math.min(a.y, b.y);
4549
+ return { x, y, w: Math.max(a.x + a.w, b.x + b.w) - x, h: Math.max(a.y + a.h, b.y + b.h) - y };
4550
+ };
4551
+ var GROUP_GAP = 240;
4552
+ function drawingGroups(strokes) {
4553
+ let groups = strokes.map((stroke) => ({ box: strokeBounds(stroke), strokes: [stroke] }));
4554
+ const near = (a, b) => a.x - GROUP_GAP < b.x + b.w && b.x - GROUP_GAP < a.x + a.w && a.y - GROUP_GAP < b.y + b.h && b.y - GROUP_GAP < a.y + a.h;
4555
+ for (let merged = true; merged; ) {
4556
+ merged = false;
4557
+ const next = [];
4558
+ for (const group of groups) {
4559
+ const into = next.find((other) => near(other.box, group.box));
4560
+ if (into) {
4561
+ into.box = union(into.box, group.box);
4562
+ into.strokes.push(...group.strokes);
4563
+ merged = true;
4564
+ } else next.push({ box: group.box, strokes: [...group.strokes] });
4565
+ }
4566
+ groups = next;
4567
+ }
4568
+ const order = new Map(strokes.map((stroke, i) => [stroke.id, i]));
4569
+ for (const group of groups) group.strokes.sort((a, b) => order.get(a.id) - order.get(b.id));
4570
+ groups.sort((a, b) => a.box.y - b.box.y);
4571
+ const rows = [];
4572
+ for (const group of groups) {
4573
+ const row = rows.at(-1);
4574
+ if (row && group.box.y < row[0].box.y + row[0].box.h / 2) row.push(group);
4575
+ else rows.push([group]);
4576
+ }
4577
+ return rows.flatMap((row) => row.sort((a, b) => a.box.x - b.box.x));
4578
+ }
4579
+ function parseLive(value) {
4580
+ const data = record2(value);
4581
+ if (!data) return null;
4582
+ const { id: id2, kind, ink, size: size2, text: text2, at, points } = data;
4583
+ if (typeof id2 !== "string" || !STROKE_ID.test(id2)) return null;
4584
+ if (!STROKE_KINDS.includes(kind) || !INKS.includes(ink)) return null;
4585
+ if (typeof size2 !== "number" || !(size2 >= 0.5 && size2 <= 400)) return null;
4586
+ if (typeof at !== "number" || !Number.isInteger(at) || at < 0 || at > MAX_NUMBERS) return null;
4587
+ if (!Array.isArray(points) || points.length > MAX_NUMBERS) return null;
4588
+ if (!points.every((n2) => typeof n2 === "number" && Number.isFinite(n2) && Math.abs(n2) <= MAX_COORD)) return null;
4589
+ if (text2 !== void 0 && (typeof text2 !== "string" || text2.length > MAX_TEXT)) return null;
4590
+ const stroke = { id: id2, kind, ink, size: size2, at, points };
4591
+ if (typeof text2 === "string") stroke.text = text2;
4592
+ return stroke;
4593
+ }
4594
+ function parseJson(raw) {
4595
+ if (raw.length > MAX_MESSAGE) return null;
4596
+ try {
4597
+ return record2(JSON.parse(raw));
4598
+ } catch {
4599
+ return null;
4600
+ }
4601
+ }
4602
+ function parseClientMessage(raw) {
4603
+ const data = parseJson(raw);
4604
+ if (!data || !isDrawingKey(data.key)) return null;
4605
+ const key = data.key;
4606
+ if (data.type === "ops") {
4607
+ if (typeof data.id !== "number" || !Number.isInteger(data.id)) return null;
4608
+ const ops = parseOps(data);
4609
+ return ops ? { type: "ops", id: data.id, key, ...ops } : null;
4610
+ }
4611
+ if (data.type === "live") {
4612
+ const stroke = parseLive(data.stroke);
4613
+ return stroke ? { type: "live", key, stroke } : null;
4614
+ }
4615
+ if (data.type === "live-end")
4616
+ return typeof data.id === "string" && STROKE_ID.test(data.id) ? { type: "live-end", key, id: data.id } : null;
4617
+ return null;
4618
+ }
4619
+ function parseServerMessage(raw) {
4620
+ const data = parseJson(raw);
4621
+ if (!data) return null;
4622
+ const peer = typeof data.peer === "string" ? data.peer : "";
4623
+ switch (data.type) {
4624
+ case "hello":
4625
+ return { type: "hello", peer, canDraw: data.canDraw === true, drawings: parseDrawings(data.drawings) };
4626
+ case "ops": {
4627
+ const ops = isDrawingKey(data.key) ? parseOps(data) : null;
4628
+ return ops ? { type: "ops", key: data.key, ...ops, peer } : null;
4629
+ }
4630
+ case "ack":
4631
+ return typeof data.id === "number" ? { type: "ack", id: data.id } : null;
4632
+ case "doc": {
4633
+ const drawing = isDrawingKey(data.key) ? parseDrawing(data.drawing) : null;
4634
+ return drawing ? { type: "doc", key: data.key, drawing } : null;
4635
+ }
4636
+ case "live": {
4637
+ const stroke = isDrawingKey(data.key) ? parseLive(data.stroke) : null;
4638
+ if (!stroke) return null;
4639
+ const name = typeof data.name === "string" ? data.name.slice(0, 80) : void 0;
4640
+ return { type: "live", key: data.key, stroke, peer, ...name ? { name } : {} };
4641
+ }
4642
+ case "live-end":
4643
+ return isDrawingKey(data.key) && typeof data.id === "string" ? { type: "live-end", key: data.key, id: data.id, peer } : null;
4644
+ case "gone":
4645
+ return { type: "gone", peer };
4646
+ case "error":
4647
+ return { type: "error", message: typeof data.message === "string" ? data.message.slice(0, 500) : "error" };
4648
+ }
4649
+ return null;
4650
+ }
4651
+
4652
+ // src/server/api.ts
4419
4653
  function sendJson(res, status, body) {
4420
4654
  res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
4421
4655
  res.end(JSON.stringify(body));
@@ -4436,7 +4670,7 @@ function readBody(req, limit = 25 * 1024 * 1024) {
4436
4670
  });
4437
4671
  }
4438
4672
  async function handleApi(ctx, req, res, url) {
4439
- const { project, events, snapshots } = ctx;
4673
+ const { project, events, snapshots, drawings } = ctx;
4440
4674
  const route2 = url.pathname.slice("/api".length);
4441
4675
  const method = req.method ?? "GET";
4442
4676
  if (route2 === "/health") {
@@ -4497,6 +4731,25 @@ async function handleApi(ctx, req, res, url) {
4497
4731
  }
4498
4732
  return true;
4499
4733
  }
4734
+ const drawingsMatch = /^\/drawings\/([^/]+)(?:\/([^/]+))?$/.exec(route2);
4735
+ if (drawingsMatch) {
4736
+ const canvas = decodeURIComponent(drawingsMatch[1]);
4737
+ const key = drawingsMatch[2] ? decodeURIComponent(drawingsMatch[2]) : null;
4738
+ if (method === "GET" && !key) {
4739
+ const found = await drawings.read(canvas);
4740
+ if (found) sendJson(res, 200, found);
4741
+ else sendJson(res, 404, { error: `No canvas "${canvas}"` });
4742
+ return true;
4743
+ }
4744
+ if (method === "DELETE" && key && isDrawingKey(key)) {
4745
+ const erased = await drawings.clear(canvas, key);
4746
+ if (erased === null) sendJson(res, 404, { error: `No canvas "${canvas}"` });
4747
+ else sendJson(res, 200, { erased });
4748
+ return true;
4749
+ }
4750
+ sendJson(res, 405, { error: "GET /api/drawings/<canvas> or DELETE /api/drawings/<canvas>/<key>" });
4751
+ return true;
4752
+ }
4500
4753
  const snapshotPost = /^\/snapshots\/([^/]+)\/([^/]+)$/.exec(route2);
4501
4754
  if (snapshotPost && method === "POST") {
4502
4755
  const canvas = decodeURIComponent(snapshotPost[1]);
@@ -4533,6 +4786,433 @@ async function handleApi(ctx, req, res, url) {
4533
4786
  return false;
4534
4787
  }
4535
4788
 
4789
+ // src/server/drawings.ts
4790
+ import fs28 from "fs";
4791
+ import path27 from "path";
4792
+ import WebSocket, { WebSocketServer } from "ws";
4793
+
4794
+ // src/server/hosts.ts
4795
+ var LOOPBACK = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
4796
+ var FRAME_HOST = "127.0.0.1";
4797
+ var VIEWER_HOST = "localhost";
4798
+ function parseHost(header) {
4799
+ const match = /^(\[[0-9a-f:.]+\]|[a-z0-9.-]+)(?::(\d{1,5}))?$/i.exec(header ?? "");
4800
+ if (!match) return null;
4801
+ return { name: match[1].toLowerCase(), port: match[2] ? Number(match[2]) : 80 };
4802
+ }
4803
+ function isAllowedHost(header, port, extra) {
4804
+ const host = parseHost(header);
4805
+ if (!host || host.port !== port) return false;
4806
+ return LOOPBACK.has(host.name) || host.name.endsWith(".localhost") || !!extra && host.name === extra.toLowerCase();
4807
+ }
4808
+ function isFrameApi(pathname, method) {
4809
+ if (pathname === "/api/health") return method === "GET";
4810
+ return /^\/api\/snapshots\/[^/]+\/[^/]+$/.test(pathname) && (method === "GET" || method === "POST");
4811
+ }
4812
+
4813
+ // src/server/drawings.ts
4814
+ var FILE = ".drawings.json";
4815
+ var IDLE_MS = 3e4;
4816
+ var SYNC_WAIT_MS = 6e3;
4817
+ var drawingsFile = (paths, canvas) => path27.join(paths.canvases, canvas, FILE);
4818
+ var send = (socket, message) => {
4819
+ if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message));
4820
+ };
4821
+ function readFile(file) {
4822
+ let raw;
4823
+ try {
4824
+ raw = fs28.readFileSync(file, "utf8");
4825
+ } catch {
4826
+ return {};
4827
+ }
4828
+ try {
4829
+ return parseDrawings(JSON.parse(raw).drawings);
4830
+ } catch {
4831
+ console.warn(`[design] ${file} is not valid JSON; its drawings are not shown`);
4832
+ return {};
4833
+ }
4834
+ }
4835
+ var Room = class {
4836
+ constructor(hub, canvas) {
4837
+ this.hub = hub;
4838
+ this.canvas = canvas;
4839
+ this.drawings = readFile(this.file);
4840
+ }
4841
+ hub;
4842
+ canvas;
4843
+ drawings;
4844
+ clients = /* @__PURE__ */ new Map();
4845
+ cloud = null;
4846
+ written = null;
4847
+ saveTimer;
4848
+ idleTimer;
4849
+ get file() {
4850
+ return drawingsFile(this.hub.paths, this.canvas);
4851
+ }
4852
+ canDraw() {
4853
+ return this.cloud?.canDraw ?? true;
4854
+ }
4855
+ cloudState() {
4856
+ return this.cloud ? this.cloud.state : "unlinked";
4857
+ }
4858
+ /** The cloud's answer, or what is here once `timeout` passes. */
4859
+ async synced(timeout) {
4860
+ this.touch();
4861
+ if (this.cloud) await Promise.race([this.cloud.ready, new Promise((resolve) => setTimeout(resolve, timeout))]);
4862
+ }
4863
+ /** Keep (or open) the cloud socket for a while even with nobody here: the CLI reads through it. */
4864
+ touch() {
4865
+ this.connectCloud();
4866
+ this.release();
4867
+ }
4868
+ connectCloud() {
4869
+ clearTimeout(this.idleTimer);
4870
+ if (this.cloud) return;
4871
+ const link = readLink(this.hub.paths);
4872
+ const credential = link ? getCredential(link.host) : null;
4873
+ if (!link || !credential) return;
4874
+ this.cloud = new CloudRelay(this, link.host, link.project, credential.token);
4875
+ }
4876
+ /** With nobody left, let the cloud socket go after a while. */
4877
+ release() {
4878
+ clearTimeout(this.idleTimer);
4879
+ if (this.clients.size) return;
4880
+ this.idleTimer = setTimeout(() => {
4881
+ if (this.clients.size) return;
4882
+ this.cloud?.close();
4883
+ this.cloud = null;
4884
+ }, IDLE_MS);
4885
+ this.idleTimer.unref();
4886
+ }
4887
+ join(socket) {
4888
+ const client = { peer: this.hub.nextPeer(), live: /* @__PURE__ */ new Map() };
4889
+ this.clients.set(socket, client);
4890
+ this.connectCloud();
4891
+ send(socket, { type: "hello", peer: client.peer, canDraw: this.canDraw(), drawings: this.drawings });
4892
+ socket.on("message", (data, isBinary) => {
4893
+ if (isBinary) return;
4894
+ const message = parseClientMessage(data.toString());
4895
+ if (message) this.fromClient(socket, client, message);
4896
+ else send(socket, { type: "error", message: "Not a drawings message." });
4897
+ });
4898
+ socket.on("close", () => {
4899
+ this.clients.delete(socket);
4900
+ for (const [id2, key] of client.live) this.cloud?.send({ type: "live-end", key, id: id2 });
4901
+ this.broadcast({ type: "gone", peer: client.peer });
4902
+ this.release();
4903
+ });
4904
+ socket.on("error", () => socket.terminate());
4905
+ }
4906
+ fromClient(socket, client, message) {
4907
+ if (!this.canDraw()) {
4908
+ send(socket, { type: "error", message: "You can look at these drawings but not draw on them." });
4909
+ return;
4910
+ }
4911
+ if (message.type === "ops") {
4912
+ this.change(message.key, { add: message.add, erase: message.erase }, client.peer);
4913
+ send(socket, { type: "ack", id: message.id });
4914
+ } else if (message.type === "live") {
4915
+ client.live.set(message.stroke.id, message.key);
4916
+ this.broadcast({ ...message, peer: client.peer }, socket);
4917
+ this.cloud?.send(message);
4918
+ } else {
4919
+ client.live.delete(message.id);
4920
+ this.broadcast({ ...message, peer: client.peer }, socket);
4921
+ this.cloud?.send(message);
4922
+ }
4923
+ }
4924
+ /** A change made here (a viewer, the CLI): applied, and passed on to the cloud. */
4925
+ change(key, ops, peer) {
4926
+ this.apply(key, ops, peer);
4927
+ this.cloud?.forward(key, ops);
4928
+ }
4929
+ /** Apply ops from anywhere; tell everyone here and save when they changed something. */
4930
+ apply(key, ops, peer) {
4931
+ const next = applyOps(this.drawings[key] ?? emptyDrawing(), ops);
4932
+ if (!next) return false;
4933
+ this.drawings = { ...this.drawings, [key]: next };
4934
+ this.broadcast({ type: "ops", key, ...ops, peer });
4935
+ this.save();
4936
+ return true;
4937
+ }
4938
+ /** A whole drawing from elsewhere, merged with this one; returns what this side has that it lacked. */
4939
+ merge(key, drawing) {
4940
+ const here = this.drawings[key] ?? emptyDrawing();
4941
+ const missing = diffDrawings(drawing, here);
4942
+ if (missing) {
4943
+ const next = applyOps(here, missing);
4944
+ if (next) {
4945
+ this.drawings = { ...this.drawings, [key]: next };
4946
+ this.broadcast({ type: "doc", key, drawing: next });
4947
+ this.save();
4948
+ }
4949
+ }
4950
+ return diffDrawings(this.drawings[key] ?? emptyDrawing(), drawing);
4951
+ }
4952
+ /** The cloud's answer came in, or its verdict on drawing changed: everyone here starts over from it. */
4953
+ rehello() {
4954
+ for (const [socket, client] of this.clients)
4955
+ send(socket, { type: "hello", peer: client.peer, canDraw: this.canDraw(), drawings: this.drawings });
4956
+ }
4957
+ broadcast(message, except) {
4958
+ const data = JSON.stringify(message);
4959
+ for (const socket of this.clients.keys())
4960
+ if (socket !== except && socket.readyState === WebSocket.OPEN) socket.send(data);
4961
+ }
4962
+ save() {
4963
+ clearTimeout(this.saveTimer);
4964
+ this.saveTimer = setTimeout(() => this.flush(), 150);
4965
+ }
4966
+ flush() {
4967
+ clearTimeout(this.saveTimer);
4968
+ const dir = path27.dirname(this.file);
4969
+ if (!fs28.existsSync(dir)) return;
4970
+ const drawings = Object.fromEntries(
4971
+ Object.entries(this.drawings).filter(([, d]) => d.strokes.length || d.erased.length)
4972
+ );
4973
+ const text2 = `${JSON.stringify({ drawings })}
4974
+ `;
4975
+ if (text2 === this.written) return;
4976
+ const temp = `${this.file}.${process.pid}.tmp`;
4977
+ fs28.writeFileSync(temp, text2);
4978
+ fs28.renameSync(temp, this.file);
4979
+ this.written = text2;
4980
+ }
4981
+ /** The file changed on disk: unless it was this server's own write, it is the drawings now. */
4982
+ reload() {
4983
+ let raw = null;
4984
+ try {
4985
+ raw = fs28.readFileSync(this.file, "utf8");
4986
+ } catch {
4987
+ }
4988
+ if (raw !== null && raw === this.written) return;
4989
+ const next = readFile(this.file);
4990
+ for (const key of /* @__PURE__ */ new Set([...Object.keys(this.drawings), ...Object.keys(next)])) {
4991
+ const drawing = next[key] ?? emptyDrawing();
4992
+ if (JSON.stringify(drawing) === JSON.stringify(this.drawings[key] ?? emptyDrawing())) continue;
4993
+ this.broadcast({ type: "doc", key, drawing });
4994
+ const ops = diffDrawings(drawing, this.drawings[key] ?? emptyDrawing());
4995
+ if (ops) this.cloud?.forward(key, ops);
4996
+ }
4997
+ this.drawings = next;
4998
+ this.written = raw;
4999
+ }
5000
+ close() {
5001
+ this.flush();
5002
+ this.cloud?.close();
5003
+ this.cloud = null;
5004
+ clearTimeout(this.idleTimer);
5005
+ for (const socket of this.clients.keys()) socket.close(1001);
5006
+ }
5007
+ };
5008
+ var CloudRelay = class {
5009
+ constructor(room, host, project, token) {
5010
+ this.room = room;
5011
+ this.host = host;
5012
+ this.project = project;
5013
+ this.token = token;
5014
+ this.ready = new Promise((resolve) => {
5015
+ this.settle = resolve;
5016
+ });
5017
+ this.connect();
5018
+ }
5019
+ room;
5020
+ host;
5021
+ project;
5022
+ token;
5023
+ canDraw = true;
5024
+ state = "connecting";
5025
+ /** Settles on the cloud's first answer, or once it is clear there will be none soon. */
5026
+ ready;
5027
+ settle = () => {
5028
+ };
5029
+ socket = null;
5030
+ peer = "";
5031
+ nextId = 1;
5032
+ delay = 1e3;
5033
+ timer;
5034
+ closed = false;
5035
+ /** Cloud peers with strokes on the way here, cleared when the socket drops. */
5036
+ peers = /* @__PURE__ */ new Set();
5037
+ url() {
5038
+ const base = new URL(this.host);
5039
+ base.protocol = base.protocol === "https:" ? "wss:" : "ws:";
5040
+ base.pathname = `/api/projects/${encodeURIComponent(this.project)}/canvases/${encodeURIComponent(this.room.canvas)}/drawings`;
5041
+ return base.href;
5042
+ }
5043
+ connect() {
5044
+ if (this.closed) return;
5045
+ const socket = new WebSocket(this.url(), {
5046
+ headers: { Authorization: `Bearer ${this.token}` },
5047
+ maxPayload: MAX_MESSAGE * 4,
5048
+ handshakeTimeout: 1e4
5049
+ });
5050
+ this.socket = socket;
5051
+ let refusal = 0;
5052
+ socket.on("unexpected-response", (_req, res) => {
5053
+ refusal = res.statusCode ?? 0;
5054
+ res.resume();
5055
+ socket.terminate();
5056
+ });
5057
+ socket.on("message", (data, isBinary) => {
5058
+ if (isBinary) return;
5059
+ const message = parseServerMessage(data.toString());
5060
+ if (message) this.receive(message);
5061
+ });
5062
+ socket.on("error", () => {
5063
+ });
5064
+ socket.on("close", () => {
5065
+ if (this.socket !== socket) return;
5066
+ this.socket = null;
5067
+ for (const peer of this.peers) this.room.broadcast({ type: "gone", peer });
5068
+ this.peers.clear();
5069
+ if (this.closed) return;
5070
+ const wasSynced = this.state === "synced";
5071
+ this.state = "offline";
5072
+ this.settle();
5073
+ const wait = refusal === 401 ? 30 * 6e4 : refusal >= 400 ? 5 * 6e4 : wasSynced ? 1e3 : this.delay;
5074
+ this.delay = Math.min(this.delay * 2, 3e4);
5075
+ if (!this.canDraw) {
5076
+ this.canDraw = true;
5077
+ this.room.rehello();
5078
+ }
5079
+ this.timer = setTimeout(() => this.connect(), wait);
5080
+ this.timer.unref();
5081
+ });
5082
+ }
5083
+ receive(message) {
5084
+ const cloudPeer = (peer) => `cloud:${peer}`;
5085
+ switch (message.type) {
5086
+ case "hello": {
5087
+ this.peer = message.peer;
5088
+ this.canDraw = message.canDraw;
5089
+ this.state = "synced";
5090
+ this.delay = 1e3;
5091
+ const keys = /* @__PURE__ */ new Set([...Object.keys(message.drawings), ...Object.keys(this.room.drawings)]);
5092
+ for (const key of keys) {
5093
+ const up = this.room.merge(key, message.drawings[key] ?? emptyDrawing());
5094
+ if (up && this.canDraw) this.forward(key, up);
5095
+ }
5096
+ this.room.rehello();
5097
+ this.settle();
5098
+ break;
5099
+ }
5100
+ case "ops":
5101
+ if (message.peer !== this.peer)
5102
+ this.room.apply(message.key, { add: message.add, erase: message.erase }, cloudPeer(message.peer));
5103
+ break;
5104
+ case "doc": {
5105
+ const up = this.room.merge(message.key, message.drawing);
5106
+ if (up && this.canDraw) this.forward(message.key, up);
5107
+ break;
5108
+ }
5109
+ case "live":
5110
+ case "live-end":
5111
+ this.peers.add(cloudPeer(message.peer));
5112
+ this.room.broadcast({ ...message, peer: cloudPeer(message.peer) });
5113
+ break;
5114
+ case "gone":
5115
+ this.peers.delete(cloudPeer(message.peer));
5116
+ this.room.broadcast({ type: "gone", peer: cloudPeer(message.peer) });
5117
+ break;
5118
+ case "error":
5119
+ console.warn(`[design] cloud drawings (${this.room.canvas}): ${message.message}`);
5120
+ break;
5121
+ }
5122
+ }
5123
+ forward(key, ops) {
5124
+ this.send({ type: "ops", id: this.nextId++, key, ...ops });
5125
+ }
5126
+ send(message) {
5127
+ if (this.state !== "synced" || !this.canDraw || !this.socket) return;
5128
+ send(this.socket, message);
5129
+ }
5130
+ close() {
5131
+ this.closed = true;
5132
+ clearTimeout(this.timer);
5133
+ this.socket?.close(1e3);
5134
+ this.socket = null;
5135
+ this.settle();
5136
+ }
5137
+ };
5138
+ var DrawingHub = class {
5139
+ constructor(paths, hasCanvas) {
5140
+ this.paths = paths;
5141
+ this.hasCanvas = hasCanvas;
5142
+ }
5143
+ paths;
5144
+ hasCanvas;
5145
+ rooms = /* @__PURE__ */ new Map();
5146
+ server = new WebSocketServer({ noServer: true, maxPayload: MAX_MESSAGE });
5147
+ peers = 0;
5148
+ nextPeer() {
5149
+ this.peers += 1;
5150
+ return `p${this.peers}`;
5151
+ }
5152
+ room(canvas) {
5153
+ if (!ID_PATTERN.test(canvas) || !this.hasCanvas(canvas)) return null;
5154
+ let room = this.rooms.get(canvas);
5155
+ if (!room) {
5156
+ room = new Room(this, canvas);
5157
+ this.rooms.set(canvas, room);
5158
+ }
5159
+ return room;
5160
+ }
5161
+ /**
5162
+ * `GET /api/drawings/<canvas>` as a socket. Only the viewer's own pages may open one: the Origin
5163
+ * is this server on the viewer's host (a site elsewhere, or a frame on 127.0.0.1, is refused).
5164
+ */
5165
+ upgrade(req, socket, head, port, extraHost) {
5166
+ const refuse = (status, text2) => {
5167
+ socket.end(`HTTP/1.1 ${status} ${text2}\r
5168
+ Connection: close\r
5169
+ Content-Length: 0\r
5170
+ \r
5171
+ `);
5172
+ };
5173
+ const host = req.headers.host;
5174
+ const origin = req.headers.origin;
5175
+ if (!isAllowedHost(host, port, extraHost) || parseHost(host)?.name === FRAME_HOST || origin !== `http://${host}`)
5176
+ return refuse(403, "Forbidden");
5177
+ const url = new URL(req.url ?? "/", "http://localhost");
5178
+ let canvas = "";
5179
+ try {
5180
+ canvas = decodeURIComponent(url.pathname.slice("/api/drawings/".length));
5181
+ } catch {
5182
+ }
5183
+ const room = this.room(canvas);
5184
+ if (!room) return refuse(404, "Not Found");
5185
+ this.server.handleUpgrade(req, socket, head, (ws) => room.join(ws));
5186
+ }
5187
+ /** Every drawing of a canvas, after the cloud had a moment to answer when `.design` is linked. */
5188
+ async read(canvas) {
5189
+ const room = this.room(canvas);
5190
+ if (!room) return null;
5191
+ await room.synced(SYNC_WAIT_MS);
5192
+ return { drawings: room.drawings, cloud: room.cloudState() };
5193
+ }
5194
+ /** Erase everything in one drawing; the number of strokes erased, or null without such a canvas. */
5195
+ async clear(canvas, key) {
5196
+ const room = this.room(canvas);
5197
+ if (!room) return null;
5198
+ await room.synced(SYNC_WAIT_MS);
5199
+ const erase = (room.drawings[key]?.strokes ?? []).map((stroke) => stroke.id);
5200
+ if (!erase.length) return 0;
5201
+ room.change(key, { erase }, "cli");
5202
+ room.flush();
5203
+ return erase.length;
5204
+ }
5205
+ /** A `.drawings.json` changed on disk. */
5206
+ fileChanged(canvas) {
5207
+ this.rooms.get(canvas)?.reload();
5208
+ }
5209
+ close() {
5210
+ for (const room of this.rooms.values()) room.close();
5211
+ this.rooms.clear();
5212
+ this.server.close();
5213
+ }
5214
+ };
5215
+
4536
5216
  // src/server/events.ts
4537
5217
  var EventHub = class {
4538
5218
  clients = /* @__PURE__ */ new Set();
@@ -4566,82 +5246,63 @@ var EventHub = class {
4566
5246
  }
4567
5247
  };
4568
5248
 
4569
- // src/server/hosts.ts
4570
- var LOOPBACK = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
4571
- var FRAME_HOST = "127.0.0.1";
4572
- var VIEWER_HOST = "localhost";
4573
- function parseHost(header) {
4574
- const match = /^(\[[0-9a-f:.]+\]|[a-z0-9.-]+)(?::(\d{1,5}))?$/i.exec(header ?? "");
4575
- if (!match) return null;
4576
- return { name: match[1].toLowerCase(), port: match[2] ? Number(match[2]) : 80 };
4577
- }
4578
- function isAllowedHost(header, port, extra) {
4579
- const host = parseHost(header);
4580
- if (!host || host.port !== port) return false;
4581
- return LOOPBACK.has(host.name) || host.name.endsWith(".localhost") || !!extra && host.name === extra.toLowerCase();
4582
- }
4583
- function isFrameApi(pathname, method) {
4584
- if (pathname === "/api/health") return method === "GET";
4585
- return /^\/api\/snapshots\/[^/]+\/[^/]+$/.test(pathname) && (method === "GET" || method === "POST");
4586
- }
4587
-
4588
5249
  // src/server/state.ts
4589
- import fs28 from "fs";
5250
+ import fs29 from "fs";
4590
5251
  import os4 from "os";
4591
- import path27 from "path";
4592
- var stateFile2 = (paths) => path27.join(paths.cache, "server.json");
4593
- var logFile = (paths) => path27.join(paths.cache, "server.log");
5252
+ import path28 from "path";
5253
+ var stateFile2 = (paths) => path28.join(paths.cache, "server.json");
5254
+ var logFile = (paths) => path28.join(paths.cache, "server.log");
4594
5255
  function readState(paths) {
4595
5256
  try {
4596
- return JSON.parse(fs28.readFileSync(stateFile2(paths), "utf8"));
5257
+ return JSON.parse(fs29.readFileSync(stateFile2(paths), "utf8"));
4597
5258
  } catch {
4598
5259
  return null;
4599
5260
  }
4600
5261
  }
4601
5262
  function registryDir() {
4602
- const base = process.platform === "win32" ? process.env.LOCALAPPDATA || process.env.APPDATA || path27.join(os4.homedir(), "AppData", "Local") : process.env.XDG_CACHE_HOME || path27.join(os4.homedir(), ".cache");
4603
- return path27.join(base, "foss-design", "previews");
5263
+ const base = process.platform === "win32" ? process.env.LOCALAPPDATA || process.env.APPDATA || path28.join(os4.homedir(), "AppData", "Local") : process.env.XDG_CACHE_HOME || path28.join(os4.homedir(), ".cache");
5264
+ return path28.join(base, "foss-design", "previews");
4604
5265
  }
4605
- var entryFile = (pid) => path27.join(registryDir(), `${pid}.json`);
5266
+ var entryFile = (pid) => path28.join(registryDir(), `${pid}.json`);
4606
5267
  function writeState(paths, state) {
4607
- fs28.mkdirSync(paths.cache, { recursive: true });
4608
- fs28.writeFileSync(stateFile2(paths), `${JSON.stringify(state, null, 2)}
5268
+ fs29.mkdirSync(paths.cache, { recursive: true });
5269
+ fs29.writeFileSync(stateFile2(paths), `${JSON.stringify(state, null, 2)}
4609
5270
  `);
4610
5271
  try {
4611
- fs28.mkdirSync(registryDir(), { recursive: true });
4612
- fs28.writeFileSync(entryFile(state.pid), `${JSON.stringify(state, null, 2)}
5272
+ fs29.mkdirSync(registryDir(), { recursive: true });
5273
+ fs29.writeFileSync(entryFile(state.pid), `${JSON.stringify(state, null, 2)}
4613
5274
  `);
4614
5275
  } catch {
4615
5276
  }
4616
5277
  }
4617
5278
  function clearState(paths, pid) {
4618
5279
  const state = readState(paths);
4619
- fs28.rmSync(entryFile(pid ?? state?.pid ?? -1), { force: true });
5280
+ fs29.rmSync(entryFile(pid ?? state?.pid ?? -1), { force: true });
4620
5281
  if (pid !== void 0 && state && state.pid !== pid) return;
4621
- fs28.rmSync(stateFile2(paths), { force: true });
5282
+ fs29.rmSync(stateFile2(paths), { force: true });
4622
5283
  }
4623
5284
  function listPreviews() {
4624
5285
  let files = [];
4625
5286
  try {
4626
- files = fs28.readdirSync(registryDir()).filter((name) => name.endsWith(".json"));
5287
+ files = fs29.readdirSync(registryDir()).filter((name) => name.endsWith(".json"));
4627
5288
  } catch {
4628
5289
  return [];
4629
5290
  }
4630
5291
  const found = [];
4631
5292
  for (const name of files) {
4632
- const file = path27.join(registryDir(), name);
5293
+ const file = path28.join(registryDir(), name);
4633
5294
  let state = null;
4634
5295
  try {
4635
- state = JSON.parse(fs28.readFileSync(file, "utf8"));
5296
+ state = JSON.parse(fs29.readFileSync(file, "utf8"));
4636
5297
  } catch {
4637
5298
  }
4638
5299
  if (!state?.pid || !pidAlive(state.pid)) {
4639
- fs28.rmSync(file, { force: true });
5300
+ fs29.rmSync(file, { force: true });
4640
5301
  continue;
4641
5302
  }
4642
5303
  let recorded = null;
4643
5304
  try {
4644
- recorded = JSON.parse(fs28.readFileSync(path27.join(state.root, ".design", ".cache", "server.json"), "utf8"));
5305
+ recorded = JSON.parse(fs29.readFileSync(path28.join(state.root, ".design", ".cache", "server.json"), "utf8"));
4645
5306
  } catch {
4646
5307
  }
4647
5308
  found.push({ ...state, tracked: recorded?.pid === state.pid });
@@ -4655,11 +5316,11 @@ async function stopPreview(entry) {
4655
5316
  }
4656
5317
  const until = Date.now() + 5e3;
4657
5318
  while (Date.now() < until && pidAlive(entry.pid)) await new Promise((resolve) => setTimeout(resolve, 100));
4658
- fs28.rmSync(entryFile(entry.pid), { force: true });
4659
- const stateAt = path27.join(entry.root, ".design", ".cache", "server.json");
5319
+ fs29.rmSync(entryFile(entry.pid), { force: true });
5320
+ const stateAt = path28.join(entry.root, ".design", ".cache", "server.json");
4660
5321
  try {
4661
- if (JSON.parse(fs28.readFileSync(stateAt, "utf8")).pid === entry.pid)
4662
- fs28.rmSync(stateAt, { force: true });
5322
+ if (JSON.parse(fs29.readFileSync(stateAt, "utf8")).pid === entry.pid)
5323
+ fs29.rmSync(stateAt, { force: true });
4663
5324
  } catch {
4664
5325
  }
4665
5326
  }
@@ -4718,14 +5379,15 @@ function notFoundPage(res, title, text2) {
4718
5379
  }
4719
5380
  async function startDevServer(options) {
4720
5381
  const paths = designPaths(options.root);
4721
- if (!fs29.existsSync(paths.design)) throw new Error(`No .design folder in ${options.root}; run \`design init\``);
4722
- fs29.mkdirSync(paths.cache, { recursive: true });
5382
+ if (!fs30.existsSync(paths.design)) throw new Error(`No .design folder in ${options.root}; run \`design init\``);
5383
+ fs30.mkdirSync(paths.cache, { recursive: true });
4723
5384
  const snapshots = new SnapshotStore(paths.snapshots);
4724
5385
  const project = new DesignProject(paths, DEV_URLS, snapshots.lookup);
4725
5386
  const entries = new Entries(project);
4726
5387
  entries.sync();
4727
5388
  const links = linkShippedPackages(paths, project.appDir());
4728
5389
  const events = new EventHub();
5390
+ const drawings = new DrawingHub(paths, (id2) => project.canvasIds().includes(id2));
4729
5391
  const httpServer = http2.createServer();
4730
5392
  const listening = () => httpServer.address()?.port ?? 0;
4731
5393
  dropStaleDepCache(paths, project.appDir());
@@ -4746,8 +5408,8 @@ async function startDevServer(options) {
4746
5408
  // `.cache` is ours (server.log, snapshots, builds): a change there must not reload frames. The
4747
5409
  // generated entries are modules the frames load, so those stay watched.
4748
5410
  ignored: (file) => {
4749
- const rel = toPosix(path28.relative(paths.cache, file));
4750
- const inCache = rel !== "" && !rel.startsWith("..") && !path28.isAbsolute(rel);
5411
+ const rel = toPosix(path29.relative(paths.cache, file));
5412
+ const inCache = rel !== "" && !rel.startsWith("..") && !path29.isAbsolute(rel);
4751
5413
  return inCache && rel !== "entries" && !rel.startsWith("entries/");
4752
5414
  }
4753
5415
  },
@@ -4756,7 +5418,7 @@ async function startDevServer(options) {
4756
5418
  forwardConsole: false
4757
5419
  },
4758
5420
  optimizeDeps: {
4759
- entries: entries.files().map((file) => toPosix(path28.relative(paths.design, file)))
5421
+ entries: entries.files().map((file) => toPosix(path29.relative(paths.design, file)))
4760
5422
  }
4761
5423
  });
4762
5424
  const publicDir = project.publicDir();
@@ -4768,21 +5430,21 @@ async function startDevServer(options) {
4768
5430
  return null;
4769
5431
  }
4770
5432
  };
4771
- const viewer = fs29.existsSync(PKG.viewer) ? sirv2(PKG.viewer, { dev: true, etag: true }) : null;
4772
- const viewerIndex = path28.join(PKG.viewer, "index.html");
5433
+ const viewer = fs30.existsSync(PKG.viewer) ? sirv2(PKG.viewer, { dev: true, etag: true }) : null;
5434
+ const viewerIndex = path29.join(PKG.viewer, "index.html");
4773
5435
  const serveViewerIndex = (req, res, url2) => {
4774
5436
  if (parseHost(req.headers.host)?.name === FRAME_HOST) {
4775
5437
  res.writeHead(302, { location: `http://${VIEWER_HOST}:${listening()}${url2.pathname}${url2.search}` });
4776
5438
  res.end();
4777
5439
  return;
4778
5440
  }
4779
- const html = fs29.readFileSync(viewerIndex, "utf8").replace(/<head>/i, '<head>\n<base href="/">');
5441
+ const html = fs30.readFileSync(viewerIndex, "utf8").replace(/<head>/i, '<head>\n<base href="/">');
4780
5442
  res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
4781
5443
  res.end(html);
4782
5444
  };
4783
- const ctx = { project, events, snapshots };
5445
+ const ctx = { project, events, snapshots, drawings };
4784
5446
  const systemCssUrl = `/${relToDesign(paths, entries.systemCssFile())}`;
4785
- const bootUrl = `/${relToDesign(paths, path28.join(entries.dir, HTML_BOOT))}`;
5447
+ const bootUrl = `/${relToDesign(paths, path29.join(entries.dir, HTML_BOOT))}`;
4786
5448
  const frameHead = (source, extra = {}) => {
4787
5449
  const canvasTheme = source.canvas === SYSTEM_CANVAS ? void 0 : project.canvas(source.canvas)?.doc.theme;
4788
5450
  return {
@@ -4803,7 +5465,7 @@ async function startDevServer(options) {
4803
5465
  notFoundPage(res, "Screen not found", `No screen "${id2}" in "${canvas}". Check canvas.json.`);
4804
5466
  return;
4805
5467
  }
4806
- if (!fs29.existsSync(entries.entryFile(source))) entries.sync();
5468
+ if (!fs30.existsSync(entries.entryFile(source))) entries.sync();
4807
5469
  const entryUrl = `/${relToDesign(paths, entries.entryFile(source))}`;
4808
5470
  const html = await vite.transformIndexHtml(url2.pathname + url2.search, moduleShell(frameHead(source), entryUrl));
4809
5471
  res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
@@ -4812,8 +5474,8 @@ async function startDevServer(options) {
4812
5474
  };
4813
5475
  const serveHtmlScreen = async (res, url2) => {
4814
5476
  const rel = decodeURIComponent(url2.pathname.slice("/_fs/".length));
4815
- const file = path28.resolve(paths.design, rel);
4816
- if (!isInside(paths.design, file) || !fs29.existsSync(file)) {
5477
+ const file = path29.resolve(paths.design, rel);
5478
+ if (!isInside(paths.design, file) || !fs30.existsSync(file)) {
4817
5479
  notFoundPage(res, "Screen not found", `No file .design/${rel}`);
4818
5480
  return;
4819
5481
  }
@@ -4823,13 +5485,13 @@ async function startDevServer(options) {
4823
5485
  key: `${canvas}/${id2}`,
4824
5486
  canvas,
4825
5487
  id: id2,
4826
- title: path28.basename(file),
5488
+ title: path29.basename(file),
4827
5489
  file,
4828
5490
  format: "html",
4829
5491
  props: {},
4830
5492
  system: true
4831
5493
  };
4832
- const raw = fs29.readFileSync(file, "utf8");
5494
+ const raw = fs30.readFileSync(file, "utf8");
4833
5495
  const withSystem = source.system && !optsOutOfSystem(raw);
4834
5496
  const html = injectIntoHtml(
4835
5497
  raw,
@@ -4870,7 +5532,7 @@ async function startDevServer(options) {
4870
5532
  }
4871
5533
  if (url2.pathname.startsWith("/_fs/")) {
4872
5534
  const rel = publicRel(url2.pathname.slice("/_fs/".length));
4873
- if (publicDir && rel !== null && !project.isPublicFile(rel) && fs29.existsSync(path28.join(publicDir, rel))) {
5535
+ if (publicDir && rel !== null && !project.isPublicFile(rel) && fs30.existsSync(path29.join(publicDir, rel))) {
4874
5536
  notFoundPage(res, "Not found", url2.pathname);
4875
5537
  return;
4876
5538
  }
@@ -4882,7 +5544,7 @@ async function startDevServer(options) {
4882
5544
  const rel = publicRel(url2.pathname.slice(1));
4883
5545
  if (servePublic && rel !== null && project.isPublicFile(rel))
4884
5546
  servePublic(req, res, () => notFoundPage(res, "Not found", url2.pathname));
4885
- else if (path28.extname(url2.pathname)) notFoundPage(res, "Not found", url2.pathname);
5547
+ else if (path29.extname(url2.pathname)) notFoundPage(res, "Not found", url2.pathname);
4886
5548
  else serveViewerIndex(req, res, url2);
4887
5549
  };
4888
5550
  if (url2.pathname === "/" || url2.pathname === "/index.html") serveViewerIndex(req, res, url2);
@@ -4892,6 +5554,11 @@ async function startDevServer(options) {
4892
5554
  fail(error);
4893
5555
  }
4894
5556
  });
5557
+ httpServer.on("upgrade", (req, socket, head) => {
5558
+ const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
5559
+ if (pathname.startsWith("/api/drawings/")) drawings.upgrade(req, socket, head, listening(), options.host);
5560
+ else if (pathname.startsWith("/api/")) socket.destroy();
5561
+ });
4895
5562
  const pending = /* @__PURE__ */ new Map();
4896
5563
  let flushTimer;
4897
5564
  const queue = (key, run) => {
@@ -4910,7 +5577,7 @@ async function startDevServer(options) {
4910
5577
  };
4911
5578
  vite.watcher.on("all", (event, file) => {
4912
5579
  if (publicDir && isInside(publicDir, file)) project.invalidatePublic();
4913
- const rel = toPosix(path28.relative(paths.design, file));
5580
+ const rel = toPosix(path29.relative(paths.design, file));
4914
5581
  if (rel.startsWith("..") || rel.startsWith(".cache/") || rel.startsWith("node_modules/")) return;
4915
5582
  if (rel === "design.json") {
4916
5583
  queue("config", () => {
@@ -4926,6 +5593,10 @@ async function startDevServer(options) {
4926
5593
  } else if (rel.startsWith("canvas/")) {
4927
5594
  const [, id2, ...rest] = rel.split("/");
4928
5595
  if (!id2) return;
5596
+ if (rest.join("/") === ".drawings.json") {
5597
+ drawings.fileChanged(id2);
5598
+ return;
5599
+ }
4929
5600
  const structural = event !== "change" || rest.join("/") === "canvas.json";
4930
5601
  if (!structural) return;
4931
5602
  queue(`canvas:${id2}`, () => {
@@ -4957,6 +5628,7 @@ async function startDevServer(options) {
4957
5628
  if (closed) return;
4958
5629
  closed = true;
4959
5630
  events.close();
5631
+ drawings.close();
4960
5632
  await vite.close();
4961
5633
  await new Promise((resolve) => {
4962
5634
  httpServer.close(() => resolve());
@@ -5021,8 +5693,8 @@ async function ensureServer(paths, options) {
5021
5693
  const recorded = readState(paths);
5022
5694
  if (recorded && pidAlive(recorded.pid)) await stopPreview(recorded);
5023
5695
  for (const other of listPreviews()) if (other.root === paths.root) await stopPreview(other);
5024
- fs30.mkdirSync(paths.cache, { recursive: true });
5025
- const log = fs30.openSync(logFile(paths), "w");
5696
+ fs31.mkdirSync(paths.cache, { recursive: true });
5697
+ const log = fs31.openSync(logFile(paths), "w");
5026
5698
  const args = [PKG.cli, "preview", "--foreground", "--root", paths.root];
5027
5699
  if (options.port) args.push("--port", String(options.port));
5028
5700
  const child = spawn(process.execPath, args, {
@@ -5034,7 +5706,7 @@ async function ensureServer(paths, options) {
5034
5706
  child.unref();
5035
5707
  const state = await waitForServer(paths, 4e4);
5036
5708
  if (!state) {
5037
- const tail = fs30.existsSync(logFile(paths)) ? fs30.readFileSync(logFile(paths), "utf8").split("\n").slice(-25) : [];
5709
+ const tail = fs31.existsSync(logFile(paths)) ? fs31.readFileSync(logFile(paths), "utf8").split("\n").slice(-25) : [];
5038
5710
  throw new CliError(`The preview server did not start.
5039
5711
  ${tail.join("\n")}`);
5040
5712
  }
@@ -5653,9 +6325,9 @@ function printLinkIssues(issues, project) {
5653
6325
  }
5654
6326
 
5655
6327
  // src/cli/commands/cloud.ts
5656
- import fs31 from "fs";
6328
+ import fs32 from "fs";
5657
6329
  import os5 from "os";
5658
- import path29 from "path";
6330
+ import path30 from "path";
5659
6331
  var sleep2 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
5660
6332
  async function runLogin() {
5661
6333
  const host = cloudHost();
@@ -5756,7 +6428,7 @@ async function runLink(paths, wanted, options) {
5756
6428
  print(`${dim("Already linked to")} ${project.name} ${dim(`(${project.id}, ${project.role})`)}`);
5757
6429
  } else {
5758
6430
  writeLink(paths, { host, project: project.id, units: {}, conflicts: {} });
5759
- fs31.rmSync(path29.join(cloudCache(paths), "incoming"), { recursive: true, force: true });
6431
+ fs32.rmSync(path30.join(cloudCache(paths), "incoming"), { recursive: true, force: true });
5760
6432
  const replaced = current ? dim(` (replacing the link to ${current.project})`) : "";
5761
6433
  print(`${green("linked")} .design \u2192 ${bold(project.name)} ${dim(`(${project.id}, ${project.role})`)}${replaced}`);
5762
6434
  }
@@ -5782,9 +6454,120 @@ async function runLink(paths, wanted, options) {
5782
6454
  else if (remote) print(dim("Everything is in sync."));
5783
6455
  }
5784
6456
 
6457
+ // src/cli/commands/drawings.ts
6458
+ import http3 from "http";
6459
+ function call(port, method, path39) {
6460
+ return new Promise((resolve, reject) => {
6461
+ const req = http3.request(
6462
+ { host: "127.0.0.1", port, method, path: path39, headers: { host: `localhost:${port}` }, timeout: 3e4 },
6463
+ (res) => {
6464
+ const chunks = [];
6465
+ res.on("data", (chunk2) => chunks.push(chunk2));
6466
+ res.on("end", () => {
6467
+ let body = null;
6468
+ try {
6469
+ body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
6470
+ } catch {
6471
+ }
6472
+ resolve({ status: res.statusCode ?? 0, body });
6473
+ });
6474
+ }
6475
+ );
6476
+ req.on("timeout", () => req.destroy(new Error("the preview server did not answer")));
6477
+ req.on("error", reject);
6478
+ req.end();
6479
+ });
6480
+ }
6481
+ async function previewPort(paths, quiet = false) {
6482
+ const wasRunning = await liveServer(paths);
6483
+ const server = await ensureServer(paths, {});
6484
+ if (!wasRunning && !quiet) print(dim(`Started the preview server at ${server.url} (\`design stop\` stops it).`));
6485
+ return server.port;
6486
+ }
6487
+ async function readDrawings(port, canvas) {
6488
+ const { status, body } = await call(port, "GET", `/api/drawings/${encodeURIComponent(canvas)}`);
6489
+ if (status !== 200) throw new CliError(body?.error ?? `No canvas "${canvas}"`);
6490
+ const data = body;
6491
+ return { drawings: parseDrawings(data.drawings), cloud: data.cloud ?? "unlinked" };
6492
+ }
6493
+ function drawn(drawings, prefix) {
6494
+ const found = /* @__PURE__ */ new Map();
6495
+ for (const [key, drawing] of Object.entries(drawings))
6496
+ if (key.startsWith(`${prefix}:`) && drawing.strokes.length) found.set(key.slice(prefix.length + 1), drawing);
6497
+ return found;
6498
+ }
6499
+ var texts = (drawing) => drawing.strokes.flatMap((stroke) => stroke.kind === "text" && stroke.text ? [stroke.text] : []);
6500
+ async function runDrawings(paths, target, options) {
6501
+ const project = new DesignProject(paths, DEV_URLS);
6502
+ const [canvasId, screenId] = (target ?? "").split("/");
6503
+ const known = project.canvasIds();
6504
+ if (canvasId && !known.includes(canvasId))
6505
+ throw new CliError(`No canvas "${canvasId}". Canvases: ${known.join(", ") || "none yet"}`);
6506
+ if (options.clear && !screenId) throw new CliError("Usage: design drawings <canvas>/<screen> --clear");
6507
+ const port = await previewPort(paths, options.json);
6508
+ if (options.clear) {
6509
+ const { status, body } = await call(
6510
+ port,
6511
+ "DELETE",
6512
+ `/api/drawings/${encodeURIComponent(canvasId)}/${encodeURIComponent(markupKey(screenId))}`
6513
+ );
6514
+ if (status !== 200) throw new CliError(body?.error ?? `Could not erase (${status})`);
6515
+ const erased = body.erased ?? 0;
6516
+ if (options.json) print(JSON.stringify({ canvas: canvasId, screen: screenId, erased }));
6517
+ else
6518
+ print(
6519
+ erased ? `Erased the markup on ${canvasId}/${screenId} (${erased} strokes).` : `No markup on ${canvasId}/${screenId}.`
6520
+ );
6521
+ return;
6522
+ }
6523
+ const report = [];
6524
+ for (const id2 of canvasId ? [canvasId] : known) {
6525
+ const { drawings, cloud: cloud2 } = await readDrawings(port, id2);
6526
+ const doc = project.canvas(id2)?.doc;
6527
+ const pageTitle = (page) => doc?.pages.find((p) => p.id === page)?.title;
6528
+ const itemTitle = (item) => doc?.pages.flatMap((p) => p.sections.flatMap((s) => s.items)).find((i) => i.id === item)?.title;
6529
+ const boards = [...drawn(drawings, "board")].map(([page, drawing]) => ({
6530
+ page,
6531
+ title: pageTitle(page),
6532
+ strokes: drawing.strokes.length,
6533
+ groups: drawingGroups(drawing.strokes).length,
6534
+ texts: texts(drawing)
6535
+ }));
6536
+ const markup = [...drawn(drawings, "markup")].filter(([screen]) => !screenId || screen === screenId).map(([screen, drawing]) => ({
6537
+ screen,
6538
+ title: itemTitle(screen),
6539
+ strokes: drawing.strokes.length,
6540
+ texts: texts(drawing)
6541
+ }));
6542
+ report.push({ canvas: id2, cloud: cloud2, boards: screenId ? [] : boards, markup });
6543
+ }
6544
+ if (options.json) {
6545
+ print(JSON.stringify({ canvases: report }, null, 2));
6546
+ return;
6547
+ }
6548
+ const quote = (text2) => `\u201C${text2.replace(/\s*\n\s*/g, " / ").slice(0, 120)}\u201D`;
6549
+ let any = false;
6550
+ for (const entry of report) {
6551
+ if (!entry.boards.length && !entry.markup.length) continue;
6552
+ any = true;
6553
+ const cloud2 = entry.cloud === "synced" ? dim(" \xB7 in step with the cloud") : entry.cloud === "unlinked" ? "" : dim(" \xB7 the cloud did not answer: drawings on this machine only");
6554
+ print(`${bold(entry.canvas)}${cloud2}`);
6555
+ for (const board of entry.boards)
6556
+ print(
6557
+ ` board ${board.page}${board.title ? dim(` (${board.title})`) : ""} \xB7 ${board.strokes} strokes${board.groups > 1 ? ` in ${board.groups} groups` : ""}${board.texts.length ? ` \xB7 ${board.texts.map(quote).join(", ")}` : ""}`
6558
+ );
6559
+ for (const mark of entry.markup)
6560
+ print(
6561
+ ` markup ${mark.screen}${mark.title ? dim(` (${mark.title})`) : ""} \xB7 ${mark.strokes} strokes${mark.texts.length ? ` \xB7 ${mark.texts.map(quote).join(", ")}` : ""}`
6562
+ );
6563
+ }
6564
+ if (!any) print(canvasId ? `No drawings on ${target}.` : "No drawings yet.");
6565
+ else print(dim("Pictures: design shot <canvas> --board | design shot <canvas>[/<screen>] --markup"));
6566
+ }
6567
+
5785
6568
  // src/cli/commands/icon.ts
5786
- import fs32 from "fs";
5787
- import path30 from "path";
6569
+ import fs33 from "fs";
6570
+ import path31 from "path";
5788
6571
  async function toCloud(paths, file) {
5789
6572
  const link = readLink(paths);
5790
6573
  if (!link) return;
@@ -5795,10 +6578,10 @@ async function toCloud(paths, file) {
5795
6578
  }
5796
6579
  const client = new CloudClient(link.host, credential.token);
5797
6580
  try {
5798
- const ext = file ? path30.extname(file).slice(1) : null;
6581
+ const ext = file ? path31.extname(file).slice(1) : null;
5799
6582
  await client.setProjectIcon(
5800
6583
  link.project,
5801
- file && ext ? { data: fs32.readFileSync(file), type: ICON_TYPES[ext] } : null
6584
+ file && ext ? { data: fs33.readFileSync(file), type: ICON_TYPES[ext] } : null
5802
6585
  );
5803
6586
  link.icon = file ? iconHash(file) : null;
5804
6587
  writeLink(paths, link);
@@ -5811,35 +6594,35 @@ async function runIcon(paths, file, options) {
5811
6594
  if (options.remove) {
5812
6595
  const had = iconFile(paths);
5813
6596
  writeIcon(paths, null);
5814
- print(had ? `${green("Removed")} ${path30.relative(process.cwd(), had)}` : "The project has no icon.");
6597
+ print(had ? `${green("Removed")} ${path31.relative(process.cwd(), had)}` : "The project has no icon.");
5815
6598
  await toCloud(paths, null);
5816
6599
  return;
5817
6600
  }
5818
6601
  if (!file) {
5819
6602
  const current = iconFile(paths);
5820
6603
  if (!current) return print("The project has no icon. Set one with `design icon <file.svg|png|webp>`.");
5821
- return print(`${path30.relative(process.cwd(), current)} ${dim(`${Math.ceil(fs32.statSync(current).size / 1024)} KB`)}`);
6604
+ return print(`${path31.relative(process.cwd(), current)} ${dim(`${Math.ceil(fs33.statSync(current).size / 1024)} KB`)}`);
5822
6605
  }
5823
6606
  let data;
5824
6607
  try {
5825
- data = fs32.readFileSync(file);
6608
+ data = fs33.readFileSync(file);
5826
6609
  } catch {
5827
6610
  throw new CliError(`No file ${file}`);
5828
6611
  }
5829
- const problem = iconProblem(data, path30.extname(file).slice(1).toLowerCase() || void 0);
6612
+ const problem = iconProblem(data, path31.extname(file).slice(1).toLowerCase() || void 0);
5830
6613
  if (problem) throw new CliError(`${file}: ${problem}`);
5831
6614
  const written = writeIcon(paths, data);
5832
- print(`${green("Set")} the project icon: ${path30.relative(process.cwd(), written)}`);
6615
+ print(`${green("Set")} the project icon: ${path31.relative(process.cwd(), written)}`);
5833
6616
  await toCloud(paths, written);
5834
6617
  }
5835
6618
 
5836
6619
  // src/cli/commands/import.ts
5837
- import fs34 from "fs";
5838
- import path32 from "path";
6620
+ import fs35 from "fs";
6621
+ import path33 from "path";
5839
6622
 
5840
6623
  // src/import/claude-design.ts
5841
- import fs33 from "fs";
5842
- import path31 from "path";
6624
+ import fs34 from "fs";
6625
+ import path32 from "path";
5843
6626
 
5844
6627
  // src/import/markup.ts
5845
6628
  var VOID = /* @__PURE__ */ new Set([
@@ -6447,11 +7230,11 @@ function boardId(file, used) {
6447
7230
  return id2;
6448
7231
  }
6449
7232
  function importClaudeDesign(dir, options = {}) {
6450
- const canvasFile = path31.join(dir, "canvas.json");
6451
- if (!fs33.existsSync(canvasFile))
7233
+ const canvasFile = path32.join(dir, "canvas.json");
7234
+ if (!fs34.existsSync(canvasFile))
6452
7235
  throw new Error(`No canvas.json in ${dir}: point at the exported Claude Design project folder`);
6453
- const source = JSON.parse(fs33.readFileSync(canvasFile, "utf8"));
6454
- const templates = fs33.readdirSync(dir).filter((file) => file.endsWith(EXT));
7236
+ const source = JSON.parse(fs34.readFileSync(canvasFile, "utf8"));
7237
+ const templates = fs34.readdirSync(dir).filter((file) => file.endsWith(EXT));
6455
7238
  if (!templates.length) throw new Error(`No ${EXT} files in ${dir}`);
6456
7239
  const known = new Set(templates.map((file) => file.slice(0, -EXT.length)));
6457
7240
  const warnings = /* @__PURE__ */ new Map();
@@ -6478,7 +7261,7 @@ function importClaudeDesign(dir, options = {}) {
6478
7261
  try {
6479
7262
  files.set(
6480
7263
  `${name}.jsx`,
6481
- convertFile(name, fs33.readFileSync(path31.join(dir, file), "utf8"), { known, routes, warn: warn2 })
7264
+ convertFile(name, fs34.readFileSync(path32.join(dir, file), "utf8"), { known, routes, warn: warn2 })
6482
7265
  );
6483
7266
  } catch (error) {
6484
7267
  warn2(`could not be converted: ${error.message}`);
@@ -6526,7 +7309,7 @@ function importClaudeDesign(dir, options = {}) {
6526
7309
  screens++;
6527
7310
  }
6528
7311
  const canvas = {
6529
- title: options.title ?? source.title ?? path31.basename(path31.resolve(dir)),
7312
+ title: options.title ?? source.title ?? path32.basename(path32.resolve(dir)),
6530
7313
  description: "Imported from Claude Design.",
6531
7314
  // The templates bring their own styles; the design system's reset would restyle them.
6532
7315
  system: false,
@@ -6538,21 +7321,21 @@ function importClaudeDesign(dir, options = {}) {
6538
7321
  // src/cli/commands/import.ts
6539
7322
  var slug = (text2) => text2.normalize("NFKD").replace(/[^\w\s-]/g, "").trim().replace(/[\s_]+/g, "-").toLowerCase();
6540
7323
  function runImport(paths, from, options) {
6541
- const dir = path32.resolve(from);
6542
- if (!fs34.existsSync(dir) || !fs34.statSync(dir).isDirectory()) throw new CliError(`No folder ${from}`);
7324
+ const dir = path33.resolve(from);
7325
+ if (!fs35.existsSync(dir) || !fs35.statSync(dir).isDirectory()) throw new CliError(`No folder ${from}`);
6543
7326
  let result;
6544
7327
  try {
6545
7328
  result = importClaudeDesign(dir, { title: options.title });
6546
7329
  } catch (error) {
6547
7330
  throw new CliError(error.message);
6548
7331
  }
6549
- const id2 = options.canvas ?? (slug(String(result.canvas.title)) || slug(path32.basename(dir)) || "imported");
7332
+ const id2 = options.canvas ?? (slug(String(result.canvas.title)) || slug(path33.basename(dir)) || "imported");
6550
7333
  if (!ID_PATTERN.test(id2)) throw new CliError(`"${id2}" is no canvas id; pass one with --canvas <id>`);
6551
- const out = path32.join(paths.canvases, id2);
6552
- if (fs34.existsSync(out)) throw new CliError(`.design/canvas/${id2} exists; pick another id with --canvas <id>`);
6553
- fs34.mkdirSync(path32.join(out, "screens"), { recursive: true });
6554
- for (const [name, content] of result.files) fs34.writeFileSync(path32.join(out, "screens", name), content);
6555
- fs34.writeFileSync(path32.join(out, "canvas.json"), `${JSON.stringify(result.canvas, null, 2)}
7334
+ const out = path33.join(paths.canvases, id2);
7335
+ if (fs35.existsSync(out)) throw new CliError(`.design/canvas/${id2} exists; pick another id with --canvas <id>`);
7336
+ fs35.mkdirSync(path33.join(out, "screens"), { recursive: true });
7337
+ for (const [name, content] of result.files) fs35.writeFileSync(path33.join(out, "screens", name), content);
7338
+ fs35.writeFileSync(path33.join(out, "canvas.json"), `${JSON.stringify(result.canvas, null, 2)}
6556
7339
  `);
6557
7340
  print(`${green("Imported")} ${bold(String(result.canvas.title))} \u2192 .design/canvas/${id2}`);
6558
7341
  print(
@@ -6572,7 +7355,7 @@ function runImport(paths, from, options) {
6572
7355
 
6573
7356
  // src/cli/commands/merge.ts
6574
7357
  import { spawnSync as spawnSync2 } from "child_process";
6575
- import path33 from "path";
7358
+ import path34 from "path";
6576
7359
  import { createInterface } from "readline/promises";
6577
7360
  var KIND = {
6578
7361
  text: "both sides changed lines, the markers show them",
@@ -6588,7 +7371,7 @@ function matchTargets(paths, states, targets2) {
6588
7371
  for (const conflict of openConflicts(paths, unit)) picked.push({ state: unit, conflict });
6589
7372
  continue;
6590
7373
  }
6591
- const rel = [path33.relative(paths.design, path33.resolve(target)), target.replace(/^\.design\//, "")];
7374
+ const rel = [path34.relative(paths.design, path34.resolve(target)), target.replace(/^\.design\//, "")];
6592
7375
  const found = states.flatMap(
6593
7376
  (state) => state.conflicts.filter((item) => rel.includes(item.path)).map((conflict) => ({ state, conflict }))
6594
7377
  );
@@ -6604,7 +7387,7 @@ function printOpen(paths, states) {
6604
7387
  print(`${bold(state.unit)} ${dim(`merged with cloud r${state.headRev}`)}`);
6605
7388
  for (const conflict of open2) {
6606
7389
  print(` ${red("\u2717")} ${conflict.path} ${dim(KIND[conflict.kind])}`);
6607
- const sides = ["here", "cloud", "base"].map((side) => `${side}: ${path33.relative(process.cwd(), sideFile(paths, state.unit, conflict.path, side))}`).join(" \xB7 ");
7390
+ const sides = ["here", "cloud", "base"].map((side) => `${side}: ${path34.relative(process.cwd(), sideFile(paths, state.unit, conflict.path, side))}`).join(" \xB7 ");
6608
7391
  print(` ${dim(sides)}`);
6609
7392
  }
6610
7393
  }
@@ -6685,7 +7468,7 @@ async function runMerge(paths, targets2, options) {
6685
7468
  if (answer === "s") break;
6686
7469
  if (answer === "e" && edit) {
6687
7470
  const editor = process.env.VISUAL || process.env.EDITOR || "vi";
6688
- spawnSync2(editor, [path33.join(paths.design, conflict.path)], { stdio: "inherit", shell: true });
7471
+ spawnSync2(editor, [path34.join(paths.design, conflict.path)], { stdio: "inherit", shell: true });
6689
7472
  if (!openConflicts(paths, state).some((item) => item.path === conflict.path)) break;
6690
7473
  print(yellow(" Conflict markers are still there."));
6691
7474
  }
@@ -6704,7 +7487,7 @@ async function runMerge(paths, targets2, options) {
6704
7487
  }
6705
7488
 
6706
7489
  // src/cli/commands/rename.ts
6707
- import fs35 from "fs";
7490
+ import fs36 from "fs";
6708
7491
  async function runRename(paths, name) {
6709
7492
  const trimmed = name.trim();
6710
7493
  if (!trimmed) throw new CliError("Usage: design rename <name>");
@@ -6719,17 +7502,17 @@ async function runRename(paths, name) {
6719
7502
  }
6720
7503
  let config = {};
6721
7504
  try {
6722
- config = JSON.parse(fs35.readFileSync(paths.config, "utf8"));
7505
+ config = JSON.parse(fs36.readFileSync(paths.config, "utf8"));
6723
7506
  } catch {
6724
7507
  }
6725
- fs35.writeFileSync(paths.config, `${JSON.stringify({ ...config, name: trimmed }, null, 2)}
7508
+ fs36.writeFileSync(paths.config, `${JSON.stringify({ ...config, name: trimmed }, null, 2)}
6726
7509
  `);
6727
7510
  print(`${green("Renamed")} the project to ${trimmed} (.design/design.json)`);
6728
7511
  }
6729
7512
 
6730
7513
  // src/cli/commands/scaffold.ts
6731
- import fs36 from "fs";
6732
- import path34 from "path";
7514
+ import fs37 from "fs";
7515
+ import path35 from "path";
6733
7516
 
6734
7517
  // src/cli/templates.ts
6735
7518
  var TOKENS_CSS = `/*
@@ -6940,40 +7723,40 @@ function mainScreen(canvasId) {
6940
7723
 
6941
7724
  // src/cli/commands/scaffold.ts
6942
7725
  function write(file, content, root) {
6943
- if (fs36.existsSync(file)) {
6944
- print(` ${dim("exists ")} ${path34.relative(root, file)}`);
7726
+ if (fs37.existsSync(file)) {
7727
+ print(` ${dim("exists ")} ${path35.relative(root, file)}`);
6945
7728
  return false;
6946
7729
  }
6947
- fs36.mkdirSync(path34.dirname(file), { recursive: true });
6948
- fs36.writeFileSync(file, content);
6949
- print(` ${green("created")} ${path34.relative(root, file)}`);
7730
+ fs37.mkdirSync(path35.dirname(file), { recursive: true });
7731
+ fs37.writeFileSync(file, content);
7732
+ print(` ${green("created")} ${path35.relative(root, file)}`);
6950
7733
  return true;
6951
7734
  }
6952
7735
  function ensureGitignored(root) {
6953
- const file = path34.join(root, ".gitignore");
6954
- const current = fs36.existsSync(file) ? fs36.readFileSync(file, "utf8") : "";
7736
+ const file = path35.join(root, ".gitignore");
7737
+ const current = fs37.existsSync(file) ? fs37.readFileSync(file, "utf8") : "";
6955
7738
  const covered = current.split(/\r?\n/).map((line) => line.trim()).some((line) => [".design", ".design/", "/.design", "/.design/", ".design/*"].includes(line));
6956
7739
  if (covered) return "present";
6957
7740
  const prefix = current && !current.endsWith("\n") ? "\n" : "";
6958
- fs36.writeFileSync(file, `${current}${prefix}${current ? "\n" : ""}# Design canvases (foss-design)
7741
+ fs37.writeFileSync(file, `${current}${prefix}${current ? "\n" : ""}# Design canvases (foss-design)
6959
7742
  .design/
6960
7743
  `);
6961
7744
  return "added";
6962
7745
  }
6963
7746
  function projectName(root) {
6964
7747
  try {
6965
- const manifest = JSON.parse(fs36.readFileSync(path34.join(root, "package.json"), "utf8"));
7748
+ const manifest = JSON.parse(fs37.readFileSync(path35.join(root, "package.json"), "utf8"));
6966
7749
  if (typeof manifest.name === "string" && manifest.name) return manifest.name;
6967
7750
  } catch {
6968
7751
  }
6969
- return path34.basename(root);
7752
+ return path35.basename(root);
6970
7753
  }
6971
7754
  function runInit(root, options) {
6972
7755
  const paths = designPaths(root);
6973
7756
  print(`Setting up ${DESIGN_DIR} in ${root}`);
6974
7757
  write(paths.config, `${JSON.stringify({ name: options.name ?? projectName(root) }, null, 2)}
6975
7758
  `, root);
6976
- fs36.mkdirSync(paths.canvases, { recursive: true });
7759
+ fs37.mkdirSync(paths.canvases, { recursive: true });
6977
7760
  if (options.gitignore) {
6978
7761
  const result = ensureGitignored(root);
6979
7762
  print(` ${result === "added" ? green("ignored") : dim("ignored")} .design/ in .gitignore`);
@@ -6987,49 +7770,59 @@ function runSystemInit(root, options) {
6987
7770
  const paths = designPaths(root);
6988
7771
  let base = projectName(root);
6989
7772
  try {
6990
- base = JSON.parse(fs36.readFileSync(paths.config, "utf8")).name || base;
7773
+ base = JSON.parse(fs37.readFileSync(paths.config, "utf8")).name || base;
6991
7774
  } catch {
6992
7775
  }
6993
7776
  const name = options.name ?? `${base} design system`;
6994
- print(`Scaffolding the design system in ${path34.relative(root, paths.system)}`);
6995
- write(path34.join(paths.system, "system.json"), `${JSON.stringify({ name }, null, 2)}
7777
+ print(`Scaffolding the design system in ${path35.relative(root, paths.system)}`);
7778
+ write(path35.join(paths.system, "system.json"), `${JSON.stringify({ name }, null, 2)}
6996
7779
  `, root);
6997
- write(path34.join(paths.system, "tokens.css"), TOKENS_CSS, root);
7780
+ write(path35.join(paths.system, "tokens.css"), TOKENS_CSS, root);
6998
7781
  if (!options.empty) {
6999
- write(path34.join(paths.system, "guidelines", "01-overview.md"), OVERVIEW_MD, root);
7000
- write(path34.join(paths.system, "components", "button.tsx"), BUTTON_TSX, root);
7001
- write(path34.join(paths.system, "specimens", "button.tsx"), BUTTON_SPECIMEN_TSX, root);
7782
+ write(path35.join(paths.system, "guidelines", "01-overview.md"), OVERVIEW_MD, root);
7783
+ write(path35.join(paths.system, "components", "button.tsx"), BUTTON_TSX, root);
7784
+ write(path35.join(paths.system, "specimens", "button.tsx"), BUTTON_SPECIMEN_TSX, root);
7002
7785
  }
7003
7786
  }
7004
7787
  function runNew(root, id2, options) {
7005
7788
  if (!ID_PATTERN.test(id2)) throw new CliError(`"${id2}" is not a valid canvas id: use letters, digits, "-" and "_"`);
7006
7789
  const paths = designPaths(root);
7007
- const dir = path34.join(paths.canvases, id2);
7008
- if (fs36.existsSync(path34.join(dir, "canvas.json"))) throw new CliError(`Canvas "${id2}" already exists in ${dir}`);
7790
+ const dir = path35.join(paths.canvases, id2);
7791
+ if (fs37.existsSync(path35.join(dir, "canvas.json"))) throw new CliError(`Canvas "${id2}" already exists in ${dir}`);
7009
7792
  print(`Creating canvas "${id2}"`);
7010
7793
  const title = options.title ?? titleize(id2);
7011
7794
  if (options.empty) {
7012
7795
  write(
7013
- path34.join(dir, "canvas.json"),
7796
+ path35.join(dir, "canvas.json"),
7014
7797
  `${JSON.stringify({ title, pages: [{ id: "main", title: "Main", sections: [] }] }, null, 2)}
7015
7798
  `,
7016
7799
  root
7017
7800
  );
7018
- fs36.mkdirSync(path34.join(dir, "screens"), { recursive: true });
7801
+ fs37.mkdirSync(path35.join(dir, "screens"), { recursive: true });
7019
7802
  } else {
7020
- write(path34.join(dir, "canvas.json"), canvasJson(title), root);
7021
- write(path34.join(dir, "screens", "Main.tsx"), mainScreen(id2), root);
7803
+ write(path35.join(dir, "canvas.json"), canvasJson(title), root);
7804
+ write(path35.join(dir, "screens", "Main.tsx"), mainScreen(id2), root);
7022
7805
  }
7023
7806
  }
7024
7807
 
7025
7808
  // src/cli/commands/shot.ts
7026
- import fs37 from "fs";
7027
- import path35 from "path";
7809
+ import fs38 from "fs";
7810
+ import path36 from "path";
7028
7811
  var OVERVIEW_TIMEOUT = 9e4;
7812
+ var DRAWING_TIMEOUT = 45e3;
7813
+ var DRAWING_MAX_SIDE = 1280;
7029
7814
  async function runShot(paths, target, options) {
7030
7815
  const [canvasId = "", screenId] = target.split("/");
7031
7816
  const project = new DesignProject(paths, DEV_URLS);
7032
- if (canvasId === SYSTEM_CANVAS) return shootSystem(paths, project, screenId, options);
7817
+ if (canvasId === SYSTEM_CANVAS) {
7818
+ if (options.board || options.markup) throw new CliError("--board and --markup apply to canvases, not @system");
7819
+ return shootSystem(paths, project, screenId, options);
7820
+ }
7821
+ if (options.board && options.markup) throw new CliError("Take --board and --markup one at a time");
7822
+ if (options.max !== void 0 && !options.board && !options.markup)
7823
+ throw new CliError("--max and --full size the pictures of --board and --markup");
7824
+ if (options.board && screenId)
7825
+ throw new CliError("Boards belong to pages: design shot <canvas> --board [--page <id>]");
7033
7826
  const canvas = project.canvas(canvasId);
7034
7827
  if (!canvas) {
7035
7828
  const known = project.canvasIds();
@@ -7040,15 +7833,26 @@ async function runShot(paths, target, options) {
7040
7833
  throw new CliError(`No page "${options.page}" in "${canvasId}". Pages: ${doc.pages.map((p) => p.id).join(", ")}`);
7041
7834
  }
7042
7835
  const theme2 = options.theme ?? doc.theme ?? "light";
7043
- const outBase = path35.resolve(options.out ?? paths.shots);
7044
- const outDir = path35.join(outBase, canvasId);
7045
- fs37.mkdirSync(outDir, { recursive: true });
7836
+ const outBase = path36.resolve(options.out ?? paths.shots);
7837
+ const outDir = path36.join(outBase, canvasId);
7838
+ fs38.mkdirSync(outDir, { recursive: true });
7046
7839
  const base = await serverBase(paths);
7047
7840
  const browser = await launchChrome();
7048
7841
  let failed = false;
7049
7842
  try {
7843
+ if (options.board || options.markup) {
7844
+ if (!fs38.existsSync(PKG.viewer)) throw new CliError("The viewer is not built, so there are no drawings to shoot.");
7845
+ const drawingsFailed = await shootDrawings(browser, base, canvasId, outDir, theme2, {
7846
+ board: !!options.board,
7847
+ page: options.page,
7848
+ screen: screenId,
7849
+ max: options.max ?? DRAWING_MAX_SIDE
7850
+ });
7851
+ if (drawingsFailed) process.exitCode = 1;
7852
+ return;
7853
+ }
7050
7854
  if (options.overview) {
7051
- if (!fs37.existsSync(PKG.viewer)) throw new CliError("The viewer is not built, so there is no canvas to shoot.");
7855
+ if (!fs38.existsSync(PKG.viewer)) throw new CliError("The viewer is not built, so there is no canvas to shoot.");
7052
7856
  const pages = options.page ? [options.page] : screenId ? doc.pages.filter((page) => page.sections.some((section) => section.items.some((item) => item.id === screenId))).map((page) => page.id) : doc.pages.map((page) => page.id);
7053
7857
  for (const pageId of pages) {
7054
7858
  const page = await browser.newPage({ viewport: { width: 1920, height: 1200 } });
@@ -7064,7 +7868,7 @@ async function runShot(paths, target, options) {
7064
7868
  warn(`page "${pageId}": not every frame reported ready within ${OVERVIEW_TIMEOUT / 1e3}s; shooting anyway`);
7065
7869
  }
7066
7870
  await page.waitForTimeout(400);
7067
- const file = path35.join(outDir, `${pageId}.overview.png`);
7871
+ const file = path36.join(outDir, `${pageId}.overview.png`);
7068
7872
  await page.screenshot({ path: file });
7069
7873
  await page.close();
7070
7874
  print(file);
@@ -7083,6 +7887,62 @@ async function runShot(paths, target, options) {
7083
7887
  }
7084
7888
  if (failed) process.exitCode = 1;
7085
7889
  }
7890
+ async function shootDrawings(browser, base, canvasId, outDir, theme2, want) {
7891
+ const port = Number(new URL(base).port);
7892
+ const { drawings, cloud: cloud2 } = await readDrawings(port, canvasId);
7893
+ if (cloud2 === "offline" || cloud2 === "connecting")
7894
+ warn("the cloud did not answer in time: these are the drawings on this machine");
7895
+ const found = drawn(drawings, want.board ? "board" : "markup");
7896
+ const named = want.board ? want.page : want.screen;
7897
+ const ids = named ? [named] : [...found.keys()];
7898
+ if (!ids.length) {
7899
+ print(want.board ? `No idea board drawn on in "${canvasId}".` : `No screen marked up in "${canvasId}".`);
7900
+ return false;
7901
+ }
7902
+ if (named && !found.has(named))
7903
+ print(dim(want.board ? `Nothing on the board of "${named}" yet.` : `No markup on "${named}" yet.`));
7904
+ let failed = false;
7905
+ for (const id2 of ids) {
7906
+ const kind = want.board ? "board" : "markup";
7907
+ const page = await browser.newPage({ viewport: { width: 1280, height: 900 }, colorScheme: theme2 });
7908
+ try {
7909
+ const url = `${base}/c/${encodeURIComponent(canvasId)}/${kind}/${encodeURIComponent(id2)}?capture=1&theme=${theme2}&max=${want.max}`;
7910
+ await page.goto(url, { waitUntil: "load" });
7911
+ await page.waitForFunction(
7912
+ () => window.__DESIGN_DRAWING_READY__ === true,
7913
+ null,
7914
+ { timeout: DRAWING_TIMEOUT }
7915
+ );
7916
+ const size2 = await page.evaluate(
7917
+ () => window.__DESIGN_DRAWING_SIZE__
7918
+ );
7919
+ if (size2?.width && size2.height)
7920
+ await page.setViewportSize({ width: Math.round(size2.width), height: Math.min(Math.round(size2.height), 16e3) });
7921
+ await page.waitForTimeout(200);
7922
+ if (!want.board) {
7923
+ const file = path36.join(outDir, `${id2}.markup.${theme2}.png`);
7924
+ await page.screenshot({ path: file });
7925
+ print(file);
7926
+ continue;
7927
+ }
7928
+ const parts = await page.evaluate(
7929
+ () => window.__DESIGN_DRAWING_PARTS__ ?? 0
7930
+ );
7931
+ for (let part = 0; part < parts; part++) {
7932
+ const file = path36.join(outDir, parts > 1 ? `${id2}.board.${part + 1}.${theme2}.png` : `${id2}.board.${theme2}.png`);
7933
+ await page.locator(`[data-part="${part}"]`).screenshot({ path: file });
7934
+ print(file);
7935
+ }
7936
+ } catch (error) {
7937
+ failed = true;
7938
+ print(`${red("\u2717")} ${canvasId}/${id2}: ${error.message.split("\n")[0]}`);
7939
+ } finally {
7940
+ await page.close().catch(() => {
7941
+ });
7942
+ }
7943
+ }
7944
+ return failed;
7945
+ }
7086
7946
  async function serverBase(paths) {
7087
7947
  const wasRunning = await liveServer(paths);
7088
7948
  const server = await ensureServer(paths, {});
@@ -7093,7 +7953,7 @@ async function shootFrames(browser, jobs, outDir) {
7093
7953
  let failed = false;
7094
7954
  const results = await mapLimit(jobs, 4, async (job) => {
7095
7955
  const page = await browser.newPage({ viewport: { width: job.width, height: job.height } });
7096
- const file = path35.join(outDir, `${job.id}.${job.theme}.png`);
7956
+ const file = path36.join(outDir, `${job.id}.${job.theme}.png`);
7097
7957
  try {
7098
7958
  const report = await openFrame(page, job.url, { waitForReady: job.waitForReady });
7099
7959
  await page.screenshot({ path: file, fullPage: job.fullPage });
@@ -7125,8 +7985,8 @@ async function shootSystem(paths, project, id2, options) {
7125
7985
  if (id2 && !all.some((job) => job.id === id2)) {
7126
7986
  throw new CliError(`No specimen "${id2}" in the design system. Specimens: ${all.map((job) => job.id).join(", ")}`);
7127
7987
  }
7128
- const outDir = path35.join(path35.resolve(options.out ?? paths.shots), SYSTEM_CANVAS);
7129
- fs37.mkdirSync(outDir, { recursive: true });
7988
+ const outDir = path36.join(path36.resolve(options.out ?? paths.shots), SYSTEM_CANVAS);
7989
+ fs38.mkdirSync(outDir, { recursive: true });
7130
7990
  const base = await serverBase(paths);
7131
7991
  const jobs = systemFrames(system.doc, base, theme2).filter((job) => !id2 || job.id === id2);
7132
7992
  const browser = await launchChrome();
@@ -7176,7 +8036,7 @@ async function runSnapshots(paths, targets2, options) {
7176
8036
  }
7177
8037
 
7178
8038
  // src/cli/commands/cloud-status.ts
7179
- import path36 from "path";
8039
+ import path37 from "path";
7180
8040
  var STATE_TEXT = {
7181
8041
  in_sync: green,
7182
8042
  local_changes: yellow,
@@ -7199,7 +8059,7 @@ function detail(paths, status) {
7199
8059
  if (status.state === "remote_ahead" && !status.local && !status.baseRev) parts.push("not pulled yet");
7200
8060
  if (status.state === "remote_ahead" && status.changed.length && !status.deletedRemotely)
7201
8061
  parts.push(`${plural(status.changed.length, "file")} changed here; the cloud only rebuilt it`);
7202
- if (status.conflictRev !== void 0) parts.push(`merge ${path36.relative(paths.root, incomingDir(paths, status.key))}`);
8062
+ if (status.conflictRev !== void 0) parts.push(`merge ${path37.relative(paths.root, incomingDir(paths, status.key))}`);
7203
8063
  return parts.join(" \xB7 ");
7204
8064
  }
7205
8065
  async function printCloudStatus(paths, label) {
@@ -7347,8 +8207,17 @@ ${bold("Verify")}
7347
8207
  [--page <id>] viewer to show while frames do not run (push does this
7348
8208
  for the canvases it pushes)
7349
8209
  shot <canvas>[/<screen>] [--page <id>] Screenshot screens with Chrome (PNG paths are printed);
7350
- [--theme light|dark] [--out <dir>] @system or @system/<id> shoots the design system specimens
7351
- [--overview]
8210
+ [--theme light|dark] [--out <dir>] @system or @system/<id> shoots the design system specimens;
8211
+ [--overview] [--board | --markup] --board shoots the idea boards drawn on (or --page's),
8212
+ [--max <px> | --full] one PNG per group of sketches; --markup the screens with
8213
+ markup, drawn over them. Those pictures are at most 1280 px
8214
+ on the long side; --max sets another limit, --full none
8215
+
8216
+ ${bold("Drawings")} ${dim("idea boards of pages and markup over screens, drawn in the viewer; shared live in the cloud")}
8217
+ drawings [<canvas>[/<screen>]] [--json] Boards and markup with something on them: strokes and the
8218
+ text written on them (read through the preview server,
8219
+ which syncs them with the cloud when linked)
8220
+ drawings <canvas>/<screen> --clear Erase a screen's markup once it is dealt with
7352
8221
 
7353
8222
  ${bold("Share")}
7354
8223
  build [canvas\u2026] [--out <dir>] [--tar] Static site of the canvases and the design system
@@ -7397,7 +8266,7 @@ ${bold("Environment")}
7397
8266
  FOSS_DESIGN_CLOUD Cloud to sign in and link to (default https://app.fossdesign.dev)
7398
8267
  `;
7399
8268
  function projectPaths(rootOption, requireDesign = true) {
7400
- const found = rootOption ? path37.resolve(rootOption) : findProjectRoot();
8269
+ const found = rootOption ? path38.resolve(rootOption) : findProjectRoot();
7401
8270
  if (!found) {
7402
8271
  if (!requireDesign) return designPaths(process.cwd());
7403
8272
  throw new CliError("No .design folder here or above. Run `design init` in the project root first.");
@@ -7405,6 +8274,15 @@ function projectPaths(rootOption, requireDesign = true) {
7405
8274
  const paths = designPaths(found);
7406
8275
  return paths;
7407
8276
  }
8277
+ function drawingMax(max, full) {
8278
+ if (full && max !== void 0) throw new CliError("Pass --max or --full, not both");
8279
+ if (full) return 0;
8280
+ if (max === void 0) return void 0;
8281
+ const value = Number(max);
8282
+ if (!Number.isInteger(value) || value < 200 || value > 8e3)
8283
+ throw new CliError("--max takes a number of pixels, 200 to 8000");
8284
+ return value;
8285
+ }
7408
8286
  async function main(argv) {
7409
8287
  const { values, positionals } = parseArgs({
7410
8288
  args: argv,
@@ -7433,6 +8311,11 @@ async function main(argv) {
7433
8311
  theme: { type: "string" },
7434
8312
  out: { type: "string" },
7435
8313
  overview: { type: "boolean" },
8314
+ board: { type: "boolean" },
8315
+ markup: { type: "boolean" },
8316
+ clear: { type: "boolean" },
8317
+ max: { type: "string" },
8318
+ full: { type: "boolean" },
7436
8319
  tar: { type: "boolean" },
7437
8320
  new: { type: "string" },
7438
8321
  resolved: { type: "string", multiple: true },
@@ -7450,7 +8333,7 @@ async function main(argv) {
7450
8333
  if (values.theme && !theme2) throw new CliError("--theme is light or dark");
7451
8334
  switch (command) {
7452
8335
  case "init":
7453
- return runInit(values.root ? path37.resolve(values.root) : process.cwd(), {
8336
+ return runInit(values.root ? path38.resolve(values.root) : process.cwd(), {
7454
8337
  name: values.name,
7455
8338
  gitignore: !values["no-gitignore"]
7456
8339
  });
@@ -7508,9 +8391,14 @@ async function main(argv) {
7508
8391
  page: values.page,
7509
8392
  theme: theme2,
7510
8393
  out: values.out,
7511
- overview: !!values.overview
8394
+ overview: !!values.overview,
8395
+ board: !!values.board,
8396
+ markup: !!values.markup,
8397
+ max: drawingMax(values.max, !!values.full)
7512
8398
  });
7513
8399
  }
8400
+ case "drawings":
8401
+ return runDrawings(projectPaths(values.root), rest[0], { json: !!values.json, clear: !!values.clear });
7514
8402
  case "build":
7515
8403
  return runBuild(projectPaths(values.root), rest, { out: values.out, tar: !!values.tar });
7516
8404
  case "login":
@@ -7557,12 +8445,12 @@ function relaunchFromRealPath() {
7557
8445
  let realScript;
7558
8446
  let realCwd;
7559
8447
  try {
7560
- realScript = fs38.realpathSync.native(script);
7561
- realCwd = fs38.realpathSync.native(process.cwd());
8448
+ realScript = fs39.realpathSync.native(script);
8449
+ realCwd = fs39.realpathSync.native(process.cwd());
7562
8450
  } catch {
7563
8451
  return false;
7564
8452
  }
7565
- const same = (a, b) => path37.resolve(a).toLowerCase() === path37.resolve(b).toLowerCase();
8453
+ const same = (a, b) => path38.resolve(a).toLowerCase() === path38.resolve(b).toLowerCase();
7566
8454
  if (same(realScript, script) && same(realCwd, process.cwd())) return false;
7567
8455
  const result = spawnSync3(process.execPath, [...process.execArgv, realScript, ...process.argv.slice(2)], {
7568
8456
  stdio: "inherit",