- commit
- a965b59e0ab6c61ae4b90d6ee791bbfa1afe95fd
- parent
- 248a839c9ce510e2353da66a755e2fb88dc70a07
- author
- Dami <dami.etoile@gmail.com>
| A | src/ | 192 | ++++++++++++++++++++++++++++++ |
| A | tests/ | 81 | +++++++++++++ |
@@ -0,0 +1,192 @@
1+/**2+ * Canvas Workshop scene.3+ *4+ * The scene owns selection, action dispatch, and its feedback model. It does5+ * not install packs itself; pass the pack-service API through `service` and6+ * connect navigation/file-picker effects with callbacks.7+ */8+9+export const WORKSHOP_COLORS = Object.freeze({10+ stage: '#0F0E0D', board: '#191816', raised: '#232120', edge: '#2E2B28', lip: '#000000',11+ tile: '#F2EEE6', ink: '#0F0E0D', fg: '#F2EEE6', muted: '#8E877C',12+ YOU: '#FF4B3E', WIN: '#2EE6A8', PUSH: '#FFB020', STOP: '#8B91FF',13+});14+15+const clone = value => typeof structuredClone === 'function' ? structuredClone(value) : JSON.parse(JSON.stringify(value));16+const actionsFor = pack => ['play', 'share', 'export', ...(pack.origin === 'bundled' ? [] : ['remove'])];17+const actionLabel = { play: 'PLAY', share: 'SHARE LINK', export: 'EXPORT .CJS', remove: 'REMOVE' };18+const FONT = "'Recursive', ui-monospace, monospace";19+20+function roundRect(ctx, x, y, width, height, radius = 4) {21+ ctx.beginPath();22+ if (typeof ctx.roundRect === 'function') ctx.roundRect(x, y, width, height, radius); else ctx.rect(x, y, width, height);23+}24+function drawText(ctx, value, x, y, size, color, align = 'left', weight = 700) {25+ ctx.font = `${weight} ${size}px ${FONT}`; ctx.fillStyle = color; ctx.textAlign = align; ctx.textBaseline = 'middle'; ctx.fillText(String(value), x, y);26+}27+function fit(ctx, value, width, size, weight = 700) {28+ let next = size;29+ while (next > size * 0.55) { ctx.font = `${weight} ${next}px ${FONT}`; if (ctx.measureText(String(value)).width <= width) break; next *= 0.92; }30+ return `${weight} ${Math.max(size * 0.55, next)}px ${FONT}`;31+}32+33+function normalizePacks(service, packs) {34+ const source = packs || service?.listPacks?.() || [];35+ return source.map(pack => ({36+ ...clone(pack),37+ actions: actionsFor(pack),38+ levelCount: pack.levelCount ?? pack.levels?.length ?? 0,39+ }));40+}41+42+export function createWorkshopScene(options = {}) {43+ const service = options.service;44+ let packs = normalizePacks(service, options.packs);45+ let rowIndex = 0;46+ let actionIndex = 0;47+ let feedback = null;48+ let note = null;49+ let hitLayout = null;50+51+ const rows = () => [{ id: '__import__', type: 'import', label: 'IMPORT…', actions: ['import'] }, ...packs.map(pack => ({ id: pack.id, type: 'pack', pack, actions: pack.actions }))];52+ function selectedRow() { return rows()[rowIndex] || rows()[0]; }53+ function refresh(nextPacks) {54+ packs = normalizePacks(service, nextPacks);55+ rowIndex = Math.min(rowIndex, rows().length - 1);56+ actionIndex = Math.min(actionIndex, (selectedRow()?.actions.length || 1) - 1);57+ return view();58+ }59+ function view() {60+ const row = selectedRow();61+ return {62+ id: 'workshop', eyebrow: 'WORKSHOP', title: 'Packs and gardens',63+ 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] })),64+ selectedRow: rowIndex, selectedAction: actionIndex, selectedActionName: row?.actions[actionIndex] || null,65+ feedback: feedback ? { ...feedback, errors: feedback.errors ? [...feedback.errors] : [] } : null,66+ note: note ? { ...note, errors: [...note.errors] } : null,67+ };68+ }69+ function setFeedback(message, kind = 'info') { feedback = { message, kind }; note = null; return view(); }70+ function showErrors(errors) {71+ const list = Array.isArray(errors) ? errors.map(String) : [String(errors)];72+ note = { errors: list, visible: list.slice(0, 5), remaining: Math.max(0, list.length - 5) };73+ feedback = { message: 'PACK NEEDS A FIX', kind: 'error', errors: list };74+ return view();75+ }76+ async function copyDetails() {77+ if (!note) return false;78+ try {79+ if (options.copyToClipboard) await options.copyToClipboard(note.errors.join('\n'));80+ else if (globalThis.navigator?.clipboard?.writeText) await globalThis.navigator.clipboard.writeText(note.errors.join('\n'));81+ else return setFeedback('COPY DETAILS IS UNAVAILABLE', 'error') && false;82+ setFeedback('DETAILS COPIED', 'success'); return true;83+ } catch { setFeedback('COPY DETAILS FAILED', 'error'); return false; }84+ }85+ async function activate() {86+ const row = selectedRow();87+ if (!row) return view();88+ const action = row.actions[actionIndex];89+ if (row.type === 'import' || action === 'import') { options.onImport?.(); return { ...view(), event: { type: 'import' } }; }90+ const id = row.pack.id;91+ try {92+ if (action === 'play') { options.onPlay?.(id); return { ...view(), event: { type: 'play', packId: id } }; }93+ if (action === 'share') {94+ const url = service?.packLink?.(id); options.onShare?.(id, url); setFeedback('LINK IS COPIED', 'success');95+ return { ...view(), event: { type: 'share', packId: id, url } };96+ }97+ if (action === 'export') {98+ const blob = service?.exportPack?.(id); options.onExport?.(id, blob); setFeedback('PACK IS SAVED', 'success');99+ return { ...view(), event: { type: 'export', packId: id, blob } };100+ }101+ if (action === 'remove') {102+ const removed = service?.removePack?.(id); if (!removed) return setFeedback('PACK COULD NOT BE REMOVED', 'error');103+ refresh(); setFeedback('PACK REMOVED', 'success'); return { ...view(), event: { type: 'remove', packId: id } };104+ }105+ } catch (error) { return showErrors(error?.errors || error?.message || String(error)); }106+ return view();107+ }108+ function move(direction) {109+ const list = rows();110+ if (direction === 'up') rowIndex = (rowIndex + list.length - 1) % list.length;111+ if (direction === 'down') rowIndex = (rowIndex + 1) % list.length;112+ if (direction === 'left') actionIndex = Math.max(0, actionIndex - 1);113+ if (direction === 'right') actionIndex = Math.min((selectedRow()?.actions.length || 1) - 1, actionIndex + 1);114+ if (direction === 'up' || direction === 'down') actionIndex = Math.min(actionIndex, (selectedRow()?.actions.length || 1) - 1);115+ return view();116+ }117+ function handleInput(input) {118+ const normalized = String(input).toLowerCase();119+ if (['up', 'arrowup', 'down', 'arrowdown', 'left', 'arrowleft', 'right', 'arrowright'].includes(normalized)) return move(normalized.replace('arrow', ''));120+ if (normalized === 'enter' || normalized === 'confirm' || normalized === 'select') return activate();121+ if (normalized === 'copy' || normalized === 'copy details') return copyDetails();122+ if (normalized === 'escape' || normalized === 'back') { options.onBack?.(); return { ...view(), event: { type: 'back' } }; }123+ return view();124+ }125+126+ /** Hit-test the last rendered layout. Action cells activate immediately. */127+ function hit(pointerX, pointerY) {128+ if (!hitLayout) return view();129+ if (note && pointerY >= hitLayout.noteCopy.y && pointerY <= hitLayout.noteCopy.y + hitLayout.noteCopy.height && pointerX >= hitLayout.noteCopy.x) return copyDetails();130+ const target = hitLayout.rows.find(row => pointerY >= row.y && pointerY <= row.y + row.height);131+ if (!target) return view();132+ rowIndex = target.index;133+ const action = target.actions.findIndex(cell => pointerX >= cell.x && pointerX <= cell.x + cell.width);134+ if (action >= 0) { actionIndex = action; return activate(); }135+ actionIndex = Math.min(actionIndex, Math.max(0, target.actions.length - 1));136+ return view();137+ }138+139+ function draw(ctx, dimensions = {}) {140+ const colors = { ...WORKSHOP_COLORS, ...(options.colors || {}) };141+ const width = dimensions.width || ctx.canvas?.width || 800;142+ const height = dimensions.height || ctx.canvas?.height || 600;143+ ctx.fillStyle = colors.stage; ctx.fillRect(0, 0, width, height);144+ drawText(ctx, 'WORKSHOP', 48, 48, 13, colors.muted, 'left', 800);145+ drawText(ctx, 'Packs and gardens', 48, 86, 28, colors.fg, 'left', 850);146+ drawText(ctx, '↑↓ CHOOSE ←→ ACTION ENTER SELECT ESC BACK', width - 48, 48, 11, colors.muted, 'right', 700);147+ const top = 130; const rowHeight = Math.max(62, Math.min(84, (height - top - 42) / Math.max(rows().length, 1)));148+ hitLayout = { width, height, rows: [], noteCopy: { x: width, y: height, height: 0 } };149+ rows().forEach((row, index) => {150+ const y = top + index * rowHeight; const selected = index === rowIndex;151+ const cells = row.type === 'pack' ? row.actions.map((action, actionNumber) => ({ action, x: width - 64 - (row.actions.length - actionNumber) * 92, width: 84 })) : [];152+ hitLayout.rows.push({ index, y, height: rowHeight - 10, actions: cells });153+ ctx.fillStyle = selected ? colors.YOU : colors.raised; ctx.fillRect(48, y, width - 96, rowHeight - 10);154+ ctx.strokeStyle = selected ? colors.YOU : colors.edge; ctx.lineWidth = 1; ctx.strokeRect(48, y, width - 96, rowHeight - 10);155+ drawText(ctx, row.type === 'import' ? '+' : String(index).padStart(2, '0'), 70, y + 26, 16, selected ? colors.ink : colors.muted, 'left', 850);156+ if (row.type === 'import') {157+ drawText(ctx, row.label, 110, y + 26, 16, selected ? colors.ink : colors.fg, 'left', 850);158+ } else {159+ const pack = row.pack; const name = pack.manifest?.name || pack.name || pack.id;160+ ctx.font = fit(ctx, name, width * 0.38, 17, 850); ctx.fillStyle = selected ? colors.ink : colors.fg; ctx.textAlign = 'left'; ctx.fillText(name, 110, y + 22);161+ drawText(ctx, `${pack.levelCount} ${pack.levelCount === 1 ? 'proof' : 'proofs'}${pack.manifest?.author ? ` · by ${pack.manifest.author}` : ''}`, 110, y + 47, 10, selected ? colors.ink : colors.muted, 'left', 700);162+ if (pack.manifest?.forkOf) drawText(ctx, `COPY OF ${pack.manifest.forkOf}`, 110, y + rowHeight - 17, 9, selected ? colors.ink : colors.muted, 'left', 700);163+ row.actions.forEach((action, actionNumber) => {164+ const actionX = width - 64 - (row.actions.length - actionNumber) * 92; const active = selected && actionNumber === actionIndex;165+ roundRect(ctx, actionX, y + rowHeight / 2 - 14, 84, 28, 3); ctx.fillStyle = active ? colors.fg : colors.board; ctx.fill(); ctx.strokeStyle = active ? colors.ink : colors.edge; ctx.stroke();166+ drawText(ctx, actionLabel[action], actionX + 42, y + rowHeight / 2, action === 'share' ? 8 : 9, active ? colors.ink : colors.fg, 'center', 800);167+ });168+ }169+ });170+ if (note) {171+ const noteHeight = Math.min(height * 0.45, 76 + note.visible.length * 19); const noteY = height - noteHeight - 24;172+ hitLayout.noteCopy = { x: width * 0.52, y: noteY + noteHeight - 38, height: 34 };173+ ctx.fillStyle = colors.raised; ctx.fillRect(48, noteY, width - 96, noteHeight); ctx.strokeStyle = colors.YOU; ctx.strokeRect(48, noteY, width - 96, noteHeight);174+ drawText(ctx, 'PACK NEEDS A FIX', 68, noteY + 22, 11, colors.YOU, 'left', 850);175+ note.visible.forEach((error, index) => drawText(ctx, error, 68, noteY + 44 + index * 18, 10, colors.fg, 'left', 600));176+ drawText(ctx, `${note.remaining ? `AND ${note.remaining} MORE · ` : ''}COPY DETAILS`, width - 68, noteY + noteHeight - 18, 10, colors.YOU, 'right', 800);177+ } else if (feedback) drawText(ctx, feedback.message, 48, height - 28, 11, feedback.kind === 'error' ? colors.YOU : colors.WIN, 'left', 800);178+ return view();179+ }180+181+ return Object.freeze({182+ id: 'workshop', eyebrow: 'WORKSHOP', title: 'Packs and gardens', prompt: 'IMPORT · PLAY · SHARE · EXPORT', help: 'ARROWS CHOOSE · ENTER SELECT · ESC BACK',183+ draw, view, refresh, move, handleInput, hit, activate, copyDetails, showErrors,184+ get selectedRow() { return rowIndex; }, get selectedAction() { return actionIndex; },185+ });186+}187+188+// Kept for the existing scene registry; the canvas controller is created by189+// the stage with createWorkshopScene when it has a catalog/service available.190+export const workshopScene = { id: 'workshop', eyebrow: 'WORKSHOP', title: 'Packs and gardens', prompt: 'IMPORT · PLAY · SHARE · EXPORT', help: 'ARROWS CHOOSE · ENTER SELECT · ESC BACK' };191+192+export default createWorkshopScene;@@ -0,0 +1,81 @@
1+import test from 'node:test';2+import assert from 'node:assert/strict';3+import { createWorkshopScene } from '../src/scenes/workshop.js';4+5+const makePack = (id, origin = 'local') => ({6+ id, origin, levelCount: 2,7+ manifest: { id, name: id.toUpperCase(), version: '1.0.0', author: 'Tester' },8+});9+10+function serviceWith(packs) {11+ return {12+ listPacks: () => packs,13+ packLink: id => `https://example.test/#pack=${id}`,14+ exportPack: id => ({ id }),15+ removePack: id => { const index = packs.findIndex(pack => pack.id === id); if (index < 0) return false; packs.splice(index, 1); return true; },16+ };17+}18+19+function canvasContext() {20+ return {21+ canvas: { width: 800, height: 600 },22+ fillRect() {}, strokeRect() {}, beginPath() {}, rect() {}, roundRect() {}, fill() {}, stroke() {},23+ fillText() {}, measureText(value) { return { width: String(value).length * 8 }; },24+ };25+}26+27+test('bundled packs do not expose a remove action', () => {28+ const scene = createWorkshopScene({ packs: [makePack('core', 'bundled')] });29+ scene.handleInput('down');30+ assert.deepEqual(scene.view().rows[1].actions, ['play', 'share', 'export']);31+ scene.handleInput('right'); scene.handleInput('right'); scene.handleInput('right');32+ assert.equal(scene.view().selectedActionName, 'export');33+});34+35+test('removing an imported pack calls the service and refreshes rows', async () => {36+ const packs = [makePack('garden')];37+ const scene = createWorkshopScene({ service: serviceWith(packs) });38+ scene.handleInput('down');39+ scene.handleInput('right'); scene.handleInput('right'); scene.handleInput('right');40+ const result = await scene.handleInput('enter');41+ assert.equal(result.event.type, 'remove');42+ assert.deepEqual(scene.view().rows.map(row => row.id), ['__import__']);43+ assert.equal(scene.view().feedback.message, 'PACK REMOVED');44+});45+46+test('share action passes the generated URL to the callback and gives feedback', async () => {47+ const shared = [];48+ const scene = createWorkshopScene({49+ service: serviceWith([makePack('garden')]),50+ onShare: (id, url) => shared.push({ id, url }),51+ });52+ scene.handleInput('down'); scene.handleInput('right');53+ const result = await scene.handleInput('enter');54+ assert.deepEqual(shared, [{ id: 'garden', url: 'https://example.test/#pack=garden' }]);55+ assert.equal(result.event.type, 'share');56+ assert.equal(scene.view().feedback.message, 'LINK IS COPIED');57+});58+59+test('validation note shows five errors and copy details can be injected', async () => {60+ let copied = '';61+ const scene = createWorkshopScene({ copyToClipboard: async value => { copied = value; } });62+ scene.showErrors(['one', 'two', 'three', 'four', 'five', 'six', 'seven']);63+ const view = scene.view();64+ assert.deepEqual(view.note.visible, ['one', 'two', 'three', 'four', 'five']);65+ assert.equal(view.note.remaining, 2);66+ assert.equal(await scene.copyDetails(), true);67+ assert.equal(copied, 'one\ntwo\nthree\nfour\nfive\nsix\nseven');68+ assert.equal(scene.view().feedback.message, 'DETAILS COPIED');69+});70+71+test('hit selects a rendered row and dispatches its action cell', async () => {72+ const played = [];73+ const scene = createWorkshopScene({ service: serviceWith([makePack('garden')]), onPlay: id => played.push(id) });74+ scene.draw(canvasContext(), { width: 800, height: 600 });75+ // The imported pack is row 1; its PLAY cell is the first action at x 368.76+ const result = await scene.hit(380, 130 + 70 + 25);77+ assert.deepEqual(played, ['garden']);78+ assert.equal(result.event.type, 'play');79+ assert.equal(scene.view().selectedRow, 1);80+ assert.equal(scene.view().selectedActionName, 'play');81+});