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

Open a Design workspace in Claude Code: a pane that shows design options for an app or a website as images. Select the one you like, ask for more options, 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.
npx -y @12ui/design cli install.12ui auth login).12ui-design). It runs npx -y @12ui/design cli install once, from npm and unpinned, then 12ui commands. Those commands reach https://12ui.com (hosted draft, branch, conversion, the design corpus and improve kits) and https://design.12ui.com (workspace plans, image upload and download), and run codex exec locally when Codex is ready. The skill also saves one workspace image from design.12ui.com.https://design.12ui.com/mcp: anonymous. It stores the brief and images; images are kept up to 30 days and removed after 14 days without use.The mod is the Design workspace pane. It runs inside Claude Code as plain readable source in hooks/.
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.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.{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.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 elseui.render{component=Pane}: draws the Design workspace pane and passes every other plugin's pane on untouchedui.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 untouchedCalls 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 paneclaude -p or Chat.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC is set.| Name | Version | License | Source |
|---|---|---|---|
| jpeg-js decoder | 0.4.4 | Apache-2.0 (file header); BSD-3-Clause (package) | https://github.com/jpeg-js/jpeg-js |
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.
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.
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.
12ui-design is installed byte-identically for every supported client. Run 12ui capabilities to see what the installed command line supports before a run.
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.
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.
MIT. See LICENSE.
hooks/register.mjs 745 lines1/**
2 * The 12ui Design workspace pane for Claude Code: the glue that wires the hooks to the modules in ./workspace.
3 *
4 * Hooks:
5 * 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}
745hooks/workspace/actions.mjs 133 lines1/**
2 * The arguments of the clicks the pane records: Build this (`design.slate.pick`, then `design.slate.handoff`), Edit, Branch, Generate more and
3 * Continue (`design.slate.run`). Each request carries a UUID v4 minted
4 * when its dialog opened, so a repeated call never repeats work (the schema's rule). Pure: no `$`.
5 *
6 * The limits are the server's schema (workers/api/src/slate/mcp/tools.json); a value over one is refused here with
7 * words the pane shows, rather than sent to be refused.
8 */
9
10import { UUID_V4 } from './bytes.mjs';
11
12/** The counts Generate more offers, and the one it starts on (SLATE_MORE_COUNTS). */
13export const MORE_COUNTS = Object.freeze([1, 2, 4, 6, 8, 12]);
14export const MORE_COUNT_INITIAL = 6;
15export const NOTE_MAX_CHARS = 600;
16export const EDIT_PROMPT_MAX_BYTES = 6000;
17/** The longest 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}
133hooks/workspace/bytes.mjs 82 lines1/**
2 * Bytes helpers for the Design workspace mod: base64 both ways and UUID v4 ids.
3 *
4 * Written by hand on purpose: the mod's environment has no Node `Buffer`, and CI runs these modules under Node 22.13,
5 * which has no `Uint8Array.prototype.toBase64` or `Uint8Array.fromBase64`. Pure: no `$`.
6 */
7
8const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
9const LOOKUP = (() => {
10 const table = new Int16Array(128).fill(-1);
11 for (let index = 0; index < ALPHABET.length; index += 1) table[ALPHABET.charCodeAt(index)] = index;
12 return table;
13})();
14
15/** Standard padded base64 of the bytes. */
16export function base64Encode(bytes) {
17 const parts = [];
18 const CHUNK = 3 * 4096;
19 for (let start = 0; start < bytes.length; start += CHUNK) {
20 const end = Math.min(bytes.length, start + CHUNK);
21 let out = '';
22 let index = start;
23 for (; index + 2 < end; index += 3) {
24 const n = (bytes[index] << 16) | (bytes[index + 1] << 8) | bytes[index + 2];
25 out += ALPHABET[(n >> 18) & 63] + ALPHABET[(n >> 12) & 63] + ALPHABET[(n >> 6) & 63] + ALPHABET[n & 63];
26 }
27 const rest = end - index;
28 if (rest === 1) {
29 const n = bytes[index] << 16;
30 out += ALPHABET[(n >> 18) & 63] + ALPHABET[(n >> 12) & 63] + '==';
31 } else if (rest === 2) {
32 const n = (bytes[index] << 16) | (bytes[index + 1] << 8);
33 out += ALPHABET[(n >> 18) & 63] + ALPHABET[(n >> 12) & 63] + ALPHABET[(n >> 6) & 63] + '=';
34 }
35 parts.push(out);
36 }
37 return parts.join('');
38}
39
40/** The bytes of standard base64 text; whitespace is ignored, anything else outside the alphabet throws. */
41export function base64Decode(text) {
42 const clean = String(text).replace(/[\t\n\r ]/g, '');
43 if (clean.length % 4 === 1) throw new Error('base64 text has a bad length');
44 const unpadded = clean.replace(/={1,2}$/, '');
45 const out = new Uint8Array(Math.floor((unpadded.length * 3) / 4));
46 let bits = 0;
47 let value = 0;
48 let at = 0;
49 for (let index = 0; index < unpadded.length; index += 1) {
50 const code = unpadded.charCodeAt(index);
51 const digit = code < 128 ? LOOKUP[code] : -1;
52 if (digit < 0) throw new Error('base64 text holds a character outside its alphabet');
53 value = (value << 6) | digit;
54 bits += 6;
55 if (bits >= 8) {
56 bits -= 8;
57 out[at] = (value >> bits) & 0xff;
58 at += 1;
59 }
60 }
61 return out;
62}
63
64/** Sixteen random bytes from the environment's `crypto.getRandomValues`. */
65export function randomBytes16() {
66 const bytes = new Uint8Array(16);
67 globalThis.crypto.getRandomValues(bytes);
68 return bytes;
69}
70
71/** A UUID v4 (the schema's pattern: lower-case hex, version 4, variant 8-b) from 16 bytes, random by default. */
72export function uuidV4(bytes = randomBytes16()) {
73 const b = Uint8Array.from(bytes.subarray(0, 16));
74 b[6] = (b[6] & 0x0f) | 0x40;
75 b[8] = (b[8] & 0x3f) | 0x80;
76 const hex = Array.from(b, (byte) => byte.toString(16).padStart(2, '0')).join('');
77 return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
78}
79
80/** The server's UUID v4 pattern for opId, handoffId and clientRequestId. */
81export const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
82hooks/workspace/copy.mjs 55 lines1/**
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}
55hooks/workspace/gallery.mjs 134 lines1/**
2 * Slate v2's three tabs as plain data (no `$`): what Inspiration and References list, which picks a waiting round
3 * holds, and how many designs a round draws. Shapes follow `12ui.slate.view/3` as the slate v2 Worker projects it
4 * (workers/api/src/slate/view.ts): `inspiration.searches`, `references`, `sites`, `rounds[].referenceIds` and `pause`.
5 */
6
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}
134hooks/workspace/handles.mjs 47 lines1/**
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}
47hooks/workspace/messages.mjs 53 lines1/**
2 * What the pane tells Claude after a click is recorded, and when it says nothing: the port of the hosted screen's
3 * messages (packages/12ui/src/mcp-slate-ui-script-remote.ts). Pure: no `$`, no clock.
4 *
5 * Five messages, one per click: new options, more like an option, an edit, a branch, and Build this (the selection). `$.prompt.submit`
6 * sends them framed as from this mod, so "I" of the screen becomes "The user". Each names what happened and how to
7 * confirm it (`design.slate.data`, whose answer is the server's own trusted text) and carries no command, flag, path,
8 * price or credit word; the user's own words (a note, an edit instruction, branch page names) are never put in a
9 * message: the agent reads them from the data tool, quoted.
10 *
11 * The runner rule: when the run result says the workspace runner on the user's computer holds the workspace
12 * (`runner.alive`), the runner claims the request within seconds and nobody is told. The agent is told after all, with
13 * the same message, when the request is still the agent's once RUNNER_GRACE_MS has passed; while the runner draws it,
14 * or while its round waits on the user, the pane keeps checking every RUNNER_RECHECK_MS.
15 */
16
17export const DATA_TOOL = 'design.slate.data';
18export const RUNNER_GRACE_MS = 20000;
19export const RUNNER_RECHECK_MS = 10000;
20
21/** The message for a recorded Generate more, More like this, Edit or Branch request. */
22export function runMessage(runDir, opId, action, labelOf) {
23 let head;
24 if (action.kind === 'edit') head = `The user asked to edit ${labelOf(action.versionId) ?? 'an option'}`;
25 else if (action.mode === 'branch') head = `The user asked for ${action.scope === 'page' ? 'the full page' : 'more pages'} of ${labelOf(action.fromVersionId) ?? 'a design'}`;
26 else if (action.mode === 'like') head = `The user asked for more like ${labelOf(action.fromVersionId) ?? 'an option'}`;
27 else head = 'The user asked for new options';
28 return `${head} in the Design workspace (request ${opId}, runDir ${runDir}). Please confirm it with ${DATA_TOOL}, then carry it out.`;
29}
30
31/** The message for a recorded Build this: the pick is recorded, and Claude is told to build that design. */
32export function buildMessage(runDir, label) {
33 return `The user chose design ${label} to build in the Design workspace (runDir ${runDir}). Please read its selection with ${DATA_TOOL}, then build that design as the user's request asks.`;
34}
35
36/** After a run is recorded: tell the agent now, or watch while the runner has it (returns the first wait). */
37export function afterRun(runner) {
38 return runner && runner.alive === true ? { tell: false, waitMs: RUNNER_GRACE_MS } : { tell: true };
39}
40
41/**
42 * One runner check, on a fresh view: `{ tell: true }` sends the message now; `{ waitMs }` checks again after that
43 * long; `{ drop: true }` forgets the request (it finished, failed or went away). `watch.waited` records that the round
44 * waited on the user, after which the runner gets its grace again; the caller keeps the returned `waited`.
45 */
46export function judgeRunner(op, waitsOnPerson, watch) {
47 if (!op || !(op.state === 'running' || op.state === 'starting')) return { drop: true };
48 if (op.phase === 'drawing') return { waitMs: RUNNER_RECHECK_MS, waited: watch.waited };
49 if (waitsOnPerson) return { waitMs: RUNNER_RECHECK_MS, waited: true };
50 if (watch.waited) return { waitMs: RUNNER_GRACE_MS, waited: false };
51 return { tell: true };
52}
53hooks/workspace/pane.mjs 251 lines1/**
2 * The Design workspace pane's element tree (slate v2), built from the surface's own element table (what
3 * `$.ui.resolve(e)` returned) and a plain model of what to show. No `$` here: every handler is a closure register.mjs
4 * passes in.
5 *
6 * Top to bottom, as the hosted screen lays it out: one prompt line (its Design button inside the same row), the tabs
7 * Inspiration · References · Designs with a quiet "N selected" at the right, the notice and error lines, then the
8 * active tab. Inspiration and References are a wrapped gallery of picture tiles; a selected tile carries a ring and a
9 * check. The tray under the gallery holds the round's words and Continue. Designs holds the 6 · 12 switch and the
10 * design tiles; pressing one opens the large view with Edit · Branch · Build this.
11 */
12
13import { 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}
251hooks/workspace/layout.mjs 58 lines1/**
2 * The pane's grid policy: how many option tiles sit side by side, how wide each one is, how tall a picture may grow, and
3 * the columns the brief line leaves for the terminal's close mark. Every sizing rule of the pane's layout lives here and
4 * nowhere else (CLAUDE.md item 9); picture.mjs sizes the picture inside the tile this module returns. Pure: no `$`.
5 *
6 * The facts it works from are the surface's own: `bodyColumns` and `scroll.bodyRows` of the Pane props, and the width
7 * the terminal draws a Button in (`[ label ]`, 2.1.287 d.ts `ButtonProps`: the label plus four columns).
8 */
9
10import { LABELS } from './copy.mjs';
11
12/** Columns between two tiles in a row. */
13export const TILE_GAP = 2;
14/** Columns between two buttons in a tile's button row (pane.mjs draws the row with this gap). */
15export const BUTTON_GAP = 1;
16/** The rows a tile draws besides its picture: the label, the state line, the button row, and the gap below it. */
17export const TILE_CHROME_ROWS = 4;
18/**
19 * The columns the terminal's close mark takes from the pane's first row: the mark in the last column, and one column of
20 * space before it, so a truncated brief ends in its ellipsis and never runs into the mark.
21 */
22export const CLOSE_MARK_COLUMNS = 2;
23
24/** The columns a terminal Button takes: `[ label ]`. */
25export function buttonColumns(label) {
26 return label.length + 4;
27}
28
29/** The narrowest tile whose button row (Select, More like this, Edit) fits on one line: 38 columns with today's labels. */
30export const TILE_MIN_COLUMNS = [LABELS.select, LABELS.like, LABELS.edit].map(buttonColumns).reduce((sum, n) => sum + n, 0)
31 + BUTTON_GAP * 2;
32
33/**
34 * The tile grid for a pane `bodyColumns` wide: `{ perRow, columns }`. As many tiles a row as fit at TILE_MIN_COLUMNS
35 * (one at least), each as wide as the row then allows. A pane narrower than one minimum tile gets one tile of the
36 * whole width (its button row wraps there; nothing narrower can hold it).
37 */
38export function tileGrid(bodyColumns) {
39 const width = Math.max(1, Math.floor(bodyColumns));
40 const perRow = Math.max(1, Math.floor((width + TILE_GAP) / (TILE_MIN_COLUMNS + TILE_GAP)));
41 const columns = Math.floor((width - TILE_GAP * (perRow - 1)) / perRow);
42 return { perRow, columns };
43}
44
45/**
46 * The tallest a picture may be in a pane whose window shows `bodyRows` rows: one whole tile (picture and chrome) fits
47 * the window, so no picture is taller than what the person can see at once. Without a window height, no limit here.
48 */
49export function pictureMaxRows(bodyRows) {
50 if (typeof bodyRows !== 'number' || !Number.isFinite(bodyRows)) return Infinity;
51 return Math.max(3, Math.floor(bodyRows) - TILE_CHROME_ROWS);
52}
53
54/** The columns the pane's first row leaves at its right edge: the close mark's on the terminal, none elsewhere. */
55export function briefReserve(surface) {
56 return surface === 'terminal' ? CLOSE_MARK_COLUMNS : 0;
57}
58hooks/workspace/picture.mjs 231 lines1/**
2 * The picture policy: how an option's thumbnail (the server's 480 px JPEG) is drawn on each surface. Every rendering
3 * rule for the pictures lives here and nowhere else (CLAUDE.md item 9). Pure: no `$`; register.mjs feeds it facts
4 * (the surface, the `$.ui.blit` answer, the person's `v` toggle) and pane.mjs draws what it returns.
5 *
6 * Terminal. The `Image` element takes PNG or RGBA only, so the JPEG is decoded (the vendored jpeg-js decoder) to RGBA.
7 * It shows real pixels on terminals with a graphics protocol (kitty, Ghostty) and its `alt` text elsewhere. The fact
8 * that tells the two apart is the engine's own: `$.ui.blit` on a keyed Image resolves `{ deny }` with a reason naming
9 * the alt when that Image draws its alt (2.1.287 d.ts `UiBlitResult`). On that fact the pane draws `Raster` cells
10 * instead: half blocks (U+2580), the top pixel as the foreground and the bottom one as the background, two pixels a
11 * cell. `v` toggles either way. Which default stands, and the exact deny words, are recorded in mod/NOTES.md (spike 1).
12 *
13 * Desktop and the other remote surfaces. `Svg` (at most 131,072 characters) with the JPEG embedded as a data URI when
14 * the whole document fits; otherwise the picture is decoded, scaled down and re-encoded as a stored PNG until it fits.
15 */
16
17import { base64Decode, base64Encode } from './bytes.mjs';
18import { decode as decodeJpeg } from './jpeg-decoder.mjs';
19import { encodePng, storedPngBytes } from './png.mjs';
20import { decodeWebp } from './webp-decoder.mjs';
21
22/** The Svg element's character ceiling (2.1.287 d.ts `SvgProps.source`). */
23export const SVG_MAX_CHARS = 131072;
24/** The XML namespace an SVG document names itself with; an identifier, never fetched. */
25export const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
26/** The half-block glyph a Raster cell draws: upper half in the foreground, lower half in the background. */
27export const HALF_BLOCK = 0x2580;
28/** Raster's own bounds (2.1.287 d.ts `RasterProps`), and Image's. */
29export const RASTER_MAX_COLUMNS = 512;
30export const RASTER_MAX_ROWS = 256;
31export const IMAGE_MAX_CELLS = 255;
32/** RGBA handed to an Image never exceeds this edge (2.1.287 d.ts `ImageSource`: 1 to 2048). */
33export const IMAGE_MAX_EDGE = 2048;
34/** The pixels an Image source carries per terminal cell, and a desktop picture's CSS pixels per column: a common cell size at 1x. */
35export const CELL_PIXELS = Object.freeze({ width: 8, height: 16 });
36
37/**
38 * Which element draws the pictures on this surface: `image` or `raster` on the terminal (the person's or the blit
39 * fact's choice, `useCells`), `svg` on every surface whose table has `Svg`.
40 */
41export function pictureKind(surface, { useCells = false } = {}) {
42 if (surface === 'terminal') return useCells ? 'raster' : 'image';
43 return 'svg';
44}
45
46/**
47 * Whether a `$.ui.blit` answer says the keyed Image draws its alt text, the one deny that means "no pixels here".
48 * Every other answer is silent: `{}` (pixels taken), or a deny for another reason (not mounted yet, another size).
49 */
50export function blitSaysAlt(result) {
51 return Boolean(result && typeof result.deny === 'string' && /\balt\b/i.test(result.deny));
52}
53
54/** Whether the terminal should draw cells: the person's toggle when they pressed `v`, else the blit fact. */
55export function useCellsFor({ toggled = null, altDrawn = false } = {}) {
56 return toggled === null ? Boolean(altDrawn) : Boolean(toggled);
57}
58
59/** The decoded thumbnail: `{ rgba, width, height }` from the JPEG's base64. Throws on bytes that are not a JPEG. */
60export function decodeThumb(jpegBase64, mimeType = 'image/jpeg') {
61 if (mimeType === 'image/webp') {
62 const webp = decodeWebp(base64Decode(jpegBase64));
63 return { rgba: webp.data, width: webp.width, height: webp.height };
64 }
65 const image = decodeJpeg(base64Decode(jpegBase64), { formatAsRGBA: true, maxResolutionInMP: 16, maxMemoryUsageInMB: 64 });
66 return { rgba: image.data, width: image.width, height: image.height };
67}
68
69/** 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}
231hooks/workspace/poll.mjs 42 lines1/**
2 * When the pane next asks the server for the workspace (`design.slate.status`): the hosted screen's backoff
3 * (2, 3, 5, 8, then 10 s; `DELAYS` in packages/12ui/src/mcp-slate-ui-script-state.ts), reset by every change, and
4 * never later than the moment a start countdown ends, when the server lets that round go. Polling is the screen being
5 * in sight (workers/api/src/slate/limits.ts `SLATE_PAUSE`): only while the pane is open and something runs, or while the
6 * first read of the shown workspace keeps failing in a way a retry can mend. Pure.
7 */
8
9export const POLL_DELAYS_MS = Object.freeze([2000, 3000, 5000, 8000, 10000]);
10
11/** A countdown's last poll lands this long after its end, so the server has passed its deadline. */
12export const COUNTDOWN_MARGIN_MS = 500;
13
14/** The backoff step after a poll: back to the start when the view changed, one step on otherwise (unchanged, failed). */
15export function nextStep(step, changed) {
16 return changed ? 0 : Math.min(step + 1, POLL_DELAYS_MS.length - 1);
17}
18
19/** Whether the pane polls at all: open, a workspace shown, and something running. */
20export function wantsPolling({ isOpen, hasWorkspace, isRunning }) {
21 return Boolean(isOpen && hasWorkspace && isRunning);
22}
23
24/**
25 * Whether the pane reads again for a workspace it holds no view of yet: open, and the last read failed in a way a later
26 * read can mend (a 429, a 5xx, no answer at all). A refusal such as `unknown_workspace` keeps its error line and reads
27 * nothing more; 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}
42hooks/workspace/redraw.mjs 88 lines1/**
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