- commit
- b02ae7f2311941d46eae464fd505f2f83af4b5a6
- parent
- d8d3e317ed99ceee19b086b74dfeb8eef4d2db28
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 58 | +++++++++++++++++++++++------- |
| M | tests/ | 33 | +++++++++++++++++- |
@@ -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 });@@ -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({