@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 +38 -12
- package/figma-plugin/code.js +1 -1
- package/figma-plugin/ui.html +8 -2
- package/package.json +11 -7
- package/src/cli-commands.ts +6 -4
- package/src/figma-pull.ts +7 -3
- package/src/init.ts +8 -5
- package/src/mascot.ts +107 -0
- package/src/route.ts +7 -4
package/README.md
CHANGED
|
@@ -1,10 +1,32 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
4
|
+
# Design Twin — `dtwin`
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
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
|
|
79
|
-
|
|
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`
|
|
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"
|
|
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. **
|
|
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
|
package/figma-plugin/code.js
CHANGED
|
@@ -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.
|
|
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.
|
package/figma-plugin/ui.html
CHANGED
|
@@ -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.
|
|
4
|
-
"description": "
|
|
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
|
|
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
|
-
"
|
|
17
|
-
"
|
|
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
|
-
"
|
|
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
|
|
49
|
+
"pull:selection": "bun src/figma-pull.ts --selection",
|
|
46
50
|
"mcp": "bun src/figma-mcp.ts"
|
|
47
51
|
},
|
|
48
52
|
"dependencies": {
|
package/src/cli-commands.ts
CHANGED
|
@@ -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:
|
|
284
|
-
"
|
|
285
|
-
"
|
|
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]
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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.
|
|
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
|
-
"
|
|
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
|
|
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
|
|
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
|
-
|
|
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 = {}) {
|