@design-twin/dtwin 0.0.0-stage → 0.1.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.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +959 -2
  3. package/figma-plugin/code.js +4207 -0
  4. package/figma-plugin/manifest.json +28 -0
  5. package/figma-plugin/ui.html +686 -0
  6. package/layer/cross-check.js +2746 -0
  7. package/layer/drift-lint.js +1526 -0
  8. package/layer/get-component.js +553 -0
  9. package/layer/map-bootstrap.js +1404 -0
  10. package/layer/map-validate.js +397 -0
  11. package/package.json +53 -4
  12. package/src/abort-reason.ts +12 -0
  13. package/src/asset-compare.ts +175 -0
  14. package/src/asset-owners.ts +280 -0
  15. package/src/atomic-write.ts +79 -0
  16. package/src/bridge-holder.ts +294 -0
  17. package/src/cli-commands.ts +427 -0
  18. package/src/client-presence.ts +146 -0
  19. package/src/clock.ts +45 -0
  20. package/src/commands.ts +561 -0
  21. package/src/daemon-auth.ts +174 -0
  22. package/src/daemon.ts +788 -0
  23. package/src/delivery.ts +85 -0
  24. package/src/design-system-layout.ts +232 -0
  25. package/src/doc-types.ts +1054 -0
  26. package/src/doctor.ts +602 -0
  27. package/src/errmsg.ts +14 -0
  28. package/src/export-reader.ts +409 -0
  29. package/src/export-verdict.ts +258 -0
  30. package/src/figma-mcp.ts +942 -0
  31. package/src/figma-pull.ts +1265 -0
  32. package/src/file-lock.ts +268 -0
  33. package/src/file-memo.ts +62 -0
  34. package/src/hash.ts +6 -0
  35. package/src/hex-color.ts +39 -0
  36. package/src/human-ms.ts +7 -0
  37. package/src/init.ts +424 -0
  38. package/src/instance-overrides.ts +7 -0
  39. package/src/json-file.ts +79 -0
  40. package/src/json-size.ts +108 -0
  41. package/src/json-util.ts +51 -0
  42. package/src/library-layout.ts +193 -0
  43. package/src/node-id.ts +69 -0
  44. package/src/pages-layout.ts +483 -0
  45. package/src/pending-requests.ts +187 -0
  46. package/src/plugin-frames.ts +32 -0
  47. package/src/png.ts +196 -0
  48. package/src/ports.ts +25 -0
  49. package/src/project-layout.ts +189 -0
  50. package/src/quick-keys.ts +68 -0
  51. package/src/read-json.ts +67 -0
  52. package/src/read-opts.ts +125 -0
  53. package/src/read-plan.ts +105 -0
  54. package/src/read-run.ts +119 -0
  55. package/src/route.ts +243 -0
  56. package/src/seed-components.ts +229 -0
  57. package/src/served-session.ts +347 -0
  58. package/src/server-core.ts +1093 -0
  59. package/src/settings.ts +156 -0
  60. package/src/source-stamp.ts +16 -0
  61. package/src/staleness.ts +63 -0
  62. package/src/stdio.ts +156 -0
  63. package/src/svg-normalize.ts +205 -0
  64. package/src/timeouts.ts +76 -0
  65. package/src/timings.ts +118 -0
  66. package/src/token-store.ts +261 -0
  67. package/src/variables-merge.ts +337 -0
  68. package/src/verbs.ts +140 -0
  69. package/src/write-out.ts +1255 -0
