clone

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

Surface installed pack notes in Workshop

Dami committed b02ae7f
commit
b02ae7f2311941d46eae464fd505f2f83af4b5a6
parent
d8d3e317ed99ceee19b086b74dfeb8eef4d2db28
author
Dami <dami.etoile@gmail.com>
2 files changed, +76 -15
Msrc/scenes/workshop.js58+++++++++++++++++++++++-------
Mtests/workshop.test.js33+++++++++++++++++-
M src/scenes/workshop.js +44 -14
@@ -14,8 +14,8 @@ export const WORKSHOP_COLORS = Object.freeze({
1414 YOU: '#FF4B3E', WIN: '#2EE6A8', PUSH: '#FFB020', STOP: '#8B91FF',1515 });1616 17-const actionsFor = pack => ['play', 'share', 'export', ...(pack.origin === 'bundled' ? [] : ['remove'])];18-const actionLabel = { play: 'PLAY', share: 'SHARE LINK', export: 'EXPORT .CJS', remove: 'REMOVE' };17+const actionsFor = pack => ['play', ...(pack.warnings?.length ? ['notes'] : []), 'share', 'export', ...(pack.origin === 'bundled' ? [] : ['remove'])];18+const actionLabel = { play: 'PLAY', notes: 'NOTES', share: 'SHARE LINK', export: 'EXPORT .CJS', remove: 'REMOVE' };1919 const FONT = "'Recursive', ui-monospace, monospace";2020 2121 function roundRect(ctx, x, y, width, height, radius = 4) {
@@ -47,6 +47,7 @@ function normalizePacks(service, packs) {
4747 ...cloneData(pack),4848 actions: actionsFor(pack),4949 levelCount: pack.levelCount ?? pack.levels?.length ?? 0,50+ warnings: Array.isArray(pack.warnings) ? cloneData(pack.warnings) : [],5051 }));5152 }5253
@@ -72,9 +73,9 @@ export function createWorkshopScene(options = {}) {
7273 const row = selectedRow();7374 return {7475 id: 'workshop', eyebrow: 'WORKSHOP', title: "Packs of proofs you've added",75- 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] })),76+ 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 })),7677 selectedRow: rowIndex, selectedAction: actionIndex, selectedActionName: row?.actions[actionIndex] || null,77- feedback: feedback ? { ...feedback, errors: feedback.errors ? [...feedback.errors] : [] } : null,78+ feedback: feedback ? { ...feedback, errors: feedback.errors ? [...feedback.errors] : [], warnings: feedback.warnings ? [...feedback.warnings] : [] } : null,7879 note: note ? { ...note, errors: [...note.errors] } : null,7980 confirm: confirm ? { ...confirm, choices: [...confirm.choices] } : null,8081 };
@@ -85,10 +86,19 @@ export function createWorkshopScene(options = {}) {
8586 const list = entries.map(error => typeof error === 'object' && error !== null8687 ? { message: String(error.message || error.detail || 'Unknown error'), detail: String(error.detail || error.message || '') }8788 : { message: String(error), detail: String(error) });88- note = { errors: list, visible: list.slice(0, 5).map(error => error.message), remaining: Math.max(0, list.length - 5) };89+ note = { kind: 'error', errors: list, visible: list.slice(0, 5).map(error => error.message), remaining: Math.max(0, list.length - 5) };8990 feedback = { message: 'This pack has problems', kind: 'error', errors: list };9091 return view();9192 }93+ function showWarnings(warnings) {94+ const entries = Array.isArray(warnings) ? warnings : [warnings];95+ const list = entries.filter(Boolean).map(warning => typeof warning === 'object'96+ ? { ...warning, message: String(warning.message || warning.detail || 'This word may not work as a rule.'), detail: String(warning.detail || warning.message || '') }97+ : { message: String(warning), detail: String(warning) });98+ note = { kind: 'warning', errors: list, visible: list.slice(0, 5).map(warning => warning.message), remaining: Math.max(0, list.length - 5) };99+ feedback = { message: 'Pack notes', kind: 'warning', warnings: list };100+ return view();101+ }92102 async function copyDetails() {93103 if (!note) return false;94104 try {
@@ -121,6 +131,7 @@ export function createWorkshopScene(options = {}) {
121131 const id = row.pack.id;122132 try {123133 if (action === 'play') { options.onPlay?.(id); return { ...view(), event: { type: 'play', packId: id } }; }134+ if (action === 'notes') return { ...showWarnings(row.pack.warnings), event: { type: 'notes', packId: id } };124135 if (action === 'share') {125136 const result = service?.packLink?.(id);126137 if (result && typeof result === 'object' && result.ok === false) return showErrors(result.errors || [{ message: 'Could not create pack link.' }]);
@@ -160,9 +171,10 @@ export function createWorkshopScene(options = {}) {
160171 if (normalized === 'enter' || normalized === 'confirm' || normalized === 'select') return activate();161172 return view();162173 }174+ if (note && (normalized === 'escape' || normalized === 'back')) { note = null; feedback = null; return view(); }163175 if (['up', 'arrowup', 'down', 'arrowdown', 'left', 'arrowleft', 'right', 'arrowright'].includes(normalized)) return move(normalized.replace('arrow', ''));164176 if (normalized === 'enter' || normalized === 'confirm' || normalized === 'select') return activate();165- if (normalized === 'copy' || normalized === 'copy details') return copyDetails();177+ if (normalized === 'c' || normalized === 'copy' || normalized === 'copy details') return copyDetails();166178 if (normalized === 'escape' || normalized === 'back') { options.onBack?.(); return { ...view(), event: { type: 'back' } }; }167179 return view();168180 }
@@ -232,7 +244,19 @@ export function createWorkshopScene(options = {}) {
232244 const pack = row.pack; const name = pack.manifest?.name || pack.name || pack.id;233245 const textX = compact ? cardX + 62 : 110;234246 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);235- drawText(ctx, `${pack.levelCount} ${pack.levelCount === 1 ? 'proof' : 'proofs'}${pack.author ? ` | by ${pack.author}` : ''}`, textX, y + 51, 10, selected ? colors.ink : colors.muted, 'left', 700);247+ const meta = `${pack.levelCount} ${pack.levelCount === 1 ? 'proof' : 'proofs'}${pack.author ? ` | by ${pack.author}` : ''}`;248+ const warningLabel = pack.warnings?.length ? `${pack.warnings.length} ${pack.warnings.length === 1 ? 'NOTE' : 'NOTES'}` : '';249+ const warningReserve = warningLabel ? 74 : 0;250+ const metaWidth = compact ? Math.max(64, cardWidth - 82 - warningReserve) : Math.max(64, buttonX(0) - textX - 12 - warningReserve);251+ 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);252+ if (pack.warnings?.length) {253+ const metaX = textX + ctx.measureText(meta).width + 12;254+ const label = warningLabel;255+ ctx.font = `800 10px ${FONT}`;256+ const chipWidth = ctx.measureText(label).width + 14;257+ roundRect(ctx, metaX - 5, y + 40, chipWidth, 22, 3); ctx.fillStyle = colors.PUSH; ctx.fill();258+ drawText(ctx, label, metaX + chipWidth / 2 - 5, y + 51, 10, colors.ink, 'center', 800);259+ }236260 if (pack.forkOf) {237261 const sourcePack = packs.find(candidate => candidate.id === pack.forkOf);238262 const sourceName = pack.forkOfName || sourcePack?.manifest?.name || sourcePack?.name || pack.forkOf;
@@ -241,18 +265,24 @@ export function createWorkshopScene(options = {}) {
241265 row.actions.forEach((action, actionNumber) => {242266 const actionX = buttonX(actionNumber); const active = selected && actionNumber === actionIndex;243267 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();244- drawText(ctx, actionLabel[action], actionX + buttonWidth / 2, buttonY + (compact ? 18 : 14), action === 'share' ? 8 : 9, active ? colors.ink : colors.fg, 'center', 800);268+ const label = actionLabel[action];269+ ctx.font = fit(ctx, label, Math.max(12, buttonWidth - 8), action === 'share' ? 8 : 9, 800);270+ ctx.fillStyle = active ? colors.ink : colors.fg; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';271+ ctx.fillText(label, actionX + buttonWidth / 2, buttonY + (compact ? 18 : 14));245272 });246273 }247274 });248275 if (note) {249276 const noteHeight = Math.min(height * 0.45, 76 + note.visible.length * 19); const noteY = height - noteHeight - 24;250277 hitLayout.noteCopy = { x: width * 0.52, y: noteY + noteHeight - 38, height: 34 };251- ctx.fillStyle = colors.raised; ctx.fillRect(48, noteY, width - 96, noteHeight); ctx.strokeStyle = colors.YOU; ctx.strokeRect(48, noteY, width - 96, noteHeight);252- drawText(ctx, 'This pack has problems', 68, noteY + 22, 11, colors.YOU, 'left', 850);253- note.visible.forEach((error, index) => drawText(ctx, error, 68, noteY + 44 + index * 18, 10, colors.fg, 'left', 600));254- drawText(ctx, `${note.remaining ? `AND ${note.remaining} MORE | ` : ''}COPY DETAILS`, width - 68, noteY + noteHeight - 18, 10, colors.YOU, 'right', 800);255- } else if (feedback) drawText(ctx, feedback.message, 48, height - 28, 11, feedback.kind === 'error' ? colors.YOU : colors.WIN, 'left', 800);278+ const noteColor = note.kind === 'warning' ? colors.PUSH : colors.YOU;279+ ctx.fillStyle = colors.raised; ctx.fillRect(48, noteY, width - 96, noteHeight); ctx.strokeStyle = noteColor; ctx.strokeRect(48, noteY, width - 96, noteHeight);280+ drawText(ctx, note.kind === 'warning' ? 'PACK NOTES' : 'This pack has problems', 68, noteY + 22, 11, noteColor, 'left', 850);281+ note.visible.forEach((error, index) => {282+ ctx.font = fit(ctx, error, Math.max(40, width - 136), 10, 600); ctx.fillStyle = colors.fg; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillText(error, 68, noteY + 44 + index * 18);283+ });284+ drawText(ctx, `${note.remaining ? `AND ${note.remaining} MORE | ` : ''}COPY DETAILS`, width - 68, noteY + noteHeight - 18, 10, noteColor, 'right', 800);285+ } 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);256286 if (confirm) {257287 const cardWidth = Math.min(540, width - 96); const cardHeight = 190; const cardX = (width - cardWidth) / 2; const cardY = (height - cardHeight) / 2;258288 ctx.fillStyle = 'rgba(0,0,0,.68)'; ctx.fillRect(0, 0, width, height);
@@ -268,7 +298,7 @@ export function createWorkshopScene(options = {}) {
268298 269299 return Object.freeze({270300 id: 'workshop', eyebrow: 'WORKSHOP', title: "Packs of proofs you've added", prompt: 'IMPORT | PLAY | SHARE | EXPORT', help: 'ARROWS CHOOSE ENTER SELECT ESC BACK',271- draw, view, refresh, move, handleInput, hit, activate, copyDetails, showErrors,301+ draw, view, refresh, move, handleInput, hit, activate, copyDetails, showErrors, showWarnings,272302 get selectedRow() { return rowIndex; }, get selectedAction() { return actionIndex; },273303 get hitLayout() { return hitLayout ? cloneData(hitLayout) : null; },274304 });
M tests/workshop.test.js +32 -1
@@ -90,11 +90,42 @@ test('validation note shows five errors and copy details can be injected', async
9090 const view = scene.view();9191 assert.deepEqual(view.note.visible, ['one', 'two', 'three', 'four', 'five']);9292 assert.equal(view.note.remaining, 2);93- assert.equal(await scene.copyDetails(), true);93+ assert.equal(await scene.handleInput('c'), true);9494 assert.equal(copied, 'one\nONE detail\ntwo\nthree\nfour\nfive\nsix\nseven');9595 assert.equal(scene.view().feedback.message, 'DETAILS COPIED');9696 });9797 98+test('warning notes add an amber Workshop chip and NOTES action', async () => {99+ let copied = '';100+ const pack = {101+ ...makePack('clockwork'),102+ warnings: [103+ { word: 'SHUT', levelId: 'door', levelTitle: 'Key and door', message: 'Proof 1, Key and door: SHUT is not a rule word here.', detail: 'levels[0].objects[2].text' },104+ { word: 'OPEN', levelId: 'door', levelTitle: 'Key and door', message: 'Proof 1, Key and door: OPEN is not a rule word here.', detail: 'levels[0].objects[3].text' },105+ ],106+ };107+ const scene = createWorkshopScene({ packs: [pack], copyToClipboard: async value => { copied = value; } });108+ scene.handleInput('down');109+ assert.deepEqual(scene.view().rows[1].actions, ['play', 'notes', 'share', 'export', 'remove']);110+ assert.equal(scene.view().rows[1].notes, 2);111+ scene.handleInput('right');112+ const result = await scene.handleInput('enter');113+ assert.equal(result.event.type, 'notes');114+ assert.equal(scene.view().note.kind, 'warning');115+ assert.deepEqual(scene.view().note.visible, [pack.warnings[0].message, pack.warnings[1].message]);116+ scene.draw(canvasContext(), { width: 800, height: 600 });117+ const labels = scene.hitLayout.rows.flatMap(row => row.actions.map(action => action.action));118+ assert.ok(labels.includes('notes'));119+ scene.draw(canvasContext(), { width: 390, height: 844 });120+ const mobileRow = scene.hitLayout.rows.find(row => row.index === 1);121+ assert.ok(mobileRow.actions.every(action => action.x >= 24 && action.x + action.width <= 366));122+ assert.ok(mobileRow.actions.every(action => action.height >= 44));123+ assert.equal(await scene.handleInput('c'), true);124+ assert.match(copied, /SHUT/);125+ scene.handleInput('escape');126+ assert.equal(scene.view().note, null);127+});128+98129 test('service envelopes show structured errors and do not trigger failed callbacks', async () => {99130 const shared = []; const exported = [];100131 const scene = createWorkshopScene({