@design-twin/dtwin 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,10 +1,32 @@
1
- # Design Twin — bridge
1
+ <!-- npm renders no <picture> and no repo-relative paths: one absolute PNG, dark ink, made for npm's white page. -->
2
+ <img alt="Design Twin" src="https://raw.githubusercontent.com/its-magdy/design-twin/main/assets/wordmark-light.png" height="56">
2
3
 
3
- **figma-pull (read CLI) + figma-mcp (write MCP).**
4
+ # Design Twin — `dtwin`
4
5
 
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.
6
+ > Figma → code with Claude Code, on the **free** Figma plan. No REST API key, no cloud service in between: the design
7
+ > goes from your Figma app to your coding agent and nowhere else.
8
+
9
+ `@design-twin/dtwin` is the **`dtwin` CLI** (pulls a design onto disk) and the **MCP server** (the same reads as live
10
+ tools, plus the only write path) of [Design Twin](https://github.com/its-magdy/design-twin). It also ships the Figma
11
+ plugin they talk to. The bridge is local and loopback-only: the plugin's hidden UI iframe is a WebSocket **client**,
12
+ and these processes are the **server** (`127.0.0.1:8787`). Nothing touches the internet. See
13
+ [ARCHITECTURE.md](https://github.com/its-magdy/design-twin/blob/main/ARCHITECTURE.md) for the full design.
14
+
15
+ **Quick start**, in the project you are building, after the install below:
16
+
17
+ ```
18
+ dtwin init --mcp # design/, the bridge token, the Figma plugin's copy, a .mcp.json entry
19
+ dtwin plugin path # import this manifest once: Figma desktop → Plugins → Development → Import plugin from manifest…
20
+ dtwin token show # paste into the plugin: Connect to Claude Code (optional) → Bridge token → Save
21
+ dtwin doctor # checks everything and names the next step
22
+ dtwin list # then: dtwin pull --node <id>
23
+ ```
24
+
25
+ For the Claude Code skills (audit, build, verify, sync), see the
26
+ [project README](https://github.com/its-magdy/design-twin#readme): its
27
+ [first screen in 5 minutes](https://github.com/its-magdy/design-twin#first-screen-in-5-minutes) goes from install
28
+ to a built screen, and [known limitations](https://github.com/its-magdy/design-twin#known-limitations--platform-support)
29
+ covers what it needs (Figma desktop, Bun) and where it runs.
8
30
 
9
31
  ## Install (once)
10
32
  It needs [Bun](https://bun.sh/install) ≥ 1.4.2 (`engines.bun`) on your PATH; Node is not supported. Install Bun
@@ -34,7 +56,7 @@ dependency tree the release was tested with, never a newer one published since.
34
56
  Or from a clone (to work on the code):
35
57
  ```
36
58
  git clone https://github.com/its-magdy/design-twin.git && cd design-twin
37
- bun install # @modelcontextprotocol/sdk (+ zod), for every workspace
59
+ bun install --frozen-lockfile # @modelcontextprotocol/sdk (+ zod), for every workspace
38
60
  cd bridge && bun link # puts `dtwin` in ~/.bun/bin (on your PATH); nothing to build
39
61
  ```
40
62
  Or skip the link and run `bun --no-install --no-env-file /path/to/design-twin/bridge/src/figma-pull.ts` wherever
@@ -75,8 +97,9 @@ access control (a sandboxed browser iframe on any site the user visits sends `Or
75
97
  could otherwise drive the plugin), so the token is the real gate.
76
98
 
77
99
  **You do not have to set anything up.** The first time a bridge starts it generates a token, saves it,
78
- prints it once, and reuses it forever after. Paste it into the plugin's **Bridge token** field (the
79
- plugin saves it per-user in `clientStorage`) and neither side asks again.
100
+ prints it once, and reuses it forever after. Paste it into the plugin's **Bridge token** field, under the
101
+ window's collapsed **Connect to Claude Code (optional)** section, and press **Save** (the plugin keeps it
102
+ per-user in `clientStorage`); neither side asks again.
80
103
 
81
104
  ```
82
105
  dtwin --token-status # where it lives, which source wins, its fingerprint — never the token
@@ -234,14 +257,15 @@ the path; on Windows paste it into the File name box. `dtwin init` installs (or
234
257
  step. Install writes only the files that differ, each one atomically, under a lock, and is safe to run any number of times.
235
258
 
236
259
  **Updates need no command.** Once a copy is installed, every `dtwin` that starts a bridge (a command, `dtwin serve`, the
237
- MCP server) first brings it up to its own package's files, so after `bun add -g @design-twin/dtwin@latest` you only
260
+ MCP server) first brings it up to its own package's files, so after `bun add -g @design-twin/dtwin@latest` (or
261
+ `brew upgrade dtwin`) you only
238
262
  reopen the plugin — with Plugins → Development → **Hot reload plugin** on. With it on, reopening runs the replaced
239
263
  `code.js` / `ui.html` and picks up a changed manifest's permissions too (seen in Figma desktop 126); with it off, Figma
240
264
  kept running the code it had already loaded. An update whose manifest changed says so, with re-importing as the
241
265
  fallback if a new permission still does not work. Nothing is created that you did not
242
266
  install, the network is never touched, and a `dtwin` running from a **repo checkout** never refreshes the copy —
243
267
  developers import `figma-plugin/manifest.json` from the checkout itself (`dtwin plugin path` prints that one there).
244
- `dtwin doctor` reports the copy as its "Plugin files" line.
268
+ `dtwin doctor` reports the copy as its "Plugin files" check.
245
269
 
246
270
  ## Seed the component map from Code Connect: `dtwin seed`
247
271
 
@@ -289,7 +313,9 @@ open file is seen), then each file's `hello` (so it is named).
289
313
  bridge (almost always the MCP server), or held by something unrelated.
290
314
  5. **Figma plugin** — can it actually connect? If a plugin is running with a *different* token, doctor
291
315
  says so with both fingerprints, instead of the "nothing connected" that case otherwise looks like.
292
- 6. **Project** (the current directory) — `design/`, `design/target.json` and the profile build-screen will use
316
+ 6. **Plugin files** — the per-user plugin copy `dtwin plugin` manages: installed or not, where, and whether it
317
+ matches this dtwin.
318
+ 7. **Project** (the current directory) — `design/`, `design/target.json` and the profile build-screen will use
293
319
  (bundled or custom, plus the project's own `design/profile.md` when it exists), which export LAYOUT it
294
320
  found, the age of the last export (its newest stamp; stale past 24h), **which Figma files that export mixes**, the component map
295
321
  (`design/codeconnect.local.json`, or the older repo-root location), and the `.mcp.json`
@@ -840,7 +866,7 @@ Behaviour worth knowing:
840
866
  while you are working *on* the bridge. Register it in the project you are *building*: a `.mcp.json`
841
867
  there that runs `bridge/src/figma-mcp.ts` on Bun — `"command"`: the absolute path of `bun`, `"args"`:
842
868
  `["--no-install", "--no-env-file", "<abs path>/bridge/src/figma-mcp.ts"]` — under the key `designtwin` (`dtwin init --mcp`
843
- writes exactly that, with the `bun` and the clone that ran it; an absolute `bun` keeps working when Claude Code is
869
+ writes exactly that, with the `bun` and the install — package or clone — that ran it; an absolute `bun` keeps working when Claude Code is
844
870
  started without `~/.bun/bin` on its PATH). Claude Code then launches it over stdio, and its tools surface as
845
871
  `mcp__designtwin__<tool>` (e.g. `mcp__designtwin__figma_status`).
846
872
  No token needs to go in that `.mcp.json`: the MCP server reads the same per-user stored token the CLI
@@ -4069,7 +4069,7 @@
4069
4069
  // freshly reloaded one, since neither startedAt nor code.js's mtime can. Reused verbatim by
4070
4070
  // the `hello` announcement below (main.ts's get-identity -> ui.html -> bridge), so `whoami`,
4071
4071
  // `dtwin list clients` and `dtwin doctor` can never disagree about which build is running.
4072
- pluginVersion: "0.1.3",
4072
+ pluginVersion: "0.1.5",
4073
4073
  fileKey: fileKey === void 0 ? null : fileKey,
4074
4074
  fileKeyAvailable: typeof fileKey === "string" && fileKey.length > 0,
4075
4075
  // So figma_write's dry run can say a real write would be refused before anyone sends one.
@@ -68,11 +68,17 @@
68
68
  background: var(--figma-color-bg-secondary, #f6f6f6); color: var(--figma-color-text-tertiary, #888); }
69
69
  #edits-state.on { background: var(--figma-color-bg-warning-tertiary, #fff4e0); color: var(--figma-color-text-warning, #8a5a00); }
70
70
  #cancel { border-color: var(--figma-color-border-danger-strong, #c0362c); color: var(--figma-color-text-danger, #c0362c); }
71
+ /* Bo, the brand mark (assets/logo-16.svg), in front of the status line: it "says" the status. It sits in the
72
+ line's own height, so no control moves and the window keeps its size. The frame and eyes are currentColor
73
+ (they follow Figma's theme); the four handles stay brand magenta. */
74
+ #head { display: flex; align-items: flex-start; gap: 8px; margin-bottom: 12px; }
75
+ #head svg { flex: 0 0 auto; margin-top: 1px; }
76
+ #head #status { flex: 1; margin-bottom: 0; }
71
77
  </style>
72
78
 
73
79
  <!-- role=status / role=alert: a screen-reader user otherwise never hears that a long export finished
74
80
  or that a download batch failed. -->
75
- <div id="status" role="status" aria-live="polite">Loading…</div>
81
+ <div id="head"><svg width="16" height="16" viewBox="0 0 16 16" fill="none" shape-rendering="crispEdges" aria-hidden="true"><rect x="3.5" y="3.5" width="9" height="9" stroke="currentColor"/><g fill="#d6409f"><rect x="2" y="2" width="3" height="3"/><rect x="11" y="2" width="3" height="3"/><rect x="2" y="11" width="3" height="3"/><rect x="11" y="11" width="3" height="3"/></g><g fill="currentColor"><rect x="5" y="6" width="2" height="3"/><rect x="9" y="6" width="2" height="3"/></g></svg><div id="status" role="status" aria-live="polite">Loading…</div></div>
76
82
  <div id="banner" role="alert" hidden></div>
77
83
  <div id="progress" hidden><div id="bar"></div></div>
78
84
 
@@ -90,7 +96,7 @@
90
96
  <button id="layers" disabled>Download layers (<span id="lcount">0</span>)</button>
91
97
  <button id="assets" disabled>Download assets (<span id="acount">0</span>)</button>
92
98
 
93
- <div class="hint">Save the downloads into your project's <code>design/</code> folder (assets into <code>design/assets/</code>).</div>
99
+ <div class="hint">Save the downloads into your project's <code>design/export/</code> folder (assets into <code>design/export/assets/</code>).</div>
94
100
 
95
101
  <div class="sep"></div>
96
102
  <div class="label">Edits</div>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@design-twin/dtwin",
3
- "version": "0.1.3",
4
- "description": "Design Twin bridge: local CLI + MCP server between Claude Code and the Design Twin Figma plugin. Reads a design on the free Figma plan, fully offline.",
3
+ "version": "0.1.5",
4
+ "description": "Figma → code with Claude Code: the dtwin CLI + MCP server (and the Figma plugin) that read a design on the free Figma plan into your project. No REST API key, no cloud service in between.",
5
5
  "license": "MIT",
6
6
  "author": "Mohamed Magdy Omar",
7
7
  "repository": {
@@ -9,17 +9,21 @@
9
9
  "url": "git+https://github.com/its-magdy/design-twin.git",
10
10
  "directory": "bridge"
11
11
  },
12
- "homepage": "https://github.com/its-magdy/design-twin/tree/main/bridge#readme",
12
+ "homepage": "https://github.com/its-magdy/design-twin#readme",
13
13
  "bugs": "https://github.com/its-magdy/design-twin/issues",
14
14
  "keywords": [
15
15
  "figma",
16
- "mcp",
17
- "model-context-protocol",
16
+ "figma-plugin",
17
+ "figma-to-code",
18
18
  "design-to-code",
19
19
  "design-system",
20
20
  "design-tokens",
21
+ "mcp",
22
+ "mcp-server",
23
+ "model-context-protocol",
24
+ "claude",
21
25
  "claude-code",
22
- "figma-plugin",
26
+ "bun",
23
27
  "cli"
24
28
  ],
25
29
  "type": "module",
@@ -42,7 +46,7 @@
42
46
  "prepack": "bun --no-install --no-env-file pack.ts",
43
47
  "postpack": "bun --no-install --no-env-file pack.ts --clean",
44
48
  "pull": "bun src/figma-pull.ts",
45
- "pull:selection": "bun src/figma-pull.ts design --selection",
49
+ "pull:selection": "bun src/figma-pull.ts --selection",
46
50
  "mcp": "bun src/figma-mcp.ts"
47
51
  },
48
52
  "dependencies": {
@@ -280,9 +280,11 @@ export const MCP_HELP =
280
280
  " (`dtwin init --mcp` writes one); you do not normally run it by hand.\n\n" +
281
281
  " It takes no flags. While it runs it owns the bridge, so ordinary `dtwin` commands\n" +
282
282
  " route through it rather than starting a second one — `dtwin doctor` says who holds the port.\n\n" +
283
- " Its tools mirror the CLI: figma_list_clients / figma_list / figma_export_* / figma_screenshot /\n" +
284
- " figma_status / figma_write. Pass writeToDisk:true to get files plus a compact index instead\n" +
285
- " of node payloads inline — asset bytes are never returned inline.";
283
+ " Its tools mirror the CLI: figma_status / figma_whoami / figma_list_clients / figma_list_pages /\n" +
284
+ " figma_list_children / figma_list_libraries / figma_get_selection / figma_screenshot / figma_export_*,\n" +
285
+ " plus figma_write (refused until the designer ticks \"Allow edits\" in the plugin) and two that read\n" +
286
+ " the export on disk: design_get_component / design_drift_lint. Pass writeToDisk:true to get\n" +
287
+ " files plus a compact index instead of node payloads inline — asset bytes are never returned inline.";
286
288
 
287
289
  // Row order is the order of `dtwin --help`'s command summary.
288
290
  export const VERB_ROWS = [
@@ -407,7 +409,7 @@ export const VERB_ROWS = [
407
409
  { word: "doctor", to: "routed", usage: " dtwin doctor [--wait N] [--json] # diagnose the setup; changes nothing. See doctor --help" },
408
410
  { word: "init", to: "routed", usage: " dtwin init [--mcp] [--dry-run] # set up the project you are building. See init --help" },
409
411
  { word: "seed", to: "routed", usage: " dtwin seed [codeRoot] [outDir] # components.json from your Code Connect files. See seed --help", help: SEED_HELP },
410
- { word: "plugin", to: "routed", usage: " dtwin plugin [install|update|status|path] # the Figma plugin's per-user copy, to import once. See plugin --help" },
412
+ { word: "plugin", to: "routed", usage: " dtwin plugin [install|update|status|path|uninstall] # the Figma plugin's per-user copy, to import once. See plugin --help" },
411
413
  { word: "mcp", to: "routed", usage: " dtwin mcp # run the MCP server (what .mcp.json points at). See mcp --help", help: MCP_HELP },
412
414
  { word: "help", to: "--help", usage: " dtwin help = --help" },
413
415
  {
package/src/figma-pull.ts CHANGED
@@ -83,6 +83,7 @@ import { ifDefined, isUnknownArray } from "./json-util.ts";
83
83
  // too): `dtwin --version` prints its version.
84
84
  import pkg from "../package.json" with { type: "json" };
85
85
  import { CliExit, exitCli, exitFlushed, runCli } from "./stdio.ts";
86
+ import { spriteAllowed, versionText } from "./mascot.ts";
86
87
 
87
88
  // --help / -h: print USAGE and exit 0. Handled in cli() BEFORE the settings are read, so help has no
88
89
  // side effects at all — no token minted, no port bound. A constant, not text read back from this file
@@ -110,7 +111,7 @@ ${COMMAND_SUMMARY}
110
111
 
111
112
  Full reference:
112
113
  dtwin init [--mcp] # set up the project you are BUILDING: design/, target.json, the bridge
113
- # token, (optionally) .mcp.json — then prints the steps left. See init --help
114
+ # token, the Figma plugin copy, (optionally) .mcp.json — then the steps left. See init --help
114
115
  dtwin [outDir] # full: design-system.json + current-page frames + assets
115
116
  dtwin [outDir] --all-pages # like full, but frame trees from EVERY page
116
117
  dtwin [outDir] --selection # just the current selection
@@ -893,7 +894,9 @@ async function openSession(parsed: ParsedArgs, settings: BridgeSettings): Promis
893
894
  // one gets more time once a plugin is connected, and with no target or a file name the fresh bridge
894
895
  // settles first (timeouts.ts SETTLE_MS), so a second open file a redial behind is connected — and the
895
896
  // command refused as ambiguous, or listed — instead of the first file to land being taken silently.
896
- timing.connectMs = (await route.connect(client)).waitedMs;
897
+ // No plugin in the whole window fails here (route.ts), so no command prints "plugin connected — …" first; the
898
+ // clients listing alone answers "none".
899
+ timing.connectMs = (await route.connect(client, { listing: command.id === "listClients" })).waitedMs;
897
900
  // Where the time went, for the closing `done in …` line. The connect wait is measured above
898
901
  // (own bridge only — a daemon does its waiting inside the request), the export is the time inside
899
902
  // every request, the write is every write-out (read-run.ts adds both to `timing`). All measured from
@@ -1205,7 +1208,8 @@ async function cli(argv: string[]): Promise<void> {
1205
1208
  }
1206
1209
  }
1207
1210
  if (argv[0] === "--version" || argv[0] === "-v") {
1208
- console.log(pkg.version);
1211
+ // On a terminal, Bo (mascot.ts) beside the name; piped, redirected or with --json, the bare version, as scripts expect.
1212
+ console.log(versionText(pkg.version, spriteAllowed(process.stdout.isTTY === true, argv), process.env));
1209
1213
  exitCli(0);
1210
1214
  }
1211
1215
  if (argv.some((a) => a === "--help" || a === "-h")) {
package/src/init.ts CHANGED
@@ -337,7 +337,7 @@ function plan(cwd: string, { mcp = false, mcpEntry, refuseMcp, probe, token }: P
337
337
  const next = { ...doc, mcpServers: { ...servers, [MCP_KEY]: mcpEntry } };
338
338
  // merge:true — the ONE write allowed onto an existing file: it re-serialises the user's own
339
339
  // document with one key added. Everything else is written with "wx" and cannot clobber.
340
- actions.push({ kind: "write", merge: fs.existsSync(file), path: rel(file), content: JSON.stringify(next, null, 2) + "\n", note: `registered the "${MCP_KEY}" MCP server (enable it with /mcp, then restart Claude Code). While it runs it holds port 8787 — use its writeToDisk:true instead of one-shot dtwin pulls` });
340
+ actions.push({ kind: "write", merge: fs.existsSync(file), path: rel(file), content: JSON.stringify(next, null, 2) + "\n", note: `registered the "${MCP_KEY}" MCP server (restart Claude Code here and approve it when asked; /mcp shows its status). While it runs it holds port 8787 — use its writeToDisk:true instead of one-shot dtwin pulls` });
341
341
  }
342
342
  }
343
343
  }
@@ -362,14 +362,17 @@ function main(argv: string[]): void {
362
362
  if (argv.includes("--help") || argv.includes("-h")) {
363
363
  console.log(
364
364
  "dtwin init [--mcp] [--dry-run]\n\n" +
365
- " Run in the root of the project you are BUILDING. Creates exactly these four things:\n" +
365
+ " Run in the root of the project you are BUILDING. In it, creates exactly these four things\n" +
366
+ " (plus ./.mcp.json with --mcp):\n" +
366
367
  " design/ the directory your decisions live under\n" +
367
368
  " design/export/ where every dtwin pull lands, empty at first — safe to delete and re-pull\n" +
368
369
  " design/README.md which half of design/ a re-pull is allowed to destroy\n" +
369
370
  " design/target.json the detected stack, or profile:null for build-screen to fill in\n" +
370
371
  " It does NOT create codeconnect.local.json, plan/, audit/ or verify/ — those are written later,\n" +
371
372
  " on demand, by map-bootstrap.ts, build-screen, audit-design and verify respectively.\n" +
372
- " …makes sure a bridge token exists, and prints the remaining steps.\n\n" +
373
+ " Outside the project it makes sure a bridge token exists and installs (or refreshes) the Figma\n" +
374
+ " plugin's per-user copy (`dtwin plugin install`; a repo checkout imports its own instead), then\n" +
375
+ " prints the steps left.\n\n" +
373
376
  " --mcp also register the Design Twin MCP server in ./.mcp.json (merged, never overwritten;\n" +
374
377
  " an entry of ours that can no longer start — a moved bun or figma-mcp.ts, a Node-era\n" +
375
378
  " entry — is repaired in place, and the output says so)\n" +
@@ -444,10 +447,10 @@ function main(argv: string[]): void {
444
447
  const steps = [
445
448
  "In Figma DESKTOP, once: Plugins → Development → Import plugin from manifest… → " + (manifest ?? "figma-plugin/manifest.json from a clone of the Design Twin repo (this install does not include it)") +
446
449
  (manifest && hiddenHint(manifest) ? ` (${hiddenHint(manifest) ?? ""})` : ""),
447
- "Run the plugin (Plugins → Development → Design Twin), paste the bridge token into its \"Bridge token\" field, Save: dtwin --show-token | pbcopy",
450
+ "Run the plugin (Plugins → Development → Design Twin), open \"Connect to Claude Code (optional)\", paste the bridge token into \"Bridge token\", Save: dtwin --show-token | pbcopy",
448
451
  ];
449
452
  if (!pluginInstalled) {
450
- steps.push("In Claude Code, once: claude plugin marketplace add <path-or-repo of Design Twin> then claude plugin install designtwin@designtwin-marketplace");
453
+ steps.push("In Claude Code, once: claude plugin marketplace add its-magdy/design-twin then claude plugin install designtwin@designtwin-marketplace");
451
454
  }
452
455
  steps.push(
453
456
  "Then, with the Figma file open and the plugin running: dtwin list → /designtwin:extract → /designtwin:audit-design <screen> → /designtwin:build-screen <screen>"
package/src/mascot.ts ADDED
@@ -0,0 +1,107 @@
1
+ // Bo, the Design Twin mark, as a terminal sprite: the same drawing as assets/logo.svg (a hollow selection box,
2
+ // four magenta handles, two eyes), drawn in half-blocks so one text cell holds two pixels stacked.
3
+ //
4
+ // Shown only by `dtwin --version` / `dtwin version` when stdout is a terminal and no --json was asked for. Piped,
5
+ // redirected or with --json, --version prints the bare version string and nothing else, so a script reading it is
6
+ // unaffected. A locale that is set and is not UTF-8 (LANG=C, a Latin-1 locale) also gets the bare version: the
7
+ // half-blocks would print as mojibake there.
8
+ //
9
+ // The grid is drawn so no cell mixes a handle pixel with an ink pixel. The ink is the terminal's own foreground
10
+ // colour (right on light and dark themes), and only the handles get a colour, so a cell never needs two colours.
11
+ // Colour follows the usual conventions: FORCE_COLOR (1/true/empty = 16 colours, 2 = 256, 3 = truecolor, anything else = off) wins, then
12
+ // NO_COLOR (https://no-color.org) and TERM=dumb turn it off; otherwise the depth is read from COLORTERM / TERM. With
13
+ // colour off the handles print in the foreground colour too: just the outline.
14
+
15
+ // "a" handle (magenta), "k" ink (the terminal's foreground), "." empty. 13 × 12 pixels → 13 × 6 cells.
16
+ export const BO_GRID: readonly string[] = [
17
+ "aaa.......aaa",
18
+ "aaakkkkkkkaaa",
19
+ ".k.........k.",
20
+ ".k.kk...kk.k.",
21
+ ".k.kk...kk.k.",
22
+ ".k.kk...kk.k.",
23
+ ".k.........k.",
24
+ ".k.........k.",
25
+ ".k.........k.",
26
+ ".k.........k.",
27
+ "aaakkkkkkkaaa",
28
+ "aaa.......aaa",
29
+ ];
30
+
31
+ // Brand magenta per colour depth: truecolor #e05aaf (a touch lighter than #d6409f, so it holds up on dark terminals
32
+ // as well as light ones), xterm-256 colour 169 (#d75faf), and bright magenta on a 16-colour terminal.
33
+ export type ColorLevel = 0 | 1 | 2 | 3;
34
+ const HANDLE: Record<Exclude<ColorLevel, 0>, string> = { 3: "\u001b[38;2;224;90;175m", 2: "\u001b[38;5;169m", 1: "\u001b[95m" };
35
+ const RESET = "\u001b[0m";
36
+ type Env = Record<string, string | undefined>;
37
+
38
+ /** The sprite as lines of text, every line 13 cells wide: ▀ / ▄ / █ per cell, handles coloured at `level` (0 = none).
39
+ * Throws on a cell that mixes a handle with ink (it cannot be drawn without picking a background colour). */
40
+ export function boLines(level: ColorLevel): string[] {
41
+ const handle = level === 0 ? "" : HANDLE[level];
42
+ const lines: string[] = [];
43
+ for (let y = 0; y < BO_GRID.length; y += 2) {
44
+ const top = BO_GRID[y] ?? "";
45
+ const bottom = BO_GRID[y + 1] ?? "";
46
+ let line = "";
47
+ let open = false;
48
+ for (let x = 0; x < Math.max(top.length, bottom.length); x++) {
49
+ const t = top[x] ?? ".";
50
+ const b = bottom[x] ?? ".";
51
+ const kinds = new Set([t, b].filter((p) => p !== "."));
52
+ if (kinds.size > 1) throw new Error(`mascot: cell ${x},${y / 2} mixes a handle with ink`);
53
+ const ch = t !== "." && b !== "." ? "█" : t !== "." ? "▀" : b !== "." ? "▄" : " ";
54
+ const wantColor = handle !== "" && kinds.has("a");
55
+ if (wantColor && !open) { line += handle; open = true; }
56
+ if (!wantColor && open) { line += RESET; open = false; }
57
+ line += ch;
58
+ }
59
+ if (open) line += RESET;
60
+ lines.push(line);
61
+ }
62
+ return lines;
63
+ }
64
+
65
+ // The depth the terminal says it has, ignoring the on/off switches.
66
+ function detectedLevel(env: Env, platform: string): Exclude<ColorLevel, 0> {
67
+ const colorterm = (env.COLORTERM ?? "").toLowerCase();
68
+ if (colorterm === "truecolor" || colorterm === "24bit") return 3;
69
+ if (platform === "win32" && env.TERM === undefined) return 3; // Windows Terminal and the Windows 10+ console
70
+ if (/256/.test(env.TERM ?? "") || colorterm !== "") return 2;
71
+ return 1;
72
+ }
73
+
74
+ /** How to colour the handles: FORCE_COLOR first, then NO_COLOR / TERM=dumb, then what the terminal reports. */
75
+ export function colorLevel(env: Env, platform: string = process.platform): ColorLevel {
76
+ const force = env.FORCE_COLOR;
77
+ if (force !== undefined) {
78
+ if (force === "0" || force.toLowerCase() === "false") return 0;
79
+ if (force === "" || force === "1" || force.toLowerCase() === "true") return 1; // as Node and chalk read it
80
+ if (force === "2") return 2;
81
+ if (force === "3") return 3;
82
+ return 0; // any other value: off, as Node reads it
83
+ }
84
+ if (env.NO_COLOR) return 0;
85
+ if (env.TERM === "dumb") return 0;
86
+ return detectedLevel(env, platform);
87
+ }
88
+
89
+ /** Whether the half-block glyphs can be printed: false only when a locale is set and is not UTF-8. */
90
+ export function utf8Locale(env: Env, platform: string = process.platform): boolean {
91
+ if (platform === "win32") return true; // the console decodes Bun's UTF-8 output itself
92
+ const locale = env.LC_ALL || env.LC_CTYPE || env.LANG;
93
+ return !locale || /utf-?8/i.test(locale);
94
+ }
95
+
96
+ /** Whether --version may draw Bo at all: only on an interactive terminal, and never when --json was asked for. */
97
+ export function spriteAllowed(stdoutIsTTY: boolean, argv: readonly string[]): boolean {
98
+ return stdoutIsTTY && !argv.includes("--json");
99
+ }
100
+
101
+ /** What `dtwin --version` prints. `tty` false (piped, redirected, or --json) → the bare version; true → Bo beside it. */
102
+ export function versionText(version: string, tty: boolean, env: Env, platform: string = process.platform): string {
103
+ if (!tty || !utf8Locale(env, platform)) return version;
104
+ const art = boLines(colorLevel(env, platform));
105
+ const text = ["", "", `Design Twin ${version}`, "the Figma ↔ code companion", "", ""];
106
+ return art.map((row, i) => `${row} ${text[i] ?? ""}`.trimEnd()).join("\n");
107
+ }
package/src/route.ts CHANGED
@@ -169,8 +169,10 @@ export interface RouteRequest {
169
169
  export interface Route {
170
170
  /** Spend the connect window (a one-shot route over its own bridge; otherwise nothing to wait for here) —
171
171
  * once: later calls answer the first one's result. Requests and listClients call it themselves; a caller
172
- * calls it first only to time the wait. `target` is what that first wait waits for. */
173
- connect(target?: string | null): Promise<{ waitedMs: number }>;
172
+ * calls it first only to time the wait. `target` is what that first wait waits for. A window in which no plugin
173
+ * ever connected throws the never-connected text here, before the caller says what it is about to do — unless
174
+ * `listing` (the clients listing, whose answer is then "none"). */
175
+ connect(target?: string | null, o?: { listing?: boolean }): Promise<{ waitedMs: number }>;
174
176
  request<C extends Cmd>(cmd: C, args: Commands[C]["args"], o?: RouteRequest): Promise<Sent<C>>;
175
177
  listClients(): Promise<ClientRow[]>;
176
178
  /** The socket half of whoami for `connId` (the connection a request was answered by), and who holds it. */
@@ -206,8 +208,9 @@ export function createRoute(upstream: () => Promise<Upstream>, policy: RoutePoli
206
208
  }
207
209
 
208
210
  return {
209
- async connect(target) {
210
- const { waitedMs } = await spend(await upstream(), target);
211
+ async connect(target, { listing = false } = {}) {
212
+ const { waitedMs, everConnected } = await spend(await upstream(), target);
213
+ if (everConnected === false && !listing) throw neverConnected(policy.connectWaitMs);
211
214
  return { waitedMs };
212
215
  },
213
216
  async request(cmd, args, o = {}) {