twombly 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -3,9 +3,9 @@
3
3
  // src/cli.ts
4
4
  import { spawn as spawn2 } from "node:child_process";
5
5
  import { randomBytes as randomBytes2 } from "node:crypto";
6
- import { copyFile, mkdir as mkdir6, mkdtemp, rm as rm4, stat as stat5, writeFile as writeFile6 } from "node:fs/promises";
6
+ import { copyFile, mkdir as mkdir7, mkdtemp, readFile as readFile7, rm as rm5, stat as stat5, writeFile as writeFile7 } from "node:fs/promises";
7
7
  import { tmpdir } from "node:os";
8
- import { basename as basename2, extname as extname3, join as join8, resolve as resolve2 } from "node:path";
8
+ import { basename as basename2, extname as extname3, join as join9, resolve as resolve2 } from "node:path";
9
9
  import { fileURLToPath as fileURLToPath2 } from "node:url";
10
10
 
11
11
  // ../protocol/src/gates.ts
@@ -17,7 +17,9 @@ var LIMITS = {
17
17
  scanBytes: 8 * 1024 * 1024,
18
18
  titleChars: 120,
19
19
  nameChars: 120,
20
- commentChars: 5e3
20
+ commentChars: 5e3,
21
+ strokePoints: 3e3,
22
+ canvasStrokes: 2e3
21
23
  };
22
24
  var STATIC_FIX = "Frames are static front-end files only. Delete it from the folder; if the design needs what it provided, use mock data.";
