@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
@@ -0,0 +1,187 @@
1
+ // pending-requests.ts — every command the bridge has sent to a plugin and not yet settled, and the one rule for how each
2
+ // settles: a reply from the connection it went to, its budget running out, the stall check, its caller giving up, its
3
+ // connection going away, or the bridge closing.
4
+ //
5
+ // A module of its own, driven only by events and a Clock (clock.ts), so the rules are tested step by step against a
6
+ // fake clock (test/pending-requests.test.ts) instead of through a real port and wall-clock sleeps. server-core's socket
7
+ // handlers feed it what they hear (heard / progress / reply / lost); request() opens an entry and sends the frame; the
8
+ // one thing it does on the wire itself goes through the injected `sendCancel`.
9
+ //
10
+ // The rules, each owned here:
11
+ // BUDGET armed at open (= send) for `budgetMs`. It bounds a wait in the plugin's queue behind an earlier run in that
12
+ // file (figma-plugin state.ts scheduleRun — the one queue on every path, so the wait is not otherwise bounded),
13
+ // and is re-armed IN FULL when the plugin says this request's run has begun (its own `start` frame: a progress
14
+ // frame naming this request's id, on this request's connection — an id-less `start` could be anyone's): a run
15
+ // that began late is not cut short by the time it spent waiting.
16
+ // STALL opt-in (`stall`): a separate, shorter wait for ANY frame from this request's connection after the send. It
17
+ // disarms itself on the first one and never re-arms: a working export goes legitimately quiet for minutes
18
+ // (measured live 2026-09-25: 14–24 s after one large top-level frame, 116–135 s between the last page tick and
19
+ // the reply), so "quiet for the window at any point" would abort working exports. What it catches is a
20
+ // connection that never shows life for the request at all; the budget bounds everything after. Only frames
21
+ // heard AFTER open count, by event order rather than by timestamp, so a connect or a frame in the same
22
+ // millisecond as the send can neither pass for life since the send nor be missed.
23
+ // CANCEL whenever this side gives up on a request it has sent — the budget, the stall check, the caller's signal,
24
+ // close() — the plugin is told (`sendCancel`), or it keeps walking the export for nobody and queues every later
25
+ // command behind it. Not when the connection is gone (a socket that closes is itself a cancel on the plugin
26
+ // side) and not for a send that never left.
27
+ // REPLY settles a request only from the connection it was SENT to. With per-bridge ids a reply carrying this id on
28
+ // another socket can only be a coincidence or a confused peer, and letting it through would resolve one file's
29
+ // request with another file's work.
30
+ // LOST a connection that closes fails ITS pending requests at once, and only those: another file's export runs on.
31
+ //
32
+ // Settling clears every timer and removes the signal listener, whichever way it happens, so a settled request leaves
33
+ // nothing armed (a one-shot CLI exits on its own) and a long-lived signal shared by many requests accumulates nothing.
34
+
35
+ import { abortReason } from "./abort-reason.ts";
36
+ import { deadline } from "./clock.ts";
37
+ import type { Clock, Deadline } from "./clock.ts";
38
+
39
+ /** The connection a request went to, as this module needs it: its id (what events are keyed on). The bridge passes its
40
+ * registry entry, so `sendCancel` reaches that exact socket. */
41
+ export interface PendingConn { connId: string }
42
+
43
+ /** One request to track, opened as it is sent. */
44
+ export interface PendingSpec<C extends PendingConn, T> {
45
+ id: string;
46
+ conn: C;
47
+ /** the per-command budget, ms, and the error it rejects with when it runs out */
48
+ budgetMs: number;
49
+ timedOut: () => Error;
50
+ /** the stall check (see STALL above): absent = none */
51
+ stall?: { ms: number; error: () => Error } | undefined;
52
+ /** the caller giving up: aborted while pending → cancel, then reject with abortReason(signal, "request aborted by the
53
+ * caller"). Already aborted at open → rejects "request aborted before it was sent" at once, with no cancel and no
54
+ * entry (`has(id)` is false), so the caller sends nothing. */
55
+ signal?: AbortSignal | undefined;
56
+ /** every progress tick this request's connection sends while it is pending (see `progress`) */
57
+ onProgress?: ((tick: T) => void) | undefined;
58
+ }
59
+
60
+ /** What a reply frame said: the plugin's result, or its error. */
61
+ export type ReplyOutcome = { ok: true; result: unknown } | { ok: false; error: Error };
62
+
63
+ interface Entry<C extends PendingConn, T> {
64
+ conn: C;
65
+ budget: Deadline;
66
+ stall: Deadline | null;
67
+ onProgress: ((tick: T) => void) | undefined;
68
+ unlisten: () => void;
69
+ resolve: (v: unknown) => void;
70
+ reject: (e: Error) => void;
71
+ }
72
+
73
+ export interface PendingRequestsOptions<C extends PendingConn> {
74
+ clock: Clock;
75
+ /** tell the plugin on `conn` to stop request `id` — best-effort, never awaited; it must not throw */
76
+ sendCancel: (conn: C, id: string) => void;
77
+ }
78
+
79
+ export function createPendingRequests<C extends PendingConn, T = unknown>({ clock, sendCancel }: PendingRequestsOptions<C>) {
80
+ const table = new Map<string, Entry<C, T>>();
81
+
82
+ // The one way out of the table: forget the entry, clear its timers and listener, and answer it — or null, when it
83
+ // has already settled (a timer and a reply can race).
84
+ function take(id: string): Entry<C, T> | null {
85
+ const p = table.get(id);
86
+ if (!p) return null;
87
+ table.delete(id);
88
+ p.budget.cancel();
89
+ p.stall?.cancel();
90
+ p.unlisten();
91
+ return p;
92
+ }
93
+
94
+ // Gave up on a request the plugin has: forget it, tell the plugin, then reject.
95
+ function giveUp(id: string, why: () => Error): void {
96
+ const p = take(id);
97
+ if (!p) return;
98
+ sendCancel(p.conn, id);
99
+ p.reject(why());
100
+ }
101
+
102
+ /** Track request `spec.id` from now (call it as the command is sent). The promise settles with the plugin's raw
103
+ * result; checking its shape is the caller's. */
104
+ function open(spec: PendingSpec<C, T>): Promise<unknown> {
105
+ const { id, conn, signal } = spec;
106
+ return new Promise<unknown>((resolve, reject) => {
107
+ if (signal?.aborted) return reject(abortReason(signal, "request aborted before it was sent"));
108
+ let onAbort: (() => void) | null = null;
109
+ const entry: Entry<C, T> = {
110
+ conn,
111
+ budget: deadline(clock, spec.budgetMs, () => giveUp(id, spec.timedOut)),
112
+ stall: null,
113
+ onProgress: spec.onProgress,
114
+ unlisten: () => { if (signal && onAbort) signal.removeEventListener("abort", onAbort); onAbort = null; },
115
+ resolve,
116
+ reject,
117
+ };
118
+ const stall = spec.stall;
119
+ if (stall && stall.ms > 0) entry.stall = deadline(clock, stall.ms, () => giveUp(id, stall.error));
120
+ table.set(id, entry);
121
+ if (signal) {
122
+ const sig = signal;
123
+ onAbort = () => giveUp(id, () => abortReason(sig, "request aborted by the caller"));
124
+ sig.addEventListener("abort", onAbort, { once: true });
125
+ }
126
+ });
127
+ }
128
+
129
+ /** Any parsed frame from `connId` — hello, progress, a reply, even one for an id nobody here knows: life on that
130
+ * connection, which disarms the stall check of every request pending on it. Call it before the frame's own event. */
131
+ function heard(connId: string): void {
132
+ for (const p of table.values()) {
133
+ if (p.conn.connId === connId && p.stall) { p.stall.cancel(); p.stall = null; }
134
+ }
135
+ }
136
+
137
+ /** A progress tick from `connId`. `requestId` names the one request it is for (the plugin's `start` / `queued` for one
138
+ * run) — only that request hears it, and a `start` (`start: true`) re-arms that request's budget in full; an id-less
139
+ * tick (page ticks, an older plugin) reaches every request pending on the connection and re-arms nothing. A tick
140
+ * naming a request this connection does not have feeds nothing. A listener's throw is its own. */
141
+ function progress(connId: string, requestId: string | null, tick: T, start: boolean): void {
142
+ if (requestId !== null && start) {
143
+ const own = table.get(requestId);
144
+ if (own && own.conn.connId === connId) own.budget.rearm();
145
+ }
146
+ for (const [id, p] of table) {
147
+ if (p.conn.connId !== connId || !p.onProgress || (requestId !== null && id !== requestId)) continue;
148
+ try { p.onProgress(tick); } catch { /* a listener's failure is its own; the request goes on */ }
149
+ }
150
+ }
151
+
152
+ /** The plugin on `connId` answered request `id`. Settles it only when `connId` is where it was sent. */
153
+ function reply(connId: string, id: string, outcome: ReplyOutcome): void {
154
+ const p = table.get(id);
155
+ if (!p || p.conn.connId !== connId) return;
156
+ take(id);
157
+ if (outcome.ok) p.resolve(outcome.result);
158
+ else p.reject(outcome.error);
159
+ }
160
+
161
+ /** The command frame for `id` never left (a dropped send): reject now, with no cancel — the plugin never heard of it. */
162
+ function fail(id: string, err: Error): void {
163
+ take(id)?.reject(err);
164
+ }
165
+
166
+ /** Connection `connId` closed: every request pending on it rejects with an error of `why`'s, with no cancel; no other
167
+ * is touched. (`why` makes one per request, so no two callers share an Error.) */
168
+ function lost(connId: string, why: () => Error): void {
169
+ for (const [id, p] of [...table]) if (p.conn.connId === connId) { take(id); p.reject(why()); }
170
+ }
171
+
172
+ /** The bridge is closing: a cancel for EVERY pending request first, while the sockets are still open, then every one
173
+ * rejects with an error of `why`'s. */
174
+ function close(why: () => Error): void {
175
+ const all = [...table];
176
+ for (const [id, p] of all) sendCancel(p.conn, id);
177
+ for (const [id, p] of all) { take(id); p.reject(why()); }
178
+ }
179
+
180
+ return {
181
+ open, heard, progress, reply, fail, lost, close,
182
+ has: (id: string): boolean => table.has(id),
183
+ size: (): number => table.size,
184
+ };
185
+ }
186
+
187
+ export type PendingRequests<C extends PendingConn, T = unknown> = ReturnType<typeof createPendingRequests<C, T>>;
@@ -0,0 +1,32 @@
1
+ // plugin-frames.ts — the plugin <-> bridge protocol on the wire: the JSON each side sends over the socket.
2
+ // The bridge's side is server-core.ts (it sends BridgeFrames and parses what comes back); the plugin's side is
3
+ // figma-plugin/src/relay.ts, bundled into ui.html, which turns main-thread messages into these frames and these
4
+ // frames into main-thread messages. The test suites' fake plugin (test/fake-plugin.ts) drives that same relay.
5
+ //
6
+ // Dependency-free apart from type imports of commands.ts: the plugin's relay imports it, so it must type-check
7
+ // under figma-plugin/tsconfig.json (ES2019 lib, no Node types). server-core.ts re-exports the wire frames.
8
+ import type { Cmd, Commands } from "./commands.ts";
9
+
10
+ /** bridge -> plugin: run one command; the plugin answers with a ReplyFrame carrying the same id. */
11
+ export interface CommandFrame<C extends Cmd = Cmd> { id: string; cmd: C; args: Commands[C]["args"] }
12
+ /** bridge -> plugin: the bridge gave up on request `id` (timed out, stalled out, or its caller left). */
13
+ export interface CancelFrame { type: "cancel"; id: string }
14
+ export type BridgeFrame = CommandFrame | CancelFrame;
15
+ /** plugin -> bridge: who this plugin is, sent on every socket open. The relay sends null for what it does not
16
+ * know; a plugin older than version reporting omits pluginVersion. */
17
+ export interface HelloFrame {
18
+ type: "hello";
19
+ instanceId?: string | null; file?: string | null; fileKey?: string | null; page?: string | null; pluginVersion?: string | null;
20
+ }
21
+ /** plugin -> bridge: an unsolicited progress tick (figma-plugin/src/progress.ts), for the run serving `requestId`. */
22
+ export interface ProgressFrame {
23
+ type: "progress";
24
+ requestId?: string | null; phase?: string; page?: { index: number; of: number; name?: string | null } | null;
25
+ nodes?: number | null; assets?: number | null;
26
+ }
27
+ /** plugin -> bridge: the answer to command `id`. */
28
+ export type ReplyFrame = { [C in Cmd]: { id: string; ok: true; result: Commands[C]["reply"] } }[Cmd] | { id: string; ok: false; error: string };
29
+ /** A ReplyFrame as the relay sends it: the main thread's result, whose shape server-core checks on arrival
30
+ * (commands.ts replyShapeError) — the relay forwards it, it cannot know it. */
31
+ export type RelayedReplyFrame = { id: string; ok: true; result: unknown } | { id: string; ok: false; error: string };
32
+ export type PluginWireFrame = HelloFrame | ProgressFrame | ReplyFrame;
package/src/png.ts ADDED
@@ -0,0 +1,196 @@
1
+ // png.ts — a small PNG codec for the visual diff: Node builtins only (node:zlib), so the shipped bundles stay
2
+ // builtins-only and the pixels never go through a browser. It lives in bridge/src because the bridge reads a PNG's
3
+ // size too (write-out.ts, via pngSize) and bridge/src never imports design-to-code; design-to-code imports it from here.
4
+ //
5
+ // Why not decode in Chromium: an <img> / createImageBitmap colour-manages gAMA / cHRM / iCCP by default,
6
+ // a 2D canvas quantises alpha, and returning the pixels through page.evaluate takes 45 s (a number[]) or 4–5× the time of
7
+ // this decoder (base64). Both sides of the diff are raw sRGB samples: Figma's references carry sRGB + gAMA 45455, and
8
+ // Playwright's screenshots are taken under --force-color-profile=srgb.
9
+ //
10
+ // What it reads: colour type 2 (RGB) and 6 (RGBA), bit depth 8, not interlaced — every Figma reference and every Playwright
11
+ // screenshot seen. Anything else throws PngError naming the colour type / depth / interlace. Every chunk's CRC
12
+ // is checked (W3C PNG 3 §5.3), the filters are §9 (None, Sub, Up, Average, Paeth). What it writes: RGB8, filter 0, deflate
13
+ // level 1 (a mostly uniform diff image compresses well at level 1, and fast), CRC via node:zlib's crc32 (which Bun implements).
14
+ import { crc32, deflateSync, inflateSync } from "node:zlib";
15
+ import { errMsg } from "./errmsg.ts";
16
+
17
+ /** RGBA8 pixels, row-major, w·h·4 bytes. */
18
+ export interface Rgba { w: number; h: number; data: Uint8Array }
19
+ /** The PNG is malformed or of a kind this codec does not read. */
20
+ export class PngError extends Error {}
21
+
22
+ const SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
23
+ interface Chunk { type: string; data: Buffer }
24
+
25
+ /** The chunks in file order, CRC-checked; stops at IEND. Throws PngError on a bad signature, a truncated chunk or a CRC. */
26
+ function chunks(b: Uint8Array): Chunk[] {
27
+ const buf = Buffer.from(b.buffer, b.byteOffset, b.byteLength);
28
+ if (buf.length < 8 || !buf.subarray(0, 8).equals(SIGNATURE)) throw new PngError("not a PNG (bad signature)");
29
+ const out: Chunk[] = [];
30
+ let o = 8;
31
+ while (o + 12 <= buf.length) {
32
+ const len = buf.readUInt32BE(o);
33
+ if (o + 12 + len > buf.length) throw new PngError("truncated PNG chunk");
34
+ const type = buf.toString("latin1", o + 4, o + 8), data = buf.subarray(o + 8, o + 8 + len);
35
+ if ((crc32(buf.subarray(o + 4, o + 8 + len)) >>> 0) !== buf.readUInt32BE(o + 8 + len)) throw new PngError(`bad CRC in the ${type} chunk`);
36
+ out.push({ type, data });
37
+ o += 12 + len;
38
+ if (type === "IEND") break;
39
+ }
40
+ if (!out.length || out[0]?.type !== "IHDR") throw new PngError("not a PNG (no IHDR)");
41
+ return out;
42
+ }
43
+
44
+ /** Width/height from the signature + IHDR header bytes alone, for a reader that only needs the size and must not reject a
45
+ * PNG over a later chunk (no CRC check, no walk past IHDR — unlike pngInfo, which needs every chunk intact). null when the
46
+ * bytes are not a PNG header. */
47
+ export function pngSize(b: Uint8Array): { w: number; h: number } | null {
48
+ const buf = Buffer.from(b.buffer, b.byteOffset, b.byteLength);
49
+ if (buf.length < 24 || !buf.subarray(0, 8).equals(SIGNATURE) || buf.readUInt32BE(8) < 13 || buf.toString("latin1", 12, 16) !== "IHDR") return null;
50
+ return { w: buf.readUInt32BE(16), h: buf.readUInt32BE(20) };
51
+ }
52
+
53
+ export interface PngInfo { w: number; h: number; colorType: number; bitDepth: number; interlace: number; srgb: boolean; gama: number | null; iccp: string | null }
54
+ /** IHDR + the colour chunks (sRGB, gAMA, the iCCP profile's name), or null when the bytes are not a readable PNG. */
55
+ export function pngInfo(b: Uint8Array): PngInfo | null {
56
+ let cs: Chunk[];
57
+ try { cs = chunks(b); } catch { return null; }
58
+ const ihdr = cs[0];
59
+ if (!ihdr || ihdr.data.length < 13) return null;
60
+ const d = ihdr.data;
61
+ const info: PngInfo = { w: d.readUInt32BE(0), h: d.readUInt32BE(4), bitDepth: d[8] ?? 0, colorType: d[9] ?? 0, interlace: d[12] ?? 0, srgb: false, gama: null, iccp: null };
62
+ for (const c of cs) {
63
+ if (c.type === "sRGB") info.srgb = true;
64
+ else if (c.type === "gAMA" && c.data.length >= 4) info.gama = c.data.readUInt32BE(0);
65
+ else if (c.type === "iCCP") { const z = c.data.indexOf(0); info.iccp = c.data.toString("latin1", 0, z < 0 ? Math.min(79, c.data.length) : z); }
66
+ }
67
+ return info;
68
+ }
69
+
70
+ /** The largest image decodePng reads: 4096 × 4096 px (64 MB of RGBA). */
71
+ export const MAX_DECODE_SIDE = 4096;
72
+ const MAX_DECODE_PIXELS = MAX_DECODE_SIDE * MAX_DECODE_SIDE;
73
+ /** Decode to RGBA8 (an RGB image gets alpha 255). Throws PngError for anything but colour type 2/6, depth 8, non-interlaced. */
74
+ export function decodePng(b: Uint8Array): Rgba {
75
+ const cs = chunks(b);
76
+ const d = cs[0]?.data;
77
+ if (!d || d.length < 13) throw new PngError("bad IHDR");
78
+ const w = d.readUInt32BE(0), h = d.readUInt32BE(4), depth = d[8] ?? 0, ct = d[9] ?? 0, interlace = d[12] ?? 0;
79
+ if (depth !== 8 || (ct !== 2 && ct !== 6) || interlace !== 0) throw new PngError(`unsupported PNG (colour type ${ct} / depth ${depth}${interlace ? " / interlaced" : ""}) — only 8-bit RGB / RGBA, not interlaced`);
80
+ if (!w || !h) throw new PngError("empty PNG");
81
+ // refused BEFORE inflating — a few-MB zlib stream can claim gigabytes (Figma's references are ≤ ~2100 px on the long side)
82
+ if (w * h > MAX_DECODE_PIXELS) throw new PngError(`the PNG is ${w}×${h} px — over the ${MAX_DECODE_SIDE}×${MAX_DECODE_SIDE} pixels this decoder reads`);
83
+ const ch = ct === 6 ? 4 : 3, stride = w * ch;
84
+ let raw: Buffer;
85
+ // the inflated size is capped at exactly what the IHDR implies (one filter byte per row)
86
+ try { raw = inflateSync(Buffer.concat(cs.filter((c) => c.type === "IDAT").map((c) => c.data)), { maxOutputLength: (stride + 1) * h }); } catch (e) { throw new PngError(`bad image data (${errMsg(e)})`); }
87
+ if (raw.length < (stride + 1) * h) throw new PngError("truncated image data");
88
+ const px = new Uint8Array(stride * h);
89
+ for (let y = 0; y < h; y++) {
90
+ const f = raw[y * (stride + 1)] ?? 0, s = y * (stride + 1) + 1, dst = y * stride, prev = dst - stride;
91
+ if (f > 4) throw new PngError(`bad filter type ${f} on row ${y}`);
92
+ for (let i = 0; i < stride; i++) {
93
+ const x = raw[s + i] ?? 0, a = i >= ch ? px[dst + i - ch] ?? 0 : 0, up = y ? px[prev + i] ?? 0 : 0, c = y && i >= ch ? px[prev + i - ch] ?? 0 : 0;
94
+ let v: number;
95
+ if (f === 0) v = x;
96
+ else if (f === 1) v = x + a;
97
+ else if (f === 2) v = x + up;
98
+ else if (f === 3) v = x + ((a + up) >> 1);
99
+ else { const p = a + up - c, pa = Math.abs(p - a), pb = Math.abs(p - up), pc = Math.abs(p - c); v = x + (pa <= pb && pa <= pc ? a : pb <= pc ? up : c); }
100
+ px[dst + i] = v & 255;
101
+ }
102
+ }
103
+ if (ch === 4) return { w, h, data: px };
104
+ const data = new Uint8Array(w * h * 4);
105
+ for (let i = 0, j = 0; i < px.length; i += 3, j += 4) { data[j] = px[i] ?? 0; data[j + 1] = px[i + 1] ?? 0; data[j + 2] = px[i + 2] ?? 0; data[j + 3] = 255; }
106
+ return { w, h, data };
107
+ }
108
+
109
+ function chunk(type: string, data: Buffer): Buffer {
110
+ const head = Buffer.alloc(8);
111
+ head.writeUInt32BE(data.length, 0);
112
+ head.write(type, 4, "latin1");
113
+ const crc = Buffer.alloc(4);
114
+ crc.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), data])) >>> 0, 0);
115
+ return Buffer.concat([head, data, crc]);
116
+ }
117
+ /** Encode as RGB8 (alpha dropped — the diff image is opaque), filter 0, deflate level 1. */
118
+ export function encodePng(img: Rgba): Buffer {
119
+ const ihdr = Buffer.alloc(13);
120
+ ihdr.writeUInt32BE(img.w, 0); ihdr.writeUInt32BE(img.h, 4);
121
+ ihdr[8] = 8; ihdr[9] = 2; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0;
122
+ const stride = img.w * 3 + 1, raw = Buffer.alloc(stride * img.h);
123
+ for (let y = 0; y < img.h; y++) {
124
+ let o = y * stride + 1, i = y * img.w * 4;
125
+ for (let x = 0; x < img.w; x++, i += 4) { raw[o++] = img.data[i] ?? 0; raw[o++] = img.data[i + 1] ?? 0; raw[o++] = img.data[i + 2] ?? 0; }
126
+ }
127
+ return Buffer.concat([SIGNATURE, chunk("IHDR", ihdr), chunk("IDAT", deflateSync(raw, { level: 1 })), chunk("IEND", Buffer.alloc(0))]);
128
+ }
129
+
130
+ /** The pixels of `r` (clamped to the image; integer px). A crop of the whole image is `img` itself, not a copy: the result
131
+ * is read-only to every caller (the diff, the diff image, the encoder). */
132
+ export function crop(img: Rgba, r: { x: number; y: number; w: number; h: number }): Rgba {
133
+ const x0 = Math.max(0, Math.min(img.w, Math.round(r.x))), y0 = Math.max(0, Math.min(img.h, Math.round(r.y)));
134
+ const w = Math.max(0, Math.min(img.w - x0, Math.round(r.w))), h = Math.max(0, Math.min(img.h - y0, Math.round(r.h)));
135
+ if (x0 === 0 && y0 === 0 && w === img.w && h === img.h && img.data.length === w * h * 4) return img;
136
+ const data = new Uint8Array(w * h * 4);
137
+ for (let y = 0; y < h; y++) data.set(img.data.subarray(((y0 + y) * img.w + x0) * 4, ((y0 + y) * img.w + x0 + w) * 4), y * w * 4);
138
+ return { w, h, data };
139
+ }
140
+
141
+ /** Area-average (box) resample to w×h: every output pixel is the coverage-weighted mean of the source pixels under it,
142
+ * colour weighted by alpha (a transparent pixel adds no colour). Exact for integer factors on uniform blocks. At the
143
+ * image's own size it is `img` itself, not a copy (read-only to every caller, as crop's). */
144
+ export function resampleBox(img: Rgba, w: number, h: number): Rgba {
145
+ if (w <= 0 || h <= 0) return { w: Math.max(0, w), h: Math.max(0, h), data: new Uint8Array(0) };
146
+ if (w === img.w && h === img.h) return img.data.length === w * h * 4 ? img : { w, h, data: img.data.slice() };
147
+ /** per output index, the source spans and their weights along one axis */
148
+ const spans = (n: number, m: number): Array<Array<[number, number]>> => {
149
+ const sc = n / m, out: Array<Array<[number, number]>> = [];
150
+ for (let o = 0; o < m; o++) {
151
+ const a = o * sc, b = (o + 1) * sc, list: Array<[number, number]> = [];
152
+ for (let s = Math.floor(a); s < Math.min(n, Math.ceil(b)); s++) { const wgt = Math.min(b, s + 1) - Math.max(a, s); if (wgt > 1e-9) list.push([s, wgt]); }
153
+ out.push(list);
154
+ }
155
+ return out;
156
+ };
157
+ const xs = spans(img.w, w), ys = spans(img.h, h);
158
+ // horizontal pass into premultiplied floats (r·a, g·a, b·a, a), then vertical. The horizontal pass is per source row, made
159
+ // when an output row first needs it and dropped once the spans (which only move down) have passed it: a few rows of
160
+ // w × 4 doubles, not one w × img.h × 4 plane (512 MiB at 4096²). Each row holds the same doubles the plane did.
161
+ const rows = new Map<number, Float64Array>(), spare: Float64Array[] = [];
162
+ const hRow = (y: number): Float64Array => {
163
+ const have = rows.get(y);
164
+ if (have) return have;
165
+ const t = spare.pop() ?? new Float64Array(w * 4);
166
+ for (let x = 0; x < w; x++) {
167
+ let r = 0, g = 0, bl = 0, a = 0, tw = 0;
168
+ for (const [s, wgt] of xs[x] ?? []) {
169
+ const i = (y * img.w + s) * 4, al = img.data[i + 3] ?? 0;
170
+ r += (img.data[i] ?? 0) * al * wgt; g += (img.data[i + 1] ?? 0) * al * wgt; bl += (img.data[i + 2] ?? 0) * al * wgt; a += al * wgt; tw += wgt;
171
+ }
172
+ const o = x * 4;
173
+ t[o] = r / tw; t[o + 1] = g / tw; t[o + 2] = bl / tw; t[o + 3] = a / tw;
174
+ }
175
+ rows.set(y, t);
176
+ return t;
177
+ };
178
+ const data = new Uint8Array(w * h * 4);
179
+ for (let y = 0; y < h; y++) {
180
+ const span = ys[y] ?? [], first = span[0]?.[0];
181
+ if (first !== undefined) for (const [s, t] of rows) if (s < first) { rows.delete(s); spare.push(t); }
182
+ const src = span.map(([s]) => hRow(s));
183
+ for (let x = 0; x < w; x++) {
184
+ let r = 0, g = 0, bl = 0, a = 0, tw = 0;
185
+ for (let j = 0; j < span.length; j++) {
186
+ const t = src[j], wgt = span[j]?.[1] ?? 0, i = x * 4;
187
+ r += (t?.[i] ?? 0) * wgt; g += (t?.[i + 1] ?? 0) * wgt; bl += (t?.[i + 2] ?? 0) * wgt; a += (t?.[i + 3] ?? 0) * wgt; tw += wgt;
188
+ }
189
+ const o = (y * w + x) * 4;
190
+ a /= tw;
191
+ data[o + 3] = Math.round(a);
192
+ if (a > 0) { data[o] = Math.round(r / tw / a); data[o + 1] = Math.round(g / tw / a); data[o + 2] = Math.round(bl / tw / a); }
193
+ }
194
+ }
195
+ return { w, h, data };
196
+ }
package/src/ports.ts ADDED
@@ -0,0 +1,25 @@
1
+ // ports.ts — the ONLY ports a published plugin can reach. figma-plugin/manifest.json lists these three
2
+ // in `networkAccess.allowedDomains`, Figma's match patterns have no port wildcard, and a plugin socket
3
+ // to any other port is blocked by Figma before it leaves the iframe. So FIGMA_BRIDGE_PORT is a choice
4
+ // of three, not a free number: binding 9999 gives a bridge that nothing can ever connect to. Keep this
5
+ // array and the manifest's allowedDomains in sync — they are two halves of one contract.
6
+ //
7
+ // Its own module (no imports, no load-time effects) so doctor.ts — which must diagnose a bad
8
+ // FIGMA_BRIDGE_PORT rather than die of it — reads the same list and the same parse the bridge's settings
9
+ // (settings.ts) are read with, instead of a restated copy the test suite had to assert equal.
10
+ export const ALLOWED_PORTS: readonly [number, ...number[]] = [8787, 8788, 8789];
11
+
12
+ /** FIGMA_BRIDGE_PORT -> the port it selects, or `{ invalid }` (the raw value) when it names none of
13
+ * ALLOWED_PORTS. Unset or empty selects the first. The value goes through Number(), so surrounding
14
+ * whitespace is ignored and " 8788" selects 8788; a whitespace-only value is 0, which is invalid. */
15
+ export function parsePortEnv(raw: string | undefined): { port: number; invalid?: undefined } | { invalid: string; port?: undefined } {
16
+ if (!raw) return { port: ALLOWED_PORTS[0] };
17
+ const n = Number(raw);
18
+ return ALLOWED_PORTS.includes(n) ? { port: n } : { invalid: raw };
19
+ }
20
+
21
+ /** The refusal for a FIGMA_BRIDGE_PORT that names none of ALLOWED_PORTS. */
22
+ export function portProblem(raw: string): string {
23
+ return `[bridge] FIGMA_BRIDGE_PORT=${raw} is not one of ${ALLOWED_PORTS.join(", ")}. The Figma plugin ` +
24
+ "may only open sockets to ports named in its manifest, so a bridge here would never be reachable.";
25
+ }
@@ -0,0 +1,189 @@
1
+ // project-layout.ts — the ONE definition of where a Design Twin project keeps things.
2
+ //
3
+ // The layout is not "everything under design/", which would put two incompatible kinds of file in one
4
+ // directory: what a pull WRITES and may overwrite without asking (pages/, design-system/, assets/,
5
+ // variables.json), and what a human or a build step OWNS and cannot regenerate (target.json, the
6
+ // component map, the plan, the audit, the verification evidence). "Delete design/ and re-pull" is the
7
+ // obvious recovery move for a bad export, and it would silently destroy the second kind — including a
8
+ // component map that can represent hours of hand-mapping. Moving the component map to
9
+ // the repo root would only draw the line in two different places.
10
+ //
11
+ // So the line is drawn once, and where it can be seen:
12
+ //
13
+ // design/
14
+ // export/ <- dtwin writes ONLY here. Safe to delete and re-pull.
15
+ // pages/<Page>/<Screen>__<id>.json the screen tree, its .vars.json and .assets.json
16
+ // pages/index.json every screen pulled, whatever the pull shape
17
+ // design-system/ tokens, styles, component catalogs
18
+ // libraries/<slug>/ a --as-library export
19
+ // assets/ shared, cumulative
20
+ // variables.json the union of every screen slice
21
+ // target.json <- the stack. Written by init, confirmed by build-screen.
22
+ // codeconnect.local.json <- the component map. HAND-OWNED; a re-pull must never touch it.
23
+ // plan/<Screen>.json <- build-screen's plan and decisions
24
+ // audit/<Screen>.{md,json} <- audit-design
25
+ // verify/<Screen>.{expected,report}.json <- verify's machine-readable evidence
26
+ // .sync/ <- design-diff --snapshot's pre-re-pull copies (tool-owned)
27
+ // changes/<name>.md <- sync-design's diff reports
28
+ //
29
+ // Projects created before this split have their export directly in design/. Nothing rewrites them:
30
+ // findExportDir() recognises the old shape and every consumer keeps working, while doctor says which
31
+ // layout it found so the difference is never a silent surprise.
32
+
33
+ import fs from "node:fs";
34
+ import path from "node:path";
35
+
36
+ export const DESIGN_DIR = "design";
37
+ export const EXPORT_SUBDIR = "export";
38
+ export const EXPORT_DIR = path.join(DESIGN_DIR, EXPORT_SUBDIR);
39
+ export const TARGET_FILE = path.join(DESIGN_DIR, "target.json");
40
+ export const MAP_FILE = path.join(DESIGN_DIR, "codeconnect.local.json");
41
+ export const LEGACY_MAP_FILE = "codeconnect.local.json"; // the repo-root map of projects that predate design/
42
+ export const PLAN_DIR = path.join(DESIGN_DIR, "plan");
43
+ export const AUDIT_DIR = path.join(DESIGN_DIR, "audit");
44
+ export const VERIFY_DIR = path.join(DESIGN_DIR, "verify");
45
+ /** Where design-diff --snapshot keeps the copy of an export file taken before a re-pull. */
46
+ export const SNAPSHOT_DIR = path.join(DESIGN_DIR, ".sync");
47
+ /** The merged token file at the export root: the union of every screen pull's variables slice. */
48
+ export const MERGED_VARIABLES_FILE = "variables.json";
49
+
50
+ // Tailwind v4's automatic source detection scans every file git does not ignore — Markdown
51
+ // included (tailwindcss.com/docs/detecting-classes-in-source-files) — so class names quoted in design/
52
+ // notes, audits and plans are generated into the shipped CSS. The exclusion is `@source not "<path
53
+ // relative to the stylesheet>";` (Tailwind v4.1+). Only SUGGESTED: the path depends on where the
54
+ // CSS entry lives, and the user's stylesheet is theirs. One wording for `dtwin init` and tokens.ts.
55
+ export const TAILWIND_SOURCE_NOT_NOTE =
56
+ `Tailwind v4 scans every file git does not ignore, ${DESIGN_DIR}/ included, so class names quoted in ${DESIGN_DIR}/ notes, audits and plans end up in your CSS. ` +
57
+ `Next to \`@import "tailwindcss";\` in your CSS entry, add \`@source not "<path from that CSS file to ${DESIGN_DIR}/>";\` ` +
58
+ `(e.g. \`@source not "../${DESIGN_DIR}";\` for src/app.css) — Tailwind v4.1+`;
59
+
60
+ // Suggest only. What was proven on a real Vite + Tailwind v4 app: with Tailwind's automatic source
61
+ // detection, rewriting an existing text file under design/ makes Vite fully reload the page — the reload that aborted
62
+ // a measurement. Either Vite's `server.watch.ignored` (chokidar options, vite.dev/config/server-options) or Tailwind's
63
+ // `@source not` stops it. Only emitted when the project lists BOTH vite and Tailwind v4: without Tailwind scanning
64
+ // design/, Vite reloads only for files in its module graph, which design/ is not — so the note would be noise there.
65
+ // (The verify tools themselves write nothing under design/ while a page is open: live status + staging live in
66
+ // node_modules/.cache/designtwin-verify/.) Never edited for the user: vite.config is theirs.
67
+ export const VITE_WATCH_IGNORED_NOTE =
68
+ `With Tailwind v4's automatic source detection, rewriting an existing text file under ${DESIGN_DIR}/ (a re-export, a verify report) makes Vite fully reload the open page. ` +
69
+ `Either add \`server: { watch: { ignored: ['**/${DESIGN_DIR}/**'] } }\` in vite.config (merge it with any existing \`server.watch\` options), or the Tailwind \`@source not\` above — both stop it`;
70
+
71
+ // design/verify/ is regenerated on every verify run (measurements, screenshots, reports); the
72
+ // plan's waivers and descopes live in design/plan/, so ignoring it loses no decision. Spelt with `/` on every OS: a
73
+ // .gitignore pattern takes only `/` (a `\` there escapes the next character, so `design\verify/` ignores nothing).
74
+ const slashed = (p: string): string => p.split(path.sep).join("/");
75
+ export const VERIFY_GITIGNORE_NOTE =
76
+ `${slashed(VERIFY_DIR)}/ is regenerated on every verify run (measurements, screenshots, reports) — consider adding \`${slashed(VERIFY_DIR)}/\` to .gitignore; decisions live in ${slashed(PLAN_DIR)}/ and are not affected`;
77
+
78
+ // The marks of an export directory, in the order a pull creates them. Used to tell "this project uses
79
+ // the old flat layout" from "this project has no export yet", which are different problems with
80
+ // different fixes.
81
+ const EXPORT_MARKERS = ["pages", "design-system", "design-system.json", MERGED_VARIABLES_FILE, "assets", "libraries"];
82
+
83
+ export function looksLikeExportDir(dir: string): boolean {
84
+ try {
85
+ if (!fs.statSync(dir).isDirectory()) return false;
86
+ } catch {
87
+ return false;
88
+ }
89
+ return EXPORT_MARKERS.some((m) => fs.existsSync(path.join(dir, m)));
90
+ }
91
+
92
+ // Where THIS project's export actually is. Prefers the current layout; falls back to the legacy one
93
+ // only when that directory really holds an export, so a fresh `design/` with nothing but target.json
94
+ // in it is reported as "no export yet" rather than as a legacy project.
95
+ /** Where findExportDir found (or would put) the export. `parallelLegacy` only on the modern layout. */
96
+ export type ExportDirInfo =
97
+ | { dir: string; layout: "export-subdir"; rel: string; parallelLegacy: boolean }
98
+ | { dir: string; layout: "legacy-flat" | "none"; rel: string; parallelLegacy?: undefined };
99
+
100
+ export function findExportDir(cwd: string): ExportDirInfo {
101
+ const modern = path.join(cwd, EXPORT_DIR);
102
+ const legacy = path.join(cwd, DESIGN_DIR);
103
+ const modernExists = looksLikeExportDir(modern);
104
+ const legacyExists = looksLikeExportDir(legacy);
105
+ // Both layouts present at once: a stray `dtwin pull design …` wrote a
106
+ // second, parallel export tree directly in design/ beside the real design/export/. The modern one
107
+ // still wins (it is where every tool looks), but the caller must be told the legacy one exists too
108
+ // — picking one silently is exactly what a project in this state cannot afford, since the two trees
109
+ // can disagree (e.g. two different `variables.json`).
110
+ if (modernExists) return { dir: modern, layout: "export-subdir", rel: EXPORT_DIR, parallelLegacy: legacyExists };
111
+ if (legacyExists) return { dir: legacy, layout: "legacy-flat", rel: DESIGN_DIR };
112
+ return { dir: modern, layout: "none", rel: EXPORT_DIR };
113
+ }
114
+
115
+ // The component map moved from the repo root into design/. An existing project's root copy is still
116
+ // the real one — finding it and saying so beats writing a second map beside it and linting the wrong one.
117
+ export interface MapFileInfo {
118
+ file: string;
119
+ rel: string;
120
+ legacy: boolean;
121
+ /** Present (true) only when neither location holds a map; `file` is then where a new one goes. */
122
+ missing?: true;
123
+ }
124
+
125
+ export function findMapFile(cwd: string): MapFileInfo {
126
+ const modern = path.join(cwd, MAP_FILE);
127
+ if (fs.existsSync(modern)) return { file: modern, rel: MAP_FILE, legacy: false };
128
+ const legacy = path.join(cwd, LEGACY_MAP_FILE);
129
+ if (fs.existsSync(legacy)) return { file: legacy, rel: LEGACY_MAP_FILE, legacy: true };
130
+ return { file: modern, rel: MAP_FILE, legacy: false, missing: true };
131
+ }
132
+
133
+ // The plan files in a plan directory (PLAN_DIR, or one a caller names): every `*.json` name, sorted so every
134
+ // reader walks them in the same order. A directory that does not exist holds none.
135
+ export function listPlans(dir: string): string[] {
136
+ if (!fs.existsSync(dir)) return [];
137
+ return fs.readdirSync(dir).filter((f) => f.endsWith(".json")).sort();
138
+ }
139
+
140
+ // Shipped into design/ by `dtwin init`, because the one thing this layout has to communicate is which
141
+ // half of it a re-pull is allowed to destroy — and a comment in a source file cannot do that.
142
+ export const README = `# design/
143
+
144
+ Two kinds of file live here, and the difference matters.
145
+
146
+ ## design/export/ — dtwin owns this
147
+
148
+ Everything \`dtwin pull\` writes lands under \`export/\` and nowhere else. It is a snapshot of Figma:
149
+ delete the whole directory and re-pull and you lose nothing.
150
+
151
+ export/pages/<Page>/<Screen>__<node-id>.json one exported screen
152
+ export/pages/<Page>/<Screen>__<node-id>.vars.json the tokens THAT screen binds
153
+ export/pages/<Page>/<Screen>__<node-id>.assets.json which assets it uses, with content hashes
154
+ export/pages/index.json every screen pulled, whatever the pull shape
155
+ export/design-system/ tokens.json, styles.*.json, components.*.json
156
+ export/variables.json the union of every screen's slice (merged, never replaced)
157
+ export/SCHEMA.md scripting quick keys (generated)
158
+ export/assets/ shared and cumulative across screens
159
+
160
+ ## design/.sync/ — a working snapshot, not an export
161
+
162
+ \`design-diff.ts --snapshot <file or export dir>\` copies a file here BEFORE you re-pull it, so the sync-design skill
163
+ can diff the old export against the new one afterwards. It is non-destructive: a snapshot that would
164
+ replace an existing, DIFFERENT one is refused until a diff has used the old one, or you pass
165
+ \`--force\`. The copy it replaces is kept as \`<name>.prev\` (one level; a diff never reads one a diff had already
166
+ used — pass it with \`--against\`) — see \`claude-plugin/skills/sync-design/SKILL.md\`. It is not part of the
167
+ Figma-owned export above and not something a re-pull ever writes to; delete it any time, it only
168
+ affects what the next diff compares against.
169
+
170
+ ## design/changes/ — where a diff report lands
171
+
172
+ \`design-diff.ts ... --out design/changes/<name>.md\` writes its human-readable report here, per the
173
+ sync-design skill. Also yours: it is evidence of what changed on a given sync, not something
174
+ \`dtwin pull\` regenerates.
175
+
176
+ ## Everything else here — you own it
177
+
178
+ These are decisions and evidence. They are not regenerable, and a re-pull never touches them.
179
+
180
+ target.json which stack you are building for
181
+ codeconnect.local.json Figma component key -> your code component. Hand-maintained.
182
+ plan/<Screen>.json what build-screen decided, and why
183
+ audit/<Screen>.{md,json} the pre-build design review
184
+ verify/<Screen>.report.json the measured per-node comparison behind any "pass"
185
+
186
+ Commit both halves. If your .gitignore ignores \`design/\`, un-ignore this half —
187
+ \`!design/target.json\`, \`!design/codeconnect.local.json\`, \`!design/plan/\` — or the work is lost
188
+ with the next clone.
189
+ `;