clone

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

fix(workshop): improve pack actions and player copy

Dami committed a667d2d
commit
a667d2ddff23feade7f15a6e2896901936753030
parent
2255d0b1bfe2a6287da9b40d7266e02b7d3d225c
author
Dami <dami.etoile@gmail.com>
2 files changed, +83 -15
Msrc/scenes/workshop.js46+++++++++++++++++++-------
Mtests/workshop.test.js52+++++++++++++++++++++++++++++-
M src/scenes/workshop.js +33 -13
@@ -29,6 +29,16 @@ function fit(ctx, value, width, size, weight = 700) {
2929 while (next > size * 0.55) { ctx.font = `${weight} ${next}px ${FONT}`; if (ctx.measureText(String(value)).width <= width) break; next *= 0.92; }3030 return `${weight} ${Math.max(size * 0.55, next)}px ${FONT}`;3131 }32+function drawKeyHint(ctx, key, verb, right, y, colors) {33+ ctx.font = `800 10px ${FONT}`;34+ const verbWidth = ctx.measureText(verb).width;35+ const keyWidth = Math.max(28, ctx.measureText(key).width + 12);36+ const keyX = right - verbWidth - 8 - keyWidth;37+ roundRect(ctx, keyX, y - 11, keyWidth, 22, 3); ctx.fillStyle = colors.raised; ctx.fill(); ctx.strokeStyle = colors.edge; ctx.lineWidth = 1; ctx.stroke();38+ drawText(ctx, key, keyX + keyWidth / 2, y, 10, colors.fg, 'center', 800);39+ drawText(ctx, verb, keyX + keyWidth + 8, y, 10, colors.muted, 'left', 650);40+ return keyX - 18;41+}3242 3343 function normalizePacks(service, packs) {3444 const source = packs || service?.listPacks?.() || [];
@@ -60,7 +70,7 @@ export function createWorkshopScene(options = {}) {
6070 function view() {6171 const row = selectedRow();6272 return {63- id: 'workshop', eyebrow: 'WORKSHOP', title: 'Packs of proofs',73+ id: 'workshop', eyebrow: 'WORKSHOP', title: "packs of proofs you've added",6474 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] })),6575 selectedRow: rowIndex, selectedAction: actionIndex, selectedActionName: row?.actions[actionIndex] || null,6676 feedback: feedback ? { ...feedback, errors: feedback.errors ? [...feedback.errors] : [] } : null,
@@ -75,7 +85,7 @@ export function createWorkshopScene(options = {}) {
7585 ? { message: String(error.message || error.detail || 'Unknown error'), detail: String(error.detail || error.message || '') }7686 : { message: String(error), detail: String(error) });7787 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 };88+ feedback = { message: 'This pack has problems', kind: 'error', errors: list };7989 return view();8090 }8191 async function copyDetails() {
@@ -84,16 +94,16 @@ export function createWorkshopScene(options = {}) {
8494 const details = note.errors.map(error => error.detail && error.detail !== error.message ? `${error.message}\n${error.detail}` : error.message).join('\n');8595 if (options.copyToClipboard) await options.copyToClipboard(details);8696 else if (globalThis.navigator?.clipboard?.writeText) await globalThis.navigator.clipboard.writeText(details);87- else return setFeedback('COPY DETAILS IS UNAVAILABLE', 'error') && false;97+ else return setFeedback('Copy details is unavailable', 'error') && false;8898 setFeedback('DETAILS COPIED', 'success'); return true;89- } catch { setFeedback('COPY DETAILS FAILED', 'error'); return false; }99+ } catch { setFeedback('Copy details failed', 'error'); return false; }90100 }91101 async function removeConfirmed(id) {92102 try {93103 const result = service?.removePack?.(id);94104 if (result && typeof result === 'object' && result.ok === false) { confirm = null; return showErrors(result.errors || [{ message: 'Pack could not be removed.' }]); }95105 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'); }106+ if (!removed) { confirm = null; return setFeedback('Pack could not be removed', 'error'); }97107 options.onRemove?.(id); confirm = null; refresh(); setFeedback('PACK REMOVED', 'success');98108 return { ...view(), event: { type: 'remove', packId: id } };99109 } catch (error) { confirm = null; return showErrors(error?.errors || error?.message || String(error)); }
@@ -169,6 +179,10 @@ export function createWorkshopScene(options = {}) {
169179 rowIndex = target.index;170180 const action = target.actions.findIndex(cell => pointerX >= cell.x && pointerX <= cell.x + cell.width);171181 if (action >= 0) { actionIndex = action; return activate(); }182+ if (target.type === 'pack') {183+ actionIndex = 0;184+ return { ...view(), event: { type: 'actions', packId: target.id } };185+ }172186 actionIndex = Math.min(actionIndex, Math.max(0, target.actions.length - 1));173187 return view();174188 }
@@ -179,24 +193,29 @@ export function createWorkshopScene(options = {}) {
179193 const height = dimensions.height || ctx.canvas?.height || 600;180194 ctx.fillStyle = colors.stage; ctx.fillRect(0, 0, width, height);181195 drawText(ctx, 'WORKSHOP', 48, 48, 13, colors.muted, 'left', 800);182- drawText(ctx, 'Packs of proofs', 48, 86, 28, colors.fg, 'left', 850);183- drawText(ctx, '↑↓ CHOOSE ←→ ACTION ENTER SELECT ESC BACK', width - 48, 48, 11, colors.muted, 'right', 700);196+ drawText(ctx, "packs of proofs you've added", 48, 86, 28, colors.fg, 'left', 850);197+ let hintX = width - 48;198+ for (const [key, verb] of [['ESC', 'back'], ['ENTER', 'select'], ['←→', 'action'], ['↑↓', 'choose']]) hintX = drawKeyHint(ctx, key, verb, hintX, 48, colors);184199 const top = 130; const rowHeight = Math.max(62, Math.min(84, (height - top - 42) / Math.max(rows().length, 1)));185200 hitLayout = { width, height, rows: [], noteCopy: { x: width, y: height, height: 0 } };186201 rows().forEach((row, index) => {187202 const y = top + index * rowHeight; const selected = index === rowIndex;188- const cells = row.type === 'pack' ? row.actions.map((action, actionNumber) => ({ action, x: width - 64 - (row.actions.length - actionNumber) * 92, width: 84 })) : [];189- hitLayout.rows.push({ index, y, height: rowHeight - 10, actions: cells });203+ const cells = row.type === 'pack' ? row.actions.map((action, actionNumber) => ({ action, x: width - 64 - (row.actions.length - actionNumber) * 92, y: y + rowHeight / 2 - 22, width: 84, height: 44 })) : [];204+ hitLayout.rows.push({ index, id: row.id, type: row.type, y, height: rowHeight - 10, actions: cells });190205 ctx.fillStyle = selected ? colors.YOU : colors.raised; ctx.fillRect(48, y, width - 96, rowHeight - 10);191206 ctx.strokeStyle = selected ? colors.YOU : colors.edge; ctx.lineWidth = 1; ctx.strokeRect(48, y, width - 96, rowHeight - 10);192207 drawText(ctx, row.type === 'import' ? '+' : String(index).padStart(2, '0'), 70, y + 26, 16, selected ? colors.ink : colors.muted, 'left', 850);193208 if (row.type === 'import') {194- drawText(ctx, row.label, 110, y + 26, 16, selected ? colors.ink : colors.fg, 'left', 850);209+ drawText(ctx, row.label, 110, y + (rowHeight - 10) / 2, 16, selected ? colors.ink : colors.fg, 'left', 850);195210 } else {196211 const pack = row.pack; const name = pack.manifest?.name || pack.name || pack.id;197212 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);198213 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);214+ if (pack.forkOf) {215+ const sourcePack = packs.find(candidate => candidate.id === pack.forkOf);216+ const sourceName = pack.forkOfName || sourcePack?.manifest?.name || sourcePack?.name || pack.forkOf;217+ drawText(ctx, `COPY OF ${sourceName}`, 110, y + rowHeight - 17, 9, selected ? colors.ink : colors.muted, 'left', 700);218+ }200219 row.actions.forEach((action, actionNumber) => {201220 const actionX = width - 64 - (row.actions.length - actionNumber) * 92; const active = selected && actionNumber === actionIndex;202221 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();
@@ -208,7 +227,7 @@ export function createWorkshopScene(options = {}) {
208227 const noteHeight = Math.min(height * 0.45, 76 + note.visible.length * 19); const noteY = height - noteHeight - 24;209228 hitLayout.noteCopy = { x: width * 0.52, y: noteY + noteHeight - 38, height: 34 };210229 ctx.fillStyle = colors.raised; ctx.fillRect(48, noteY, width - 96, noteHeight); ctx.strokeStyle = colors.YOU; ctx.strokeRect(48, noteY, width - 96, noteHeight);211- drawText(ctx, 'THIS PACK HAS PROBLEMS', 68, noteY + 22, 11, colors.YOU, 'left', 850);230+ drawText(ctx, 'This pack has problems', 68, noteY + 22, 11, colors.YOU, 'left', 850);212231 note.visible.forEach((error, index) => drawText(ctx, error, 68, noteY + 44 + index * 18, 10, colors.fg, 'left', 600));213232 drawText(ctx, `${note.remaining ? `AND ${note.remaining} MORE | ` : ''}COPY DETAILS`, width - 68, noteY + noteHeight - 18, 10, colors.YOU, 'right', 800);214233 } else if (feedback) drawText(ctx, feedback.message, 48, height - 28, 11, feedback.kind === 'error' ? colors.YOU : colors.WIN, 'left', 800);
@@ -226,9 +245,10 @@ export function createWorkshopScene(options = {}) {
226245 }227246 228247 return Object.freeze({229- id: 'workshop', eyebrow: 'WORKSHOP', title: 'Packs of proofs', prompt: 'IMPORT | PLAY | SHARE | EXPORT', help: 'ARROWS CHOOSE ENTER SELECT ESC BACK',248+ id: 'workshop', eyebrow: 'WORKSHOP', title: "packs of proofs you've added", prompt: 'IMPORT | PLAY | SHARE | EXPORT', help: 'ARROWS CHOOSE ENTER SELECT ESC BACK',230249 draw, view, refresh, move, handleInput, hit, activate, copyDetails, showErrors,231250 get selectedRow() { return rowIndex; }, get selectedAction() { return actionIndex; },251+ get hitLayout() { return hitLayout ? clone(hitLayout) : null; },232252 });233253 }234254
M tests/workshop.test.js +50 -2
@@ -17,10 +17,12 @@ function serviceWith(packs) {
1717 }1818 1919 function canvasContext() {20+ const calls = [];2021 return {2122 canvas: { width: 800, height: 600 },22- fillRect() {}, strokeRect() {}, beginPath() {}, rect() {}, roundRect() {}, fill() {}, stroke() {},23- fillText() {}, measureText(value) { return { width: String(value).length * 8 }; },23+ calls,24+ fillRect(...args) { calls.push(['fillRect', ...args]); }, strokeRect(...args) { calls.push(['strokeRect', ...args]); }, beginPath() {}, rect() {}, roundRect() {}, fill() {}, stroke() {},25+ fillText(...args) { calls.push(['fillText', ...args]); }, measureText(value) { return { width: String(value).length * 8 }; },2426 };2527 }2628
@@ -124,3 +126,49 @@ test('hit selects a rendered row and dispatches its action cell', async () => {
124126 assert.equal(scene.view().selectedRow, 1);125127 assert.equal(scene.view().selectedActionName, 'play');126128 });129+130+test('tapping a pack row opens its actions and action hit targets are at least 44px high', () => {131+ const scene = createWorkshopScene({ packs: [makePack('garden')] });132+ const ctx = canvasContext();133+ scene.draw(ctx, { width: 800, height: 600 });134+ const rowTap = scene.hit(100, 130 + 70 + 25);135+ assert.equal(rowTap.event.type, 'actions');136+ assert.equal(rowTap.event.packId, 'garden');137+ assert.equal(scene.view().selectedActionName, 'play');138+ const packRow = scene.hitLayout.rows.find(row => row.index === 1);139+ assert.ok(packRow.actions.every(action => action.height >= 44));140+});141+142+test('IMPORT label is vertically centered in its row', () => {143+ const scene = createWorkshopScene({ packs: [makePack('garden')] });144+ const ctx = canvasContext();145+ scene.draw(ctx, { width: 800, height: 600 });146+ const importLabel = ctx.calls?.find(call => call[0] === 'fillText' && call[1] === 'IMPORT…');147+ assert.equal(importLabel[3], 167);148+});149+150+test('header hints render keycaps before lowercase verbs', () => {151+ const scene = createWorkshopScene({ packs: [makePack('garden')] });152+ const ctx = canvasContext();153+ scene.draw(ctx, { width: 800, height: 600 });154+ const labels = ctx.calls.filter(call => call[0] === 'fillText').map(call => call[1]);155+ for (const label of ['↑↓', 'choose', '←→', 'action', 'ENTER', 'select', 'ESC', 'back']) assert.ok(labels.includes(label), `missing ${label}`);156+ assert.equal(labels.some(label => String(label).includes('[')), false);157+});158+159+test('uses deck copy, resolves COPY OF to a source name, and uses sentence case errors', () => {160+ const scene = createWorkshopScene({ packs: [161+ makePack('source'),162+ { ...makePack('fork'), forkOf: 'source', manifest: { ...makePack('fork').manifest, name: 'Fork' } },163+ ] });164+ assert.equal(scene.view().title, "packs of proofs you've added");165+ const ctx = canvasContext();166+ scene.draw(ctx, { width: 800, height: 600 });167+ const labels = ctx.calls.filter(call => call[0] === 'fillText').map(call => call[1]);168+ assert.ok(labels.includes("packs of proofs you've added"));169+ assert.ok(labels.includes('COPY OF SOURCE'));170+ assert.equal(labels.includes('COPY OF source'), false);171+ scene.showErrors([{ message: 'bad pack', detail: 'detail' }]);172+ scene.draw(ctx, { width: 800, height: 600 });173+ assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === 'This pack has problems'));174+});