@design-twin/dtwin 0.1.0-alpha.0 → 0.1.1

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.
@@ -0,0 +1,722 @@
1
+ <!-- GENERATED from ui.template.html + src/relay.ts by build.ts — do not edit by hand. Run `bun run build`. -->
2
+ <style>
3
+ /* Colors come from Figma's own theme variables (injected because main.ts passes themeColors:true),
4
+ so the window follows the user's light/dark setting. Every var() carries the old light-theme
5
+ literal as its fallback — if the variables are ever absent the UI still renders, just light. */
6
+ body { font: 13px -apple-system, sans-serif; margin: 0; padding: 14px;
7
+ color: var(--figma-color-text, #222); background: var(--figma-color-bg, #fff); }
8
+ #status { margin-bottom: 12px; color: var(--figma-color-text-secondary, #555); min-height: 18px; }
9
+ #status.err { color: var(--figma-color-text-danger, #c0362c); }
10
+ button { display: block; width: 100%; margin: 6px 0; padding: 8px; border: 1px solid var(--figma-color-border-strong, #ccc);
11
+ border-radius: 6px; background: var(--figma-color-bg, #fff); color: var(--figma-color-text, #222);
12
+ cursor: pointer; font-size: 13px; }
13
+ button:disabled { opacity: 0.4; cursor: default; }
14
+ button:not(:disabled):hover { background: var(--figma-color-bg-hover, #f4f4f4); }
15
+ .primary { background: var(--figma-color-bg-brand, #0d99ff); color: var(--figma-color-text-onbrand, #fff);
16
+ border-color: var(--figma-color-bg-brand, #0d99ff); font-weight: 600; }
17
+ .primary:not(:disabled):hover { background: var(--figma-color-bg-brand-hover, #0a86e0); }
18
+ /* Keyboard focus must be visible in both themes — the browser default ring is near-invisible on the
19
+ dark background. */
20
+ button:focus-visible, input:focus-visible, summary:focus-visible {
21
+ outline: 2px solid var(--figma-color-border-selected, #0d99ff); outline-offset: 1px; }
22
+ .sep { height: 1px; background: var(--figma-color-border, #eee); margin: 12px 0; }
23
+ .label { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: var(--figma-color-text-tertiary, #999); margin: 4px 0; }
24
+ label.label { display: block; }
25
+ .hint { margin-top: 12px; font-size: 11px; color: var(--figma-color-text-tertiary, #999); line-height: 1.45; }
26
+ .hint code, #bridge code { font-size: 11px; color: var(--figma-color-text-secondary, #555); }
27
+ .row { display: flex; gap: 6px; }
28
+ .row button { margin: 6px 0; }
29
+ input { flex: 1; box-sizing: border-box; padding: 8px; border: 1px solid var(--figma-color-border-strong, #ccc); border-radius: 6px;
30
+ font-size: 13px; color: var(--figma-color-text, #222); background: var(--figma-color-bg, #fff); }
31
+ input::placeholder { color: var(--figma-color-text-tertiary, #999); }
32
+ /* The connection section is collapsed by default: exporting by hand needs none of it, and a designer
33
+ who never opens a terminal should not be greeted by "bridge token". Its one-line state stays
34
+ visible in the summary either way. */
35
+ details#connect > summary { cursor: pointer; list-style: none; display: flex; align-items: center; gap: 6px;
36
+ font-size: 12px; color: var(--figma-color-text-secondary, #555); padding: 2px 0; border-radius: 4px; }
37
+ details#connect > summary::-webkit-details-marker { display: none; }
38
+ details#connect > summary::before { content: "▸"; font-size: 10px; color: var(--figma-color-text-tertiary, #999); }
39
+ details#connect[open] > summary::before { content: "▾"; }
40
+ #pill { margin-left: auto; font-size: 11px; padding: 1px 7px; border-radius: 9px;
41
+ background: var(--figma-color-bg-secondary, #f6f6f6); color: var(--figma-color-text-tertiary, #888); }
42
+ #pill.on { background: var(--figma-color-bg-success-tertiary, #e7f7ec); color: var(--figma-color-text-success, #1a7f3c); }
43
+ #pill.bad { background: var(--figma-color-bg-danger-tertiary, #fdecea); color: var(--figma-color-text-danger, #9b1c12); }
44
+ #bridge { font-size: 11px; margin-top: 10px; padding: 6px 8px; border-radius: 6px; line-height: 1.45;
45
+ background: var(--figma-color-bg-secondary, #f6f6f6); color: var(--figma-color-text-secondary, #888); }
46
+ #bridge.on { background: var(--figma-color-bg-success-tertiary, #e7f7ec); color: var(--figma-color-text-success, #1a7f3c); }
47
+ #bridge.bad { background: var(--figma-color-bg-danger-tertiary, #fdecea); color: var(--figma-color-text-danger, #9b1c12); }
48
+ /* `button { display: block }` above beats a bare [hidden], so the hide rule has to win explicitly —
49
+ otherwise the Cancel button is permanently visible. */
50
+ [hidden] { display: none !important; }
51
+ /* A PERSISTENT failure notice, deliberately not the #status line: status is overwritten by the very
52
+ next selection change or export, and "the browser delivered 0 of your 400 files" is precisely the
53
+ message that must still be on screen when you come back to the window. */
54
+ #banner { margin-bottom: 12px; padding: 8px; border: 1px solid var(--figma-color-border-danger, #f0b4ae); border-radius: 6px;
55
+ background: var(--figma-color-bg-danger-tertiary, #fdecea); color: var(--figma-color-text-danger, #9b1c12);
56
+ font-size: 12px; line-height: 1.4; }
57
+ #progress { height: 6px; margin-bottom: 12px; border-radius: 3px; background: var(--figma-color-bg-tertiary, #eee); overflow: hidden; }
58
+ #bar { height: 100%; width: 0; background: var(--figma-color-bg-brand, #0d99ff); }
59
+ /* Phases with no page count (the design-system catalog, per-asset ticks inside one huge frame) still
60
+ have to look ALIVE — a bar frozen at 0% reads as a hang. */
61
+ #progress.indet #bar { width: 30%; animation: indet 1.2s linear infinite; }
62
+ @keyframes indet { from { margin-left: -30%; } to { margin-left: 100%; } }
63
+ /* "Allow edits": always visible (never inside the collapsed connection section), because it is the one
64
+ control an agent will ask the designer to find. */
65
+ #edits-row { display: flex; align-items: center; gap: 8px; font-size: 13px; cursor: pointer; margin: 6px 0; }
66
+ #edits-row input { flex: 0 0 auto; width: 14px; height: 14px; margin: 0; padding: 0; }
67
+ #edits-state { margin-left: auto; font-size: 11px; padding: 1px 7px; border-radius: 9px;
68
+ background: var(--figma-color-bg-secondary, #f6f6f6); color: var(--figma-color-text-tertiary, #888); }
69
+ #edits-state.on { background: var(--figma-color-bg-warning-tertiary, #fff4e0); color: var(--figma-color-text-warning, #8a5a00); }
70
+ #cancel { border-color: var(--figma-color-border-danger-strong, #c0362c); color: var(--figma-color-text-danger, #c0362c); }
71
+ </style>
72
+
73
+ <!-- role=status / role=alert: a screen-reader user otherwise never hears that a long export finished
74
+ or that a download batch failed. -->
75
+ <div id="status" role="status" aria-live="polite">Loading…</div>
76
+ <div id="banner" role="alert" hidden></div>
77
+ <div id="progress" hidden><div id="bar"></div></div>
78
+
79
+ <div class="label">Export</div>
80
+ <button id="run-sel" disabled>Export current selection</button>
81
+ <button id="run-full" class="primary">Export design system + all page frames</button>
82
+ <div class="hint" style="margin-top:6px">Exports the file's design system and all frames on the current page. On a large page this can take several minutes. You can cancel it.</div>
83
+ <!-- Shown only while a run is walking the file — INCLUDING one a CLI/MCP client started, which the
84
+ designer never clicked for and would otherwise have no way to stop. -->
85
+ <button id="cancel" hidden>Cancel export</button>
86
+
87
+ <div class="sep"></div>
88
+ <div class="label">Downloads</div>
89
+ <div id="downloads"></div>
90
+ <button id="layers" disabled>Download layers (<span id="lcount">0</span>)</button>
91
+ <button id="assets" disabled>Download assets (<span id="acount">0</span>)</button>
92
+
93
+ <div class="hint">Save the downloads into your project's <code>design/</code> folder (assets into <code>design/assets/</code>).</div>
94
+
95
+ <div class="sep"></div>
96
+ <div class="label">Edits</div>
97
+ <!-- Off by default. While it is off the plugin only READS this file: every edit request from the bridge is
98
+ refused before anything changes (figma-plugin/src/edits.ts is the authority, not this checkbox). -->
99
+ <label id="edits-row" for="allow-edits">
100
+ <input id="allow-edits" type="checkbox" disabled />
101
+ <span>Allow edits</span>
102
+ <span id="edits-state">off</span>
103
+ </label>
104
+ <div class="hint" id="edits-hint" style="margin-top:2px">Lets Claude Code change this file over the bridge (add frames and text, set fills and text). Off: it can only read. Every change is announced here.</div>
105
+
106
+ <div class="sep"></div>
107
+ <!-- Optional. Closed by default; opened automatically only when it needs the user (a rejected token). -->
108
+ <details id="connect">
109
+ <summary>Connect to Claude Code (optional) <span id="pill">not set up</span></summary>
110
+ <label class="label" for="token" style="margin-top: 10px">Bridge token</label>
111
+ <div class="row">
112
+ <input id="token" type="password" autocomplete="off" spellcheck="false" placeholder="paste the token here" />
113
+ <button id="save-token" style="flex: 0 0 60px">Save</button>
114
+ </div>
115
+ <div id="bridge" role="status" aria-live="polite">Not set up.</div>
116
+ <div class="hint">Lets the <code>dtwin</code> command line or the Design Twin MCP server pull this file directly, instead of you downloading it. It runs on your own computer only (localhost) — nothing is sent over the internet. Get the token by running <code>dtwin token show</code> in a terminal — or ask whoever set this up to send it to you. You paste it once.</div>
117
+ </details>
118
+
119
+ <script>
120
+ var DesignTwinRelay = (() => {
121
+ var __defProp = Object.defineProperty;
122
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
123
+ var __getOwnPropNames = Object.getOwnPropertyNames;
124
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
125
+ var __export = (target, all) => {
126
+ for (var name in all)
127
+ __defProp(target, name, { get: all[name], enumerable: true });
128
+ };
129
+ var __copyProps = (to, from, except, desc) => {
130
+ if (from && typeof from === "object" || typeof from === "function") {
131
+ for (let key of __getOwnPropNames(from))
132
+ if (!__hasOwnProp.call(to, key) && key !== except)
133
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
134
+ }
135
+ return to;
136
+ };
137
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
138
+
139
+ // src/relay.ts
140
+ var relay_exports = {};
141
+ __export(relay_exports, {
142
+ createRelay: () => createRelay,
143
+ helloFrame: () => helloFrame,
144
+ mainMessageFor: () => mainMessageFor,
145
+ progressFrame: () => progressFrame,
146
+ replyFrame: () => replyFrame
147
+ });
148
+
149
+ // ../bridge/src/json-util.ts
150
+ function isRecord(x) {
151
+ return typeof x === "object" && x !== null && !Array.isArray(x);
152
+ }
153
+ function ifDefined(key, v) {
154
+ const o = {};
155
+ if (v !== void 0) o[key] = v;
156
+ return o;
157
+ }
158
+
159
+ // src/relay.ts
160
+ var idStr = (idn, k) => {
161
+ const v = idn[k];
162
+ return typeof v === "string" && v ? v : null;
163
+ };
164
+ function helloFrame(identity) {
165
+ const idn = isRecord(identity) ? identity : {};
166
+ return {
167
+ type: "hello",
168
+ instanceId: idStr(idn, "instanceId"),
169
+ file: idStr(idn, "file"),
170
+ // Only a readable key: a plugin without the private API reports fileKeyAvailable:false (and fileKey null).
171
+ fileKey: idn.fileKeyAvailable === true ? idStr(idn, "fileKey") : null,
172
+ page: idStr(idn, "page"),
173
+ // The plugin's build version (build-options.ts), so the bridge can tell a stale plugin from a reloaded one.
174
+ pluginVersion: idStr(idn, "pluginVersion")
175
+ };
176
+ }
177
+ function replyFrame(m) {
178
+ var _a;
179
+ if (m.id === void 0) return null;
180
+ return m.ok ? { id: m.id, ok: true, result: m.result } : { id: m.id, ok: false, error: (_a = m.error) != null ? _a : "" };
181
+ }
182
+ function progressFrame(m) {
183
+ if (m.type === "run-begin") return m.source === "bridge" ? { type: "progress", phase: "start", ...ifDefined("requestId", m.requestId) } : null;
184
+ if (m.type !== "progress" || m.source !== "bridge") return null;
185
+ return {
186
+ type: "progress",
187
+ phase: m.phase,
188
+ ..."requestId" in m ? ifDefined("requestId", m.requestId) : {},
189
+ ..."page" in m ? ifDefined("page", m.page) : {},
190
+ ..."nodes" in m ? ifDefined("nodes", m.nodes) : {},
191
+ ..."assets" in m ? ifDefined("assets", m.assets) : {}
192
+ };
193
+ }
194
+ function mainMessageFor(data) {
195
+ let f;
196
+ try {
197
+ f = JSON.parse(String(data));
198
+ } catch {
199
+ return null;
200
+ }
201
+ if (!isRecord(f)) return null;
202
+ if (f.type === "cancel" && f.cmd === void 0) return typeof f.id === "string" ? { type: "cancel", id: f.id } : null;
203
+ return { type: "bridge", ...ifDefined("id", typeof f.id === "string" ? f.id : void 0), cmd: typeof f.cmd === "string" ? f.cmd : String(f.cmd), args: f.args };
204
+ }
205
+ function createRelay(ports) {
206
+ return {
207
+ fromMain(m) {
208
+ if (m.type === "identity") {
209
+ ports.toSocket(helloFrame(m.identity));
210
+ return;
211
+ }
212
+ if (m.type === "bridge-result") {
213
+ const r = replyFrame(m);
214
+ if (r) ports.toSocket(r);
215
+ return;
216
+ }
217
+ if (m.type === "run-begin" || m.type === "progress") {
218
+ const p = progressFrame(m);
219
+ if (p) ports.toSocket(p);
220
+ }
221
+ },
222
+ fromSocket(data) {
223
+ const msg = mainMessageFor(data);
224
+ if (msg) ports.toMain(msg);
225
+ },
226
+ // Announce which FILE this is on every open, reconnects included: the bridge accepts several plugin instances
227
+ // at once (one per open file) and routes by identity. Only the main thread can read it (figma.* lives there).
228
+ // Never cached: if Figma restarts the plugin runtime the instanceId changes, and re-reading it on each open is
229
+ // what keeps the bridge's listing honest.
230
+ socketOpened() {
231
+ ports.toMain({ type: "get-identity" });
232
+ },
233
+ // The socket that asked for any bridge work is gone, and a reply carrying an id nobody on the next bridge
234
+ // knows is dropped — so finishing a bridge run is pure waste that queues every later command behind it.
235
+ // Abandon them all (UI-started runs are untouched).
236
+ socketClosed() {
237
+ ports.toMain({ type: "cancel", scope: "bridge" });
238
+ }
239
+ };
240
+ }
241
+ return __toCommonJS(relay_exports);
242
+ })();
243
+ // Everything is wrapped so a single init error can NEVER leave the UI silently frozen:
244
+ // - window.onerror surfaces any uncaught error as visible text in #status (no DevTools needed).
245
+ // - window.onmessage is registered immediately (catches early messages from code.js).
246
+ // - DOM-dependent wiring (button handlers, get-token) runs on DOMContentLoaded, never before the
247
+ // elements exist — the classic "script ran before the buttons" freeze.
248
+ (function () {
249
+ // The ports the bridge may listen on, in the order we try them. This list is NOT free-form: a
250
+ // published plugin can only open sockets to ports named in manifest.json's `allowedDomains`, and
251
+ // match patterns take no port wildcard, so every port here must also be listed there (and vice
252
+ // versa). Adding a fourth port means shipping a new plugin VERSION through review — which is why
253
+ // FIGMA_BRIDGE_PORT can no longer be an arbitrary number; see bridge/server-core.js.
254
+ var BRIDGE_PORTS = [8787, 8788, 8789];
255
+ var portIdx = 0; // which one the NEXT connect() attempt uses; advances on every failure
256
+ var assetsState = [];
257
+ var layerFilesState = [];
258
+ // A batch runs for items.length * 200ms — 80s for 400 assets — with the button still on screen.
259
+ // Two clicks used to start two interleaved batches writing the same status line and doubling the
260
+ // download rate back into the throttle the staggering exists to clear. One at a time.
261
+ var batchRunning = false;
262
+ var ws = null;
263
+ var bridgeToken = "";
264
+ var busy = false; // an export is in flight — block a second run (the main thread serializes too)
265
+ var lastSelCount = 0; // last-known selection count, so we restore run-sel state after a run ends
266
+ // The run currently walking the file ({source,label}), or null. Driven by run-begin/run-end from
267
+ // the main thread rather than by our own button clicks, so a BRIDGE-triggered export — which no
268
+ // one clicked here — puts the window into exactly the same visible state.
269
+ var running = null;
270
+ var cancelSent = false; // we asked to stop; don't let progress frames overwrite "Cancelling…"
271
+ // The bridge protocol's plugin half (figma-plugin/src/relay.ts, bundled in above by build.ts): it turns
272
+ // main-thread messages into socket frames and socket frames into main-thread messages. This window only
273
+ // hands it the socket (a frame for a socket that is not open is dropped) and the main thread.
274
+ var relay = DesignTwinRelay.createRelay({
275
+ toSocket: function (frame) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(frame)); },
276
+ toMain: function (msg) { parent.postMessage({ pluginMessage: msg }, "*"); }
277
+ });
278
+
279
+ function $(id) { return document.getElementById(id); }
280
+ // A batch button is enabled exactly when it has files AND no batch is running. DERIVED from that
281
+ // state every time, never snapshotted-and-restored: a saved `disabled` flag goes stale the moment
282
+ // a new export lands mid-batch, and restoring it would re-disable a button that now has files (or
283
+ // enable one that no longer does). Same reason the manifest derives `skipped` instead of counting.
284
+ function syncBatchButtons() {
285
+ var lb = $("layers"); if (lb) lb.disabled = batchRunning || layerFilesState.length === 0;
286
+ var ab = $("assets"); if (ab) ab.disabled = batchRunning || assetsState.length === 0;
287
+ }
288
+ // Thrown value -> message string. Byte-for-byte the body of bridge/errmsg.js, restated here only
289
+ // because a sandboxed iframe cannot import the bundle — one copy in this file, not one per catch.
290
+ function errText(e) { return typeof e === "string" ? e : String((e && e.message) || e); }
291
+ function setStatus(text, isErr) {
292
+ var s = $("status");
293
+ if (!s) return;
294
+ s.textContent = text;
295
+ s.className = isErr ? "err" : "";
296
+ }
297
+ function setBusy(b) {
298
+ busy = b;
299
+ var rf = $("run-full"); if (rf) rf.disabled = b;
300
+ var rs = $("run-sel"); if (rs) rs.disabled = b || lastSelCount === 0;
301
+ }
302
+ // A persistent red notice. Pass "" to clear it — only a NEW export or a new batch does that, never
303
+ // an ordinary status update.
304
+ function setBanner(text) {
305
+ var b = $("banner");
306
+ if (!b) return;
307
+ b.textContent = text || "";
308
+ b.hidden = !text;
309
+ }
310
+ // 1204 -> "1,204". A node count is the main number on the progress line and unseparated six-digit
311
+ // counts are unreadable at a glance.
312
+ function fmt(n) { return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ","); }
313
+ // `frac` null => indeterminate (a phase with no page count). The bar is never hidden mid-run, so
314
+ // "no page info yet" must still look like motion rather than a stall.
315
+ function setProgress(frac) {
316
+ var p = $("progress"), bar = $("bar");
317
+ if (!p || !bar) return;
318
+ if (frac == null) { p.className = "indet"; bar.style.width = ""; }
319
+ else { p.className = ""; bar.style.width = Math.max(0, Math.min(100, Math.round(frac * 100))) + "%"; }
320
+ }
321
+ function runBegin(info) {
322
+ running = info;
323
+ cancelSent = false;
324
+ var c = $("cancel"); if (c) { c.hidden = false; c.disabled = false; c.textContent = "Cancel export"; }
325
+ var p = $("progress"); if (p) p.hidden = false;
326
+ setProgress(null);
327
+ setBusy(true);
328
+ setStatus(info.source !== "bridge" ? "Exporting…"
329
+ : info.label === "write" ? "Applying edits requested over the bridge…"
330
+ : "Export in progress — requested over the bridge (" + info.label + ")…");
331
+ }
332
+ // Tear the run chrome down. Deliberately does NOT clear `cancelSent`: the main thread sends
333
+ // run-end BEFORE the error that the cancellation produced, and both need to know it was a cancel.
334
+ function runEnd() {
335
+ running = null;
336
+ var c = $("cancel"); if (c) c.hidden = true;
337
+ var p = $("progress"); if (p) { p.hidden = true; p.className = ""; }
338
+ setBusy(false);
339
+ }
340
+
341
+ // Any uncaught UI-script error becomes visible text in the plugin window.
342
+ window.onerror = function (msg, src, line) {
343
+ setStatus("UI error: " + msg + (line ? " (line " + line + ")" : ""), true);
344
+ return false;
345
+ };
346
+
347
+ function b64ToBytes(b64) {
348
+ var bin = atob(b64 || "");
349
+ var arr = new Uint8Array(bin.length);
350
+ for (var i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
351
+ return arr;
352
+ }
353
+ function dl(name, blob) {
354
+ var a = document.createElement("a");
355
+ var url = URL.createObjectURL(blob);
356
+ a.href = url;
357
+ a.download = name;
358
+ a.click();
359
+ // Release the blob once the browser has started the download — otherwise every export leaks
360
+ // the (multi-MB) object URL for the plugin window's lifetime.
361
+ setTimeout(function () { URL.revokeObjectURL(url); }, 10000);
362
+ }
363
+ function send(type) {
364
+ setStatus("Exporting…");
365
+ parent.postMessage({ pluginMessage: { type: type } }, "*");
366
+ }
367
+
368
+ // ---- messages FROM the Figma main thread (code.js). Registered immediately. ----
369
+ window.onmessage = function (e) {
370
+ var m = e.data && e.data.pluginMessage;
371
+ if (!m) return;
372
+ relay.fromMain(m); // what the bridge must hear of it (hello, replies, a bridge run's progress); the DOM's below
373
+
374
+ if (m.type === "selection") {
375
+ lastSelCount = m.count;
376
+ var rs = $("run-sel");
377
+ if (rs && !busy) rs.disabled = m.count === 0;
378
+ // Figma fires selectionchange freely while an export runs (and collectNode selects the node it
379
+ // exported), so without this guard a selection event wipes the progress line mid-walk.
380
+ if (running) return;
381
+ setStatus(
382
+ m.count === 0
383
+ ? "Select a frame for single-screen export, or use the blue button for the whole page."
384
+ : m.count + " selected" + (m.name ? ' — "' + m.name + '"' : "") + "."
385
+ );
386
+ return;
387
+ }
388
+ // ---- run lifecycle + progress (both manual and bridge-triggered runs) ----
389
+ if (m.type === "run-begin") {
390
+ runBegin(m);
391
+ return;
392
+ }
393
+ if (m.type === "run-end") {
394
+ var wasCancelled = cancelSent;
395
+ runEnd();
396
+ // Bridge-triggered runs never produce a `files`/`error` message here, so this is the ONLY
397
+ // place a designer learns their cancel took effect on one.
398
+ if (wasCancelled) setStatus("Export cancelled — nothing was delivered.", true);
399
+ // `abandoned` is set only when the run died because the BRIDGE gave up on the request that
400
+ // asked for it (its caller timed out, stalled out, or disconnected) — no one here clicked
401
+ // anything, so the button's sentence would be a lie. The designer's own cancel wins if both.
402
+ else if (m.abandoned) setStatus("Export cancelled — the caller went away.", true);
403
+ return;
404
+ }
405
+ if (m.type === "progress") {
406
+ // `queued`: a bridge run waiting behind the run that is executing (state.ts scheduleRun). It
407
+ // has not begun, so it is relayed (relay.ts) and nothing here changes — the window keeps showing
408
+ // the run that IS walking the file, or stays idle. A `start` here is a scene read that waited
409
+ // and has now begun: not a run of the window's own (no run-begin), so likewise relayed only.
410
+ if (m.phase === "queued" || m.phase === "start") return;
411
+ if (!running) runBegin(m); // a frame that outran its run-begin — never show progress with no chrome
412
+ if (cancelSent) return; // "Cancelling…" must not be overwritten by in-flight frames
413
+ var pg = m.page;
414
+ setProgress(pg && pg.of ? pg.index / pg.of : null);
415
+ var bits = [];
416
+ if (typeof m.nodes === "number" && m.nodes) bits.push(fmt(m.nodes) + " nodes");
417
+ if (typeof m.assets === "number" && m.assets) bits.push(fmt(m.assets) + " assets");
418
+ var detail = bits.length ? " (" + bits.join(", ") + ")" : "";
419
+ var who = m.source === "bridge" ? " [bridge: " + m.label + "]" : "";
420
+ var line;
421
+ if (m.phase === "design-system") line = "Building the design system" + (pg ? " — page " + pg.index + " of " + pg.of + ", " + pg.name : "") + detail + "…";
422
+ else if (pg) line = "Exporting page " + pg.index + " of " + pg.of + " — " + pg.name + detail + "…";
423
+ else line = "Exporting" + detail + "…";
424
+ setStatus(line + who);
425
+ return;
426
+ }
427
+ if (m.type === "cancel-ack") {
428
+ // Refused (nothing was running) — say so and put the window back, rather than leaving a
429
+ // "Cancelling…" that will never resolve.
430
+ if (!m.accepted) { cancelSent = false; runEnd(); setStatus("Nothing to cancel — the export had already finished."); }
431
+ return;
432
+ }
433
+ if (m.type === "error") {
434
+ var errWasCancel = cancelSent;
435
+ runEnd();
436
+ setStatus(errWasCancel ? "Export cancelled — nothing was delivered." : m.message, true);
437
+ return;
438
+ }
439
+ if (m.type === "files") {
440
+ runEnd();
441
+ setBanner(""); // a fresh successful export clears the previous batch's failure notice
442
+ // The warnings COUNT, surfaced next to the summary. The list itself is in the export's own
443
+ // manifest; showing only `summary` meant a run with 40 unexported icons read as a clean one.
444
+ setStatus("Ready: " + m.summary
445
+ + (m.warnings ? " — " + fmt(m.warnings) + " warning" + (m.warnings === 1 ? "" : "s") + ", see manifest.warnings in the export" : ""));
446
+ var wrap = $("downloads");
447
+ if (wrap) {
448
+ wrap.innerHTML = "";
449
+ m.files.forEach(function (f) {
450
+ var row = document.createElement("div");
451
+ row.className = "row";
452
+ var d = document.createElement("button");
453
+ d.textContent = "Download " + f.name;
454
+ d.onclick = function () { dl(f.name, new Blob([f.content], { type: "application/json" })); };
455
+ row.appendChild(d);
456
+ if (f.copyable) {
457
+ var c = document.createElement("button");
458
+ c.textContent = "Copy";
459
+ c.style.flex = "0 0 70px";
460
+ c.onclick = function () { navigator.clipboard.writeText(f.content); };
461
+ row.appendChild(c);
462
+ }
463
+ wrap.appendChild(row);
464
+ });
465
+ }
466
+ assetsState = m.assets || [];
467
+ var ac = $("acount"); if (ac) ac.textContent = assetsState.length;
468
+ layerFilesState = m.layerFiles || [];
469
+ var lc = $("lcount"); if (lc) lc.textContent = layerFilesState.length;
470
+ syncBatchButtons();
471
+ return;
472
+ }
473
+ if (m.type === "allow-edits") {
474
+ // The main thread's answer is the truth (edits.ts): the checkbox only ever shows what it holds.
475
+ var ae = $("allow-edits"); if (ae) { ae.checked = !!m.allowed; ae.disabled = false; }
476
+ var es = $("edits-state"); if (es) { es.textContent = m.allowed ? "on" : "off"; es.className = m.allowed ? "on" : ""; }
477
+ return;
478
+ }
479
+ if (m.type === "writes") {
480
+ // A write batch changed the file (or was refused): never silent. It arrives inside the write's own run
481
+ // (run-begin … run-end, and run-end leaves the status alone), so it replaces that run's line.
482
+ setStatus(m.text, !m.ok);
483
+ return;
484
+ }
485
+ if (m.type === "token") {
486
+ bridgeToken = m.token || "";
487
+ var t = $("token"); if (t) t.value = bridgeToken;
488
+ connect();
489
+ return;
490
+ }
491
+ if (m.type === "identity") {
492
+ // The main thread answered "who am I" — relay.ts has sent it to the bridge as the `hello`.
493
+ var idn = m.identity || {};
494
+ if (idn.file) setBridge("on", "connected", "Connected to the bridge on this computer as " + JSON.stringify(idn.file) + ".");
495
+ return;
496
+ }
497
+ };
498
+
499
+ // ---- the localhost bridge (WebSocket client) ----
500
+ // One connection state, shown twice: a short `pill` in the (usually collapsed) section header and
501
+ // a full sentence inside it. `kind` is "on" | "bad" | "" (neutral). "bad" is reserved for states
502
+ // the USER has to fix — a bridge that simply isn't running right now is neutral, not an error:
503
+ // one-shot `dtwin` commands start and stop a bridge on every run, so "nothing listening" is the
504
+ // normal resting state between pulls.
505
+ function setBridge(kind, pill, text) {
506
+ var el = $("bridge"), p = $("pill");
507
+ if (el) { el.className = kind; el.textContent = text; }
508
+ if (p) { p.className = kind; p.textContent = pill; }
509
+ }
510
+ // Application close code the bridge uses for "the handshake was fine but the TOKEN is wrong"
511
+ // (bridge/server-core.js). A browser WebSocket can never read an HTTP 401 — a refused handshake is
512
+ // an opaque 1006, identical to "nothing is listening" — so the bridge admits a plugin with a bad
513
+ // token just long enough to say so with a code we CAN read.
514
+ var CLOSE_BAD_TOKEN = 4401;
515
+ var everOpened = false; // has ANY socket opened since the token was last saved?
516
+ var failsSinceOpen = 0; // consecutive failed attempts; >= ports.length means "tried them all"
517
+ // The last answer was "wrong token". Held until a socket opens or a new token is saved, so the
518
+ // background retry cannot replace the one instruction the user needs with "connecting…".
519
+ var tokenRejected = false;
520
+ // Reconnect discipline. Every handler below is guarded by `s === ws` so only the CURRENT socket
521
+ // can drive state, and reconnects go through a single shared timer. Without both, each retired
522
+ // socket kept its own live reconnect chain: saving the token closed a socket AND called connect()
523
+ // immediately, then a redial later the closed socket's onclose connected again — so sockets multiplied
524
+ // with every save (and with every stale-socket terminate() from the bridge).
525
+ var reconnectTimer = null;
526
+ // The redial delay, one port per attempt, so a window that keeps missing walks all of BRIDGE_PORTS
527
+ // in 3 s. A one-shot dtwin command opens its bridge at any moment and waits for this, so it is short;
528
+ // a refused dial to localhost costs next to nothing. The bridge reads it as timeouts.ts
529
+ // PLUGIN_REDIAL_MS (its settle window is built from it) — change both together.
530
+ var REDIAL_MS = 1000;
531
+ function scheduleReconnect(ms) {
532
+ if (reconnectTimer) return; // one pending reconnect at a time, ever
533
+ reconnectTimer = setTimeout(function () { reconnectTimer = null; connect(); }, ms || REDIAL_MS);
534
+ }
535
+ // Nothing answered. Which sentence depends on history: a bridge that WAS here and went away is the
536
+ // ordinary end of a one-shot `dtwin` pull; one that was never found (after every allowed port has
537
+ // been tried) more likely was never started.
538
+ function showOffline() {
539
+ if (everOpened) {
540
+ setBridge("", "waiting", "Disconnected — the last dtwin command finished, or Claude Code closed. Nothing to do: this reconnects by itself the next time one starts.");
541
+ } else if (failsSinceOpen >= BRIDGE_PORTS.length) {
542
+ setBridge("", "not running", "No bridge found on this computer (tried ports " + BRIDGE_PORTS.join(", ") + "). Start one — run a dtwin command such as dtwin --serve in a terminal, or open Claude Code in a project with the Design Twin MCP server enabled. This keeps retrying.");
543
+ }
544
+ }
545
+ function dropSocket() {
546
+ if (!ws) return;
547
+ var old = ws;
548
+ ws = null;
549
+ old.onopen = old.onclose = old.onerror = old.onmessage = null; // retire it silently
550
+ try { old.close(); } catch (e) {}
551
+ }
552
+ function connect() {
553
+ if (!bridgeToken) { setBridge("", "not set up", "Not set up. Paste the bridge token above and press Save."); return; }
554
+ if (ws && (ws.readyState === 0 || ws.readyState === 1)) return; // already CONNECTING/OPEN
555
+ // Try each allowed port in turn rather than only 8787. A bridge that lost the race for 8787 to
556
+ // some unrelated local process is otherwise invisible to the plugin, and the user has no way to
557
+ // point it elsewhere — the manifest, not a setting, decides what we may dial.
558
+ var port = BRIDGE_PORTS[portIdx % BRIDGE_PORTS.length];
559
+ portIdx++;
560
+ var s;
561
+ try {
562
+ s = new WebSocket("ws://localhost:" + port + "/?token=" + encodeURIComponent(bridgeToken));
563
+ } catch (e) { failsSinceOpen++; showOffline(); scheduleReconnect(); return; }
564
+ ws = s;
565
+ if (!tokenRejected && !everOpened && failsSinceOpen < BRIDGE_PORTS.length) setBridge("", "connecting…", "Looking for the bridge on this computer…");
566
+ s.onopen = function () {
567
+ if (s !== ws) return;
568
+ // Stick to whatever answered: the next reconnect should retry the LIVE bridge's port first,
569
+ // not resume walking the ladder past it.
570
+ portIdx = BRIDGE_PORTS.indexOf(port);
571
+ everOpened = true;
572
+ tokenRejected = false;
573
+ failsSinceOpen = 0;
574
+ setBridge("on", "connected", "Connected to the bridge on this computer (localhost:" + port + ").");
575
+ relay.socketOpened(); // asks the main thread who this file is; its answer goes out as the `hello`
576
+ };
577
+ s.onclose = function (ev) {
578
+ if (s !== ws) return;
579
+ ws = null;
580
+ // Every bridge run is abandoned (relay.ts) — also on CLOSE_BAD_TOKEN: nothing can collect the result
581
+ // there either. Not on onerror — it closes the socket, and this handler follows.
582
+ relay.socketClosed();
583
+ if (ev && ev.code === CLOSE_BAD_TOKEN) {
584
+ // A bridge IS there — stay on its port instead of walking on to the next one — and this is
585
+ // the one state only the user can fix, so open the section and say exactly what to do. Retry
586
+ // slowly: the same token will be refused again until it is re-pasted (Save reconnects at once).
587
+ portIdx = BRIDGE_PORTS.indexOf(port);
588
+ failsSinceOpen = 0;
589
+ tokenRejected = true;
590
+ setBridge("bad", "token rejected", "The bridge is running, but it rejected this token — it was probably replaced (dtwin --rotate-token) or this is a different computer's token. Run dtwin --show-token in a terminal, paste the result above and press Save.");
591
+ var d = $("connect"); if (d) d.open = true;
592
+ scheduleReconnect(15000);
593
+ return;
594
+ }
595
+ tokenRejected = false; // that bridge is gone; whatever answers next gets a fresh diagnosis
596
+ failsSinceOpen++;
597
+ showOffline();
598
+ scheduleReconnect();
599
+ };
600
+ s.onerror = function () { try { s.close(); } catch (e) {} };
601
+ s.onmessage = function (ev) {
602
+ if (s !== ws) return;
603
+ relay.fromSocket(ev.data); // a command or a cancel, for the main thread (relay.ts)
604
+ };
605
+ }
606
+
607
+ // ---- DOM-dependent wiring — runs only once the elements exist ----
608
+ function init() {
609
+ try {
610
+ var rs = $("run-sel"); if (rs) rs.onclick = function () { if (busy) return; setBusy(true); setStatus("Exporting…"); send("run-selection"); };
611
+ var rf = $("run-full"); if (rf) rf.onclick = function () { if (busy) return; setBusy(true); setStatus("Exporting…"); send("run-full"); };
612
+ var cb = $("cancel"); if (cb) cb.onclick = function () {
613
+ if (!running || cancelSent) return;
614
+ cancelSent = true;
615
+ cb.disabled = true;
616
+ cb.textContent = "Cancelling…";
617
+ // Honest about the latency: the walk only aborts at its safe points (each node, between
618
+ // pages and top-level frames, at each asset export), so the last exportAsync still finishes.
619
+ setStatus("Cancelling — the export stops at its next safe point…");
620
+ parent.postMessage({ pluginMessage: { type: "cancel" } }, "*");
621
+ };
622
+ var st = $("save-token"); if (st) st.onclick = function () {
623
+ var t = $("token"); bridgeToken = (t && t.value ? t.value : "").trim();
624
+ parent.postMessage({ pluginMessage: { type: "set-token", token: bridgeToken } }, "*");
625
+ dropSocket(); // retires handlers first, so the old socket can't schedule its own reconnect
626
+ if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } // a pending 15s bad-token retry must not delay this
627
+ everOpened = false; failsSinceOpen = 0; tokenRejected = false;
628
+ connect();
629
+ };
630
+ // Source images keep their ORIGINAL container format (png/jpg/webp/gif, detected from magic
631
+ // bytes in assets.ts), so the MIME must follow a.format rather than always claiming PNG.
632
+ var MIME = { svg: "image/svg+xml", png: "image/png", jpg: "image/jpeg", webp: "image/webp", gif: "image/gif" };
633
+ // Chromium (which Figma desktop embeds) throttles programmatic downloads to ~10 PER SECOND —
634
+ // a rate limit, not a total cap — and silently drops what exceeds it, so a `forEach(dl)` over a
635
+ // real export's dozens of layers or hundreds of assets delivered the first handful and looked
636
+ // like it delivered them all. 200ms spacing puts us at 5/s, comfortably under. Stagger them and
637
+ // report the actual count, so "done" means done. `label` names the batch in the status line;
638
+ // `make` builds each Blob lazily, one at a time, instead of materialising every asset's bytes
639
+ // up front.
640
+ // NOT fixed by pacing: Chrome's per-site "Automatic downloads" permission can block multi-file
641
+ // downloads outright at any rate. A blocked anchor click does not throw, so `failed` stays empty
642
+ // and the batch would otherwise announce a full delivery of zero files — the silent truncation
643
+ // this file refuses everywhere else. We cannot detect it, so the completion line names it.
644
+ // Re-entry is refused, and BOTH batch buttons go disabled for the duration (syncBatchButtons
645
+ // reads `batchRunning`) so it is visible why.
646
+ function dlBatch(items, label, name, make) {
647
+ if (!items.length) return;
648
+ if (batchRunning) { setStatus("A download batch is already running — wait for it to finish.", true); return; }
649
+ batchRunning = true;
650
+ setBanner(""); // this attempt's outcome replaces the last one's, never stacks on it
651
+ syncBatchButtons();
652
+ var done = function () {
653
+ batchRunning = false;
654
+ syncBatchButtons();
655
+ };
656
+ var i = 0;
657
+ var failed = [];
658
+ setStatus("Downloading " + items.length + " " + label + "… 0/" + items.length);
659
+ (function step() {
660
+ if (i >= items.length) {
661
+ done();
662
+ // Report the WHOLE outcome. Aborting the batch on the first failure — as this did —
663
+ // threw away every remaining item over one bad blob, and a partial delivery that
664
+ // announces itself as finished is the silent-truncation this file avoids everywhere
665
+ // else. One bad asset should cost you that asset, not the other 399.
666
+ var delivered = items.length - failed.length;
667
+ if (!failed.length) {
668
+ setStatus("Downloaded " + items.length + " " + label + " — if fewer arrived, allow “Automatic downloads” for figma.com and retry.");
669
+ } else if (delivered === 0) {
670
+ // ZERO delivered is a different event from "3 of 400 failed": nothing landed on disk,
671
+ // and the cause is almost always Chrome's per-site "Automatic downloads" permission
672
+ // rather than the blobs themselves. It gets the persistent banner — a status line here
673
+ // is gone the moment the next selection change or export writes over it, which is how
674
+ // this failed silently before.
675
+ setBanner("Downloaded 0 of " + items.length + " " + label + " — nothing was saved. "
676
+ + "Allow “Automatic downloads” for figma.com in your browser/desktop-app settings, then click the button again. "
677
+ + "First failure: " + failed[0]);
678
+ setStatus("Download failed — see the notice above.", true);
679
+ } else {
680
+ setBanner("Downloaded " + delivered + " of " + items.length + " " + label + " — " + failed.length + " failed: " + failed.join(", "));
681
+ setStatus("Downloaded " + delivered + "/" + items.length + " " + label + " — see the notice above.", true);
682
+ }
683
+ return;
684
+ }
685
+ var it = items[i++];
686
+ try { dl(name(it), make(it)); } catch (e) { failed.push(name(it) + " (" + errText(e) + ")"); }
687
+ setStatus("Downloading " + items.length + " " + label + "… " + i + "/" + items.length + (failed.length ? " (" + failed.length + " failed)" : ""));
688
+ setTimeout(step, 200); // ~200ms is the spacing that reliably clears Chromium's throttle
689
+ })();
690
+ }
691
+ var lb = $("layers"); if (lb) lb.onclick = function () {
692
+ dlBatch(layerFilesState, "layer file(s)", function (f) { return f.name; }, function (f) {
693
+ return new Blob([f.content], { type: "application/json" });
694
+ });
695
+ };
696
+ var as = $("assets"); if (as) as.onclick = function () {
697
+ // a.file is the name the plugin already wrote into the tree's `asset` path — use it verbatim
698
+ // rather than re-deriving the convention (this copy had already drifted: it sanitised the id
699
+ // but not the format).
700
+ dlBatch(assetsState, "asset(s)", function (a) { return a.file; }, function (a) {
701
+ var type = MIME[a.format] || "application/octet-stream";
702
+ return a.text != null ? new Blob([a.text], { type: type }) : new Blob([b64ToBytes(a.base64)], { type: type });
703
+ });
704
+ };
705
+ var ae = $("allow-edits"); if (ae) ae.onchange = function () {
706
+ // Disabled until the main thread confirms, so a double toggle cannot race its own answer.
707
+ ae.disabled = true;
708
+ parent.postMessage({ pluginMessage: { type: "set-allow-edits", allowed: !!ae.checked } }, "*");
709
+ };
710
+ parent.postMessage({ pluginMessage: { type: "get-allow-edits" } }, "*");
711
+ // Ask code.js for the stored token; connect() runs once it replies. Also proves the round-trip.
712
+ parent.postMessage({ pluginMessage: { type: "get-token" } }, "*");
713
+ setStatus("Ready — select a frame, or use the blue button.");
714
+ } catch (e) {
715
+ setStatus("UI init error: " + errText(e), true);
716
+ }
717
+ }
718
+
719
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init);
720
+ else init();
721
+ })();
722
+ </script>