clone

a browser grid puzzle game where you push words around to rewrite the rules of each level

feat(workshop): confirm pack removal and show service errors

Dami committed c42cf42
commit
c42cf423db5a9c6614451f91d51c556be59fc240
parent
261d15564b167bf0b0cb8c63c3eec20db516de23
author
Dami <dami.etoile@gmail.com>
2 files changed, +111 -19
Msrc/scenes/workshop.js81++++++++++++++++++++++++------
Mtests/workshop.test.js49+++++++++++++++++-
M src/scenes/workshop.js +64 -17
@@ -47,6 +47,7 @@ export function createWorkshopScene(options = {}) {
4747 let feedback = null;4848 let note = null;4949 let hitLayout = null;50+ let confirm = null;5051 5152 const rows = () => [{ id: '__import__', type: 'import', label: 'IMPORT…', actions: ['import'] }, ...packs.map(pack => ({ id: pack.id, type: 'pack', pack, actions: pack.actions }))];5253 function selectedRow() { return rows()[rowIndex] || rows()[0]; }
@@ -59,30 +60,49 @@ export function createWorkshopScene(options = {}) {
5960 function view() {6061 const row = selectedRow();6162 return {62- id: 'workshop', eyebrow: 'WORKSHOP', title: 'Packs and gardens',63+ id: 'workshop', eyebrow: 'WORKSHOP', title: 'Packs of proofs',6364 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] })),6465 selectedRow: rowIndex, selectedAction: actionIndex, selectedActionName: row?.actions[actionIndex] || null,6566 feedback: feedback ? { ...feedback, errors: feedback.errors ? [...feedback.errors] : [] } : null,6667 note: note ? { ...note, errors: [...note.errors] } : null,68+ confirm: confirm ? { ...confirm, choices: [...confirm.choices] } : null,6769 };6870 }6971 function setFeedback(message, kind = 'info') { feedback = { message, kind }; note = null; return view(); }7072 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 };73+ const entries = Array.isArray(errors) ? errors : [errors];74+ const list = entries.map(error => typeof error === 'object' && error !== null75+ ? { message: String(error.message || error.detail || 'Unknown error'), detail: String(error.detail || error.message || '') }76+ : { message: String(error), detail: String(error) });77+ note = { errors: list, visible: list.slice(0, 5).map(error => error.message), remaining: Math.max(0, list.length - 5) };78+ feedback = { message: 'THIS PACK HAS PROBLEMS', kind: 'error', errors: list };7479 return view();7580 }7681 async function copyDetails() {7782 if (!note) return false;7883 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'));84+ const details = note.errors.map(error => error.detail && error.detail !== error.message ? `${error.message}\n${error.detail}` : error.message).join('\n');85+ if (options.copyToClipboard) await options.copyToClipboard(details);86+ else if (globalThis.navigator?.clipboard?.writeText) await globalThis.navigator.clipboard.writeText(details);8187 else return setFeedback('COPY DETAILS IS UNAVAILABLE', 'error') && false;8288 setFeedback('DETAILS COPIED', 'success'); return true;8389 } catch { setFeedback('COPY DETAILS FAILED', 'error'); return false; }8490 }91+ async function removeConfirmed(id) {92+ try {93+ const result = service?.removePack?.(id);94+ if (result && typeof result === 'object' && result.ok === false) { confirm = null; return showErrors(result.errors || [{ message: 'Pack could not be removed.' }]); }95+ const removed = result && typeof result === 'object' && 'removed' in result ? result.removed : result;96+ if (!removed) { confirm = null; return setFeedback('PACK COULD NOT BE REMOVED', 'error'); }97+ options.onRemove?.(id); confirm = null; refresh(); setFeedback('PACK REMOVED', 'success');98+ return { ...view(), event: { type: 'remove', packId: id } };99+ } catch (error) { confirm = null; return showErrors(error?.errors || error?.message || String(error)); }100+ }85101 async function activate() {102+ if (confirm) {103+ if (confirm.choice === 1) { confirm = null; return setFeedback('REMOVE CANCELLED', 'info'); }104+ return removeConfirmed(confirm.packId);105+ }86106 const row = selectedRow();87107 if (!row) return view();88108 const action = row.actions[actionIndex];
@@ -91,16 +111,22 @@ export function createWorkshopScene(options = {}) {
91111 try {92112 if (action === 'play') { options.onPlay?.(id); return { ...view(), event: { type: 'play', packId: id } }; }93113 if (action === 'share') {94- const url = service?.packLink?.(id); options.onShare?.(id, url); setFeedback('LINK IS COPIED', 'success');114+ const result = service?.packLink?.(id);115+ if (result && typeof result === 'object' && result.ok === false) return showErrors(result.errors || [{ message: 'Could not create pack link.' }]);116+ const url = result && typeof result === 'object' && 'url' in result ? result.url : result;117+ options.onShare?.(id, url); setFeedback('LINK IS COPIED', 'success');95118 return { ...view(), event: { type: 'share', packId: id, url } };96119 }97120 if (action === 'export') {98- const blob = service?.exportPack?.(id); options.onExport?.(id, blob); setFeedback('PACK IS SAVED', 'success');121+ const result = service?.exportPack?.(id);122+ if (result && typeof result === 'object' && result.ok === false) return showErrors(result.errors || [{ message: 'Could not export pack.' }]);123+ const blob = result && typeof result === 'object' && 'blob' in result ? result.blob : result;124+ options.onExport?.(id, blob); setFeedback('PACK IS SAVED', 'success');99125 return { ...view(), event: { type: 'export', packId: id, blob } };100126 }101127 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 } };128+ confirm = { packId: id, name: row.pack.manifest?.name || row.pack.name || id, choice: 0, choices: ['REMOVE', 'CANCEL'] };129+ return { ...view(), event: { type: 'remove-confirm', packId: id } };104130 }105131 } catch (error) { return showErrors(error?.errors || error?.message || String(error)); }106132 return view();
@@ -116,6 +142,13 @@ export function createWorkshopScene(options = {}) {
116142 }117143 function handleInput(input) {118144 const normalized = String(input).toLowerCase();145+ if (confirm) {146+ if (normalized === 'escape' || normalized === 'back') { confirm = null; return setFeedback('REMOVE CANCELLED', 'info'); }147+ if (normalized === 'left' || normalized === 'arrowleft') { confirm.choice = 0; return view(); }148+ if (normalized === 'right' || normalized === 'arrowright') { confirm.choice = 1; return view(); }149+ if (normalized === 'enter' || normalized === 'confirm' || normalized === 'select') return activate();150+ return view();151+ }119152 if (['up', 'arrowup', 'down', 'arrowdown', 'left', 'arrowleft', 'right', 'arrowright'].includes(normalized)) return move(normalized.replace('arrow', ''));120153 if (normalized === 'enter' || normalized === 'confirm' || normalized === 'select') return activate();121154 if (normalized === 'copy' || normalized === 'copy details') return copyDetails();
@@ -126,6 +159,10 @@ export function createWorkshopScene(options = {}) {
126159 /** Hit-test the last rendered layout. Action cells activate immediately. */127160 function hit(pointerX, pointerY) {128161 if (!hitLayout) return view();162+ if (confirm && hitLayout.confirm) {163+ const choice = hitLayout.confirm.choices.findIndex(cell => pointerX >= cell.x && pointerX <= cell.x + cell.width && pointerY >= cell.y && pointerY <= cell.y + cell.height);164+ if (choice >= 0) { confirm.choice = choice; return activate(); }165+ }129166 if (note && pointerY >= hitLayout.noteCopy.y && pointerY <= hitLayout.noteCopy.y + hitLayout.noteCopy.height && pointerX >= hitLayout.noteCopy.x) return copyDetails();130167 const target = hitLayout.rows.find(row => pointerY >= row.y && pointerY <= row.y + row.height);131168 if (!target) return view();
@@ -142,7 +179,7 @@ export function createWorkshopScene(options = {}) {
142179 const height = dimensions.height || ctx.canvas?.height || 600;143180 ctx.fillStyle = colors.stage; ctx.fillRect(0, 0, width, height);144181 drawText(ctx, 'WORKSHOP', 48, 48, 13, colors.muted, 'left', 800);145- drawText(ctx, 'Packs and gardens', 48, 86, 28, colors.fg, 'left', 850);182+ drawText(ctx, 'Packs of proofs', 48, 86, 28, colors.fg, 'left', 850);146183 drawText(ctx, '↑↓ CHOOSE ←→ ACTION ENTER SELECT ESC BACK', width - 48, 48, 11, colors.muted, 'right', 700);147184 const top = 130; const rowHeight = Math.max(62, Math.min(84, (height - top - 42) / Math.max(rows().length, 1)));148185 hitLayout = { width, height, rows: [], noteCopy: { x: width, y: height, height: 0 } };
@@ -158,8 +195,8 @@ export function createWorkshopScene(options = {}) {
158195 } else {159196 const pack = row.pack; const name = pack.manifest?.name || pack.name || pack.id;160197 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);198+ drawText(ctx, `${pack.levelCount} ${pack.levelCount === 1 ? 'proof' : 'proofs'}${pack.author ? ` | by ${pack.author}` : ''}`, 110, y + 47, 10, selected ? colors.ink : colors.muted, 'left', 700);199+ if (pack.forkOf) drawText(ctx, `COPY OF ${pack.forkOf}`, 110, y + rowHeight - 17, 9, selected ? colors.ink : colors.muted, 'left', 700);163200 row.actions.forEach((action, actionNumber) => {164201 const actionX = width - 64 - (row.actions.length - actionNumber) * 92; const active = selected && actionNumber === actionIndex;165202 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();
@@ -171,15 +208,25 @@ export function createWorkshopScene(options = {}) {
171208 const noteHeight = Math.min(height * 0.45, 76 + note.visible.length * 19); const noteY = height - noteHeight - 24;172209 hitLayout.noteCopy = { x: width * 0.52, y: noteY + noteHeight - 38, height: 34 };173210 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);211+ drawText(ctx, 'THIS PACK HAS PROBLEMS', 68, noteY + 22, 11, colors.YOU, 'left', 850);175212 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);213+ drawText(ctx, `${note.remaining ? `AND ${note.remaining} MORE | ` : ''}COPY DETAILS`, width - 68, noteY + noteHeight - 18, 10, colors.YOU, 'right', 800);177214 } else if (feedback) drawText(ctx, feedback.message, 48, height - 28, 11, feedback.kind === 'error' ? colors.YOU : colors.WIN, 'left', 800);215+ if (confirm) {216+ const cardWidth = Math.min(540, width - 96); const cardHeight = 190; const cardX = (width - cardWidth) / 2; const cardY = (height - cardHeight) / 2;217+ ctx.fillStyle = 'rgba(0,0,0,.68)'; ctx.fillRect(0, 0, width, height);218+ ctx.fillStyle = colors.raised; ctx.fillRect(cardX, cardY, cardWidth, cardHeight); ctx.strokeStyle = colors.YOU; ctx.strokeRect(cardX, cardY, cardWidth, cardHeight);219+ drawText(ctx, `REMOVE ${confirm.name}?`, cardX + cardWidth / 2, cardY + 48, 19, colors.fg, 'center', 850);220+ drawText(ctx, 'Your progress is kept if you add it again.', cardX + cardWidth / 2, cardY + 82, 11, colors.muted, 'center', 600);221+ const buttonWidth = 120; const gap = 14; const buttonsLeft = cardX + (cardWidth - buttonWidth * 2 - gap) / 2;222+ hitLayout.confirm = { choices: confirm.choices.map((choice, index) => ({ x: buttonsLeft + index * (buttonWidth + gap), y: cardY + 118, width: buttonWidth, height: 38 })) };223+ 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); });224+ }178225 return view();179226 }180227 181228 return Object.freeze({182- id: 'workshop', eyebrow: 'WORKSHOP', title: 'Packs and gardens', prompt: 'IMPORT · PLAY · SHARE · EXPORT', help: 'ARROWS CHOOSE · ENTER SELECT · ESC BACK',229+ id: 'workshop', eyebrow: 'WORKSHOP', title: 'Packs of proofs', prompt: 'IMPORT | PLAY | SHARE | EXPORT', help: 'ARROWS CHOOSE ENTER SELECT ESC BACK',183230 draw, view, refresh, move, handleInput, hit, activate, copyDetails, showErrors,184231 get selectedRow() { return rowIndex; }, get selectedAction() { return actionIndex; },185232 });
@@ -187,6 +234,6 @@ export function createWorkshopScene(options = {}) {
187234 188235 // Kept for the existing scene registry; the canvas controller is created by189236 // 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' };237+export const workshopScene = { id: 'workshop', eyebrow: 'WORKSHOP', title: 'Packs of proofs', prompt: 'IMPORT | PLAY | SHARE | EXPORT', help: 'ARROWS CHOOSE ENTER SELECT ESC BACK' };191238 192239 export default createWorkshopScene;
M tests/workshop.test.js +47 -2
@@ -37,12 +37,34 @@ test('removing an imported pack calls the service and refreshes rows', async ()
3737 const scene = createWorkshopScene({ service: serviceWith(packs) });3838 scene.handleInput('down');3939 scene.handleInput('right'); scene.handleInput('right'); scene.handleInput('right');40+ const prompt = await scene.handleInput('enter');41+ assert.equal(prompt.event.type, 'remove-confirm');42+ assert.match(prompt.confirm.name, /GARDEN/);43+ assert.deepEqual(packs.map(pack => pack.id), ['garden']);4044 const result = await scene.handleInput('enter');4145 assert.equal(result.event.type, 'remove');4246 assert.deepEqual(scene.view().rows.map(row => row.id), ['__import__']);4347 assert.equal(scene.view().feedback.message, 'PACK REMOVED');4448 });4549 50+test('cancel leaves the pack installed and pointer choices can confirm removal', async () => {51+ const packs = [makePack('garden')];52+ const scene = createWorkshopScene({ service: serviceWith(packs) });53+ scene.handleInput('down'); scene.handleInput('right'); scene.handleInput('right'); scene.handleInput('right');54+ await scene.handleInput('enter');55+ assert.equal(scene.view().confirm.choices[scene.view().confirm.choice], 'REMOVE');56+ scene.handleInput('right'); scene.handleInput('enter');57+ assert.deepEqual(packs.map(pack => pack.id), ['garden']);58+ assert.equal(scene.view().confirm, null);59+ scene.handleInput('right'); scene.handleInput('right'); scene.handleInput('right');60+ scene.draw(canvasContext(), { width: 800, height: 600 });61+ await scene.hit(650, 245); // REMOVE action opens the card.62+ scene.draw(canvasContext(), { width: 800, height: 600 });63+ const result = await scene.hit(300, 340); // REMOVE choice on the card.64+ assert.equal(result.event.type, 'remove');65+ assert.deepEqual(packs, []);66+});67+4668 test('share action passes the generated URL to the callback and gives feedback', async () => {4769 const shared = [];4870 const scene = createWorkshopScene({
@@ -59,15 +81,38 @@ test('share action passes the generated URL to the callback and gives feedback',
5981 test('validation note shows five errors and copy details can be injected', async () => {6082 let copied = '';6183 const scene = createWorkshopScene({ copyToClipboard: async value => { copied = value; } });62- scene.showErrors(['one', 'two', 'three', 'four', 'five', 'six', 'seven']);84+ scene.showErrors([85+ { message: 'one', detail: 'ONE detail' }, { message: 'two' }, { message: 'three' },86+ { message: 'four' }, { message: 'five' }, { message: 'six' }, { message: 'seven' },87+ ]);6388 const view = scene.view();6489 assert.deepEqual(view.note.visible, ['one', 'two', 'three', 'four', 'five']);6590 assert.equal(view.note.remaining, 2);6691 assert.equal(await scene.copyDetails(), true);67- assert.equal(copied, 'one\ntwo\nthree\nfour\nfive\nsix\nseven');92+ assert.equal(copied, 'one\nONE detail\ntwo\nthree\nfour\nfive\nsix\nseven');6893 assert.equal(scene.view().feedback.message, 'DETAILS COPIED');6994 });7095 96+test('service envelopes show structured errors and do not trigger failed callbacks', async () => {97+ const shared = []; const exported = [];98+ const scene = createWorkshopScene({99+ packs: [makePack('garden')],100+ service: {101+ packLink: () => ({ ok: false, url: '', errors: [{ message: 'Link blocked', detail: 'Payload too large.' }] }),102+ exportPack: () => ({ ok: false, blob: null, errors: [{ message: 'Export blocked', detail: 'Download unavailable.' }] }),103+ },104+ onShare: (...args) => shared.push(args), onExport: (...args) => exported.push(args),105+ });106+ scene.handleInput('down'); scene.handleInput('right');107+ const shareFailure = await scene.handleInput('enter');108+ assert.equal(shareFailure.note.visible[0], 'Link blocked');109+ assert.deepEqual(shared, []);110+ scene.showErrors([]);111+ scene.handleInput('right'); scene.handleInput('enter');112+ assert.equal(scene.view().note.visible[0], 'Export blocked');113+ assert.deepEqual(exported, []);114+});115+71116 test('hit selects a rendered row and dispatches its action cell', async () => {72117 const played = [];73118 const scene = createWorkshopScene({ service: serviceWith([makePack('garden')]), onPlay: id => played.push(id) });