foss-design 0.9.5 → 0.9.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli/index.ts
4
- import path34 from "path";
4
+ import { spawnSync as spawnSync3 } from "child_process";
5
+ import fs37 from "fs";
6
+ import path35 from "path";
5
7
  import { parseArgs } from "util";
6
8
 
7
9
  // src/core/paths.ts
@@ -346,8 +348,8 @@ function routeError(pattern) {
346
348
  }
347
349
  return null;
348
350
  }
349
- function split(path35) {
350
- return path35.split("/").filter(Boolean);
351
+ function split(path36) {
352
+ return path36.split("/").filter(Boolean);
351
353
  }
352
354
  function compileRoute(pattern) {
353
355
  return {
@@ -366,8 +368,8 @@ function decode2(part) {
366
368
  return part;
367
369
  }
368
370
  }
369
- function scoreRoute(route2, path35) {
370
- const parts = split(path35.split(/[?#]/)[0] ?? "").map(decode2);
371
+ function scoreRoute(route2, path36) {
372
+ const parts = split(path36.split(/[?#]/)[0] ?? "").map(decode2);
371
373
  const walk2 = (s, p) => {
372
374
  if (s === route2.segments.length) return p === parts.length ? 0 : null;
373
375
  const segment = route2.segments[s];
@@ -385,11 +387,11 @@ function scoreRoute(route2, path35) {
385
387
  };
386
388
  return walk2(0, 0);
387
389
  }
388
- function matchRoute(entries, path35) {
390
+ function matchRoute(entries, path36) {
389
391
  let best = null;
390
392
  for (const entry of entries) {
391
393
  for (const route2 of entry.routes) {
392
- const score = scoreRoute(route2, path35);
394
+ const score = scoreRoute(route2, path36);
393
395
  if (score !== null && (!best || score > best.score)) best = { score, value: entry.value };
394
396
  }
395
397
  }
@@ -516,9 +518,9 @@ var CanvasSchema = z.strictObject({
516
518
  pages: z.array(z.unknown()).min(1, "add at least one page")
517
519
  });
518
520
  var ID_PATTERN = /^[a-z0-9][a-z0-9_-]*$/i;
519
- function formatPath(base, path35) {
521
+ function formatPath(base, path36) {
520
522
  let out = base;
521
- for (const part of path35) out += typeof part === "number" ? `[${part}]` : out ? `.${String(part)}` : String(part);
523
+ for (const part of path36) out += typeof part === "number" ? `[${part}]` : out ? `.${String(part)}` : String(part);
522
524
  return out;
523
525
  }
524
526
  function describeZodError(error, base = "") {
@@ -2632,7 +2634,7 @@ var CloudClient = class {
2632
2634
  }
2633
2635
  host;
2634
2636
  token;
2635
- async send(method, path35, init) {
2637
+ async send(method, path36, init) {
2636
2638
  const headers = {
2637
2639
  "User-Agent": `foss-design/${packageVersion()} (${os.platform()})`,
2638
2640
  Accept: "application/json"
@@ -2640,7 +2642,7 @@ var CloudClient = class {
2640
2642
  if (this.token) headers.Authorization = `Bearer ${this.token}`;
2641
2643
  if (init.type) headers["Content-Type"] = init.type;
2642
2644
  try {
2643
- return await fetch(`${this.host}/api${path35}`, {
2645
+ return await fetch(`${this.host}/api${path36}`, {
2644
2646
  method,
2645
2647
  headers,
2646
2648
  body: init.body,
@@ -2661,8 +2663,8 @@ var CloudClient = class {
2661
2663
  throw errorFor(this.host, response.status, body, response.headers.get("retry-after"));
2662
2664
  }
2663
2665
  /** JSON in, JSON out; any non-2xx answer throws a CloudError. */
2664
- async request(method, path35, body) {
2665
- const response = await this.send(method, path35, {
2666
+ async request(method, path36, body) {
2667
+ const response = await this.send(method, path36, {
2666
2668
  body: body === void 0 ? void 0 : JSON.stringify(body),
2667
2669
  type: body === void 0 ? void 0 : "application/json",
2668
2670
  timeout: JSON_TIMEOUT
@@ -2674,7 +2676,7 @@ var CloudClient = class {
2674
2676
  try {
2675
2677
  return JSON.parse(text2);
2676
2678
  } catch {
2677
- throw new CloudError(`${this.host} answered ${method} ${path35} with something other than JSON`, 0, "bad_response");
2679
+ throw new CloudError(`${this.host} answered ${method} ${path36} with something other than JSON`, 0, "bad_response");
2678
2680
  }
2679
2681
  }
2680
2682
  /** POST /device/code. */
@@ -3273,51 +3275,84 @@ async function mapLimit(items, limit, fn) {
3273
3275
 
3274
3276
  // src/capture/cover.ts
3275
3277
  var SNAPSHOT_WIDTH = 900;
3276
- async function refreshCovers(paths, canvasIds, base) {
3278
+ var TABS = 4;
3279
+ var THEMES2 = ["light", "dark"];
3280
+ function staleSnapshots(paths, canvasIds, filter = {}) {
3277
3281
  const store = new SnapshotStore(paths.snapshots);
3278
3282
  const project = new DesignProject(paths, DEV_URLS, store.lookup);
3279
3283
  const stale = [];
3280
3284
  for (const id2 of canvasIds) {
3281
3285
  const canvas = project.canvas(id2);
3282
- if (!canvas || canvas.doc.cover && "url" in canvas.doc.cover) continue;
3283
- const screen = canvas.doc.cover?.screen ?? canvas.screens[0]?.id;
3284
- const source = canvas.screens.find((item) => item.id === screen);
3285
- if (!screen || !source || !fs19.existsSync(source.file)) continue;
3286
- const taken = store.version(id2, screen, "light");
3287
- if (!taken || taken < fs19.statSync(source.file).mtimeMs) stale.push({ canvas: id2, screen });
3288
- }
3289
- if (!stale.length) return [];
3290
- if (!findChrome()) return ["No Chrome here, so canvas covers keep their last snapshot."];
3286
+ if (!canvas) continue;
3287
+ const inLight = canvasFrames(canvas.doc, "", "light", { page: filter.page, includeUrls: false });
3288
+ const inDark = new Map(
3289
+ canvasFrames(canvas.doc, "", "dark", { page: filter.page, includeUrls: false }).map((job) => [job.id, job.theme])
3290
+ );
3291
+ const files = new Map(canvas.screens.map((screen) => [screen.id, screen.file]));
3292
+ for (const job of inLight) {
3293
+ if (filter.screens && !filter.screens.has(job.id)) continue;
3294
+ const file = files.get(job.id);
3295
+ if (!file || !fs19.existsSync(file)) continue;
3296
+ const changed = fs19.statSync(file).mtimeMs;
3297
+ const pinned = inDark.get(job.id) === job.theme;
3298
+ const themes = pinned ? [job.theme] : THEMES2;
3299
+ const screen = { id: job.id };
3300
+ for (const theme2 of themes) {
3301
+ const taken = store.version(id2, screen.id, theme2);
3302
+ if (!taken || taken < changed) stale.push({ canvas: id2, screen: screen.id, theme: theme2 });
3303
+ }
3304
+ }
3305
+ }
3306
+ return stale;
3307
+ }
3308
+ async function takeSnapshots(paths, targets, base, progress2) {
3309
+ if (!targets.length) return [];
3310
+ if (!findChrome()) return ["No Chrome here, so screens keep the snapshots they have."];
3311
+ const store = new SnapshotStore(paths.snapshots);
3312
+ const project = new DesignProject(paths, DEV_URLS, store.lookup);
3291
3313
  const url = await base();
3292
3314
  const browser = await launchChrome();
3293
- const lines = [];
3315
+ const failed = [];
3316
+ const started = Date.now();
3317
+ let done = 0;
3318
+ const line = () => `Snapshots ${done}/${targets.length} (both themes, as the viewer and the cloud show them)`;
3319
+ progress2.update(line());
3294
3320
  try {
3295
- for (const { canvas, screen } of stale) {
3296
- const doc = project.canvas(canvas).doc;
3297
- const job = canvasFrames(doc, url, "light", { id: screen })[0];
3298
- if (!job) continue;
3299
- const page = await browser.newPage({
3300
- viewport: { width: job.width, height: job.height },
3301
- deviceScaleFactor: Math.min(1, SNAPSHOT_WIDTH / job.width)
3302
- });
3303
- try {
3304
- const report = await openFrame(page, job.url, { waitForReady: job.waitForReady });
3305
- if (!report.ready) {
3306
- lines.push(`${canvas}: ${screen} did not finish rendering, so its cover keeps the last snapshot`);
3307
- continue;
3308
- }
3309
- const png = await page.screenshot({ fullPage: job.fullPage });
3310
- const height = job.fullPage ? await page.evaluate(() => document.documentElement.scrollHeight) : void 0;
3311
- store.save(canvas, screen, "light", png, height);
3312
- } finally {
3313
- await page.close().catch(() => {
3321
+ await mapLimit([...targets], TABS, async ({ canvas, screen, theme: theme2 }) => {
3322
+ const job = canvasFrames(project.canvas(canvas).doc, url, theme2, { id: screen })[0];
3323
+ if (job) {
3324
+ const page = await browser.newPage({
3325
+ viewport: { width: job.width, height: job.height },
3326
+ deviceScaleFactor: Math.min(1, SNAPSHOT_WIDTH / job.width),
3327
+ colorScheme: theme2
3314
3328
  });
3329
+ try {
3330
+ const report = await openFrame(page, job.url, { waitForReady: job.waitForReady });
3331
+ if (report.ready) {
3332
+ const png = await page.screenshot({ fullPage: job.fullPage });
3333
+ const height = job.fullPage ? await page.evaluate(() => document.documentElement.scrollHeight) : void 0;
3334
+ store.save(canvas, screen, theme2, png, height);
3335
+ } else failed.push(`${canvas}/${screen} (${theme2})`);
3336
+ } catch {
3337
+ failed.push(`${canvas}/${screen} (${theme2})`);
3338
+ } finally {
3339
+ await page.close().catch(() => {
3340
+ });
3341
+ }
3315
3342
  }
3316
- }
3343
+ done++;
3344
+ progress2.update(line());
3345
+ });
3317
3346
  } finally {
3318
3347
  await browser.close();
3319
3348
  }
3320
- return lines;
3349
+ const seconds2 = Math.round((Date.now() - started) / 1e3);
3350
+ progress2.done(`Took ${targets.length - failed.length} snapshot${targets.length === 1 ? "" : "s"} in ${seconds2}s`);
3351
+ if (!failed.length) return [];
3352
+ const shown = failed.slice(0, 5).join(", ");
3353
+ return [
3354
+ `${failed.length} screen${failed.length === 1 ? "" : "s"} did not finish rendering, so they keep their last snapshot: ${shown}${failed.length > 5 ? ", \u2026" : ""}. \`design check --render\` says why.`
3355
+ ];
3321
3356
  }
3322
3357
 
3323
3358
  // src/cloud/pull.ts
@@ -3975,7 +4010,18 @@ async function push(ctx, options) {
3975
4010
  bar.done(`Uploaded ${plural(missing.length, "file")} (${megabytes(bytes)}) in ${seconds(started)}`);
3976
4011
  }
3977
4012
  log(`Pushing ${plural(units.length, "unit")}\u2026`);
3978
- const revs2 = await client.push(link.project, units);
4013
+ let revs2;
4014
+ try {
4015
+ revs2 = await client.push(link.project, units);
4016
+ } catch (error) {
4017
+ if (!(error instanceof CloudError) || error.status !== 409 || error.error !== "conflict") throw error;
4018
+ report.ok = false;
4019
+ report.error = {
4020
+ code: "pull_first",
4021
+ message: "Nothing was pushed: someone pushed first. Run `design pull`, then push again."
4022
+ };
4023
+ return report;
4024
+ }
3979
4025
  for (const unit of units) {
3980
4026
  const rev = revs2.get(unit.key) ?? unit.baseRev + 1;
3981
4027
  link.units[unit.key] = { rev, files: hashes(unit.source) };
@@ -4174,6 +4220,7 @@ var EventHub = class {
4174
4220
 
4175
4221
  // src/server/state.ts
4176
4222
  import fs26 from "fs";
4223
+ import os3 from "os";
4177
4224
  import path24 from "path";
4178
4225
  var stateFile2 = (paths) => path24.join(paths.cache, "server.json");
4179
4226
  var logFile = (paths) => path24.join(paths.cache, "server.log");
@@ -4184,16 +4231,71 @@ function readState(paths) {
4184
4231
  return null;
4185
4232
  }
4186
4233
  }
4234
+ function registryDir() {
4235
+ const base = process.platform === "win32" ? process.env.LOCALAPPDATA || process.env.APPDATA || path24.join(os3.homedir(), "AppData", "Local") : process.env.XDG_CACHE_HOME || path24.join(os3.homedir(), ".cache");
4236
+ return path24.join(base, "foss-design", "previews");
4237
+ }
4238
+ var entryFile = (pid) => path24.join(registryDir(), `${pid}.json`);
4187
4239
  function writeState(paths, state) {
4188
4240
  fs26.mkdirSync(paths.cache, { recursive: true });
4189
4241
  fs26.writeFileSync(stateFile2(paths), `${JSON.stringify(state, null, 2)}
4190
4242
  `);
4243
+ try {
4244
+ fs26.mkdirSync(registryDir(), { recursive: true });
4245
+ fs26.writeFileSync(entryFile(state.pid), `${JSON.stringify(state, null, 2)}
4246
+ `);
4247
+ } catch {
4248
+ }
4191
4249
  }
4192
4250
  function clearState(paths, pid) {
4193
4251
  const state = readState(paths);
4252
+ fs26.rmSync(entryFile(pid ?? state?.pid ?? -1), { force: true });
4194
4253
  if (pid !== void 0 && state && state.pid !== pid) return;
4195
4254
  fs26.rmSync(stateFile2(paths), { force: true });
4196
4255
  }
4256
+ function listPreviews() {
4257
+ let files = [];
4258
+ try {
4259
+ files = fs26.readdirSync(registryDir()).filter((name) => name.endsWith(".json"));
4260
+ } catch {
4261
+ return [];
4262
+ }
4263
+ const found = [];
4264
+ for (const name of files) {
4265
+ const file = path24.join(registryDir(), name);
4266
+ let state = null;
4267
+ try {
4268
+ state = JSON.parse(fs26.readFileSync(file, "utf8"));
4269
+ } catch {
4270
+ }
4271
+ if (!state?.pid || !pidAlive(state.pid)) {
4272
+ fs26.rmSync(file, { force: true });
4273
+ continue;
4274
+ }
4275
+ let recorded = null;
4276
+ try {
4277
+ recorded = JSON.parse(fs26.readFileSync(path24.join(state.root, ".design", ".cache", "server.json"), "utf8"));
4278
+ } catch {
4279
+ }
4280
+ found.push({ ...state, tracked: recorded?.pid === state.pid });
4281
+ }
4282
+ return found.sort((a, b) => a.startedAt.localeCompare(b.startedAt));
4283
+ }
4284
+ async function stopPreview(entry) {
4285
+ try {
4286
+ process.kill(entry.pid, "SIGTERM");
4287
+ } catch {
4288
+ }
4289
+ const until = Date.now() + 5e3;
4290
+ while (Date.now() < until && pidAlive(entry.pid)) await new Promise((resolve) => setTimeout(resolve, 100));
4291
+ fs26.rmSync(entryFile(entry.pid), { force: true });
4292
+ const stateAt = path24.join(entry.root, ".design", ".cache", "server.json");
4293
+ try {
4294
+ if (JSON.parse(fs26.readFileSync(stateAt, "utf8")).pid === entry.pid)
4295
+ fs26.rmSync(stateAt, { force: true });
4296
+ } catch {
4297
+ }
4298
+ }
4197
4299
  function pidAlive(pid) {
4198
4300
  try {
4199
4301
  process.kill(pid, 0);
@@ -4487,7 +4589,7 @@ async function startDevServer(options) {
4487
4589
  // src/cli/commands/preview.ts
4488
4590
  function openBrowser(url) {
4489
4591
  const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", '""', url]] : ["xdg-open", [url]];
4490
- const child = spawn(command, args, { stdio: "ignore", detached: true });
4592
+ const child = spawn(command, args, { stdio: "ignore", detached: true, windowsHide: true });
4491
4593
  child.on("error", () => {
4492
4594
  });
4493
4595
  child.unref();
@@ -4535,11 +4637,19 @@ async function ensureServer(paths, options) {
4535
4637
  if (stale)
4536
4638
  print(dim(`Restarting the preview server: it runs foss-design ${live2.version}, this is ${packageVersion()}.`));
4537
4639
  if (live2) await stopServer(paths);
4640
+ const recorded = readState(paths);
4641
+ if (recorded && pidAlive(recorded.pid)) await stopPreview(recorded);
4642
+ for (const other of listPreviews()) if (other.root === paths.root) await stopPreview(other);
4538
4643
  fs28.mkdirSync(paths.cache, { recursive: true });
4539
4644
  const log = fs28.openSync(logFile(paths), "w");
4540
4645
  const args = [PKG.cli, "preview", "--foreground", "--root", paths.root];
4541
4646
  if (options.port) args.push("--port", String(options.port));
4542
- const child = spawn(process.execPath, args, { detached: true, stdio: ["ignore", log, log], cwd: paths.root });
4647
+ const child = spawn(process.execPath, args, {
4648
+ detached: true,
4649
+ windowsHide: true,
4650
+ stdio: ["ignore", log, log],
4651
+ cwd: paths.root
4652
+ });
4543
4653
  child.unref();
4544
4654
  const state = await waitForServer(paths, 4e4);
4545
4655
  if (!state) {
@@ -4569,6 +4679,27 @@ async function runPreview(paths, options) {
4569
4679
  async function runStop(paths) {
4570
4680
  print(await stopServer(paths) ? "Stopped the preview server." : "No preview server is running for this project.");
4571
4681
  }
4682
+ var since = (iso) => {
4683
+ const minutes = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 6e4));
4684
+ return minutes < 60 ? `${minutes}m` : `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
4685
+ };
4686
+ function runPreviews(options) {
4687
+ const previews = listPreviews();
4688
+ if (options.json) return print(JSON.stringify({ previews }, null, 2));
4689
+ if (!previews.length) return print("No preview servers are running.");
4690
+ for (const preview of previews) {
4691
+ const orphan = preview.tracked ? "" : ` ${bold("orphan")} ${dim("(its project names another server)")}`;
4692
+ print(`${preview.url} ${preview.root}${orphan}`);
4693
+ print(dim(` foss-design ${preview.version} \xB7 pid ${preview.pid} \xB7 up ${since(preview.startedAt)}`));
4694
+ }
4695
+ }
4696
+ async function runStopAll() {
4697
+ const previews = listPreviews();
4698
+ for (const preview of previews) await stopPreview(preview);
4699
+ print(
4700
+ previews.length ? `Stopped ${previews.length} preview server${previews.length === 1 ? "" : "s"}.` : "No preview servers are running."
4701
+ );
4702
+ }
4572
4703
 
4573
4704
  // src/cli/commands/sync.ts
4574
4705
  var GOOD = /* @__PURE__ */ new Set(["pushed", "created", "updated", "deleted"]);
@@ -4649,11 +4780,19 @@ async function runSync(paths, command, json, run) {
4649
4780
  if (!report.ok && report.error) throw new CliError(report.error.message, 2);
4650
4781
  }
4651
4782
  function runPush(paths, units, options) {
4652
- const beforeBuild = (canvasIds) => refreshCovers(paths, canvasIds, async () => `http://127.0.0.1:${(await ensureServer(paths, {})).port}`);
4653
4783
  return runSync(paths, "push", options.json, async (ctx) => {
4784
+ const beforeBuild = (canvasIds) => takeSnapshots(
4785
+ paths,
4786
+ staleSnapshots(paths, canvasIds),
4787
+ async () => `http://127.0.0.1:${(await ensureServer(paths, {})).port}`,
4788
+ ctx.progress()
4789
+ );
4654
4790
  const first = await push(ctx, { units, resolved: options.resolved, beforeBuild });
4655
4791
  if (first.ok || first.error?.code !== "pull_first") return first;
4656
- ctx.log("The cloud is ahead: merging its changes first\u2026");
4792
+ const raced = first.error.message.includes("someone pushed first");
4793
+ ctx.log(
4794
+ raced ? "Someone pushed while this push was uploading: merging their changes first\u2026" : "The cloud is ahead: merging its changes first\u2026"
4795
+ );
4657
4796
  const pulled = await pull(ctx, { units: [], theirs: [] });
4658
4797
  if (!pulled.ok) {
4659
4798
  pulled.error = {
@@ -4665,7 +4804,7 @@ function runPush(paths, units, options) {
4665
4804
  ctx.link = readLink(paths);
4666
4805
  const second = await push(ctx, { units, resolved: [], beforeBuild });
4667
4806
  second.hints.unshift(
4668
- `Merged the cloud's changes first: ${pulled.units.filter((entry) => entry.action !== "up_to_date").map((entry) => `${entry.unit} (${entry.action.replaceAll("_", " ")})`).join(", ")}`
4807
+ `${raced ? "Someone pushed while this push was uploading; merged" : "Merged"} the cloud's changes first: ${pulled.units.filter((entry) => entry.action !== "up_to_date").map((entry) => `${entry.unit} (${entry.action.replaceAll("_", " ")})`).join(", ")}`
4669
4808
  );
4670
4809
  return second;
4671
4810
  });
@@ -4896,6 +5035,10 @@ async function runMe(paths, options) {
4896
5035
  }
4897
5036
 
4898
5037
  // src/cli/commands/check.ts
5038
+ import fs29 from "fs";
5039
+ import http2 from "http";
5040
+ import path26 from "path";
5041
+ import sirv2 from "sirv";
4899
5042
  var plural2 = (count, word, many = `${word}s`) => `${count} ${count === 1 ? word : many}`;
4900
5043
  function printIssues(issues) {
4901
5044
  const byFile = /* @__PURE__ */ new Map();
@@ -4935,29 +5078,103 @@ async function renderFrames(jobs) {
4935
5078
  await browser.close();
4936
5079
  }
4937
5080
  }
4938
- async function runCheck(paths, canvases, options) {
5081
+ async function runCheck(paths, targets, options) {
4939
5082
  const project = new DesignProject(paths, DEV_URLS);
4940
5083
  const known = project.canvasIds();
4941
- const unknown = canvases.filter((id2) => !known.includes(id2));
4942
- if (unknown.length) {
4943
- throw new CliError(
4944
- `No canvas ${unknown.map((id2) => `"${id2}"`).join(", ")}. Canvases: ${known.join(", ") || "none yet"}`
4945
- );
5084
+ const picked = /* @__PURE__ */ new Map();
5085
+ for (const target of targets) {
5086
+ const [canvasId = "", screenId] = target.split("/");
5087
+ if (!known.includes(canvasId))
5088
+ throw new CliError(`No canvas "${canvasId}". Canvases: ${known.join(", ") || "none yet"}`);
5089
+ const screens = picked.has(canvasId) ? picked.get(canvasId) : /* @__PURE__ */ new Set();
5090
+ if (!screenId || screens === null) picked.set(canvasId, null);
5091
+ else picked.set(canvasId, screens.add(screenId));
5092
+ }
5093
+ const canvases = [...picked.keys()];
5094
+ if (options.page) {
5095
+ if (canvases.length !== 1)
5096
+ throw new CliError("--page needs exactly one canvas: `design check <canvas> --page <id>`");
5097
+ const doc = project.canvas(canvases[0]).doc;
5098
+ if (!doc.pages.some((page) => page.id === options.page))
5099
+ throw new CliError(`No page "${options.page}" in "${doc.id}". Pages: ${doc.pages.map((p) => p.id).join(", ")}`);
5100
+ }
5101
+ for (const [canvasId, screens] of picked) {
5102
+ if (!screens) continue;
5103
+ const doc = project.canvas(canvasId).doc;
5104
+ const ids = new Set(canvasFrames(doc, "", "light", { page: options.page, skipMissing: false }).map((job) => job.id));
5105
+ const missing = [...screens].filter((id2) => !ids.has(id2));
5106
+ if (missing.length)
5107
+ throw new CliError(
5108
+ `No screen ${missing.map((id2) => `"${id2}"`).join(", ")} in "${canvasId}"${options.page ? ` on page "${options.page}"` : ""}. Screens: ${[...ids].slice(0, 30).join(", ")}${ids.size > 30 ? ", \u2026" : ""}`
5109
+ );
4946
5110
  }
4947
5111
  const issues = canvases.length ? canvases.flatMap((id2) => project.canvas(id2)?.doc.issues ?? []) : project.issues();
4948
5112
  let frames = [];
4949
5113
  if (options.render) {
4950
- const wasRunning = await liveServer(paths);
4951
- const server = await ensureServer(paths, {});
4952
- if (!wasRunning && !options.json)
4953
- print(dim(`Started the preview server at ${server.url} (\`design stop\` stops it).`));
4954
- const base = `http://127.0.0.1:${server.port}`;
4955
- const docs = (canvases.length ? canvases : known).map((id2) => project.canvas(id2).doc);
4956
- const jobs = docs.flatMap((doc) => canvasFrames(doc, base, doc.theme ?? "light", { includeUrls: false }));
4957
- const system = project.system();
4958
- if (!canvases.length && system) jobs.unshift(...systemFrames(system.doc, base, "light"));
4959
- if (!options.json) print(dim(`Loading ${plural2(jobs.length, "frame")} in Chrome via ${server.url}\u2026`));
4960
- frames = await renderFrames(jobs);
5114
+ let base;
5115
+ let docOf = (id2) => project.canvas(id2).doc;
5116
+ let systemDoc = project.system()?.doc ?? null;
5117
+ let close = async () => {
5118
+ };
5119
+ if (options.built) {
5120
+ const out = path26.join(paths.cache, "check-build");
5121
+ fs29.rmSync(out, { recursive: true, force: true });
5122
+ if (!options.json) print(dim("Building the screens as design push does\u2026"));
5123
+ await buildSite(paths, out, {
5124
+ canvases: canvases.length ? canvases : void 0,
5125
+ includeSystem: !canvases.length,
5126
+ includeViewer: false
5127
+ });
5128
+ const read = (rel) => {
5129
+ try {
5130
+ return JSON.parse(fs29.readFileSync(path26.join(out, rel), "utf8"));
5131
+ } catch {
5132
+ return null;
5133
+ }
5134
+ };
5135
+ docOf = (id2) => read(`api/canvas/${id2}.json`) ?? project.canvas(id2).doc;
5136
+ systemDoc = canvases.length ? null : read("api/system.json");
5137
+ const files = sirv2(out, { dev: true });
5138
+ const server = http2.createServer((req, res) => {
5139
+ const url = new URL(req.url ?? "/", "http://localhost");
5140
+ const segments = url.pathname.split("/").map((segment) => {
5141
+ try {
5142
+ return decodeURIComponent(segment);
5143
+ } catch {
5144
+ return segment;
5145
+ }
5146
+ });
5147
+ req.url = segments.join("/") + url.search;
5148
+ files(req, res, () => {
5149
+ res.statusCode = 404;
5150
+ res.end("Not found");
5151
+ });
5152
+ });
5153
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
5154
+ base = `http://127.0.0.1:${server.address().port}`;
5155
+ close = () => new Promise((resolve) => server.close(() => resolve()));
5156
+ if (!options.json) print(dim(`Serving the build at ${base}`));
5157
+ } else {
5158
+ const wasRunning = await liveServer(paths);
5159
+ const server = await ensureServer(paths, {});
5160
+ if (!wasRunning && !options.json)
5161
+ print(dim(`Started the preview server at ${server.url} (\`design stop\` stops it).`));
5162
+ base = `http://127.0.0.1:${server.port}`;
5163
+ }
5164
+ const jobs = (canvases.length ? canvases : known).flatMap((id2) => {
5165
+ const doc = docOf(id2);
5166
+ const theme2 = doc.theme ?? "light";
5167
+ const screens = picked.get(id2);
5168
+ const filter = { includeUrls: false, page: options.page };
5169
+ return screens ? [...screens].flatMap((screen) => canvasFrames(doc, base, theme2, { ...filter, id: screen })) : canvasFrames(doc, base, theme2, filter);
5170
+ });
5171
+ if (!canvases.length && systemDoc) jobs.unshift(...systemFrames(systemDoc, base, "light"));
5172
+ if (!options.json) print(dim(`Loading ${plural2(jobs.length, "frame")} in Chrome\u2026`));
5173
+ try {
5174
+ frames = await renderFrames(jobs);
5175
+ } finally {
5176
+ await close();
5177
+ }
4961
5178
  }
4962
5179
  const linkIssues = options.render ? unroutedLinks(frames, project, canvases.length ? canvases : known) : [];
4963
5180
  const errors = issues.filter((issue) => issue.severity === "error").length;
@@ -4978,7 +5195,8 @@ async function runCheck(paths, canvases, options) {
4978
5195
  }
4979
5196
  }
4980
5197
  if (linkIssues.length) printLinkIssues(linkIssues, project);
4981
- const scope = canvases.length ? plural2(canvases.length, "canvas", "canvases") : "the project";
5198
+ const screens = [...picked.values()].reduce((sum, set) => sum + (set?.size ?? 0), 0);
5199
+ const scope = !canvases.length ? "the project" : screens && [...picked.values()].every(Boolean) ? plural2(screens, "screen") : options.page ? `page "${options.page}" of ${canvases[0]}` : plural2(canvases.length, "canvas", "canvases");
4982
5200
  if (!errors && !warnings && !broken.length) {
4983
5201
  print(
4984
5202
  green(
@@ -5047,14 +5265,14 @@ function printLinkIssues(issues, project) {
5047
5265
  }
5048
5266
 
5049
5267
  // src/cli/commands/cloud.ts
5050
- import fs29 from "fs";
5051
- import os3 from "os";
5052
- import path26 from "path";
5268
+ import fs30 from "fs";
5269
+ import os4 from "os";
5270
+ import path27 from "path";
5053
5271
  var sleep2 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
5054
5272
  async function runLogin() {
5055
5273
  const host = cloudHost();
5056
5274
  const client = new CloudClient(host, null);
5057
- const code = await client.deviceCode(os3.hostname());
5275
+ const code = await client.deviceCode(os4.hostname());
5058
5276
  const url = new URL(code.verification_uri_complete || code.verification_uri, host).toString();
5059
5277
  const expiresIn = code.expires_in ?? 600;
5060
5278
  print(`${bold("Sign in to foss-design Cloud")} ${dim(host)}`);
@@ -5150,7 +5368,7 @@ async function runLink(paths, wanted, options) {
5150
5368
  print(`${dim("Already linked to")} ${project.name} ${dim(`(${project.id}, ${project.role})`)}`);
5151
5369
  } else {
5152
5370
  writeLink(paths, { host, project: project.id, units: {}, conflicts: {} });
5153
- fs29.rmSync(path26.join(cloudCache(paths), "incoming"), { recursive: true, force: true });
5371
+ fs30.rmSync(path27.join(cloudCache(paths), "incoming"), { recursive: true, force: true });
5154
5372
  const replaced = current ? dim(` (replacing the link to ${current.project})`) : "";
5155
5373
  print(`${green("linked")} .design \u2192 ${bold(project.name)} ${dim(`(${project.id}, ${project.role})`)}${replaced}`);
5156
5374
  }
@@ -5177,8 +5395,8 @@ async function runLink(paths, wanted, options) {
5177
5395
  }
5178
5396
 
5179
5397
  // src/cli/commands/icon.ts
5180
- import fs30 from "fs";
5181
- import path27 from "path";
5398
+ import fs31 from "fs";
5399
+ import path28 from "path";
5182
5400
  async function toCloud(paths, file) {
5183
5401
  const link = readLink(paths);
5184
5402
  if (!link) return;
@@ -5189,10 +5407,10 @@ async function toCloud(paths, file) {
5189
5407
  }
5190
5408
  const client = new CloudClient(link.host, credential.token);
5191
5409
  try {
5192
- const ext = file ? path27.extname(file).slice(1) : null;
5410
+ const ext = file ? path28.extname(file).slice(1) : null;
5193
5411
  await client.setProjectIcon(
5194
5412
  link.project,
5195
- file && ext ? { data: fs30.readFileSync(file), type: ICON_TYPES[ext] } : null
5413
+ file && ext ? { data: fs31.readFileSync(file), type: ICON_TYPES[ext] } : null
5196
5414
  );
5197
5415
  link.icon = file ? iconHash(file) : null;
5198
5416
  writeLink(paths, link);
@@ -5205,35 +5423,35 @@ async function runIcon(paths, file, options) {
5205
5423
  if (options.remove) {
5206
5424
  const had = iconFile(paths);
5207
5425
  writeIcon(paths, null);
5208
- print(had ? `${green("Removed")} ${path27.relative(process.cwd(), had)}` : "The project has no icon.");
5426
+ print(had ? `${green("Removed")} ${path28.relative(process.cwd(), had)}` : "The project has no icon.");
5209
5427
  await toCloud(paths, null);
5210
5428
  return;
5211
5429
  }
5212
5430
  if (!file) {
5213
5431
  const current = iconFile(paths);
5214
5432
  if (!current) return print("The project has no icon. Set one with `design icon <file.svg|png|webp>`.");
5215
- return print(`${path27.relative(process.cwd(), current)} ${dim(`${Math.ceil(fs30.statSync(current).size / 1024)} KB`)}`);
5433
+ return print(`${path28.relative(process.cwd(), current)} ${dim(`${Math.ceil(fs31.statSync(current).size / 1024)} KB`)}`);
5216
5434
  }
5217
5435
  let data;
5218
5436
  try {
5219
- data = fs30.readFileSync(file);
5437
+ data = fs31.readFileSync(file);
5220
5438
  } catch {
5221
5439
  throw new CliError(`No file ${file}`);
5222
5440
  }
5223
- const problem = iconProblem(data, path27.extname(file).slice(1).toLowerCase() || void 0);
5441
+ const problem = iconProblem(data, path28.extname(file).slice(1).toLowerCase() || void 0);
5224
5442
  if (problem) throw new CliError(`${file}: ${problem}`);
5225
5443
  const written = writeIcon(paths, data);
5226
- print(`${green("Set")} the project icon: ${path27.relative(process.cwd(), written)}`);
5444
+ print(`${green("Set")} the project icon: ${path28.relative(process.cwd(), written)}`);
5227
5445
  await toCloud(paths, written);
5228
5446
  }
5229
5447
 
5230
5448
  // src/cli/commands/import.ts
5231
- import fs32 from "fs";
5232
- import path29 from "path";
5449
+ import fs33 from "fs";
5450
+ import path30 from "path";
5233
5451
 
5234
5452
  // src/import/claude-design.ts
5235
- import fs31 from "fs";
5236
- import path28 from "path";
5453
+ import fs32 from "fs";
5454
+ import path29 from "path";
5237
5455
 
5238
5456
  // src/import/markup.ts
5239
5457
  var VOID = /* @__PURE__ */ new Set([
@@ -5841,11 +6059,11 @@ function boardId(file, used) {
5841
6059
  return id2;
5842
6060
  }
5843
6061
  function importClaudeDesign(dir, options = {}) {
5844
- const canvasFile = path28.join(dir, "canvas.json");
5845
- if (!fs31.existsSync(canvasFile))
6062
+ const canvasFile = path29.join(dir, "canvas.json");
6063
+ if (!fs32.existsSync(canvasFile))
5846
6064
  throw new Error(`No canvas.json in ${dir}: point at the exported Claude Design project folder`);
5847
- const source = JSON.parse(fs31.readFileSync(canvasFile, "utf8"));
5848
- const templates = fs31.readdirSync(dir).filter((file) => file.endsWith(EXT));
6065
+ const source = JSON.parse(fs32.readFileSync(canvasFile, "utf8"));
6066
+ const templates = fs32.readdirSync(dir).filter((file) => file.endsWith(EXT));
5849
6067
  if (!templates.length) throw new Error(`No ${EXT} files in ${dir}`);
5850
6068
  const known = new Set(templates.map((file) => file.slice(0, -EXT.length)));
5851
6069
  const warnings = /* @__PURE__ */ new Map();
@@ -5872,7 +6090,7 @@ function importClaudeDesign(dir, options = {}) {
5872
6090
  try {
5873
6091
  files.set(
5874
6092
  `${name}.jsx`,
5875
- convertFile(name, fs31.readFileSync(path28.join(dir, file), "utf8"), { known, routes, warn: warn2 })
6093
+ convertFile(name, fs32.readFileSync(path29.join(dir, file), "utf8"), { known, routes, warn: warn2 })
5876
6094
  );
5877
6095
  } catch (error) {
5878
6096
  warn2(`could not be converted: ${error.message}`);
@@ -5920,7 +6138,7 @@ function importClaudeDesign(dir, options = {}) {
5920
6138
  screens++;
5921
6139
  }
5922
6140
  const canvas = {
5923
- title: options.title ?? source.title ?? path28.basename(path28.resolve(dir)),
6141
+ title: options.title ?? source.title ?? path29.basename(path29.resolve(dir)),
5924
6142
  description: "Imported from Claude Design.",
5925
6143
  // The templates bring their own styles; the design system's reset would restyle them.
5926
6144
  system: false,
@@ -5932,21 +6150,21 @@ function importClaudeDesign(dir, options = {}) {
5932
6150
  // src/cli/commands/import.ts
5933
6151
  var slug = (text2) => text2.normalize("NFKD").replace(/[^\w\s-]/g, "").trim().replace(/[\s_]+/g, "-").toLowerCase();
5934
6152
  function runImport(paths, from, options) {
5935
- const dir = path29.resolve(from);
5936
- if (!fs32.existsSync(dir) || !fs32.statSync(dir).isDirectory()) throw new CliError(`No folder ${from}`);
6153
+ const dir = path30.resolve(from);
6154
+ if (!fs33.existsSync(dir) || !fs33.statSync(dir).isDirectory()) throw new CliError(`No folder ${from}`);
5937
6155
  let result;
5938
6156
  try {
5939
6157
  result = importClaudeDesign(dir, { title: options.title });
5940
6158
  } catch (error) {
5941
6159
  throw new CliError(error.message);
5942
6160
  }
5943
- const id2 = options.canvas ?? (slug(String(result.canvas.title)) || slug(path29.basename(dir)) || "imported");
6161
+ const id2 = options.canvas ?? (slug(String(result.canvas.title)) || slug(path30.basename(dir)) || "imported");
5944
6162
  if (!ID_PATTERN.test(id2)) throw new CliError(`"${id2}" is no canvas id; pass one with --canvas <id>`);
5945
- const out = path29.join(paths.canvases, id2);
5946
- if (fs32.existsSync(out)) throw new CliError(`.design/canvas/${id2} exists; pick another id with --canvas <id>`);
5947
- fs32.mkdirSync(path29.join(out, "screens"), { recursive: true });
5948
- for (const [name, content] of result.files) fs32.writeFileSync(path29.join(out, "screens", name), content);
5949
- fs32.writeFileSync(path29.join(out, "canvas.json"), `${JSON.stringify(result.canvas, null, 2)}
6163
+ const out = path30.join(paths.canvases, id2);
6164
+ if (fs33.existsSync(out)) throw new CliError(`.design/canvas/${id2} exists; pick another id with --canvas <id>`);
6165
+ fs33.mkdirSync(path30.join(out, "screens"), { recursive: true });
6166
+ for (const [name, content] of result.files) fs33.writeFileSync(path30.join(out, "screens", name), content);
6167
+ fs33.writeFileSync(path30.join(out, "canvas.json"), `${JSON.stringify(result.canvas, null, 2)}
5950
6168
  `);
5951
6169
  print(`${green("Imported")} ${bold(String(result.canvas.title))} \u2192 .design/canvas/${id2}`);
5952
6170
  print(
@@ -5966,7 +6184,7 @@ function runImport(paths, from, options) {
5966
6184
 
5967
6185
  // src/cli/commands/merge.ts
5968
6186
  import { spawnSync as spawnSync2 } from "child_process";
5969
- import path30 from "path";
6187
+ import path31 from "path";
5970
6188
  import { createInterface } from "readline/promises";
5971
6189
  var KIND = {
5972
6190
  text: "both sides changed lines, the markers show them",
@@ -5982,7 +6200,7 @@ function matchTargets(paths, states, targets) {
5982
6200
  for (const conflict of openConflicts(paths, unit)) picked.push({ state: unit, conflict });
5983
6201
  continue;
5984
6202
  }
5985
- const rel = [path30.relative(paths.design, path30.resolve(target)), target.replace(/^\.design\//, "")];
6203
+ const rel = [path31.relative(paths.design, path31.resolve(target)), target.replace(/^\.design\//, "")];
5986
6204
  const found = states.flatMap(
5987
6205
  (state) => state.conflicts.filter((item) => rel.includes(item.path)).map((conflict) => ({ state, conflict }))
5988
6206
  );
@@ -5998,7 +6216,7 @@ function printOpen(paths, states) {
5998
6216
  print(`${bold(state.unit)} ${dim(`merged with cloud r${state.headRev}`)}`);
5999
6217
  for (const conflict of open2) {
6000
6218
  print(` ${red("\u2717")} ${conflict.path} ${dim(KIND[conflict.kind])}`);
6001
- const sides = ["here", "cloud", "base"].map((side) => `${side}: ${path30.relative(process.cwd(), sideFile(paths, state.unit, conflict.path, side))}`).join(" \xB7 ");
6219
+ const sides = ["here", "cloud", "base"].map((side) => `${side}: ${path31.relative(process.cwd(), sideFile(paths, state.unit, conflict.path, side))}`).join(" \xB7 ");
6002
6220
  print(` ${dim(sides)}`);
6003
6221
  }
6004
6222
  }
@@ -6079,7 +6297,7 @@ async function runMerge(paths, targets, options) {
6079
6297
  if (answer === "s") break;
6080
6298
  if (answer === "e" && edit) {
6081
6299
  const editor = process.env.VISUAL || process.env.EDITOR || "vi";
6082
- spawnSync2(editor, [path30.join(paths.design, conflict.path)], { stdio: "inherit", shell: true });
6300
+ spawnSync2(editor, [path31.join(paths.design, conflict.path)], { stdio: "inherit", shell: true });
6083
6301
  if (!openConflicts(paths, state).some((item) => item.path === conflict.path)) break;
6084
6302
  print(yellow(" Conflict markers are still there."));
6085
6303
  }
@@ -6098,7 +6316,7 @@ async function runMerge(paths, targets, options) {
6098
6316
  }
6099
6317
 
6100
6318
  // src/cli/commands/rename.ts
6101
- import fs33 from "fs";
6319
+ import fs34 from "fs";
6102
6320
  async function runRename(paths, name) {
6103
6321
  const trimmed = name.trim();
6104
6322
  if (!trimmed) throw new CliError("Usage: design rename <name>");
@@ -6113,17 +6331,17 @@ async function runRename(paths, name) {
6113
6331
  }
6114
6332
  let config = {};
6115
6333
  try {
6116
- config = JSON.parse(fs33.readFileSync(paths.config, "utf8"));
6334
+ config = JSON.parse(fs34.readFileSync(paths.config, "utf8"));
6117
6335
  } catch {
6118
6336
  }
6119
- fs33.writeFileSync(paths.config, `${JSON.stringify({ ...config, name: trimmed }, null, 2)}
6337
+ fs34.writeFileSync(paths.config, `${JSON.stringify({ ...config, name: trimmed }, null, 2)}
6120
6338
  `);
6121
6339
  print(`${green("Renamed")} the project to ${trimmed} (.design/design.json)`);
6122
6340
  }
6123
6341
 
6124
6342
  // src/cli/commands/scaffold.ts
6125
- import fs34 from "fs";
6126
- import path31 from "path";
6343
+ import fs35 from "fs";
6344
+ import path32 from "path";
6127
6345
 
6128
6346
  // src/cli/templates.ts
6129
6347
  var TOKENS_CSS = `/*
@@ -6334,40 +6552,40 @@ function mainScreen(canvasId) {
6334
6552
 
6335
6553
  // src/cli/commands/scaffold.ts
6336
6554
  function write(file, content, root) {
6337
- if (fs34.existsSync(file)) {
6338
- print(` ${dim("exists ")} ${path31.relative(root, file)}`);
6555
+ if (fs35.existsSync(file)) {
6556
+ print(` ${dim("exists ")} ${path32.relative(root, file)}`);
6339
6557
  return false;
6340
6558
  }
6341
- fs34.mkdirSync(path31.dirname(file), { recursive: true });
6342
- fs34.writeFileSync(file, content);
6343
- print(` ${green("created")} ${path31.relative(root, file)}`);
6559
+ fs35.mkdirSync(path32.dirname(file), { recursive: true });
6560
+ fs35.writeFileSync(file, content);
6561
+ print(` ${green("created")} ${path32.relative(root, file)}`);
6344
6562
  return true;
6345
6563
  }
6346
6564
  function ensureGitignored(root) {
6347
- const file = path31.join(root, ".gitignore");
6348
- const current = fs34.existsSync(file) ? fs34.readFileSync(file, "utf8") : "";
6565
+ const file = path32.join(root, ".gitignore");
6566
+ const current = fs35.existsSync(file) ? fs35.readFileSync(file, "utf8") : "";
6349
6567
  const covered = current.split(/\r?\n/).map((line) => line.trim()).some((line) => [".design", ".design/", "/.design", "/.design/", ".design/*"].includes(line));
6350
6568
  if (covered) return "present";
6351
6569
  const prefix = current && !current.endsWith("\n") ? "\n" : "";
6352
- fs34.writeFileSync(file, `${current}${prefix}${current ? "\n" : ""}# Design canvases (foss-design)
6570
+ fs35.writeFileSync(file, `${current}${prefix}${current ? "\n" : ""}# Design canvases (foss-design)
6353
6571
  .design/
6354
6572
  `);
6355
6573
  return "added";
6356
6574
  }
6357
6575
  function projectName(root) {
6358
6576
  try {
6359
- const manifest = JSON.parse(fs34.readFileSync(path31.join(root, "package.json"), "utf8"));
6577
+ const manifest = JSON.parse(fs35.readFileSync(path32.join(root, "package.json"), "utf8"));
6360
6578
  if (typeof manifest.name === "string" && manifest.name) return manifest.name;
6361
6579
  } catch {
6362
6580
  }
6363
- return path31.basename(root);
6581
+ return path32.basename(root);
6364
6582
  }
6365
6583
  function runInit(root, options) {
6366
6584
  const paths = designPaths(root);
6367
6585
  print(`Setting up ${DESIGN_DIR} in ${root}`);
6368
6586
  write(paths.config, `${JSON.stringify({ name: options.name ?? projectName(root) }, null, 2)}
6369
6587
  `, root);
6370
- fs34.mkdirSync(paths.canvases, { recursive: true });
6588
+ fs35.mkdirSync(paths.canvases, { recursive: true });
6371
6589
  if (options.gitignore) {
6372
6590
  const result = ensureGitignored(root);
6373
6591
  print(` ${result === "added" ? green("ignored") : dim("ignored")} .design/ in .gitignore`);
@@ -6381,44 +6599,44 @@ function runSystemInit(root, options) {
6381
6599
  const paths = designPaths(root);
6382
6600
  let base = projectName(root);
6383
6601
  try {
6384
- base = JSON.parse(fs34.readFileSync(paths.config, "utf8")).name || base;
6602
+ base = JSON.parse(fs35.readFileSync(paths.config, "utf8")).name || base;
6385
6603
  } catch {
6386
6604
  }
6387
6605
  const name = options.name ?? `${base} design system`;
6388
- print(`Scaffolding the design system in ${path31.relative(root, paths.system)}`);
6389
- write(path31.join(paths.system, "system.json"), `${JSON.stringify({ name }, null, 2)}
6606
+ print(`Scaffolding the design system in ${path32.relative(root, paths.system)}`);
6607
+ write(path32.join(paths.system, "system.json"), `${JSON.stringify({ name }, null, 2)}
6390
6608
  `, root);
6391
- write(path31.join(paths.system, "tokens.css"), TOKENS_CSS, root);
6609
+ write(path32.join(paths.system, "tokens.css"), TOKENS_CSS, root);
6392
6610
  if (!options.empty) {
6393
- write(path31.join(paths.system, "guidelines", "01-overview.md"), OVERVIEW_MD, root);
6394
- write(path31.join(paths.system, "components", "button.tsx"), BUTTON_TSX, root);
6395
- write(path31.join(paths.system, "specimens", "button.tsx"), BUTTON_SPECIMEN_TSX, root);
6611
+ write(path32.join(paths.system, "guidelines", "01-overview.md"), OVERVIEW_MD, root);
6612
+ write(path32.join(paths.system, "components", "button.tsx"), BUTTON_TSX, root);
6613
+ write(path32.join(paths.system, "specimens", "button.tsx"), BUTTON_SPECIMEN_TSX, root);
6396
6614
  }
6397
6615
  }
6398
6616
  function runNew(root, id2, options) {
6399
6617
  if (!ID_PATTERN.test(id2)) throw new CliError(`"${id2}" is not a valid canvas id: use letters, digits, "-" and "_"`);
6400
6618
  const paths = designPaths(root);
6401
- const dir = path31.join(paths.canvases, id2);
6402
- if (fs34.existsSync(path31.join(dir, "canvas.json"))) throw new CliError(`Canvas "${id2}" already exists in ${dir}`);
6619
+ const dir = path32.join(paths.canvases, id2);
6620
+ if (fs35.existsSync(path32.join(dir, "canvas.json"))) throw new CliError(`Canvas "${id2}" already exists in ${dir}`);
6403
6621
  print(`Creating canvas "${id2}"`);
6404
6622
  const title = options.title ?? titleize(id2);
6405
6623
  if (options.empty) {
6406
6624
  write(
6407
- path31.join(dir, "canvas.json"),
6625
+ path32.join(dir, "canvas.json"),
6408
6626
  `${JSON.stringify({ title, pages: [{ id: "main", title: "Main", sections: [] }] }, null, 2)}
6409
6627
  `,
6410
6628
  root
6411
6629
  );
6412
- fs34.mkdirSync(path31.join(dir, "screens"), { recursive: true });
6630
+ fs35.mkdirSync(path32.join(dir, "screens"), { recursive: true });
6413
6631
  } else {
6414
- write(path31.join(dir, "canvas.json"), canvasJson(title), root);
6415
- write(path31.join(dir, "screens", "Main.tsx"), mainScreen(id2), root);
6632
+ write(path32.join(dir, "canvas.json"), canvasJson(title), root);
6633
+ write(path32.join(dir, "screens", "Main.tsx"), mainScreen(id2), root);
6416
6634
  }
6417
6635
  }
6418
6636
 
6419
6637
  // src/cli/commands/shot.ts
6420
- import fs35 from "fs";
6421
- import path32 from "path";
6638
+ import fs36 from "fs";
6639
+ import path33 from "path";
6422
6640
  var OVERVIEW_TIMEOUT = 9e4;
6423
6641
  async function runShot(paths, target, options) {
6424
6642
  const [canvasId = "", screenId] = target.split("/");
@@ -6434,15 +6652,15 @@ async function runShot(paths, target, options) {
6434
6652
  throw new CliError(`No page "${options.page}" in "${canvasId}". Pages: ${doc.pages.map((p) => p.id).join(", ")}`);
6435
6653
  }
6436
6654
  const theme2 = options.theme ?? doc.theme ?? "light";
6437
- const outBase = path32.resolve(options.out ?? paths.shots);
6438
- const outDir = path32.join(outBase, canvasId);
6439
- fs35.mkdirSync(outDir, { recursive: true });
6655
+ const outBase = path33.resolve(options.out ?? paths.shots);
6656
+ const outDir = path33.join(outBase, canvasId);
6657
+ fs36.mkdirSync(outDir, { recursive: true });
6440
6658
  const base = await serverBase(paths);
6441
6659
  const browser = await launchChrome();
6442
6660
  let failed = false;
6443
6661
  try {
6444
6662
  if (options.overview) {
6445
- if (!fs35.existsSync(PKG.viewer)) throw new CliError("The viewer is not built, so there is no canvas to shoot.");
6663
+ if (!fs36.existsSync(PKG.viewer)) throw new CliError("The viewer is not built, so there is no canvas to shoot.");
6446
6664
  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);
6447
6665
  for (const pageId of pages) {
6448
6666
  const page = await browser.newPage({ viewport: { width: 1920, height: 1200 } });
@@ -6458,7 +6676,7 @@ async function runShot(paths, target, options) {
6458
6676
  warn(`page "${pageId}": not every frame reported ready within ${OVERVIEW_TIMEOUT / 1e3}s; shooting anyway`);
6459
6677
  }
6460
6678
  await page.waitForTimeout(400);
6461
- const file = path32.join(outDir, `${pageId}.overview.png`);
6679
+ const file = path33.join(outDir, `${pageId}.overview.png`);
6462
6680
  await page.screenshot({ path: file });
6463
6681
  await page.close();
6464
6682
  print(file);
@@ -6487,7 +6705,7 @@ async function shootFrames(browser, jobs, outDir) {
6487
6705
  let failed = false;
6488
6706
  const results = await mapLimit(jobs, 4, async (job) => {
6489
6707
  const page = await browser.newPage({ viewport: { width: job.width, height: job.height } });
6490
- const file = path32.join(outDir, `${job.id}.${job.theme}.png`);
6708
+ const file = path33.join(outDir, `${job.id}.${job.theme}.png`);
6491
6709
  try {
6492
6710
  const report = await openFrame(page, job.url, { waitForReady: job.waitForReady });
6493
6711
  await page.screenshot({ path: file, fullPage: job.fullPage });
@@ -6519,8 +6737,8 @@ async function shootSystem(paths, project, id2, options) {
6519
6737
  if (id2 && !all.some((job) => job.id === id2)) {
6520
6738
  throw new CliError(`No specimen "${id2}" in the design system. Specimens: ${all.map((job) => job.id).join(", ")}`);
6521
6739
  }
6522
- const outDir = path32.join(path32.resolve(options.out ?? paths.shots), SYSTEM_CANVAS);
6523
- fs35.mkdirSync(outDir, { recursive: true });
6740
+ const outDir = path33.join(path33.resolve(options.out ?? paths.shots), SYSTEM_CANVAS);
6741
+ fs36.mkdirSync(outDir, { recursive: true });
6524
6742
  const base = await serverBase(paths);
6525
6743
  const jobs = systemFrames(system.doc, base, theme2).filter((job) => !id2 || job.id === id2);
6526
6744
  const browser = await launchChrome();
@@ -6531,8 +6749,36 @@ async function shootSystem(paths, project, id2, options) {
6531
6749
  }
6532
6750
  }
6533
6751
 
6752
+ // src/cli/commands/snapshots.ts
6753
+ async function runSnapshots(paths, targets, options) {
6754
+ const known = new DesignProject(paths, DEV_URLS).canvasIds();
6755
+ const picked = /* @__PURE__ */ new Map();
6756
+ for (const target of targets) {
6757
+ const [canvas = "", screen] = target.split("/");
6758
+ if (!known.includes(canvas))
6759
+ throw new CliError(`No canvas "${canvas}". Canvases: ${known.join(", ") || "none yet"}`);
6760
+ const screens = picked.has(canvas) ? picked.get(canvas) : /* @__PURE__ */ new Set();
6761
+ picked.set(canvas, !screen || screens === null ? null : screens.add(screen));
6762
+ }
6763
+ if (options.page && picked.size !== 1)
6764
+ throw new CliError("--page needs exactly one canvas: `design snapshots <canvas> --page <id>`");
6765
+ const canvases = picked.size ? [...picked.keys()] : known;
6766
+ const stale = canvases.flatMap(
6767
+ (canvas) => staleSnapshots(paths, [canvas], { page: options.page, screens: picked.get(canvas) ?? void 0 })
6768
+ );
6769
+ if (!stale.length) return print(green("\u2713 Every screen has current snapshots in both themes."));
6770
+ print(dim(`${stale.length} snapshot${stale.length === 1 ? "" : "s"} to take`));
6771
+ const lines = await takeSnapshots(
6772
+ paths,
6773
+ stale,
6774
+ async () => `http://127.0.0.1:${(await ensureServer(paths, {})).port}`,
6775
+ progress()
6776
+ );
6777
+ for (const line of lines) print(line);
6778
+ }
6779
+
6534
6780
  // src/cli/commands/cloud-status.ts
6535
- import path33 from "path";
6781
+ import path34 from "path";
6536
6782
  var STATE_TEXT = {
6537
6783
  in_sync: green,
6538
6784
  local_changes: yellow,
@@ -6555,7 +6801,7 @@ function detail(paths, status) {
6555
6801
  if (status.state === "remote_ahead" && !status.local && !status.baseRev) parts.push("not pulled yet");
6556
6802
  if (status.state === "remote_ahead" && status.changed.length && !status.deletedRemotely)
6557
6803
  parts.push(`${plural(status.changed.length, "file")} changed here; the cloud only rebuilt it`);
6558
- if (status.conflictRev !== void 0) parts.push(`merge ${path33.relative(paths.root, incomingDir(paths, status.key))}`);
6804
+ if (status.conflictRev !== void 0) parts.push(`merge ${path34.relative(paths.root, incomingDir(paths, status.key))}`);
6559
6805
  return parts.join(" \xB7 ");
6560
6806
  }
6561
6807
  async function printCloudStatus(paths, label) {
@@ -6685,15 +6931,23 @@ ${bold("Preview")}
6685
6931
  it; one from another version restarts) and print its URL;
6686
6932
  --open also opens a browser
6687
6933
  [--restart] [--foreground]
6688
- stop Stop the viewer
6934
+ stop [--all] Stop the viewer; --all stops every preview on this machine
6935
+ previews [--json] Every preview running on this machine: project, URL,
6936
+ version, uptime; orphans (a project's untracked server) marked
6689
6937
  status Viewer state, a project summary and, with a cloud link, the
6690
6938
  account, the linked project and each unit: in sync, local
6691
6939
  changes, remote ahead, conflict or archived
6692
6940
 
6693
6941
  ${bold("Verify")}
6694
- check [canvas\u2026] [--render] [--json] Validate canvas.json and the system (go() is an error);
6695
- --render loads every screen in Chrome and reports runtime
6696
- errors and links that lead to no screen
6942
+ check [<canvas>[/<screen>]\u2026] [--page <id>] Validate canvas.json and the system (go() is an error);
6943
+ [--render [--built]] [--json] --render loads every screen in Chrome and reports runtime
6944
+ errors and links that lead to no screen; --built renders
6945
+ the production build design push uploads instead of the
6946
+ dev server. Narrow it to canvases, one page (--page) or
6947
+ single screens
6948
+ snapshots [<canvas>[/<screen>]\u2026] Take the snapshots screens lack, in both themes, for the
6949
+ [--page <id>] viewer to show while frames do not run (push does this
6950
+ for the canvases it pushes)
6697
6951
  shot <canvas>[/<screen>] [--page <id>] Screenshot screens with Chrome (PNG paths are printed);
6698
6952
  [--theme light|dark] [--out <dir>] @system or @system/<id> shoots the design system specimens
6699
6953
  [--overview]
@@ -6712,7 +6966,7 @@ ${bold("Cloud")} ${dim("units: system (design.json + system/) and canvas/<id>;
6712
6966
  push [canvas\u2026] [--json] Build and upload what changed here (or the named units); a
6713
6967
  [--resolved <unit>] system change pushes every active canvas with it. When the
6714
6968
  cloud is ahead it pulls and merges first, then pushes; it
6715
- stops on conflicts. Refreshes each canvas's cover snapshot.
6969
+ stops on conflicts. Takes missing snapshots (both themes).
6716
6970
  Prints the web link of every pushed canvas; refuses screens
6717
6971
  that still call go()
6718
6972
  pull [canvas\u2026] [--json] Take cloud changes into .design; a unit changed on both sides
@@ -6742,7 +6996,7 @@ ${bold("Environment")}
6742
6996
  FOSS_DESIGN_CLOUD Cloud to sign in and link to (default https://app.fossdesign.dev)
6743
6997
  `;
6744
6998
  function projectPaths(rootOption, requireDesign = true) {
6745
- const found = rootOption ? path34.resolve(rootOption) : findProjectRoot();
6999
+ const found = rootOption ? path35.resolve(rootOption) : findProjectRoot();
6746
7000
  if (!found) {
6747
7001
  if (!requireDesign) return designPaths(process.cwd());
6748
7002
  throw new CliError("No .design folder here or above. Run `design init` in the project root first.");
@@ -6769,6 +7023,8 @@ async function main(argv) {
6769
7023
  open: { type: "boolean" },
6770
7024
  port: { type: "string" },
6771
7025
  restart: { type: "boolean" },
7026
+ all: { type: "boolean" },
7027
+ built: { type: "boolean" },
6772
7028
  foreground: { type: "boolean" },
6773
7029
  render: { type: "boolean" },
6774
7030
  json: { type: "boolean" },
@@ -6793,7 +7049,7 @@ async function main(argv) {
6793
7049
  if (values.theme && !theme2) throw new CliError("--theme is light or dark");
6794
7050
  switch (command) {
6795
7051
  case "init":
6796
- return runInit(values.root ? path34.resolve(values.root) : process.cwd(), {
7052
+ return runInit(values.root ? path35.resolve(values.root) : process.cwd(), {
6797
7053
  name: values.name,
6798
7054
  gitignore: !values["no-gitignore"]
6799
7055
  });
@@ -6831,11 +7087,20 @@ async function main(argv) {
6831
7087
  });
6832
7088
  }
6833
7089
  case "stop":
6834
- return runStop(projectPaths(values.root));
7090
+ return values.all ? runStopAll() : runStop(projectPaths(values.root));
7091
+ case "previews":
7092
+ return runPreviews({ json: !!values.json });
6835
7093
  case "status":
6836
7094
  return runStatus(projectPaths(values.root));
6837
7095
  case "check":
6838
- return runCheck(projectPaths(values.root), rest, { render: !!values.render, json: !!values.json });
7096
+ return runCheck(projectPaths(values.root), rest, {
7097
+ render: !!values.render,
7098
+ json: !!values.json,
7099
+ page: values.page,
7100
+ built: !!values.built
7101
+ });
7102
+ case "snapshots":
7103
+ return runSnapshots(projectPaths(values.root), rest, { page: values.page });
6839
7104
  case "shot": {
6840
7105
  if (!rest[0]) throw new CliError("Usage: design shot <canvas>[/<screen>] [--page <id>] [--theme light|dark]");
6841
7106
  return runShot(projectPaths(values.root), rest[0], {
@@ -6884,20 +7149,42 @@ async function main(argv) {
6884
7149
  throw new CliError(`Unknown command "${command}". Run \`design --help\`.`);
6885
7150
  }
6886
7151
  }
6887
- main(process.argv.slice(2)).catch((error) => {
6888
- endLine();
6889
- if (error instanceof CliError) {
6890
- process.stderr.write(`${red("error")} ${error.message}
6891
- `);
6892
- process.exit(error.code);
7152
+ function relaunchFromRealPath() {
7153
+ if (process.platform !== "win32" || process.env.FOSS_DESIGN_RELAUNCHED) return false;
7154
+ const script = process.argv[1];
7155
+ if (!script) return false;
7156
+ let realScript;
7157
+ let realCwd;
7158
+ try {
7159
+ realScript = fs37.realpathSync.native(script);
7160
+ realCwd = fs37.realpathSync.native(process.cwd());
7161
+ } catch {
7162
+ return false;
6893
7163
  }
6894
- if (error.code?.startsWith("ERR_PARSE_ARGS")) {
6895
- process.stderr.write(`${red("error")} ${error.message}
7164
+ const same = (a, b) => path35.resolve(a).toLowerCase() === path35.resolve(b).toLowerCase();
7165
+ if (same(realScript, script) && same(realCwd, process.cwd())) return false;
7166
+ const result = spawnSync3(process.execPath, [...process.execArgv, realScript, ...process.argv.slice(2)], {
7167
+ stdio: "inherit",
7168
+ cwd: realCwd,
7169
+ env: { ...process.env, FOSS_DESIGN_RELAUNCHED: "1" }
7170
+ });
7171
+ process.exit(result.status ?? 1);
7172
+ }
7173
+ if (!relaunchFromRealPath())
7174
+ main(process.argv.slice(2)).catch((error) => {
7175
+ endLine();
7176
+ if (error instanceof CliError) {
7177
+ process.stderr.write(`${red("error")} ${error.message}
7178
+ `);
7179
+ process.exit(error.code);
7180
+ }
7181
+ if (error.code?.startsWith("ERR_PARSE_ARGS")) {
7182
+ process.stderr.write(`${red("error")} ${error.message}
6896
7183
  Run \`design --help\`.
6897
7184
  `);
6898
- process.exit(2);
6899
- }
6900
- process.stderr.write(`${red("error")} ${error?.stack ?? String(error)}
7185
+ process.exit(2);
7186
+ }
7187
+ process.stderr.write(`${red("error")} ${error?.stack ?? String(error)}
6901
7188
  `);
6902
- process.exit(1);
6903
- });
7189
+ process.exit(1);
7190
+ });
@@ -1,3 +1,57 @@
1
+ // src/runtime/storage.ts
2
+ var frame = new URLSearchParams(location.search).get("__design") || location.pathname;
3
+ function memoryStorage() {
4
+ const data = /* @__PURE__ */ new Map();
5
+ const api = {
6
+ get length() {
7
+ return data.size;
8
+ },
9
+ clear: () => data.clear(),
10
+ getItem: (key) => data.get(String(key)) ?? null,
11
+ key: (index) => [...data.keys()][index] ?? null,
12
+ removeItem: (key) => void data.delete(String(key)),
13
+ setItem: (key, value) => void data.set(String(key), String(value))
14
+ };
15
+ return new Proxy(api, {
16
+ get: (target, prop) => typeof prop === "string" && !(prop in target) ? data.get(prop) ?? void 0 : Reflect.get(target, prop),
17
+ set: (target, prop, value) => {
18
+ if (typeof prop === "string" && !(prop in target)) data.set(prop, String(value));
19
+ return true;
20
+ },
21
+ deleteProperty: (_, prop) => typeof prop === "string" ? data.delete(prop) || true : true,
22
+ has: (target, prop) => typeof prop === "string" && data.has(prop) || prop in target,
23
+ ownKeys: () => [...data.keys()],
24
+ getOwnPropertyDescriptor: (_, prop) => typeof prop === "string" && data.has(prop) ? { value: data.get(prop), writable: true, enumerable: true, configurable: true } : void 0
25
+ });
26
+ }
27
+ for (const name of ["localStorage", "sessionStorage"]) {
28
+ try {
29
+ Object.defineProperty(window, name, { configurable: true, value: memoryStorage() });
30
+ } catch {
31
+ }
32
+ }
33
+ var idb = window.indexedDB;
34
+ if (idb) {
35
+ const own = `design:${frame}:`;
36
+ const prefix = `${own}${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}:`;
37
+ const open = idb.open.bind(idb);
38
+ const remove = idb.deleteDatabase.bind(idb);
39
+ const list = idb.databases?.bind(idb);
40
+ idb.open = (name, version) => open(prefix + name, version);
41
+ idb.deleteDatabase = (name) => remove(prefix + name);
42
+ if (list) {
43
+ idb.databases = async () => (await list()).filter((db) => db.name?.startsWith(prefix)).map((db) => ({ ...db, name: db.name.slice(prefix.length) }));
44
+ void list().then((dbs) => {
45
+ for (const db of dbs) {
46
+ if (!db.name?.startsWith(own) || db.name.startsWith(prefix)) continue;
47
+ const born = Number.parseInt(db.name.slice(own.length).split(":")[0].slice(0, -4), 36);
48
+ if (!Number.isFinite(born) || Date.now() - born > 10 * 60 * 1e3) remove(db.name);
49
+ }
50
+ }).catch(() => {
51
+ });
52
+ }
53
+ }
54
+
1
55
  // src/runtime/index.tsx
2
56
  import { Component, useSyncExternalStore as useSyncExternalStore2 } from "react";
3
57
  import { createRoot } from "react-dom/client";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "foss-design",
3
- "version": "0.9.5",
3
+ "version": "0.9.6",
4
4
  "description": "Design canvases and design systems for coding agents: live, animated, clickable screens on an infinite canvas, served locally from a .design folder.",
5
5
  "type": "module",
6
6
  "license": "MIT",