package/README.md CHANGED
@@ -1,3 +1,960 @@
1
- # Temporary Holding Version
1
+ # Design Twin — bridge
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **figma-pull (read CLI) + figma-mcp (write MCP).**
4
+
5
+ Local, loopback-only bridge between Claude Code and the **Design Twin** Figma plugin.
6
+ The plugin's hidden UI iframe is a WebSocket **client**; these processes are the **server**
7
+ (`127.0.0.1:8787`). Nothing touches the internet. See `../ARCHITECTURE.md` for the full design.
8
+
9
+ ## Install (once)
10
+ It needs [Bun](https://bun.sh/install) ≥ 1.4.2 (`engines.bun`) on your PATH; Node is not supported. Install Bun
11
+ first (`curl -fsSL https://bun.sh/install | bash`, or `powershell -c "irm bun.sh/install.ps1 | iex"` on Windows), then
12
+ the package, `@design-twin/dtwin`, from npm:
13
+ ```
14
+ bun add -g @design-twin/dtwin # puts `dtwin` in ~/.bun/bin (on your PATH via the Bun installer)
15
+ ```
16
+ `npm i -g @design-twin/dtwin` installs the same thing, but `dtwin` still runs on Bun: without Bun on PATH it stops at
17
+ `env: bun: No such file or directory`. The package also carries the built Figma plugin (`figma-plugin/manifest.json`
18
+ inside it — `dtwin init` prints the full path to import) and the design-to-code bundles the MCP server's
19
+ `design_get_component` / `design_drift_lint` load (`layer/`).
20
+
21
+ Or from a clone (to work on the code):
22
+ ```
23
+ git clone https://github.com/its-magdy/design-twin.git && cd design-twin
24
+ bun install # @modelcontextprotocol/sdk (+ zod), for every workspace
25
+ cd bridge && bun link # puts `dtwin` in ~/.bun/bin (on your PATH); nothing to build
26
+ ```
27
+ Or skip the link and run `bun --no-install --no-env-file /path/to/design-twin/bridge/src/figma-pull.ts` wherever
28
+ this README says `dtwin` (those two flags are what the bin's shebang adds: without them, Bun loads the `.env` of the
29
+ project you run it in and may fetch a missing package from the registry).
30
+ The `dtwin` bin's shebang needs an `env` that understands `-S` (GNU coreutils ≥ 8.30, macOS, FreeBSD). Where it
31
+ does not (BusyBox — Alpine and other minimal images), `dtwin` fails before Bun starts; run it as
32
+ `bun --no-install --no-env-file "$(command -v dtwin)" …` (or the path above) instead.
33
+
34
+ The plugin manifest allows the bridge in **both** dev and published builds
35
+ (`allowedDomains: ws://localhost:{8787,8788,8789}`). Those three ports are the only ones the plugin can dial — `FIGMA_BRIDGE_PORT` is
36
+ validated against them and exits rather than binding a port nothing could ever reach.
37
+
38
+ > **Re-import the plugin after its manifest changes — required for library reads.** The manifest declares
39
+ > `"permissions": ["teamlibrary"]`, without which Figma denies the team-library APIs. A plugin
40
+ > imported from an older manifest keeps running happily and simply returns **no libraries** — a silent
41
+ > empty result, not an error. If `--list-libraries` / `figma_list_libraries` shows nothing, re-import
42
+ > `figma-plugin/manifest.json` (Plugins → Development → Import plugin from manifest…) first.
43
+
44
+ **Languages:** the whole bridge is **TypeScript** (ESM) under `src/` — `figma-pull.ts` (read CLI),
45
+ `server-core.ts` (WS core), `figma-mcp.ts` (MCP server), and the modules they share. From a checkout,
46
+ Bun runs it directly with **zero build** (`bun bridge/src/figma-pull.ts …`, `bun bridge/src/figma-mcp.ts`), and the
47
+ package ships the same TypeScript: its `dtwin` bin — what `bun add -g` or `bun link` puts on your PATH — is `src/figma-pull.ts`,
48
+ whose shebang is `#!/usr/bin/env -S bun --no-install --no-env-file` (so a `.env` or a missing package in the
49
+ project you run it from can neither change its environment nor make Bun fetch a module from the registry;
50
+ `env -S` needs GNU coreutils ≥ 8.30 or a BSD/macOS `env` — on BusyBox, call `bun --no-install --no-env-file` on
51
+ the file yourself, see Install above).
52
+ The WebSocket server is Bun's own (`Bun.serve`), so the only runtime dependencies are the MCP SDK and zod.
53
+ ```
54
+ bun run typecheck # tsc --noEmit (the only check there is no emit step)
55
+ ```
56
+
57
+ ### Bridge token (required)
58
+ The bridge authenticates every connection with a shared token — loopback binding alone is not
59
+ access control (a sandboxed browser iframe on any site the user visits sends `Origin: null` and
60
+ could otherwise drive the plugin), so the token is the real gate.
61
+
62
+ **You do not have to set anything up.** The first time a bridge starts it generates a token, saves it,
63
+ prints it once, and reuses it forever after. Paste it into the plugin's **Bridge token** field (the
64
+ plugin saves it per-user in `clientStorage`) and neither side asks again.
65
+
66
+ ```
67
+ dtwin --token-status # where it lives, which source wins, its fingerprint — never the token
68
+ dtwin --show-token # print the token itself (stdout only, so `dtwin --show-token | pbcopy` works)
69
+ dtwin --rotate-token # replace it — you must then re-paste it into the plugin
70
+ dtwin --forget-token # delete it; the next bridge start mints a new one
71
+ dtwin --token-file <p> # read the token from <p> for this run instead of the stored one
72
+ ```
73
+
74
+ **Where it is stored**
75
+
76
+ | OS | Path |
77
+ |---|---|
78
+ | macOS / Linux | `$XDG_CONFIG_HOME/design-twin/bridge-token`, else `~/.config/design-twin/bridge-token` |
79
+ | Windows | `%APPDATA%\design-twin\bridge-token` |
80
+
81
+ Written `0600` (owner only) in a `0700` directory, atomically. `DESIGNTWIN_CONFIG_DIR` overrides the
82
+ directory outright — for a container, a CI runner, or a test.
83
+
84
+ **Precedence** — first match wins:
85
+
86
+ 1. `--token-file <path>`
87
+ 2. `FIGMA_BRIDGE_TOKEN` (the CI / ephemeral escape hatch)
88
+ 3. the saved file
89
+ 4. otherwise: generate one, save it, print it once
90
+
91
+ There is deliberately **no `--token <value>` flag**. Process arguments are world-readable through
92
+ `ps` and `/proc/<pid>/cmdline`, so a value flag would hand the secret to every other user on the
93
+ machine for as long as the command runs. A *path* is not a secret; the file it points at is.
94
+
95
+ `FIGMA_BRIDGE_TOKEN` works and wins over the file. It is not the recommended setup, for two reasons: exported environment variables
96
+ propagate to every child process and leak into logs, and it has to be re-exported in every shell.
97
+ When both are present `--token-status` says so explicitly — a saved token silently shadowed by an
98
+ env var is otherwise a genuinely confusing state to debug.
99
+
100
+ **What this protects against, and what it doesn't.** The `0600` file stops *other users* on the
101
+ machine. It does not stop another process running as *you* — only an OS keychain would, and this
102
+ bridge deliberately doesn't use one: `keytar` is archived, its live replacements are native binaries
103
+ with per-platform install friction, and that is disproportionate for a loopback token that grants no
104
+ authority beyond driving a Figma plugin you already have open. If that trade doesn't suit your
105
+ environment, keep the token in your own secret manager and pass `--token-file`.
106
+
107
+ ## Set a project up: `dtwin init`
108
+
109
+ Run in the root of the project you are **building** (not this repo):
110
+
111
+ ```
112
+ dtwin init # design/ + design/export/, README, target.json, bridge token, next steps
113
+ dtwin init --mcp # …and register this MCP server in ./.mcp.json (merged; a broken entry of ours is repaired)
114
+ dtwin init --dry-run # print what it would do; write nothing, mint no token
115
+ ```
116
+
117
+ It lays out the project as two halves, and the split is the point:
118
+
119
+ ```
120
+ design/
121
+ export/ <- dtwin writes ONLY here. rm -rf and re-pull loses nothing.
122
+ pages/<Page>/<Screen>__<node-id>.json one screen, any pull shape — carries `sourceFile` (the
123
+ connected Figma file this pull actually talked to; absent
124
+ on a screen pulled before this field existed, never
125
+ guessed from another file's export)
126
+ pages/index.json every screen pulled, each row also carrying `sourceFile`
127
+ design-system/ tokens, styles, component catalogs
128
+ SCHEMA.md scripting quick keys (generated beside the data; same text
129
+ as the top of ir-fields.md; rewritten only when its text
130
+ changes, a SCHEMA.md of your own is never touched)
131
+ variables.json the union of every screen's tokens (merged, never replaced)
132
+ assets/ named after the Figma layer, deduped by content
133
+ README.md what dtwin owns vs what you own
134
+ target.json the stack (profile: null when nothing was detected — build-screen fills it in)
135
+ codeconnect.local.json Figma component -> your code component. HAND-OWNED.
136
+ plan/ audit/ verify/ decisions and evidence from the skills
137
+ ```
138
+
139
+ The two halves live apart so that "delete design/export/ and re-pull" — the obvious way to recover from
140
+ a bad export — never touches a hand-mapped component map. Projects created before the split keep
141
+ their export directly in `design/`; every command still finds it and `dtwin doctor` says which layout
142
+ it found.
143
+
144
+ It needs no bridge, plugin or free port. It never overwrites: an existing `target.json`, an existing
145
+ `designtwin` entry in `.mcp.json` (or this server already registered under any other name), or an
146
+ unparseable `.mcp.json` are all left alone and reported. The one exception is an entry of **ours** that
147
+ can no longer start — its `bun` or `figma-mcp.ts` is gone (a deleted clone, a `brew upgrade` that removed
148
+ the versioned bun it named), or it is a Node-era entry (`node …/bridge/dist/figma-mcp.js`), or bun runs it
149
+ without `--no-install --no-env-file`, or it runs the package by name (`bunx @design-twin/dtwin mcp`, which fetches it
150
+ from the registry or Bun's cache at every start): `--mcp` re-points that entry, under its own key and keeping its other
151
+ fields, at this install, and prints `REPAIRED …` with what was wrong (`--dry-run` only prints it). The entry
152
+ names the bun on your PATH when that is the bun running init (`~/.bun/bin/bun`, Homebrew's
153
+ `/opt/homebrew/bin/bun` — paths that survive an upgrade), else the running bun's own path. Run from a
154
+ `bunx` cache (`bunx @design-twin/dtwin init`), `--mcp` refuses to register anything: Bun replaces or deletes that
155
+ cache on its own, so install the package for good first. The server is registered as `designtwin`,
156
+ never `figma` — that is the name Figma's own MCP server usually has, and Claude Code loads one server
157
+ per name, so the two sit side by side. `--mcp` is
158
+ opt-in because a registered MCP server holds port 8787 for as long as Claude Code runs (`dtwin`
159
+ commands still work — they route through it, see "One bridge, shared" below). The two
160
+ steps it cannot do — importing the plugin into Figma and pasting the token — it prints.
161
+
162
+ ## Commands at a glance
163
+
164
+ ```
165
+ dtwin list # what is in the file: pages + top-level frames, with ids
166
+ dtwin pull --page Screens # export ONE page into ./design/export
167
+ dtwin screenshot 12:34 # a reference PNG of one node
168
+ dtwin doctor # something not working? start here
169
+ dtwin seed # components.json from the Code Connect files in your code (offline)
170
+ ```
171
+
172
+ Every command is shorthand for a flag, and **the flags keep working unchanged** — the rest of this
173
+ README documents the flags, and either spelling is fine everywhere:
174
+
175
+ | Command | Same as |
176
+ |---|---|
177
+ | `dtwin pull [outDir] [flags]` | `dtwin [outDir] [flags]` |
178
+ | `dtwin list` · `list pages` · `list libraries` · `list clients` | `--list` · `--list-pages` · `--list-libraries` · `--list-clients` |
179
+ | `dtwin list children <id\|url>` | `--children <id\|url>` |
180
+ | `dtwin screenshot <id\|url> [--scale N]` | `--screenshot <id\|url>` |
181
+ | `dtwin whoami` | `--whoami` |
182
+ | `dtwin serve` · `stop` · `status` | `--serve` · `--stop` · `--daemon-status` |
183
+ | `dtwin token` · `token show` · `token rotate` · `token forget` | `--token-status` · `--show-token` · `--rotate-token` · `--forget-token` |
184
+ | `dtwin help` | `--help` |
185
+ | `dtwin doctor` · `dtwin init` · `dtwin mcp` · `dtwin seed` | their own entry points (no flag form) |
186
+
187
+ Every verb has its own page: `dtwin <verb> --help` (e.g. `dtwin whoami --help`, `dtwin serve --help`) prints
188
+ that verb's flags and exits without starting a bridge. `outDir` is **positional** (`dtwin pull my-dir`):
189
+ `--out` / `-o` / `--output` / `--out-dir` / `--dir` are refused with a hint saying so. The port is chosen
190
+ only by the `FIGMA_BRIDGE_PORT` environment variable (8787, 8788 or 8789; there is no `--port` flag, and
191
+ `DTWIN_PORT` is not read — `dtwin` and `doctor` warn when it is set).
192
+
193
+ A command is only recognised as the **first** argument, so `dtwin my-dir` still means "pull into
194
+ `./my-dir`". For an output directory spelled like a command, write `dtwin pull list` or `dtwin ./list`.
195
+ With no outDir the export lands in `design/export` — the only place doctor, cross-check, audit and
196
+ build-screen read. Never pull into bare `design`: that writes a second, parallel export tree beside
197
+ `design/export/` (dtwin warns when it sees one).
198
+
199
+ ## Seed the component map from Code Connect: `dtwin seed`
200
+
201
+ If the project you are building already has Code Connect files, they are the Figma-component →
202
+ code-component map you would otherwise write by hand. `dtwin seed` reads them — template files
203
+ (`// url=` + `// component=` + `// source=` headers), `figma.connect(...)` calls, and `@FigmaConnect`
204
+ (SwiftUI/Compose) — and fills `components.json`:
205
+
206
+ ```
207
+ dtwin seed # scan . and merge into design/components.json
208
+ dtwin seed src design # [codeRoot] [outDir]
209
+ dtwin seed --dry-run # scan and report what it would add; write nothing
210
+ ```
211
+
212
+ It only reads source files: no bridge, plugin or token, and never the network. The merge is
213
+ fill-missing-only — an existing entry's hand-authored fields are never overwritten — and each new
214
+ entry leaves `import` and `props` for you to fill in. A mapping that carries only a node id (no
215
+ component name) is named through `<outDir>/design-system.json`, so export the design system first;
216
+ without it such mappings are skipped and the run says so. In a clone of this repo the same script
217
+ also runs directly: `bun bridge/src/seed-components.ts [codeRoot] [outDir] [--dry-run]`.
218
+
219
+ ## Something not working: `dtwin doctor`
220
+
221
+ Every failure looks the same from outside — a pull that waits, then times out. `doctor` tells the
222
+ causes apart, marks each check ✓ / ! / ✗, and gives the one next step:
223
+
224
+ ```
225
+ dtwin doctor # exit code 1 only if a check is ✗ (a note, !, is not a failure)
226
+ dtwin doctor --wait 30 # wait longer for the plugin to connect (default 10s; it retries every second)
227
+ dtwin doctor --json # { ok, checks: [{ id, title, status, detail, next, skipped }] }
228
+ ```
229
+
230
+ `ok` is false when a check is ✗ **or** `skipped` (a check that could not run — the plugin probe while something
231
+ else holds the port, or before a token exists — is a ! marked `skipped: true`, since nothing it would have verified was).
232
+
233
+ Once a plugin connects, the probe can run up to ~6.5 s past `--wait`: the bridge's settle (so a second
234
+ open file is seen), then each file's `hello` (so it is named).
235
+
236
+ 1. **Bun** version against this package's `engines.bun` (≥ 1.4.2).
237
+ 2. **Bridge token** — saved or not, where, loose file permissions, and whether `FIGMA_BRIDGE_TOKEN` is
238
+ shadowing the saved one. Fingerprint only, never the token.
239
+ 3. **Daemon** — running or not, and which files are connected to it (on Windows: a note that it is not
240
+ supported there, and why).
241
+ 4. **Port** (`FIGMA_BRIDGE_PORT`, one of 8787/8788/8789) — free, held by the daemon, held by another
242
+ bridge (almost always the MCP server), or held by something unrelated.
243
+ 5. **Figma plugin** — can it actually connect? If a plugin is running with a *different* token, doctor
244
+ says so with both fingerprints, instead of the "nothing connected" that case otherwise looks like.
245
+ 6. **Project** (the current directory) — `design/`, `design/target.json` and the profile build-screen will use
246
+ (bundled or custom, plus the project's own `design/profile.md` when it exists), which export LAYOUT it
247
+ found, the age of the last export (its newest stamp; stale past 24h), **which Figma files that export mixes**, the component map
248
+ (`design/codeconnect.local.json`, or the older repo-root location), and the `.mcp.json`
249
+ registration (a legacy `figma` key still works; new setups use `designtwin`). A registered entry that
250
+ cannot start — its command or `figma-mcp.ts` missing, Node-shaped (`node`, a `bridge/dist` script), bun
251
+ without `--no-install --no-env-file`, a script in a `bunx` cache, or the package run by name
252
+ (`bunx @design-twin/dtwin mcp`) — is a ✗ naming `dtwin init --mcp`,
253
+ which repairs it. `DT_TEST_EPHEMERAL_PORT` (a test-only seam) set in the environment is a note too.
254
+
255
+ The "which Figma files" line exists because one stamp describes exactly one file, and on a project
256
+ whose screens came from a different Figma file than its design system it would name the wrong one.
257
+ doctor lists every source with its own age — the screens of each Figma file, the design system — and
258
+ when they are more than one Figma file it points at `/designtwin:audit-design`, whose cross-file check
259
+ proves whether the screens and the design system really are the same system. `figma_status`'s
260
+ `snapshot` is the same verdict (bridge/src/export-verdict.ts), so the two never disagree.
261
+
262
+ It changes nothing: no token is created, no file written. Check 5 opens a bridge for a few seconds,
263
+ so it only runs when the port is free **and** a token already exists; when a daemon is up doctor asks
264
+ the daemon instead, and when something else holds the port it identifies the holder with a plain HTTP
265
+ request (a bridge answers `426`) rather than a WebSocket — a WebSocket client would be registered as a
266
+ plugin and disturb whoever owns that bridge.
267
+
268
+ ## Read: figma-pull (CLI) — recommended for bulk extraction
269
+
270
+ Every direct run ends with `done in 12.4s — waited 9.8s for the plugin to connect · export 2.4s · write 0.2s`
271
+ (or `via the running daemon · export (incl. any wait for the plugin) 2.6s` when a daemon answered: the
272
+ daemon waits for a not-yet-connected plugin inside the request, so that wait is in the export figure). A long "waited" figure is the plugin
273
+ re-dialling a fresh one-shot bridge (it retries every second, one port at a time, slower in a background window), not the export
274
+ itself; the line says so past 5 s. `dtwin serve` in another terminal keeps one bridge open and the plugin
275
+ connected, so every later command skips that wait.
276
+
277
+ `dtwin help` (or `--help` / `-h`) prints the quick start, the command table and every flag below, and
278
+ exits — no bridge is started, no token minted.
279
+ An unknown or mistyped flag is an error with a suggestion (`--lst` → "did you mean --list?"), never
280
+ silently ignored — a typo must never fall through to a full pull.
281
+
282
+ A full pull writes `design/export/design-system.json` as a slim MANIFEST (`exportedAt`/`file`/`colorProfile`,
283
+ `files` pointers, `counts`) over the catalog split under `design/export/design-system/`, one file per Figma
284
+ concept: `tokens.json` (variable collections → modes → variables), one file per style type —
285
+ `styles.paint.json`/`styles.text.json`/`styles.effect.json`/`styles.grid.json`/`styles.animation.json` — (the
286
+ separate Paint/Text/Effect/Grid style system, plus custom animation styles), `components.local.json` (components that are real nodes in this
287
+ file — a `COMPONENT_SET`'s heavy per-variant node trees, or a standalone `COMPONENT`'s own node tree,
288
+ are NOT inlined here; they live in a sibling `design-system/components/<name>__<id>.json`, pointed at
289
+ by that entry's `variantsFile`/`nodeFile`, opt-in via `--variant-visuals`; see `design-to-code/get-component.ts`
290
+ below), `components.library.json` (`remote: true` —
291
+ consumed from a published library, recovered from instances, props possibly inferred) and
292
+ `hygiene.json` (the lint report). Every part repeats the
293
+ `exportedAt` stamp, so `design-to-code/` reads freshness off whichever part it is handed. The split files sit
294
+ in a subdirectory because `design/tokens.json` and `design/components.json` are your
295
+ hand-authored, non-regenerable config maps.
296
+
297
+ ```
298
+ # from the repo root, with the Figma file open + plugin running:
299
+ dtwin pull # design-system.json + design-system/ + CURRENT-page frames + assets/
300
+ dtwin pull --all-pages # like above, but frame trees from EVERY page
301
+ dtwin pull --selection # just the current selection
302
+ dtwin pull --design-system # ONLY design-system.json + design-system/ — no page
303
+ # walk, no assets/ (the cheap "tokens only" pull)
304
+ dtwin pull --as-library "Acme UI" # the COMPLETE catalog of a LIBRARY file — run this
305
+ # with the LIBRARY open, not the file that uses it
306
+ dtwin pull --node <id> # ONE node, fully exported (properties + assets) —
307
+ # see "--node <id>" below
308
+ ```
309
+ **Recommended workflow — discover, then scope.** Never open with a whole-file pull. Work down from
310
+ cheap questions to expensive ones, which is also what Figma's own agent guidance recommends
311
+ (discover first, then scope):
312
+ ```
313
+ dtwin whoami # 0. (optional) which file am I actually connected to?
314
+ dtwin list pages # 1. page NAMES only (near-free — loads no page)
315
+ dtwin list # 2. WHERE is what — pages + top-level layers (ids)
316
+ dtwin list children <id> # 3. peek inside one frame / SECTION
317
+ dtwin pull --node <id> # 4. pull just that ONE node, real export + its own assets
318
+ dtwin pull --page <id> # 4b. (only for a whole page)
319
+ dtwin screenshot <id> # 5. (optional) visually check ONE component after generating code for it
320
+ ```
321
+ `dtwin list libraries` is a side step, not part of that order (the slowest read): run it to learn which
322
+ library owns a token, or before an `--as-library` pull.
323
+ (The flag spellings — `--whoami`, `--list-libraries`, `--list-pages`, `--list`, `--children`,
324
+ `--screenshot` — are the same commands; the sections below are titled by flag.)
325
+
326
+ ### `--node <id>` — one node, fully exported
327
+
328
+ ```
329
+ dtwin pull --node 123:456 # -> design/export/pages/<Page>/<Name>__123_456.json
330
+ dtwin pull --node "https://figma.com/design/KEY/App?node-id=123-456" # a pasted link works too
331
+ ```
332
+
333
+ A REAL export scoped to exactly one node — the CLI twin of the MCP `figma_export_url` tool ("paste a
334
+ link and ask about it," see below). It runs the full `serialize()` walk on that node's subtree and
335
+ exports the assets found inside it, exactly as a `--page` pull would for that subtree, just rooted
336
+ lower. Accepts a bare id, dash form, percent-encoded id, a nested-instance path, or a whole figma.com
337
+ URL — the same lenient parsing `--children`/`--screenshot` use.
338
+
339
+ Don't confuse it with its two neighbors:
340
+ - `--children <id>` peeks at one node's **direct children only** — no recursion, no assets, for
341
+ deciding whether to pull further. Each row has `childCount`; rows sharing name + size also get a
342
+ `title` (the first visible text), and the listing warns once per such group ("N children share name
343
+ … and size … — told apart by `title`/`childCount`"). When the titles match too, those rows also get
344
+ `distinctTexts` (up to 3 texts that row shows and its twins do not); the listing shares one budget of
345
+ node reads, and a group with a row it runs out on gets none, with one warning naming them. When a row has
346
+ no `distinctTexts`, screenshot each candidate.
347
+ - `--screenshot <id>` renders a **PNG only** — skips `serialize()` and the asset walk entirely, for
348
+ visual validation after you've already generated code.
349
+ - `--node <id>` is the one that actually **exports** — real node tree + real assets, just scoped
350
+ smaller than a whole page.
351
+
352
+ It is a SCOPE flag like `--page`/`--selection`/`--all-pages`, so it's mutually exclusive with them,
353
+ and unlike `--screenshot` it composes with read options (`--css`, `--measurements`, etc.) since it
354
+ does a real walk.
355
+
356
+ It files into the **same `pages/` tree** a `--page` pull writes, so nothing downstream has to know
357
+ which pull produced a file:
358
+
359
+ ```
360
+ design/export/pages/<Page>/<Name>__<node-id>.json the screen tree
361
+ design/export/pages/<Page>/<Name>__<node-id>.vars.json exactly the variables THIS screen binds
362
+ design/export/pages/<Page>/<Name>__<node-id>.assets.json its assets, with content hashes,
363
+ plus duplicates / monochrome / heavy; rows carry
364
+ name / owner / context / usedBy (search those,
365
+ not file names)
366
+ design/export/pages/<Page>/index.json merged, one row per screen pulled
367
+ design/export/pages/index.json merged, one row per page
368
+ design/export/variables.json the UNION of every screen's slice
369
+ design/export/assets/ shared, deduped by content
370
+ ```
371
+
372
+ A pull prints these asset lines: `warn N node(s) fell back to raw geometry …` (any fallback, naming the
373
+ nodes), `warn … too heavy to inline` (or "a raster image embedded in an SVG shell" when it is one), and
374
+ `info N hidden graphic(s) not exported (assetSkipped:"hidden"); M reuse a visible twin's file` and
375
+ `info N node(s) hidden (themselves or under a hidden ancestor) kept in the tree (conditional UI)` (only
376
+ the ones hidden themselves carry `hidden:true`). A root that is hidden itself or under a
377
+ hidden ancestor is exported with a warning (its graphics are not rendered, its reference may be blank).
378
+ A file a hidden node reuses from another screen is listed in this screen's `.assets.json` with
379
+ `reusedFrom`.
380
+ `duplicates` is found by a tolerant compare (def ids and numbers within ±0.01 ignored — except an
381
+ embedded image's scale/offset/tile, the numbers of a transform on `<use>`/`<image>` and of a `<pattern>`'s
382
+ x/y/width/height, compared relatively, within 1 %), so the same artwork re-exported under another name
383
+ is one file.
384
+
385
+ Three consequences worth knowing:
386
+
387
+ - **The node id is in the filename**, because a frame NAME does not identify a frame: two frames both
388
+ called `Popup` would otherwise overwrite each other, and a frame whose name ends in a space files as
389
+ `Name___<id>.json`, which looks like a typo. Read `pages/index.json` — it carries each screen's real
390
+ name, page, node id, size and the paths to all three files — rather than reconstructing a filename.
391
+ - **`variables.json` MERGES.** Each pull's slice is unioned in, keyed on each variable's Figma key, so
392
+ pulling screen B never deletes screen A's tokens — replacing the file would leave A's theme
393
+ unresolvable, silently, while the build still rendered from the raw hex beside every binding. The raw
394
+ per-pull slice is kept verbatim as the screen's `.vars.json`. When two screens resolve one variable
395
+ differently the newest wins and the disagreement is recorded under `_conflicts` and in `hygiene`.
396
+ It holds only the variables bound by the pulled nodes — to find the variable behind a library colour,
397
+ look it up on the library catalog: `bun --no-install --no-env-file <scripts>/tokens.js <library>/tokens.json --lookup <hex>` (`ffbc1c`, or a quoted `'#ffbc1c'`:
398
+ an unquoted `#` starts a shell comment).
399
+ - **Assets are named after their Figma layer and deduped by content.** `icons/linear/arrow-down` lands
400
+ as `arrow-down.svg`; the same artwork reached through several instance paths is one file carrying
401
+ every node id that resolved to it; two different icons whose leaf names collide are separated by a
402
+ short content hash, never by overwriting.
403
+
404
+ MCP twin: `figma_export_url` (also accepts a bare node id, not just a URL).
405
+
406
+ ### `--screenshot <id>` — an on-demand PNG of one node
407
+
408
+ ```
409
+ dtwin --screenshot 123:456 # writes design/export/assets/123_456_ref.png — the same
410
+ # path a later --node pull of that frame writes its own
411
+ # reference to, so there is never a second copy
412
+ dtwin --screenshot 123:456 --scale 0.25 # override the default (auto, capped at 2048px on the longest side);
413
+ # writes assets/123_456_shot@0.25x.png, never the reference's name
414
+ ```
415
+
416
+ Every export already carries **one** whole-frame reference PNG per exported root (`reference` in the
417
+ tree), for the codegen agent to self-check the overall page against. That's a weak validation target
418
+ for a **dense** screen, though — eyeballing one small component inside a huge flat image doesn't scale.
419
+ `--screenshot` is the single-node counterpart: pull it for *one* component/instance you just generated
420
+ code for, after `--list`/`--children` gave you its id, and compare it directly.
421
+
422
+ It is cheap next to a real export — it skips `serialize()` and the recursive asset walk entirely, so
423
+ `exportAsync` on the node itself is the only Plugin-API cost — but unlike `--list`/`--children` it
424
+ **writes a file** (it's a tiny export, not a structural index), so it can't be combined with a scope
425
+ flag, another index command, or a read option (`--css` etc. need a node walk this skips).
426
+
427
+ This mirrors Figma's own Dev Mode MCP server: `get_screenshot` is scoped to a single node/selection,
428
+ called **on demand** after `get_metadata`, specifically for post-generation visual validation — not a
429
+ bulk pre-render pass over every node, which would pay the same per-node cost `--no-assets` exists to
430
+ avoid, for a much bigger payload (PNG > structural JSON). If you want a component's reference image
431
+ without picking through instances one at a time, dedupe by the component's own id (or its
432
+ `mainComponent`) rather than pulling every instance of it.
433
+
434
+ It prints `{id, name, type, w, h, scale, png:{w,h}, reference}` on stdout: `w`/`h` are the node's size, `scale`
435
+ the factor the plugin rendered at, `png` the written file's pixel size (keys that do not apply are omitted).
436
+
437
+ MCP twin: `figma_screenshot`.
438
+
439
+ ### `--list-clients` / `--client` — working with two Figma files at once
440
+ ```
441
+ dtwin --list-clients
442
+ ```
443
+ The bridge accepts **one connection per open Figma file**, so a design file and the library it draws
444
+ on can both be connected. Each plugin announces itself on connect (and on every reconnect), so the
445
+ listing shows what you can address:
446
+
447
+ ```
448
+ 2 Figma files connected:
449
+
450
+ c1 "App — Base"
451
+ page "Home" · fileKey KEYBASE · up 12m
452
+ c2 "Acme UI Library"
453
+ page "Tokens" · no fileKey (private-plugin API not in effect) · up 3m
454
+
455
+ Address one with --client <connId | fileKey | part of the file name>, e.g. --client "App".
456
+ ```
457
+
458
+ **`c1`/`c2` are display labels, not stable ids.** They are assigned in *reconnect order* for the
459
+ current bridge's lifetime — whichever plugin's WebSocket lands first becomes `c1`. Two runs of the
460
+ same `--client c1` can select **different Figma files** if the bridge restarted (no `dtwin serve`
461
+ daemon) and the reconnect race went the other way between runs. Prefer the **file name**
462
+ (a substring match, e.g. `--client "App"`) or the **fileKey** when one is available — both are stable
463
+ across restarts; `c1`/`c2` are only for telling two *currently open* files apart in one sitting, exactly
464
+ like `--list-clients`' own listing does. Under a running `dtwin serve` daemon, connIds are assigned
465
+ once per plugin connection and stay stable until that plugin's window disconnects/reconnects.
466
+
467
+ A named target (file name or fileKey) is **waited for**: each open plugin window redials on its own
468
+ clock (every second, one of the three allowed ports at a time), so right after a bridge starts only one of two open files may be connected yet. Once a
469
+ first plugin is connected, `dtwin` gives the named file up to 15 s more (or `--timeout`, whichever is
470
+ shorter) to land before resolving the name; a name that never matches then fails with the list of
471
+ what *is* connected. A bare `c<N>` never waits for its file. The MCP `client` argument gets the same
472
+ wait on every tool: holding the bridge, up to 15 s for a first plugin to connect and then up to 15 s
473
+ more for the name; routed through a daemon, the same two windows, spent by the daemon's bridge.
474
+
475
+ **A fresh bridge settles before it guesses.** For 3.5 s after a bridge's first plugin connects (one
476
+ walk of the three ports at a redial a second, plus a moment for the next window's hello), a command
477
+ with **no** `--client`, or one whose `--client` is a file name — whole or partial: names repeat (two `Untitled`) and a name matches as
478
+ a substring (`App` also matches `App (Copy)`) — is held: a second open file may still be redialling,
479
+ and without it the command would silently go to whichever file landed first instead of being refused
480
+ as ambiguous. The hold outlasts a shorter `--timeout`, which only bounds how long to wait for a first
481
+ plugin. A connId or fileKey is routed at once. A bridge that has had its plugins connected for 3.5 s
482
+ pays nothing — a `dtwin serve` daemon, a running MCP session — until one of them leaves while others
483
+ stay: that window is probably redialling, so the 3.5 s start again. Wherever a command waits like this
484
+ — the one-shot CLI's own bridge, a daemon request that carries a connect window, any bridge still
485
+ settling — a file name, no `--client` and `--list-clients` are also held, within the connect window (or
486
+ the settle, when that ends later), while another connected window has not yet said which file it is
487
+ (usually a few milliseconds; 3 s per window that never says), so a
488
+ refusal or a listing names that file instead of showing it as unidentified; a window that never says is
489
+ listed unidentified. Whatever the settle and those holds add up to, the wait ends at most 3 s past the
490
+ longer of the settle and its window (the window capped at 15 s), counted from when a plugin went live —
491
+ a window that keeps connecting and dropping without saying which file it is cannot hold a command.
492
+ `--list-clients` on its own bridge waits the same 3.5 s, so it lists every window that redialled in
493
+ that time; a settled MCP session or `dtwin serve` daemon reads its list directly, with no wait. A window
494
+ that redials more slowly (Figma throttles a background window's timers) can still land after that;
495
+ with two files open, address the one you mean.
496
+
497
+ `--client` is an **address**, not a scope — it composes with every other flag:
498
+ ```
499
+ dtwin design/base --client "Marketing" --page 12:34
500
+ dtwin design/lib --client "Acme UI" --as-library "Acme UI"
501
+ ```
502
+ Give each file its own output directory, as above, and their exports never collide.
503
+
504
+ **With one file connected, omit it** — nothing changes from the single-file workflow. **With several,
505
+ omitting it is an error** that lists your choices:
506
+
507
+ ```
508
+ 2 Figma files are connected to the bridge — say which one to use.
509
+ c1 "App — Base" fileKey KEYBASE
510
+ c2 "Acme UI Library"
511
+ Pass the connId, the fileKey, or part of the file name (CLI: --client <id|name>; MCP: client: "<id|name>").
512
+ ```
513
+
514
+ That refusal is deliberate. An export pulled from the wrong file is indistinguishable from a correct
515
+ one — it writes the same shape of JSON to the same directory and reports success — so the bridge
516
+ refuses rather than picking, the same call `adb` makes with "more than one device".
517
+
518
+ MCP twins: `figma_list_clients`, plus a `client` argument on every tool. `figma_status` lists the
519
+ roster instead of failing when several are connected.
520
+
521
+ ### `--whoami` — who is connected (and can two files connect at once?)
522
+ ```
523
+ dtwin --whoami
524
+ ```
525
+ Prints both halves of the connection (`connection` is the **addressed** file's socket — the one `--client`
526
+ picked — not whichever connected first): what the **plugin** says it is (`instanceId` minted per plugin
527
+ run, `file`, `page`, and whether `figma.fileKey` is available) and what the **socket** did
528
+ (`connId`, uptime, `connectionsThisRun`, `takeovers`).
529
+
530
+ Use `--list-clients` to see *which* files are connected; use `--whoami` to interrogate **one** of them
531
+ (`--client` selects it). What the fields tell you:
532
+
533
+ | What you see | What it means |
534
+ | --- | --- |
535
+ | `instanceId` **changed** between two calls addressing the *same* file | Figma tore down and re-ran the plugin runtime. The socket survived and the `connId` is unchanged — only the runtime restarted (see the note below) |
536
+ | `fileKeyAvailable: false` | `figma.fileKey` is gated to private plugins, so this file is addressed by `connId` or name instead. Normal for a locally-imported plugin |
537
+ | `takeovers` > 0 | Never happens — connections coexist, so the field is always `0`. It stays so a reader of this field sees `0` rather than the key vanishing |
538
+
539
+ > **Runtime teardown.** At least one comparable project ([`southleft/figma-console-mcp#67`](https://github.com/southleft/figma-console-mcp/issues/67))
540
+ > reports Figma destroying the plugin's JS execution context every 1–3 minutes regardless of focus.
541
+ > This repo's own live runs contradict that (a 25-page `--all-pages` export ran >15 min to
542
+ > completion — hence `TIMEOUTS.exportAll`), so it may be setup-specific. If you plan to leave a
543
+ > connection **idle** while working in another file, check it with `--whoami` first: a changed
544
+ > `instanceId` is the symptom. Related and better attested: an *occluded* Figma window is fine, a
545
+ > **minimized** one gets its renderer suspended.
546
+
547
+ > **Background tab.** Measured live (one page through the daemon): with the exporting
548
+ > file's tab in front, 31–38 progress ticks and ~2,500 nodes per 10 s; with another file's tab in
549
+ > front, 3–5 ticks and ~15 nodes per 10 s — about 100× slower; then 27 ticks in the first 10 s after
550
+ > switching back and the normal 31–38 from the second window on. The export completes either way,
551
+ > but a `--timeout` sized for the foreground fires. Figma Desktop is an Electron app; Electron's
552
+ > `backgroundThrottling` (default `true`) is "Whether to throttle animations and timers when the
553
+ > page becomes background", and the plugin's walk advances on Figma API awaits, so it is the tab
554
+ > that is throttled. Keep the exporting file's tab in front.
555
+
556
+ MCP twin: `figma_whoami`.
557
+
558
+ ### `--list-libraries` — which library owns a token
559
+ ```
560
+ dtwin --list-libraries
561
+ ```
562
+ Prints an aligned table of the libraries this file uses — the local file's own published assets plus
563
+ every **enabled** team library — with each one's variable collections (and variable counts) and how
564
+ many of its components this file uses. It's the library-scoped sibling of `--list`: no tree export, no node
565
+ properties, no assets, same `--timeout`, prints to stdout and writes nothing — but the slowest read there
566
+ is, a few seconds up to ~15 s (about 2 s on a file with no libraries enabled), because it walks every
567
+ instance in the file and makes one Figma call per enabled library variable collection.
568
+
569
+ **Waiting for the plugin.** Every command that needs Figma first waits for the plugin to connect:
570
+ `--timeout <seconds>` bounds that wait as well as the command itself. Without the flag it is 10
571
+ minutes at a terminal and 90 seconds when stderr is not a TTY (an agent's shell, CI) — then
572
+ `no Figma plugin connected within Ns`, exit 1, with the next step. A first word that is not a verb
573
+ but is an obvious guess at one (`dtwin export`, `dtwin ls`, `dtwin check`) is refused with the real
574
+ verb instead of being taken as an output folder; `dtwin pull export` still exports into `./export`.
575
+ `--timeout` takes at most 2147483 seconds (about 24 days, the longest delay one JavaScript timer holds);
576
+ `Infinity`, `0`, a negative or a larger number is refused with a usage error rather than run as an
577
+ export that times out at once.
578
+
579
+ Three honest limits, because they change what the numbers mean:
580
+ - **Component counts are usage-derived.** Figma exposes **no API to enumerate a library's contents**,
581
+ so the count is "components of that library used in *this* file", not the library's size.
582
+ - **Libraries can only be enabled from the Figma UI** (Assets panel → Libraries) — no API can enable
583
+ one. An export is therefore silently scoped to whatever was enabled at export time; if a component
584
+ looks unresolved, an unenabled library is the first thing to check.
585
+ - **An empty result is normal**, not a failure: free plans don't expose library variable collections,
586
+ and a file with no library enabled genuinely has none. The output says so rather than printing an
587
+ empty table. It is also what a **stale plugin** looks like — see the re-import callout above.
588
+
589
+ ### `--as-library <name>` — the COMPLETE catalog of one library
590
+
591
+ ```
592
+ # open the LIBRARY file in Figma (not the design file that consumes it), then:
593
+ dtwin pull --as-library "Acme UI" # -> design/export/libraries/<slug>-<fileKey8>/
594
+ ```
595
+
596
+ `--list-libraries` tells you a library exists and how many variables its collections hold.
597
+ `--as-library` gets you the **values**: every variable with its full per-mode values, every paint /
598
+ text / effect / grid style, and every component with its **real** property definitions. Output lands
599
+ in `design/export/libraries/<slug>-<fileKey8>/` and **never touches `design-system/`** — the two catalogs
600
+ describe different Figma files and answer different questions.
601
+
602
+ **Why it must run inside the library file.** From a consuming file the Plugin API simply does not have
603
+ this data: `getVariablesInLibraryCollectionAsync` returns `name`/`key`/`resolvedType` with **no
604
+ values**, and there is no API to enumerate a library's components at all. Inside the library every one
605
+ of those objects is *local*, so the ordinary local reads return the whole thing — no extra permissions,
606
+ no paid plan, no import step.
607
+
608
+ > **Deliberately not used: `importVariableByKeyAsync`.** It *would* resolve library values from a
609
+ > consuming file, and it is the obvious-looking answer. It is also a **write**: the `import*ByKeyAsync`
610
+ > family materialises into the current document (the plugin typings say exactly that of its sibling
611
+ > `importShaderAsync`), which is why those calls fail in read-only/Dev Mode. Using it here would make a
612
+ > read-plane command subscribe your file to hundreds of variables. This path reads and writes nothing.
613
+
614
+ **Four honest limits**, because they change what the output means:
615
+ 1. **`publish` is per-object, and only meaningful here.** Each variable/style/component carries
616
+ `publish: current | changed | unpublished` (`current` = published and in sync, `changed` =
617
+ published but edited since, `unpublished` = never published). Figma answers `UNPUBLISHED` for
618
+ *everything* when asked from a consuming file or a branch, so the normal `--design-system` pull
619
+ deliberately omits the field rather than emit a confident wrong answer.
620
+ 2. **What the last publish contained cannot be listed.** `publish` describes each *local* object's
621
+ relationship to the library. A component deleted here but still live in the published library is
622
+ invisible to any API — so this is the library file's **current state**, not a diff of what
623
+ consumers see.
624
+ 3. **Nothing is filtered for you.** Unpublished drafts are exported and *labelled*, never dropped:
625
+ `hiddenFromPublishing` is forward-looking publish config, not publish state, so filtering on it
626
+ would be wrong in both directions.
627
+ 4. **One library per run.** A library is a file, so exporting several means running this once per
628
+ library file. Rows accumulate in `design/export/libraries/index.json` — a second library is added, never
629
+ substituted. A variable that aliases into a *different* library stays an unresolved reference until
630
+ that library is exported too.
631
+
632
+ **Re-running is safe.** Every file is rewritten in place with a fresh stamp, and the directory is keyed
633
+ on `fileKey` (which survives a rename) so a renamed library keeps its directory instead of forking.
634
+ Files a previous export produced and this one did not are **reported as `STALE:`, never deleted** — a
635
+ read command does not remove files it did not create.
636
+
637
+ **How the two catalogs fit together.** They stay separate and join on `key` (durable cross-file
638
+ identity, present on variables, collections, styles and components — names collide across libraries):
639
+
640
+ | | `design/export/design-system/` | `design/export/libraries/<lib>/` |
641
+ |---|---|---|
642
+ | Which file | the design file you build screens from | one library file |
643
+ | Variables | the subset your screens reference | **all**, full per-mode values |
644
+ | Components | usage-derived; props *inferred* from instances present | **all**, real property definitions |
645
+ | Styles | local only | **all**, with `key` |
646
+ | Use it for | **codegen** — emit only what is used | **lookup/enrichment** — resolve what exists |
647
+
648
+ Emit CSS from the design file's catalog, not the library's, or you ship every unused primitive in the
649
+ library. The library catalog is what you consult to answer "what else does this token family offer?"
650
+ and "what props does this component *really* take?".
651
+
652
+ Because the layout matches `design-system/`, existing tooling runs on it unchanged:
653
+ ```
654
+ bun design-to-code/tokens.ts design/export/libraries/acme-kit-ab12cd34/tokens.json ./out # DTCG + CSS, no special-casing
655
+ ```
656
+ The generated `tokens.css` / `theme.css` start with a provenance line (`designtwin-source: <dir>/<input> · <n>
657
+ variables · sha256 <12>`); `tokens.js <input> --check <file>` exits 0 when the file is current, 1 when the
658
+ input has changed since (stale), 2 when the file has no such line (regenerate once) or names a different source file (pass that one; a design-system `tokens.json` and a library's are told apart by their parent directory) or the command line is wrong (usage errors are 2 there, never 1). `--lookup <hex>`
659
+ (repeatable; `ffbc1c` or a quoted `'#ffbc1c'`) lists the COLOR variables with that exact value, per mode, following aliases.
660
+
661
+ Scope note: the **design system** (variables, styles, component catalog) always spans the whole
662
+ file. **Frame trees** default to the current page; `--all-pages` walks every page (each screen is
663
+ tagged with its `page` name). All-pages can be large — the CLI is the right tool for it because it
664
+ streams to disk, not into the context window. `--design-system` narrows the OTHER way: it skips the
665
+ frame walk (and therefore the assets a walk would export) and writes only `design-system.json` +
666
+ `design-system/` — tokens, styles, local + library components, hygiene. One honest limit: on a file
667
+ that CONSUMES a library, `--design-system` returns that file's OWN tokens, styles and components plus only
668
+ the library (remote) variables something in the file references (a page pull adds the ones its nodes
669
+ bind) — never the library's catalog. For that, open the LIBRARY file and run `--as-library "<name>"`
670
+ (CLI only); the tell is `remote: true` on the variables and `hygiene.json`'s count line. It shares
671
+ `--selection`/`--all-pages`/`--page`/`--as-library`'s scope slot (pass only one) and, like `--list`/`--list-libraries`,
672
+ refuses the read-option flags (`--css`/`--measurements`/…) since there is no node walk for them to apply to.
673
+ It waits for the plugin to connect, pulls, writes files to `design/`, and exits. The agent then
674
+ Reads those files — big payloads live on disk, not in the context window.
675
+
676
+ **`visuals`.** Every catalogued component/variant carries its own paint under `visuals` on each
677
+ `components.local.json` entry: `{ fills, strokes, effects, radius, opacity, blendMode }` — the same
678
+ fields `ComponentNode`/`ComponentSetNode` carry as any other node. This is always on, not a flag:
679
+ unlike `--css`/`--measurements`/asset export, these six are plain synchronous property reads (Figma's
680
+ Plugin API only marks the genuinely expensive calls `Async` — `getCSSAsync`, `exportAsync` — and
681
+ fills/strokes/effects/cornerRadius/opacity/blendMode aren't among them), so there's no walk-avoiding
682
+ reason to gate them the way `--design-system` gates a page walk. **Two honest limits:**
683
+ 1. It's the component's own base look — a specific instance's overrides elsewhere in the file are
684
+ not reflected, and a variant SET's `visuals` is the SET wrapper node's own paint (Figma's purple
685
+ dashed selection chrome), not a per-variant one. Pass `--variant-visuals` to also walk each set's
686
+ variants and attach their real `layout`/`fills`/`radius`/`tokens`/`css` under `entry.variants[]` —
687
+ opt-in because it costs one extra node walk per variant. A standalone `COMPONENT` (not inside a
688
+ set) gets the same walk, attached as `entry.node` instead — there's no set to enumerate variants
689
+ of. `entry.props` (the prop *schema*) is unaffected either way; a variant's
690
+ `componentPropertyDefinitions` throws, so per-variant `values` there come from `variantProperties`
691
+ or, failing that, the variant name Figma guarantees is `"Prop=Val, ..."`. The node tree itself never
692
+ lands in `components.local.json` — it is written to a sibling
693
+ `design-system/components/<name>__<id>.json`, pointed at by that entry's `variantsFile` (sets) or
694
+ `nodeFile` (standalone components) — absent when nothing was exported for that entry. `entry.variants[]`
695
+ in the slim catalog keeps only `id`/`name`/`key`/`values`. Fetch one component's real node tree(s) with
696
+ `bun design-to-code/get-component.ts design/export/design-system/components.local.json <key|id|name>`.
697
+ 2. It only covers components DEFINED in this file. Components consumed from a published library
698
+ (the `remote:true` entries, recovered via instance-walk — see the honest-limits note above) are
699
+ not covered here; pull `--as-library` on the *source* library file for those. There is no
700
+ client-side fix for this — Figma exposes no API to enumerate a library's contents from a
701
+ consuming file (see `--list-libraries` above), so this is a hard ceiling, not a gap.
702
+
703
+ **Duplicate component names.** The catalog build already flags a component name reused elsewhere in
704
+ the file (e.g. two unrelated "Action Buttons" component sets) as `duplicate component name: '<name>'`
705
+ in `hygiene`. `--design-system`/full pulls echo `hygiene` to stderr as soon as the write completes
706
+ (prefixed `hygiene (N):`), so this shows up in the run itself instead of requiring a manual
707
+ `hygiene.json` diff.
708
+
709
+ Allowlist it in Claude Code so it runs without a prompt (`.claude/settings.json`):
710
+ ```
711
+ { "permissions": { "allow": ["Bash(dtwin:*)"] } }
712
+ ```
713
+
714
+ ### Keep the connection open: `--serve` (daemon)
715
+ Each one-shot run above waits for the plugin to reconnect. For several pulls in a row, hold the bridge
716
+ open and let later invocations reuse it:
717
+ ```
718
+ dtwin --serve # holds the bridge open until stopped (Ctrl-C works too)
719
+ dtwin --daemon-status # is one up, and is the plugin connected?
720
+ dtwin --stop # or: dtwin stop
721
+ ```
722
+ Every ordinary command detects a running daemon and **routes through it automatically** — same
723
+ invocations, no reconnect, no second bridge to collide on 8787.
724
+
725
+ Why a daemon rather than "a flag that just leaves the connection open": a long-running CLI has no way
726
+ to receive further commands, so it would hold the port while being unreachable — blocking the MCP
727
+ server and every other CLI call. Instead one process (`--serve`) owns the bridge and each later
728
+ invocation is a thin client over a unix socket, `designtwin-<port>.sock`, in a directory only you can
729
+ access: `$XDG_RUNTIME_DIR` when it is set (and private), else `$TMPDIR/designtwin-<uid>/`, created
730
+ `0700`. The socket takes no token, so dtwin refuses that directory if it is a symlink, owned by
731
+ another user, or open to group/other — remove it, or point `XDG_RUNTIME_DIR` or `TMPDIR` at a
732
+ directory only you can access. Until then ordinary commands and the MCP server run without a daemon
733
+ (the refusal is printed once on stderr), `--serve` / `--stop` refuse, and `dtwin doctor` fails its
734
+ daemon check with the same message. The location follows `XDG_RUNTIME_DIR` / `TMPDIR`, so two processes
735
+ started with different values (a terminal, and an MCP server launched by an app) see different daemons.
736
+ A daemon started by an earlier dtwin version listens at `$TMPDIR/designtwin-<port>.sock`: `--stop`
737
+ stops it too, and a port-in-use error names that socket.
738
+
739
+ > **On Windows** the daemon listens on a named pipe, `\\.\pipe\designtwin-<port>-<hash>`, named from the
740
+ > bridge token. A pipe cannot be made private to your account the way the socket's directory is (its
741
+ > default access lets any local user open it, and a client cannot check who created it), so before any
742
+ > request both ends prove they hold the bridge token — a mutual HMAC challenge over fresh nonces from each
743
+ > side (`bridge/src/daemon-auth.ts`). A process that cannot prove it is dropped without a word, and a
744
+ > squatter holding the pipe name is refused by every command (nothing is sent to it): `--serve` says so,
745
+ > and commands fall back to opening their own bridge. What it cannot stop: a squatter can still deny you
746
+ > the daemon (move it with `dtwin --rotate-token`), and the pipe is only as private as the token file —
747
+ > `%APPDATA%\design-twin\bridge-token` is per-user; `dtwin doctor` warns when the token comes from a file
748
+ > outside your profile. With no token saved yet there is nothing to name the pipe with: start a bridge once
749
+ > first. `--rotate-token` / `--forget-token` stop a running daemon first (on every OS), since nothing
750
+ > could find it under the new token while it still held the port.
751
+
752
+ Behaviour worth knowing:
753
+ - Requests are **forwarded as they arrive**; the **plugin** decides what may run alongside what, in each
754
+ file, for every caller (a daemon client, the MCP server holding the bridge, the plugin window's own
755
+ buttons). Each command declares how it runs (`bridge/src/commands.ts` `runKind`): exports, screenshots
756
+ and writes run one at a time, in arrival order; the selection and the listings (`list pages` / `list` /
757
+ `list children`, `list libraries`) wait for the run ahead of them, because they read layers and an
758
+ export's catalogue walk briefly hides hidden layers inside instances (`skipInvisibleInstanceChildren`);
759
+ the identity reads (`ping`, `whoami`) answer at once, even during an export. Two files are two plugins,
760
+ so a pull of one file never waits on another file's export.
761
+ - Every request id is unique per bridge (`r<nonce>-<seq>`) and a reply only settles the request on the
762
+ connection it was sent to, so a late reply from an abandoned earlier request cannot answer the
763
+ next one. When the plugin is connected but sends nothing the stall message says so ("the plugin is
764
+ connected but sent nothing for '<cmd>' … it is busy"): an earlier export is still running in that file,
765
+ or a long cold first read of a large page. Retry, or use a warm plugin (`dtwin serve`).
766
+ The plugin sends a `start` frame when a bridge run begins and a `queued` frame when it arrives behind
767
+ another run, so "connected but silent" is distinguishable from "waiting its turn".
768
+ - An **abandoned request is cancelled in the plugin**: when a command times out, when the one-shot
769
+ stall check gives up, when the bridge shuts down, or when the client that asked (a Ctrl-C'd CLI, an
770
+ ended MCP session) disconnects from the daemon before its reply, the bridge tells the plugin to stop
771
+ that work rather than leaving it to finish for nobody (the plugin checks for the cancel at every node, so it
772
+ stops within a node, not at the end of the page; one long single `exportAsync` still runs to its end) —
773
+ and one the plugin still has queued is dropped without ever running. The next run in that file starts
774
+ when the abandoned one has actually stopped, not when the cancel was sent.
775
+ - `--serve` / `--stop` / `--daemon-status` each own the whole invocation; combining one with a pull or
776
+ a read option is refused rather than silently dropping the export you typed.
777
+ - A stale socket left by a crashed daemon reads as "no daemon" and is replaced, rather than making
778
+ every later run fail against a file nobody is listening on.
779
+ - A second `--serve` is **refused**, not allowed to steal the live socket.
780
+ - **Idle shutdown after 120 min** (`FIGMA_DAEMON_IDLE_MIN`, `0` disables). A daemon outlives the
781
+ terminal that started it, so this reaps abandoned ones instead of leaving port 8787 held by a
782
+ process you've forgotten. It is deliberately generous, the clock resets on every request, and a
783
+ request in flight holds it off entirely — a 15-minute `--all-pages` export is never cut short by
784
+ its own daemon. `--daemon-status` shows how much of the window is used.
785
+ - **An MCP server that owns the bridge exits when its client goes away** (stdin closes, or stdout breaks),
786
+ at once, unless another process has used its shared socket; then it keeps serving and idles out on the
787
+ same `FIGMA_DAEMON_IDLE_MIN` window (default 120 min, `0` = never), since other sessions route through
788
+ it. This deliberately departs from the MCP stdio binding's "exit promptly when stdin closes". SIGINT,
789
+ SIGTERM and SIGHUP cancel in-flight reads, remove the socket and exit 0, even while serving others.
790
+
791
+ ## Write / interactive: figma-mcp (MCP over stdio)
792
+ **Not registered in this repo** — there is no `.mcp.json` here on purpose, so the MCP never starts
793
+ while you are working *on* the bridge. Register it in the project you are *building*: a `.mcp.json`
794
+ there that runs `bridge/src/figma-mcp.ts` on Bun — `"command"`: the absolute path of `bun`, `"args"`:
795
+ `["--no-install", "--no-env-file", "<abs path>/bridge/src/figma-mcp.ts"]` — under the key `designtwin` (`dtwin init --mcp`
796
+ writes exactly that, with the `bun` and the clone that ran it; an absolute `bun` keeps working when Claude Code is
797
+ started without `~/.bun/bin` on its PATH). Claude Code then launches it over stdio, and its tools surface as
798
+ `mcp__designtwin__<tool>` (e.g. `mcp__designtwin__figma_status`).
799
+ No token needs to go in that `.mcp.json`: the MCP server reads the same per-user stored token the CLI
800
+ does, so the one you already pasted into the plugin keeps working (set `FIGMA_BRIDGE_TOKEN` in the
801
+ registration only if you deliberately want the MCP on a *different* token from the CLI).
802
+ The server also hosts the bridge WebSocket the plugin connects to. Built on `@modelcontextprotocol/sdk`
803
+ with the `McpServer` + `registerTool` + zod pattern (tool schemas are zod, validated per call).
804
+
805
+ Tools (15): `figma_status`, `figma_list_clients`, `figma_whoami`, `figma_get_selection`, `figma_list_libraries`,
806
+ `figma_list_pages`, `figma_list_children`, `figma_export_full`, `figma_export_design_system`, `figma_export_selection`, `figma_export_url`,
807
+ `figma_screenshot` (an on-demand PNG of ONE node — the single-node visual-validation counterpart to the
808
+ export tools' whole-frame reference PNG; see `--screenshot` above),
809
+ `figma_write` (batch of safe ops — createFrame / createText / setFill / setText; **no arbitrary code
810
+ execution**, unlike some community servers; a `fill` / `color` must be a hex colour — `#rgb`, `#rgba`,
811
+ `#rrggbb` or `#rrggbbaa` with the `#`, never a name like `red` — and anything else is refused (by the
812
+ schema here, and by the plugin for a write from another client) instead of painted as a wrong colour; `dryRun: true` returns a preview of what each op would
813
+ create or overwrite without touching the file — there is no undo from the MCP side, so preview anything
814
+ that overwrites; refused outright when the file is in read-only Dev Mode), and two that read the export already on disk and
815
+ need no Figma connection — `design_get_component` (one component's variant node trees) and
816
+ `design_drift_lint` (the component map vs the export; see below). The frame-walking export tools accept opt-in read flags
817
+ `css` / `measurements` / `pluginData` / `motion` / `sharedData`; `figma_export_design_system` doesn't
818
+ take them (it never walks a node — see below) but DOES take `variantVisuals`, since the component
819
+ catalog it builds is exactly what that flag enriches.
820
+
821
+ ### `writeToDisk` — the export path that doesn't go through the context window
822
+ Every export tool takes `writeToDisk: true` (plus an optional `outDir`). It writes the export through
823
+ the same `write-out.ts` the CLI uses — same layout, by construction — and returns a **compact index**
824
+ (counts + file paths) instead of the node payloads. The agent then Reads/Greps those files at whatever
825
+ granularity it needs.
826
+
827
+ Use it for anything past a quick look. Two things make it not optional:
828
+ - **Asset bytes are never returned inline** (they'd dump megabytes of base64 into context), so
829
+ `writeToDisk` is the *only* way to get `assets/` out of the MCP path.
830
+ - **Inline MCP results are capped** (25k tokens by default, `MAX_MCP_OUTPUT_TOKENS`, and never above
831
+ 48,000 characters — under Claude Code's own 50,000-character persist threshold). The server
832
+ measures the result before returning it: one that would not fit is **written to disk on its own**
833
+ and the index's `note` says so (a real design-system export is ~590 KB ≈ 147k tokens). Passing
834
+ `writeToDisk: false` opts out of that — an oversized result is then an error naming the size,
835
+ never a payload cut off mid-JSON.
836
+
837
+ Every export result (written or inline) carries `sourceFile` (the Figma file it came from) and
838
+ `durationMs {total, request, write}`. When an implicit spill replaces a file whose content differs (the stamps
839
+ `exportedAt`/`generatedAt` aside), the previous one is kept one level as `<file>.prev` and the note names it. A screen
840
+ spill (`figma_export_url`/`figma_export_selection`) keeps ONLY `<screen>.json.prev` (`wrote.prev`) — its `.vars.json`,
841
+ `.assets.json`, `pages/index.json` and the merged `variables.json` are replaced with no copy, so a restored `.prev` is
842
+ paired with the NEW siblings. A page/full/design-system spill keeps one per changed JSON file under `pages/` and
843
+ `design-system/` plus `design-system.json`, listed in `wrote.prevKept`, with the count. `figma_status` reports
844
+ `snapshot` — the export on disk, the verdict `dtwin doctor`'s export line words: `exportedAt`/`ageMs`/`sourceFile`/`file`
845
+ of its NEWEST stamp (whichever source was pulled last — not the design system's by default), `warning` when no source
846
+ carries a usable stamp, `stale` (older than `staleAfterMs`, 24h), `sources[]` (`{kind: "screens"|"design-system"|
847
+ "page-walk"|"screen-doc", sourceFile, count?, exportedAt, ageMs, problem?, file?}` — the screens of each Figma file, `sourceFile:
848
+ null` for rows an older bridge did not stamp) and `problems[]` (files there but unusable); `{file, error, problems}` when
849
+ the only export file there is unusable; null when nothing is exported — plus
850
+ `lastScreenExport` (the newest screen on disk: name, id, file, exportedAt, ageMs) and `lastWrite` (this server process's
851
+ last export: tool, time, whether implicit, file, `prev`, `prevKept` count). `figma_whoami`'s `connection` is the addressed
852
+ client's. `page`, `screens` and `catalogs` arguments accept an array, a JSON-encoded array string or a bare string.
853
+ `design_drift_lint` runs the CLI's own lint run: `screens: string[]` (the CLI's `--screen`) adds `screenCoverage`
854
+ with its warnings, `catalogs: string[]` (the CLI's `--catalog`) adds catalogs the map may point into, both under the
855
+ working directory. A file the CLI would refuse (exit 2: a missing or non-catalog file, a broken `catalogs` entry, a
856
+ non-screen) is a tool error with the CLI's message; an unreadable discovered catalog is a `catalog-unreadable` warning. An interrupted tool call (Claude Code's cancel,
857
+ or the session ending) cancels the in-flight read in the plugin; `figma_write` is never cancelled, since a
858
+ write cannot be un-applied.
859
+
860
+ `outDir` resolves against **the directory the MCP server was started in** — i.e. the project you're
861
+ building, not this repo. Default `FIGMA_EXPORT_DIR` or `design/export/`, the same var `figma_status` reads. The `design_*` tools'
862
+ `exportDir` defaults the same way (`FIGMA_EXPORT_DIR`, else the project's export — `design/export/`, or `design/` on
863
+ the older flat layout), and `design_drift_lint`'s `map` to `design/codeconnect.local.json` (or a root
864
+ `codeconnect.local.json` an older project still has).
865
+ With `writeToDisk: true` or an explicit `outDir` it is checked before the export (inside the project
866
+ lexically or by real path), so a refused path never costs a read; a server started at the filesystem root accepts none.
867
+
868
+ > **One bridge, shared.** Port 8787 has one owner at a time. The MCP server and `dtwin serve` both
869
+ > publish the bridge they own on a local socket, and everything that starts later — a `dtwin` command,
870
+ > a second Claude Code session's MCP server — routes through it instead of binding the port again.
871
+ > When the owner exits, the next MCP call opens the bridge itself (the plugin reconnects on its own).
872
+ > The one holder that does NOT share is a plain one-shot `dtwin pull` with no daemon: while it runs,
873
+ > another `dtwin` command that needs the port exits with `EADDRINUSE`, while an MCP server stays up and
874
+ > only that tool call errors — retry it once the pull is done. `writeToDisk` is still the simplest way
875
+ > to get files from inside an MCP session. On Windows the shared socket is a named pipe guarded by a
876
+ > token handshake (see the `--serve` section); sharing otherwise works the same.
877
+
878
+ **Look before you pull.** `figma_list_pages` (and `figma_list_children` to drill into one frame) return
879
+ a cheap structural index — ids, names, types, sizes; no recursion, no assets, no node properties. Every
880
+ other read here deep-serializes, so without them the only way to learn what a file holds was to export
881
+ all of it: measured 12+ minutes and tens of MB straight into the context window on a real design
882
+ system. Use the index to pick a target, then `figma_export_full({ page: ["<id>"] })` — or
883
+ `skipAssets: true`, which is close to free here since asset bytes are stripped from MCP results anyway.
884
+
885
+ `figma_list_libraries` (optional `client`, like every Figma tool) is a side step, not part of that order: it answers **which design
886
+ libraries** the file draws on — which library owns a token, or what to name in an `--as-library` pull. Its result is deliberately
887
+ compact — libraries, their variable collections, and usage-derived component counts — because a
888
+ discovery call that costs context defeats its own purpose. The same three limits apply as for the CLI
889
+ flag above (usage-derived counts, UI-only library enablement, empty-is-normal), and the tool reports
890
+ them in-result rather than leaving an empty list ambiguous. It is a discovery read (no export) but the
891
+ slowest one — a few seconds up to ~15 s (about 2 s on a file with no libraries enabled): it walks every
892
+ instance and makes one Figma call per enabled library collection. Recommended order for one
893
+ screen: `figma_status` → `figma_list_pages {depth:1}` → `figma_list_children` → `figma_export_url
894
+ {url:"<node id>", writeToDisk:true}`; `figma_export_full({ page: [...] })` only for a whole page, and
895
+ `figma_list_libraries` when you need to know which library owns a token.
896
+
897
+ **Only want the design system?** `figma_export_design_system` skips the page/frame walk (and the
898
+ assets that walk would produce) and returns just variables/styles/components/hygiene — the MCP twin of
899
+ the CLI's `--design-system` flag. Same `writeToDisk`/`outDir` contract as the other export tools; same
900
+ one honest gap, too: on a file that CONSUMES a library this returns that file's own tokens plus only the
901
+ library variables something in it references — never the library's catalog (that is `--as-library`, CLI
902
+ only, run with the library open).
903
+
904
+ ### "Paste a link and ask about it" (`figma_export_url`)
905
+ CLI twin: `dtwin pull --node <id>` (see above) — same underlying `exportNode` op, so a pull from
906
+ either surface lands in the identical shape.
907
+
908
+ Keep Claude Code and the MCP running for the whole session; the plugin stays connected to your
909
+ open file. Then just paste a Figma link and ask — Claude pulls the node the link points to and
910
+ answers from live data, as many times as you like (free, no per-request cap):
911
+ ```
912
+ you: here's the header — https://figma.com/design/KEY/App?node-id=123-456 — what spacing does it use?
913
+ → Claude calls figma_export_url(url); the plugin selects node 123:456 and exports it live.
914
+ ```
915
+ It reads the **live open file** — it can't fetch a link cold. The file the link points to must be
916
+ open in the Figma desktop app with the plugin running. A URL carries `node-id` in dash form
917
+ (`123-456`); the plugin normalises it to Plugin-API colon form (`123:456`) and switches pages /
918
+ scrolls the node into view so you can see what's being read.
919
+
920
+ Pre-approve tools in `.claude/settings.json`:
921
+ ```
922
+ { "permissions": { "allow": ["mcp__designtwin__*"] } }
923
+ ```
924
+
925
+ ## Notes / limits
926
+ - The Figma file must be **open** with the plugin running for either path (never headless).
927
+ - One process owns port 8787 at a time. `figma-mcp` and `dtwin serve` share the bridge they own
928
+ (later `dtwin` commands and MCP servers route through it); only a one-shot pull does not. A `dtwin`
929
+ command that then finds the port taken exits immediately with a clear `EADDRINUSE` message; an MCP
930
+ server stays up and that one tool call errors — retry it after the pull.
931
+ - Windows: the daemon is a named pipe named from the bridge token, and every connection proves the token
932
+ first (`daemon-auth.ts`). A daemon started under an EARLIER token cannot be found or stopped by name —
933
+ `--rotate-token` stops it before rotating, so that only happens to one started with a different
934
+ `FIGMA_BRIDGE_TOKEN`.
935
+ - One WebSocket frame may be at most `FIGMA_BRIDGE_MAX_PAYLOAD_MB` (default 128; clamped just under
936
+ 4096). A value that is not a number above 0 falls back to 128 with a warning — there is no "unlimited".
937
+ An export over it fails with a message quoting the cap in force.
938
+ - Image assets travel as base64 (not JSON int-arrays) — ~3.5–4x smaller on the wire.
939
+ - Profiling: `DTWIN_TIMINGS=1` prints one `[dtwin] timings {…}` JSON line on stderr at the end of a
940
+ `dtwin` command; `DTWIN_TIMINGS=<file>` appends it to that file instead (JSON Lines). It holds the
941
+ connect / export / write split, when each plugin phase (`queued`, `page`, `designSystem`, `assets`, …)
942
+ was first and last heard, the queue wait, the reply's asset count and decoded bytes, and peak RSS —
943
+ counts and times only, never a file, page or layer name, a key or document content. Off by default.
944
+ - v1 sends each response as a single WebSocket frame. If you hit very large pages, add chunking
945
+ (see ARCHITECTURE.md "chunk large payloads").
946
+ - Hidden-graphic reuse (`assetFrom`) compares the node's own rotation/flip and box only, not an
947
+ ancestor's, and not which slot of its parent the icon sits in (two icons of one component in one
948
+ parent variant count as one, even if that variant colours them differently); across screens it depends on pull order (a screen pulled before its twin's screen keeps
949
+ `assetSkipped:"hidden"`, and re-pulling it later changes its JSON with nothing changed in Figma), and
950
+ each `.assets.json` row offers one `reuseKey`. With an old plugin still loaded, the geometry warning
951
+ may name hidden nodes — re-run the plugin.
952
+ - A cross-screen twin comes from any screen JSON on disk, however old: a screen pulled before its icon
953
+ component was redrawn hands its old file to a hidden node of a screen pulled today (the IR carries no
954
+ component version to check). Re-pull every screen in a sync.
955
+ - `list children` titles a colliding row from its "Page Title" slot; when that search runs past its node
956
+ cap (a very large frame), the row's `title` falls back to the first text — which can differ from the
957
+ title the pages index gives the same screen after a pull. `distinctTexts` reads the same way, with a
958
+ budget of 20000 node reads for the whole listing (a group with a row it cuts off gets none — see the warning).
959
+ - Write ops are a deliberately small, explicit set — extend `applyWrite()` in
960
+ `../figma-plugin/src/writes.ts` (rebuild `code.js`) and add a matching `registerTool` here as needed.