1import { cloneData } from '../data/clone.js';2import { warningLines } from './warning-lines.js';3import { actionMatches, keycapPressFrame } from '../render/motion.js';45/**6 * Canvas Workshop scene.7 *8 * The scene owns selection, action dispatch, and its feedback model. It does9 * not install packs itself; pass the pack-service API through `service` and10 * connect navigation/file-picker effects with callbacks.11 */1213export const WORKSHOP_COLORS = Object.freeze({14 stage: '#0F0E0D', board: '#191816', raised: '#232120', edge: '#2E2B28', lip: '#000000',15 tile: '#F2EEE6', ink: '#0F0E0D', fg: '#F2EEE6', muted: '#8E877C',16 YOU: '#FF4B3E', WIN: '#2EE6A8', PUSH: '#FFB020', STOP: '#8B91FF',17});1819const actionsFor = pack => ['play', ...(pack.warnings?.length ? ['notes'] : []), 'share', 'export', ...(pack.origin === 'bundled' ? [] : ['remove'])];20const actionLabel = { play: 'PLAY', notes: 'NOTES', share: 'SHARE LINK', export: 'EXPORT .CJS', remove: 'REMOVE' };21const FONT = "'Recursive', ui-monospace, monospace";2223function roundRect(ctx, x, y, width, height, radius = 4) {24 ctx.beginPath();25 if (typeof ctx.roundRect === 'function') ctx.roundRect(x, y, width, height, radius); else ctx.rect(x, y, width, height);26}27function drawText(ctx, value, x, y, size, color, align = 'left', weight = 700) {28 ctx.font = `${weight} ${size}px ${FONT}`; ctx.fillStyle = color; ctx.textAlign = align; ctx.textBaseline = 'middle'; ctx.fillText(String(value), x, y);29}30function fit(ctx, value, width, size, weight = 700) {31 let next = size;32 while (next > size * 0.55) { ctx.font = `${weight} ${next}px ${FONT}`; if (ctx.measureText(String(value)).width <= width) break; next *= 0.92; }33 return `${weight} ${Math.max(size * 0.55, next)}px ${FONT}`;34}35function wrapText(ctx, value, width, size, weight = 700) {36 ctx.font = `${weight} ${size}px ${FONT}`;37 const lines = [];38 let line = '';39 for (const word of String(value).split(/\s+/)) {40 const candidate = line ? `${line} ${word}` : word;41 if (line && ctx.measureText(candidate).width > width) { lines.push(line); line = word; } else line = candidate;42 }43 if (line) lines.push(line);44 return lines.length ? lines : [''];45}46function clampMotion(value) { return Math.max(0, Math.min(1, Number(value) || 0)); }47function easeOutCubic(value) {48 const t = clampMotion(value);49 return 1 - ((1 - t) ** 3);50}51export function workshopMotionFrame(sceneMotion = {}, reducedMotion = false) {52 if (reducedMotion || sceneMotion.phase === 'settled') return { offsetY: 0, alpha: 1 };53 if (sceneMotion.phase === 'exiting') {54 const exit = clampMotion(sceneMotion.exitProgress);55 return { offsetY: 16 * (exit ** 3), alpha: 1 - exit * 0.28 };56 }57 const entry = clampMotion(sceneMotion.entryProgress);58 return { offsetY: -18 * (1 - easeOutCubic(entry)), alpha: 0.76 + easeOutCubic(entry) * 0.24 };59}60function drawKeyHint(ctx, key, verb, right, y, colors, options = {}) {61 ctx.font = `800 10px ${FONT}`;62 const verbWidth = ctx.measureText(verb).width;63 const keyWidth = Math.max(28, ctx.measureText(key).width + 12);64 const keyX = right - verbWidth - 8 - keyWidth;65 const action = verb === 'back' ? 'menu' : verb === 'select' ? 'confirm' : 'navigate';66 const press = options.keycapPress && actionMatches(action, options.keycapPress.action)67 ? keycapPressFrame({ now: options.now, startedAt: options.keycapPress.startedAt, reducedMotion: options.reducedMotion })68 : { offsetY: 0 };69 const offsetY = press.offsetY || 0;70 roundRect(ctx, keyX, y - 11 + offsetY, keyWidth, 22, 3); ctx.fillStyle = colors.raised; ctx.fill(); ctx.strokeStyle = colors.edge; ctx.lineWidth = 1; ctx.stroke();71 drawText(ctx, key, keyX + keyWidth / 2, y + offsetY, 10, colors.fg, 'center', 800);72 drawText(ctx, verb, keyX + keyWidth + 8, y + offsetY, 10, colors.muted, 'left', 650);73 return keyX - 18;74}7576function normalizePacks(service, packs) {77 const source = packs || service?.listPacks?.() || [];78 return source.map(pack => ({79 ...cloneData(pack),80 actions: actionsFor(pack),81 levelCount: pack.levelCount ?? pack.levels?.length ?? 0,82 warnings: Array.isArray(pack.warnings) ? cloneData(pack.warnings) : [],83 }));84}8586export function createWorkshopScene(options = {}) {87 const service = options.service;88 let packs = normalizePacks(service, options.packs);89 let rowIndex = 0;90 let actionIndex = 0;91 let feedback = null;92 let note = null;93 let hitLayout = null;94 let confirm = null;9596 const rows = () => [{ id: '__import__', type: 'import', label: 'IMPORT…', actions: ['import'] }, ...packs.map(pack => ({ id: pack.id, type: 'pack', pack, actions: pack.actions }))];97 function selectedRow() { return rows()[rowIndex] || rows()[0]; }98 function refresh(nextPacks) {99 packs = normalizePacks(service, nextPacks);100 rowIndex = Math.min(rowIndex, rows().length - 1);101 actionIndex = Math.min(actionIndex, (selectedRow()?.actions.length || 1) - 1);102 return view();103 }104 function view() {105 const row = selectedRow();106 return {107 id: 'workshop', eyebrow: 'WORKSHOP', title: "Packs of proofs you've added",108 rows: rows().map(item => ({ id: item.id, type: item.type, label: item.type === 'pack' ? item.pack.manifest?.name || item.pack.name || item.id : item.label, actions: [...item.actions], notes: item.type === 'pack' ? item.pack.warnings.length : 0 })),109 selectedRow: rowIndex, selectedAction: actionIndex, selectedActionName: row?.actions[actionIndex] || null,110 feedback: feedback ? { ...feedback, errors: feedback.errors ? [...feedback.errors] : [], warnings: feedback.warnings ? [...feedback.warnings] : [] } : null,111 note: note ? { ...note, errors: [...note.errors] } : null,112 confirm: confirm ? { ...confirm, choices: [...confirm.choices] } : null,113 };114 }115 function accessibilityText() {116 const row = selectedRow();117 const pack = row?.pack;118 const selection = pack119 ? `${pack.name || pack.manifest?.name || pack.id}. ${pack.levelCount} ${pack.levelCount === 1 ? 'proof' : 'proofs'}. ${pack.warnings.length} ${pack.warnings.length === 1 ? 'note' : 'notes'}. ${row.actions[actionIndex]} selected.`120 : 'Import pack selected.';121 const details = note122 ? ` ${note.kind === 'warning' ? 'Pack notes' : 'Pack errors'}. ${note.visible.join(' ')}${note.remaining ? ` And ${note.remaining} more.` : ''} Press C to copy details or Escape to close.`123 : '';124 return `Workshop. ${selection}${details}`;125 }126 function setFeedback(message, kind = 'info') { feedback = { message, kind }; note = null; return view(); }127 function showErrors(errors) {128 const entries = Array.isArray(errors) ? errors : [errors];129 const list = entries.map(error => typeof error === 'object' && error !== null130 ? { message: String(error.message || error.detail || 'Unknown error'), detail: String(error.detail || error.message || '') }131 : { message: String(error), detail: String(error) });132 note = { kind: 'error', errors: list, visible: list.slice(0, 5).map(error => error.message), remaining: Math.max(0, list.length - 5) };133 feedback = { message: 'This pack has problems', kind: 'error', errors: list };134 return view();135 }136 function showWarnings(warnings, pack = null) {137 const entries = Array.isArray(warnings) ? warnings : [warnings];138 const list = entries.filter(Boolean).map(warning => typeof warning === 'object'139 ? { ...warning, message: String(warning.message || warning.detail || 'This word may not work as a rule.'), detail: String(warning.detail || warning.message || '') }140 : { message: String(warning), detail: String(warning) });141 const visible = warningLines(pack, list);142 note = { kind: 'warning', errors: list, visible: visible.slice(0, 5), remaining: Math.max(0, visible.length - 5) };143 feedback = { message: 'Pack notes', kind: 'warning', warnings: list };144 return view();145 }146 async function copyDetails() {147 if (!note) return false;148 try {149 const details = note.errors.map(error => error.detail && error.detail !== error.message ? `${error.message}\n${error.detail}` : error.message).join('\n');150 if (options.copyToClipboard) await options.copyToClipboard(details);151 else if (globalThis.navigator?.clipboard?.writeText) await globalThis.navigator.clipboard.writeText(details);152 else return setFeedback('Copy details is unavailable', 'error') && false;153 setFeedback('DETAILS COPIED', 'success'); return true;154 } catch { setFeedback('Copy details failed', 'error'); return false; }155 }156 async function removeConfirmed(id) {157 try {158 const result = service?.removePack?.(id);159 if (result && typeof result === 'object' && result.ok === false) { confirm = null; return showErrors(result.errors || [{ message: 'Pack could not be removed.' }]); }160 const removed = result && typeof result === 'object' && 'removed' in result ? result.removed : result;161 if (!removed) { confirm = null; return setFeedback('Pack could not be removed', 'error'); }162 options.onRemove?.(id); confirm = null; refresh(); setFeedback('PACK REMOVED', 'success');163 return { ...view(), event: { type: 'remove', packId: id } };164 } catch (error) { confirm = null; return showErrors(error?.errors || error?.message || String(error)); }165 }166 async function activate() {167 if (confirm) {168 if (confirm.choice === 1) { confirm = null; return setFeedback('REMOVE CANCELLED', 'info'); }169 return removeConfirmed(confirm.packId);170 }171 const row = selectedRow();172 if (!row) return view();173 const action = row.actions[actionIndex];174 if (row.type === 'import' || action === 'import') { options.onImport?.(); return { ...view(), event: { type: 'import' } }; }175 const id = row.pack.id;176 try {177 if (action === 'play') { options.onPlay?.(id); return { ...view(), event: { type: 'play', packId: id } }; }178 if (action === 'notes') return { ...showWarnings(row.pack.warnings, row.pack), event: { type: 'notes', packId: id } };179 if (action === 'share') {180 const result = service?.packLink?.(id);181 if (result && typeof result === 'object' && result.ok === false) return showErrors(result.errors || [{ message: 'Could not create pack link.' }]);182 const url = result && typeof result === 'object' && 'url' in result ? result.url : result;183 options.onShare?.(id, url); setFeedback('LINK IS COPIED', 'success');184 return { ...view(), event: { type: 'share', packId: id, url } };185 }186 if (action === 'export') {187 const result = service?.exportPack?.(id);188 if (result && typeof result === 'object' && result.ok === false) return showErrors(result.errors || [{ message: 'Could not export pack.' }]);189 const blob = result && typeof result === 'object' && 'blob' in result ? result.blob : result;190 options.onExport?.(id, blob); setFeedback('PACK IS SAVED', 'success');191 return { ...view(), event: { type: 'export', packId: id, blob } };192 }193 if (action === 'remove') {194 confirm = { packId: id, name: row.pack.manifest?.name || row.pack.name || id, choice: 0, choices: ['REMOVE', 'CANCEL'] };195 return { ...view(), event: { type: 'remove-confirm', packId: id } };196 }197 } catch (error) { return showErrors(error?.errors || error?.message || String(error)); }198 return view();199 }200 function move(direction) {201 const list = rows();202 if (direction === 'up') rowIndex = (rowIndex + list.length - 1) % list.length;203 if (direction === 'down') rowIndex = (rowIndex + 1) % list.length;204 if (direction === 'left') actionIndex = Math.max(0, actionIndex - 1);205 if (direction === 'right') actionIndex = Math.min((selectedRow()?.actions.length || 1) - 1, actionIndex + 1);206 if (direction === 'up' || direction === 'down') actionIndex = Math.min(actionIndex, (selectedRow()?.actions.length || 1) - 1);207 return view();208 }209 function handleInput(input) {210 const normalized = String(input).toLowerCase();211 if (confirm) {212 if (normalized === 'escape' || normalized === 'back') { confirm = null; return setFeedback('REMOVE CANCELLED', 'info'); }213 if (normalized === 'left' || normalized === 'arrowleft') { confirm.choice = 0; return view(); }214 if (normalized === 'right' || normalized === 'arrowright') { confirm.choice = 1; return view(); }215 if (normalized === 'enter' || normalized === 'confirm' || normalized === 'select') return activate();216 return view();217 }218 if (note && (normalized === 'escape' || normalized === 'back')) { note = null; feedback = null; return view(); }219 if (['up', 'arrowup', 'down', 'arrowdown', 'left', 'arrowleft', 'right', 'arrowright'].includes(normalized)) return move(normalized.replace('arrow', ''));220 if (normalized === 'enter' || normalized === 'confirm' || normalized === 'select') return activate();221 if (normalized === 'c' || normalized === 'copy' || normalized === 'copy details') return copyDetails();222 if (normalized === 'escape' || normalized === 'back') { options.onBack?.(); return { ...view(), event: { type: 'back' } }; }223 return view();224 }225226 /** Hit-test the last rendered layout. Action cells activate immediately. */227 function hit(pointerX, pointerY) {228 if (!hitLayout) return view();229 if (confirm && hitLayout.confirm) {230 const choice = hitLayout.confirm.choices.findIndex(cell => pointerX >= cell.x && pointerX <= cell.x + cell.width && pointerY >= cell.y && pointerY <= cell.y + cell.height);231 if (choice >= 0) { confirm.choice = choice; return activate(); }232 }233 if (note) {234 if (pointerY >= hitLayout.noteCopy.y && pointerY <= hitLayout.noteCopy.y + hitLayout.noteCopy.height && pointerX >= hitLayout.noteCopy.x) return copyDetails();235 if (hitLayout.noteClose && pointerX >= hitLayout.noteClose.x && pointerX <= hitLayout.noteClose.x + hitLayout.noteClose.width && pointerY >= hitLayout.noteClose.y && pointerY <= hitLayout.noteClose.y + hitLayout.noteClose.height) {236 note = null; feedback = null; return { ...view(), event: { type: 'notes-close' } };237 }238 if (hitLayout.noteCard && (pointerX < hitLayout.noteCard.x || pointerX > hitLayout.noteCard.x + hitLayout.noteCard.width || pointerY < hitLayout.noteCard.y || pointerY > hitLayout.noteCard.y + hitLayout.noteCard.height)) {239 note = null; feedback = null; return { ...view(), event: { type: 'notes-close' } };240 }241 if (hitLayout.noteCard) return view();242 }243 const target = hitLayout.rows.find(row => pointerY >= row.y && pointerY <= row.y + row.height);244 if (!target) return view();245 rowIndex = target.index;246 const action = target.actions.findIndex(cell => pointerX >= cell.x && pointerX <= cell.x + cell.width && pointerY >= cell.y && pointerY <= cell.y + cell.height);247 if (action >= 0) { actionIndex = action; return activate(); }248 if (target.type === 'pack') {249 actionIndex = 0;250 return { ...view(), event: { type: 'actions', packId: target.id } };251 }252 actionIndex = Math.min(actionIndex, Math.max(0, target.actions.length - 1));253 return view();254 }255256 function draw(ctx, dimensions = {}) {257 const colors = { ...WORKSHOP_COLORS, ...(options.colors || {}) };258 const width = dimensions.width || ctx.canvas?.width || 800;259 const height = dimensions.height || ctx.canvas?.height || 600;260 const compact = width < 600;261 const coarsePointer = dimensions.coarsePointer ?? globalThis.matchMedia?.('(pointer: coarse)')?.matches ?? false;262 const noKeycaps = width < 760 || coarsePointer;263 const motion = workshopMotionFrame(dimensions.sceneMotion, dimensions.reducedMotion);264 ctx.fillStyle = colors.stage; ctx.fillRect(0, 0, width, height);265 const motionContext = typeof ctx.save === 'function' && typeof ctx.translate === 'function';266 if (motionContext) {267 ctx.save();268 ctx.globalAlpha *= motion.alpha;269 ctx.translate(0, motion.offsetY);270 }271 const margin = compact ? 24 : 48;272 drawText(ctx, 'WORKSHOP', margin, compact ? 30 : 48, 13, colors.muted, 'left', 800);273 if (compact) {274 drawText(ctx, 'Packs of proofs', margin, 76, 24, colors.fg, 'left', 850);275 drawText(ctx, "you've added", margin, 105, 24, colors.fg, 'left', 850);276 } else {277 drawText(ctx, "Packs of proofs you've added", margin, 86, 28, colors.fg, 'left', 850);278 if (!noKeycaps) {279 let hintX = width - margin;280 for (const [key, verb] of [['ESC', 'back'], ['ENTER', 'select'], ['←→', 'action'], ['↑↓', 'choose']]) hintX = drawKeyHint(ctx, key, verb, hintX, 48, colors, dimensions);281 }282 }283 const top = compact ? 204 : 130;284 const rowHeight = compact ? 120 : Math.max(62, Math.min(84, (height - top - 42) / Math.max(rows().length, 1)));285 const visibleCount = Math.max(1, Math.floor((height - top - 20) / rowHeight));286 const firstRow = Math.min(Math.max(0, rowIndex - visibleCount + 1), Math.max(0, rows().length - visibleCount));287 hitLayout = { width, height, rows: [], noteCopy: { x: width, y: height, height: 0 }, noteClose: null, noteCard: null };288 rows().forEach((row, index) => {289 if (index < firstRow || index >= firstRow + visibleCount) return;290 const y = top + (index - firstRow) * rowHeight; const selected = index === rowIndex;291 const cardX = margin; const cardWidth = width - margin * 2;292 const buttonGap = compact ? 7 : 8;293 const buttonWidth = compact ? (cardWidth - 32 - buttonGap * (row.actions.length - 1)) / row.actions.length : 84;294 const buttonY = compact ? y + 70 : y + rowHeight / 2 - 14;295 const buttonX = actionNumber => compact ? cardX + 16 + actionNumber * (buttonWidth + buttonGap) : width - 64 - (row.actions.length - actionNumber) * 92;296 const cells = row.type === 'pack' ? row.actions.map((action, actionNumber) => ({ action, x: buttonX(actionNumber), y: buttonY - (compact ? 4 : 8), width: buttonWidth, height: 44 })) : [];297 hitLayout.rows.push({ index, id: row.id, type: row.type, y, height: rowHeight - 10, actions: cells });298 ctx.fillStyle = selected ? colors.YOU : colors.raised; ctx.fillRect(cardX, y, cardWidth, rowHeight - 10);299 ctx.strokeStyle = selected ? colors.YOU : colors.edge; ctx.lineWidth = 1; ctx.strokeRect(cardX, y, cardWidth, rowHeight - 10);300 drawText(ctx, row.type === 'import' ? '+' : String(index).padStart(2, '0'), compact ? cardX + 20 : 70, y + (row.type === 'import' ? (rowHeight - 10) / 2 : 26), 16, selected ? colors.ink : colors.muted, 'left', 850);301 if (row.type === 'import') {302 drawText(ctx, row.label, compact ? cardX + 62 : 110, y + (rowHeight - 10) / 2, 16, selected ? colors.ink : colors.fg, 'left', 850);303 } else {304 const pack = row.pack; const name = pack.manifest?.name || pack.name || pack.id;305 const textX = compact ? cardX + 62 : 110;306 ctx.font = fit(ctx, name, compact ? cardWidth - 82 : width * 0.38, 17, 850); ctx.fillStyle = selected ? colors.ink : colors.fg; ctx.textAlign = 'left'; ctx.fillText(name, textX, y + 27);307 const meta = `${pack.levelCount} ${pack.levelCount === 1 ? 'proof' : 'proofs'}${pack.author ? ` | by ${pack.author}` : ''}`;308 const warningLabel = pack.warnings?.length ? `${pack.warnings.length} ${pack.warnings.length === 1 ? 'NOTE' : 'NOTES'}` : '';309 const warningReserve = warningLabel ? 74 : 0;310 const metaWidth = compact ? Math.max(64, cardWidth - 82 - warningReserve) : Math.max(64, buttonX(0) - textX - 12 - warningReserve);311 ctx.font = fit(ctx, meta, metaWidth, 10, 700); ctx.fillStyle = selected ? colors.ink : colors.muted; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillText(meta, textX, y + 51);312 if (pack.warnings?.length) {313 const metaX = textX + ctx.measureText(meta).width + 12;314 const label = warningLabel;315 ctx.font = `800 10px ${FONT}`;316 const chipWidth = ctx.measureText(label).width + 14;317 roundRect(ctx, metaX - 5, y + 40, chipWidth, 22, 3); ctx.fillStyle = colors.PUSH; ctx.fill();318 drawText(ctx, label, metaX + chipWidth / 2 - 5, y + 51, 10, colors.ink, 'center', 800);319 }320 if (pack.forkOf) {321 const sourcePack = packs.find(candidate => candidate.id === pack.forkOf);322 const sourceName = pack.forkOfName || sourcePack?.manifest?.name || sourcePack?.name || pack.forkOf;323 drawText(ctx, `COPY OF ${sourceName}`, textX, compact ? y + 66 : y + rowHeight - 17, 9, selected ? colors.ink : colors.muted, 'left', 700);324 }325 row.actions.forEach((action, actionNumber) => {326 const actionX = buttonX(actionNumber); const active = selected && actionNumber === actionIndex;327 roundRect(ctx, actionX, buttonY, buttonWidth, compact ? 36 : 28, 3); ctx.fillStyle = active ? colors.fg : colors.board; ctx.fill(); ctx.strokeStyle = active ? colors.ink : colors.edge; ctx.stroke();328 const label = actionLabel[action];329 ctx.font = fit(ctx, label, Math.max(12, buttonWidth - 8), action === 'share' ? 8 : 9, 800);330 ctx.fillStyle = active ? colors.ink : colors.fg; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';331 ctx.fillText(label, actionX + buttonWidth / 2, buttonY + (compact ? 18 : 14));332 });333 }334 });335 if (note) {336 const noteWidth = compact ? width - 48 : width - 96;337 const noteTextWidth = Math.max(40, noteWidth - 40);338 const noteLineHeight = compact ? 16 : 18;339 const wrappedNotes = note.visible.flatMap(error => wrapText(ctx, error, noteTextWidth, 10, 600));340 const noteHeight = Math.min(height * 0.72, 76 + wrappedNotes.length * noteLineHeight);341 const noteX = compact ? 24 : 48; const noteY = compact ? Math.max(24, (height - noteHeight) / 2) : height - noteHeight - 24;342 hitLayout.noteCard = { x: noteX, y: noteY, width: noteWidth, height: noteHeight };343 hitLayout.noteCopy = { x: noteX + noteWidth * 0.52, y: noteY + noteHeight - 38, height: 34 };344 hitLayout.noteClose = { x: noteX + 12, y: noteY + noteHeight - 38, width: 82, height: 34 };345 const noteColor = note.kind === 'warning' ? colors.PUSH : colors.YOU;346 if (compact) { ctx.fillStyle = 'rgba(0,0,0,.72)'; ctx.fillRect(0, 0, width, height); }347 ctx.fillStyle = colors.raised; ctx.fillRect(noteX, noteY, noteWidth, noteHeight); ctx.strokeStyle = noteColor; ctx.strokeRect(noteX, noteY, noteWidth, noteHeight);348 drawText(ctx, note.kind === 'warning' ? 'PACK NOTES' : 'This pack has problems', noteX + 20, noteY + 22, 11, noteColor, 'left', 850);349 wrappedNotes.forEach((error, index) => {350 ctx.font = `600 10px ${FONT}`; ctx.fillStyle = colors.fg; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillText(error, noteX + 20, noteY + 44 + index * noteLineHeight);351 });352 drawText(ctx, noKeycaps ? 'CLOSE' : 'ESC CLOSE', noteX + 20, noteY + noteHeight - 18, 10, colors.muted, 'left', 800);353 drawText(ctx, `${note.remaining ? `AND ${note.remaining} MORE | ` : ''}COPY DETAILS`, noteX + noteWidth - 20, noteY + noteHeight - 18, 10, noteColor, 'right', 800);354 } else if (feedback) drawText(ctx, feedback.message, 48, height - 28, 11, feedback.kind === 'error' ? colors.YOU : feedback.kind === 'warning' ? colors.PUSH : colors.WIN, 'left', 800);355 if (confirm) {356 const cardWidth = Math.min(540, width - 96); const cardHeight = 190; const cardX = (width - cardWidth) / 2; const cardY = (height - cardHeight) / 2;357 ctx.fillStyle = 'rgba(0,0,0,.68)'; ctx.fillRect(0, 0, width, height);358 ctx.fillStyle = colors.raised; ctx.fillRect(cardX, cardY, cardWidth, cardHeight); ctx.strokeStyle = colors.YOU; ctx.strokeRect(cardX, cardY, cardWidth, cardHeight);359 drawText(ctx, `REMOVE ${confirm.name}?`, cardX + cardWidth / 2, cardY + 48, 19, colors.fg, 'center', 850);360 drawText(ctx, 'Your progress is kept if you add it again.', cardX + cardWidth / 2, cardY + 82, 11, colors.muted, 'center', 600);361 const buttonWidth = 120; const gap = 14; const buttonsLeft = cardX + (cardWidth - buttonWidth * 2 - gap) / 2;362 hitLayout.confirm = { choices: confirm.choices.map((choice, index) => ({ x: buttonsLeft + index * (buttonWidth + gap), y: cardY + 118, width: buttonWidth, height: 38 })) };363 confirm.choices.forEach((choice, index) => { const cell = hitLayout.confirm.choices[index]; roundRect(ctx, cell.x, cell.y, cell.width, cell.height, 3); ctx.fillStyle = confirm.choice === index ? colors.YOU : colors.board; ctx.fill(); ctx.strokeStyle = confirm.choice === index ? colors.YOU : colors.edge; ctx.stroke(); drawText(ctx, choice, cell.x + cell.width / 2, cell.y + cell.height / 2, 10, confirm.choice === index ? colors.ink : colors.fg, 'center', 850); });364 }365 if (motionContext) ctx.restore();366 return view();367 }368369 return Object.freeze({370 id: 'workshop', eyebrow: 'WORKSHOP', title: "Packs of proofs you've added", prompt: 'IMPORT | PLAY | SHARE | EXPORT', help: 'ARROWS CHOOSE ENTER SELECT ESC BACK',371 draw, view, accessibilityText, refresh, move, handleInput, hit, activate, copyDetails, showErrors, showWarnings,372 get selectedRow() { return rowIndex; }, get selectedAction() { return actionIndex; },373 get hitLayout() { return hitLayout ? cloneData(hitLayout) : null; },374 });375}376377// Kept for the existing scene registry; the canvas controller is created by378// the stage with createWorkshopScene when it has a catalog/service available.379export const workshopScene = { id: 'workshop', eyebrow: 'WORKSHOP', title: "Packs of proofs you've added", prompt: 'IMPORT | PLAY | SHARE | EXPORT', help: 'ARROWS CHOOSE ENTER SELECT ESC BACK' };380381export default createWorkshopScene;