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…

newpanebandguardtoastnetwork
★ 2v0.2.120MITupdated 2026-10-09just-every/12ui-plugin
A shopper browsing a rack in a slop shop
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, 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. Drawing, editing, branching and Build this need no 12ui account: branching plans and draws the new screens with your own Codex too, and 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.120. Install with claude plugin marketplace add just-every/12ui-plugin, then claude plugin install 12ui-design@12ui-plugin.

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.
  • Branch also runs on your own Codex: with Codex signed in with ChatGPT it plans and draws the new screens there and needs no 12ui account.
  • Draft, hosted conversion and the hosted branch service (used when Codex is not ready) 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 saves one workspace image 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 reaches only https://design.12ui.com/mcp; "What the mod does" below says exactly what it reads, sends and submits.

What the mod does

The mod is the Design workspace pane. It runs inside Claude Code as plain readable source in hooks/.

  • The one host it contacts: https://design.12ui.com/mcp, this plugin's own MCP server. The address is written as fixed text at the mod's one network call, and the request cannot be sent anywhere else.
  • What it reads: the results of this plugin's own two tools, design_slate_create and design_slate_show, for the workspace handle, and what you click and type in the pane. It reads nothing else from the conversation.
  • What it sends, to that host only, and never any conversation text:
  • the workspace handle, and the option, version, reference and request ids the pane shows
  • your picks and which button you pressed (new options, more like an option, an edit, a branch, Build this, Continue)
  • the words you type in the pane (the design prompt, an edit note, branch page names)
  • The prompts it submits: after you click in the pane, one line telling Claude what you did, exactly one of these ({label} is an option's letter, {request} and {handle} are ids). Your own words are never put in a prompt; Claude reads them from the workspace.
  • The user asked for new options in the Design workspace (request {request}, runDir {handle}). Please confirm it with design.slate.data, then carry it out.
  • The user asked for more like {label} in the Design workspace (request {request}, runDir {handle}). Please confirm it with design.slate.data, then carry it out.
  • The user asked to edit {label} in the Design workspace (request {request}, runDir {handle}). Please confirm it with design.slate.data, then carry it out.
  • The user asked for the full page of {label} in the Design workspace (request {request}, runDir {handle}). Please confirm it with design.slate.data, then carry it out.
  • The user asked for more pages of {label} in the Design workspace (request {request}, runDir {handle}). Please confirm it with design.slate.data, then carry it out.
  • The user chose design {label} to build in the Design workspace (runDir {handle}). Please read its selection with design.slate.data, then build that design as the user's request asks.
  • What it never does: it reads no credentials, keys, environment, settings or files, runs no commands or processes, calls no model and touches no other tool's calls.

Hooks it registers, each with what it is for:

  • tool.call{tool=mcp__plugin_12ui-design_12ui-workspace__design_slate_create|mcp__plugin_12ui-design_12ui-workspace__design_slate_show}: observes only this plugin's own create and show tools: reads the workspace handle from the result and opens the pane; it never changes or blocks the call, and reads nothing else
  • ui.render{component=Pane}: draws the Design workspace pane and passes every other plugin's pane on untouched
  • ui.render{component=AbovePrompt}: while the pane waits for room on a narrow terminal, shows one line with an Open button above the prompt; otherwise passes on untouched

Calls it makes, each with its purpose:

  • $.clock.after: schedules the next workspace refresh, the follow-up check after a click, and one redraw for pictures that land together or for what changed during your presses
  • $.clock.now: reads the time to pace the workspace refreshes and redraws, and to refresh when the start countdown ends
  • $.http.fetch: reads the workspace and records your clicks at https://design.12ui.com/mcp
  • $.prompt.submit: tells Claude, in one fixed line, what you clicked in the pane (new options, more like an option, an edit, a branch, Build this); never your own words
  • $.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, and again when you press Open above the prompt
  • $.ui.panes: checks whether the Design workspace pane is still open, and stops polling once you have closed it
  • $.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

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.
  • The pane waits on a narrow terminal: press Open above the prompt. To open a workspace again, ask Claude to show it.

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

Codex plugin

The 12ui-design skill as a Codex plugin. The skill uses the 12ui command line for local work. The hosted service and its documentation live at 12ui.com.

This repository is generated from the private 12ui implementation at the exact @12ui/design release revision. Version 0.2.120 matches the npm package.

Install as a Codex plugin

Add this repository as the 12ui-plugin marketplace, then install the 12ui-design plugin from it:

codex plugin marketplace add just-every/12ui-plugin
codex plugin add 12ui-design@12ui-plugin

The marketplace metadata is generated at /.agents/plugins/marketplace.json, and the plugin manifest is at /.codex-plugin/plugin.json.

Install across supported agents

The cross-agent installer supports Codex, Claude Code, Grok, Cursor, Antigravity, and GitHub Copilot:

npx -y @12ui/design skill install

That installer places only 12ui-design; recognized historical design bundles are retired, while locally modified copies are preserved.

Skill

12ui-design is installed byte-identically for every supported client. Run 12ui capabilities to see what the installed command line supports before a run.

Documentation

Manual directory upload

Each GitHub release attaches 12ui-design-0.2.120.zip, the validated plugin with the manifest and skill at the archive root, and 12ui-design-0.2.120-openai.zip, its skills-only form for the OpenAI Plugins Directory manual upload flow.

Provenance

Every release is generated from a fixed allowlist, validated for exact skill, icon, manifest, and marketplace parity, packaged as a zip, tagged design-v0.2.120, and published only after the matching npm release completes. Do not edit generated files directly; changes must originate in the 12ui release source.

License

MIT. See LICENSE.

Source 18 files
hooks/register.mjs 745 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 *   this plugin's own create and show tools   reads the workspace handle from their result, opens the pane
6 *   drawing the Pane                          draws the workspace in its own pane; passes every other pane on
7 *   drawing above the prompt                  while the pane waits for room, one Open button; otherwise passes on
8 *   closing the pane                          stops polling
9 *
10 * Every `$` call lives in this file, because the engine follows `$` only into functions declared in the hooks module
11 * itself. The one network request is the fetch in `rpc`, its address and options written at the call: the plugin's
12 * own MCP server. It sends workspace handles, ids, picks and the words typed in the pane, never the conversation.
13 * `$.prompt.submit` tells Claude, in one fixed line, what the person clicked; `$.ui.*` draws, `$.clock.*` polls and
14 * counts down. Nothing else: the mod reads nothing from the computer, runs nothing and touches no other tool's calls.
15 */
16
17import { continueArgs, convertHandoffArgs, holdArgs, inspireArgs, isActionError, pickArgs, runBranchArgs, runEditArgs, runNewArgs } from './workspace/actions.mjs';
18import { uuidV4 } from './workspace/bytes.mjs';
19import { LOADING, NO_WORKSPACE, PANE_ID, PANE_TITLE, READY_TOAST, RECORDED_RUNNER, SENT } from './workspace/copy.mjs';
20import { PER_ROUND, SEARCH_COUNT, inspirationIds, openingTab, perRoundOf, pickLimit, picksOf, referenceSize, sameList, siteItems, tabThumbs, togglePick, twelveDraws, viewThumbs, waitingRound } from './workspace/gallery.mjs';
21import { handleFromCall } from './workspace/handles.mjs';
22import { afterRun, buildMessage, judgeRunner, runMessage } from './workspace/messages.mjs';
23import { bandTree, paneTree } from './workspace/pane.mjs';
24import { pictureMaxRows, tileGrid } from './workspace/layout.mjs';
25import { blitSaysAlt, decodeThumb, desktopPictureSize, imageSource, imageTreeBudget, pictureKind, rasterCells, svgPicture, tileBox, useCellsFor } from './workspace/picture.mjs';
26import { nextStep, pollDelay, retriesFirstRead, wantsPolling } from './workspace/poll.mjs';
27import { drawnSignature, pictureRedrawDelay, pressQuietDelay } from './workspace/redraw.mjs';
28import { TOOLS, errorLine, imageBlock, isWorkspaceError, mendsOnRetry, readRpc, rpcBody, workspaceError } from './workspace/transport.mjs';
29import { briefLine, countdownLine, countdownOf, findOp, hasRunning, isView, labelOfVersion, opWaitsOnPerson, tilesOf } from './workspace/view.mjs';
30
31// ---- the session's state: module memory, as the hosted screen keeps it (a reload starts over) ----
32let current = null;
33// True while the pane is open but waits undrawn (opened unasked on a narrow terminal): the band above the prompt offers Open.
34let paneWaits = false;
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 (`openedFor`: the workspace whose opening tab was chosen), the prompt
43// line, the waiting round's picks, the large view.
44const ui = { tab: 'inspiration', autoTab: false, openedFor: null, prompt: '', promptEdited: false, chosen: null, chosenOp: null, heldOp: null, pauseOp: null, lv: null, perRound: null };
45const poll = { timer: null, step: 0, busy: false, isOpen: false, failedRetryably: false };
46const blit = { altDrawn: false, toggled: null, probed: false, lastAnswer: null };
47// The pane's own redraws (redraw.mjs): what the last drawing showed (`shows`: the workspace whose view it drew) and for
48// which surface and props, when the pane last asked for one on its own, the batch of pictures waiting to be drawn
49// together (when it began, its timer), the person's last press and the redraw waiting for the pause after it.
50const redraw = { drawn: null, shows: null, surface: null, props: null, lastSelfAtMs: null, batchStartAtMs: null, timer: null, lastPressAtMs: null, quietTimer: null };
51let pollError = '';
52let actionError = '';
53let noticeText = '';
54
55// ---- the wire ----
56/** One JSON-RPC `tools/call` to the plugin's own MCP server: the mod's one network request, its address fixed here. */
57async function rpc($, name, args) {
58  const response = await $.http.fetch('https://design.12ui.com/mcp', {
59    method: 'POST',
60    headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream', 'mcp-protocol-version': '2025-06-18' },
61    body: rpcBody(name, args),
62  });
63  return readRpc(response);
64}
65
66function viewOf(runDir) {
67  const entry = runDir ? views.get(runDir) : null;
68  return entry ? entry.view : null;
69}
70
71/** The slot of anything kept per workspace and per item: the workspace handle, then the item's own parts. */
72function workspaceSlot(runDir, ...parts) {
73  return [runDir, ...parts].join('\u0000');
74}
75
76function draftId(slot) {
77  if (!drafts.has(slot)) drafts.set(slot, uuidV4());
78  return drafts.get(slot);
79}
80
81// ---- the view, the pictures and the polling ----
82async function applyView($, runDir, view) {
83  views.set(runDir, { view, receivedAtMs: await $.clock.now() });
84  if (runDir !== current) return;
85  followPause(runDir, view, views.get(runDir).receivedAtMs);
86  fetchThumbs($, runDir);
87  // A view whose shown tab still waits on pictures (ones it brings, or ones already being read) is drawn once, together
88  // with them: it joins their batch (redraw.mjs PICTURE_WAIT_MS at most) instead of drawing its tiles loading and then
89  // again with their pictures. That holds for the first view too: until it draws, the pane says it is loading and has
90  // no tabs to press. A view with every picture in is drawn at once.
91  if (thumbsOut() > 0) await joinPictureBatch($);
92  else await redrawIfChanged($);
93}
94
95/** Reads every thumbnail of the view the pane does not hold yet, the shown tab's first (gallery.mjs `viewThumbs`). */
96function fetchThumbs($, runDir) {
97  const view = viewOf(runDir);
98  if (!view || runDir !== current) return;
99  for (const want of viewThumbs(view, ui.tab)) {
100    const slot = workspaceSlot(runDir, want.slot);
101    if (thumbs.has(slot)) continue;
102    void fetchThumb($, slot, want.kind === 'version' ? TOOLS.image : TOOLS.reference, { runDir, ...want.args, size: 'thumb' });
103  }
104}
105
106/** Whether the shown tab of the shown workspace draws the thumbnail kept under `slot`. */
107function isShown(slot) {
108  const view = viewOf(current);
109  return Boolean(view) && tabThumbs(view, ui.tab).some((want) => workspaceSlot(current, want.slot) === slot);
110}
111
112/** How many of the shown tab's thumbnails are still being read. */
113function thumbsOut() {
114  const view = viewOf(current);
115  if (!view) return 0;
116  return tabThumbs(view, ui.tab).filter((want) => {
117    const thumb = thumbs.get(workspaceSlot(current, want.slot));
118    return thumb && thumb.state === 'loading';
119  }).length;
120}
121
122/**
123 * The tab a workspace opens on (gallery.mjs `openingTab`): Inspiration with its picks preselected while a round waits on
124 * the person, else Designs once it has options. A round that starts waiting later moves the pane to Inspiration; when
125 * the wait ends (Continue, or the countdown ran out) a pane the round put on Inspiration moves to Designs, as the screen
126 * does.
127 */
128function followPause(runDir, view, receivedAtMs) {
129  const countdown = countdownOf(view, receivedAtMs);
130  const opening = ui.openedFor !== runDir;
131  ui.openedFor = runDir;
132  if (opening && !countdown) {
133    ui.tab = openingTab(view, false);
134    ui.autoTab = false;
135  } else if (countdown && ui.pauseOp !== countdown.opId) {
136    ui.pauseOp = countdown.opId;
137    ui.chosen = null;
138    ui.chosenOp = null;
139    if (ui.tab !== 'inspiration') ui.tab = 'inspiration';
140    ui.autoTab = true;
141  } else if (!countdown && ui.pauseOp) {
142    ui.pauseOp = null;
143    if (ui.autoTab) ui.tab = 'designs';
144    ui.autoTab = false;
145  }
146}
147
148async function fetchThumb($, slot, tool, args) {
149  thumbs.set(slot, { state: 'loading' });
150  try {
151    const { content } = await rpc($, tool, args);
152    const block = imageBlock(content);
153    if (!block) throw workspaceError('No picture came back.');
154    thumbs.set(slot, { state: 'ready', jpeg: block.data, mimeType: block.mimeType, cache: new Map() });
155  } catch (error) {
156    thumbs.set(slot, { state: 'failed', reason: errorLine(error) });
157  }
158  // Another tab's picture, or another workspace's, changes nothing drawn: it waits in `thumbs` for that tab's drawing.
159  if (isShown(slot)) await joinPictureBatch($);
160}
161
162/**
163 * A shown picture landed, or a view that waits on some: drawn with the others of the shown tab, in one redraw once the
164 * last is in, or when the batch has waited PICTURE_WAIT_MS (redraw.mjs `pictureRedrawDelay`).
165 */
166async function joinPictureBatch($) {
167  const now = await $.clock.now();
168  if (redraw.batchStartAtMs === null) redraw.batchStartAtMs = now;
169  // The once-a-second pace is between drawings of one workspace's view: a workspace's first view (the pane says it is
170  // loading) draws as soon as its pictures are in.
171  const paced = redraw.lastSelfAtMs !== null && redraw.shows === current;
172  const delay = pictureRedrawDelay({
173    sinceLastSelfMs: paced ? now - redraw.lastSelfAtMs : null,
174    sinceBatchStartMs: now - redraw.batchStartAtMs,
175    outstanding: thumbsOut(),
176  });
177  if (redraw.timer) redraw.timer.cancel();
178  redraw.timer = $.clock.after(delay, () => {
179    redraw.timer = null;
180    redraw.batchStartAtMs = null;
181    void redrawIfChanged($);
182  });
183}
184
185/** What `thumbs` holds for a slot, as a drawing would show it, without decoding or encoding a picture. */
186function thumbFact(thumbSlot) {
187  const thumb = thumbs.get(thumbSlot);
188  if (!thumb) return null;
189  if (thumb.state === 'loading') return { kind: 'loading' };
190  if (thumb.state === 'failed') return { kind: 'failed', reason: thumb.reason };
191  return { kind: 'ready' };
192}
193
194/**
195 * A redraw the pane asks for on its own (a view, pictures, a poll's error): only when what it would draw differs from
196 * what is drawn (redraw.mjs `drawnSignature`), and only once the person has paused pressing (`pressQuietDelay`).
197 * Presses redraw directly.
198 */
199async function redrawIfChanged($) {
200  const now = await $.clock.now();
201  const quiet = pressQuietDelay(redraw.lastPressAtMs === null ? null : now - redraw.lastPressAtMs);
202  if (quiet > 0) {
203    if (!redraw.quietTimer) redraw.quietTimer = $.clock.after(quiet, () => { redraw.quietTimer = null; void redrawIfChanged($); });
204    return;
205  }
206  if (redraw.drawn !== null) {
207    const { model } = paneModel(redraw.surface, redraw.props, thumbFact);
208    const signature = drawnSignature(model);
209    if (signature === redraw.drawn) return;
210    redraw.drawn = signature;
211  }
212  redraw.lastSelfAtMs = now;
213  $.ui.invalidate('ui.render');
214}
215
216function stopPolling() {
217  if (poll.timer) poll.timer.cancel();
218  poll.timer = null;
219}
220
221/** Whether the Design workspace pane is still on screen: the person closing it raises nothing a hook sees, so this asks. */
222async function paneIsUp($) {
223  return (await $.ui.panes()).some((pane) => pane.id === PANE_ID);
224}
225
226/** The pane is gone: nothing polls or waits for it any more. */
227function paneGone() {
228  poll.isOpen = false;
229  paneWaits = false;
230  stopPolling();
231}
232
233async function schedulePoll($) {
234  stopPolling();
235  const entry = views.get(current);
236  const running = wantsPolling({ isOpen: poll.isOpen, hasWorkspace: Boolean(entry), isRunning: Boolean(entry && hasRunning(entry.view)) });
237  const retrying = retriesFirstRead({ isOpen: poll.isOpen, hasView: Boolean(entry), failedRetryably: poll.failedRetryably });
238  if (!running && !retrying) return;
239  const delay = pollDelay(poll.step, entry ? countdownOf(entry.view, entry.receivedAtMs) : null, await $.clock.now());
240  poll.timer = $.clock.after(delay, () => { void refresh($); });
241}
242
243/** Read the shown workspace now, once the current turn of the clock is through (a pane in sight only). */
244function readSoon($) {
245  if (poll.isOpen && current) $.clock.after(0, () => { void refresh($); });
246}
247
248/**
249 * One status read for the shown workspace (the screen in sight), then the next one if anything still runs or a first
250 * read failed in a way a retry can mend. A read only ever lands on the workspace it asked about: when the person
251 * switched workspaces while it was out, its answer is dropped and the workspace now shown is read instead (the read
252 * the switch asked for found this one still out and was dropped).
253 */
254async function refresh($) {
255  if (!(await paneIsUp($))) {
256    paneGone();
257    return;
258  }
259  const runDir = current;
260  if (!runDir || poll.busy) return;
261  poll.busy = true;
262  let changed = false;
263  try {
264    const known = viewOf(runDir);
265    const { structured } = await rpc($, TOOLS.status, known ? { runDir, since: known.stamp } : { runDir });
266    if (isView(structured) && runDir === current) {
267      changed = true;
268      pollError = '';
269      poll.failedRetryably = false;
270      await applyView($, runDir, structured);
271    }
272  } catch (error) {
273    if (runDir === current) {
274      pollError = errorLine(error);
275      poll.failedRetryably = mendsOnRetry(error);
276      await redrawIfChanged($);
277    }
278  } finally {
279    poll.busy = false;
280  }
281  if (runDir !== current) {
282    if (poll.isOpen) await refresh($);
283    return;
284  }
285  poll.step = nextStep(poll.step, changed);
286  await schedulePoll($);
287}
288
289/**
290 * Show `runDir` in the pane. A different workspace starts over and, with the pane open, is read at once (an open pane
291 * reads nothing else for a workspace it holds no view of). The workspace already shown is read again only while the
292 * pane holds no view of it, as after a first read the server refused: that is how showing it again asks once more.
293 */
294function showWorkspace($, runDir) {
295  if (runDir === current) {
296    if (!views.has(runDir)) readSoon($);
297    return;
298  }
299  current = runDir;
300  stopPolling();
301  poll.step = 0;
302  poll.failedRetryably = false;
303  pollError = '';
304  actionError = '';
305  noticeText = '';
306  ui.lv = null;
307  ui.pauseOp = null;
308  ui.promptEdited = false;
309  // Presses on the last workspace's drawing hold back no redraw of this one: what they pressed on is gone.
310  redraw.lastPressAtMs = null;
311  // No redraw here: its one caller opens the pane next, and that open redraws it.
312  readSoon($);
313}
314
315/**
316 * Open the pane. Opened unasked (Claude opened a workspace) a narrow terminal keeps it waiting undrawn: the toast says
317 * so and the band above the prompt offers Open, which the person presses, so that open is asked and placed at any width.
318 */
319async function openPane($, asked) {
320  const opened = await $.ui.open(asked ? { id: PANE_ID, title: PANE_TITLE, focus: true } : { id: PANE_ID, title: PANE_TITLE });
321  paneWaits = !opened.isPlaced;
322  if (paneWaits && !asked) $.ui.toast(READY_TOAST);
323  $.ui.invalidate('ui.render');
324  return opened;
325}
326
327// ---- telling Claude ----
328function tell($, text) {
329  $.prompt.submit({ text }).catch((error) => {
330    actionError = `Recorded, but Claude Code did not take the message: ${error && error.message ? error.message : String(error)}`;
331    $.ui.invalidate('ui.render');
332  });
333  noticeText = SENT;
334}
335
336async function runnerCheck($, runDir, opId) {
337  const watch = runnerWatch.get(opId);
338  if (!watch) return;
339  try {
340    const { structured } = await rpc($, TOOLS.status, { runDir });
341    if (isView(structured)) await applyView($, runDir, structured);
342  } catch {
343    // The check judges on the last view the pane holds, as the hosted screen does.
344  }
345  const view = viewOf(runDir);
346  const op = view ? findOp(view, opId) : null;
347  const verdict = judgeRunner(op, view ? opWaitsOnPerson(view, op) : false, watch);
348  if (verdict.drop) {
349    runnerWatch.delete(opId);
350    return;
351  }
352  if (verdict.tell) {
353    runnerWatch.delete(opId);
354    tell($, watch.message);
355    $.ui.invalidate('ui.render');
356    return;
357  }
358  watch.waited = verdict.waited;
359  $.clock.after(verdict.waitMs, () => { void runnerCheck($, runDir, opId); });
360}
361
362function afterRunRecorded($, runDir, opId, message, runner) {
363  const first = afterRun(runner);
364  if (first.tell) {
365    tell($, message);
366    return;
367  }
368  noticeText = RECORDED_RUNNER;
369  runnerWatch.set(opId, { message, waited: false });
370  $.clock.after(first.waitMs, () => { void runnerCheck($, runDir, opId); });
371}
372
373// ---- the clicks ----
374async function record($, slot, work) {
375  if (inFlight.has(slot)) return;
376  inFlight.add(slot);
377  actionError = '';
378  noticeText = '';
379  $.ui.invalidate('ui.render');
380  try {
381    await work();
382    drafts.delete(slot);
383  } catch (error) {
384    actionError = isActionError(error) ? error.message : errorLine(error);
385    // A request the server answered and refused is done with: the next try is a new request. One that may not have
386    // reached it (no answer, or a retryable status) keeps its id, so trying again cannot record it twice.
387    if (isWorkspaceError(error) && !error.retryable) drafts.delete(slot);
388  } finally {
389    inFlight.delete(slot);
390    $.ui.invalidate('ui.render');
391  }
392}
393
394async function recordRun($, slot, args) {
395  const runDir = args.runDir;
396  const { structured } = await rpc($, TOOLS.run, args);
397  const view = structured && isView(structured.view) ? structured.view : viewOf(runDir);
398  const message = runMessage(runDir, args.opId, args.action, (versionId) => (view ? labelOfVersion(view, versionId) : null));
399  if (structured && isView(structured.view)) await applyView($, runDir, structured.view);
400  afterRunRecorded($, runDir, args.opId, message, structured ? structured.runner : null);
401  poll.step = 0;
402  await schedulePoll($);
403}
404
405/** The waiting round's op, its picks and whether the person changed them, for the shown workspace. */
406function pauseNow() {
407  const entry = views.get(current);
408  const countdown = entry ? countdownOf(entry.view, entry.receivedAtMs) : null;
409  if (!entry || !countdown) return null;
410  const round = waitingRound(entry.view);
411  const picks = picksOf(entry.view, ui.chosenOp === countdown.opId ? ui.chosen : null);
412  return { view: entry.view, countdown, round, picks, changed: Boolean(round && ui.chosenOp === countdown.opId && ui.chosen && !sameList(ui.chosen, round.referenceIds || [])) };
413}
414
415/** The screen holds a waiting round's countdown once the person touches it; the pane does the same on the first pick. */
416function holdOnce($) {
417  const pause = pauseNow();
418  if (!pause || pause.countdown.state !== 'countdown' || ui.heldOp === pause.countdown.opId) return;
419  ui.heldOp = pause.countdown.opId;
420  const runDir = current;
421  rpc($, TOOLS.run, holdArgs(runDir, pause.countdown.opId)).then(async ({ structured }) => {
422    if (structured && isView(structured.view) && runDir === current) await applyView($, runDir, structured.view);
423  }, (error) => { actionError = errorLine(error); $.ui.invalidate('ui.render'); });
424}
425
426/** A pick: offered only while a round waits (pane.mjs); a press on a drawing from before the wait ended does nothing. */
427function togglePickOf($, id) {
428  const pause = pauseNow();
429  if (!pause) return;
430  const next = togglePick(pause.picks, id, pickLimit(pause.view));
431  if (next.refused) noticeText = `At most ${pickLimit(pause.view)} picks.`;
432  else noticeText = '';
433  ui.chosen = next.picks;
434  ui.chosenOp = pause.countdown.opId;
435  holdOnce($);
436  $.ui.invalidate('ui.render');
437}
438
439function continueRound($) {
440  const pause = pauseNow();
441  if (!pause) return Promise.resolve();
442  const runDir = current;
443  const opId = pause.countdown.opId;
444  const brief = ui.promptEdited ? ui.prompt : '';
445  return record($, workspaceSlot(runDir, 'continue', opId), async () => {
446    const { structured } = await rpc($, TOOLS.run, continueArgs(runDir, opId, { referenceIds: pause.changed ? pause.picks : null, brief }));
447    ui.tab = 'designs';
448    ui.autoTab = false;
449    if (structured && isView(structured.view)) await applyView($, runDir, structured.view);
450    poll.step = 0;
451    await schedulePoll($);
452  });
453}
454
455function searchPrompt($, words) {
456  const runDir = current;
457  ui.prompt = String(words ?? ui.prompt);
458  ui.promptEdited = true;
459  holdOnce($);
460  return record($, workspaceSlot(runDir, 'inspire', ui.prompt), async () => {
461    const { structured } = await rpc($, TOOLS.inspire, inspireArgs(runDir, ui.prompt, SEARCH_COUNT));
462    ui.tab = 'inspiration';
463    if (structured && isView(structured.view)) await applyView($, runDir, structured.view);
464    else await refresh($);
465  });
466}
467
468function setPerRound($, n) {
469  const view = viewOf(current);
470  ui.perRound = n;
471  $.ui.invalidate('ui.render');
472  if (n !== 12 || !view || !twelveDraws(view, hasRunning(view))) return Promise.resolve();
473  const runDir = current;
474  const slot = workspaceSlot(runDir, 'more');
475  return record($, slot, () => recordRun($, slot, runNewArgs(runDir, draftId(slot), { count: 12 })));
476}
477
478function largeTile() {
479  const view = viewOf(current);
480  return view && ui.lv ? tilesOf(view).find((tile) => tile.candidateId === ui.lv.candidateId) ?? null : null;
481}
482
483/** Build this: record the pick, record the Convert-to-HTML hand-off (the request Claude builds from), then tell Claude. */
484function buildThis($) {
485  const tile = largeTile();
486  if (!tile || !tile.versionId) return Promise.resolve();
487  const runDir = current;
488  const slot = workspaceSlot(runDir, 'build', tile.versionId);
489  return record($, slot, async () => {
490    const { structured } = await rpc($, TOOLS.pick, pickArgs(runDir, tile.versionId));
491    if (structured && isView(structured.view)) await applyView($, runDir, structured.view);
492    // One handoff id per version, kept until the whole click succeeded, so a retry never records a second request.
493    const handoff = await rpc($, TOOLS.handoff, convertHandoffArgs(runDir, draftId(slot), tile.versionId));
494    if (handoff.structured && isView(handoff.structured.view)) await applyView($, runDir, handoff.structured.view);
495    const label = structured && structured.pick && structured.pick.label ? structured.pick.label : tile.label;
496    tell($, buildMessage(runDir, label));
497  });
498}
499
500function editDesign($, words) {
501  const tile = largeTile();
502  if (!tile || !tile.versionId) return Promise.resolve();
503  const runDir = current;
504  const slot = workspaceSlot(runDir, 'edit', tile.versionId);
505  return record($, slot, async () => {
506    await recordRun($, slot, runEditArgs(runDir, draftId(slot), tile.versionId, words));
507    ui.lv = null;
508  });
509}
510
511function branchDesign($, scope, pagesText) {
512  const tile = largeTile();
513  if (!tile || !tile.versionId) return Promise.resolve();
514  const runDir = current;
515  // One draft per version and scope: a Full page retry cannot repeat as More pages, nor the reverse.
516  const slot = workspaceSlot(runDir, 'branch', tile.versionId, scope);
517  return record($, slot, async () => {
518    await recordRun($, slot, runBranchArgs(runDir, draftId(slot), tile.versionId, scope, pagesText));
519    ui.lv = null;
520  });
521}
522
523// ---- drawing ----
524async function probeBlit($, slot, source) {
525  if (blit.probed) return;
526  blit.probed = true;
527  const answer = await $.ui.blit({ requestId: PANE_ID, key: slot, source });
528  blit.lastAnswer = answer;
529  $.ui.log(`blit probe: ${JSON.stringify(answer)}`, { to: 'debug' });
530  if (blitSaysAlt(answer)) {
531    blit.altDrawn = true;
532    $.ui.invalidate('ui.render');
533  }
534}
535
536/**
537 * New thumbnail decodes one render may run. The JPEG and WebP decoders are pure JS on the hooks worker, which must
538 * answer the engine's heartbeat within 5 s or the engine unloads the mod (seen live 2026-10-02: one render decoding a
539 * page of references wedged the worker and the pane vanished). The rest show as loading and the next render takes them.
540 */
541const DECODES_PER_RENDER = 2;
542const decodes = { left: DECODES_PER_RENDER, deferred: false };
543
544/** The picture of one thumbnail (a design version or a reference) for a box `tileColumns` wide. */
545function pictureFor(surface, thumbSlot, size, useCells, tileColumns, maxRows) {
546  const thumb = thumbs.get(thumbSlot);
547  if (!thumb) return null;
548  if (thumb.state === 'loading') return { kind: 'loading' };
549  if (thumb.state === 'failed') return { kind: 'failed', reason: thumb.reason };
550  const kind = pictureKind(surface, { useCells });
551  if (kind !== 'svg' && !thumb.decoded) {
552    if (decodes.left <= 0) {
553      decodes.deferred = true;
554      return { kind: 'loading' };
555    }
556    decodes.left -= 1;
557  }
558  const box = tileBox(tileColumns, size.width, size.height, maxRows);
559  const cacheSlot = `${kind}:${box.columns}x${box.rows}`;
560  if (thumb.cache.has(cacheSlot)) return thumb.cache.get(cacheSlot);
561  let picture;
562  try {
563    if (kind === 'svg') {
564      const width = size.width || 480;
565      const height = size.height || 270;
566      const svg = svgPicture({ jpegBase64: thumb.jpeg, width, height, mimeType: thumb.mimeType }, () => decodedOf(thumb));
567      picture = { kind, source: svg.source, ...desktopPictureSize(box, width, height) };
568    } else if (kind === 'raster') {
569      picture = { kind, ...rasterCells(decodedOf(thumb), box.columns, box.rows) };
570    } else {
571      picture = { kind, source: imageSource(decodedOf(thumb), box), columns: box.columns, rows: box.rows };
572    }
573  } catch (error) {
574    picture = { kind: 'failed', reason: error && error.message ? error.message : String(error) };
575  }
576  thumb.cache.set(cacheSlot, picture);
577  return picture;
578}
579
580function decodedOf(thumb) {
581  if (!thumb.decoded) thumb.decoded = decodeThumb(thumb.jpeg, thumb.mimeType);
582  return thumb.decoded;
583}
584
585/** A reference's size: the corpus record, else the decoded picture's own once it is in. */
586function refSize(view, id) {
587  const thumb = thumbs.get(workspaceSlot(current, `ref:${id}`));
588  if (thumb && thumb.state === 'ready' && thumb.decoded) return { width: thumb.decoded.width, height: thumb.decoded.height };
589  return referenceSize(view, id);
590}
591
592/** The pane's handlers, each noting the press first, so the pane's own redraws wait for the pause after it (redraw.mjs). */
593function pressHandlers($, handlers) {
594  const noted = {};
595  for (const [name, handler] of Object.entries(handlers)) {
596    noted[name] = (...args) => {
597      void $.clock.now().then((now) => { redraw.lastPressAtMs = now; });
598      return handler(...args);
599    };
600  }
601  return noted;
602}
603
604/**
605 * What the pane draws for `surface` and its `props`, as a plain model (pane.mjs draws it). `pictureOf(thumbSlot, size,
606 * tileColumns, maxRows)` gives each picture: the render's real ones, or redrawIfChanged's facts without pixels.
607 */
608function paneModel(surface, props, pictureOf) {
609  const bodyColumns = props && typeof props.bodyColumns === 'number' ? props.bodyColumns : 80;
610  const maxRows = pictureMaxRows(props && props.scroll ? props.scroll.bodyRows : undefined);
611  const grid = tileGrid(bodyColumns);
612  const tileColumns = Math.max(8, grid.columns - 2);
613  const entry = current ? views.get(current) : null;
614  const view = entry ? entry.view : null;
615  let probe = null;
616  const budget = imageTreeBudget();
617  const remember = (slot, picture) => {
618    const admitted = budget.admit(picture);
619    if (admitted && admitted.kind === 'image' && !probe) probe = { slot: 'picture:' + slot, source: admitted.source };
620    return admitted;
621  };
622  const model = {
623    surface,
624    workspace: current,
625    emptyText: current ? LOADING : NO_WORKSPACE,
626    isLoading: !view,
627    prompt: ui.prompt,
628    tab: ui.tab,
629    noticeText,
630    errorText: actionError || pollError,
631    tileColumns,
632    items: [],
633    tiles: [],
634    lv: null,
635    tray: null,
636    selectedCount: 0,
637    emptyLine: '',
638    perRound: 6,
639    perRoundOptions: PER_ROUND,
640  };
641  if (!view) return { model, probe };
642  if (!ui.promptEdited) model.prompt = ui.prompt = briefLine(view);
643  const pause = pauseNow();
644  const picks = pause ? pause.picks : [];
645  model.selectedCount = pause ? picks.length : (view.pick ? 1 : 0);
646  if (pause) {
647    model.tray = { words: `${picks.length} picked · ${countdownLine(pause.countdown)}`, canContinue: picks.length > 0 };
648  }
649  if (ui.tab === 'designs') {
650    model.perRound = perRoundOf(view, ui.perRound);
651    if (ui.lv) {
652      const tile = largeTile();
653      if (tile) {
654        const lvColumns = Math.max(16, bodyColumns - 4);
655        const picture = tile.versionId ? pictureOf(workspaceSlot(current, tile.versionId), { width: tile.width, height: tile.height }, lvColumns, Math.max(maxRows, 30)) : null;
656        model.lv = { tile, picture: remember(`lv:${tile.candidateId}`, picture), mode: ui.lv.mode, scope: ui.lv.scope, pages: ui.lv.pages };
657      } else ui.lv = null;
658    }
659    model.tiles = tilesOf(view).map((tile) => ({
660      ...tile,
661      picture: tile.versionId ? remember(tile.candidateId, pictureOf(workspaceSlot(current, tile.versionId), { width: tile.width, height: tile.height }, tileColumns, maxRows)) : null,
662    }));
663  } else {
664    const ids = ui.tab === 'references' ? siteItems(view) : inspirationIds(view).map((id) => ({ id, title: '' }));
665    model.emptyLine = ui.tab === 'references' ? (pause ? 'Finding live sites.' : 'No website references yet.') : 'No inspiration yet.';
666    model.items = ids.map((item) => ({
667      ...item,
668      selected: picks.includes(item.id),
669      picture: remember(item.id, pictureOf(workspaceSlot(current, `ref:${item.id}`), refSize(view, item.id), tileColumns, maxRows)),
670    }));
671  }
672  return { model, probe };
673}
674
675export function register(on) {
676  on('tool.call', { tool: ['mcp__plugin_12ui-design_12ui-workspace__design_slate_create', 'mcp__plugin_12ui-design_12ui-workspace__design_slate_show'] }, async ($, e, next) => {
677    const result = await next(e);
678    const handle = handleFromCall(e, result);
679    if (handle) {
680      showWorkspace($, handle);
681      // The tool's result is the model's whatever the pane does: a pane that cannot open is logged, never thrown.
682      try {
683        await openPane($, false);
684      } catch (error) {
685        $.ui.log(`the Design workspace pane did not open: ${error && error.message ? error.message : String(error)}`, { to: 'debug' });
686      }
687    }
688    return result;
689  });
690
691  // 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
692  // on down the chain untouched.
693  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
694    if (e.requestId !== PANE_ID) return next(e);
695    if (paneWaits) {
696      // Drawn after waiting (the terminal widened): the band's Open goes away.
697      paneWaits = false;
698      $.ui.invalidate('ui.render');
699    }
700    const els = $.ui.resolve(e);
701    if (!poll.isOpen) {
702      poll.isOpen = true;
703      readSoon($);
704    }
705    const useCells = useCellsFor({ toggled: blit.toggled, altDrawn: blit.altDrawn });
706    decodes.left = DECODES_PER_RENDER;
707    decodes.deferred = false;
708    const { model, probe } = paneModel(e.surface, e.props, (thumbSlot, size, tileColumns, maxRows) => pictureFor(e.surface, thumbSlot, size, useCells, tileColumns, maxRows));
709    redraw.drawn = drawnSignature(model);
710    redraw.shows = current && views.has(current) ? current : null;
711    redraw.surface = e.surface;
712    redraw.props = e.props;
713    if (decodes.deferred) $.clock.after(50, () => { $.ui.invalidate('ui.render'); });
714    if (probe && !blit.probed) $.clock.after(250, () => { void probeBlit($, probe.slot, probe.source); });
715    model.handlers = pressHandlers($, {
716      promptInput: (value) => { ui.prompt = String(value ?? ''); ui.promptEdited = true; },
717      promptSubmit: (value) => { void searchPrompt($, value); },
718      tab: (name) => { ui.tab = name; ui.autoTab = false; ui.lv = null; fetchThumbs($, current); $.ui.invalidate('ui.render'); },
719      toggle: (id) => { togglePickOf($, id); },
720      continueRound: () => { void continueRound($); },
721      perRound: (n) => { void setPerRound($, n); },
722      open: (tile) => { ui.lv = { candidateId: tile.candidateId, mode: null, scope: null, pages: '' }; $.ui.invalidate('ui.render'); },
723      close: () => { ui.lv = null; $.ui.invalidate('ui.render'); },
724      edit: () => { if (ui.lv) { ui.lv.mode = ui.lv.mode === 'edit' ? null : 'edit'; ui.lv.scope = null; } $.ui.invalidate('ui.render'); },
725      editSubmit: (words) => { void editDesign($, words); },
726      branch: () => { if (ui.lv) { ui.lv.mode = ui.lv.mode === 'branch' ? null : 'branch'; ui.lv.scope = null; } $.ui.invalidate('ui.render'); },
727      branchScope: (scope) => {
728        if (scope === 'page') void branchDesign($, 'page', '');
729        else if (ui.lv) { ui.lv.scope = 'site'; $.ui.invalidate('ui.render'); }
730      },
731      branchPagesInput: (value) => { if (ui.lv) ui.lv.pages = String(value ?? ''); },
732      branchSubmit: (value) => { void branchDesign($, 'site', value ?? (ui.lv ? ui.lv.pages : '')); },
733      buildThis: () => { void buildThis($); },
734    });
735    return paneTree(els, model);
736  });
737
738  // The band above the prompt, only while the pane waits undrawn: one line and Open. Every other time, and while the
739  // engine shows its own survey there, the band is the next plugin's.
740  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
741    if (!paneWaits || (e.props && e.props.hasSurvey)) return next(e);
742    return bandTree($.ui.resolve(e), () => { void openPane($, true); });
743  });
744}
745
hooks/workspace/actions.mjs 133 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 design prompt the server stores (the view contract's SLATE_BRIEF_MAX_CHARACTERS), in characters once whitespace collapses. */
18export const BRIEF_MAX_CHARS = 20000;
19
20/** A request the pane cannot send as it stands, with the words it shows: a plain Error named `ActionError`. */
21export function actionError(message) {
22  const error = new Error(message);
23  error.name = 'ActionError';
24  return error;
25}
26
27/** Whether an error is one `actionError` made. */
28export function isActionError(error) {
29  return Boolean(error) && error.name === 'ActionError';
30}
31
32function requireId(id) {
33  if (!UUID_V4.test(id)) throw actionError('The request id is not a UUID v4.');
34  return id;
35}
36
37function cleanText(text) {
38  // Control characters are stripped and the ends trimmed, as the servers measure user words.
39  return String(text ?? '').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '').trim();
40}
41
42function utf8Bytes(text) {
43  return new TextEncoder().encode(text).length;
44}
45
46/** `design.slate.pick`: record the selection of a version. */
47export function pickArgs(runDir, versionId) {
48  return { runDir, versionId };
49}
50
51/**
52 * `design.slate.handoff`: record Build this's Convert hand-off, as the Codex screen's does (remoteChoose): kind
53 * `convert`, output `html`, engine `local`. It puts a waiting request, with its command and an upload token, into
54 * `design.slate.data`; without it Claude has a pick but nothing to build from.
55 */
56export function convertHandoffArgs(runDir, handoffId, versionId) {
57  return { runDir, handoffId: requireId(handoffId), kind: 'convert', versionId, options: { engine: 'local', output: 'html' } };
58}
59
60/** `design.slate.run`: new options, `count` of MORE_COUNTS, an optional steer note. */
61export function runNewArgs(runDir, opId, { count = MORE_COUNT_INITIAL, note = '' } = {}) {
62  const n = Number(count);
63  if (!MORE_COUNTS.includes(n)) throw actionError(`Choose ${MORE_COUNTS.join(', ')} options.`);
64  const words = cleanText(note);
65  if (words.length > NOTE_MAX_CHARS) throw actionError(`The note is longer than ${NOTE_MAX_CHARS} characters.`);
66  const action = { kind: 'round', mode: 'new', count: n };
67  if (words) action.note = words;
68  return { runDir, opId: requireId(opId), action };
69}
70
71/** `design.slate.run`: more like one version (the server's default count). */
72export function runLikeArgs(runDir, opId, fromVersionId) {
73  return { runDir, opId: requireId(opId), action: { kind: 'round', mode: 'like', fromVersionId } };
74}
75
76/** `design.slate.run`: edit one version with an instruction in words (the pane has no drawing). */
77export function runEditArgs(runDir, opId, versionId, prompt) {
78  const words = cleanText(prompt);
79  if (!words) throw actionError('Say what to change.');
80  if (utf8Bytes(words) > EDIT_PROMPT_MAX_BYTES) throw actionError('The change is too long to send.');
81  return { runDir, opId: requireId(opId), action: { kind: 'edit', versionId, prompt: words } };
82}
83
84/** `design.slate.run`: start a round that waits on the person now. The op is the round's own (`pause.opId`). */
85export function continueArgs(runDir, roundOpId, { referenceIds = null, brief = '' } = {}) {
86  const action = { kind: 'continue' };
87  if (referenceIds) action.referenceIds = [...referenceIds];
88  const words = cleanText(brief).replace(/\s+/g, ' ');
89  // Every word the person wrote is sent, never cut: one over the server's ceiling is refused here with its count.
90  const count = Array.from(words).length;
91  if (count > BRIEF_MAX_CHARS) throw actionError(`The design prompt is ${count} characters; at most ${BRIEF_MAX_CHARS} fit. Shorten it by ${count - BRIEF_MAX_CHARS}.`);
92  if (words) action.brief = words;
93  return { runDir, opId: requireId(roundOpId), action };
94}
95
96/** `design.slate.run`: hold a waiting round's countdown while the person chooses (the screen holds on any touch). */
97export function holdArgs(runDir, roundOpId) {
98  return { runDir, opId: requireId(roundOpId), action: { kind: 'hold' } };
99}
100
101/** `design.slate.inspire`: search the reference collection with the prompt's words. */
102export function inspireArgs(runDir, query, count) {
103  const words = cleanText(query);
104  if (!words) throw actionError('Describe the design first.');
105  return { runDir, query: words, count };
106}
107
108/** The scopes of a branch (the screen's two choices): the design's full page, or more pages of the product. */
109export const BRANCH_SCOPES = Object.freeze(['page', 'site']);
110export const BRANCH_MAX_PAGES = 8;
111export const PAGE_NAME_MAX_CHARS = 60;
112
113/** Page names typed as one line: comma, semicolon or newline separated, each trimmed and cut, at most eight. */
114export function branchPages(text) {
115  return String(text ?? '')
116    .split(/[,\n;]/)
117    .map((name) => Array.from(cleanText(name).replace(/\s+/g, ' ')).slice(0, PAGE_NAME_MAX_CHARS).join(''))
118    .filter(Boolean)
119    .slice(0, BRANCH_MAX_PAGES);
120}
121
122/**
123 * `design.slate.run`: branch one design. `scope` `page` continues it below the fold into its full page; `site` adds the
124 * product's other pages, the person's names when given (`pages` only travels with `site`).
125 */
126export function runBranchArgs(runDir, opId, fromVersionId, scope, pagesText = '') {
127  if (!BRANCH_SCOPES.includes(scope)) throw actionError('Choose Full page or More pages.');
128  const action = { kind: 'round', mode: 'branch', fromVersionId, scope };
129  const pages = scope === 'site' ? branchPages(pagesText) : [];
130  if (pages.length > 0) action.pages = pages;
131  return { runDir, opId: requireId(opId), action };
132}
133
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 55 lines
1/**
2 * The words the mod shows the person: the pane's labels, the toast and the band above the prompt. Sentence case,
3 * bare-verb buttons, no all-caps. Pure: no `$`.
4 */
5
6export const PANE_ID = 'design-workspace';
7export const PANE_TITLE = 'Design workspace';
8
9export const READY_TOAST = 'Design workspace ready. Press Open above the prompt to see it.';
10/** The band above the prompt while the pane waits for room, beside its Open button. */
11export const BAND_TEXT = 'Design workspace ready';
12export const NO_WORKSPACE = 'No Design workspace yet. Ask Claude to open one, for example: Open a Design workspace for my app idea.';
13export const LOADING = 'Loading the Design workspace.';
14export const BRANCH_PAGES_HINT = 'Pages (optional), e.g. about, sign in, settings';
15export const SENT = 'Recorded. Claude has been told.';
16export const RECORDED_RUNNER = 'Recorded. It will be drawn on this computer.';
17
18export const LABELS = Object.freeze({
19  design: 'Design',
20  selected: 'Selected',
21  chosen: 'Building',
22  back: 'Back',
23  branch: 'Branch',
24  fullPage: 'Full page',
25  morePages: 'More pages',
26  buildThis: 'Build this',
27  select: 'Select',
28  like: 'More like this',
29  edit: 'Edit',
30  send: 'Send',
31  cancel: 'Cancel',
32  more: 'Generate more',
33  continue: 'Continue',
34  cells: 'Show colour cells',
35  pictures: 'Show pictures',
36  count: 'Options',
37  note: 'Note',
38  pages: 'Pages',
39  change: 'Change',
40  explanation: 'Explanation',
41  open: 'Open',
42  format: 'Format',
43});
44
45/**
46 * An option picture's alt text. The terminal draws it in the picture's place, under the option's own label, where it
47 * cannot show pixels: there it says only what to do, never the label again. Elsewhere it is read in place of the
48 * picture, so it names the option.
49 */
50export const TERMINAL_PICTURE_ALT = 'Press v for colour cells';
51
52export function pictureAlt(label, surface) {
53  return surface === 'terminal' ? TERMINAL_PICTURE_ALT : `Option ${label}`;
54}
55
hooks/workspace/gallery.mjs 134 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
7import { shownVersionIds } from './view.mjs';
8
9export const TABS = Object.freeze(['inspiration', 'references', 'designs']);
10export const TAB_LABELS = Object.freeze({ inspiration: 'Inspiration', references: 'References', designs: 'Designs' });
11/** Designs per round: 6 by default, 12 one tap away (the screen's switch). */
12export const PER_ROUND = Object.freeze([6, 12]);
13/** How many references one search asks for (the screen's first page). */
14export const SEARCH_COUNT = 12;
15
16/** The latest round that waits on the person, or null. */
17export function waitingRound(view) {
18  const rounds = (view && view.rounds ? view.rounds : []).filter((round) => round.pause);
19  return rounds.length ? rounds[rounds.length - 1] : null;
20}
21
22function unique(ids) {
23  return [...new Set(ids.filter((id) => typeof id === 'string' && id))];
24}
25
26/**
27 * Inspiration: the first page (SEARCH_COUNT) of the newest search's results, with any pick of the waiting round that page
28 * lacks put first. The server's own search behind a new workspace answers up to 96; every listed reference is fetched
29 * and decoded on the hooks worker, so the pane lists one page, as the screen's first page does.
30 */
31export function inspirationIds(view) {
32  const searches = (view.inspiration && view.inspiration.searches) || [];
33  const newest = (searches.length ? searches[searches.length - 1].referenceIds || [] : []).slice(0, SEARCH_COUNT);
34  const round = waitingRound(view);
35  const picks = round ? round.referenceIds || [] : [];
36  const missing = picks.filter((id) => !newest.includes(id));
37  return unique([...missing, ...newest]);
38}
39
40/** References: the live site screenshots, in the order they landed. */
41export function siteItems(view) {
42  return (view.sites || []).map((site) => ({ id: site.id, title: site.title || '', sourceUrl: site.sourceUrl || '' }));
43}
44
45/** A reference's picture size as the corpus recorded it (null when unknown: the picture's own size then decides). */
46export function referenceSize(view, id) {
47  const meta = view.references ? view.references[id] : null;
48  if (meta && meta.width && meta.height) return { width: meta.width, height: meta.height };
49  if (meta && meta.aspect === 'portrait') return { width: 3, height: 4 };
50  if (meta && meta.aspect === 'square') return { width: 1, height: 1 };
51  return { width: 16, height: 10 };
52}
53
54/** The picks of the waiting round: the person's own once they changed them, else the round's preselection. */
55export function picksOf(view, chosen) {
56  if (chosen) return chosen;
57  const round = waitingRound(view);
58  return round ? [...(round.referenceIds || [])] : [];
59}
60
61/** At most this many picks: the round's own count of references. */
62export function pickLimit(view) {
63  const round = waitingRound(view);
64  return round && typeof round.count === 'number' && round.count > 0 ? round.count : 12;
65}
66
67/** Adds or removes one pick; past the limit nothing changes and `refused` says so. The order never changes. */
68export function togglePick(picks, id, limit) {
69  const at = picks.indexOf(id);
70  if (at >= 0) return { picks: picks.filter((pick) => pick !== id), refused: false };
71  if (picks.length >= limit) return { picks, refused: true };
72  return { picks: [...picks, id], refused: false };
73}
74
75export function sameList(a, b) {
76  return a.length === b.length && a.every((id, index) => b[index] === id);
77}
78
79/** The switch's place: 12 once the newest round drew twelve or more, else 6 (or what the person tapped). */
80export function perRoundOf(view, tapped) {
81  if (tapped) return tapped;
82  const rounds = view.rounds || [];
83  const newest = rounds.length ? rounds[rounds.length - 1] : null;
84  return newest && typeof newest.count === 'number' && newest.count >= 12 ? 12 : 6;
85}
86
87/**
88 * The tab the pane opens a workspace on: Inspiration while a round waits for the person's picks (its Select buttons
89 * work only then); otherwise Designs once the workspace has options to show; Inspiration when it has none yet.
90 */
91export function openingTab(view, roundWaits) {
92  if (roundWaits) return 'inspiration';
93  return view.candidates.length > 0 ? 'designs' : 'inspiration';
94}
95
96/**
97 * The thumbnails a tab shows, each with the slot it is kept under (the version id, or `ref:` and the reference id) and
98 * which picture tool reads it. Only the shown tab's decide a drawing (redraw.mjs `drawnSignature`).
99 */
100export function tabThumbs(view, tab) {
101  if (tab === 'designs') {
102    return shownVersionIds(view).map((versionId) => ({ slot: versionId, kind: 'version', args: { versionId } }));
103  }
104  const ids = tab === 'references' ? siteItems(view).map((site) => site.id) : inspirationIds(view);
105  return ids.map((id) => ({ slot: `ref:${id}`, kind: 'reference', args: { referenceId: id } }));
106}
107
108/**
109 * Every thumbnail the pane reads for a view: the shown tab's first, then the other tabs', each slot once (a pick that is
110 * also a website is one picture). The other tabs' pictures land without a redraw, since only the shown tab is drawn, so
111 * a tab's first visit draws them in the press's own drawing. Read for the shown tab only, each first visit drew its
112 * tab once or twice more 1 to 2 s after the press, where the next press landed (gate press rig, a press every 2 s: 1 of
113 * 14 presses refused that way in out-drawn400p2000 and in out-drawn400p2000c; every 3 s, 1 of 9 in out-drawn400p3000a).
114 */
115export function viewThumbs(view, shownTab) {
116  const seen = new Set();
117  const wants = [];
118  for (const tab of [shownTab, ...TABS.filter((name) => name !== shownTab)]) {
119    for (const want of tabThumbs(view, tab)) {
120      if (seen.has(want.slot)) continue;
121      seen.add(want.slot);
122      wants.push(want);
123    }
124  }
125  return wants;
126}
127
128/** Whether tapping 12 draws a round of twelve now: the newest round drew fewer and no round runs. */
129export function twelveDraws(view, roundRuns) {
130  const rounds = view.rounds || [];
131  const newest = rounds.length ? rounds[rounds.length - 1] : null;
132  return Boolean(newest) && !roundRuns && (typeof newest.count !== 'number' || newest.count < 12);
133}
134
hooks/workspace/handles.mjs 47 lines
1/**
2 * Workspace handles (`runDir`, "w_" and 22 base62 characters): reading them from the results of the plugin's own create
3 * and show tools. 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-design_12ui-workspace__design_slate_create';
8export const SHOW_TOOL = 'mcp__plugin_12ui-design_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
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 251 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 { BAND_TEXT, 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, name, picture, alt) {
32  if (!picture) return text(els, ' ', { dimColor: true });
33  if (picture.kind === 'image') return els.Image({ key: `picture:${name}`, source: picture.source, columns: picture.columns, rows: picture.rows, alt });
34  if (picture.kind === 'raster') return els.Raster({ key: `cells:${name}`, 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/**
91 * One gallery tile: its picture inside a ring when selected, then, only while a round waits for picks (the tray with
92 * its Continue is there exactly then), the one-tap toggle. With no round waiting a pick would feed nothing, so no
93 * button is offered.
94 */
95function galleryTile(els, item, model) {
96  const children = [pictureNode(els, item.id, item.picture, item.title || 'Reference')];
97  if (item.title) children.push(text(els, item.title, { dimColor: true, wrap: 'truncate-end' }));
98  if (model.tray) {
99    const mark = item.selected ? `✓ ${LABELS.selected}` : LABELS.select;
100    children.push(els.Button({ key: `pick:${item.id}`, label: mark, ...quiet(!item.selected), dimColor: !item.selected, onPress: () => model.handlers.toggle(item.id) }));
101  }
102  return els.Box({
103    key: `item:${item.id}`,
104    flexDirection: 'column',
105    width: model.tileColumns + 2,
106    borderStyle: 'round',
107    borderColor: item.selected ? RING : undefined,
108    borderDimColor: !item.selected,
109    marginBottom: 1,
110    children,
111  });
112}
113
114function trayNode(els, model) {
115  if (!model.tray) return null;
116  const { words, canContinue } = model.tray;
117  return row(els, 'tray', [
118    text(els, words, { wrap: 'wrap' }),
119    canContinue ? els.Button({ key: 'continue', label: LABELS.continue, variant: 'primary', onPress: () => model.handlers.continueRound() }) : null,
120  ], 2);
121}
122
123function galleryNode(els, model) {
124  const parts = [trayNode(els, model)];
125  if (model.items.length === 0) parts.push(text(els, model.emptyLine, { dimColor: true, wrap: 'wrap' }));
126  else parts.push(els.Box({ key: 'gallery', flexDirection: 'row', flexWrap: 'wrap', columnGap: TILE_GAP, children: model.items.map((item) => galleryTile(els, item, model)) }));
127  return parts.filter(Boolean);
128}
129
130function designTile(els, tile, model) {
131  const children = [
132    pictureNode(els, tile.candidateId, tile.picture, pictureAlt(tile.label, model.surface)),
133    row(els, `under:${tile.candidateId}`, [
134      els.Button({ key: `open:${tile.candidateId}`, label: tile.label, plain: true, onPress: () => model.handlers.open(tile) }),
135      tile.isReady && !tile.isSelected ? null : text(els, tile.isSelected ? LABELS.chosen : stateLine(tile), tile.state === 'failed' ? { color: 'red', wrap: 'wrap' } : { dimColor: true, wrap: 'wrap' }),
136    ]),
137  ];
138  return els.Box({
139    key: `design:${tile.candidateId}`,
140    flexDirection: 'column',
141    width: model.tileColumns + 2,
142    borderStyle: 'round',
143    borderColor: tile.isSelected ? RING : undefined,
144    borderDimColor: !tile.isSelected,
145    marginBottom: 1,
146    children,
147  });
148}
149
150function designsNode(els, model) {
151  const switchRow = row(els, 'per-round', [
152    text(els, 'Per round', { dimColor: true }),
153    ...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) })),
154  ]);
155  const parts = [trayNode(els, model), switchRow];
156  if (model.tiles.length === 0) parts.push(text(els, 'No designs yet.', { dimColor: true }));
157  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)) }));
158  return parts.filter(Boolean);
159}
160
161/** The large view of one design: the picture at the pane's width, then Edit · Branch · Build this. */
162function largeViewNode(els, model) {
163  const { lv, handlers } = model;
164  const { tile } = lv;
165  const actions = [els.Button({ key: 'lv-back', label: LABELS.back, plain: true, dimColor: true, onPress: () => handlers.close() })];
166  if (tile.isReady) {
167    if (els.Input) actions.push(els.Button({ key: 'lv-edit', label: LABELS.edit, ...quiet(lv.mode !== 'edit'), onPress: () => handlers.edit() }));
168    // Once the design is building, Branch is no longer offered next to "Building".
169    if (!tile.isSelected) actions.push(els.Button({ key: 'lv-branch', label: LABELS.branch, ...quiet(lv.mode !== 'branch'), onPress: () => handlers.branch() }));
170    actions.push(tile.isSelected ? text(els, LABELS.chosen, { color: RING }) : els.Button({ key: 'lv-build-this', label: LABELS.buildThis, variant: 'primary', onPress: () => handlers.buildThis() }));
171  }
172  const children = [
173    row(els, 'lv-head', [text(els, `Design ${tile.label}`, { bold: true }), tile.isReady ? null : text(els, stateLine(tile), { dimColor: true })], 2),
174    pictureNode(els, `lv:${tile.candidateId}`, lv.picture, pictureAlt(tile.label, model.surface)),
175    row(els, 'lv-actions', actions, 2),
176  ];
177  if (lv.mode === 'edit' && els.Input) {
178    children.push(els.Input({
179      key: `lv-edit-note:${tile.candidateId}`,
180      label: LABELS.change,
181      placeholder: `What to change in ${tile.label}`,
182      submitLabel: 'send',
183      autoFocus: true,
184      onSubmit: (value) => handlers.editSubmit(value),
185    }));
186  }
187  if (lv.mode === 'branch') children.push(...branchNodes(els, model));
188  return children;
189}
190
191/**
192 * The branch bar, as the screen's: Full page (the design continued below the fold) or More pages (other pages of the
193 * product, names optional). Full page branches at once; More pages opens the names line, then Send.
194 */
195function branchNodes(els, model) {
196  const { lv, handlers } = model;
197  const choices = [
198    els.Button({ key: 'lv-scope-page', label: LABELS.fullPage, onPress: () => handlers.branchScope('page') }),
199    els.Button({ key: 'lv-scope-site', label: LABELS.morePages, ...quiet(lv.scope !== 'site'), onPress: () => handlers.branchScope('site') }),
200  ];
201  const nodes = [row(els, 'lv-branch-scope', choices, 2)];
202  if (lv.scope === 'site') {
203    const line = [];
204    if (els.Input) {
205      line.push(els.Input({
206        key: `lv-branch-pages:${lv.tile.candidateId}`,
207        label: LABELS.pages,
208        placeholder: BRANCH_PAGES_HINT,
209        value: lv.pages,
210        submitLabel: 'send',
211        autoFocus: true,
212        onInput: (value) => handlers.branchPagesInput(value),
213        onSubmit: (value) => handlers.branchSubmit(value),
214      }));
215    }
216    line.push(els.Button({ key: 'lv-branch-send', label: LABELS.send, variant: 'primary', onPress: () => handlers.branchSubmit(lv.pages) }));
217    nodes.push(row(els, 'lv-branch-pages', line, 2));
218  }
219  return nodes;
220}
221
222/** The whole pane. `model` is built by register.mjs; see the fields read above. */
223export function paneTree(els, model) {
224  if (!model.workspace) {
225    const children = [text(els, model.emptyText, { wrap: 'wrap' })];
226    if (model.errorText) children.push(text(els, model.errorText, { color: 'red', wrap: 'wrap' }));
227    return els.Box({ flexDirection: 'column', children });
228  }
229  const children = [promptNode(els, model)];
230  if (model.isLoading) {
231    if (model.errorText) children.push(text(els, model.errorText, { color: 'red', wrap: 'wrap' }));
232    else children.push(text(els, LOADING, { dimColor: true }));
233    return els.Box({ flexDirection: 'column', children });
234  }
235  children.push(tabsNode(els, model));
236  if (model.noticeText) children.push(text(els, model.noticeText, { dimColor: true, wrap: 'wrap' }));
237  if (model.errorText) children.push(text(els, model.errorText, { color: 'red', wrap: 'wrap' }));
238  if (model.lv) children.push(...largeViewNode(els, model));
239  else if (model.tab === 'designs') children.push(...designsNode(els, model));
240  else children.push(...galleryNode(els, model));
241  return els.Box({ flexDirection: 'column', rowGap: 0, children: children.filter(Boolean) });
242}
243
244/** The band above the prompt while the pane waits undrawn for room: one dim line and Open, which seats the pane. */
245export function bandTree(els, onOpen) {
246  return row(els, 'design-workspace-band', [
247    text(els, BAND_TEXT, { key: 'ready', dimColor: true }),
248    els.Button({ key: 'open', label: LABELS.open, variant: 'primary', onPress: onOpen }),
249  ]);
250}
251
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 231 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/** Image source one pane tree may carry: under the engine's 2 MiB cap on a tree's Image sources. */
70export const IMAGE_TREE_BYTES = 1_800_000;
71
72/**
73 * One tree's Image budget. `admit(picture)` passes every picture through except an Image whose source would take the
74 * tree past `limit`, which waits as loading for a later drawing: the engine refuses a whole tree past its cap and draws
75 * an empty pane (seen live 2026-10-02 on a page of tall references before the blit probe answered). An Image's source
76 * is `{ rgba, width, height }` (`imageSource`), so the bytes that count are its base64 RGBA. Every other picture
77 * (Raster cells, an Svg, loading, failed) passes untouched.
78 */
79export function imageTreeBudget(limit = IMAGE_TREE_BYTES) {
80  let used = 0;
81  return {
82    admit(picture) {
83      if (!picture || picture.kind !== 'image') return picture;
84      const bytes = picture.source.rgba.length;
85      if (used + bytes > limit) return { kind: 'loading' };
86      used += bytes;
87      return picture;
88    },
89  };
90}
91
92/** The picture scaled to `width` x `height` by averaging the source area under each target pixel. */
93export function resizeRgba({ rgba, width, height }, toWidth, toHeight) {
94  const w = Math.max(1, Math.round(toWidth));
95  const h = Math.max(1, Math.round(toHeight));
96  if (w === width && h === height) return { rgba, width, height };
97  const out = new Uint8Array(w * h * 4);
98  const sx = width / w;
99  const sy = height / h;
100  for (let y = 0; y < h; y += 1) {
101    const y0 = y * sy;
102    const y1 = Math.min(height, y0 + sy);
103    for (let x = 0; x < w; x += 1) {
104      const x0 = x * sx;
105      const x1 = Math.min(width, x0 + sx);
106      let r = 0;
107      let g = 0;
108      let b = 0;
109      let a = 0;
110      let area = 0;
111      for (let py = Math.floor(y0); py < Math.ceil(y1); py += 1) {
112        const wy = Math.min(py + 1, y1) - Math.max(py, y0);
113        if (wy <= 0) continue;
114        for (let px = Math.floor(x0); px < Math.ceil(x1); px += 1) {
115          const wx = Math.min(px + 1, x1) - Math.max(px, x0);
116          if (wx <= 0) continue;
117          const weight = wx * wy;
118          const at = (py * width + px) * 4;
119          r += rgba[at] * weight;
120          g += rgba[at + 1] * weight;
121          b += rgba[at + 2] * weight;
122          a += rgba[at + 3] * weight;
123          area += weight;
124        }
125      }
126      const to = (y * w + x) * 4;
127      out[to] = Math.round(r / area);
128      out[to + 1] = Math.round(g / area);
129      out[to + 2] = Math.round(b / area);
130      out[to + 3] = Math.round(a / area);
131    }
132  }
133  return { rgba: out, width: w, height: h };
134}
135
136/**
137 * The box of one tile's picture, in cells: the tile's whole width (layout.mjs `tileGrid`), and as many rows as the
138 * picture's aspect asks at two pixels a cell height (a cell is about twice as tall as wide). A picture taller than
139 * `maxRows` (layout.mjs `pictureMaxRows`) is held to it and narrowed to keep its aspect. Never past Image's 255 cells.
140 */
141export function tileBox(tileColumns, width, height, maxRows = Infinity) {
142  const aspect = width > 0 && height > 0 ? height / width : 9 / 16;
143  let columns = Math.max(1, Math.min(IMAGE_MAX_CELLS, Math.floor(tileColumns)));
144  let rows = Math.max(1, Math.round(columns * aspect / 2));
145  const limit = Math.max(1, Math.min(IMAGE_MAX_CELLS, Math.floor(maxRows)));
146  if (rows > limit) {
147    rows = limit;
148    columns = Math.max(1, Math.min(columns, Math.round(rows * 2 / aspect)));
149  }
150  return { columns, rows };
151}
152
153/** A desktop picture's CSS size for its box: CELL_PIXELS.width a column, the height from the picture's own aspect. */
154export function desktopPictureSize(box, width, height) {
155  const cssWidth = box.columns * CELL_PIXELS.width;
156  return { width: cssWidth, height: Math.round(cssWidth * height / width) };
157}
158
159/**
160 * An Image's source for the decoded picture: raw RGBA no larger than its box needs at CELL_PIXELS per cell (the tree
161 * crosses to the engine on every redraw, so a 480 px thumbnail drawn 30 columns wide is sent at 240 px), and never past
162 * the element's edge limit. A picture already small enough is sent as decoded.
163 */
164export function imageSource(picture, box = null) {
165  const limits = [1, IMAGE_MAX_EDGE / Math.max(picture.width, picture.height)];
166  if (box) limits.push((box.columns * CELL_PIXELS.width) / picture.width, (box.rows * CELL_PIXELS.height) / picture.height);
167  const scale = Math.min(...limits);
168  const sized = scale < 1 ? resizeRgba(picture, Math.max(1, picture.width * scale), Math.max(1, picture.height * scale)) : picture;
169  return { rgba: base64Encode(sized.rgba), width: sized.width, height: sized.height };
170}
171
172/** A Raster's `cells` for the picture over `columns` x `rows`: half blocks, two pixels a cell. */
173export function rasterCells(picture, columns, rows) {
174  const c = Math.max(1, Math.min(RASTER_MAX_COLUMNS, columns));
175  const r = Math.max(1, Math.min(RASTER_MAX_ROWS, rows));
176  const sized = resizeRgba(picture, c, r * 2);
177  const words = new Uint32Array(c * r * 3);
178  for (let row = 0; row < r; row += 1) {
179    for (let col = 0; col < c; col += 1) {
180      const top = ((row * 2) * c + col) * 4;
181      const bottom = ((row * 2 + 1) * c + col) * 4;
182      const at = (row * c + col) * 3;
183      words[at] = HALF_BLOCK;
184      words[at + 1] = (sized.rgba[top] << 16) | (sized.rgba[top + 1] << 8) | sized.rgba[top + 2];
185      words[at + 2] = (sized.rgba[bottom] << 16) | (sized.rgba[bottom + 1] << 8) | sized.rgba[bottom + 2];
186    }
187  }
188  const bytes = new Uint8Array(words.length * 4);
189  for (let index = 0; index < words.length; index += 1) {
190    const word = words[index];
191    const at = index * 4;
192    bytes[at] = word & 0xff;
193    bytes[at + 1] = (word >>> 8) & 0xff;
194    bytes[at + 2] = (word >>> 16) & 0xff;
195    bytes[at + 3] = (word >>> 24) & 0xff;
196  }
197  return { cells: base64Encode(bytes), columns: c, rows: r };
198}
199
200function svgDocument(width, height, mimeType, base64) {
201  return `<svg xmlns="${SVG_NAMESPACE}" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">`
202    + `<image href="data:${mimeType};base64,${base64}" x="0" y="0" width="${width}" height="${height}" preserveAspectRatio="xMidYMid meet"/>`
203    + '</svg>';
204}
205
206/** The characters an Svg embedding `bytes` bytes of base64 takes, for a picture of that size. */
207function svgChars(width, height, mimeType, bytes) {
208  return svgDocument(width, height, mimeType, '').length + 4 * Math.ceil(bytes / 3);
209}
210
211/**
212 * The Svg source for a thumbnail: `{ source, embedded }`, `embedded` naming what it carries (`jpeg` as the server sent
213 * it, or `png` re-encoded smaller). `decode` is only called when the JPEG does not fit.
214 */
215export function svgPicture({ jpegBase64, width, height, mimeType = 'image/jpeg' }, decode = () => decodeThumb(jpegBase64, mimeType)) {
216  const asIs = svgDocument(width, height, mimeType, jpegBase64);
217  if (asIs.length <= SVG_MAX_CHARS) return { source: asIs, embedded: mimeType === 'image/webp' ? 'webp' : 'jpeg' };
218  const picture = decode();
219  let scale = 1;
220  for (;;) {
221    const w = Math.max(1, Math.floor(picture.width * scale));
222    const h = Math.max(1, Math.floor(picture.height * scale));
223    if (svgChars(width, height, 'image/png', storedPngBytes(w, h, 3)) <= SVG_MAX_CHARS) {
224      const png = encodePng(resizeRgba(picture, w, h));
225      return { source: svgDocument(width, height, 'image/png', base64Encode(png)), embedded: 'png' };
226    }
227    if (w === 1 && h === 1) throw new Error('the picture cannot fit an Svg');
228    scale *= 0.85;
229  }
230}
231
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; Claude showing the workspace again still asks 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/redraw.mjs 88 lines
1/**
2 * The redraw policy, one module (CLAUDE.md item 9): when the pane asks the engine for a fresh drawing on its own, not
3 * after the person pressed something. Pure: no `$`; register.mjs feeds it the model and the clock.
4 *
5 * Why it matters. Claude Code keeps one live set of button handles per pane, and every fresh drawing retires the last
6 * set; the Claude app cancels an ask in flight when a newer one comes and applies drawings only after the pointer is
7 * released, so a press lands on a drawing the engine has already retired and is refused (`ui_press` answered
8 * `{ handled: false }`, nothing run). Measured on the 0.2.117 pane: 21 to 25 self-redraws in a drawn workspace's first
9 * 10 s (one per picture) and about 1.5 a second while a round waits (a 1 s countdown ticker plus polls), with 23% to
10 * 50% of presses refused under the app's lag (~/.orchestrator/evidence/12ui/claude-workspace/results.md). So the pane
11 * redraws on its own only when what it shows changes, never on the clock alone (the start countdown's words do not
12 * change with time, view.mjs); a view that brings pictures is drawn once, together with them; and its own redraws wait
13 * for a pause in the person's presses: a press redraws at once and its drawing carries whatever landed before it.
14 */
15
16/** The pane's picture redraws come at most once a second: a batch never draws sooner than this after the last self-redraw. */
17export const PICTURE_BATCH_MS = 1000;
18
19/**
20 * How long a batch waits for the shown tab's pictures still being read, from the batch's start (a view that brings
21 * pictures, or the first picture to land), before it draws what is in. design.12ui.com answered a whole workspace's
22 * pictures in 0.6 to 2.6 s when they were all read at once (fix/round2/picture-latency-all.out: 3 runs of 34), and a
23 * website's screenshot in a waiting round took longer than the 1 s the batch used to wait, so each website landing was
24 * drawn twice, a second apart (gate out-waitidle400 at 19.0/20.0 s and 21.7/22.7 s).
25 */
26export const PICTURE_WAIT_MS = 3000;
27
28/** A picture as a drawing shows it: nothing, loading, failed with its reason, or ready (whatever element draws it). */
29function pictureFact(picture) {
30  if (!picture) return null;
31  if (picture.kind === 'loading') return 'loading';
32  if (picture.kind === 'failed') return `failed:${picture.reason}`;
33  return 'ready';
34}
35
36/**
37 * What a drawing of `model` shows, as one string: the tab, the lines, the tray, every tile and item with its picture's
38 * state. Two models with the same signature draw the same pane, so a self-redraw between them is skipped. Pictures
39 * count by state only, so the bytes of an Svg or an Image never decide a redraw. Only the shown tab is in the model, so
40 * the pictures of the other tabs land without a redraw.
41 */
42export function drawnSignature(model) {
43  return JSON.stringify({
44    surface: model.surface,
45    workspace: model.workspace,
46    emptyText: model.emptyText,
47    isLoading: model.isLoading,
48    prompt: model.prompt,
49    tab: model.tab,
50    noticeText: model.noticeText,
51    errorText: model.errorText,
52    tileColumns: model.tileColumns,
53    tray: model.tray,
54    selectedCount: model.selectedCount,
55    emptyLine: model.emptyLine,
56    perRound: model.perRound,
57    lv: model.lv ? { tile: { ...model.lv.tile }, picture: pictureFact(model.lv.picture), mode: model.lv.mode, scope: model.lv.scope, pages: model.lv.pages } : null,
58    tiles: model.tiles.map((tile) => ({ ...tile, picture: pictureFact(tile.picture) })),
59    items: model.items.map((item) => ({ ...item, picture: pictureFact(item.picture) })),
60  });
61}
62
63/**
64 * How long, from now, a batch of the shown tab's pictures waits for its redraw: never sooner than PICTURE_BATCH_MS after
65 * the last self-redraw (`sinceLastSelfMs`, null for none yet); once nothing the shown tab draws is still out, no longer
66 * than that; while pictures are still out (`outstanding`), until PICTURE_WAIT_MS after the batch began
67 * (`sinceBatchStartMs`), unless the last of them lands first. A picture that lands after that starts a batch of its own.
68 */
69export function pictureRedrawDelay({ sinceLastSelfMs, sinceBatchStartMs, outstanding }) {
70  const floor = sinceLastSelfMs === null ? 0 : Math.max(0, PICTURE_BATCH_MS - sinceLastSelfMs);
71  if (outstanding === 0) return floor;
72  return Math.max(floor, PICTURE_WAIT_MS - sinceBatchStartMs);
73}
74
75/**
76 * The pane's own redraws wait this long after the person's last press. Each self-redraw retires the drawing the person
77 * is pressing on; while they press, every press redraws anyway and its drawing shows what landed meanwhile, so nothing
78 * waits longer than the next press or this pause. Measured in the press rig at a 400 ms app delay: a picture batch drawn
79 * 114 ms before a tab press refused it (1 of 28; fix/results.md).
80 */
81export const PRESS_QUIET_MS = 1500;
82
83/** How long a self-redraw waits for the pause after the last press (`sinceLastPressMs`, null for none yet): 0 to draw now. */
84export function pressQuietDelay(sinceLastPressMs) {
85  if (sinceLastPressMs === null) return 0;
86  return Math.max(0, PRESS_QUIET_MS - sinceLastPressMs);
87}
88