SLOPSHOPPER

12ui Design

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…

newpaneguardcommandtoastnetwork
v0.2.84MITupdated 2026-10-02just-every/12ui-claude-plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · 12ui
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /12ui ⎿ 12ui: No Design workspace yet. Ask Claude to open one, for example: Open a Design workspace for my app idea. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

12ui Design

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.

Requirements

  • Claude Code in the terminal or in the Desktop app's Code tab, with Node.js. The agent installs the 12ui command line once with npx -y @12ui/design cli install.
  • The pane needs Claude Code 2.1.287 or later.
  • Drawing needs Codex signed in with ChatGPT on the same computer.
  • Draft, branch and hosted conversion need a 12ui account (12ui auth login).

What this plugin runs, sends and fetches

  • Skill (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.
  • MCP server 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.
  • Mod (the Design workspace pane, Claude Code 2.1.287 or later). It runs inside Claude Code as plain readable source in 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 command
  • tool.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 call
  • command.run{command="12ui"}: answers /12ui: opens the pane, or lists the options as text where nothing draws
  • ui.close{id=design-workspace}: stops polling when the pane closes
  • ui.render{component=Pane}: draws the Design workspace pane and passes every other plugin's pane on untouched

Calls 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 pane

The one network destination it reaches: https://design.12ui.com/mcp.

Example prompts

  • "Open a Design workspace for my app idea."
  • "Show me design options for a fitness app home screen."
  • "Explore looks for my landing page in a Design workspace."

Troubleshooting

  • No pane: Claude Code is older than 2.1.287, mods are turned off, or the session is the VS Code panel, claude -p or Chat.
  • "Codex on this Mac is not signed in. Nothing was drawn.": that is the 12ui command line's own message. Sign in to Codex with ChatGPT on the same computer.
  • "can't reach design.12ui.com": your organization's network policy blocks the host.
  • "The Design workspace can't reach design.12ui.com: … nonessential network traffic is disabled": Claude Code refuses the pane's web requests while CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC is set.
  • Open a workspace again with /12ui <handle>.

Privacy and support

Dependencies

NameVersionLicenseSource
jpeg-js decoder0.4.4Apache-2.0 (file header); BSD-3-Clause (package)https://github.com/jpeg-js/jpeg-js

License

MIT. See LICENSE.

Source 17 files
hooks/register.mjs 663 lines
1/**
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}
663
hooks/workspace/actions.mjs 127 lines
1/**
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}
127
hooks/workspace/bytes.mjs 82 lines
1/**
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}$/;
82
hooks/workspace/copy.mjs 76 lines
1/**
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.';
76
hooks/workspace/gallery.mjs 91 lines
1/**
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}
91
hooks/workspace/handles.mjs 60 lines
1/**
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}
60
hooks/workspace/messages.mjs 53 lines
1/**
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}
53
hooks/workspace/pane.mjs 237 lines
1/**
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}
237
hooks/workspace/layout.mjs 58 lines
1/**
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}
58
hooks/workspace/picture.mjs 208 lines
1/**
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}
208
hooks/workspace/poll.mjs 42 lines
1/**
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}
42
hooks/workspace/transport.mjs 131 lines
1/**
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