twombly 0.1.0 → 0.3.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 as copyFile2, 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
@@ -13,11 +13,13 @@ var LIMITS = {
13
13
  fileBytes: 25 * 1024 * 1024,
14
14
  frameBytes: 100 * 1024 * 1024,
15
15
  frameFiles: 5e3,
16
- canvasFrames: 200,
16
+ canvasFrames: 500,
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,24 @@ 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 COLORS = ["#F24822", "#FF7A00", "#14AE5C", "#0D99FF", "#9747FF", "#E5277B", "#00B5B8", "#7B61FF"];
248
+ function colorOf(id) {
249
+ let h = 0;
250
+ for (const c of String(id)) h = h * 31 + c.charCodeAt(0) >>> 0;
251
+ return COLORS[h % COLORS.length];
252
+ }
253
+ var FRAME_ID = /^[a-km-z2-9]{8,32}$/;
236
254
  var FRAME_GAP = 80;
237
255
  function nextPosition(frames, newRow = false) {
238
256
  if (!frames.length) return { x: 0, y: 0 };
@@ -243,7 +261,7 @@ function nextPosition(frames, newRow = false) {
243
261
  }
244
262
 
245
263
  // ../../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";
264
+ 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, 500 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. If `created` is false, the canvas already existed: see [Other agents and more canvases](#other-agents-and-more-canvases).\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/<task>/<version>` so the person's source stays clean. Pick a `<task>` no other agent would, because a shared folder gets overwritten. Use the first approach below that works. The canvas tells people an agent is working: `init` and the slow commands keep that showing 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 in your own folder under `.twombly/work`; here, `composer`.\n\n```js\n// .twombly/work/composer/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/composer/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/composer/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/composer/vite.config.mjs` and `twombly add .twombly/work/composer/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## What the person is looking at\n\nWhen the person says \"this\" or \"these\" without naming a frame, run `twombly context`. It returns the frames they have open fullscreen, are using, or have selected, in that order, plus any comment they have open. `looking: false` means no canvas is open in a browser. If nothing is selected, ask which frame they mean.\n\n`twombly comments` returns every comment, with the frame it's on, where on the frame (in the frame's pixels), whether it's resolved, and `mine: true` for the person's own comments.\n\nEach frame in `context` and `list` has a `source`: the folder or file it was last added or updated from. Edit what built it, rebuild, then `update` the frame. Frames added before this existed show `null`.\n\n## Other agents and more canvases\n\nA folder has one default canvas, and every agent in that folder or below it lands on it. `init` on an existing canvas joins it and keeps its name. A folder can also hold any number of named canvases, each with its own address, frames and link.\n\n**Use a named canvas** when your work is a separate exploration, or the person asks for its own canvas: `twombly init --canvas \"Pricing\" --no-open`. Then pass `--canvas \"Pricing\"` on every command, because nothing remembers it between commands. `init --canvas` with a name that exists joins it, so agents can share one by agreeing on the name. `twombly canvases` lists what's here.\n\n**On a canvas with other agents:**\n\n- Only change your own frames, unless the person asks. Any agent's `update`, `move` and `remove` work on any frame, and `arrange` moves all of them.\n- Give frames titles no one else would use, such as \"Pricing: V1\", since commands find frames by title. A repeated title gets a warning, and then has to be addressed by id.\n- Add each set in one `--frames` command, with `--new-row` on its first frame, so it stays together.\n- If `update` says someone else changed a frame, run `twombly list` and don't retry without checking whose work you'd replace.\n- Run `twombly done` when you finish. The canvas keeps showing work until every agent that ran `init` has finished, or three minutes after the last activity.\n\n**Worktrees:** a canvas lives in the folder it was started in. A worktree outside the repo gets its own canvas, which is deleted with the worktree, so tell the person that before removing it. A worktree inside the repo joins the repo's default canvas if there is 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 keeps the canvas on this machine, comments included.\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
265
 
248
266
  // src/capture.ts
249
267
  import { createHash } from "node:crypto";
@@ -284,10 +302,10 @@ async function capture(url, out, options) {
284
302
  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
303
  return { folder: out, assets: 1, failed: [] };
286
304
  }
287
- const saved = /* @__PURE__ */ new Map(), failed = [];
305
+ const saved2 = /* @__PURE__ */ new Map(), failed = [];
288
306
  const save = async (assetUrl) => {
289
307
  if (assetUrl.startsWith("data:") || assetUrl.startsWith("blob:")) return null;
290
- if (saved.has(assetUrl)) return saved.get(assetUrl);
308
+ if (saved2.has(assetUrl)) return saved2.get(assetUrl);
291
309
  try {
292
310
  const response = await context.request.get(assetUrl, { timeout: 2e4 });
293
311
  if (!response.ok()) throw new Error(String(response.status()));
@@ -295,7 +313,7 @@ async function capture(url, out, options) {
295
313
  let bytes = await response.body();
296
314
  const hash = createHash("sha256").update(assetUrl).digest("hex").slice(0, 16);
297
315
  const name = `assets/${hash}${EXT[type] || extname(new URL(assetUrl).pathname) || ""}`;
298
- saved.set(assetUrl, name);
316
+ saved2.set(assetUrl, name);
299
317
  if (type === "text/css") bytes = Buffer.from(await rewriteCss(bytes.toString("utf8"), assetUrl, "css"));
300
318
  await writeFile(join(out, name), bytes);
301
319
  return name;
@@ -388,7 +406,7 @@ async function capture(url, out, options) {
388
406
  return "<!doctype html>\n" + document.documentElement.outerHTML;
389
407
  }, { replacements, styles, inline });
390
408
  await writeFile(join(out, "index.html"), html);
391
- return { folder: out, assets: saved.size, failed };
409
+ return { folder: out, assets: saved2.size, failed };
392
410
  } finally {
393
411
  await browser.close().catch(() => {
394
412
  });
@@ -398,10 +416,9 @@ async function capture(url, out, options) {
398
416
  // src/daemon.ts
399
417
  import { createHash as createHash3, randomBytes } from "node:crypto";
400
418
  import { createReadStream as createReadStream2 } from "node:fs";
401
- import { readFile as readFile5, stat as stat3 } from "node:fs/promises";
419
+ import { copyFile, mkdir as mkdir6, readFile as readFile5, stat as stat3 } from "node:fs/promises";
402
420
  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";
421
+ import { dirname as dirname3, extname as extname2, join as join8 } from "node:path";
405
422
  import { WebSocketServer, WebSocket } from "ws";
406
423
 
407
424
  // src/credentials.ts
@@ -418,6 +435,7 @@ var PORT = Number(process.env.TWOMBLY_PORT) || 4747;
418
435
  var ENDPOINT = (process.env.TWOMBLY_ENDPOINT || "https://twombly.app").replace(/\/+$/, "");
419
436
  var DAEMON_ORIGIN = `http://localhost:${PORT}`;
420
437
  var NO_OPEN = process.env.TWOMBLY_NO_OPEN === "1";
438
+ var TOOL_API = 3;
421
439
 
422
440
  // src/credentials.ts
423
441
  var run = promisify(execFile);
@@ -545,36 +563,164 @@ async function startFrameServer(folder, poster) {
545
563
  } };
546
564
  }
547
565
 
566
+ // src/interface.ts
567
+ import { createPublicKey, verify } from "node:crypto";
568
+ import { mkdir as mkdir3, readdir, rename, rm as rm3, writeFile as writeFile3 } from "node:fs/promises";
569
+ import { dirname, join as join5 } from "node:path";
570
+ import { fileURLToPath } from "node:url";
571
+
572
+ // src/release.ts
573
+ var INTERFACE_VERSION = 1;
574
+ var INTERFACE_PUBLIC_KEY = "MCowBQYDK2VwAyEA2/JCcP+q2Ht/fjTqlMvBzwrUS07UAXNp1z6bqY0hG9U=";
575
+
576
+ // src/interface.ts
577
+ var OVERRIDE = process.env.TWOMBLY_UI;
578
+ var UPDATES = !OVERRIDE && process.env.TWOMBLY_NO_INTERFACE_UPDATES !== "1";
579
+ var BUNDLED = join5(dirname(fileURLToPath(import.meta.url)), "ui");
580
+ var STORE = join5(HOME, "interface", String(INTERFACE_VERSION));
581
+ var PUBLIC_KEY = createPublicKey({ key: Buffer.from(process.env.TWOMBLY_INTERFACE_KEY || INTERFACE_PUBLIC_KEY, "base64"), format: "der", type: "spki" });
582
+ var KEEP = 3;
583
+ var CHECK_MS = 36e5;
584
+ var CHECK_ON_OPEN_MS = 5 * 6e4;
585
+ var WAIT_ON_OPEN_MS = 600;
586
+ var DOWNLOAD_MS = 3e4;
587
+ var MAX_CHARS = 64 * 1024 * 1024;
588
+ var SAFE_PATH = /^[\w.@-]+(\/[\w.@-]+)*$/;
589
+ var saved = [];
590
+ var etag = "";
591
+ var checking = null;
592
+ var checkedAt = 0;
593
+ function interfaceFolders() {
594
+ if (OVERRIDE) return [OVERRIDE];
595
+ const folder = (released) => join5(STORE, String(released));
596
+ return [...saved.filter((released) => released > 1793910384715).map(folder), BUNDLED, ...saved.filter((released) => released <= 1793910384715).map(folder)];
597
+ }
598
+ function unpack(body) {
599
+ const { payload, signature } = JSON.parse(body);
600
+ if (typeof payload !== "string" || typeof signature !== "string") return null;
601
+ if (!verify(null, Buffer.from(payload), PUBLIC_KEY, Buffer.from(signature, "base64"))) return null;
602
+ const release = JSON.parse(payload);
603
+ if (release.interface !== INTERFACE_VERSION || !Number.isSafeInteger(release.released) || !release.files?.["index.html"]) return null;
604
+ const safe = Object.entries(release.files).every(([path, bytes]) => SAFE_PATH.test(path) && !path.split("/").includes("..") && typeof bytes === "string");
605
+ return safe ? release : null;
606
+ }
607
+ async function install(released, files) {
608
+ const temporary = join5(STORE, `.${released}-${process.pid}`);
609
+ try {
610
+ for (const [path, bytes] of Object.entries(files)) {
611
+ await mkdir3(dirname(join5(temporary, path)), { recursive: true });
612
+ await writeFile3(join5(temporary, path), Buffer.from(bytes, "base64"));
613
+ }
614
+ await rename(temporary, join5(STORE, String(released)));
615
+ } catch (error) {
616
+ await rm3(temporary, { recursive: true, force: true });
617
+ throw error;
618
+ }
619
+ saved = [released, ...saved].sort((a, b) => b - a);
620
+ for (const old of saved.splice(KEEP)) await rm3(join5(STORE, String(old)), { recursive: true, force: true });
621
+ }
622
+ async function check() {
623
+ const response = await fetch(`${ENDPOINT}/interface/${INTERFACE_VERSION}`, { headers: etag ? { "if-none-match": etag } : {}, signal: AbortSignal.timeout(DOWNLOAD_MS) });
624
+ if (response.status !== 200) return;
625
+ const body = await response.text();
626
+ if (body.length > MAX_CHARS) return;
627
+ const release = unpack(body);
628
+ if (!release) return;
629
+ etag = response.headers.get("etag") || "";
630
+ if (release.released > Math.max(saved[0] ?? 0, 1793910384715)) await install(release.released, release.files);
631
+ }
632
+ function startCheck() {
633
+ if (checking) return;
634
+ checkedAt = Date.now();
635
+ checking = check().catch(() => {
636
+ }).finally(() => {
637
+ checking = null;
638
+ });
639
+ }
640
+ async function freshInterface() {
641
+ if (!UPDATES) return;
642
+ if (Date.now() - checkedAt > CHECK_ON_OPEN_MS) startCheck();
643
+ if (checking) await Promise.race([checking, new Promise((done) => setTimeout(done, WAIT_ON_OPEN_MS))]);
644
+ }
645
+ async function startInterfaceUpdates() {
646
+ if (!UPDATES) return;
647
+ await mkdir3(STORE, { recursive: true });
648
+ const names = await readdir(STORE);
649
+ for (const name of names) if (name.startsWith(".")) await rm3(join5(STORE, name), { recursive: true, force: true });
650
+ saved = names.filter((name) => /^\d+$/.test(name)).map(Number).sort((a, b) => b - a);
651
+ startCheck();
652
+ setInterval(startCheck, CHECK_MS).unref();
653
+ }
654
+
548
655
  // src/project.ts
549
656
  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";
657
+ 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";
658
+ import { basename, dirname as dirname2, join as join6, relative, resolve, sep } from "node:path";
552
659
  var DIR = ".twombly";
553
660
  var exists = (path) => stat2(path).then(() => true, () => false);
554
661
  async function findProject(from) {
555
662
  let dir = resolve(from);
556
663
  while (true) {
557
- if (await exists(join5(dir, DIR, "canvas.json"))) return dir;
558
- const up = dirname(dir);
664
+ if (await exists(join6(dir, DIR, "canvas.json"))) return dir;
665
+ const up = dirname2(dir);
559
666
  if (up === dir) return null;
560
667
  dir = up;
561
668
  }
562
669
  }
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`);
670
+ function inOrder() {
671
+ let last = Promise.resolve();
672
+ return (work) => {
673
+ const next = last.then(work, work);
674
+ last = next.catch(() => {
675
+ });
676
+ return next;
677
+ };
678
+ }
679
+ var NAMED = "canvases";
680
+ function canvasSlug(name) {
681
+ const slug = name.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
682
+ return slug || createHash2("sha256").update(name).digest("hex").slice(0, 12);
683
+ }
684
+ var namedProject = (root, name) => join6(root, DIR, NAMED, canvasSlug(name));
685
+ async function canvasesAbove(from) {
686
+ const out = [];
687
+ const read = async (project, slug) => {
688
+ const canvas = await readCanvas(project).catch(() => null);
689
+ if (canvas) out.push({ project, slug, id: canvas.id, name: canvas.name, frames: canvas.frames.length, link: canvas.remote?.url || null });
690
+ };
691
+ for (let dir = resolve(from); ; dir = dirname2(dir)) {
692
+ await read(dir, null);
693
+ const named = await readdir2(join6(dir, DIR, NAMED)).catch(() => []);
694
+ for (const slug of named.sort()) await read(join6(dir, DIR, NAMED, slug), slug);
695
+ if (dirname2(dir) === dir) return out;
696
+ }
697
+ }
698
+ async function findNamed(from, ref) {
699
+ const slug = canvasSlug(ref), all = await canvasesAbove(from);
700
+ return all.find((entry) => entry.slug === slug || entry.id === ref) ?? all.find((entry) => canvasSlug(entry.name) === slug) ?? null;
701
+ }
702
+ async function findRoot(from) {
703
+ for (let dir = resolve(from); ; dir = dirname2(dir)) {
704
+ if (await exists(join6(dir, DIR, "canvas.json")) || await exists(join6(dir, DIR, NAMED))) return dir;
705
+ if (dirname2(dir) === dir) return null;
706
+ }
707
+ }
708
+ var canvasFile = (project) => join6(project, DIR, "canvas.json");
709
+ var frameDir = (project, id) => join6(project, DIR, "frames", id);
710
+ var posterFile = (project, id) => join6(project, DIR, "posters", `${id}.png`);
566
711
  async function readCanvas(project) {
567
712
  return JSON.parse(await readFile3(canvasFile(project), "utf8"));
568
713
  }
569
714
  async function writeCanvas(project, canvas) {
570
715
  const path = canvasFile(project), temp = `${path}.${process.pid}.tmp`;
571
- await writeFile3(temp, JSON.stringify(canvas, null, 2) + "\n");
572
- await rename(temp, path);
716
+ await writeFile4(temp, JSON.stringify(canvas, null, 2) + "\n");
717
+ await rename2(temp, path);
573
718
  }
574
719
  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");
720
+ await mkdir4(join6(project, DIR, "frames"), { recursive: true });
721
+ await mkdir4(join6(project, DIR, "posters"), { recursive: true });
722
+ await writeFile4(join6(project, DIR, ".gitignore"), "*\n");
723
+ if (basename(dirname2(project)) === NAMED && basename(dirname2(dirname2(project))) === DIR) await writeFile4(join6(dirname2(dirname2(project)), ".gitignore"), "*\n");
578
724
  }
579
725
  function defaultName(project) {
580
726
  return basename(project).replace(/[-_]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()) || "Untitled";
@@ -582,8 +728,8 @@ function defaultName(project) {
582
728
  async function hashFolder(folder) {
583
729
  const out = [];
584
730
  const walk = async (dir) => {
585
- for (const entry of await readdir(dir, { withFileTypes: true })) {
586
- const absolute = join5(dir, entry.name);
731
+ for (const entry of await readdir2(dir, { withFileTypes: true })) {
732
+ const absolute = join6(dir, entry.name);
587
733
  if (entry.isDirectory()) await walk(absolute);
588
734
  else if (entry.isFile()) {
589
735
  if (entry.name === ".DS_Store") continue;
@@ -600,14 +746,18 @@ async function folderContent(folder) {
600
746
  return { files, content: await contentHash(files) };
601
747
  }
602
748
  async function replaceFolder(source, target) {
603
- const temp = `${target}.${process.pid}.tmp`;
604
- await rm3(temp, { recursive: true, force: true });
605
- await mkdir3(dirname(target), { recursive: true });
749
+ const temp = `${target}.${process.pid}.tmp`, old = `${target}.${process.pid}.old`;
750
+ await rm4(temp, { recursive: true, force: true });
751
+ await rm4(old, { recursive: true, force: true });
752
+ await mkdir4(dirname2(target), { recursive: true });
606
753
  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);
754
+ await rename2(target, old).catch((error) => {
755
+ if (error.code !== "ENOENT") throw error;
756
+ });
757
+ await rename2(temp, target);
758
+ await rm4(old, { recursive: true, force: true });
609
759
  }
610
- var registryFile = join5(HOME, "registry.json");
760
+ var registryFile = join6(HOME, "registry.json");
611
761
  async function readRegistry() {
612
762
  try {
613
763
  return JSON.parse(await readFile3(registryFile, "utf8"));
@@ -615,31 +765,35 @@ async function readRegistry() {
615
765
  return {};
616
766
  }
617
767
  }
618
- async function register(id, project) {
619
- const registry = await readRegistry();
620
- if (registry[id] === project) return;
621
- registry[id] = project;
622
- await mkdir3(HOME, { recursive: true });
623
- await writeFile3(registryFile, JSON.stringify(registry, null, 2));
768
+ var registryTurn = inOrder();
769
+ function register(id, project) {
770
+ return registryTurn(async () => {
771
+ const registry = await readRegistry();
772
+ if (registry[id] === project) return;
773
+ registry[id] = project;
774
+ await mkdir4(HOME, { recursive: true });
775
+ const temp = `${registryFile}.${process.pid}.tmp`;
776
+ await writeFile4(temp, JSON.stringify(registry, null, 2));
777
+ await rename2(temp, registryFile);
778
+ });
624
779
  }
625
780
 
626
781
  // 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");
782
+ import { mkdir as mkdir5, readFile as readFile4, rename as rename3, writeFile as writeFile5 } from "node:fs/promises";
783
+ import { join as join7 } from "node:path";
784
+ var FILE = join7(HOME, "settings.json");
630
785
  async function readSettings() {
631
- const saved = await readFile4(FILE, "utf8").then(JSON.parse).catch(() => null);
632
- return { invertScroll: saved?.invertScroll === true };
786
+ const saved2 = await readFile4(FILE, "utf8").then(JSON.parse).catch(() => null);
787
+ return { invertScroll: saved2?.invertScroll === true };
633
788
  }
634
789
  async function writeSettings(settings2) {
635
- await mkdir4(HOME, { recursive: true });
790
+ await mkdir5(HOME, { recursive: true });
636
791
  const temporary = `${FILE}.${process.pid}`;
637
- await writeFile4(temporary, JSON.stringify(settings2));
638
- await rename2(temporary, FILE);
792
+ await writeFile5(temporary, JSON.stringify(settings2));
793
+ await rename3(temporary, FILE);
639
794
  }
640
795
 
641
796
  // src/daemon.ts
642
- var UI = process.env.TWOMBLY_UI || join7(dirname2(fileURLToPath(import.meta.url)), "ui");
643
797
  var IDLE_MS = 6 * 36e5;
644
798
  var LOGIN_MS = 10 * 6e4;
645
799
  var ID_ALPHABET = "abcdefghijkmnpqrstuvwxyz23456789";
@@ -682,6 +836,11 @@ async function edge(path, init = {}) {
682
836
  if (!response.ok) throw new Failure(response.status, data.error || `The server answered ${response.status}`, data.problems ? { problems: data.problems } : void 0);
683
837
  return data;
684
838
  }
839
+ var STROKE_UPLOAD = 40;
840
+ var THREAD_UPLOAD_CHARS = 5e5;
841
+ var THREAD_ID = /^[a-z0-9]{8,32}$/;
842
+ var finite = (value) => typeof value === "number" && Number.isFinite(value);
843
+ var FRAME_SERVERS_IDLE_MS = Number(process.env.TWOMBLY_FRAME_IDLE_MS) || 5 * 6e4;
685
844
  var Canvas = class {
686
845
  id;
687
846
  project;
@@ -691,7 +850,13 @@ var Canvas = class {
691
850
  servers = /* @__PURE__ */ new Map();
692
851
  queue = Promise.resolve();
693
852
  saveTimer = null;
853
+ serversTimer = null;
854
+ writeTurn = inOrder();
694
855
  workingUntil = 0;
856
+ agents = 0;
857
+ contexts = /* @__PURE__ */ new Map();
858
+ /** So undoing a delete in the page keeps where the frame came from. */
859
+ removedSources = /* @__PURE__ */ new Map();
695
860
  constructor(project, data) {
696
861
  this.project = project;
697
862
  this.data = data;
@@ -707,13 +872,26 @@ var Canvas = class {
707
872
  save() {
708
873
  if (this.saveTimer) clearTimeout(this.saveTimer);
709
874
  this.saveTimer = setTimeout(() => {
710
- writeCanvas(this.project, this.data).catch(() => {
875
+ this.writeTurn(() => writeCanvas(this.project, this.data)).catch(() => {
711
876
  });
712
877
  }, 150);
713
878
  }
714
879
  async saveNow() {
715
880
  if (this.saveTimer) clearTimeout(this.saveTimer);
716
- await writeCanvas(this.project, this.data);
881
+ await this.writeTurn(() => writeCanvas(this.project, this.data));
882
+ }
883
+ /** Frame servers are only for people looking. They come back, on new ports, the next time someone does. */
884
+ closeServersWhenUnwatched() {
885
+ if (this.serversTimer) clearTimeout(this.serversTimer);
886
+ this.serversTimer = setTimeout(() => {
887
+ if (this.sockets.size) return;
888
+ for (const running of this.servers.values()) running.then((server) => server.close());
889
+ this.servers.clear();
890
+ }, FRAME_SERVERS_IDLE_MS);
891
+ }
892
+ /** Tells anyone looking about a frame, without starting its server for nobody. */
893
+ async announce(frame) {
894
+ if (this.sockets.size) this.broadcast({ t: "frame.upsert", frame: await this.toFrame(frame) });
717
895
  }
718
896
  serverFor(frame) {
719
897
  const key = `${frame.id}:${frame.content}`;
@@ -748,9 +926,18 @@ var Canvas = class {
748
926
  workingLeft() {
749
927
  return Math.max(0, this.workingUntil - Date.now());
750
928
  }
751
- /** An agent is at work for at least this long; zero means it's finished. Everyone looking at the canvas sees it. */
752
- working(ms) {
753
- this.workingUntil = ms > 0 ? Math.max(this.workingUntil, Date.now() + ms) : 0;
929
+ /**
930
+ * An agent is at work for at least this long; zero means it's finished. Everyone looking at the canvas sees it.
931
+ * Agents have no identity, so each `init` counts as one more and it only stops once each has finished, or time runs out.
932
+ */
933
+ working(ms, joining = false) {
934
+ if (!this.workingLeft()) this.agents = 0;
935
+ if (joining) this.agents++;
936
+ if (ms > 0) this.workingUntil = Math.max(this.workingUntil, Date.now() + ms);
937
+ else if (--this.agents <= 0) {
938
+ this.agents = 0;
939
+ this.workingUntil = 0;
940
+ }
754
941
  const left = this.workingLeft();
755
942
  this.broadcast({ t: "working", ms: left });
756
943
  if (this.data.remote) edge(`/api/canvases/${this.data.remote.id}/working`, { method: "POST", body: JSON.stringify({ ms: left }) }).catch(() => {
@@ -765,7 +952,10 @@ var Canvas = class {
765
952
  this.sockets.add(socket);
766
953
  touch();
767
954
  const self = randomId(10);
768
- socket.on("close", () => this.sockets.delete(socket));
955
+ socket.on("close", () => {
956
+ this.sockets.delete(socket);
957
+ this.closeServersWhenUnwatched();
958
+ });
769
959
  socket.on("message", (raw) => {
770
960
  let message;
771
961
  try {
@@ -782,23 +972,105 @@ var Canvas = class {
782
972
  t: "hello",
783
973
  self,
784
974
  role: "owner",
785
- doc: { id: this.id, name: this.data.name, frames },
786
- threads: [],
975
+ doc: { id: this.id, name: this.data.name, frames, strokes: this.data.strokes ?? [] },
976
+ threads: this.data.threads ?? [],
787
977
  peers: [this.peer(self)],
788
- capabilities: { comments: false, huddle: false },
978
+ // Shared but signed out, this is a copy the room can't hear about.
979
+ capabilities: { comments: !this.data.remote, huddle: false },
789
980
  working: this.workingLeft()
790
981
  }));
791
982
  }
792
983
  attachEvents(socket) {
793
984
  this.events.add(socket);
794
- socket.on("close", () => this.events.delete(socket));
985
+ socket.on("close", () => {
986
+ this.events.delete(socket);
987
+ this.contexts.delete(socket);
988
+ });
989
+ socket.on("message", (raw) => {
990
+ let message;
991
+ try {
992
+ message = JSON.parse(String(raw));
993
+ } catch {
994
+ return;
995
+ }
996
+ const context = message?.t === "context" ? cleanContext(message.context) : null;
997
+ if (context) this.contexts.set(socket, { context, at: Date.now() });
998
+ });
999
+ }
1000
+ /** What the person means by "this", from whichever of their windows changed it last. Null when none is open. */
1001
+ context() {
1002
+ let latest = null;
1003
+ for (const entry of this.contexts.values()) if (!latest || entry.at > latest.at) latest = entry;
1004
+ return latest?.context ?? null;
1005
+ }
1006
+ // ---------- comments, while local ----------
1007
+ author() {
1008
+ const me = credential?.me, id = me?.id || "local";
1009
+ return { id, name: me?.name || "You", picture: me?.picture || null, color: colorOf(id) };
1010
+ }
1011
+ thread(id) {
1012
+ return (this.data.threads ?? []).find((thread) => thread.id === id);
1013
+ }
1014
+ putThread(thread) {
1015
+ const threads = this.data.threads ??= [], index = threads.findIndex((item) => item.id === thread.id);
1016
+ if (index < 0) threads.push(thread);
1017
+ else threads[index] = thread;
1018
+ this.broadcast({ t: "thread", thread });
1019
+ this.save();
1020
+ }
1021
+ dropThread(id) {
1022
+ this.data.threads = (this.data.threads ?? []).filter((thread) => thread.id !== id);
1023
+ this.broadcast({ t: "thread.remove", id });
1024
+ this.save();
1025
+ }
1026
+ frameIdOf(id) {
1027
+ return typeof id === "string" && this.data.frames.some((frame) => frame.id === id) ? id : null;
1028
+ }
1029
+ handleComment(m) {
1030
+ const text2 = (value) => typeof value === "string" ? value.trim().slice(0, LIMITS.commentChars) : "";
1031
+ const comment = (value) => ({ id: randomId(12), author: this.author(), text: value, createdAt: Date.now() });
1032
+ switch (m.t) {
1033
+ case "thread.create": {
1034
+ const value = text2(m.text);
1035
+ if (!value || !finite(m.x) || !finite(m.y) || typeof m.id !== "string" || !THREAD_ID.test(m.id) || this.thread(m.id)) return;
1036
+ const first = comment(value);
1037
+ return this.putThread({ id: m.id, frameId: this.frameIdOf(m.frameId), x: m.x, y: m.y, resolved: false, createdAt: first.createdAt, comments: [first] });
1038
+ }
1039
+ case "thread.move": {
1040
+ const thread = this.thread(m.id);
1041
+ if (thread && finite(m.x) && finite(m.y)) this.putThread({ ...thread, frameId: this.frameIdOf(m.frameId), x: m.x, y: m.y });
1042
+ return;
1043
+ }
1044
+ case "thread.resolve": {
1045
+ const thread = this.thread(m.id);
1046
+ if (thread) this.putThread({ ...thread, resolved: !!m.resolved });
1047
+ return;
1048
+ }
1049
+ case "thread.delete":
1050
+ if (this.thread(m.id)) this.dropThread(m.id);
1051
+ return;
1052
+ case "comment.add": {
1053
+ const thread = this.thread(m.threadId), value = text2(m.text);
1054
+ if (thread && value) this.putThread({ ...thread, comments: [...thread.comments, comment(value)] });
1055
+ return;
1056
+ }
1057
+ case "comment.delete": {
1058
+ const thread = this.thread(m.threadId);
1059
+ if (!thread || !thread.comments.some((item) => item.id === m.id)) return;
1060
+ if (thread.comments[0].id === m.id) return this.dropThread(thread.id);
1061
+ this.putThread({ ...thread, comments: thread.comments.filter((item) => item.id !== m.id) });
1062
+ }
1063
+ }
795
1064
  }
796
1065
  async handle(socket, m) {
797
1066
  if (m.t === "ping") return socket.send('{"t":"pong"}');
798
1067
  if (this.data.remote) return;
799
1068
  if (m.t === "frame.patch") return this.applyPatch(m.id, m.patch);
1069
+ if (m.t.startsWith("thread.") || m.t.startsWith("comment.")) return this.handleComment(m);
800
1070
  if (m.t === "frame.remove") {
801
1071
  const before = this.data.frames.length;
1072
+ const removed = this.data.frames.find((frame) => frame.id === m.id);
1073
+ if (removed) this.removedSources.set(removed.id, removed.source);
802
1074
  this.data.frames = this.data.frames.filter((frame) => frame.id !== m.id);
803
1075
  if (this.data.frames.length !== before) {
804
1076
  this.broadcast({ t: "frame.remove", id: m.id });
@@ -809,9 +1081,25 @@ var Canvas = class {
809
1081
  if (m.t === "frame.restore") {
810
1082
  const f = m.frame;
811
1083
  if (this.data.frames.some((frame) => frame.id === f.id)) return;
812
- const restored = { id: f.id, title: f.title, x: f.x, y: f.y, w: f.w, h: f.h, z: f.z, entry: f.entry, content: f.content, level: f.level, hasPoster: !!f.posterUrl, updatedAt: Date.now() };
1084
+ const restored = { id: f.id, title: f.title, x: f.x, y: f.y, w: f.w, h: f.h, z: f.z, entry: f.entry, content: f.content, level: f.level, hasPoster: !!f.posterUrl, updatedAt: Date.now(), source: this.removedSources.get(f.id) };
813
1085
  this.data.frames.push(restored);
814
- this.broadcast({ t: "frame.upsert", frame: await this.toFrame(restored) });
1086
+ await this.announce(restored);
1087
+ this.save();
1088
+ return;
1089
+ }
1090
+ if (m.t === "stroke.add") {
1091
+ const strokes = this.data.strokes ??= [];
1092
+ const clean = cleanStroke(m.stroke);
1093
+ if (!clean || strokes.length >= LIMITS.canvasStrokes && !strokes.some((item) => item.id === clean.id)) return this.broadcast({ t: "stroke.remove", id: String(m.stroke?.id) });
1094
+ const stroke = { ...clean, authorId: "local", createdAt: Date.now() };
1095
+ this.data.strokes = [...strokes.filter((item) => item.id !== stroke.id), stroke];
1096
+ this.broadcast({ t: "strokes", strokes: [stroke] });
1097
+ this.save();
1098
+ return;
1099
+ }
1100
+ if (m.t === "stroke.remove") {
1101
+ this.data.strokes = (this.data.strokes ?? []).filter((item) => item.id !== m.id);
1102
+ this.broadcast({ t: "stroke.remove", id: m.id });
815
1103
  this.save();
816
1104
  return;
817
1105
  }
@@ -832,16 +1120,16 @@ var Canvas = class {
832
1120
  frame.w = Math.max(40, Math.min(8e3, frame.w));
833
1121
  frame.h = Math.max(40, Math.min(8e3, frame.h));
834
1122
  frame.updatedAt = Date.now();
835
- this.broadcast({ t: "frame.upsert", frame: await this.toFrame(frame) });
1123
+ await this.announce(frame);
836
1124
  this.save();
837
1125
  return frame;
838
1126
  }
839
1127
  /** Once shared, the room is the truth. Bring the local list up to date with it. */
840
1128
  async pull() {
841
1129
  if (!this.data.remote) return;
842
- let doc;
1130
+ let doc, threads;
843
1131
  try {
844
- ({ doc } = await edge(`/api/canvases/${this.data.remote.id}`));
1132
+ ({ doc, threads } = await edge(`/api/canvases/${this.data.remote.id}`));
845
1133
  } catch (error) {
846
1134
  if (error instanceof Failure && (error.status === 404 || error.status === 403)) {
847
1135
  this.data.remote = null;
@@ -852,6 +1140,8 @@ var Canvas = class {
852
1140
  throw error;
853
1141
  }
854
1142
  this.data.name = doc.name;
1143
+ if (doc.strokes) this.data.strokes = doc.strokes;
1144
+ if (threads) this.data.threads = threads;
855
1145
  const known = new Map(this.data.frames.map((frame) => [frame.id, frame]));
856
1146
  this.data.frames = doc.frames.map((frame) => ({
857
1147
  id: frame.id,
@@ -865,7 +1155,8 @@ var Canvas = class {
865
1155
  content: frame.content,
866
1156
  level: frame.level,
867
1157
  hasPoster: known.get(frame.id)?.hasPoster ?? !!frame.posterUrl,
868
- updatedAt: frame.updatedAt
1158
+ updatedAt: frame.updatedAt,
1159
+ source: known.get(frame.id)?.source
869
1160
  }));
870
1161
  await this.saveNow();
871
1162
  }
@@ -910,6 +1201,23 @@ var Canvas = class {
910
1201
  this.event({ t: "share-progress", done: ++done, total });
911
1202
  });
912
1203
  }
1204
+ const strokes = this.data.strokes ?? [];
1205
+ for (let i = 0; i < strokes.length; i += STROKE_UPLOAD) {
1206
+ await edge(`/api/canvases/${created.id}/strokes`, { method: "POST", body: JSON.stringify({ strokes: strokes.slice(i, i + STROKE_UPLOAD) }) });
1207
+ }
1208
+ let batch = [], chars = 0;
1209
+ const send = async () => {
1210
+ if (batch.length) await edge(`/api/canvases/${created.id}/threads`, { method: "POST", body: JSON.stringify({ threads: batch }) });
1211
+ batch = [];
1212
+ chars = 0;
1213
+ };
1214
+ for (const thread of this.data.threads ?? []) {
1215
+ const size2 = JSON.stringify(thread).length;
1216
+ if (chars + size2 > THREAD_UPLOAD_CHARS) await send();
1217
+ batch.push(thread);
1218
+ chars += size2;
1219
+ }
1220
+ await send();
913
1221
  } catch (error) {
914
1222
  await edge(`/api/canvases/${created.id}`, { method: "DELETE" }).catch(() => {
915
1223
  });
@@ -922,8 +1230,8 @@ var Canvas = class {
922
1230
  }
923
1231
  async unshare() {
924
1232
  if (!this.data.remote) return;
925
- await this.pull().catch(() => {
926
- });
1233
+ await this.pull();
1234
+ if (!this.data.remote) return;
927
1235
  await edge(`/api/canvases/${this.data.remote.id}`, { method: "DELETE" }).catch((error) => {
928
1236
  if (error.status !== 404) throw error;
929
1237
  });
@@ -933,16 +1241,24 @@ var Canvas = class {
933
1241
  }
934
1242
  };
935
1243
  var canvases = /* @__PURE__ */ new Map();
936
- async function canvasById(id) {
1244
+ var opening = /* @__PURE__ */ new Map();
1245
+ function canvasById(id) {
937
1246
  const open = canvases.get(id);
938
- if (open) return open;
939
- const project = (await readRegistry())[id];
940
- if (!project) throw new Failure(404, "That canvas is not on this machine");
941
- const data = await readCanvas(project).catch(() => null);
942
- if (!data || data.id !== id) throw new Failure(404, "That canvas is not on this machine");
943
- const canvas = new Canvas(project, data);
944
- canvases.set(id, canvas);
945
- return canvas;
1247
+ if (open) return Promise.resolve(open);
1248
+ let pending = opening.get(id);
1249
+ if (!pending) {
1250
+ pending = (async () => {
1251
+ const project = (await readRegistry())[id];
1252
+ if (!project) throw new Failure(404, "That canvas is not on this machine");
1253
+ const data = await readCanvas(project).catch(() => null);
1254
+ if (!data || data.id !== id) throw new Failure(404, "That canvas is not on this machine");
1255
+ const canvas = new Canvas(project, data);
1256
+ canvases.set(id, canvas);
1257
+ return canvas;
1258
+ })().finally(() => opening.delete(id));
1259
+ opening.set(id, pending);
1260
+ }
1261
+ return pending;
946
1262
  }
947
1263
  async function canvasForProject(project) {
948
1264
  const found = await findProject(project);
@@ -964,8 +1280,36 @@ var summary = (canvas) => ({
964
1280
  name: canvas.data.name,
965
1281
  local: `${DAEMON_ORIGIN}/${canvas.id}`,
966
1282
  link: canvas.data.remote?.url || null,
967
- frames: canvas.data.frames.map(({ id, title, x, y, w, h, entry, level: level2 }) => ({ id, title, x, y, w, h, entry, level: level2 }))
1283
+ frames: canvas.data.frames.map(({ id, title, x, y, w, h, entry, level: level2, content, source }) => ({ id, title, x, y, w, h, entry, level: level2, content, source: source ?? null }))
968
1284
  });
1285
+ function cleanContext(input) {
1286
+ const c = input;
1287
+ if (!c || typeof c !== "object" || !Array.isArray(c.frames)) return null;
1288
+ const via = c.via === "fullscreen" || c.via === "interacting" || c.via === "selected" ? c.via : null;
1289
+ const frames = c.frames.filter((id) => typeof id === "string").slice(0, LIMITS.canvasFrames);
1290
+ return { via, frames, thread: typeof c.thread === "string" ? c.thread : null };
1291
+ }
1292
+ function commentsOf(canvas, thread) {
1293
+ const me = credential?.me?.id;
1294
+ const frame = thread.frameId ? canvas.data.frames.find((item) => item.id === thread.frameId) : void 0;
1295
+ return {
1296
+ id: thread.id,
1297
+ frame: frame ? { id: frame.id, title: frame.title } : null,
1298
+ // Where the pin is: in the frame's own pixels, or on the open canvas.
1299
+ at: frame ? { x: Math.round(thread.x * frame.w), y: Math.round(thread.y * frame.h) } : { x: Math.round(thread.x), y: Math.round(thread.y) },
1300
+ resolved: thread.resolved,
1301
+ comments: thread.comments.map((comment) => ({ by: comment.author.name, mine: comment.author.id === "local" || comment.author.id === me, text: comment.text, at: new Date(comment.createdAt).toISOString() }))
1302
+ };
1303
+ }
1304
+ var visibleThreads = (canvas) => (canvas.data.threads ?? []).filter((thread) => !thread.frameId || canvas.data.frames.some((frame) => frame.id === thread.frameId));
1305
+ function contextOf(canvas) {
1306
+ const context = canvas.context();
1307
+ if (!context) return { looking: false, via: null, frames: [], comment: null };
1308
+ const frames = context.frames.flatMap((id) => canvas.data.frames.filter((frame) => frame.id === id)).map(({ id, title, w, h, entry, source }) => ({ id, title, w, h, entry, source: source ?? null }));
1309
+ const thread = visibleThreads(canvas).find((item) => item.id === context.thread);
1310
+ return { looking: true, via: frames.length ? context.via : null, frames, comment: thread ? commentsOf(canvas, thread) : null };
1311
+ }
1312
+ var initTurn = inOrder();
969
1313
  function findFrame(canvas, ref) {
970
1314
  const byId = canvas.data.frames.find((frame) => frame.id === ref);
971
1315
  if (byId) return byId;
@@ -976,6 +1320,7 @@ function findFrame(canvas, ref) {
976
1320
  async function command(name, input) {
977
1321
  touch();
978
1322
  if (name === "stop") {
1323
+ if (input.ifIdle && inFlight > 1) return { stopped: false };
979
1324
  setTimeout(() => process.exit(0), 50);
980
1325
  return { stopped: true };
981
1326
  }
@@ -998,18 +1343,21 @@ async function command(name, input) {
998
1343
  return { signedIn: false };
999
1344
  }
1000
1345
  if (name === "init") {
1001
- const project = input.project;
1002
- let found = await findProject(project);
1003
- if (!found) {
1004
- await ensureProjectDir(project);
1005
- await writeCanvas(project, { version: 1, id: randomId(12), name: String(input.name || "Untitled").slice(0, LIMITS.nameChars), frames: [], remote: null });
1006
- found = project;
1007
- }
1008
- const canvas2 = await canvasForProject(found);
1009
- if (input.rename && input.name && input.name !== canvas2.data.name) await rename3(canvas2, input.name);
1010
- if (input.open !== false && !canvas2.watchers) openBrowser(`${DAEMON_ORIGIN}/${canvas2.id}`);
1011
- canvas2.working(WORKING_MS);
1012
- return summary(canvas2);
1346
+ return initTurn(async () => {
1347
+ const project = input.project;
1348
+ const nearest = await findProject(project);
1349
+ let found = input.exact && nearest !== project ? null : nearest;
1350
+ const created = !found;
1351
+ if (!found) {
1352
+ await ensureProjectDir(project);
1353
+ await writeCanvas(project, { version: 1, id: randomId(12), name: String(input.name || "Untitled").slice(0, LIMITS.nameChars), frames: [], remote: null });
1354
+ found = project;
1355
+ }
1356
+ const canvas2 = await canvasForProject(found);
1357
+ if (input.open !== false && !canvas2.watchers) openBrowser(`${DAEMON_ORIGIN}/${canvas2.id}`);
1358
+ canvas2.working(WORKING_MS, true);
1359
+ return { ...summary(canvas2), created };
1360
+ });
1013
1361
  }
1014
1362
  const canvas = await canvasForProject(input.project);
1015
1363
  if (name === "working") return { working: canvas.working(Number(input.ms) || 0) > 0 };
@@ -1018,9 +1366,17 @@ async function command(name, input) {
1018
1366
  switch (name) {
1019
1367
  case "list":
1020
1368
  return summary(canvas);
1369
+ case "context":
1370
+ return contextOf(canvas);
1371
+ case "comments":
1372
+ return { comments: visibleThreads(canvas).map((thread) => commentsOf(canvas, thread)) };
1021
1373
  case "open":
1022
1374
  openBrowser(`${DAEMON_ORIGIN}/${canvas.id}`);
1023
1375
  return summary(canvas);
1376
+ case "preview": {
1377
+ const { id, title, w, h } = findFrame(canvas, input.frame);
1378
+ return { frame: { id, title, w, h }, url: `${DAEMON_ORIGIN}/preview/${canvas.id}/${id}` };
1379
+ }
1024
1380
  case "place":
1025
1381
  return place(canvas, input);
1026
1382
  case "revise":
@@ -1054,7 +1410,7 @@ async function command(name, input) {
1054
1410
  return summary(canvas);
1055
1411
  }
1056
1412
  case "rename":
1057
- await rename3(canvas, input.name);
1413
+ await rename4(canvas, input.name);
1058
1414
  return summary(canvas);
1059
1415
  case "share": {
1060
1416
  if (!credential) throw new Failure(401, "The person needs to sign in first. Ask them to click Share on the canvas.");
@@ -1069,7 +1425,7 @@ async function command(name, input) {
1069
1425
  }
1070
1426
  });
1071
1427
  }
1072
- async function rename3(canvas, name) {
1428
+ async function rename4(canvas, name) {
1073
1429
  const clean = String(name || "").trim().slice(0, LIMITS.nameChars);
1074
1430
  if (!clean) throw new Failure(400, "Expected a name");
1075
1431
  if (canvas.data.remote) await edge(`/api/canvases/${canvas.data.remote.id}`, { method: "PATCH", body: JSON.stringify({ name: clean }) });
@@ -1082,45 +1438,76 @@ async function patchFrame(canvas, id, patch) {
1082
1438
  await canvas.applyPatch(id, patch);
1083
1439
  await canvas.saveNow();
1084
1440
  }
1441
+ async function install2(canvas, id, folder, screenshot) {
1442
+ await replaceFolder(folder, frameDir(canvas.project, id));
1443
+ if (screenshot) {
1444
+ const poster = posterFile(canvas.project, id);
1445
+ await mkdir6(dirname3(poster), { recursive: true });
1446
+ await copyFile(screenshot, poster);
1447
+ }
1448
+ return (await folderContent(frameDir(canvas.project, id))).content;
1449
+ }
1450
+ var placedFrame = ({ id, title, x, y, w, h }) => ({ id, title, x, y, w, h });
1451
+ var canvasResult = (canvas) => ({ local: `${DAEMON_ORIGIN}/${canvas.id}`, link: canvas.data.remote?.url || null, frames: canvas.data.frames.length });
1085
1452
  async function place(canvas, input) {
1086
- const position = nextPosition(canvas.data.frames, !!input.newRow);
1087
- const frame = {
1088
- id: input.id,
1089
- title: String(input.title).slice(0, LIMITS.titleChars),
1090
- w: input.w,
1091
- h: input.h,
1092
- x: typeof input.x === "number" ? input.x : position.x,
1093
- y: typeof input.y === "number" ? input.y : position.y,
1094
- z: canvas.data.frames.length,
1095
- entry: input.entry,
1096
- content: input.content,
1097
- level: input.level,
1098
- hasPoster: !!input.hasPoster,
1099
- updatedAt: Date.now()
1100
- };
1101
- if (canvas.data.remote) await canvas.uploadFrame(canvas.data.remote.id, frame);
1102
- canvas.data.frames.push(frame);
1103
- await canvas.saveNow();
1104
- canvas.broadcast({ t: "frame.upsert", frame: await canvas.toFrame(frame) });
1453
+ const count = canvas.data.frames.length;
1454
+ if (count + input.items.length > LIMITS.canvasFrames) throw new Failure(422, `A canvas can hold ${LIMITS.canvasFrames} frames, and this one has ${count}. Remove some, or start another: twombly init --canvas "Name"`);
1455
+ const frames = [];
1456
+ for (const item of input.items) {
1457
+ const title = String(item.title).slice(0, LIMITS.titleChars);
1458
+ const warnings = canvas.data.frames.some((other) => other.title === title) ? [`Another frame is already titled "${title}". Give each a distinct title, or use update to replace it.`] : [];
1459
+ const content = await install2(canvas, item.id, item.folder, item.screenshot);
1460
+ const position = nextPosition(canvas.data.frames, !!item.newRow);
1461
+ const frame = {
1462
+ id: item.id,
1463
+ title,
1464
+ w: item.w,
1465
+ h: item.h,
1466
+ x: typeof item.x === "number" ? item.x : position.x,
1467
+ y: typeof item.y === "number" ? item.y : position.y,
1468
+ z: canvas.data.frames.length,
1469
+ entry: item.entry,
1470
+ content,
1471
+ level: item.level,
1472
+ hasPoster: !!item.screenshot,
1473
+ updatedAt: Date.now(),
1474
+ source: typeof item.source === "string" ? item.source : void 0
1475
+ };
1476
+ if (canvas.data.remote) await canvas.uploadFrame(canvas.data.remote.id, frame);
1477
+ canvas.data.frames.push(frame);
1478
+ await canvas.saveNow();
1479
+ await canvas.announce(frame);
1480
+ frames.push({ ...placedFrame(frame), warnings });
1481
+ }
1105
1482
  canvas.working(WORKING_TAIL_MS);
1106
- return { frame: { id: frame.id, title: frame.title, x: frame.x, y: frame.y, w: frame.w, h: frame.h }, canvas: { local: `${DAEMON_ORIGIN}/${canvas.id}`, link: canvas.data.remote?.url || null, frames: canvas.data.frames.length } };
1483
+ return { frames, canvas: canvasResult(canvas) };
1107
1484
  }
1108
1485
  async function revise(canvas, input) {
1109
- const frame = canvas.data.frames.find((item) => item.id === input.id);
1110
- if (!frame) throw new Failure(404, "That frame is gone");
1111
- const next = { ...frame, updatedAt: Date.now() };
1112
- if (input.title) next.title = String(input.title).slice(0, LIMITS.titleChars);
1113
- for (const key of ["w", "h"]) if (typeof input[key] === "number") next[key] = input[key];
1114
- if (input.entry) next.entry = input.entry;
1115
- if (input.level) next.level = input.level;
1116
- if (input.content) next.content = input.content;
1117
- if (typeof input.hasPoster === "boolean") next.hasPoster = input.hasPoster;
1118
- if (canvas.data.remote) await canvas.uploadFrame(canvas.data.remote.id, next);
1119
- Object.assign(frame, next);
1120
- await canvas.saveNow();
1121
- canvas.broadcast({ t: "frame.upsert", frame: await canvas.toFrame(frame) });
1486
+ const targets = input.items.map((item) => {
1487
+ const frame = canvas.data.frames.find((other) => other.id === item.id);
1488
+ if (!frame) throw new Failure(404, `"${item.label}" is gone. Run: twombly list`);
1489
+ if (frame.content !== item.base) throw new Failure(409, `"${frame.title}" was updated by someone else while this was being checked, so nothing changed. Run twombly list, and update again only if you mean to replace their work.`);
1490
+ return frame;
1491
+ });
1492
+ const frames = [];
1493
+ for (const [index, item] of input.items.entries()) {
1494
+ const frame = targets[index];
1495
+ const next = { ...frame, updatedAt: Date.now() };
1496
+ if (item.title) next.title = String(item.title).slice(0, LIMITS.titleChars);
1497
+ for (const key of ["w", "h"]) if (typeof item[key] === "number") next[key] = item[key];
1498
+ if (item.entry) next.entry = item.entry;
1499
+ if (item.level) next.level = item.level;
1500
+ if (typeof item.source === "string") next.source = item.source;
1501
+ next.content = await install2(canvas, item.id, item.folder, item.screenshot);
1502
+ if (item.screenshot) next.hasPoster = true;
1503
+ if (canvas.data.remote) await canvas.uploadFrame(canvas.data.remote.id, next);
1504
+ Object.assign(frame, next);
1505
+ await canvas.saveNow();
1506
+ await canvas.announce(frame);
1507
+ frames.push({ ...placedFrame(frame), warnings: [] });
1508
+ }
1122
1509
  canvas.working(WORKING_TAIL_MS);
1123
- return { frame: { id: frame.id, title: frame.title, x: frame.x, y: frame.y, w: frame.w, h: frame.h }, canvas: { local: `${DAEMON_ORIGIN}/${canvas.id}`, link: canvas.data.remote?.url || null, frames: canvas.data.frames.length } };
1510
+ return { frames, canvas: canvasResult(canvas) };
1124
1511
  }
1125
1512
  var JSON_HEADERS = { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" };
1126
1513
  var reply = (response, status, value) => {
@@ -1167,9 +1554,20 @@ var APP_CSP = [
1167
1554
  ].join("; ");
1168
1555
  async function serveUi(response, pathname) {
1169
1556
  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");
1557
+ let file2 = null;
1558
+ if (clean && !clean.includes("..")) {
1559
+ for (const folder of interfaceFolders()) {
1560
+ const candidate = join8(folder, clean);
1561
+ if ((await stat3(candidate).catch(() => null))?.isFile()) {
1562
+ file2 = candidate;
1563
+ break;
1564
+ }
1565
+ }
1566
+ }
1567
+ if (!file2) {
1568
+ await freshInterface();
1569
+ file2 = join8(interfaceFolders()[0], "index.html");
1570
+ }
1173
1571
  const type = contentType(file2);
1174
1572
  const headers = { "content-type": type, "x-content-type-options": "nosniff", "cache-control": extname2(file2) === ".html" ? "no-cache" : "public, max-age=3600" };
1175
1573
  if (type.startsWith("text/html")) {
@@ -1179,10 +1577,23 @@ async function serveUi(response, pathname) {
1179
1577
  response.writeHead(200, headers);
1180
1578
  createReadStream2(file2).pipe(response);
1181
1579
  }
1580
+ var escapeHtml = (text2) => text2.replace(/[&<>"]/g, (c) => `&#${c.charCodeAt(0)};`);
1581
+ async function servePreview(response, canvasId, frameId) {
1582
+ const canvas = await canvasById(canvasId);
1583
+ const frame = await canvas.toFrame(findFrame(canvas, frameId));
1584
+ response.writeHead(200, {
1585
+ "content-type": "text/html; charset=utf-8",
1586
+ "cache-control": "no-store",
1587
+ "referrer-policy": "no-referrer",
1588
+ "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; frame-src http://localhost:*; base-uri 'none'; frame-ancestors 'none'"
1589
+ });
1590
+ const title = escapeHtml(frame.title);
1591
+ 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>`);
1592
+ }
1182
1593
  async function route(request, response) {
1183
1594
  const url = new URL(request.url || "/", DAEMON_ORIGIN);
1184
1595
  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 });
1596
+ if (path === "/health") return reply(response, 200, { ok: true, build: "0.3.0+1793910384715", api: TOOL_API, pid: process.pid });
1186
1597
  if (path.startsWith("/cli/") && method === "POST") return reply(response, 200, await command(path.slice(5), await readBody(request)));
1187
1598
  if (path === "/auth/callback" && method === "GET") {
1188
1599
  const code = url.searchParams.get("code"), state = url.searchParams.get("state");
@@ -1233,8 +1644,11 @@ async function route(request, response) {
1233
1644
  }
1234
1645
  if (path.startsWith("/api/")) return reply(response, 404, { error: "Not found" });
1235
1646
  if (method !== "GET" && method !== "HEAD") return reply(response, 405, { error: "Method not allowed" });
1647
+ const preview = path.match(/^\/preview\/([a-z0-9]+)\/([a-z0-9]+)$/);
1648
+ if (preview) return servePreview(response, preview[1], preview[2]);
1236
1649
  return serveUi(response, path);
1237
1650
  }
1651
+ var inFlight = 0;
1238
1652
  var idleTimer = null;
1239
1653
  function touch() {
1240
1654
  if (idleTimer) clearTimeout(idleTimer);
@@ -1252,6 +1666,8 @@ async function runDaemon() {
1252
1666
  response.end('{"error":"Forbidden"}');
1253
1667
  return;
1254
1668
  }
1669
+ const counted = /^\/(cli|api\/canvases)\//.test(request.url || "");
1670
+ if (counted) inFlight++;
1255
1671
  try {
1256
1672
  await route(request, response);
1257
1673
  } catch (error) {
@@ -1262,6 +1678,8 @@ async function runDaemon() {
1262
1678
  if (error instanceof Failure) return reply(response, error.status, { error: error.message, ...error.extra });
1263
1679
  console.error(error);
1264
1680
  reply(response, 500, { error: error instanceof Error ? error.message : "Something went wrong" });
1681
+ } finally {
1682
+ if (counted) inFlight--;
1265
1683
  }
1266
1684
  });
1267
1685
  const sockets = new WebSocketServer({ noServer: true });
@@ -1286,17 +1704,19 @@ async function runDaemon() {
1286
1704
  server.listen(PORT, "127.0.0.1", resolve3);
1287
1705
  });
1288
1706
  touch();
1707
+ startInterfaceUpdates().catch(() => {
1708
+ });
1289
1709
  }
1290
1710
 
1291
1711
  // src/gates.ts
1292
1712
  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";
1294
- import { dirname as dirname3 } from "node:path";
1713
+ import { mkdir as mkdir7, readFile as readFile6, stat as stat4, writeFile as writeFile6 } from "node:fs/promises";
1714
+ import { dirname as dirname4 } from "node:path";
1295
1715
  var dedupe = (problems) => [...new Map(problems.map((p) => [`${p.code}:${p.file || ""}:${p.message}`, p])).values()];
1296
- async function staticGates(folder, entry) {
1716
+ async function scanFolder(folder) {
1297
1717
  const problems = [];
1298
1718
  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 };
1719
+ 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
1720
  const files = await hashFolder(folder);
1301
1721
  let bytes = 0;
1302
1722
  for (const file2 of files) {
@@ -1314,13 +1734,26 @@ async function staticGates(folder, entry) {
1314
1734
  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
1735
  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
1736
  }
1737
+ return { problems, files, bytes };
1738
+ }
1739
+ function entryProblems(files, entry) {
1317
1740
  const entryPath = entry.split(/[?#]/)[0];
1318
1741
  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 };
1742
+ if (files.some((file2) => candidates.includes(file2.path))) return [];
1743
+ 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." }];
1744
+ }
1745
+ async function findReferences(folder, needle) {
1746
+ const found = [];
1747
+ for (const file2 of await hashFolder(folder)) {
1748
+ const bytes = await readFile6(file2.absolute);
1749
+ if (looksLikeText(bytes) && bytes.includes(needle)) found.push(file2.path);
1750
+ if (found.length === 3) break;
1751
+ }
1752
+ return found;
1321
1753
  }
1322
- async function browserGates(folder, options) {
1754
+ async function browserGates(folder, options, launch) {
1323
1755
  const problems = [], warnings = [];
1756
+ const traces = /* @__PURE__ */ new Map();
1324
1757
  const server = await startFrameServer(folder);
1325
1758
  const width = options.width, height = options.height === "auto" ? 800 : options.height;
1326
1759
  const harness = createServer3((_, response) => {
@@ -1331,7 +1764,7 @@ async function browserGates(folder, options) {
1331
1764
  const harnessOrigin = `http://localhost:${harness.address().port}`;
1332
1765
  let browser;
1333
1766
  try {
1334
- browser = await launchBrowser();
1767
+ browser = await launch();
1335
1768
  } catch {
1336
1769
  harness.close();
1337
1770
  server.close();
@@ -1347,7 +1780,9 @@ async function browserGates(folder, options) {
1347
1780
  host = new URL(url).host;
1348
1781
  } catch {
1349
1782
  }
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." });
1783
+ 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." };
1784
+ problems.push(problem);
1785
+ traces.set(problem, host);
1351
1786
  };
1352
1787
  page.on("pageerror", (error) => {
1353
1788
  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 +1803,9 @@ async function browserGates(folder, options) {
1368
1803
  const url = response.url();
1369
1804
  if (!isFrameOrigin(url) || response.status() < 400 || url.endsWith("/favicon.ico")) return;
1370
1805
  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) });
1806
+ 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) };
1807
+ problems.push(problem);
1808
+ traces.set(problem, path.slice(1));
1372
1809
  });
1373
1810
  page.on("requestfailed", (request) => {
1374
1811
  const url = request.url();
@@ -1401,8 +1838,8 @@ async function browserGates(folder, options) {
1401
1838
  const shot = await page.locator("#frame").screenshot({ type: "png", timeout: 15e3 }).catch(() => null);
1402
1839
  if (shot) {
1403
1840
  if (options.poster) {
1404
- await mkdir5(dirname3(options.poster), { recursive: true });
1405
- await writeFile5(options.poster, shot);
1841
+ await mkdir7(dirname4(options.poster), { recursive: true });
1842
+ await writeFile6(options.poster, shot);
1406
1843
  }
1407
1844
  const colors = await page.evaluate(async (base64) => {
1408
1845
  const image = new Image();
@@ -1423,40 +1860,84 @@ async function browserGates(folder, options) {
1423
1860
  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
1861
  await context.close();
1425
1862
  const unique = dedupe(problems);
1863
+ for (const problem of unique) {
1864
+ const trace = traces.get(problem);
1865
+ const references = trace ? await findReferences(folder, trace) : [];
1866
+ if (references.length) problem.message += ` Mentioned in ${references.join(", ")}.`;
1867
+ }
1426
1868
  return { ok: unique.length === 0, problems: unique, warnings: [...new Set(warnings)], content, height: finalHeight, screenshot: options.poster };
1427
1869
  } finally {
1428
- await browser.close().catch(() => {
1429
- });
1430
1870
  harness.closeAllConnections();
1431
1871
  harness.close();
1432
1872
  server.close();
1433
1873
  }
1434
1874
  }
1875
+ function gateSession() {
1876
+ const scans = /* @__PURE__ */ new Map();
1877
+ let browser = null;
1878
+ const launch = () => browser ??= launchBrowser();
1879
+ return {
1880
+ async run(folder, options) {
1881
+ if (!scans.has(folder)) scans.set(folder, scanFolder(folder));
1882
+ const scan = await scans.get(folder);
1883
+ const size2 = { files: scan.files.length, bytes: scan.bytes };
1884
+ const notFolder = scan.problems.some((problem) => problem.code === "NOT_A_FOLDER");
1885
+ const problems = notFolder ? scan.problems : [...scan.problems, ...entryProblems(scan.files, options.entry)];
1886
+ if (problems.length) return { ok: false, problems: dedupe(problems), warnings: [], ...size2 };
1887
+ return { ...await browserGates(folder, options, launch), ...size2 };
1888
+ },
1889
+ close: async () => {
1890
+ await browser?.then((open) => open.close()).catch(() => {
1891
+ });
1892
+ }
1893
+ };
1894
+ }
1435
1895
  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 };
1896
+ const session = gateSession();
1897
+ try {
1898
+ return await session.run(folder, options);
1899
+ } finally {
1900
+ await session.close();
1901
+ }
1440
1902
  }
1441
1903
 
1442
1904
  // src/cli.ts
1443
- var HELP = `twombly ${"0.1.0"}: put design explorations on a shared canvas.
1905
+ var HELP = `twombly ${"0.3.0"}: put design explorations on a shared canvas.
1444
1906
 
1445
- init [--name "Name"] [--no-open] create or reopen this project's canvas
1907
+ init [--name "Name"] [--no-open] create or join this folder's canvas
1908
+ init --canvas "Name" [--no-open] create or join a separate named canvas in this folder
1909
+ canvases list the canvases this folder can reach
1910
+ --canvas "Name" works on every command below, and picks that canvas
1446
1911
  add <folder|file> --title "T" run the gates, then place a frame
1447
1912
  [--width 1280] [--height 800|auto] [--entry index.html] [--level build|component|capture|image]
1448
1913
  [--new-row] [--x N --y N]
1914
+ [--frames '[{"title":"A","entry":"index.html?v=a"}]'] several at once, as JSON or a JSON file
1449
1915
  update <frame> <folder|file> replace a frame's contents, keeping its comments
1450
1916
  [--title "T"] [--width N] [--height N|auto] [--entry path] [--level ...]
1451
- check <folder> run the gates without placing anything
1917
+ update <folder> --frames '[{"frame":"A"},{"frame":"B"}]' several at once
1918
+ check <folder> run the gates without placing anything
1452
1919
  capture <url> --out <folder> freeze a running page into a static folder
1453
1920
  [--width 1280] [--height 800] [--image] [--selector css]
1921
+ preview <frame> a page with just that frame, for testing interactions
1454
1922
  working | done show on the canvas that you're building, or that you've finished
1923
+ context what the person has selected, open fullscreen or open as a comment
1924
+ comments every comment, and which are the person's own
1455
1925
  list | move <frame> --x N --y N | resize <frame> --width N --height N
1456
1926
  retitle <frame> "T" | remove <frame> | arrange [--columns N] | rename "Name"
1457
1927
  open | status | login | logout | stop
1458
1928
  share | unshare only when the person asks: this publishes a public link
1459
1929
  guide how to adapt a project into frames. Read this first.`;
1930
+ var SWITCHES = /* @__PURE__ */ new Set(["no-open", "new-row", "image"]);
1931
+ var FLAGS = {
1932
+ init: ["name", "no-open"],
1933
+ add: ["title", "width", "height", "entry", "level", "new-row", "x", "y", "frames"],
1934
+ update: ["title", "width", "height", "entry", "level", "frames"],
1935
+ check: ["entry", "width", "height"],
1936
+ capture: ["out", "width", "height", "image", "selector", "wait"],
1937
+ move: ["x", "y"],
1938
+ resize: ["width", "height"],
1939
+ arrange: ["columns"]
1940
+ };
1460
1941
  function parse(argv) {
1461
1942
  const positional = [], flags = {};
1462
1943
  for (let i = 0; i < argv.length; i++) {
@@ -1471,13 +1952,19 @@ function parse(argv) {
1471
1952
  continue;
1472
1953
  }
1473
1954
  const next = argv[i + 1];
1474
- if (next !== void 0 && !next.startsWith("--")) {
1955
+ if (!SWITCHES.has(arg.slice(2)) && next !== void 0 && !next.startsWith("--")) {
1475
1956
  flags[arg.slice(2)] = next;
1476
1957
  i++;
1477
1958
  } else flags[arg.slice(2)] = true;
1478
1959
  }
1479
1960
  return { positional, flags };
1480
1961
  }
1962
+ function checkFlags(name, flags) {
1963
+ const known = name === "canvases" ? [] : [...FLAGS[name] || [], "canvas"];
1964
+ for (const flag of Object.keys(flags)) {
1965
+ if (!known.includes(flag)) fail(`Unknown flag --${flag}. ${known.length ? `"${name}" takes: ${known.map((item) => `--${item}`).join(", ")}.` : `"${name}" takes no flags.`}`);
1966
+ }
1967
+ }
1481
1968
  var print = (value) => process.stdout.write(JSON.stringify(value, null, 2) + "\n");
1482
1969
  function fail(message, extra = {}) {
1483
1970
  print({ ok: false, error: message, ...extra });
@@ -1513,18 +2000,31 @@ async function health() {
1513
2000
  return null;
1514
2001
  }
1515
2002
  }
2003
+ var builtAt = (build) => Number(build.split("+")[1]) || 0;
2004
+ var sleep = (ms) => new Promise((done) => setTimeout(done, ms));
2005
+ var IDLE_WAIT_MS = 6e4;
2006
+ var usable = (running) => !!running && (running.build === "0.3.0+1793910384715" || running.api === TOOL_API && builtAt(running.build) > builtAt("0.3.0+1793910384715"));
1516
2007
  async function ensureDaemon() {
1517
2008
  const running = await health();
1518
- if (running?.build === "0.1.0+1793887853207") return;
2009
+ if (usable(running)) return;
1519
2010
  if (running) {
1520
- await fetch(`${DAEMON_ORIGIN}/cli/stop`, { method: "POST", body: "{}" }).catch(() => {
1521
- });
1522
- await new Promise((done) => setTimeout(done, 200));
2011
+ if (builtAt(running.build) > builtAt("0.3.0+1793910384715")) fail("A newer twombly is running on this machine. Run this with twombly@latest.");
2012
+ let stopped = false;
2013
+ for (const end = Date.now() + IDLE_WAIT_MS; !stopped && Date.now() < end; ) {
2014
+ const answer = await fetch(`${DAEMON_ORIGIN}/cli/stop`, { method: "POST", body: '{"ifIdle":true}' }).then((response) => response.json()).catch(() => ({ stopped: true }));
2015
+ stopped = !!answer.stopped;
2016
+ if (!stopped) await sleep(500);
2017
+ }
2018
+ if (!stopped) {
2019
+ if (running.api === TOOL_API) return;
2020
+ fail("The background server is busy with another agent's work. Try again in a minute.");
2021
+ }
2022
+ await sleep(200);
1523
2023
  }
1524
2024
  spawn2(process.execPath, [fileURLToPath2(import.meta.url), "__daemon"], { detached: true, stdio: "ignore" }).unref();
1525
2025
  for (let attempt = 0; attempt < 60; attempt++) {
1526
- await new Promise((done) => setTimeout(done, 100));
1527
- if ((await health())?.build === "0.1.0+1793887853207") return;
2026
+ await sleep(100);
2027
+ if (usable(await health())) return;
1528
2028
  }
1529
2029
  fail(`Could not start the background server on port ${PORT}. Is something else using it?`);
1530
2030
  }
@@ -1543,32 +2043,181 @@ async function prepare(source) {
1543
2043
  const extension = extname3(source).toLowerCase();
1544
2044
  const isImage = IMAGE.has(extension);
1545
2045
  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-"));
2046
+ const temporary = await mkdtemp(join9(tmpdir(), "twombly-"));
1547
2047
  if (isImage) {
1548
2048
  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="">`);
2049
+ await copyFile2(source, join9(temporary, name));
2050
+ 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
2051
  } else {
1552
- await copyFile(source, join8(temporary, "index.html"));
2052
+ await copyFile2(source, join9(temporary, "index.html"));
1553
2053
  }
1554
2054
  return { folder: temporary, temporary, image: isImage };
1555
2055
  }
2056
+ var chosenCanvas;
2057
+ async function locate() {
2058
+ if (chosenCanvas === void 0) return findProject(process.cwd());
2059
+ return (await findNamed(process.cwd(), chosenCanvas))?.project ?? null;
2060
+ }
1556
2061
  async function currentProject() {
1557
- const project = await findProject(process.cwd());
1558
- if (!project) fail("No canvas here yet. Run: twombly init");
1559
- return project;
2062
+ const project = await locate();
2063
+ if (project) return project;
2064
+ if (chosenCanvas !== void 0) fail(`No canvas "${chosenCanvas}" here. Run: twombly init --canvas "${chosenCanvas}"`);
2065
+ const others = await canvasesAbove(process.cwd());
2066
+ fail(others.length ? `This folder has no default canvas. Use --canvas with one of: ${others.map((entry) => `"${entry.name}"`).join(", ")}.` : "No canvas here yet. Run: twombly init");
1560
2067
  }
1561
2068
  async function markWorking() {
1562
- const project = await findProject(process.cwd());
1563
- if (project) await call("working", { project, ms: WORKING_TAIL_MS });
2069
+ const project = await locate();
2070
+ if (project) await call("working", { project, ms: WORKING_MS });
1564
2071
  }
1565
2072
  function gateFailure(result) {
1566
2073
  print({ ok: false, placed: false, problems: result.problems, warnings: result.warnings, screenshot: result.screenshot });
1567
- process.exit(1);
2074
+ process.exitCode = 1;
2075
+ }
2076
+ var NEXT = "Open the screenshot. Passing the gates does not prove it looks right. To test interactions, open the url from: twombly preview <frame>";
2077
+ var CONCURRENT_GATES = 4;
2078
+ async function frameItems(flags, known) {
2079
+ const value = flags.frames;
2080
+ if (value === void 0) return [flags];
2081
+ if (typeof value !== "string") fail("--frames needs JSON or a JSON file.");
2082
+ const { frames: _, ...shared } = flags;
2083
+ let items;
2084
+ try {
2085
+ items = JSON.parse(value.trimStart().startsWith("[") ? value : await readFile7(value, "utf8"));
2086
+ } catch (error) {
2087
+ fail(`--frames: ${error instanceof Error ? error.message : error}`);
2088
+ }
2089
+ if (!Array.isArray(items) || !items.length) fail("--frames needs a list of frames.");
2090
+ return items.map((item) => {
2091
+ const unknown = Object.keys(item).find((key) => !known.includes(key));
2092
+ if (unknown) fail(`--frames: unknown field "${unknown}". Each frame takes: ${known.join(", ")}.`);
2093
+ const merged = { ...shared };
2094
+ for (const [key, field] of Object.entries(item)) {
2095
+ if (key === "newRow") {
2096
+ if (field) merged["new-row"] = true;
2097
+ else delete merged["new-row"];
2098
+ } else merged[key] = String(field);
2099
+ }
2100
+ return merged;
2101
+ });
2102
+ }
2103
+ async function placeFrames(project, many, jobs, command2, item) {
2104
+ await markWorking();
2105
+ const scratch = await mkdtemp(join9(tmpdir(), "twombly-gates-"));
2106
+ const gates = gateSession(), prepared = /* @__PURE__ */ new Map(), temporary = [];
2107
+ let keepScratch = false;
2108
+ try {
2109
+ for (const job of jobs) {
2110
+ if (prepared.has(job.source)) continue;
2111
+ const ready = await prepare(job.source);
2112
+ prepared.set(job.source, ready);
2113
+ if (ready.temporary) temporary.push(ready.temporary);
2114
+ }
2115
+ const results = [];
2116
+ let next = 0;
2117
+ await Promise.all(Array.from({ length: CONCURRENT_GATES }, async () => {
2118
+ while (next < jobs.length) {
2119
+ const index = next++, job = jobs[index];
2120
+ 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`) });
2121
+ }
2122
+ }));
2123
+ if (results.some((result) => !result.ok)) {
2124
+ keepScratch = true;
2125
+ if (!many) return gateFailure(results[0]);
2126
+ return gateFailure({
2127
+ problems: results.flatMap((result, index) => result.problems.map((problem) => ({ ...problem, frame: jobs[index].label, screenshot: result.screenshot }))),
2128
+ warnings: results.flatMap((result, index) => result.warnings.map((warning) => `${jobs[index].label}: ${warning}`))
2129
+ });
2130
+ }
2131
+ const items = jobs.map((job, index) => {
2132
+ const { folder, image } = prepared.get(job.source);
2133
+ return { id: job.id, folder, source: job.source, screenshot: results[index].screenshot, ...item(job, { result: results[index], folder, image, screenshot: results[index].screenshot }) };
2134
+ });
2135
+ const { frames, canvas } = await call(command2, { project, items });
2136
+ const placed = frames.map(({ warnings, ...frame }, index) => {
2137
+ const result = results[index];
2138
+ return { frame, warnings: [...result.warnings, ...warnings], screenshot: result.screenshot ? posterFile(project, jobs[index].id) : void 0, contentSize: result.content };
2139
+ });
2140
+ if (!many) return print({ ok: true, placed: true, ...placed[0], canvas, next: NEXT });
2141
+ print({ ok: true, placed: true, frames: placed.map(({ frame, ...rest }) => ({ ...frame, ...rest })), canvas, next: NEXT });
2142
+ } finally {
2143
+ await gates.close();
2144
+ for (const folder of temporary) await rm5(folder, { recursive: true, force: true });
2145
+ if (!keepScratch) await rm5(scratch, { recursive: true, force: true });
2146
+ }
2147
+ }
2148
+ async function addFrames(project, source, many, items) {
2149
+ const jobs = items.map((flags) => {
2150
+ const folder = text(flags, "folder") || source, title = text(flags, "title");
2151
+ if (!folder || !title) fail('twombly add <folder|file> --title "Title"');
2152
+ level(flags, "build");
2153
+ number(flags, "x");
2154
+ number(flags, "y");
2155
+ return {
2156
+ flags,
2157
+ label: title,
2158
+ source: resolve2(folder),
2159
+ entry: text(flags, "entry") || "index.html",
2160
+ width: size(flags, "width", 1280),
2161
+ height: flags.height === "auto" ? "auto" : size(flags, "height", 800),
2162
+ id: randomBytes2(8).toString("hex").replace(/[01lo]/g, "a").slice(0, 12)
2163
+ };
2164
+ });
2165
+ await placeFrames(project, many, jobs, "place", (job, { result, image }) => ({
2166
+ title: job.label,
2167
+ w: job.width,
2168
+ h: result.height,
2169
+ entry: job.entry,
2170
+ level: level(job.flags, image ? "image" : "build"),
2171
+ x: number(job.flags, "x"),
2172
+ y: number(job.flags, "y"),
2173
+ newRow: job.flags["new-row"] === true
2174
+ }));
2175
+ }
2176
+ async function updateFrames(project, source, many, items) {
2177
+ const { frames } = await call("list", { project });
2178
+ const seen = /* @__PURE__ */ new Set(), bases = /* @__PURE__ */ new Map();
2179
+ const jobs = items.map((flags) => {
2180
+ const ref = text(flags, "frame"), folder = text(flags, "folder") || source;
2181
+ if (!ref || !folder) fail("twombly update <frame> <folder|file>");
2182
+ const matches = frames.filter((frame2) => frame2.id === ref || frame2.title === ref);
2183
+ if (matches.length !== 1) fail(matches.length ? `More than one frame is titled "${ref}". Use its id.` : `No frame "${ref}". Run: twombly list`);
2184
+ const frame = matches[0];
2185
+ if (seen.has(frame.id)) fail(`--frames lists "${ref}" twice.`);
2186
+ seen.add(frame.id);
2187
+ bases.set(frame.id, frame.content);
2188
+ level(flags, "build");
2189
+ return {
2190
+ flags,
2191
+ label: frame.title,
2192
+ id: frame.id,
2193
+ source: resolve2(folder),
2194
+ entry: text(flags, "entry") || frame.entry,
2195
+ width: size(flags, "width", frame.w),
2196
+ height: flags.height === "auto" ? "auto" : size(flags, "height", frame.h)
2197
+ };
2198
+ });
2199
+ await placeFrames(project, many, jobs, "revise", (job, { result, image }) => ({
2200
+ label: job.label,
2201
+ base: bases.get(job.id),
2202
+ title: text(job.flags, "title"),
2203
+ w: job.flags.width !== void 0 ? job.width : void 0,
2204
+ h: job.flags.height !== void 0 ? result.height : void 0,
2205
+ entry: text(job.flags, "entry"),
2206
+ level: text(job.flags, "level") || image ? level(job.flags, image ? "image" : "build") : void 0
2207
+ }));
1568
2208
  }
1569
2209
  async function main() {
1570
2210
  const [name, ...rest] = process.argv.slice(2);
1571
2211
  const { positional, flags } = parse(rest);
2212
+ if (flags.help !== void 0 || positional.includes("-h")) {
2213
+ process.stdout.write(HELP + "\n");
2214
+ return;
2215
+ }
2216
+ if (name && !["--help", "-h", "--version", "help", "version", "guide", "__daemon"].includes(name)) checkFlags(name, flags);
2217
+ if (flags.canvas !== void 0) {
2218
+ if (typeof flags.canvas !== "string" || !flags.canvas.trim()) fail("--canvas needs a name.");
2219
+ chosenCanvas = flags.canvas.trim();
2220
+ }
1572
2221
  switch (name) {
1573
2222
  case "__daemon":
1574
2223
  return runDaemon();
@@ -1580,31 +2229,46 @@ async function main() {
1580
2229
  return;
1581
2230
  case "--version":
1582
2231
  case "version":
1583
- process.stdout.write("0.1.0\n");
2232
+ process.stdout.write("0.3.0\n");
1584
2233
  return;
1585
2234
  case "guide":
1586
2235
  process.stdout.write(SKILL_default);
1587
2236
  return;
1588
2237
  case "init": {
2238
+ const next = "Frames must be static files that work with no server or network. Read how to adapt this project: twombly guide";
2239
+ const open = flags["no-open"] === void 0, given = text(flags, "name");
2240
+ if (chosenCanvas !== void 0) {
2241
+ if (given) fail("Use --canvas or --name, not both. --canvas is the name.");
2242
+ const found = await findNamed(process.cwd(), chosenCanvas);
2243
+ const project2 = found?.project || namedProject(await findRoot(process.cwd()) || process.cwd(), chosenCanvas);
2244
+ print({ ok: true, ...await call("init", { project: project2, exact: true, name: chosenCanvas, open }), next });
2245
+ return;
2246
+ }
1589
2247
  const project = await findProject(process.cwd()) || process.cwd();
1590
- 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 }) });
2248
+ const result = await call("init", { project, name: given || defaultName(project), open });
2249
+ const note = !result.created && given && given !== result.name ? `This folder's canvas is "${result.name}", and you've joined it. For a separate one: twombly init --canvas "${given}"` : void 0;
2250
+ print({ ok: true, ...result, note, next });
2251
+ return;
2252
+ }
2253
+ case "canvases": {
2254
+ const found = await canvasesAbove(process.cwd());
2255
+ print({ ok: true, canvases: found.map(({ name: name2, slug, frames, id, link }) => ({ name: name2, canvas: slug, frames, local: `${DAEMON_ORIGIN}/${id}`, link })) });
1592
2256
  return;
1593
2257
  }
1594
2258
  case "check": {
1595
2259
  const source = positional[0];
1596
- if (!source) fail("Which folder? twombly check <folder>");
2260
+ if (!source) fail("twombly check <folder> checks a build before it is added. It needs the build's folder.");
1597
2261
  await markWorking();
1598
2262
  const { folder, temporary } = await prepare(resolve2(source));
1599
- const scratch = await mkdtemp(join8(tmpdir(), "twombly-check-"));
1600
- const shot = join8(scratch, "screenshot.png");
2263
+ const scratch = await mkdtemp(join9(tmpdir(), "twombly-check-"));
2264
+ const shot = join9(scratch, "screenshot.png");
1601
2265
  try {
1602
2266
  const height = flags.height === "auto" ? "auto" : size(flags, "height", 800);
1603
2267
  const result = await runGates(folder, { entry: text(flags, "entry") || "index.html", width: size(flags, "width", 1280), height, poster: shot });
1604
2268
  print(result);
1605
2269
  if (!result.ok) process.exitCode = 1;
1606
2270
  } finally {
1607
- if (temporary) await rm4(temporary, { recursive: true, force: true });
2271
+ if (temporary) await rm5(temporary, { recursive: true, force: true });
1608
2272
  }
1609
2273
  return;
1610
2274
  }
@@ -1617,83 +2281,12 @@ async function main() {
1617
2281
  return;
1618
2282
  }
1619
2283
  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;
2284
+ return addFrames(await currentProject(), positional[0], flags.frames !== void 0, await frameItems(flags, ["folder", "title", "width", "height", "entry", "level", "newRow", "x", "y"]));
1658
2285
  }
1659
2286
  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;
2287
+ const many = flags.frames !== void 0;
2288
+ const items = many ? await frameItems(flags, ["frame", "folder", "title", "width", "height", "entry", "level"]) : [{ ...flags, ...positional[0] ? { frame: positional[0] } : {} }];
2289
+ return updateFrames(await currentProject(), many ? positional[0] : positional[1], many, items);
1697
2290
  }
1698
2291
  case "working":
1699
2292
  print({ ok: true, ...await call("working", { project: await currentProject(), ms: WORKING_MS }) });
@@ -1704,11 +2297,22 @@ async function main() {
1704
2297
  case "list":
1705
2298
  print({ ok: true, ...await call("list", { project: await currentProject() }) });
1706
2299
  return;
2300
+ case "context":
2301
+ print({ ok: true, ...await call("context", { project: await currentProject() }) });
2302
+ return;
2303
+ case "comments":
2304
+ print({ ok: true, ...await call("comments", { project: await currentProject() }) });
2305
+ return;
1707
2306
  case "open":
1708
2307
  print({ ok: true, ...await call("open", { project: await currentProject() }) });
1709
2308
  return;
2309
+ case "preview": {
2310
+ if (!positional[0]) fail("twombly preview <frame>");
2311
+ print({ ok: true, ...await call("preview", { project: await currentProject(), frame: positional[0] }) });
2312
+ return;
2313
+ }
1710
2314
  case "status":
1711
- print({ ok: true, ...await call("status", { project: await findProject(process.cwd()) }) });
2315
+ print({ ok: true, ...await call("status", { project: await locate() }) });
1712
2316
  return;
1713
2317
  case "remove": {
1714
2318
  if (!positional[0]) fail("twombly remove <frame>");
@@ -1722,6 +2326,7 @@ async function main() {
1722
2326
  return;
1723
2327
  }
1724
2328
  case "resize": {
2329
+ if (flags.height === "auto") fail("resize takes a number. To fit the page: twombly update <frame> <folder> --height auto");
1725
2330
  if (!positional[0] || flags.width === void 0 || flags.height === void 0) fail("twombly resize <frame> --width N --height N");
1726
2331
  print({ ok: true, ...await call("patch", { project: await currentProject(), frame: positional[0], patch: { w: size(flags, "width", 1280), h: size(flags, "height", 800) } }) });
1727
2332
  return;