A Design workspace in Claude Code: see design options for a UI as images, select one, ask for more, ask for a change with a note and hand an option off to be…

Open a Design workspace in Claude Code: a pane that shows design options for an app or a website as images. Select the one you like, ask for more options with a note, ask for a change to an option with a note, and hand an option off to be built.
Your agent runs the 12ui command line once per request, which draws every option at once with your own Codex sign-in and image generation, and adds each option to the workspace as it finishes. Your agent never draws in the conversation. There is no 12ui account and no sign-in: a workspace is reached through a private handle held in your session. Images are stored for up to 30 days and removed after 14 days without use.
Works in Claude Code on a computer with Node.js; your agent installs the 12ui command line once. Drawing needs Codex signed in with ChatGPT on the same computer; without it nothing is drawn and each option shows the reason. The pane needs Claude Code 2.1.287 or later, in the terminal or the Desktop app's Code tab. The draw command reaches design.12ui.com, and Claude Code asks you to approve it.
Version 0.2.84. Install with claude plugin marketplace add just-every/12ui-claude-plugin, then claude plugin install 12ui@12ui.
npx -y @12ui/design cli install.12ui auth login).12ui-design). It runs npx -y @12ui/design cli install once, from npm and unpinned, then 12ui commands. Those commands reach https://12ui.com (hosted draft, branch, conversion, the design corpus and improve kits) and https://design.12ui.com (workspace plans, image upload and download), and run codex exec locally when Codex is ready. The skill also downloads one workspace image with curl from design.12ui.com.https://design.12ui.com/mcp: anonymous. It stores the brief and images; images are kept up to 30 days and removed after 14 days without use.hooks/. It never draws or converts anything itself, runs no processes, reads and writes no files, and reads no environment or settings.Hooks it registers, each with what it is for:
session.start: registers the /12ui commandtool.call{tool=mcp__plugin_12ui_12ui-workspace__design_slate_create|mcp__plugin_12ui_12ui-workspace__design_slate_show}: observes only this plugin's own create and show tools, to learn the workspace handle and open the pane; it never changes or blocks the callcommand.run{command="12ui"}: answers /12ui: opens the pane, or lists the options as text where nothing drawsui.close{id=design-workspace}: stops polling when the pane closesui.render{component=Pane}: draws the Design workspace pane and passes every other plugin's pane on untouchedCalls it makes, each with its purpose:
$.clock.after: schedules the next workspace refresh and the follow-up check after a click$.clock.every: redraws the start countdown once a second while it runs$.clock.now: reads the time to count the start countdown down$.command.register: registers the /12ui command$.http.fetch: reads the workspace and records your clicks at https://design.12ui.com/mcp$.prompt.submit: tells Claude what you clicked in the pane (a selection, a request for options, a change, a hand-off), framed as from this plugin and never with your own words$.session.surfaces: checks whether the session draws anything before opening the pane$.ui.blit: asks whether the terminal shows pictures, to switch to colour cells where it cannot$.ui.invalidate: redraws the pane when the workspace changes$.ui.log: writes diagnostic lines to Claude Code's debug log$.ui.open: opens the Design workspace pane$.ui.resolve: reads the elements the surface draws with$.ui.toast: says the workspace is ready when the terminal is too narrow to show the paneThe one network destination it reaches: https://design.12ui.com/mcp.
claude -p or Chat.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC is set./12ui <handle>.| Name | Version | License | Source |
|---|---|---|---|
| jpeg-js decoder | 0.4.4 | Apache-2.0 (file header); BSD-3-Clause (package) | https://github.com/jpeg-js/jpeg-js |
MIT. See LICENSE.
hooks/register.mjs 663 lines1/**
2 * The 12ui Design workspace pane for Claude Code: the glue that wires the hooks to the modules in ./workspace.
3 *
4 * Hooks:
5 * session.start registers /12ui
6 * tool.call (create and show only) observes the plugin's own two tools: learns the workspace handle, opens the pane
7 * command.run (/12ui) opens the pane, or answers in text where nothing draws
8 * ui.render (every Pane) draws the workspace in its own pane; passes every other pane on
9 * ui.close (the pane) stops polling when the pane closes
10 *
11 * Every `$` call lives in this file, because the engine follows `$` only into functions declared in the hooks module
12 * itself. The calls: `$.http.fetch` to the plugin's own MCP server (https://design.12ui.com/mcp, the one network
13 * destination), `$.prompt.submit` to tell Claude what the person clicked, `$.ui.*` to draw, `$.clock.*` to poll and
14 * count down, `$.session.surfaces` to know whether anything draws, `$.command.register` for /12ui. It never draws or
15 * converts anything itself, runs processes, reads or writes files, reads the environment or settings, calls a model,
16 * or touches any other tool's calls.
17 */
18
19import { ActionError, continueArgs, convertHandoffArgs, holdArgs, inspireArgs, pickArgs, runBranchArgs, runEditArgs, runNewArgs } from './workspace/actions.mjs';
20import { uuidV4 } from './workspace/bytes.mjs';
21import { COMMAND_DESCRIPTION, COMMAND_HINT, COMMAND_NAME, LOADING, NO_ROUND_WAITING, NO_WORKSPACE, OPENED, PANE_ID, PANE_TITLE, READY_TOAST, RECORDED_RUNNER, SENT, notAHandle, workspaceText } from './workspace/copy.mjs';
22import { PER_ROUND, SEARCH_COUNT, inspirationIds, perRoundOf, pickLimit, picksOf, referenceSize, sameList, siteItems, togglePick, twelveDraws, waitingRound } from './workspace/gallery.mjs';
23import { commandTarget, handleFromCall, noteHandle } from './workspace/handles.mjs';
24import { afterRun, buildMessage, judgeRunner, runMessage } from './workspace/messages.mjs';
25import { paneTree } from './workspace/pane.mjs';
26import { pictureMaxRows, tileGrid } from './workspace/layout.mjs';
27import { blitSaysAlt, decodeThumb, desktopPictureSize, imageSource, pictureKind, rasterCells, svgPicture, tileBox, useCellsFor } from './workspace/picture.mjs';
28import { nextStep, pollDelay, retriesFirstRead, wantsPolling } from './workspace/poll.mjs';
29import { MCP_URL, TOOLS, WorkspaceError, errorLine, imageBlock, mendsOnRetry, readRpc, rpcInit } from './workspace/transport.mjs';
30import { briefLine, countdownLine, countdownOf, findOp, hasRunning, isView, labelOfVersion, opWaitsOnPerson, shownVersionIds, tilesOf } from './workspace/view.mjs';
31
32// ---- the session's state: module memory, as the hosted screen keeps it (a reload starts over) ----
33let seen = [];
34let current = null;
35const views = new Map();
36// Thumbnails, request-id drafts and clicks in flight are kept per workspace: the server mints option and version ids per
37// workspace (every workspace's first option is `r1_a` / `v_r1_a`), so an id alone names nothing across workspaces.
38const thumbs = new Map();
39const drafts = new Map();
40const inFlight = new Set();
41const runnerWatch = new Map();
42// What the person is doing in the pane: the tab, the prompt line, the waiting round's picks, the large view.
43const ui = { tab: 'inspiration', autoTab: false, prompt: '', promptEdited: false, chosen: null, chosenOp: null, heldOp: null, pauseOp: null, lv: null, perRound: null, nowMs: 0 };
44const poll = { timer: null, step: 0, busy: false, isOpen: false, failedRetryably: false };
45const blit = { altDrawn: false, toggled: null, probed: false, lastAnswer: null };
46let ticker = null;
47let pollError = '';
48let actionError = '';
49let noticeText = '';
50let commandLogged = false;
51
52// ---- the wire ----
53async function rpc($, name, args) {
54 const response = await $.http.fetch(MCP_URL, rpcInit(name, args));
55 return readRpc(response);
56}
57
58async function canDraw($) {
59 return (await $.session.surfaces()).length > 0;
60}
61
62function viewOf(runDir) {
63 const entry = runDir ? views.get(runDir) : null;
64 return entry ? entry.view : null;
65}
66
67/** The key of anything kept per workspace and per item: the workspace handle, then the item's own parts. */
68function workspaceKey(runDir, ...parts) {
69 return [runDir, ...parts].join('\u0000');
70}
71
72function draftId(key) {
73 if (!drafts.has(key)) drafts.set(key, uuidV4());
74 return drafts.get(key);
75}
76
77// ---- the view, the pictures and the polling ----
78async function applyView($, runDir, view) {
79 views.set(runDir, { view, receivedAtMs: await $.clock.now() });
80 syncTicker($);
81 if (runDir === current) followPause(view, views.get(runDir).receivedAtMs);
82 for (const versionId of shownVersionIds(view)) {
83 const key = workspaceKey(runDir, versionId);
84 if (!thumbs.has(key)) void fetchThumb($, key, TOOLS.image, { runDir, versionId, size: 'thumb' });
85 }
86 for (const id of [...inspirationIds(view), ...siteItems(view).map((site) => site.id)]) {
87 const key = workspaceKey(runDir, `ref:${id}`);
88 if (!thumbs.has(key)) void fetchThumb($, key, TOOLS.reference, { runDir, referenceId: id, size: 'thumb' });
89 }
90 $.ui.invalidate('ui.render');
91}
92
93/**
94 * A round that starts waiting on the person opens the pane on Inspiration with its picks preselected; when the wait
95 * ends (Continue, or the countdown ran out) a pane the round put on Inspiration moves to Designs, as the screen does.
96 */
97function followPause(view, receivedAtMs) {
98 const countdown = countdownOf(view, receivedAtMs);
99 if (countdown && ui.pauseOp !== countdown.opId) {
100 ui.pauseOp = countdown.opId;
101 ui.chosen = null;
102 ui.chosenOp = null;
103 if (ui.tab !== 'inspiration') ui.tab = 'inspiration';
104 ui.autoTab = true;
105 } else if (!countdown && ui.pauseOp) {
106 ui.pauseOp = null;
107 if (ui.autoTab) ui.tab = 'designs';
108 ui.autoTab = false;
109 }
110}
111
112async function fetchThumb($, key, tool, args) {
113 thumbs.set(key, { state: 'loading' });
114 try {
115 const { content } = await rpc($, tool, args);
116 const block = imageBlock(content);
117 if (!block) throw new WorkspaceError('No picture came back.');
118 thumbs.set(key, { state: 'ready', jpeg: block.data, mimeType: block.mimeType, cache: new Map() });
119 } catch (error) {
120 thumbs.set(key, { state: 'failed', reason: errorLine(error) });
121 }
122 $.ui.invalidate('ui.render');
123}
124
125function stopPolling() {
126 if (poll.timer) poll.timer.cancel();
127 poll.timer = null;
128}
129
130function stopTicker() {
131 if (ticker) ticker.cancel();
132 ticker = null;
133}
134
135function syncTicker($) {
136 const entry = views.get(current);
137 const countdown = entry ? countdownOf(entry.view, entry.receivedAtMs) : null;
138 if (poll.isOpen && countdown && countdown.state === 'countdown') {
139 if (!ticker) ticker = $.clock.every(1000, () => $.ui.invalidate('ui.render'));
140 } else stopTicker();
141}
142
143async function schedulePoll($) {
144 stopPolling();
145 const entry = views.get(current);
146 const running = wantsPolling({ isOpen: poll.isOpen, hasWorkspace: Boolean(entry), isRunning: Boolean(entry && hasRunning(entry.view)) });
147 const retrying = retriesFirstRead({ isOpen: poll.isOpen, hasView: Boolean(entry), failedRetryably: poll.failedRetryably });
148 if (!running && !retrying) return;
149 const delay = pollDelay(poll.step, entry ? countdownOf(entry.view, entry.receivedAtMs) : null, await $.clock.now());
150 poll.timer = $.clock.after(delay, () => { void refresh($); });
151}
152
153/** Read the shown workspace now, once the current turn of the clock is through (a pane in sight only). */
154function readSoon($) {
155 if (poll.isOpen && current) $.clock.after(0, () => { void refresh($); });
156}
157
158/**
159 * One status read for the shown workspace (the screen in sight), then the next one if anything still runs or a first
160 * read failed in a way a retry can mend. A read only ever lands on the workspace it asked about: when the person
161 * switched workspaces while it was out, its answer is dropped and the workspace now shown is read instead (the read
162 * the switch asked for found this one still out and was dropped).
163 */
164async function refresh($) {
165 const runDir = current;
166 if (!runDir || poll.busy) return;
167 poll.busy = true;
168 let changed = false;
169 try {
170 const known = viewOf(runDir);
171 const { structured } = await rpc($, TOOLS.status, known ? { runDir, since: known.stamp } : { runDir });
172 if (isView(structured) && runDir === current) {
173 changed = true;
174 pollError = '';
175 poll.failedRetryably = false;
176 await applyView($, runDir, structured);
177 }
178 } catch (error) {
179 if (runDir === current) {
180 pollError = errorLine(error);
181 poll.failedRetryably = mendsOnRetry(error);
182 $.ui.invalidate('ui.render');
183 }
184 } finally {
185 poll.busy = false;
186 }
187 if (runDir !== current) {
188 if (poll.isOpen) await refresh($);
189 return;
190 }
191 poll.step = nextStep(poll.step, changed);
192 await schedulePoll($);
193}
194
195/**
196 * Show `runDir` in the pane. A different workspace starts over and, with the pane open, is read at once (an open pane
197 * reads nothing else for a workspace it holds no view of). The workspace already shown is read again only while the
198 * pane holds no view of it, as after a first read the server refused: that is how `/12ui` asks once more.
199 */
200function showWorkspace($, runDir) {
201 if (runDir === current) {
202 if (!views.has(runDir)) readSoon($);
203 return;
204 }
205 current = runDir;
206 stopPolling();
207 stopTicker();
208 poll.step = 0;
209 poll.failedRetryably = false;
210 pollError = '';
211 actionError = '';
212 noticeText = '';
213 ui.lv = null;
214 ui.pauseOp = null;
215 ui.promptEdited = false;
216 $.ui.invalidate('ui.render');
217 readSoon($);
218}
219
220async function openPane($, asked) {
221 const opened = await $.ui.open(asked ? { id: PANE_ID, title: PANE_TITLE, focus: true } : { id: PANE_ID, title: PANE_TITLE });
222 if (!opened.isPlaced && !asked) $.ui.toast(READY_TOAST);
223 return opened;
224}
225
226// ---- telling Claude ----
227function tell($, text) {
228 $.prompt.submit({ text }).catch((error) => {
229 actionError = `Recorded, but Claude Code did not take the message: ${error && error.message ? error.message : String(error)}`;
230 $.ui.invalidate('ui.render');
231 });
232 noticeText = SENT;
233}
234
235async function runnerCheck($, runDir, opId) {
236 const watch = runnerWatch.get(opId);
237 if (!watch) return;
238 try {
239 const { structured } = await rpc($, TOOLS.status, { runDir });
240 if (isView(structured)) await applyView($, runDir, structured);
241 } catch {
242 // The check judges on the last view the pane holds, as the hosted screen does.
243 }
244 const view = viewOf(runDir);
245 const op = view ? findOp(view, opId) : null;
246 const verdict = judgeRunner(op, view ? opWaitsOnPerson(view, op) : false, watch);
247 if (verdict.drop) {
248 runnerWatch.delete(opId);
249 return;
250 }
251 if (verdict.tell) {
252 runnerWatch.delete(opId);
253 tell($, watch.message);
254 $.ui.invalidate('ui.render');
255 return;
256 }
257 watch.waited = verdict.waited;
258 $.clock.after(verdict.waitMs, () => { void runnerCheck($, runDir, opId); });
259}
260
261function afterRunRecorded($, runDir, opId, message, runner) {
262 const first = afterRun(runner);
263 if (first.tell) {
264 tell($, message);
265 return;
266 }
267 noticeText = RECORDED_RUNNER;
268 runnerWatch.set(opId, { message, waited: false });
269 $.clock.after(first.waitMs, () => { void runnerCheck($, runDir, opId); });
270}
271
272// ---- the clicks ----
273async function record($, key, work) {
274 if (inFlight.has(key)) return;
275 inFlight.add(key);
276 actionError = '';
277 noticeText = '';
278 $.ui.invalidate('ui.render');
279 try {
280 await work();
281 drafts.delete(key);
282 } catch (error) {
283 actionError = error instanceof ActionError ? error.message : errorLine(error);
284 // A request the server answered and refused is done with: the next try is a new request. One that may not have
285 // reached it (no answer, or a retryable status) keeps its id, so trying again cannot record it twice.
286 if (error instanceof WorkspaceError && !error.retryable) drafts.delete(key);
287 } finally {
288 inFlight.delete(key);
289 $.ui.invalidate('ui.render');
290 }
291}
292
293async function recordRun($, key, args) {
294 const runDir = args.runDir;
295 const { structured } = await rpc($, TOOLS.run, args);
296 const view = structured && isView(structured.view) ? structured.view : viewOf(runDir);
297 const message = runMessage(runDir, args.opId, args.action, (versionId) => (view ? labelOfVersion(view, versionId) : null));
298 if (structured && isView(structured.view)) await applyView($, runDir, structured.view);
299 afterRunRecorded($, runDir, args.opId, message, structured ? structured.runner : null);
300 poll.step = 0;
301 await schedulePoll($);
302}
303
304/** The waiting round's op, its picks and whether the person changed them, for the shown workspace. */
305function pauseNow() {
306 const entry = views.get(current);
307 const countdown = entry ? countdownOf(entry.view, entry.receivedAtMs) : null;
308 if (!entry || !countdown) return null;
309 const round = waitingRound(entry.view);
310 const picks = picksOf(entry.view, ui.chosenOp === countdown.opId ? ui.chosen : null);
311 return { view: entry.view, countdown, round, picks, changed: Boolean(round && ui.chosenOp === countdown.opId && ui.chosen && !sameList(ui.chosen, round.referenceIds || [])) };
312}
313
314/** The screen holds a waiting round's countdown once the person touches it; the pane does the same on the first pick. */
315function holdOnce($) {
316 const pause = pauseNow();
317 if (!pause || pause.countdown.state !== 'countdown' || ui.heldOp === pause.countdown.opId) return;
318 ui.heldOp = pause.countdown.opId;
319 const runDir = current;
320 rpc($, TOOLS.run, holdArgs(runDir, pause.countdown.opId)).then(async ({ structured }) => {
321 if (structured && isView(structured.view) && runDir === current) await applyView($, runDir, structured.view);
322 }, (error) => { actionError = errorLine(error); $.ui.invalidate('ui.render'); });
323}
324
325function togglePickOf($, id) {
326 const pause = pauseNow();
327 if (!pause) {
328 noticeText = NO_ROUND_WAITING;
329 $.ui.invalidate('ui.render');
330 return;
331 }
332 const next = togglePick(pause.picks, id, pickLimit(pause.view));
333 if (next.refused) noticeText = `At most ${pickLimit(pause.view)} picks.`;
334 else noticeText = '';
335 ui.chosen = next.picks;
336 ui.chosenOp = pause.countdown.opId;
337 holdOnce($);
338 $.ui.invalidate('ui.render');
339}
340
341function continueRound($) {
342 const pause = pauseNow();
343 if (!pause) return Promise.resolve();
344 const runDir = current;
345 const opId = pause.countdown.opId;
346 const brief = ui.promptEdited ? ui.prompt : '';
347 return record($, workspaceKey(runDir, 'continue', opId), async () => {
348 const { structured } = await rpc($, TOOLS.run, continueArgs(runDir, opId, { referenceIds: pause.changed ? pause.picks : null, brief }));
349 ui.tab = 'designs';
350 ui.autoTab = false;
351 if (structured && isView(structured.view)) await applyView($, runDir, structured.view);
352 poll.step = 0;
353 await schedulePoll($);
354 });
355}
356
357function searchPrompt($, words) {
358 const runDir = current;
359 ui.prompt = String(words ?? ui.prompt);
360 ui.promptEdited = true;
361 holdOnce($);
362 return record($, workspaceKey(runDir, 'inspire', ui.prompt), async () => {
363 const { structured } = await rpc($, TOOLS.inspire, inspireArgs(runDir, ui.prompt, SEARCH_COUNT));
364 if (structured && isView(structured.view)) await applyView($, runDir, structured.view);
365 else await refresh($);
366 ui.tab = 'inspiration';
367 });
368}
369
370function setPerRound($, n) {
371 const view = viewOf(current);
372 ui.perRound = n;
373 $.ui.invalidate('ui.render');
374 if (n !== 12 || !view || !twelveDraws(view, hasRunning(view))) return Promise.resolve();
375 const runDir = current;
376 const key = workspaceKey(runDir, 'more');
377 return record($, key, () => recordRun($, key, runNewArgs(runDir, draftId(key), { count: 12 })));
378}
379
380function largeTile() {
381 const view = viewOf(current);
382 return view && ui.lv ? tilesOf(view).find((tile) => tile.candidateId === ui.lv.candidateId) ?? null : null;
383}
384
385/** Build this: record the pick, record the Convert-to-HTML hand-off (the request Claude builds from), then tell Claude. */
386function buildThis($) {
387 const tile = largeTile();
388 if (!tile || !tile.versionId) return Promise.resolve();
389 const runDir = current;
390 const key = workspaceKey(runDir, 'build', tile.versionId);
391 return record($, key, async () => {
392 const { structured } = await rpc($, TOOLS.pick, pickArgs(runDir, tile.versionId));
393 if (structured && isView(structured.view)) await applyView($, runDir, structured.view);
394 // One handoff id per version, kept until the whole click succeeded, so a retry never records a second request.
395 const handoff = await rpc($, TOOLS.handoff, convertHandoffArgs(runDir, draftId(key), tile.versionId));
396 if (handoff.structured && isView(handoff.structured.view)) await applyView($, runDir, handoff.structured.view);
397 const label = structured && structured.pick && structured.pick.label ? structured.pick.label : tile.label;
398 tell($, buildMessage(runDir, label));
399 });
400}
401
402function editDesign($, words) {
403 const tile = largeTile();
404 if (!tile || !tile.versionId) return Promise.resolve();
405 const runDir = current;
406 const key = workspaceKey(runDir, 'edit', tile.versionId);
407 return record($, key, async () => {
408 await recordRun($, key, runEditArgs(runDir, draftId(key), tile.versionId, words));
409 ui.lv = null;
410 });
411}
412
413function branchDesign($, scope, pagesText) {
414 const tile = largeTile();
415 if (!tile || !tile.versionId) return Promise.resolve();
416 const runDir = current;
417 // One draft per version and scope: a Full page retry cannot repeat as More pages, nor the reverse.
418 const key = workspaceKey(runDir, 'branch', tile.versionId, scope);
419 return record($, key, async () => {
420 await recordRun($, key, runBranchArgs(runDir, draftId(key), tile.versionId, scope, pagesText));
421 ui.lv = null;
422 });
423}
424
425// ---- drawing ----
426async function probeBlit($, key, source) {
427 if (blit.probed) return;
428 blit.probed = true;
429 const answer = await $.ui.blit({ requestId: PANE_ID, key, source });
430 blit.lastAnswer = answer;
431 $.ui.log(`blit probe: ${JSON.stringify(answer)}`, { to: 'debug' });
432 if (blitSaysAlt(answer)) {
433 blit.altDrawn = true;
434 $.ui.invalidate('ui.render');
435 }
436}
437
438/**
439 * New thumbnail decodes one render may run. The JPEG and WebP decoders are pure JS on the hooks worker, which must
440 * answer the engine's heartbeat within 5 s or the engine unloads the mod (seen live 2026-10-02: one render decoding a
441 * page of references wedged the worker and the pane vanished). The rest show as loading and the next render takes them.
442 */
443const DECODES_PER_RENDER = 2;
444/** Image source one pane tree may carry: under the engine's 2 MiB cap on a tree's Image sources. */
445const IMAGE_TREE_BYTES = 1_800_000;
446const decodes = { left: DECODES_PER_RENDER, deferred: false };
447
448/** The picture of one thumbnail (a design version or a reference) for a box `tileColumns` wide. */
449function pictureFor(surface, thumbKey, size, useCells, tileColumns, maxRows) {
450 const thumb = thumbs.get(thumbKey);
451 if (!thumb) return null;
452 if (thumb.state === 'loading') return { kind: 'loading' };
453 if (thumb.state === 'failed') return { kind: 'failed', reason: thumb.reason };
454 const kind = pictureKind(surface, { useCells });
455 if (kind !== 'svg' && !thumb.decoded) {
456 if (decodes.left <= 0) {
457 decodes.deferred = true;
458 return { kind: 'loading' };
459 }
460 decodes.left -= 1;
461 }
462 const box = tileBox(tileColumns, size.width, size.height, maxRows);
463 const cacheKey = `${kind}:${box.columns}x${box.rows}`;
464 if (thumb.cache.has(cacheKey)) return thumb.cache.get(cacheKey);
465 let picture;
466 try {
467 if (kind === 'svg') {
468 const width = size.width || 480;
469 const height = size.height || 270;
470 const svg = svgPicture({ jpegBase64: thumb.jpeg, width, height, mimeType: thumb.mimeType }, () => decodedOf(thumb));
471 picture = { kind, source: svg.source, ...desktopPictureSize(box, width, height) };
472 } else if (kind === 'raster') {
473 picture = { kind, ...rasterCells(decodedOf(thumb), box.columns, box.rows) };
474 } else {
475 picture = { kind, source: imageSource(decodedOf(thumb), box), columns: box.columns, rows: box.rows };
476 }
477 } catch (error) {
478 picture = { kind: 'failed', reason: error && error.message ? error.message : String(error) };
479 }
480 thumb.cache.set(cacheKey, picture);
481 return picture;
482}
483
484function decodedOf(thumb) {
485 if (!thumb.decoded) thumb.decoded = decodeThumb(thumb.jpeg, thumb.mimeType);
486 return thumb.decoded;
487}
488
489/** A reference's size: the corpus record, else the decoded picture's own once it is in. */
490function refSize(view, id) {
491 const thumb = thumbs.get(workspaceKey(current, `ref:${id}`));
492 if (thumb && thumb.state === 'ready' && thumb.decoded) return { width: thumb.decoded.width, height: thumb.decoded.height };
493 return referenceSize(view, id);
494}
495
496function paneModel($, e, els) {
497 const surface = e.surface;
498 const bodyColumns = e.props && typeof e.props.bodyColumns === 'number' ? e.props.bodyColumns : 80;
499 const maxRows = pictureMaxRows(e.props && e.props.scroll ? e.props.scroll.bodyRows : undefined);
500 const grid = tileGrid(bodyColumns);
501 const tileColumns = Math.max(8, grid.columns - 2);
502 const entry = current ? views.get(current) : null;
503 const view = entry ? entry.view : null;
504 const useCells = useCellsFor({ toggled: blit.toggled, altDrawn: blit.altDrawn });
505 decodes.left = DECODES_PER_RENDER;
506 decodes.deferred = false;
507 let probe = null;
508 let imageBytes = 0;
509 const remember = (key, picture) => {
510 if (!picture || picture.kind !== 'image') return picture;
511 // The engine refuses a whole tree past IMAGE_TREE_BYTES of Image source and draws an empty pane (seen live
512 // 2026-10-02 on a page of tall references before the blit probe answered): a picture past the budget waits.
513 imageBytes += picture.source.length;
514 if (imageBytes > IMAGE_TREE_BYTES) return { kind: 'loading' };
515 if (!probe) probe = { key: `picture:${key}`, source: picture.source };
516 return picture;
517 };
518 const model = {
519 surface,
520 workspace: current,
521 emptyText: current ? LOADING : NO_WORKSPACE,
522 isLoading: !view,
523 prompt: ui.prompt,
524 tab: ui.tab,
525 noticeText,
526 errorText: actionError || pollError,
527 tileColumns,
528 items: [],
529 tiles: [],
530 lv: null,
531 tray: null,
532 selectedCount: 0,
533 emptyLine: '',
534 perRound: 6,
535 perRoundOptions: PER_ROUND,
536 };
537 if (!view) return { model, probe };
538 if (!ui.promptEdited) model.prompt = ui.prompt = briefLine(view);
539 const pause = pauseNow();
540 const picks = pause ? pause.picks : [];
541 model.selectedCount = pause ? picks.length : (view.pick ? 1 : 0);
542 if (pause) {
543 const nowWords = countdownLine(pause.countdown, ui.nowMs);
544 model.tray = { words: `${picks.length} picked · ${nowWords}`, canContinue: picks.length > 0 };
545 }
546 if (ui.tab === 'designs') {
547 model.perRound = perRoundOf(view, ui.perRound);
548 if (ui.lv) {
549 const tile = largeTile();
550 if (tile) {
551 const lvColumns = Math.max(16, bodyColumns - 4);
552 const picture = tile.versionId ? pictureFor(surface, workspaceKey(current, tile.versionId), { width: tile.width, height: tile.height }, useCells, lvColumns, Math.max(maxRows, 30)) : null;
553 model.lv = { tile, picture: remember(`lv:${tile.candidateId}`, picture), mode: ui.lv.mode, scope: ui.lv.scope, pages: ui.lv.pages };
554 } else ui.lv = null;
555 }
556 model.tiles = tilesOf(view).map((tile) => ({
557 ...tile,
558 picture: tile.versionId ? remember(tile.candidateId, pictureFor(surface, workspaceKey(current, tile.versionId), { width: tile.width, height: tile.height }, useCells, tileColumns, maxRows)) : null,
559 }));
560 } else {
561 const ids = ui.tab === 'references' ? siteItems(view) : inspirationIds(view).map((id) => ({ id, title: '' }));
562 model.emptyLine = ui.tab === 'references' ? (pause ? 'Finding live sites.' : 'No website references yet.') : 'No inspiration yet.';
563 model.items = ids.map((item) => ({
564 ...item,
565 selected: picks.includes(item.id),
566 picture: remember(item.id, pictureFor(surface, workspaceKey(current, `ref:${item.id}`), refSize(view, item.id), useCells, tileColumns, maxRows)),
567 }));
568 }
569 return { model, probe };
570}
571
572export function register(on) {
573 on('session.start', async ($, e, next) => {
574 try {
575 await $.command.register({ name: COMMAND_NAME, description: COMMAND_DESCRIPTION, argumentHint: COMMAND_HINT });
576 } catch (error) {
577 if (!commandLogged) {
578 commandLogged = true;
579 $.ui.log(`/12ui is not available (${error && error.message ? error.message : String(error)}); the Design workspace still opens after Claude opens one.`, { to: 'debug' });
580 }
581 }
582 return next(e);
583 });
584
585 on('tool.call', { tool: ['mcp__plugin_12ui_12ui-workspace__design_slate_create', 'mcp__plugin_12ui_12ui-workspace__design_slate_show'] }, async ($, e, next) => {
586 const result = await next(e);
587 const handle = handleFromCall(e, result);
588 if (handle) {
589 seen = noteHandle(seen, handle);
590 showWorkspace($, handle);
591 // The tool's result is the model's whatever the pane does: a pane that cannot open is logged, never thrown.
592 try {
593 if (await canDraw($)) await openPane($, false);
594 } catch (error) {
595 $.ui.log(`the Design workspace pane did not open: ${error && error.message ? error.message : String(error)}`, { to: 'debug' });
596 }
597 }
598 return result;
599 });
600
601 on('command.run', { command: '12ui' }, async ($, e) => {
602 const target = commandTarget(e.args, seen);
603 if (target.none) return { text: NO_WORKSPACE };
604 if (target.invalid) return { text: notAHandle(target.invalid) };
605 seen = noteHandle(seen, target.handle);
606 showWorkspace($, target.handle);
607 if (!(await canDraw($))) {
608 try {
609 const { structured } = await rpc($, TOOLS.show, { runDir: target.handle });
610 if (!isView(structured)) return { text: 'The Design workspace sent no options.' };
611 return { text: workspaceText(target.handle, briefLine(structured), tilesOf(structured)) };
612 } catch (error) {
613 return { text: errorLine(error) };
614 }
615 }
616 await openPane($, true);
617 return { text: OPENED };
618 });
619
620 on('ui.close', { id: 'design-workspace' }, async ($, e, next) => {
621 poll.isOpen = false;
622 stopPolling();
623 stopTicker();
624 return next(e);
625 });
626
627 // Matched on every Pane, as design 3.7 spells the hook; only this mod's own pane is drawn here, and any other pane goes
628 // on down the chain untouched.
629 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
630 if (e.requestId !== PANE_ID) return next(e);
631 const els = $.ui.resolve(e);
632 if (!poll.isOpen) {
633 poll.isOpen = true;
634 readSoon($);
635 }
636 ui.nowMs = await $.clock.now();
637 const { model, probe } = paneModel($, e, els);
638 if (decodes.deferred) $.clock.after(50, () => { $.ui.invalidate('ui.render'); });
639 if (probe && !blit.probed) $.clock.after(250, () => { void probeBlit($, probe.key, probe.source); });
640 model.handlers = {
641 promptInput: (value) => { ui.prompt = String(value ?? ''); ui.promptEdited = true; },
642 promptSubmit: (value) => { void searchPrompt($, value); },
643 tab: (name) => { ui.tab = name; ui.autoTab = false; ui.lv = null; $.ui.invalidate('ui.render'); },
644 toggle: (id) => { togglePickOf($, id); },
645 continueRound: () => { void continueRound($); },
646 perRound: (n) => { void setPerRound($, n); },
647 open: (tile) => { ui.lv = { candidateId: tile.candidateId, mode: null, scope: null, pages: '' }; $.ui.invalidate('ui.render'); },
648 close: () => { ui.lv = null; $.ui.invalidate('ui.render'); },
649 edit: () => { if (ui.lv) { ui.lv.mode = ui.lv.mode === 'edit' ? null : 'edit'; ui.lv.scope = null; } $.ui.invalidate('ui.render'); },
650 editSubmit: (words) => { void editDesign($, words); },
651 branch: () => { if (ui.lv) { ui.lv.mode = ui.lv.mode === 'branch' ? null : 'branch'; ui.lv.scope = null; } $.ui.invalidate('ui.render'); },
652 branchScope: (scope) => {
653 if (scope === 'page') void branchDesign($, 'page', '');
654 else if (ui.lv) { ui.lv.scope = 'site'; $.ui.invalidate('ui.render'); }
655 },
656 branchPagesInput: (value) => { if (ui.lv) ui.lv.pages = String(value ?? ''); },
657 branchSubmit: (value) => { void branchDesign($, 'site', value ?? (ui.lv ? ui.lv.pages : '')); },
658 buildThis: () => { void buildThis($); },
659 };
660 return paneTree(els, model);
661 });
662}
663hooks/workspace/actions.mjs 127 lines1/**
2 * The arguments of the clicks the pane records: Build this (`design.slate.pick`, then `design.slate.handoff`), Edit, Branch, Generate more and
3 * Continue (`design.slate.run`). Each request carries a UUID v4 minted
4 * when its dialog opened, so a repeated call never repeats work (the schema's rule). Pure: no `$`.
5 *
6 * The limits are the server's schema (workers/api/src/slate/mcp/tools.json); a value over one is refused here with
7 * words the pane shows, rather than sent to be refused.
8 */
9
10import { UUID_V4 } from './bytes.mjs';
11
12/** The counts Generate more offers, and the one it starts on (SLATE_MORE_COUNTS). */
13export const MORE_COUNTS = Object.freeze([1, 2, 4, 6, 8, 12]);
14export const MORE_COUNT_INITIAL = 6;
15export const NOTE_MAX_CHARS = 600;
16export const EDIT_PROMPT_MAX_BYTES = 6000;
17/** The longest prompt Continue sends (the screen's cut). */
18export const BRIEF_MAX_CHARS = 2000;
19
20/** A request the pane cannot send as it stands, with the words it shows. */
21export class ActionError extends Error {
22 constructor(message) {
23 super(message);
24 this.name = 'ActionError';
25 }
26}
27
28function requireId(id) {
29 if (!UUID_V4.test(id)) throw new ActionError('The request id is not a UUID v4.');
30 return id;
31}
32
33function cleanText(text) {
34 // Control characters are stripped and the ends trimmed, as the servers measure user words.
35 return String(text ?? '').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '').trim();
36}
37
38function utf8Bytes(text) {
39 return new TextEncoder().encode(text).length;
40}
41
42/** `design.slate.pick`: record the selection of a version. */
43export function pickArgs(runDir, versionId) {
44 return { runDir, versionId };
45}
46
47/**
48 * `design.slate.handoff`: record Build this's Convert hand-off, as the Codex screen's does (remoteChoose): kind
49 * `convert`, output `html`, engine `service` (`12ui convert --engine api`, the web Convert page's /api/v1/convert
50 * pipeline). It puts a waiting request, with its command and an upload token, into `design.slate.data`; without it
51 * Claude has a pick but nothing to build from.
52 */
53export function convertHandoffArgs(runDir, handoffId, versionId) {
54 return { runDir, handoffId: requireId(handoffId), kind: 'convert', versionId, options: { engine: 'service', output: 'html' } };
55}
56
57/** `design.slate.run`: new options, `count` of MORE_COUNTS, an optional steer note. */
58export function runNewArgs(runDir, opId, { count = MORE_COUNT_INITIAL, note = '' } = {}) {
59 const n = Number(count);
60 if (!MORE_COUNTS.includes(n)) throw new ActionError(`Choose ${MORE_COUNTS.join(', ')} options.`);
61 const words = cleanText(note);
62 if (words.length > NOTE_MAX_CHARS) throw new ActionError(`The note is longer than ${NOTE_MAX_CHARS} characters.`);
63 const action = { kind: 'round', mode: 'new', count: n };
64 if (words) action.note = words;
65 return { runDir, opId: requireId(opId), action };
66}
67
68/** `design.slate.run`: more like one version (the server's default count). */
69export function runLikeArgs(runDir, opId, fromVersionId) {
70 return { runDir, opId: requireId(opId), action: { kind: 'round', mode: 'like', fromVersionId } };
71}
72
73/** `design.slate.run`: edit one version with an instruction in words (the pane has no drawing). */
74export function runEditArgs(runDir, opId, versionId, prompt) {
75 const words = cleanText(prompt);
76 if (!words) throw new ActionError('Say what to change.');
77 if (utf8Bytes(words) > EDIT_PROMPT_MAX_BYTES) throw new ActionError('The change is too long to send.');
78 return { runDir, opId: requireId(opId), action: { kind: 'edit', versionId, prompt: words } };
79}
80
81/** `design.slate.run`: start a round that waits on the person now. The op is the round's own (`pause.opId`). */
82export function continueArgs(runDir, roundOpId, { referenceIds = null, brief = '' } = {}) {
83 const action = { kind: 'continue' };
84 if (referenceIds) action.referenceIds = [...referenceIds];
85 const words = cleanText(brief);
86 if (words) action.brief = Array.from(words).slice(0, BRIEF_MAX_CHARS).join('');
87 return { runDir, opId: requireId(roundOpId), action };
88}
89
90/** `design.slate.run`: hold a waiting round's countdown while the person chooses (the screen holds on any touch). */
91export function holdArgs(runDir, roundOpId) {
92 return { runDir, opId: requireId(roundOpId), action: { kind: 'hold' } };
93}
94
95/** `design.slate.inspire`: search the reference collection with the prompt's words. */
96export function inspireArgs(runDir, query, count) {
97 const words = cleanText(query);
98 if (!words) throw new ActionError('Describe the design first.');
99 return { runDir, query: words, count };
100}
101
102/** The scopes of a branch (the screen's two choices): the design's full page, or more pages of the product. */
103export const BRANCH_SCOPES = Object.freeze(['page', 'site']);
104export const BRANCH_MAX_PAGES = 8;
105export const PAGE_NAME_MAX_CHARS = 60;
106
107/** Page names typed as one line: comma, semicolon or newline separated, each trimmed and cut, at most eight. */
108export function branchPages(text) {
109 return String(text ?? '')
110 .split(/[,\n;]/)
111 .map((name) => Array.from(cleanText(name).replace(/\s+/g, ' ')).slice(0, PAGE_NAME_MAX_CHARS).join(''))
112 .filter(Boolean)
113 .slice(0, BRANCH_MAX_PAGES);
114}
115
116/**
117 * `design.slate.run`: branch one design. `scope` `page` continues it below the fold into its full page; `site` adds the
118 * product's other pages, the person's names when given (`pages` only travels with `site`).
119 */
120export function runBranchArgs(runDir, opId, fromVersionId, scope, pagesText = '') {
121 if (!BRANCH_SCOPES.includes(scope)) throw new ActionError('Choose Full page or More pages.');
122 const action = { kind: 'round', mode: 'branch', fromVersionId, scope };
123 const pages = scope === 'site' ? branchPages(pagesText) : [];
124 if (pages.length > 0) action.pages = pages;
125 return { runDir, opId: requireId(opId), action };
126}
127hooks/workspace/bytes.mjs 82 lines1/**
2 * Bytes helpers for the Design workspace mod: base64 both ways and UUID v4 ids.
3 *
4 * Written by hand on purpose: the mod's environment has no Node `Buffer`, and CI runs these modules under Node 22.13,
5 * which has no `Uint8Array.prototype.toBase64` or `Uint8Array.fromBase64`. Pure: no `$`.
6 */
7
8const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
9const LOOKUP = (() => {
10 const table = new Int16Array(128).fill(-1);
11 for (let index = 0; index < ALPHABET.length; index += 1) table[ALPHABET.charCodeAt(index)] = index;
12 return table;
13})();
14
15/** Standard padded base64 of the bytes. */
16export function base64Encode(bytes) {
17 const parts = [];
18 const CHUNK = 3 * 4096;
19 for (let start = 0; start < bytes.length; start += CHUNK) {
20 const end = Math.min(bytes.length, start + CHUNK);
21 let out = '';
22 let index = start;
23 for (; index + 2 < end; index += 3) {
24 const n = (bytes[index] << 16) | (bytes[index + 1] << 8) | bytes[index + 2];
25 out += ALPHABET[(n >> 18) & 63] + ALPHABET[(n >> 12) & 63] + ALPHABET[(n >> 6) & 63] + ALPHABET[n & 63];
26 }
27 const rest = end - index;
28 if (rest === 1) {
29 const n = bytes[index] << 16;
30 out += ALPHABET[(n >> 18) & 63] + ALPHABET[(n >> 12) & 63] + '==';
31 } else if (rest === 2) {
32 const n = (bytes[index] << 16) | (bytes[index + 1] << 8);
33 out += ALPHABET[(n >> 18) & 63] + ALPHABET[(n >> 12) & 63] + ALPHABET[(n >> 6) & 63] + '=';
34 }
35 parts.push(out);
36 }
37 return parts.join('');
38}
39
40/** The bytes of standard base64 text; whitespace is ignored, anything else outside the alphabet throws. */
41export function base64Decode(text) {
42 const clean = String(text).replace(/[\t\n\r ]/g, '');
43 if (clean.length % 4 === 1) throw new Error('base64 text has a bad length');
44 const unpadded = clean.replace(/={1,2}$/, '');
45 const out = new Uint8Array(Math.floor((unpadded.length * 3) / 4));
46 let bits = 0;
47 let value = 0;
48 let at = 0;
49 for (let index = 0; index < unpadded.length; index += 1) {
50 const code = unpadded.charCodeAt(index);
51 const digit = code < 128 ? LOOKUP[code] : -1;
52 if (digit < 0) throw new Error('base64 text holds a character outside its alphabet');
53 value = (value << 6) | digit;
54 bits += 6;
55 if (bits >= 8) {
56 bits -= 8;
57 out[at] = (value >> bits) & 0xff;
58 at += 1;
59 }
60 }
61 return out;
62}
63
64/** Sixteen random bytes from the environment's `crypto.getRandomValues`. */
65export function randomBytes16() {
66 const bytes = new Uint8Array(16);
67 globalThis.crypto.getRandomValues(bytes);
68 return bytes;
69}
70
71/** A UUID v4 (the schema's pattern: lower-case hex, version 4, variant 8-b) from 16 bytes, random by default. */
72export function uuidV4(bytes = randomBytes16()) {
73 const b = Uint8Array.from(bytes.subarray(0, 16));
74 b[6] = (b[6] & 0x0f) | 0x40;
75 b[8] = (b[8] & 0x3f) | 0x80;
76 const hex = Array.from(b, (byte) => byte.toString(16).padStart(2, '0')).join('');
77 return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
78}
79
80/** The server's UUID v4 pattern for opId, handoffId and clientRequestId. */
81export const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
82hooks/workspace/copy.mjs 76 lines1/**
2 * The words the mod shows the person: the pane's labels, the toast, and the `/12ui` command's answers. Sentence case,
3 * bare-verb buttons, no all-caps. Pure: no `$`.
4 */
5
6import { stateLine } from './view.mjs';
7
8export const PANE_ID = 'design-workspace';
9export const PANE_TITLE = 'Design workspace';
10export const COMMAND_NAME = '12ui';
11export const COMMAND_DESCRIPTION = 'Open the Design workspace';
12export const COMMAND_HINT = '[handle]';
13
14export const READY_TOAST = 'Design workspace ready. Type /12ui to open it.';
15export const NO_WORKSPACE = 'No Design workspace yet. Ask Claude to open one, for example: Open a Design workspace for my app idea.';
16export const WHERE_PANE_SHOWS = 'The Design workspace pane shows in the Claude Code terminal or the Desktop app\'s Code tab.';
17export const OPENED = 'Design workspace opened.';
18export const LOADING = 'Loading the Design workspace.';
19export const BRANCH_PAGES_HINT = 'Pages (optional), e.g. about, sign in, settings';
20export const SENT = 'Recorded. Claude has been told.';
21export const RECORDED_RUNNER = 'Recorded. It will be drawn on this computer.';
22
23export const LABELS = Object.freeze({
24 design: 'Design',
25 selected: 'Selected',
26 chosen: 'Building',
27 back: 'Back',
28 branch: 'Branch',
29 fullPage: 'Full page',
30 morePages: 'More pages',
31 buildThis: 'Build this',
32 select: 'Select',
33 like: 'More like this',
34 edit: 'Edit',
35 send: 'Send',
36 cancel: 'Cancel',
37 more: 'Generate more',
38 continue: 'Continue',
39 cells: 'Show colour cells',
40 pictures: 'Show pictures',
41 count: 'Options',
42 note: 'Note',
43 pages: 'Pages',
44 change: 'Change',
45 explanation: 'Explanation',
46 format: 'Format',
47});
48
49/** The `/12ui` answer for an argument that is not a handle. */
50export function notAHandle(word) {
51 return `"${word}" is not a Design workspace handle. A handle is w_ followed by 22 letters and digits.`;
52}
53
54/** The `/12ui` answer where nothing draws: one line per option, then where the pane shows. */
55export function workspaceText(runDir, brief, tiles) {
56 const lines = [`Design workspace ${runDir}: ${brief}`];
57 if (tiles.length === 0) lines.push('No options yet.');
58 for (const tile of tiles) lines.push(`${tile.label}: ${stateLine(tile)}`);
59 lines.push(WHERE_PANE_SHOWS);
60 return lines.join('\n');
61}
62
63/**
64 * An option picture's alt text. The terminal draws it in the picture's place, under the option's own label, where it
65 * cannot show pixels: there it says only what to do, never the label again. Elsewhere it is read in place of the
66 * picture, so it names the option.
67 */
68export const TERMINAL_PICTURE_ALT = 'Press v for colour cells';
69
70export function pictureAlt(label, surface) {
71 return surface === 'terminal' ? TERMINAL_PICTURE_ALT : `Option ${label}`;
72}
73
74/** A pick with no round waiting on the person. */
75export const NO_ROUND_WAITING = 'Picks feed a round while it waits for you. Ask Claude for new designs to choose again.';
76hooks/workspace/gallery.mjs 91 lines1/**
2 * Slate v2's three tabs as plain data (no `$`): what Inspiration and References list, which picks a waiting round
3 * holds, and how many designs a round draws. Shapes follow `12ui.slate.view/3` as the slate v2 Worker projects it
4 * (workers/api/src/slate/view.ts): `inspiration.searches`, `references`, `sites`, `rounds[].referenceIds` and `pause`.
5 */
6
7export const TABS = Object.freeze(['inspiration', 'references', 'designs']);
8export const TAB_LABELS = Object.freeze({ inspiration: 'Inspiration', references: 'References', designs: 'Designs' });
9/** Designs per round: 6 by default, 12 one tap away (the screen's switch). */
10export const PER_ROUND = Object.freeze([6, 12]);
11/** How many references one search asks for (the screen's first page). */
12export const SEARCH_COUNT = 12;
13
14/** The latest round that waits on the person, or null. */
15export function waitingRound(view) {
16 const rounds = (view && view.rounds ? view.rounds : []).filter((round) => round.pause);
17 return rounds.length ? rounds[rounds.length - 1] : null;
18}
19
20function unique(ids) {
21 return [...new Set(ids.filter((id) => typeof id === 'string' && id))];
22}
23
24/**
25 * Inspiration: the first page (SEARCH_COUNT) of the newest search's results, with any pick of the waiting round that page
26 * lacks put first. The server's own search behind a new workspace answers up to 96; every listed reference is fetched
27 * and decoded on the hooks worker, so the pane lists one page, as the screen's first page does.
28 */
29export function inspirationIds(view) {
30 const searches = (view.inspiration && view.inspiration.searches) || [];
31 const newest = (searches.length ? searches[searches.length - 1].referenceIds || [] : []).slice(0, SEARCH_COUNT);
32 const round = waitingRound(view);
33 const picks = round ? round.referenceIds || [] : [];
34 const missing = picks.filter((id) => !newest.includes(id));
35 return unique([...missing, ...newest]);
36}
37
38/** References: the live site screenshots, in the order they landed. */
39export function siteItems(view) {
40 return (view.sites || []).map((site) => ({ id: site.id, title: site.title || '', sourceUrl: site.sourceUrl || '' }));
41}
42
43/** A reference's picture size as the corpus recorded it (null when unknown: the picture's own size then decides). */
44export function referenceSize(view, id) {
45 const meta = view.references ? view.references[id] : null;
46 if (meta && meta.width && meta.height) return { width: meta.width, height: meta.height };
47 if (meta && meta.aspect === 'portrait') return { width: 3, height: 4 };
48 if (meta && meta.aspect === 'square') return { width: 1, height: 1 };
49 return { width: 16, height: 10 };
50}
51
52/** The picks of the waiting round: the person's own once they changed them, else the round's preselection. */
53export function picksOf(view, chosen) {
54 if (chosen) return chosen;
55 const round = waitingRound(view);
56 return round ? [...(round.referenceIds || [])] : [];
57}
58
59/** At most this many picks: the round's own count of references. */
60export function pickLimit(view) {
61 const round = waitingRound(view);
62 return round && typeof round.count === 'number' && round.count > 0 ? round.count : 12;
63}
64
65/** Adds or removes one pick; past the limit nothing changes and `refused` says so. The order never changes. */
66export function togglePick(picks, id, limit) {
67 const at = picks.indexOf(id);
68 if (at >= 0) return { picks: picks.filter((pick) => pick !== id), refused: false };
69 if (picks.length >= limit) return { picks, refused: true };
70 return { picks: [...picks, id], refused: false };
71}
72
73export function sameList(a, b) {
74 return a.length === b.length && a.every((id, index) => b[index] === id);
75}
76
77/** The switch's place: 12 once the newest round drew twelve or more, else 6 (or what the person tapped). */
78export function perRoundOf(view, tapped) {
79 if (tapped) return tapped;
80 const rounds = view.rounds || [];
81 const newest = rounds.length ? rounds[rounds.length - 1] : null;
82 return newest && typeof newest.count === 'number' && newest.count >= 12 ? 12 : 6;
83}
84
85/** Whether tapping 12 draws a round of twelve now: the newest round drew fewer and no round runs. */
86export function twelveDraws(view, roundRuns) {
87 const rounds = view.rounds || [];
88 const newest = rounds.length ? rounds[rounds.length - 1] : null;
89 return Boolean(newest) && !roundRuns && (typeof newest.count !== 'number' || newest.count < 12);
90}
91hooks/workspace/handles.mjs 60 lines1/**
2 * Workspace handles (`runDir`, "w_" and 22 base62 characters): reading them from the tool calls the mod observes and
3 * from the `/12ui` argument, and the session's list of the ones it saw. Pure: no `$`.
4 */
5
6/** The plugin's two model-facing tools whose results name a workspace (exact names as Claude Code lists them). */
7export const CREATE_TOOL = 'mcp__plugin_12ui_12ui-workspace__design_slate_create';
8export const SHOW_TOOL = 'mcp__plugin_12ui_12ui-workspace__design_slate_show';
9
10const HANDLE = /^w_[0-9A-Za-z]{22}$/;
11const HANDLE_IN_TEXT = /\bw_[0-9A-Za-z]{22}\b/;
12
13/** Whether the text is exactly one workspace handle. */
14export function isHandle(text) {
15 return typeof text === 'string' && HANDLE.test(text);
16}
17
18function handleInText(text) {
19 if (typeof text !== 'string' || !text) return null;
20 try {
21 const parsed = JSON.parse(text);
22 if (parsed && typeof parsed === 'object' && isHandle(parsed.runDir)) return parsed.runDir;
23 } catch {
24 // Not JSON: the text the model reads may be plain words; the pattern below reads those.
25 }
26 const match = HANDLE_IN_TEXT.exec(text);
27 return match ? match[0] : null;
28}
29
30function stringify(value) {
31 try {
32 return value === undefined ? '' : JSON.stringify(value);
33 } catch {
34 return '';
35 }
36}
37
38/**
39 * The workspace a create or show call names: the show input's `runDir`, else the first handle in the result as the
40 * model reads it (`text`), else in the result record. Null when the call names none (an error result, a deny).
41 */
42export function handleFromCall(input, result) {
43 if (input && input.tool === SHOW_TOOL && isHandle(input.runDir)) return input.runDir;
44 if (!result || result.deny || result.isError) return null;
45 return handleInText(result.text) ?? handleInText(stringify(result.result));
46}
47
48/** The session's workspaces, oldest first, without repeats; the latest seen moves to the end. */
49export function noteHandle(list, handle) {
50 if (!isHandle(handle)) return list;
51 return [...list.filter((item) => item !== handle), handle];
52}
53
54/** What `/12ui [handle]` asks for: `{ handle }` (given or the latest seen), `{ none: true }`, or `{ invalid }`. */
55export function commandTarget(args, seen) {
56 const word = String(args ?? '').trim();
57 if (word) return isHandle(word) ? { handle: word } : { invalid: word };
58 return seen.length ? { handle: seen[seen.length - 1] } : { none: true };
59}
60hooks/workspace/messages.mjs 53 lines1/**
2 * What the pane tells Claude after a click is recorded, and when it says nothing: the port of the hosted screen's
3 * messages (packages/12ui/src/mcp-slate-ui-script-remote.ts). Pure: no `$`, no clock.
4 *
5 * Five messages, one per click: new options, more like an option, an edit, a branch, and Build this (the selection). `$.prompt.submit`
6 * sends them framed as from this mod, so "I" of the screen becomes "The user". Each names what happened and how to
7 * confirm it (`design.slate.data`, whose answer is the server's own trusted text) and carries no command, flag, path,
8 * price or credit word; the user's own words (a note, an edit instruction, branch page names) are never put in a
9 * message: the agent reads them from the data tool, quoted.
10 *
11 * The runner rule: when the run result says the workspace runner on the user's computer holds the workspace
12 * (`runner.alive`), the runner claims the request within seconds and nobody is told. The agent is told after all, with
13 * the same message, when the request is still the agent's once RUNNER_GRACE_MS has passed; while the runner draws it,
14 * or while its round waits on the user, the pane keeps checking every RUNNER_RECHECK_MS.
15 */
16
17export const DATA_TOOL = 'design.slate.data';
18export const RUNNER_GRACE_MS = 20000;
19export const RUNNER_RECHECK_MS = 10000;
20
21/** The message for a recorded Generate more, More like this, Edit or Branch request. */
22export function runMessage(runDir, opId, action, labelOf) {
23 let head;
24 if (action.kind === 'edit') head = `The user asked to edit ${labelOf(action.versionId) ?? 'an option'}`;
25 else if (action.mode === 'branch') head = `The user asked for ${action.scope === 'page' ? 'the full page' : 'more pages'} of ${labelOf(action.fromVersionId) ?? 'a design'}`;
26 else if (action.mode === 'like') head = `The user asked for more like ${labelOf(action.fromVersionId) ?? 'an option'}`;
27 else head = 'The user asked for new options';
28 return `${head} in the Design workspace (request ${opId}, runDir ${runDir}). Please confirm it with ${DATA_TOOL}, then carry it out.`;
29}
30
31/** The message for a recorded Build this: the pick is recorded, and Claude is told to build that design. */
32export function buildMessage(runDir, label) {
33 return `The user chose design ${label} to build in the Design workspace (runDir ${runDir}). Please read its selection with ${DATA_TOOL}, then build that design as the user's request asks.`;
34}
35
36/** After a run is recorded: tell the agent now, or watch while the runner has it (returns the first wait). */
37export function afterRun(runner) {
38 return runner && runner.alive === true ? { tell: false, waitMs: RUNNER_GRACE_MS } : { tell: true };
39}
40
41/**
42 * One runner check, on a fresh view: `{ tell: true }` sends the message now; `{ waitMs }` checks again after that
43 * long; `{ drop: true }` forgets the request (it finished, failed or went away). `watch.waited` records that the round
44 * waited on the user, after which the runner gets its grace again; the caller keeps the returned `waited`.
45 */
46export function judgeRunner(op, waitsOnPerson, watch) {
47 if (!op || !(op.state === 'running' || op.state === 'starting')) return { drop: true };
48 if (op.phase === 'drawing') return { waitMs: RUNNER_RECHECK_MS, waited: watch.waited };
49 if (waitsOnPerson) return { waitMs: RUNNER_RECHECK_MS, waited: true };
50 if (watch.waited) return { waitMs: RUNNER_GRACE_MS, waited: false };
51 return { tell: true };
52}
53hooks/workspace/pane.mjs 237 lines1/**
2 * The Design workspace pane's element tree (slate v2), built from the surface's own element table (what
3 * `$.ui.resolve(e)` returned) and a plain model of what to show. No `$` here: every handler is a closure register.mjs
4 * passes in.
5 *
6 * Top to bottom, as the hosted screen lays it out: one prompt line (its Design button inside the same row), the tabs
7 * Inspiration · References · Designs with a quiet "N selected" at the right, the notice and error lines, then the
8 * active tab. Inspiration and References are a wrapped gallery of picture tiles; a selected tile carries a ring and a
9 * check. The tray under the gallery holds the round's words and Continue. Designs holds the 6 · 12 switch and the
10 * design tiles; pressing one opens the large view with Edit · Branch · Build this.
11 */
12
13import { BRANCH_PAGES_HINT, LABELS, LOADING, pictureAlt } from './copy.mjs';
14import { TABS, TAB_LABELS } from './gallery.mjs';
15import { BUTTON_GAP, TILE_GAP, briefReserve } from './layout.mjs';
16import { stateLine } from './view.mjs';
17
18const RING = 'cyan';
19/** Columns between two tab labels. */
20const TAB_GAP = 3;
21
22/** The engine accepts `plain` only as true or absent (a Button with `plain: false` is skipped), so a quiet button spreads this. */
23function quiet(isQuiet) {
24 return isQuiet ? { plain: true } : {};
25}
26
27function text(els, words, props = {}) {
28 return els.Text({ ...props, children: words });
29}
30
31function pictureNode(els, key, picture, alt) {
32 if (!picture) return text(els, ' ', { dimColor: true });
33 if (picture.kind === 'image') return els.Image({ key: `picture:${key}`, source: picture.source, columns: picture.columns, rows: picture.rows, alt });
34 if (picture.kind === 'raster') return els.Raster({ key: `cells:${key}`, cells: picture.cells, columns: picture.columns, rows: picture.rows });
35 if (picture.kind === 'svg') return els.Svg({ source: picture.source, alt, width: picture.width, height: picture.height });
36 if (picture.kind === 'loading') return text(els, 'Loading picture', { dimColor: true });
37 if (picture.kind === 'failed') return text(els, `No picture: ${picture.reason}`, { dimColor: true, wrap: 'wrap' });
38 return null;
39}
40
41function row(els, key, children, gap = BUTTON_GAP) {
42 return els.Box({ key, flexDirection: 'row', flexWrap: 'wrap', columnGap: gap, children: children.filter(Boolean) });
43}
44
45function promptNode(els, model) {
46 const { handlers, prompt } = model;
47 const children = [];
48 if (els.Input) {
49 // No label: the engine draws any given label, even an empty one, as "<label>: " before the value.
50 children.push(els.Input({
51 key: 'prompt',
52 placeholder: 'Describe the design',
53 value: prompt,
54 submitLabel: LABELS.design.toLowerCase(),
55 onInput: (value) => handlers.promptInput(value),
56 onSubmit: (value) => handlers.promptSubmit(value),
57 }));
58 } else children.push(text(els, prompt, { bold: true, wrap: 'wrap' }));
59 children.push(els.Button({ key: 'design', label: LABELS.design, variant: 'primary', onPress: () => handlers.promptSubmit(prompt) }));
60 return els.Box({ key: 'prompt-row', flexDirection: 'row', columnGap: BUTTON_GAP, paddingRight: briefReserve(model.surface), children });
61}
62
63function tabsNode(els, model) {
64 const tabs = TABS.map((name) => els.Button({
65 key: `tab:${name}`,
66 label: TAB_LABELS[name],
67 plain: true,
68 dimColor: model.tab !== name,
69 onPress: () => model.handlers.tab(name),
70 }));
71 const right = model.selectedCount > 0 ? text(els, `${model.selectedCount} selected`, { dimColor: true }) : null;
72 // The active tab is underlined: a rule under its label, as the screen's slim tab row has it.
73 let offset = 0;
74 for (const name of TABS) {
75 if (name === model.tab) break;
76 offset += TAB_LABELS[name].length + TAB_GAP;
77 }
78 const rule = `${' '.repeat(offset)}${'─'.repeat(TAB_LABELS[model.tab].length)}`;
79 return els.Box({
80 key: 'tabs',
81 flexDirection: 'column',
82 marginTop: 1,
83 children: [
84 els.Box({ key: 'tab-line', flexDirection: 'row', justifyContent: 'space-between', children: [row(els, 'tab-row', tabs, TAB_GAP), right].filter(Boolean) }),
85 text(els, rule, { color: RING }),
86 ],
87 });
88}
89
90/** One gallery tile: its picture inside a ring when selected, then the one-tap toggle. */
91function galleryTile(els, item, model) {
92 const mark = item.selected ? `✓ ${LABELS.selected}` : LABELS.select;
93 const children = [pictureNode(els, item.id, item.picture, item.title || 'Reference')];
94 if (item.title) children.push(text(els, item.title, { dimColor: true, wrap: 'truncate-end' }));
95 children.push(els.Button({ key: `pick:${item.id}`, label: mark, ...quiet(!item.selected), dimColor: !item.selected, onPress: () => model.handlers.toggle(item.id) }));
96 return els.Box({
97 key: `item:${item.id}`,
98 flexDirection: 'column',
99 width: model.tileColumns + 2,
100 borderStyle: 'round',
101 borderColor: item.selected ? RING : undefined,
102 borderDimColor: !item.selected,
103 marginBottom: 1,
104 children,
105 });
106}
107
108function trayNode(els, model) {
109 if (!model.tray) return null;
110 const { words, canContinue } = model.tray;
111 return row(els, 'tray', [
112 text(els, words, { wrap: 'wrap' }),
113 canContinue ? els.Button({ key: 'continue', label: LABELS.continue, variant: 'primary', onPress: () => model.handlers.continueRound() }) : null,
114 ], 2);
115}
116
117function galleryNode(els, model) {
118 const parts = [trayNode(els, model)];
119 if (model.items.length === 0) parts.push(text(els, model.emptyLine, { dimColor: true, wrap: 'wrap' }));
120 else parts.push(els.Box({ key: 'gallery', flexDirection: 'row', flexWrap: 'wrap', columnGap: TILE_GAP, children: model.items.map((item) => galleryTile(els, item, model)) }));
121 return parts.filter(Boolean);
122}
123
124function designTile(els, tile, model) {
125 const children = [
126 pictureNode(els, tile.candidateId, tile.picture, pictureAlt(tile.label, model.surface)),
127 row(els, `under:${tile.candidateId}`, [
128 els.Button({ key: `open:${tile.candidateId}`, label: tile.label, plain: true, onPress: () => model.handlers.open(tile) }),
129 tile.isReady && !tile.isSelected ? null : text(els, tile.isSelected ? LABELS.chosen : stateLine(tile), tile.state === 'failed' ? { color: 'red', wrap: 'wrap' } : { dimColor: true, wrap: 'wrap' }),
130 ]),
131 ];
132 return els.Box({
133 key: `design:${tile.candidateId}`,
134 flexDirection: 'column',
135 width: model.tileColumns + 2,
136 borderStyle: 'round',
137 borderColor: tile.isSelected ? RING : undefined,
138 borderDimColor: !tile.isSelected,
139 marginBottom: 1,
140 children,
141 });
142}
143
144function designsNode(els, model) {
145 const switchRow = row(els, 'per-round', [
146 text(els, 'Per round', { dimColor: true }),
147 ...model.perRoundOptions.map((n) => els.Button({ key: `per:${n}`, label: String(n), ...quiet(model.perRound !== n), dimColor: model.perRound !== n, variant: model.perRound === n ? 'primary' : undefined, onPress: () => model.handlers.perRound(n) })),
148 ]);
149 const parts = [trayNode(els, model), switchRow];
150 if (model.tiles.length === 0) parts.push(text(els, 'No designs yet.', { dimColor: true }));
151 else parts.push(els.Box({ key: 'designs', flexDirection: 'row', flexWrap: 'wrap', columnGap: TILE_GAP, marginTop: 1, children: model.tiles.map((tile) => designTile(els, tile, model)) }));
152 return parts.filter(Boolean);
153}
154
155/** The large view of one design: the picture at the pane's width, then Edit · Branch · Build this. */
156function largeViewNode(els, model) {
157 const { lv, handlers } = model;
158 const { tile } = lv;
159 const actions = [els.Button({ key: 'lv-back', label: LABELS.back, plain: true, dimColor: true, onPress: () => handlers.close() })];
160 if (tile.isReady) {
161 if (els.Input) actions.push(els.Button({ key: 'lv-edit', label: LABELS.edit, ...quiet(lv.mode !== 'edit'), onPress: () => handlers.edit() }));
162 // Once the design is building, Branch is no longer offered next to "Building".
163 if (!tile.isSelected) actions.push(els.Button({ key: 'lv-branch', label: LABELS.branch, ...quiet(lv.mode !== 'branch'), onPress: () => handlers.branch() }));
164 actions.push(tile.isSelected ? text(els, LABELS.chosen, { color: RING }) : els.Button({ key: 'lv-build-this', label: LABELS.buildThis, variant: 'primary', onPress: () => handlers.buildThis() }));
165 }
166 const children = [
167 row(els, 'lv-head', [text(els, `Design ${tile.label}`, { bold: true }), tile.isReady ? null : text(els, stateLine(tile), { dimColor: true })], 2),
168 pictureNode(els, `lv:${tile.candidateId}`, lv.picture, pictureAlt(tile.label, model.surface)),
169 row(els, 'lv-actions', actions, 2),
170 ];
171 if (lv.mode === 'edit' && els.Input) {
172 children.push(els.Input({
173 key: `lv-edit-note:${tile.candidateId}`,
174 label: LABELS.change,
175 placeholder: `What to change in ${tile.label}`,
176 submitLabel: 'send',
177 autoFocus: true,
178 onSubmit: (value) => handlers.editSubmit(value),
179 }));
180 }
181 if (lv.mode === 'branch') children.push(...branchNodes(els, model));
182 return children;
183}
184
185/**
186 * The branch bar, as the screen's: Full page (the design continued below the fold) or More pages (other pages of the
187 * product, names optional). Full page branches at once; More pages opens the names line, then Send.
188 */
189function branchNodes(els, model) {
190 const { lv, handlers } = model;
191 const choices = [
192 els.Button({ key: 'lv-scope-page', label: LABELS.fullPage, onPress: () => handlers.branchScope('page') }),
193 els.Button({ key: 'lv-scope-site', label: LABELS.morePages, ...quiet(lv.scope !== 'site'), onPress: () => handlers.branchScope('site') }),
194 ];
195 const nodes = [row(els, 'lv-branch-scope', choices, 2)];
196 if (lv.scope === 'site') {
197 const line = [];
198 if (els.Input) {
199 line.push(els.Input({
200 key: `lv-branch-pages:${lv.tile.candidateId}`,
201 label: LABELS.pages,
202 placeholder: BRANCH_PAGES_HINT,
203 value: lv.pages,
204 submitLabel: 'send',
205 autoFocus: true,
206 onInput: (value) => handlers.branchPagesInput(value),
207 onSubmit: (value) => handlers.branchSubmit(value),
208 }));
209 }
210 line.push(els.Button({ key: 'lv-branch-send', label: LABELS.send, variant: 'primary', onPress: () => handlers.branchSubmit(lv.pages) }));
211 nodes.push(row(els, 'lv-branch-pages', line, 2));
212 }
213 return nodes;
214}
215
216/** The whole pane. `model` is built by register.mjs; see the fields read above. */
217export function paneTree(els, model) {
218 if (!model.workspace) {
219 const children = [text(els, model.emptyText, { wrap: 'wrap' })];
220 if (model.errorText) children.push(text(els, model.errorText, { color: 'red', wrap: 'wrap' }));
221 return els.Box({ flexDirection: 'column', children });
222 }
223 const children = [promptNode(els, model)];
224 if (model.isLoading) {
225 if (model.errorText) children.push(text(els, model.errorText, { color: 'red', wrap: 'wrap' }));
226 else children.push(text(els, LOADING, { dimColor: true }));
227 return els.Box({ flexDirection: 'column', children });
228 }
229 children.push(tabsNode(els, model));
230 if (model.noticeText) children.push(text(els, model.noticeText, { dimColor: true, wrap: 'wrap' }));
231 if (model.errorText) children.push(text(els, model.errorText, { color: 'red', wrap: 'wrap' }));
232 if (model.lv) children.push(...largeViewNode(els, model));
233 else if (model.tab === 'designs') children.push(...designsNode(els, model));
234 else children.push(...galleryNode(els, model));
235 return els.Box({ flexDirection: 'column', rowGap: 0, children: children.filter(Boolean) });
236}
237hooks/workspace/layout.mjs 58 lines1/**
2 * The pane's grid policy: how many option tiles sit side by side, how wide each one is, how tall a picture may grow, and
3 * the columns the brief line leaves for the terminal's close mark. Every sizing rule of the pane's layout lives here and
4 * nowhere else (CLAUDE.md item 9); picture.mjs sizes the picture inside the tile this module returns. Pure: no `$`.
5 *
6 * The facts it works from are the surface's own: `bodyColumns` and `scroll.bodyRows` of the Pane props, and the width
7 * the terminal draws a Button in (`[ label ]`, 2.1.287 d.ts `ButtonProps`: the label plus four columns).
8 */
9
10import { LABELS } from './copy.mjs';
11
12/** Columns between two tiles in a row. */
13export const TILE_GAP = 2;
14/** Columns between two buttons in a tile's button row (pane.mjs draws the row with this gap). */
15export const BUTTON_GAP = 1;
16/** The rows a tile draws besides its picture: the label, the state line, the button row, and the gap below it. */
17export const TILE_CHROME_ROWS = 4;
18/**
19 * The columns the terminal's close mark takes from the pane's first row: the mark in the last column, and one column of
20 * space before it, so a truncated brief ends in its ellipsis and never runs into the mark.
21 */
22export const CLOSE_MARK_COLUMNS = 2;
23
24/** The columns a terminal Button takes: `[ label ]`. */
25export function buttonColumns(label) {
26 return label.length + 4;
27}
28
29/** The narrowest tile whose button row (Select, More like this, Edit) fits on one line: 38 columns with today's labels. */
30export const TILE_MIN_COLUMNS = [LABELS.select, LABELS.like, LABELS.edit].map(buttonColumns).reduce((sum, n) => sum + n, 0)
31 + BUTTON_GAP * 2;
32
33/**
34 * The tile grid for a pane `bodyColumns` wide: `{ perRow, columns }`. As many tiles a row as fit at TILE_MIN_COLUMNS
35 * (one at least), each as wide as the row then allows. A pane narrower than one minimum tile gets one tile of the
36 * whole width (its button row wraps there; nothing narrower can hold it).
37 */
38export function tileGrid(bodyColumns) {
39 const width = Math.max(1, Math.floor(bodyColumns));
40 const perRow = Math.max(1, Math.floor((width + TILE_GAP) / (TILE_MIN_COLUMNS + TILE_GAP)));
41 const columns = Math.floor((width - TILE_GAP * (perRow - 1)) / perRow);
42 return { perRow, columns };
43}
44
45/**
46 * The tallest a picture may be in a pane whose window shows `bodyRows` rows: one whole tile (picture and chrome) fits
47 * the window, so no picture is taller than what the person can see at once. Without a window height, no limit here.
48 */
49export function pictureMaxRows(bodyRows) {
50 if (typeof bodyRows !== 'number' || !Number.isFinite(bodyRows)) return Infinity;
51 return Math.max(3, Math.floor(bodyRows) - TILE_CHROME_ROWS);
52}
53
54/** The columns the pane's first row leaves at its right edge: the close mark's on the terminal, none elsewhere. */
55export function briefReserve(surface) {
56 return surface === 'terminal' ? CLOSE_MARK_COLUMNS : 0;
57}
58hooks/workspace/picture.mjs 208 lines1/**
2 * The picture policy: how an option's thumbnail (the server's 480 px JPEG) is drawn on each surface. Every rendering
3 * rule for the pictures lives here and nowhere else (CLAUDE.md item 9). Pure: no `$`; register.mjs feeds it facts
4 * (the surface, the `$.ui.blit` answer, the person's `v` toggle) and pane.mjs draws what it returns.
5 *
6 * Terminal. The `Image` element takes PNG or RGBA only, so the JPEG is decoded (the vendored jpeg-js decoder) to RGBA.
7 * It shows real pixels on terminals with a graphics protocol (kitty, Ghostty) and its `alt` text elsewhere. The fact
8 * that tells the two apart is the engine's own: `$.ui.blit` on a keyed Image resolves `{ deny }` with a reason naming
9 * the alt when that Image draws its alt (2.1.287 d.ts `UiBlitResult`). On that fact the pane draws `Raster` cells
10 * instead: half blocks (U+2580), the top pixel as the foreground and the bottom one as the background, two pixels a
11 * cell. `v` toggles either way. Which default stands, and the exact deny words, are recorded in mod/NOTES.md (spike 1).
12 *
13 * Desktop and the other remote surfaces. `Svg` (at most 131,072 characters) with the JPEG embedded as a data URI when
14 * the whole document fits; otherwise the picture is decoded, scaled down and re-encoded as a stored PNG until it fits.
15 */
16
17import { base64Decode, base64Encode } from './bytes.mjs';
18import { decode as decodeJpeg } from './jpeg-decoder.mjs';
19import { encodePng, storedPngBytes } from './png.mjs';
20import { decodeWebp } from './webp-decoder.mjs';
21
22/** The Svg element's character ceiling (2.1.287 d.ts `SvgProps.source`). */
23export const SVG_MAX_CHARS = 131072;
24/** The XML namespace an SVG document names itself with; an identifier, never fetched. */
25export const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
26/** The half-block glyph a Raster cell draws: upper half in the foreground, lower half in the background. */
27export const HALF_BLOCK = 0x2580;
28/** Raster's own bounds (2.1.287 d.ts `RasterProps`), and Image's. */
29export const RASTER_MAX_COLUMNS = 512;
30export const RASTER_MAX_ROWS = 256;
31export const IMAGE_MAX_CELLS = 255;
32/** RGBA handed to an Image never exceeds this edge (2.1.287 d.ts `ImageSource`: 1 to 2048). */
33export const IMAGE_MAX_EDGE = 2048;
34/** The pixels an Image source carries per terminal cell, and a desktop picture's CSS pixels per column: a common cell size at 1x. */
35export const CELL_PIXELS = Object.freeze({ width: 8, height: 16 });
36
37/**
38 * Which element draws the pictures on this surface: `image` or `raster` on the terminal (the person's or the blit
39 * fact's choice, `useCells`), `svg` on every surface whose table has `Svg`.
40 */
41export function pictureKind(surface, { useCells = false } = {}) {
42 if (surface === 'terminal') return useCells ? 'raster' : 'image';
43 return 'svg';
44}
45
46/**
47 * Whether a `$.ui.blit` answer says the keyed Image draws its alt text, the one deny that means "no pixels here".
48 * Every other answer is silent: `{}` (pixels taken), or a deny for another reason (not mounted yet, another size).
49 */
50export function blitSaysAlt(result) {
51 return Boolean(result && typeof result.deny === 'string' && /\balt\b/i.test(result.deny));
52}
53
54/** Whether the terminal should draw cells: the person's toggle when they pressed `v`, else the blit fact. */
55export function useCellsFor({ toggled = null, altDrawn = false } = {}) {
56 return toggled === null ? Boolean(altDrawn) : Boolean(toggled);
57}
58
59/** The decoded thumbnail: `{ rgba, width, height }` from the JPEG's base64. Throws on bytes that are not a JPEG. */
60export function decodeThumb(jpegBase64, mimeType = 'image/jpeg') {
61 if (mimeType === 'image/webp') {
62 const webp = decodeWebp(base64Decode(jpegBase64));
63 return { rgba: webp.data, width: webp.width, height: webp.height };
64 }
65 const image = decodeJpeg(base64Decode(jpegBase64), { formatAsRGBA: true, maxResolutionInMP: 16, maxMemoryUsageInMB: 64 });
66 return { rgba: image.data, width: image.width, height: image.height };
67}
68
69/** The picture scaled to `width` x `height` by averaging the source area under each target pixel. */
70export function resizeRgba({ rgba, width, height }, toWidth, toHeight) {
71 const w = Math.max(1, Math.round(toWidth));
72 const h = Math.max(1, Math.round(toHeight));
73 if (w === width && h === height) return { rgba, width, height };
74 const out = new Uint8Array(w * h * 4);
75 const sx = width / w;
76 const sy = height / h;
77 for (let y = 0; y < h; y += 1) {
78 const y0 = y * sy;
79 const y1 = Math.min(height, y0 + sy);
80 for (let x = 0; x < w; x += 1) {
81 const x0 = x * sx;
82 const x1 = Math.min(width, x0 + sx);
83 let r = 0;
84 let g = 0;
85 let b = 0;
86 let a = 0;
87 let area = 0;
88 for (let py = Math.floor(y0); py < Math.ceil(y1); py += 1) {
89 const wy = Math.min(py + 1, y1) - Math.max(py, y0);
90 if (wy <= 0) continue;
91 for (let px = Math.floor(x0); px < Math.ceil(x1); px += 1) {
92 const wx = Math.min(px + 1, x1) - Math.max(px, x0);
93 if (wx <= 0) continue;
94 const weight = wx * wy;
95 const at = (py * width + px) * 4;
96 r += rgba[at] * weight;
97 g += rgba[at + 1] * weight;
98 b += rgba[at + 2] * weight;
99 a += rgba[at + 3] * weight;
100 area += weight;
101 }
102 }
103 const to = (y * w + x) * 4;
104 out[to] = Math.round(r / area);
105 out[to + 1] = Math.round(g / area);
106 out[to + 2] = Math.round(b / area);
107 out[to + 3] = Math.round(a / area);
108 }
109 }
110 return { rgba: out, width: w, height: h };
111}
112
113/**
114 * The box of one tile's picture, in cells: the tile's whole width (layout.mjs `tileGrid`), and as many rows as the
115 * picture's aspect asks at two pixels a cell height (a cell is about twice as tall as wide). A picture taller than
116 * `maxRows` (layout.mjs `pictureMaxRows`) is held to it and narrowed to keep its aspect. Never past Image's 255 cells.
117 */
118export function tileBox(tileColumns, width, height, maxRows = Infinity) {
119 const aspect = width > 0 && height > 0 ? height / width : 9 / 16;
120 let columns = Math.max(1, Math.min(IMAGE_MAX_CELLS, Math.floor(tileColumns)));
121 let rows = Math.max(1, Math.round(columns * aspect / 2));
122 const limit = Math.max(1, Math.min(IMAGE_MAX_CELLS, Math.floor(maxRows)));
123 if (rows > limit) {
124 rows = limit;
125 columns = Math.max(1, Math.min(columns, Math.round(rows * 2 / aspect)));
126 }
127 return { columns, rows };
128}
129
130/** A desktop picture's CSS size for its box: CELL_PIXELS.width a column, the height from the picture's own aspect. */
131export function desktopPictureSize(box, width, height) {
132 const cssWidth = box.columns * CELL_PIXELS.width;
133 return { width: cssWidth, height: Math.round(cssWidth * height / width) };
134}
135
136/**
137 * An Image's source for the decoded picture: raw RGBA no larger than its box needs at CELL_PIXELS per cell (the tree
138 * crosses to the engine on every redraw, so a 480 px thumbnail drawn 30 columns wide is sent at 240 px), and never past
139 * the element's edge limit. A picture already small enough is sent as decoded.
140 */
141export function imageSource(picture, box = null) {
142 const limits = [1, IMAGE_MAX_EDGE / Math.max(picture.width, picture.height)];
143 if (box) limits.push((box.columns * CELL_PIXELS.width) / picture.width, (box.rows * CELL_PIXELS.height) / picture.height);
144 const scale = Math.min(...limits);
145 const sized = scale < 1 ? resizeRgba(picture, Math.max(1, picture.width * scale), Math.max(1, picture.height * scale)) : picture;
146 return { rgba: base64Encode(sized.rgba), width: sized.width, height: sized.height };
147}
148
149/** A Raster's `cells` for the picture over `columns` x `rows`: half blocks, two pixels a cell. */
150export function rasterCells(picture, columns, rows) {
151 const c = Math.max(1, Math.min(RASTER_MAX_COLUMNS, columns));
152 const r = Math.max(1, Math.min(RASTER_MAX_ROWS, rows));
153 const sized = resizeRgba(picture, c, r * 2);
154 const words = new Uint32Array(c * r * 3);
155 for (let row = 0; row < r; row += 1) {
156 for (let col = 0; col < c; col += 1) {
157 const top = ((row * 2) * c + col) * 4;
158 const bottom = ((row * 2 + 1) * c + col) * 4;
159 const at = (row * c + col) * 3;
160 words[at] = HALF_BLOCK;
161 words[at + 1] = (sized.rgba[top] << 16) | (sized.rgba[top + 1] << 8) | sized.rgba[top + 2];
162 words[at + 2] = (sized.rgba[bottom] << 16) | (sized.rgba[bottom + 1] << 8) | sized.rgba[bottom + 2];
163 }
164 }
165 const bytes = new Uint8Array(words.length * 4);
166 for (let index = 0; index < words.length; index += 1) {
167 const word = words[index];
168 const at = index * 4;
169 bytes[at] = word & 0xff;
170 bytes[at + 1] = (word >>> 8) & 0xff;
171 bytes[at + 2] = (word >>> 16) & 0xff;
172 bytes[at + 3] = (word >>> 24) & 0xff;
173 }
174 return { cells: base64Encode(bytes), columns: c, rows: r };
175}
176
177function svgDocument(width, height, mimeType, base64) {
178 return `<svg xmlns="${SVG_NAMESPACE}" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">`
179 + `<image href="data:${mimeType};base64,${base64}" x="0" y="0" width="${width}" height="${height}" preserveAspectRatio="xMidYMid meet"/>`
180 + '</svg>';
181}
182
183/** The characters an Svg embedding `bytes` bytes of base64 takes, for a picture of that size. */
184function svgChars(width, height, mimeType, bytes) {
185 return svgDocument(width, height, mimeType, '').length + 4 * Math.ceil(bytes / 3);
186}
187
188/**
189 * The Svg source for a thumbnail: `{ source, embedded }`, `embedded` naming what it carries (`jpeg` as the server sent
190 * it, or `png` re-encoded smaller). `decode` is only called when the JPEG does not fit.
191 */
192export function svgPicture({ jpegBase64, width, height, mimeType = 'image/jpeg' }, decode = () => decodeThumb(jpegBase64, mimeType)) {
193 const asIs = svgDocument(width, height, mimeType, jpegBase64);
194 if (asIs.length <= SVG_MAX_CHARS) return { source: asIs, embedded: mimeType === 'image/webp' ? 'webp' : 'jpeg' };
195 const picture = decode();
196 let scale = 1;
197 for (;;) {
198 const w = Math.max(1, Math.floor(picture.width * scale));
199 const h = Math.max(1, Math.floor(picture.height * scale));
200 if (svgChars(width, height, 'image/png', storedPngBytes(w, h, 3)) <= SVG_MAX_CHARS) {
201 const png = encodePng(resizeRgba(picture, w, h));
202 return { source: svgDocument(width, height, 'image/png', base64Encode(png)), embedded: 'png' };
203 }
204 if (w === 1 && h === 1) throw new Error('the picture cannot fit an Svg');
205 scale *= 0.85;
206 }
207}
208hooks/workspace/poll.mjs 42 lines1/**
2 * When the pane next asks the server for the workspace (`design.slate.status`): the hosted screen's backoff
3 * (2, 3, 5, 8, then 10 s; `DELAYS` in packages/12ui/src/mcp-slate-ui-script-state.ts), reset by every change, and
4 * never later than the moment a start countdown ends, when the server lets that round go. Polling is the screen being
5 * in sight (workers/api/src/slate/limits.ts `SLATE_PAUSE`): only while the pane is open and something runs, or while the
6 * first read of the shown workspace keeps failing in a way a retry can mend. Pure.
7 */
8
9export const POLL_DELAYS_MS = Object.freeze([2000, 3000, 5000, 8000, 10000]);
10
11/** A countdown's last poll lands this long after its end, so the server has passed its deadline. */
12export const COUNTDOWN_MARGIN_MS = 500;
13
14/** The backoff step after a poll: back to the start when the view changed, one step on otherwise (unchanged, failed). */
15export function nextStep(step, changed) {
16 return changed ? 0 : Math.min(step + 1, POLL_DELAYS_MS.length - 1);
17}
18
19/** Whether the pane polls at all: open, a workspace shown, and something running. */
20export function wantsPolling({ isOpen, hasWorkspace, isRunning }) {
21 return Boolean(isOpen && hasWorkspace && isRunning);
22}
23
24/**
25 * Whether the pane reads again for a workspace it holds no view of yet: open, and the last read failed in a way a later
26 * read can mend (a 429, a 5xx, no answer at all). A refusal such as `unknown_workspace` keeps its error line and reads
27 * nothing more; `/12ui` can still ask once.
28 */
29export function retriesFirstRead({ isOpen, hasView, failedRetryably }) {
30 return Boolean(isOpen && !hasView && failedRetryably);
31}
32
33/** How long until the next poll: the backoff delay, cut short to just after a running countdown's end. */
34export function pollDelay(step, countdown, nowMs) {
35 const delay = POLL_DELAYS_MS[Math.min(Math.max(step, 0), POLL_DELAYS_MS.length - 1)];
36 if (countdown && countdown.state === 'countdown' && typeof countdown.endsAtMs === 'number') {
37 const untilEnd = countdown.endsAtMs - nowMs + COUNTDOWN_MARGIN_MS;
38 return Math.max(COUNTDOWN_MARGIN_MS, Math.min(delay, untilEnd));
39 }
40 return delay;
41}
42hooks/workspace/transport.mjs 131 lines1/**
2 * The Design workspace's wire: JSON-RPC `tools/call` posts to the plugin's own declared MCP server, and what comes back.
3 *
4 * Why not `$.mcp.call`: Claude Code keeps the server's app-only tools (status, image, pick, run, handoff) out of its
5 * registry, so `$.mcp.call` refuses them; a plain POST reaches them (design §3.2, planner probes 1 and 2). The server is
6 * stateless JSON, so no `initialize` is needed.
7 *
8 * Pure: the one `$.http.fetch` call lives in register.mjs, because the engine follows `$` only into functions declared
9 * in the hooks module itself. This module builds the request and reads the answer.
10 */
11
12/** The one network destination of the mod: the same URL as the plugin's `.mcp.json`. */
13export const MCP_URL = 'https://design.12ui.com/mcp';
14
15/** The server's tool names, as the screen's contract spells them. */
16export const TOOLS = Object.freeze({
17 show: 'design.slate.show',
18 data: 'design.slate.data',
19 status: 'design.slate.status',
20 image: 'design.slate.image',
21 pick: 'design.slate.pick',
22 run: 'design.slate.run',
23 handoff: 'design.slate.handoff',
24 reference: 'design.slate.reference',
25 inspire: 'design.slate.inspire',
26});
27
28const HEADERS = Object.freeze({
29 'content-type': 'application/json',
30 accept: 'application/json, text/event-stream',
31 'mcp-protocol-version': '2025-06-18',
32});
33
34let nextId = 1;
35
36/** The `$.http.fetch` init for one `tools/call`. */
37export function rpcInit(name, args) {
38 const id = nextId;
39 nextId += 1;
40 return {
41 method: 'POST',
42 headers: { ...HEADERS },
43 body: JSON.stringify({ jsonrpc: '2.0', id, method: 'tools/call', params: { name, arguments: args } }),
44 };
45}
46
47/** An answer the pane shows as its error line: the server's or the transport's own words, verbatim. */
48export class WorkspaceError extends Error {
49 constructor(message, { code = null, status = null, retryable = false } = {}) {
50 super(message);
51 this.name = 'WorkspaceError';
52 this.code = code;
53 this.status = status;
54 this.retryable = retryable;
55 }
56}
57
58function firstText(content) {
59 if (!Array.isArray(content)) return '';
60 const block = content.find((item) => item && item.type === 'text' && typeof item.text === 'string');
61 return block ? block.text : '';
62}
63
64/**
65 * The tool result of one `$.http.fetch` answer: `{ structured, content }`, or a thrown WorkspaceError carrying the
66 * HTTP status, the JSON-RPC error, or the tool's own `isError` text ("code: message") verbatim.
67 */
68export function readRpc(response) {
69 const status = response && typeof response.status === 'number' ? response.status : null;
70 const text = response && typeof response.text === 'string' ? response.text : '';
71 let body = null;
72 try {
73 body = text ? JSON.parse(text) : null;
74 } catch {
75 body = null;
76 }
77 if (!response || !response.ok) {
78 const serverWords = body && typeof body.error === 'string' ? body.error
79 : body && body.error && typeof body.error.message === 'string' ? body.error.message
80 : text.trim().slice(0, 300);
81 throw new WorkspaceError(`design.12ui.com answered ${status ?? 'nothing'}${serverWords ? `: ${serverWords}` : ''}`, {
82 status,
83 code: status === 429 ? 'rate_limited' : null,
84 retryable: status === 429 || (status !== null && status >= 500),
85 });
86 }
87 if (!body || typeof body !== 'object') throw new WorkspaceError('design.12ui.com sent an answer that is not JSON', { status });
88 if (body.error) {
89 const message = typeof body.error.message === 'string' ? body.error.message : 'the request was refused';
90 throw new WorkspaceError(message, { status, code: body.error.code ?? null });
91 }
92 const result = body.result;
93 if (!result || typeof result !== 'object') throw new WorkspaceError('design.12ui.com sent an answer with no result', { status });
94 const structured = result.structuredContent && typeof result.structuredContent === 'object' ? result.structuredContent : null;
95 if (result.isError) {
96 const words = firstText(result.content) || (structured && typeof structured.message === 'string' ? structured.message : 'the request failed');
97 throw new WorkspaceError(words, {
98 status,
99 code: structured && typeof structured.code === 'string' ? structured.code : null,
100 retryable: Boolean(structured && structured.retryable),
101 });
102 }
103 return { structured, content: Array.isArray(result.content) ? result.content : [] };
104}
105
106/** The base64 data of the first image block of a tool result, with its type; null when there is none. */
107export function imageBlock(content) {
108 const block = Array.isArray(content) ? content.find((item) => item && item.type === 'image' && typeof item.data === 'string') : null;
109 return block ? { data: block.data, mimeType: typeof block.mimeType === 'string' ? block.mimeType : '' } : null;
110}
111
112/**
113 * Whether a later read can mend a failed call: a WorkspaceError the server marked retryable (a 429, a 5xx, a tool result
114 * with `retryable: true`), or no answer at all (`$.http.fetch` refused or failed). A refusal the server answered, such as
115 * `unknown_workspace`, is final.
116 */
117export function mendsOnRetry(error) {
118 return error instanceof WorkspaceError ? error.retryable : true;
119}
120
121/** The line the pane shows when `$.http.fetch` itself was refused or failed (no answer from the server at all). */
122export function unreachableLine(error) {
123 const reason = error && typeof error.message === 'string' && error.message ? error.message : String(error);
124 return `The Design workspace can't reach design.12ui.com: ${reason}`;
125}
126
127/** The pane's error line for any failure of a call: a server answer verbatim, or the unreachable line. */
128export function errorLine(error) {
129 return error instanceof WorkspaceError ? error.message : unreachableLine(error);
130}
131