23
25
  var FORBIDDEN = [
@@ -231,8 +233,18 @@ var FRAME_ONLY_MESSAGE = "This frame only opens inside a canvas.";
231
233
 
232
234
  // ../protocol/src/index.ts
233
235
  var FIDELITY_LEVELS = ["build", "component", "capture", "image"];
236
+ function cleanStroke(input) {
237
+ const s = input;
238
+ if (!s || typeof s !== "object" || typeof s.id !== "string" || !/^[a-z0-9]{8,32}$/.test(s.id)) return null;
239
+ if (typeof s.color !== "string" || !/^#[0-9a-f]{6}$/i.test(s.color)) return null;
240
+ if (typeof s.size !== "number" || !Number.isFinite(s.size) || s.size <= 0 || s.size > 2e3) return null;
241
+ if (!Array.isArray(s.points) || !s.points.length || s.points.length > LIMITS.strokePoints) return null;
242
+ if (!s.points.every((p) => Array.isArray(p) && p.length === 3 && p.every((n) => typeof n === "number" && Number.isFinite(n)))) return null;
243
+ return { id: s.id, frameId: typeof s.frameId === "string" && FRAME_ID.test(s.frameId) ? s.frameId : null, points: s.points, color: s.color, size: s.size };
244
+ }
234
245
  var WORKING_MS = 3 * 6e4;
235
246
  var WORKING_TAIL_MS = 3e4;
247
+ var FRAME_ID = /^[a-km-z2-9]{8,32}$/;
236
248
  var FRAME_GAP = 80;
237
249
  function nextPosition(frames, newRow = false) {
238
250
  if (!frames.length) return { x: 0, y: 0 };
@@ -243,7 +255,7 @@ function nextPosition(frames, newRow = false) {
243
255
  }
244
256
 
245
257
  // ../../skills/twombly/SKILL.md
246
- var SKILL_default = "---\nname: twombly\ndescription: Lay out design explorations on a shared canvas. Use when asked to \"put this on Twombly\", \"put these on a canvas\", \"lay out these versions\", \"show me all the variations side by side\", or to share explorations with real-time cursors and comments. Works with any framework in any repo.\n---\n\n# Twombly\n\nTwombly shows design work as live frames on an infinite canvas. You build each version, turn it into a folder of static files, and add it. The canvas opens on `localhost`; `init` returns its address. Nothing is online until the person clicks Share. After that the local canvas and the link are one state: everything you add, update or move appears for everyone straight away.\n\nRun commands with `twombly <command>`, or `npx -y twombly <command>` if it isn't installed. Output is JSON.\n\n## The rule that decides everything\n\n**A frame is a folder of static files that runs with no server and no network.** It can use anything in its own folder and nothing else. The browser enforces this: outside requests, form posts and server code all fail, locally and once shared.\n\nThe person's repo may have a database, an API, auth and environment keys. None of that goes in a frame. Your job is to adapt the design so it works without them:\n\n- **Data and APIs:** replace with realistic mock data, written inline or as JSON in the folder.\n- **Auth and providers:** skip login, and mount the design with mock providers and a fake signed-in user.\n- **Environment values:** never include them. Build with none set. Don't copy `.env` files. If the build needs a value to compile, use an obviously fake one.\n- **Analytics and widgets:** remove tag managers, analytics, chat widgets and similar scripts, including any in page templates such as `app.html` or `_document`.\n- **Real customer data:** never copy it from a database, fixture or log. Invent names, emails and numbers. The gates can't detect this, so it's on you. Links are public.\n- **Fonts and images:** copy the files into the folder with relative or root paths. For a hosted font such as Google Fonts, download the files and use `@font-face`. If a font is licensed, tell the person it will be publicly hosted. Leave out images the design doesn't show: a frame is limited to 100 MB.\n- **One screen of a routed app:** a router reads the page's address, which won't match the screen you want. Write a small entry file that mounts that screen with a memory or hash router, instead of trying to reach its route.\n\n## Steps\n\n1. **Start the canvas.** From the project root: `twombly init --name \"Notification Explorations\" --no-open`. Pick a short, specific name. Run it once per canvas. The result's `local` field is the canvas's address.\n2. **Open it beside the chat.** The person should see the canvas next to this conversation, in the app they're already using. Open the `local` address in that app's built-in browser: the Claude desktop app's browser pane, the ChatGPT app's in-app browser, or the built-in browser in Cursor, VS Code or any other editor. Only if the app has none, run `twombly open`, which uses their default browser.\n3. **Build each version** as its own static folder, keeping work under `.twombly/work/<version>` so the person's source stays clean. Use the first approach below that works. Run `twombly working` before each build: the canvas background moves for three minutes so people can see you're at work, and running it again extends that.\n4. **Add it:** `twombly add <folder> --title \"V1 Compact\" --width 1280 --height 800 --level build`, saying which level below it is. Use the design's real size, such as `--width 390 --height 844` for a phone. Use `--height auto` to fit a tall page.\n5. **Read the output.** If `ok` is false, fix every item in `problems` and run `add` again. Each one includes a `fix`. The frame isn't placed until it passes.\n6. **Look at it.** The result has a `screenshot` path. Open it and check the frame shows the design you meant, with the content in the right state. The gates can't tell if a page came out unstyled or on the wrong screen; a common miss is Tailwind classes that were never generated.\n7. **Finish:** run `twombly done` so the background goes still, then tell the person the canvas is open. They click Share when they're ready. The first time, Google sign-in opens in their regular browser, and sharing continues on its own once they finish.\n\n## Choosing how to build a frame\n\nUse the first one that fits. Say so if you end up lower than the first, because the frame is less faithful. **If the app has server routes, auth or a database, skip straight to `component`**: a static export of that kind of app rarely works and isn't worth the attempt.\n\n1. `--level build`: **a real static build.** Vite: `vite build --base ./ --outDir .twombly/work/v1`. Next.js: `output: 'export'` in a temporary config (static export can't use server features). SvelteKit: `adapter-static` with a `fallback`, `ssr = false` in the root layout, and any server `load` swapped for mock data in a universal `+layout.ts`. Astro: `astro build`. Plain HTML: use the folder as is. To show one state of a bigger app, add a build entry that renders just that screen.\n2. `--level component`: **the component on its own.** Write a tiny entry file that imports the real component with mock props, and bundle it with Vite or esbuild into a folder. This is the usual answer when the app needs a backend. It keeps the real code and styles.\n3. `--level capture`: **freeze what's on screen.** With the app already running: `twombly capture http://localhost:3000/page --out .twombly/work/v1 --width 1280 --height 800`, then add that folder. It looks right but no longer responds, because scripts are removed.\n4. `--level image`: **a picture**, as a last resort. `twombly capture <url> --out <folder> --image`, or `twombly add shot.png --title \"V1\"`.\n\nSeveral versions can share one build: read the variant from `location.search` and pass `--entry \"index.html?variant=2\"`. Shared files are only uploaded once.\n\n### Example: the component level with Vite\n\nFor most projects, this is all it takes. It works the same for React, Svelte or Vue with that framework's Vite plugin. Keep it under `.twombly/work`.\n\n```js\n// .twombly/work/vite.config.mjs\nimport { defineConfig } from 'vite';\nimport { resolve } from 'node:path';\nimport react from '@vitejs/plugin-react';\n\nconst root = resolve(import.meta.dirname, '../..');\nexport default defineConfig({\n root: import.meta.dirname,\n base: './',\n plugins: [react()],\n resolve: { alias: { '@': root } }, // match the project's path aliases\n define: { 'process.env': {} },\n build: { outDir: 'out', emptyOutDir: true, sourcemap: false },\n});\n```\n\n```html\n<!-- .twombly/work/index.html -->\n<!doctype html><html><body><div id=\"root\"></div><script type=\"module\" src=\"./main.jsx\"></script></body></html>\n```\n\n```jsx\n// .twombly/work/main.jsx: the real component, the real styles, invented data.\nimport { createRoot } from 'react-dom/client';\nimport '@/styles/globals.css';\nimport { Composer } from '@/components/composer';\n\nconst variant = new URLSearchParams(location.search).get('variant');\ncreateRoot(document.getElementById('root')).render(<Composer variant={variant} onSend={() => {}} messages={MOCK_MESSAGES} />);\n```\n\nThen: `npx vite build --config .twombly/work/vite.config.mjs` and `twombly add .twombly/work/out --title \"A\" --entry \"index.html?variant=a\" --level component`. If the project uses Tailwind, add its Vite or PostCSS plugin to the config so the classes are generated.\n\n## Commands\n\n```sh\ntwombly init [--name \"Name\"] [--no-open] # create or reopen this project's canvas\ntwombly add <folder|file> --title \"T\" [--width 1280] [--height 800|auto] [--entry index.html] [--level build] [--new-row] [--x N --y N]\ntwombly update <frame> <folder|file> [--title \"T\"] [--width N] [--height N|auto] [--entry path] [--level build]\ntwombly check <folder> [--entry index.html] [--width 1280] [--height 800] # run the gates without adding\ntwombly list # frames, positions and the live link\ntwombly move <frame> --x N --y N\ntwombly resize <frame> --width N --height N\ntwombly retitle <frame> \"New title\"\ntwombly remove <frame>\ntwombly arrange [--columns N] # tidy into a grid\ntwombly rename \"Canvas name\"\ntwombly capture <url> --out <folder> [--width N] [--height N] [--image] [--selector css]\ntwombly working # show on the canvas that you're at work, for three minutes\ntwombly done # you've finished: the canvas goes still\ntwombly open # open the canvas in the default browser\ntwombly status # sign-in and canvas state\n```\n\n`<frame>` is a frame's id or its exact title. Frames fill in left to right; use `--new-row` to start a new group, for example one row per direction.\n\nUse `update` to change a version. Don't remove and re-add it, because that loses its comments. Each frame keeps its own copy of its files, so if several frames came from one build, `update` each one.\n\n## Sharing\n\nThe person shares by clicking Share. Only run `twombly share` if they explicitly ask you to, because it publishes the canvas to a public link. If they ask you to take it down, run `twombly unshare`. That deletes the link and its comments and leaves the local canvas.\n\nOnce it's shared, every `add`, `update`, `move` and `remove` goes live immediately. Check your work with `twombly check` before updating a canvas people are looking at.\n";
258
+ var SKILL_default = "---\nname: twombly\ndescription: Lay out design explorations on a shared canvas. Use when asked to \"put this on Twombly\", \"put these on a canvas\", \"lay out these versions\", \"show me all the variations side by side\", or to share explorations with real-time cursors and comments. Works with any framework in any repo.\n---\n\n# Twombly\n\nTwombly shows design work as live frames on an infinite canvas. You build each version, turn it into a folder of static files, and add it. The canvas opens on `localhost`; `init` returns its address. Nothing is online until the person clicks Share. After that the local canvas and the link are one state: everything you add, update or move appears for everyone straight away.\n\nRun every command as `npx -y twombly@latest <command>`; this guide shortens that to `twombly <command>`. Output is JSON.\n\n## The rule that decides everything\n\n**A frame is a folder of static files that runs with no server and no network.** It can use anything in its own folder and nothing else. The browser enforces this: outside requests, form posts and server code all fail, locally and once shared.\n\nThe person's repo may have a database, an API, auth and environment keys. None of that goes in a frame. Your job is to adapt the design so it works without them:\n\n- **Data and APIs:** replace with realistic mock data, written inline or as JSON in the folder. For public CMS content, save a snapshot as JSON when you build.\n- **Auth and providers:** skip login, and mount the design with mock providers and a fake signed-in user.\n- **Environment values:** never include them. Build with none set. Don't copy `.env` files. If the build needs a value to compile, use an obviously fake one. **Never run or read a secret manager such as Infisical or Doppler for this, even if the project is set up for it.** If you can't get what you need without one, ask the person.\n- **Analytics and widgets:** remove tag managers, analytics, chat widgets and similar scripts, including any in page templates such as `app.html` or `_document`.\n- **Real customer data:** never copy it from a database, fixture or log. Invent names, emails and numbers. The gates can't detect this, so it's on you. Links are public.\n- **Fonts, images and video:** copy the files into the folder with relative or root paths. For a hosted font such as Google Fonts, download the files and use `@font-face`; Google only serves `woff2` to a browser user agent. If a font is licensed, tell the person it will be publicly hosted. Add up media sizes before downloading, take only what the design shows, and compress video.\n- **One screen of a routed app:** a router reads the page's address, which won't match the screen you want. Write a small entry file that mounts that screen with a memory or hash router, instead of trying to reach its route.\n\n## Limits\n\n100 MB per frame, 25 MB per file, 5000 files per frame, 200 frames per canvas. A URL can't be added, not even `localhost`: build it or `capture` it. Refused files: `.env`, credentials, keys, databases, source maps, server code, `node_modules` and project files such as `package.json`. Pop-ups, camera and microphone are off.\n\n## Steps\n\n1. **Start the canvas.** From the project root, or in a monorepo from the app's own folder so `.twombly/work` can find its packages: `twombly init --name \"Notification Explorations\" --no-open`. Pick a short, specific name. Run it once per canvas. The result's `local` field is the canvas's address.\n2. **Open it beside the chat.** The person should see the canvas next to this conversation, in the app they're already using. Open the `local` address in that app's built-in browser: the Claude desktop app's browser pane, the ChatGPT app's in-app browser, or the built-in browser in Cursor, VS Code or any other editor. Only if the app has none, run `twombly open`, which uses their default browser.\n3. **Build each version** as its own static folder, keeping work under `.twombly/work/<version>` so the person's source stays clean. Use the first approach below that works. The canvas background moves so people can see you're at work: `init` and the slow commands keep it going for three minutes. Run `twombly working` only before a longer gap.\n4. **Add it:** `twombly add <folder> --title \"V1 Compact\" --width 1280 --height 800 --level build`, saying which level below it is. Use the design's real size, such as `--width 390 --height 844` for a phone. Use `--height auto` to fit a tall page.\n5. **Read the output.** If `ok` is false, fix every item in `problems` and run `add` again. Each one includes a `fix`. The frame isn't placed until it passes.\n6. **Look at it.** The result has a `screenshot` path. Open it and check the frame shows the design you meant, with the content in the right state. The gates can't tell if a page came out unstyled or on the wrong screen; a common miss is Tailwind classes that were never generated. Tailwind 4 skips gitignored folders, so source copied into `.twombly` needs an `@source`.\n7. **Test interactions, if they matter.** Open the `url` from `twombly preview <frame>` in your browser tool and use the page inside its iframe. On the canvas, a frame ignores clicks until it's double-clicked, and frame URLs refuse to open on their own. Tell the person what you didn't test.\n8. **Finish:** run `twombly done` so the background goes still, then tell the person the canvas is open. They click Share when they're ready. The first time, Google sign-in opens in their regular browser, and sharing continues on its own once they finish.\n\n## Choosing how to build a frame\n\nUse the first one that fits. Say so if you end up lower than the first, because the frame is less faithful. **If the app has server routes, auth or a database, skip straight to `component`**: a static export of that kind of app rarely works and isn't worth the attempt.\n\n1. `--level build`: **a real static build.** Vite: `vite build --base ./ --outDir .twombly/work/v1`. Next.js: `output: 'export'` in a temporary config (static export can't use server features). SvelteKit: `adapter-static` with a `fallback`, `ssr = false` in the root layout, and any server `load` swapped for mock data in a universal `+layout.ts`. Astro: `astro build`. Plain HTML: use the folder as is. To show one state of a bigger app, add a build entry that renders just that screen.\n2. `--level component`: **the component on its own.** Write a tiny entry file that imports the real component with mock props, and bundle it with Vite or esbuild into a folder. This is the usual answer when the app needs a backend. It keeps the real code and styles.\n3. `--level capture`: **freeze what's on screen.** With the app already running: `twombly capture http://localhost:3000/page --out .twombly/work/v1 --width 1280 --height 800`, then add that folder. It looks right but no longer responds, because scripts are removed.\n4. `--level image`: **a picture**, as a last resort. `twombly capture <url> --out <folder> --image`, or `twombly add shot.png --title \"V1\"`.\n\nSeveral versions can share one build: read the variant from `location.search` and pass `--entry \"index.html?variant=2\"`. Shared files are only uploaded once.\n\nAdd several frames in one command, which is faster, instead of writing a loop. Nothing is placed unless all of them pass. Each item takes `folder`, `title`, `entry`, `width`, `height`, `level`, `newRow`, `x` and `y`, and flags on the command apply to all:\n\n```sh\ntwombly add out --width 1280 --height 800 --level component --frames '[{\"title\":\"A\",\"entry\":\"index.html?variant=a\"},{\"title\":\"B\",\"entry\":\"index.html?variant=b\"}]'\n```\n\n### Example: the component level with Vite\n\nFor most projects, this is all it takes. It works the same for React, Svelte or Vue with that framework's Vite plugin. Keep it under `.twombly/work`.\n\n```js\n// .twombly/work/vite.config.mjs\nimport { defineConfig } from 'vite';\nimport { resolve } from 'node:path';\nimport react from '@vitejs/plugin-react';\n\nconst root = resolve(import.meta.dirname, '../..');\nexport default defineConfig({\n root: import.meta.dirname,\n base: './',\n plugins: [react()],\n resolve: { alias: { '@': root } }, // match the project's path aliases\n define: { 'process.env': {} },\n build: { outDir: 'out', emptyOutDir: true, sourcemap: false },\n});\n```\n\n```html\n<!-- .twombly/work/index.html -->\n<!doctype html><html><body><div id=\"root\"></div><script type=\"module\" src=\"./main.jsx\"></script></body></html>\n```\n\n```jsx\n// .twombly/work/main.jsx: the real component, the real styles, invented data.\nimport { createRoot } from 'react-dom/client';\nimport '@/styles/globals.css';\nimport { Composer } from '@/components/composer';\n\nconst variant = new URLSearchParams(location.search).get('variant');\ncreateRoot(document.getElementById('root')).render(<Composer variant={variant} onSend={() => {}} messages={MOCK_MESSAGES} />);\n```\n\nThen: `npx vite build --config .twombly/work/vite.config.mjs` and `twombly add .twombly/work/out --title \"A\" --entry \"index.html?variant=a\" --level component`. If the project uses Tailwind, add its Vite or PostCSS plugin to the config so the classes are generated.\n\n## Commands\n\n`twombly help` lists every command and flag. `<frame>` is a frame's id or its exact title. Frames fill in left to right; use `--new-row` to start a new group, for example one row per direction.\n\nUse `update` to change a version. Don't remove and re-add it, because that loses its comments. It keeps the frame's entry and size unless you change them. Each frame keeps its own copy of its files, so after rebuilding, update the set together: `twombly update out --frames '[{\"frame\":\"A\"},{\"frame\":\"B\"}]'`. Nothing changes unless all of them pass.\n\n## Sharing\n\nThe person shares by clicking Share. Only run `twombly share` if they explicitly ask you to, because it publishes the canvas to a public link. If they ask you to take it down, run `twombly unshare`. That deletes the link and its comments and leaves the local canvas.\n\nOnce it's shared, every `add`, `update`, `move` and `remove` goes live immediately. Check your work with `twombly check` before updating a canvas people are looking at.\n";
247
259
 
248
260
  // src/capture.ts
249
261
  import { createHash } from "node:crypto";
@@ -284,10 +296,10 @@ async function capture(url, out, options) {
284
296
  await writeFile(join(out, "index.html"), `<!doctype html><meta charset="utf-8"><style>html,body{margin:0;background:#fff}img{display:block;max-width:100%;height:auto}</style><img src="assets/capture.png" alt="">`);
285
297
  return { folder: out, assets: 1, failed: [] };
286
298
  }
287
- const saved = /* @__PURE__ */ new Map(), failed = [];
299
+ const saved2 = /* @__PURE__ */ new Map(), failed = [];
288
300
  const save = async (assetUrl) => {
289
301
  if (assetUrl.startsWith("data:") || assetUrl.startsWith("blob:")) return null;
290
- if (saved.has(assetUrl)) return saved.get(assetUrl);
302
+ if (saved2.has(assetUrl)) return saved2.get(assetUrl);
291
303
  try {
292
304
  const response = await context.request.get(assetUrl, { timeout: 2e4 });
293
305
  if (!response.ok()) throw new Error(String(response.status()));
@@ -295,7 +307,7 @@ async function capture(url, out, options) {
295
307
  let bytes = await response.body();
296
308
  const hash = createHash("sha256").update(assetUrl).digest("hex").slice(0, 16);
297
309
  const name = `assets/${hash}${EXT[type] || extname(new URL(assetUrl).pathname) || ""}`;
298
- saved.set(assetUrl, name);
310
+ saved2.set(assetUrl, name);
299
311
  if (type === "text/css") bytes = Buffer.from(await rewriteCss(bytes.toString("utf8"), assetUrl, "css"));
300
312
  await writeFile(join(out, name), bytes);
301
313
  return name;
@@ -388,7 +400,7 @@ async function capture(url, out, options) {
388
400
  return "<!doctype html>\n" + document.documentElement.outerHTML;
389
401
  }, { replacements, styles, inline });
390
402
  await writeFile(join(out, "index.html"), html);
391
- return { folder: out, assets: saved.size, failed };
403
+ return { folder: out, assets: saved2.size, failed };
392
404
  } finally {
393
405
  await browser.close().catch(() => {
394
406
  });
@@ -400,8 +412,7 @@ import { createHash as createHash3, randomBytes } from "node:crypto";
400
412
  import { createReadStream as createReadStream2 } from "node:fs";
401
413
  import { readFile as readFile5, stat as stat3 } from "node:fs/promises";
402
414
  import { createServer as createServer2 } from "node:http";
403
- import { dirname as dirname2, extname as extname2, join as join7 } from "node:path";
404
- import { fileURLToPath } from "node:url";
415
+ import { extname as extname2, join as join8 } from "node:path";
405
416
  import { WebSocketServer, WebSocket } from "ws";
406
417
 
407
418
  // src/credentials.ts
@@ -545,36 +556,125 @@ async function startFrameServer(folder, poster) {
545
556
  } };
546
557
  }
547
558
 
559
+ // src/interface.ts
560
+ import { createPublicKey, verify } from "node:crypto";
561
+ import { mkdir as mkdir3, readdir, rename, rm as rm3, writeFile as writeFile3 } from "node:fs/promises";
562
+ import { dirname, join as join5 } from "node:path";
563
+ import { fileURLToPath } from "node:url";
564
+
565
+ // src/release.ts
566
+ var INTERFACE_VERSION = 1;
567
+ var INTERFACE_PUBLIC_KEY = "MCowBQYDK2VwAyEA2/JCcP+q2Ht/fjTqlMvBzwrUS07UAXNp1z6bqY0hG9U=";
568
+
569
+ // src/interface.ts
570
+ var OVERRIDE = process.env.TWOMBLY_UI;
571
+ var UPDATES = !OVERRIDE && process.env.TWOMBLY_NO_INTERFACE_UPDATES !== "1";
572
+ var BUNDLED = join5(dirname(fileURLToPath(import.meta.url)), "ui");
573
+ var STORE = join5(HOME, "interface", String(INTERFACE_VERSION));
574
+ var PUBLIC_KEY = createPublicKey({ key: Buffer.from(process.env.TWOMBLY_INTERFACE_KEY || INTERFACE_PUBLIC_KEY, "base64"), format: "der", type: "spki" });
575
+ var KEEP = 3;
576
+ var CHECK_MS = 36e5;
577
+ var CHECK_ON_OPEN_MS = 5 * 6e4;
578
+ var WAIT_ON_OPEN_MS = 600;
579
+ var DOWNLOAD_MS = 3e4;
580
+ var MAX_CHARS = 64 * 1024 * 1024;
581
+ var SAFE_PATH = /^[\w.@-]+(\/[\w.@-]+)*$/;
582
+ var saved = [];
583
+ var etag = "";
584
+ var checking = null;
585
+ var checkedAt = 0;
586
+ function interfaceFolders() {
587
+ if (OVERRIDE) return [OVERRIDE];
588
+ const folder = (released) => join5(STORE, String(released));
589
+ return [...saved.filter((released) => released > 1793896544071).map(folder), BUNDLED, ...saved.filter((released) => released <= 1793896544071).map(folder)];
590
+ }
591
+ function unpack(body) {
592
+ const { payload, signature } = JSON.parse(body);
593
+ if (typeof payload !== "string" || typeof signature !== "string") return null;
594
+ if (!verify(null, Buffer.from(payload), PUBLIC_KEY, Buffer.from(signature, "base64"))) return null;
595
+ const release = JSON.parse(payload);
596
+ if (release.interface !== INTERFACE_VERSION || !Number.isSafeInteger(release.released) || !release.files?.["index.html"]) return null;
597
+ const safe = Object.entries(release.files).every(([path, bytes]) => SAFE_PATH.test(path) && !path.split("/").includes("..") && typeof bytes === "string");
598
+ return safe ? release : null;
599
+ }
600
+ async function install(released, files) {
601
+ const temporary = join5(STORE, `.${released}-${process.pid}`);
602
+ try {
603
+ for (const [path, bytes] of Object.entries(files)) {
604
+ await mkdir3(dirname(join5(temporary, path)), { recursive: true });
605
+ await writeFile3(join5(temporary, path), Buffer.from(bytes, "base64"));
606
+ }
607
+ await rename(temporary, join5(STORE, String(released)));
608
+ } catch (error) {
609
+ await rm3(temporary, { recursive: true, force: true });
610
+ throw error;
611
+ }
612
+ saved = [released, ...saved].sort((a, b) => b - a);
613
+ for (const old of saved.splice(KEEP)) await rm3(join5(STORE, String(old)), { recursive: true, force: true });
614
+ }
615
+ async function check() {
616
+ const response = await fetch(`${ENDPOINT}/interface/${INTERFACE_VERSION}`, { headers: etag ? { "if-none-match": etag } : {}, signal: AbortSignal.timeout(DOWNLOAD_MS) });
617
+ if (response.status !== 200) return;
618
+ const body = await response.text();
619
+ if (body.length > MAX_CHARS) return;
620
+ const release = unpack(body);
621
+ if (!release) return;
622
+ etag = response.headers.get("etag") || "";
623
+ if (release.released > Math.max(saved[0] ?? 0, 1793896544071)) await install(release.released, release.files);
624
+ }
625
+ function startCheck() {
626
+ if (checking) return;
627
+ checkedAt = Date.now();
628
+ checking = check().catch(() => {
629
+ }).finally(() => {
630
+ checking = null;
631
+ });
632
+ }
633
+ async function freshInterface() {
634
+ if (!UPDATES) return;
635
+ if (Date.now() - checkedAt > CHECK_ON_OPEN_MS) startCheck();
636
+ if (checking) await Promise.race([checking, new Promise((done) => setTimeout(done, WAIT_ON_OPEN_MS))]);
637
+ }
638
+ async function startInterfaceUpdates() {
639
+ if (!UPDATES) return;
640
+ await mkdir3(STORE, { recursive: true });
641
+ const names = await readdir(STORE);
642
+ for (const name of names) if (name.startsWith(".")) await rm3(join5(STORE, name), { recursive: true, force: true });
643
+ saved = names.filter((name) => /^\d+$/.test(name)).map(Number).sort((a, b) => b - a);
644
+ startCheck();
645
+ setInterval(startCheck, CHECK_MS).unref();
646
+ }
647
+
548
648
  // src/project.ts
549
649
  import { createHash as createHash2 } from "node:crypto";
550
- import { cp, mkdir as mkdir3, readdir, readFile as readFile3, rename, rm as rm3, stat as stat2, writeFile as writeFile3 } from "node:fs/promises";
551
- import { basename, dirname, join as join5, relative, resolve, sep } from "node:path";
650
+ import { cp, mkdir as mkdir4, readdir as readdir2, readFile as readFile3, rename as rename2, rm as rm4, stat as stat2, writeFile as writeFile4 } from "node:fs/promises";
651
+ import { basename, dirname as dirname2, join as join6, relative, resolve, sep } from "node:path";
552
652
  var DIR = ".twombly";
553
653
  var exists = (path) => stat2(path).then(() => true, () => false);
554
654
  async function findProject(from) {
555
655
  let dir = resolve(from);
556
656
  while (true) {
557
- if (await exists(join5(dir, DIR, "canvas.json"))) return dir;
558
- const up = dirname(dir);
657
+ if (await exists(join6(dir, DIR, "canvas.json"))) return dir;
658
+ const up = dirname2(dir);
559
659
  if (up === dir) return null;
560
660
  dir = up;
561
661
  }
562
662
  }
563
- var canvasFile = (project) => join5(project, DIR, "canvas.json");
564
- var frameDir = (project, id) => join5(project, DIR, "frames", id);
565
- var posterFile = (project, id) => join5(project, DIR, "posters", `${id}.png`);
663
+ var canvasFile = (project) => join6(project, DIR, "canvas.json");
664
+ var frameDir = (project, id) => join6(project, DIR, "frames", id);
665
+ var posterFile = (project, id) => join6(project, DIR, "posters", `${id}.png`);
566
666
  async function readCanvas(project) {
567
667
  return JSON.parse(await readFile3(canvasFile(project), "utf8"));
568
668
  }
569
669
  async function writeCanvas(project, canvas) {
570
670
  const path = canvasFile(project), temp = `${path}.${process.pid}.tmp`;
571
- await writeFile3(temp, JSON.stringify(canvas, null, 2) + "\n");
572
- await rename(temp, path);
671
+ await writeFile4(temp, JSON.stringify(canvas, null, 2) + "\n");
672
+ await rename2(temp, path);
573
673
  }
574
674
  async function ensureProjectDir(project) {
575
- await mkdir3(join5(project, DIR, "frames"), { recursive: true });
576
- await mkdir3(join5(project, DIR, "posters"), { recursive: true });
577
- await writeFile3(join5(project, DIR, ".gitignore"), "*\n");
675
+ await mkdir4(join6(project, DIR, "frames"), { recursive: true });
676
+ await mkdir4(join6(project, DIR, "posters"), { recursive: true });
677
+ await writeFile4(join6(project, DIR, ".gitignore"), "*\n");
578
678
  }
579
679
  function defaultName(project) {
580
680
  return basename(project).replace(/[-_]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()) || "Untitled";
@@ -582,8 +682,8 @@ function defaultName(project) {
582
682
  async function hashFolder(folder) {
583
683
  const out = [];
584
684
  const walk = async (dir) => {
585
- for (const entry of await readdir(dir, { withFileTypes: true })) {
586
- const absolute = join5(dir, entry.name);
685
+ for (const entry of await readdir2(dir, { withFileTypes: true })) {
686
+ const absolute = join6(dir, entry.name);
587
687
  if (entry.isDirectory()) await walk(absolute);
588
688
  else if (entry.isFile()) {
589
689
  if (entry.name === ".DS_Store") continue;
@@ -601,13 +701,13 @@ async function folderContent(folder) {
601
701
  }
602
702
  async function replaceFolder(source, target) {
603
703
  const temp = `${target}.${process.pid}.tmp`;
604
- await rm3(temp, { recursive: true, force: true });
605
- await mkdir3(dirname(target), { recursive: true });
704
+ await rm4(temp, { recursive: true, force: true });
705
+ await mkdir4(dirname2(target), { recursive: true });
606
706
  await cp(source, temp, { recursive: true, verbatimSymlinks: false, filter: (path) => basename(path) !== ".DS_Store" });
607
- await rm3(target, { recursive: true, force: true });
608
- await rename(temp, target);
707
+ await rm4(target, { recursive: true, force: true });
708
+ await rename2(temp, target);
609
709
  }
610
- var registryFile = join5(HOME, "registry.json");
710
+ var registryFile = join6(HOME, "registry.json");
611
711
  async function readRegistry() {
612
712
  try {
613
713
  return JSON.parse(await readFile3(registryFile, "utf8"));
@@ -619,27 +719,26 @@ async function register(id, project) {
619
719
  const registry = await readRegistry();
620
720
  if (registry[id] === project) return;
621
721
  registry[id] = project;
622
- await mkdir3(HOME, { recursive: true });
623
- await writeFile3(registryFile, JSON.stringify(registry, null, 2));
722
+ await mkdir4(HOME, { recursive: true });
723
+ await writeFile4(registryFile, JSON.stringify(registry, null, 2));
624
724
  }
625
725
 
626
726
  // src/settings.ts
627
- import { mkdir as mkdir4, readFile as readFile4, rename as rename2, writeFile as writeFile4 } from "node:fs/promises";
628
- import { join as join6 } from "node:path";
629
- var FILE = join6(HOME, "settings.json");
727
+ import { mkdir as mkdir5, readFile as readFile4, rename as rename3, writeFile as writeFile5 } from "node:fs/promises";
728
+ import { join as join7 } from "node:path";
729
+ var FILE = join7(HOME, "settings.json");
630
730
  async function readSettings() {
631
- const saved = await readFile4(FILE, "utf8").then(JSON.parse).catch(() => null);
632
- return { invertScroll: saved?.invertScroll === true };
731
+ const saved2 = await readFile4(FILE, "utf8").then(JSON.parse).catch(() => null);
732
+ return { invertScroll: saved2?.invertScroll === true };
633
733
  }
634
734
  async function writeSettings(settings2) {
635
- await mkdir4(HOME, { recursive: true });
735
+ await mkdir5(HOME, { recursive: true });
636
736
  const temporary = `${FILE}.${process.pid}`;
637
- await writeFile4(temporary, JSON.stringify(settings2));
638
- await rename2(temporary, FILE);
737
+ await writeFile5(temporary, JSON.stringify(settings2));
738
+ await rename3(temporary, FILE);
639
739
  }
640
740
 
641
741
  // src/daemon.ts
642
- var UI = process.env.TWOMBLY_UI || join7(dirname2(fileURLToPath(import.meta.url)), "ui");
643
742
  var IDLE_MS = 6 * 36e5;
644
743
  var LOGIN_MS = 10 * 6e4;
645
744
  var ID_ALPHABET = "abcdefghijkmnpqrstuvwxyz23456789";
@@ -682,6 +781,7 @@ async function edge(path, init = {}) {
682
781
  if (!response.ok) throw new Failure(response.status, data.error || `The server answered ${response.status}`, data.problems ? { problems: data.problems } : void 0);
683
782
  return data;
684
783
  }
784
+ var STROKE_UPLOAD = 40;
685
785
  var Canvas = class {
686
786
  id;
687
787
  project;
@@ -782,7 +882,7 @@ var Canvas = class {
782
882
  t: "hello",
783
883
  self,
784
884
  role: "owner",
785
- doc: { id: this.id, name: this.data.name, frames },
885
+ doc: { id: this.id, name: this.data.name, frames, strokes: this.data.strokes ?? [] },
786
886
  threads: [],
787
887
  peers: [this.peer(self)],
788
888
  capabilities: { comments: false, huddle: false },
@@ -815,6 +915,22 @@ var Canvas = class {
815
915
  this.save();
816
916
  return;
817
917
  }
918
+ if (m.t === "stroke.add") {
919
+ const strokes = this.data.strokes ??= [];
920
+ const clean = cleanStroke(m.stroke);
921
+ if (!clean || strokes.length >= LIMITS.canvasStrokes && !strokes.some((item) => item.id === clean.id)) return this.broadcast({ t: "stroke.remove", id: String(m.stroke?.id) });
922
+ const stroke = { ...clean, authorId: "local", createdAt: Date.now() };
923
+ this.data.strokes = [...strokes.filter((item) => item.id !== stroke.id), stroke];
924
+ this.broadcast({ t: "strokes", strokes: [stroke] });
925
+ this.save();
926
+ return;
927
+ }
928
+ if (m.t === "stroke.remove") {
929
+ this.data.strokes = (this.data.strokes ?? []).filter((item) => item.id !== m.id);
930
+ this.broadcast({ t: "stroke.remove", id: m.id });
931
+ this.save();
932
+ return;
933
+ }
818
934
  if (m.t === "canvas.rename") {
819
935
  const name = String(m.name || "").trim().slice(0, LIMITS.nameChars);
820
936
  if (name) {
@@ -852,6 +968,7 @@ var Canvas = class {
852
968
  throw error;
853
969
  }
854
970
  this.data.name = doc.name;
971
+ if (doc.strokes) this.data.strokes = doc.strokes;
855
972
  const known = new Map(this.data.frames.map((frame) => [frame.id, frame]));
856
973
  this.data.frames = doc.frames.map((frame) => ({
857
974
  id: frame.id,
@@ -910,6 +1027,10 @@ var Canvas = class {
910
1027
  this.event({ t: "share-progress", done: ++done, total });
911
1028
  });
912
1029
  }
1030
+ const strokes = this.data.strokes ?? [];
1031
+ for (let i = 0; i < strokes.length; i += STROKE_UPLOAD) {
1032
+ await edge(`/api/canvases/${created.id}/strokes`, { method: "POST", body: JSON.stringify({ strokes: strokes.slice(i, i + STROKE_UPLOAD) }) });
1033
+ }
913
1034
  } catch (error) {
914
1035
  await edge(`/api/canvases/${created.id}`, { method: "DELETE" }).catch(() => {
915
1036
  });
@@ -1006,7 +1127,7 @@ async function command(name, input) {
1006
1127
  found = project;
1007
1128
  }
1008
1129
  const canvas2 = await canvasForProject(found);
1009
- if (input.rename && input.name && input.name !== canvas2.data.name) await rename3(canvas2, input.name);
1130
+ if (input.rename && input.name && input.name !== canvas2.data.name) await rename4(canvas2, input.name);
1010
1131
  if (input.open !== false && !canvas2.watchers) openBrowser(`${DAEMON_ORIGIN}/${canvas2.id}`);
1011
1132
  canvas2.working(WORKING_MS);
1012
1133
  return summary(canvas2);
@@ -1021,6 +1142,10 @@ async function command(name, input) {
1021
1142
  case "open":
1022
1143
  openBrowser(`${DAEMON_ORIGIN}/${canvas.id}`);
1023
1144
  return summary(canvas);
1145
+ case "preview": {
1146
+ const { id, title, w, h } = findFrame(canvas, input.frame);
1147
+ return { frame: { id, title, w, h }, url: `${DAEMON_ORIGIN}/preview/${canvas.id}/${id}` };
1148
+ }
1024
1149
  case "place":
1025
1150
  return place(canvas, input);
1026
1151
  case "revise":
@@ -1054,7 +1179,7 @@ async function command(name, input) {
1054
1179
  return summary(canvas);
1055
1180
  }
1056
1181
  case "rename":
1057
- await rename3(canvas, input.name);
1182
+ await rename4(canvas, input.name);
1058
1183
  return summary(canvas);
1059
1184
  case "share": {
1060
1185
  if (!credential) throw new Failure(401, "The person needs to sign in first. Ask them to click Share on the canvas.");
@@ -1069,7 +1194,7 @@ async function command(name, input) {
1069
1194
  }
1070
1195
  });
1071
1196
  }
1072
- async function rename3(canvas, name) {
1197
+ async function rename4(canvas, name) {
1073
1198
  const clean = String(name || "").trim().slice(0, LIMITS.nameChars);
1074
1199
  if (!clean) throw new Failure(400, "Expected a name");
1075
1200
  if (canvas.data.remote) await edge(`/api/canvases/${canvas.data.remote.id}`, { method: "PATCH", body: JSON.stringify({ name: clean }) });
@@ -1167,9 +1292,20 @@ var APP_CSP = [
1167
1292
  ].join("; ");
1168
1293
  async function serveUi(response, pathname) {
1169
1294
  const clean = pathname.replace(/^\/+/, "");
1170
- const candidate = clean && !clean.includes("..") ? join7(UI, clean) : null;
1171
- const info = candidate ? await stat3(candidate).catch(() => null) : null;
1172
- const file2 = info?.isFile() && candidate ? candidate : join7(UI, "index.html");
1295
+ let file2 = null;
1296
+ if (clean && !clean.includes("..")) {
1297
+ for (const folder of interfaceFolders()) {
1298
+ const candidate = join8(folder, clean);
1299
+ if ((await stat3(candidate).catch(() => null))?.isFile()) {
1300
+ file2 = candidate;
1301
+ break;
1302
+ }
1303
+ }
1304
+ }
1305
+ if (!file2) {
1306
+ await freshInterface();
1307
+ file2 = join8(interfaceFolders()[0], "index.html");
1308
+ }
1173
1309
  const type = contentType(file2);
1174
1310
  const headers = { "content-type": type, "x-content-type-options": "nosniff", "cache-control": extname2(file2) === ".html" ? "no-cache" : "public, max-age=3600" };
1175
1311
  if (type.startsWith("text/html")) {
@@ -1179,10 +1315,23 @@ async function serveUi(response, pathname) {
1179
1315
  response.writeHead(200, headers);
1180
1316
  createReadStream2(file2).pipe(response);
1181
1317
  }
1318
+ var escapeHtml = (text2) => text2.replace(/[&<>"]/g, (c) => `&#${c.charCodeAt(0)};`);
1319
+ async function servePreview(response, canvasId, frameId) {
1320
+ const canvas = await canvasById(canvasId);
1321
+ const frame = await canvas.toFrame(findFrame(canvas, frameId));
1322
+ response.writeHead(200, {
1323
+ "content-type": "text/html; charset=utf-8",
1324
+ "cache-control": "no-store",
1325
+ "referrer-policy": "no-referrer",
1326
+ "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; frame-src http://localhost:*; base-uri 'none'; frame-ancestors 'none'"
1327
+ });
1328
+ const title = escapeHtml(frame.title);
1329
+ response.end(`<!doctype html><meta charset="utf-8"><title>${title}</title><style>html,body{margin:0;background:#fff}iframe{display:block;border:0;width:${frame.w}px;height:${frame.h}px}</style><iframe src="${escapeHtml(frame.url)}" title="${title}" sandbox="${FRAME_SANDBOX}" referrerpolicy="no-referrer" allow=""></iframe>`);
1330
+ }
1182
1331
  async function route(request, response) {
1183
1332
  const url = new URL(request.url || "/", DAEMON_ORIGIN);
1184
1333
  const path = url.pathname, method = request.method || "GET";
1185
- if (path === "/health") return reply(response, 200, { ok: true, build: "0.1.0+1793887853207", pid: process.pid });
1334
+ if (path === "/health") return reply(response, 200, { ok: true, build: "0.2.0+1793896544071", pid: process.pid });
1186
1335
  if (path.startsWith("/cli/") && method === "POST") return reply(response, 200, await command(path.slice(5), await readBody(request)));
1187
1336
  if (path === "/auth/callback" && method === "GET") {
1188
1337
  const code = url.searchParams.get("code"), state = url.searchParams.get("state");
@@ -1233,6 +1382,8 @@ async function route(request, response) {
1233
1382
  }
1234
1383
  if (path.startsWith("/api/")) return reply(response, 404, { error: "Not found" });
1235
1384
  if (method !== "GET" && method !== "HEAD") return reply(response, 405, { error: "Method not allowed" });
1385
+ const preview = path.match(/^\/preview\/([a-z0-9]+)\/([a-z0-9]+)$/);
1386
+ if (preview) return servePreview(response, preview[1], preview[2]);
1236
1387
  return serveUi(response, path);
1237
1388
  }
1238
1389
  var idleTimer = null;
@@ -1286,17 +1437,19 @@ async function runDaemon() {
1286
1437
  server.listen(PORT, "127.0.0.1", resolve3);
1287
1438
  });
1288
1439
  touch();
1440
+ startInterfaceUpdates().catch(() => {
1441
+ });
1289
1442
  }
1290
1443
 
1291
1444
  // src/gates.ts
1292
1445
  import { createServer as createServer3 } from "node:http";
1293
- import { mkdir as mkdir5, readFile as readFile6, stat as stat4, writeFile as writeFile5 } from "node:fs/promises";
1446
+ import { mkdir as mkdir6, readFile as readFile6, stat as stat4, writeFile as writeFile6 } from "node:fs/promises";
1294
1447
  import { dirname as dirname3 } from "node:path";
1295
1448
  var dedupe = (problems) => [...new Map(problems.map((p) => [`${p.code}:${p.file || ""}:${p.message}`, p])).values()];
1296
- async function staticGates(folder, entry) {
1449
+ async function scanFolder(folder) {
1297
1450
  const problems = [];
1298
1451
  const info = await stat4(folder).catch(() => null);
1299
- if (!info?.isDirectory()) return { problems: [{ code: "NOT_A_FOLDER", message: `"${folder}" is not a folder.`, fix: "Point at a folder of built static files." }], files: 0, bytes: 0 };
1452
+ if (!info?.isDirectory()) return { problems: [{ code: "NOT_A_FOLDER", message: `"${folder}" is not a folder.`, fix: "Point at a folder of built static files." }], files: [], bytes: 0 };
1300
1453
  const files = await hashFolder(folder);
1301
1454
  let bytes = 0;
1302
1455
  for (const file2 of files) {
@@ -1314,13 +1467,26 @@ async function staticGates(folder, entry) {
1314
1467
  const largest = [...files].sort((a, b) => b.size - a.size).slice(0, 5).map((file2) => `${file2.path} (${(file2.size / 1048576).toFixed(1)} MB)`).join(", ");
1315
1468
  problems.push({ code: "FRAME_TOO_LARGE", message: `${(bytes / 1048576).toFixed(1)} MB in total; the limit is ${LIMITS.frameBytes / 1048576} MB. Largest: ${largest}.`, fix: "Remove what this design doesn't show, and compress the rest." });
1316
1469
  }
1470
+ return { problems, files, bytes };
1471
+ }
1472
+ function entryProblems(files, entry) {
1317
1473
  const entryPath = entry.split(/[?#]/)[0];
1318
1474
  const candidates = candidatePaths("/" + entryPath);
1319
- if (!files.some((file2) => candidates.includes(file2.path))) problems.push({ code: "NO_ENTRY", message: `"${entryPath}" is not in the folder.`, fix: "Build to a folder with an index.html at its top level, or pass --entry." });
1320
- return { problems, files: files.length, bytes };
1475
+ if (files.some((file2) => candidates.includes(file2.path))) return [];
1476
+ return [{ code: "NO_ENTRY", message: `"${entryPath}" is not in the folder.`, fix: "Build to a folder with an index.html at its top level, or pass --entry." }];
1477
+ }
1478
+ async function findReferences(folder, needle) {
1479
+ const found = [];
1480
+ for (const file2 of await hashFolder(folder)) {
1481
+ const bytes = await readFile6(file2.absolute);
1482
+ if (looksLikeText(bytes) && bytes.includes(needle)) found.push(file2.path);
1483
+ if (found.length === 3) break;
1484
+ }
1485
+ return found;
1321
1486
  }
1322
- async function browserGates(folder, options) {
1487
+ async function browserGates(folder, options, launch) {
1323
1488
  const problems = [], warnings = [];
1489
+ const traces = /* @__PURE__ */ new Map();
1324
1490
  const server = await startFrameServer(folder);
1325
1491
  const width = options.width, height = options.height === "auto" ? 800 : options.height;
1326
1492
  const harness = createServer3((_, response) => {
@@ -1331,7 +1497,7 @@ async function browserGates(folder, options) {
1331
1497
  const harnessOrigin = `http://localhost:${harness.address().port}`;
1332
1498
  let browser;
1333
1499
  try {
1334
- browser = await launchBrowser();
1500
+ browser = await launch();
1335
1501
  } catch {
1336
1502
  harness.close();
1337
1503
  server.close();
@@ -1347,7 +1513,9 @@ async function browserGates(folder, options) {
1347
1513
  host = new URL(url).host;
1348
1514
  } catch {
1349
1515
  }
1350
- problems.push({ code: "EXTERNAL_REQUEST", message: `The frame tried to reach ${host} (${url.slice(0, 160)}).`, fix: /google-analytics|googletagmanager|clarity\.ms|segment\.|sentry|hotjar|posthog|plausible|intercom|mixpanel|amplitude/i.test(url) ? "That is analytics or a third-party widget, which a frame doesn't need. Delete the tag or skip loading it in this build." : /fonts\.(googleapis|gstatic)|typekit|fontshare|fonts\.bunny/i.test(url) ? "Download the font files into the folder and load them with @font-face and a local path." : "Frames can only use their own files. For an asset, copy it into the folder and use a relative path. For an API call, replace it with mock data. For a tag or widget in the page template, delete it." });
1516
+ const problem = { code: "EXTERNAL_REQUEST", message: `The frame tried to reach ${host} (${url.slice(0, 160)}).`, fix: /google-analytics|googletagmanager|clarity\.ms|segment\.|sentry|hotjar|posthog|plausible|intercom|mixpanel|amplitude/i.test(url) ? "That is analytics or a third-party widget, which a frame doesn't need. Delete the tag or skip loading it in this build." : /fonts\.(googleapis|gstatic)|typekit|fontshare|fonts\.bunny/i.test(url) ? "Download the font files into the folder and load them with @font-face and a local path." : "Frames can only use their own files. For an asset, copy it into the folder and use a relative path. For an API call, replace it with mock data. For a tag or widget in the page template, delete it." };
1517
+ problems.push(problem);
1518
+ traces.set(problem, host);
1351
1519
  };
1352
1520
  page.on("pageerror", (error) => {
1353
1521
  problems.push({ code: "UNCAUGHT_ERROR", message: `The page threw: ${String(error.message).slice(0, 400)}`, fix: "Usually a missing provider, env value or API response, or a router reading the file path as a route. Mount the design with mock props and data, and use a memory or hash router." });
@@ -1368,7 +1536,9 @@ async function browserGates(folder, options) {
1368
1536
  const url = response.url();
1369
1537
  if (!isFrameOrigin(url) || response.status() < 400 || url.endsWith("/favicon.ico")) return;
1370
1538
  const path = new URL(url).pathname;
1371
- problems.push({ code: "MISSING_FILE", message: `The frame asked for ${path} and it isn't in the folder (${response.status()}).`, fix: /^\/(api|graphql|trpc|auth)(\/|$)/.test(path) ? "That is a server route, and frames have no server. Replace the call with mock data." : "Copy the file into the folder, or remove the reference. If it is an API call, replace it with mock data.", file: path.slice(1) });
1539
+ const problem = { code: "MISSING_FILE", message: `The frame asked for ${path} and it isn't in the folder (${response.status()}).`, fix: /^\/(api|graphql|trpc|auth)(\/|$)/.test(path) ? "That is a server route, and frames have no server. Replace the call with mock data." : "Copy the file into the folder, or remove the reference. If it is an API call, replace it with mock data.", file: path.slice(1) };
1540
+ problems.push(problem);
1541
+ traces.set(problem, path.slice(1));
1372
1542
  });
1373
1543
  page.on("requestfailed", (request) => {
1374
1544
  const url = request.url();
@@ -1401,8 +1571,8 @@ async function browserGates(folder, options) {
1401
1571
  const shot = await page.locator("#frame").screenshot({ type: "png", timeout: 15e3 }).catch(() => null);
1402
1572
  if (shot) {
1403
1573
  if (options.poster) {
1404
- await mkdir5(dirname3(options.poster), { recursive: true });
1405
- await writeFile5(options.poster, shot);
1574
+ await mkdir6(dirname3(options.poster), { recursive: true });
1575
+ await writeFile6(options.poster, shot);
1406
1576
  }
1407
1577
  const colors = await page.evaluate(async (base64) => {
1408
1578
  const image = new Image();
@@ -1423,40 +1593,79 @@ async function browserGates(folder, options) {
1423
1593
  if (content && content.width > width * 1.02) warnings.push(`The page is ${content.width}px wide and the frame is ${width}px; it overflows sideways.`);
1424
1594
  await context.close();
1425
1595
  const unique = dedupe(problems);
1596
+ for (const problem of unique) {
1597
+ const trace = traces.get(problem);
1598
+ const references = trace ? await findReferences(folder, trace) : [];
1599
+ if (references.length) problem.message += ` Mentioned in ${references.join(", ")}.`;
1600
+ }
1426
1601
  return { ok: unique.length === 0, problems: unique, warnings: [...new Set(warnings)], content, height: finalHeight, screenshot: options.poster };
1427
1602
  } finally {
1428
- await browser.close().catch(() => {
1429
- });
1430
1603
  harness.closeAllConnections();
1431
1604
  harness.close();
1432
1605
  server.close();
1433
1606
  }
1434
1607
  }
1608
+ function gateSession() {
1609
+ const scans = /* @__PURE__ */ new Map();
1610
+ let browser = null;
1611
+ const launch = () => browser ??= launchBrowser();
1612
+ return {
1613
+ async run(folder, options) {
1614
+ if (!scans.has(folder)) scans.set(folder, scanFolder(folder));
1615
+ const scan = await scans.get(folder);
1616
+ const size2 = { files: scan.files.length, bytes: scan.bytes };
1617
+ const notFolder = scan.problems.some((problem) => problem.code === "NOT_A_FOLDER");
1618
+ const problems = notFolder ? scan.problems : [...scan.problems, ...entryProblems(scan.files, options.entry)];
1619
+ if (problems.length) return { ok: false, problems: dedupe(problems), warnings: [], ...size2 };
1620
+ return { ...await browserGates(folder, options, launch), ...size2 };
1621
+ },
1622
+ close: async () => {
1623
+ await browser?.then((open) => open.close()).catch(() => {
1624
+ });
1625
+ }
1626
+ };
1627
+ }
1435
1628
  async function runGates(folder, options) {
1436
- const first = await staticGates(folder, options.entry);
1437
- if (first.problems.length) return { ok: false, problems: dedupe(first.problems), warnings: [], files: first.files, bytes: first.bytes };
1438
- const result = await browserGates(folder, options);
1439
- return { ...result, files: first.files, bytes: first.bytes };
1629
+ const session = gateSession();
1630
+ try {
1631
+ return await session.run(folder, options);
1632
+ } finally {
1633
+ await session.close();
1634
+ }
1440
1635
  }
1441
1636
 
1442
1637
  // src/cli.ts
1443
- var HELP = `twombly ${"0.1.0"}: put design explorations on a shared canvas.
1638
+ var HELP = `twombly ${"0.2.0"}: put design explorations on a shared canvas.
1444
1639
 
1445
1640
  init [--name "Name"] [--no-open] create or reopen this project's canvas
1446
1641
  add <folder|file> --title "T" run the gates, then place a frame
1447
1642
  [--width 1280] [--height 800|auto] [--entry index.html] [--level build|component|capture|image]
1448
1643
  [--new-row] [--x N --y N]
1644
+ [--frames '[{"title":"A","entry":"index.html?v=a"}]'] several at once, as JSON or a JSON file
1449
1645
  update <frame> <folder|file> replace a frame's contents, keeping its comments
1450
1646
  [--title "T"] [--width N] [--height N|auto] [--entry path] [--level ...]
1451
- check <folder> run the gates without placing anything
1647
+ update <folder> --frames '[{"frame":"A"},{"frame":"B"}]' several at once
1648
+ check <folder> run the gates without placing anything
1452
1649
  capture <url> --out <folder> freeze a running page into a static folder
1453
1650
  [--width 1280] [--height 800] [--image] [--selector css]
1651
+ preview <frame> a page with just that frame, for testing interactions
1454
1652
  working | done show on the canvas that you're building, or that you've finished
1455
1653
  list | move <frame> --x N --y N | resize <frame> --width N --height N
1456
1654
  retitle <frame> "T" | remove <frame> | arrange [--columns N] | rename "Name"
1457
1655
  open | status | login | logout | stop
1458
1656
  share | unshare only when the person asks: this publishes a public link
1459
1657
  guide how to adapt a project into frames. Read this first.`;
1658
+ var SWITCHES = /* @__PURE__ */ new Set(["no-open", "new-row", "image"]);
1659
+ var FLAGS = {
1660
+ init: ["name", "no-open"],
1661
+ add: ["title", "width", "height", "entry", "level", "new-row", "x", "y", "frames"],
1662
+ update: ["title", "width", "height", "entry", "level", "frames"],
1663
+ check: ["entry", "width", "height"],
1664
+ capture: ["out", "width", "height", "image", "selector", "wait"],
1665
+ move: ["x", "y"],
1666
+ resize: ["width", "height"],
1667
+ arrange: ["columns"]
1668
+ };
1460
1669
  function parse(argv) {
1461
1670
  const positional = [], flags = {};
1462
1671
  for (let i = 0; i < argv.length; i++) {
@@ -1471,13 +1680,19 @@ function parse(argv) {
1471
1680
  continue;
1472
1681
  }
1473
1682
  const next = argv[i + 1];
1474
- if (next !== void 0 && !next.startsWith("--")) {
1683
+ if (!SWITCHES.has(arg.slice(2)) && next !== void 0 && !next.startsWith("--")) {
1475
1684
  flags[arg.slice(2)] = next;
1476
1685
  i++;
1477
1686
  } else flags[arg.slice(2)] = true;
1478
1687
  }
1479
1688
  return { positional, flags };
1480
1689
  }
1690
+ function checkFlags(name, flags) {
1691
+ const known = FLAGS[name] || [];
1692
+ for (const flag of Object.keys(flags)) {
1693
+ if (!known.includes(flag)) fail(`Unknown flag --${flag}. ${known.length ? `"${name}" takes: ${known.map((item) => `--${item}`).join(", ")}.` : `"${name}" takes no flags.`}`);
1694
+ }
1695
+ }
1481
1696
  var print = (value) => process.stdout.write(JSON.stringify(value, null, 2) + "\n");
1482
1697
  function fail(message, extra = {}) {
1483
1698
  print({ ok: false, error: message, ...extra });
@@ -1515,7 +1730,7 @@ async function health() {
1515
1730
  }
1516
1731
  async function ensureDaemon() {
1517
1732
  const running = await health();
1518
- if (running?.build === "0.1.0+1793887853207") return;
1733
+ if (running?.build === "0.2.0+1793896544071") return;
1519
1734
  if (running) {
1520
1735
  await fetch(`${DAEMON_ORIGIN}/cli/stop`, { method: "POST", body: "{}" }).catch(() => {
1521
1736
  });
@@ -1524,7 +1739,7 @@ async function ensureDaemon() {
1524
1739
  spawn2(process.execPath, [fileURLToPath2(import.meta.url), "__daemon"], { detached: true, stdio: "ignore" }).unref();
1525
1740
  for (let attempt = 0; attempt < 60; attempt++) {
1526
1741
  await new Promise((done) => setTimeout(done, 100));
1527
- if ((await health())?.build === "0.1.0+1793887853207") return;
1742
+ if ((await health())?.build === "0.2.0+1793896544071") return;
1528
1743
  }
1529
1744
  fail(`Could not start the background server on port ${PORT}. Is something else using it?`);
1530
1745
  }
@@ -1543,13 +1758,13 @@ async function prepare(source) {
1543
1758
  const extension = extname3(source).toLowerCase();
1544
1759
  const isImage = IMAGE.has(extension);
1545
1760
  if (!isImage && extension !== ".html" && extension !== ".htm") fail("Point at a folder of static files, an HTML file or an image.");
1546
- const temporary = await mkdtemp(join8(tmpdir(), "twombly-"));
1761
+ const temporary = await mkdtemp(join9(tmpdir(), "twombly-"));
1547
1762
  if (isImage) {
1548
1763
  const name = `image${extension}`;
1549
- await copyFile(source, join8(temporary, name));
1550
- await writeFile6(join8(temporary, "index.html"), `<!doctype html><meta charset="utf-8"><title>${basename2(source)}</title><style>html,body{margin:0;background:#fff}img{display:block;width:100%;height:auto}</style><img src="${name}" alt="">`);
1764
+ await copyFile(source, join9(temporary, name));
1765
+ await writeFile7(join9(temporary, "index.html"), `<!doctype html><meta charset="utf-8"><title>${basename2(source)}</title><style>html,body{margin:0;background:#fff}img{display:block;width:100%;height:auto}</style><img src="${name}" alt="">`);
1551
1766
  } else {
1552
- await copyFile(source, join8(temporary, "index.html"));
1767
+ await copyFile(source, join9(temporary, "index.html"));
1553
1768
  }
1554
1769
  return { folder: temporary, temporary, image: isImage };
1555
1770
  }
@@ -1560,15 +1775,169 @@ async function currentProject() {
1560
1775
  }
1561
1776
  async function markWorking() {
1562
1777
  const project = await findProject(process.cwd());
1563
- if (project) await call("working", { project, ms: WORKING_TAIL_MS });
1778
+ if (project) await call("working", { project, ms: WORKING_MS });
1564
1779
  }
1565
1780
  function gateFailure(result) {
1566
1781
  print({ ok: false, placed: false, problems: result.problems, warnings: result.warnings, screenshot: result.screenshot });
1567
- process.exit(1);
1782
+ process.exitCode = 1;
1783
+ }
1784
+ var NEXT = "Open the screenshot. Passing the gates does not prove it looks right. To test interactions, open the url from: twombly preview <frame>";
1785
+ var CONCURRENT_GATES = 4;
1786
+ async function frameItems(flags, known) {
1787
+ const value = flags.frames;
1788
+ if (value === void 0) return [flags];
1789
+ if (typeof value !== "string") fail("--frames needs JSON or a JSON file.");
1790
+ const { frames: _, ...shared } = flags;
1791
+ let items;
1792
+ try {
1793
+ items = JSON.parse(value.trimStart().startsWith("[") ? value : await readFile7(value, "utf8"));
1794
+ } catch (error) {
1795
+ fail(`--frames: ${error instanceof Error ? error.message : error}`);
1796
+ }
1797
+ if (!Array.isArray(items) || !items.length) fail("--frames needs a list of frames.");
1798
+ return items.map((item) => {
1799
+ const unknown = Object.keys(item).find((key) => !known.includes(key));
1800
+ if (unknown) fail(`--frames: unknown field "${unknown}". Each frame takes: ${known.join(", ")}.`);
1801
+ const merged = { ...shared };
1802
+ for (const [key, field] of Object.entries(item)) {
1803
+ if (key === "newRow") {
1804
+ if (field) merged["new-row"] = true;
1805
+ else delete merged["new-row"];
1806
+ } else merged[key] = String(field);
1807
+ }
1808
+ return merged;
1809
+ });
1810
+ }
1811
+ async function placeFrames(project, many, jobs, place2) {
1812
+ await markWorking();
1813
+ const scratch = await mkdtemp(join9(tmpdir(), "twombly-gates-"));
1814
+ const gates = gateSession(), prepared = /* @__PURE__ */ new Map(), temporary = [];
1815
+ let keepScratch = false;
1816
+ try {
1817
+ for (const job of jobs) {
1818
+ if (prepared.has(job.source)) continue;
1819
+ const ready = await prepare(job.source);
1820
+ prepared.set(job.source, ready);
1821
+ if (ready.temporary) temporary.push(ready.temporary);
1822
+ }
1823
+ const results = [];
1824
+ let next = 0;
1825
+ await Promise.all(Array.from({ length: CONCURRENT_GATES }, async () => {
1826
+ while (next < jobs.length) {
1827
+ const index = next++, job = jobs[index];
1828
+ results[index] = await gates.run(prepared.get(job.source).folder, { entry: job.entry, width: job.width, height: job.height, poster: join9(scratch, `${job.id}.png`) });
1829
+ }
1830
+ }));
1831
+ if (results.some((result) => !result.ok)) {
1832
+ keepScratch = true;
1833
+ if (!many) return gateFailure(results[0]);
1834
+ return gateFailure({
1835
+ problems: results.flatMap((result, index) => result.problems.map((problem) => ({ ...problem, frame: jobs[index].label, screenshot: result.screenshot }))),
1836
+ warnings: results.flatMap((result, index) => result.warnings.map((warning) => `${jobs[index].label}: ${warning}`))
1837
+ });
1838
+ }
1839
+ const placed = [];
1840
+ for (const [index, job] of jobs.entries()) {
1841
+ const result = results[index], { folder, image } = prepared.get(job.source);
1842
+ const poster = result.screenshot ? posterFile(project, job.id) : void 0;
1843
+ await replaceFolder(folder, frameDir(project, job.id));
1844
+ if (poster) {
1845
+ await mkdir7(join9(project, ".twombly/posters"), { recursive: true });
1846
+ await copyFile(result.screenshot, poster);
1847
+ }
1848
+ placed.push({ ...await place2(job, { result, folder, image, poster }), warnings: result.warnings, screenshot: poster, contentSize: result.content });
1849
+ }
1850
+ if (!many) return print({ ok: true, placed: true, ...placed[0], next: NEXT });
1851
+ print({ ok: true, placed: true, frames: placed.map(({ frame, canvas: _, ...rest }) => ({ ...frame, ...rest })), canvas: placed[placed.length - 1].canvas, next: NEXT });
1852
+ } finally {
1853
+ await gates.close();
1854
+ for (const folder of temporary) await rm5(folder, { recursive: true, force: true });
1855
+ if (!keepScratch) await rm5(scratch, { recursive: true, force: true });
1856
+ }
1857
+ }
1858
+ async function addFrames(project, source, many, items) {
1859
+ const jobs = items.map((flags) => {
1860
+ const folder = text(flags, "folder") || source, title = text(flags, "title");
1861
+ if (!folder || !title) fail('twombly add <folder|file> --title "Title"');
1862
+ level(flags, "build");
1863
+ number(flags, "x");
1864
+ number(flags, "y");
1865
+ return {
1866
+ flags,
1867
+ label: title,
1868
+ source: resolve2(folder),
1869
+ entry: text(flags, "entry") || "index.html",
1870
+ width: size(flags, "width", 1280),
1871
+ height: flags.height === "auto" ? "auto" : size(flags, "height", 800),
1872
+ id: randomBytes2(8).toString("hex").replace(/[01lo]/g, "a").slice(0, 12)
1873
+ };
1874
+ });
1875
+ const titles = (await call("list", { project })).frames.map((frame) => frame.title);
1876
+ await placeFrames(project, many, jobs, async (job, { result, image, poster }) => {
1877
+ const { content } = await folderContent(frameDir(project, job.id));
1878
+ if (titles.includes(job.label)) result.warnings.push(`Another frame is already titled "${job.label}". Give each a distinct title, or use update to replace it.`);
1879
+ titles.push(job.label);
1880
+ return call("place", {
1881
+ project,
1882
+ id: job.id,
1883
+ title: job.label,
1884
+ w: job.width,
1885
+ h: result.height,
1886
+ entry: job.entry,
1887
+ content,
1888
+ level: level(job.flags, image ? "image" : "build"),
1889
+ hasPoster: !!poster,
1890
+ x: number(job.flags, "x"),
1891
+ y: number(job.flags, "y"),
1892
+ newRow: job.flags["new-row"] === true
1893
+ });
1894
+ });
1895
+ }
1896
+ async function updateFrames(project, source, many, items) {
1897
+ const { frames } = await call("list", { project });
1898
+ const seen = /* @__PURE__ */ new Set();
1899
+ const jobs = items.map((flags) => {
1900
+ const ref = text(flags, "frame"), folder = text(flags, "folder") || source;
1901
+ if (!ref || !folder) fail("twombly update <frame> <folder|file>");
1902
+ const matches = frames.filter((frame2) => frame2.id === ref || frame2.title === ref);
1903
+ if (matches.length !== 1) fail(matches.length ? `More than one frame is titled "${ref}". Use its id.` : `No frame "${ref}". Run: twombly list`);
1904
+ const frame = matches[0];
1905
+ if (seen.has(frame.id)) fail(`--frames lists "${ref}" twice.`);
1906
+ seen.add(frame.id);
1907
+ level(flags, "build");
1908
+ return {
1909
+ flags,
1910
+ label: frame.title,
1911
+ id: frame.id,
1912
+ source: resolve2(folder),
1913
+ entry: text(flags, "entry") || frame.entry,
1914
+ width: size(flags, "width", frame.w),
1915
+ height: flags.height === "auto" ? "auto" : size(flags, "height", frame.h)
1916
+ };
1917
+ });
1918
+ await placeFrames(project, many, jobs, async (job, { result, image, poster }) => {
1919
+ const { content } = await folderContent(frameDir(project, job.id));
1920
+ return call("revise", {
1921
+ project,
1922
+ id: job.id,
1923
+ title: text(job.flags, "title"),
1924
+ w: job.width,
1925
+ h: result.height,
1926
+ entry: job.entry,
1927
+ content,
1928
+ hasPoster: poster ? true : void 0,
1929
+ level: text(job.flags, "level") || image ? level(job.flags, image ? "image" : "build") : void 0
1930
+ });
1931
+ });
1568
1932
  }
1569
1933
  async function main() {
1570
1934
  const [name, ...rest] = process.argv.slice(2);
1571
1935
  const { positional, flags } = parse(rest);
1936
+ if (flags.help !== void 0 || positional.includes("-h")) {
1937
+ process.stdout.write(HELP + "\n");
1938
+ return;
1939
+ }
1940
+ if (name && !["--help", "-h", "--version", "help", "version", "guide", "__daemon"].includes(name)) checkFlags(name, flags);
1572
1941
  switch (name) {
1573
1942
  case "__daemon":
1574
1943
  return runDaemon();
@@ -1580,7 +1949,7 @@ async function main() {
1580
1949
  return;
1581
1950
  case "--version":
1582
1951
  case "version":
1583
- process.stdout.write("0.1.0\n");
1952
+ process.stdout.write("0.2.0\n");
1584
1953
  return;
1585
1954
  case "guide":
1586
1955
  process.stdout.write(SKILL_default);
@@ -1588,23 +1957,23 @@ async function main() {
1588
1957
  case "init": {
1589
1958
  const project = await findProject(process.cwd()) || process.cwd();
1590
1959
  const given = text(flags, "name");
1591
- print({ ok: true, ...await call("init", { project, name: given || defaultName(project), rename: !!given, open: flags["no-open"] === void 0 }) });
1960
+ print({ ok: true, ...await call("init", { project, name: given || defaultName(project), rename: !!given, open: flags["no-open"] === void 0 }), next: "Frames must be static files that work with no server or network. Read how to adapt this project: twombly guide" });
1592
1961
  return;
1593
1962
  }
1594
1963
  case "check": {
1595
1964
  const source = positional[0];
1596
- if (!source) fail("Which folder? twombly check <folder>");
1965
+ if (!source) fail("twombly check <folder> checks a build before it is added. It needs the build's folder.");
1597
1966
  await markWorking();
1598
1967
  const { folder, temporary } = await prepare(resolve2(source));
1599
- const scratch = await mkdtemp(join8(tmpdir(), "twombly-check-"));
1600
- const shot = join8(scratch, "screenshot.png");
1968
+ const scratch = await mkdtemp(join9(tmpdir(), "twombly-check-"));
1969
+ const shot = join9(scratch, "screenshot.png");
1601
1970
  try {
1602
1971
  const height = flags.height === "auto" ? "auto" : size(flags, "height", 800);
1603
1972
  const result = await runGates(folder, { entry: text(flags, "entry") || "index.html", width: size(flags, "width", 1280), height, poster: shot });
1604
1973
  print(result);
1605
1974
  if (!result.ok) process.exitCode = 1;
1606
1975
  } finally {
1607
- if (temporary) await rm4(temporary, { recursive: true, force: true });
1976
+ if (temporary) await rm5(temporary, { recursive: true, force: true });
1608
1977
  }
1609
1978
  return;
1610
1979
  }
@@ -1617,83 +1986,12 @@ async function main() {
1617
1986
  return;
1618
1987
  }
1619
1988
  case "add": {
1620
- const source = positional[0], title = text(flags, "title");
1621
- if (!source || !title) fail('twombly add <folder|file> --title "Title"');
1622
- const project = await currentProject();
1623
- await markWorking();
1624
- const { folder, temporary, image } = await prepare(resolve2(source));
1625
- const id = randomBytes2(8).toString("hex").replace(/[01lo]/g, "a").slice(0, 12);
1626
- const entry = text(flags, "entry") || "index.html", width = size(flags, "width", 1280);
1627
- const height = flags.height === "auto" ? "auto" : size(flags, "height", 800);
1628
- const shot = posterFile(project, id);
1629
- try {
1630
- const result = await runGates(folder, { entry, width, height, poster: shot });
1631
- if (!result.ok) {
1632
- await rm4(shot, { force: true });
1633
- gateFailure(result);
1634
- }
1635
- await replaceFolder(folder, frameDir(project, id));
1636
- const { content } = await folderContent(frameDir(project, id));
1637
- const existing = await call("list", { project });
1638
- if (existing.frames.some((frame) => frame.title === title)) result.warnings.push(`Another frame is already titled "${title}". Give each a distinct title, or use update to replace it.`);
1639
- const placed = await call("place", {
1640
- project,
1641
- id,
1642
- title,
1643
- w: width,
1644
- h: result.height,
1645
- entry,
1646
- content,
1647
- level: level(flags, image ? "image" : "build"),
1648
- hasPoster: !!result.screenshot,
1649
- x: number(flags, "x"),
1650
- y: number(flags, "y"),
1651
- newRow: flags["new-row"] === true
1652
- });
1653
- print({ ok: true, placed: true, ...placed, warnings: result.warnings, screenshot: result.screenshot, next: "Open the screenshot. Passing the gates does not prove it looks right.", contentSize: result.content });
1654
- } finally {
1655
- if (temporary) await rm4(temporary, { recursive: true, force: true });
1656
- }
1657
- return;
1989
+ return addFrames(await currentProject(), positional[0], flags.frames !== void 0, await frameItems(flags, ["folder", "title", "width", "height", "entry", "level", "newRow", "x", "y"]));
1658
1990
  }
1659
1991
  case "update": {
1660
- const ref = positional[0], source = positional[1];
1661
- if (!ref || !source) fail("twombly update <frame> <folder|file>");
1662
- const project = await currentProject();
1663
- await markWorking();
1664
- const before = await call("list", { project });
1665
- const matches = before.frames.filter((frame2) => frame2.id === ref || frame2.title === ref);
1666
- if (matches.length !== 1) fail(matches.length ? `More than one frame is titled "${ref}". Use its id.` : `No frame "${ref}". Run: twombly list`);
1667
- const frame = matches[0];
1668
- const { folder, temporary, image } = await prepare(resolve2(source));
1669
- const entry = text(flags, "entry") || frame.entry, width = size(flags, "width", frame.w);
1670
- const height = flags.height === "auto" ? "auto" : size(flags, "height", frame.h);
1671
- const scratch = await mkdtemp(join8(tmpdir(), "twombly-update-"));
1672
- const candidate = join8(scratch, "poster.png");
1673
- try {
1674
- const result = await runGates(folder, { entry, width, height, poster: candidate });
1675
- if (!result.ok) gateFailure(result);
1676
- await replaceFolder(folder, frameDir(project, frame.id));
1677
- await mkdir6(join8(project, ".twombly/posters"), { recursive: true });
1678
- await copyFile(candidate, posterFile(project, frame.id));
1679
- const { content } = await folderContent(frameDir(project, frame.id));
1680
- const updated = await call("revise", {
1681
- project,
1682
- id: frame.id,
1683
- title: text(flags, "title"),
1684
- w: width,
1685
- h: result.height,
1686
- entry,
1687
- content,
1688
- hasPoster: true,
1689
- level: text(flags, "level") || image ? level(flags, image ? "image" : "build") : void 0
1690
- });
1691
- print({ ok: true, placed: true, ...updated, warnings: result.warnings, screenshot: posterFile(project, frame.id), next: "Open the screenshot. Passing the gates does not prove it looks right.", contentSize: result.content });
1692
- } finally {
1693
- if (temporary) await rm4(temporary, { recursive: true, force: true });
1694
- await rm4(scratch, { recursive: true, force: true });
1695
- }
1696
- return;
1992
+ const many = flags.frames !== void 0;
1993
+ const items = many ? await frameItems(flags, ["frame", "folder", "title", "width", "height", "entry", "level"]) : [{ ...flags, ...positional[0] ? { frame: positional[0] } : {} }];
1994
+ return updateFrames(await currentProject(), many ? positional[0] : positional[1], many, items);
1697
1995
  }
1698
1996
  case "working":
1699
1997
  print({ ok: true, ...await call("working", { project: await currentProject(), ms: WORKING_MS }) });
@@ -1707,6 +2005,11 @@ async function main() {
1707
2005
  case "open":
1708
2006
  print({ ok: true, ...await call("open", { project: await currentProject() }) });
1709
2007
  return;
2008
+ case "preview": {
2009
+ if (!positional[0]) fail("twombly preview <frame>");
2010
+ print({ ok: true, ...await call("preview", { project: await currentProject(), frame: positional[0] }) });
2011
+ return;
2012
+ }
1710
2013
  case "status":
1711
2014
  print({ ok: true, ...await call("status", { project: await findProject(process.cwd()) }) });
1712
2015
  return;
@@ -1722,6 +2025,7 @@ async function main() {
1722
2025
  return;
1723
2026
  }
1724
2027
  case "resize": {
2028
+ if (flags.height === "auto") fail("resize takes a number. To fit the page: twombly update <frame> <folder> --height auto");
1725
2029
  if (!positional[0] || flags.width === void 0 || flags.height === void 0) fail("twombly resize <frame> --width N --height N");
1726
2030
  print({ ok: true, ...await call("patch", { project: await currentProject(), frame: positional[0], patch: { w: size(flags, "width", 1280), h: size(flags, "height", 800) } }) });
1727
2031
  return;