clone

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

Show nonblocking rule notes in Gift previews

Dami committed d8d3e31
commit
d8d3e317ed99ceee19b086b74dfeb8eef4d2db28
parent
3e75be9f5229c1deb89955feaf5dc2d7c1eb37f8
author
Dami <dami.etoile@gmail.com>
3 files changed, +179 -12
Msrc/main.js24++++++-
Msrc/scenes/gift.js104+++++++++++++++++++++++++++---
Mtests/gift.test.js63++++++++++++++++++
M src/main.js +21 -3
@@ -55,7 +55,14 @@ const progress = createProgressStore();
5555 const titleController = createTitleScene({ onSolved: () => stage.show('index'), onSkip: () => stage.show('index'), onWorkshop: () => stage.show('workshop', { origin: 'title' }) });5656 const stage = createStage({ levels: () => currentLevels(), title: titleController, onSelectLevel: id => { const ok = loadLevel(currentLevels().findIndex(level => level.id === id)); if (ok) scheduleInitialWin(); return ok; }, onConfirm: scene => { if (scene === 'gift') giftController.input('confirm'); } });5757 let giftOrigin = 'url';58-const giftController = createGiftScene({ onAction: result => { if (result.action === 'keep' || result.action === 'replace' || result.action === 'copy') { const installed=packService.installPack(result.pack,{origin:giftOrigin,...(result.install||{})}); if(installed.ok){ showModStatus(`Pack installed / ${installed.pack.name}`); const first = currentLevels().findIndex(level => level.packId === installed.pack.id); if (first >= 0) loadLevel(first); refreshIndex(currentLevels()[first]?.id); workshopController.refresh(); stage.show('index'); } else {workshopController.showErrors(installed.errors);stage.show('workshop');} } } });58+const giftController = createGiftScene({59+ copyToClipboard: text => {60+ const pending = navigator.clipboard?.writeText(text);61+ if (pending?.then) pending.then(() => showModStatus('NOTE DETAILS COPIED')).catch(error => showModStatus('Could not copy note details', error.message));62+ return pending;63+ },64+ onAction: result => { if (result.action === 'keep' || result.action === 'replace' || result.action === 'copy') { const installed=packService.installPack(result.pack,{origin:giftOrigin,...(result.install||{})}); if(installed.ok){ showModStatus(`Pack installed / ${installed.pack.name}`); const first = currentLevels().findIndex(level => level.packId === installed.pack.id); if (first >= 0) loadLevel(first); refreshIndex(currentLevels()[first]?.id); workshopController.refresh(); stage.show('index'); } else {workshopController.showErrors(installed.errors);stage.show('workshop');} } }65+});5966 const workshopController = createWorkshopScene({ service: packService, onBack: () => stage.closeWorkshop(), onImport: () => document.querySelector('#mod-input').click(), onPlay: packId => { const first=currentLevels().find(level=>level.packId===packId); refreshIndex(first?.id); stage.show('index'); }, onRemove: () => { refreshIndex(); if (!catalog.getLevel(catalog.activeLevelId)) loadLevel(0); }, onShare: (_id,url) => { navigator.clipboard?.writeText(url).then(() => showModStatus('LINK IS COPIED')).catch(() => showModStatus('Share URL ready / copy it manually',url)); }, onExport: (id,blob) => { const href=URL.createObjectURL(blob); const a=document.createElement('a');a.href=href;a.download=`${id}.cjs`;a.click();setTimeout(()=>URL.revokeObjectURL(href),0); }, copyToClipboard: text => navigator.clipboard?.writeText(text) });6067 stage.setControllers({ title: titleController, gift: giftController, workshop: workshopController });6168 let soundOn = !audio.isMuted();
@@ -261,7 +268,7 @@ soundButton.addEventListener('click', () => { soundOn = !soundOn; audio.mute(!so
261268 stage.setActions({ onWaitAction: waitTurn, onPauseAction: action => { if(action==='restart') restart(); if(action==='sound') soundButton.click(); if(action==='music'){audio.setMusicEnabled(!audio.isMusicEnabled());stage.setPauseStatus?.({soundOn,musicOn:audio.isMusicEnabled()});} } });262269 stage.setPauseStatus?.({soundOn,musicOn:audio.isMusicEnabled()});263270 function reportPackError(error) { const errors=Array.isArray(error?.errors)?error.errors:[{message:error?.message||String(error),detail:error?.message||String(error)}];workshopController.showErrors(errors);stage.show('workshop');showModStatus(`Invalid pack: ${errors[0]?.message||'Unknown error'}`,errorText(errors)); }264-function previewLocalText(source, filename) { const parsed=packService.importPackText(source, filename); if(!parsed.ok){const error=new Error(errorText(parsed.errors));error.errors=parsed.errors;throw error;} giftOrigin='local'; giftController.show(parsed.pack,{collision:parsed.conflict}); stage.show('gift'); return parsed.pack.id; }271+function previewLocalText(source, filename) { const parsed=packService.importPackText(source, filename); if(!parsed.ok){const error=new Error(errorText(parsed.errors));error.errors=parsed.errors;throw error;} giftOrigin='local'; giftController.show(parsed.pack,{collision:parsed.conflict,warnings:parsed.warnings}); stage.show('gift'); return parsed.pack.id; }265272 document.querySelector('#mod-input').addEventListener('change',async event=>{const file=event.target.files?.[0]; if(!file) return; try { previewLocalText(await file.text(),file.name); } catch(error) { reportPackError(error); } finally { event.target.value=''; }});266273 window.addEventListener('dragover', event => { event.preventDefault(); document.body.classList.add('pack-drop'); });267274 window.addEventListener('dragleave', event => { if (event.clientX <= 0 || event.clientY <= 0 || event.clientX >= innerWidth || event.clientY >= innerHeight) document.body.classList.remove('pack-drop'); });
@@ -271,6 +278,17 @@ function makeShareUrl() { const shared = packService.packLink(activePackId()); i
271278 exportButton?.addEventListener('click', downloadPack);272279 copyShareButton?.addEventListener('click', async () => { let value; try { value = makeShareUrl(); shareUrlInput.value = value; } catch (error) { showModStatus(error instanceof RangeError ? 'Share URL too large / export .cjs instead' : 'Could not create share URL', error.message); return; } try { await navigator.clipboard.writeText(value); showModStatus('Share URL copied'); } catch (error) { showModStatus('Share URL ready / copy it manually', error.message); } });273280 function sharedPayload() { return location.hash.startsWith('#pack=') ? location.hash.slice(6) : ''; }281+document.addEventListener('keydown', event => {282+ if (event.target?.matches?.('input,textarea,select,[contenteditable]')) return;283+ const key = event.key.toLowerCase();284+ if (stage.scene === 'gift' && (key === 'i' || key === 'c')) {285+ const result = giftController.input(key);286+ if (result.ok) { event.preventDefault(); stage.render(); }287+ } else if (stage.scene === 'workshop' && key === 'c' && workshopController.view().note) {288+ event.preventDefault();289+ Promise.resolve(workshopController.handleInput('copy')).finally(() => stage.render());290+ }291+});274292 window.render_game_to_text=()=>JSON.stringify({coordinateSystem:'origin top-left; x right, y down',level:lastState?.title||'',mode:lastState?.won?'won':'play',moves:lastState?.moves||0,grid:{width:lastState?.width||0,height:lastState?.height||0},rules:lastState?.rules||[],automata:lastState?.automata||[],objects:lastState?.objects?.map(({id,kind,noun,text,x,y})=>({id,kind,noun,text,x,y}))||[]});275293 window.advanceTime=ms=>{render();return ms};276294 const input = createInput({ onIntent: handleIntent });
@@ -279,4 +297,4 @@ input.attach({ stage: stage.canvas });
279297 // self-hosted face instead of relying on CSS font-display to repaint it.280298 await document.fonts.load('800 20px Recursive');281299 await document.fonts.load('600 20px Recursive');282-refreshIndex(); loadLevel(0); const pending=packService.decodePackLink(location.hash); if(sharedPayload() && pending.ok){ giftOrigin='url'; giftController.show(pending.pack,{collision:pending.conflict}); stage.show('gift',{origin:'title'}); } else if(sharedPayload() && !pending.ok){ reportPackError({errors:pending.errors}); } else stage.show('title');300+refreshIndex(); loadLevel(0); const pending=packService.decodePackLink(location.hash); if(sharedPayload() && pending.ok){ giftOrigin='url'; giftController.show(pending.pack,{collision:pending.conflict,warnings:pending.warnings}); stage.show('gift',{origin:'title'}); } else if(sharedPayload() && !pending.ok){ reportPackError({errors:pending.errors}); } else stage.show('title');
M src/scenes/gift.js +95 -9
@@ -32,19 +32,44 @@ function fitLabel(ctx, value, maxWidth, size = 16, weight = 800) {
3232 while (valueText.length > 1 && ctx.measureText(`${valueText.join('')}…`).width > maxWidth) valueText.pop();3333 return { value: `${valueText.join('')}…`, size: current };3434 }35+function wrapText(ctx, value, maxWidth, size = 9, weight = 650) {36+ const words = String(value).split(/\s+/).filter(Boolean);37+ const lines = [];38+ let line = '';39+ ctx.font = `${weight} ${size}px ${FONT}`;40+ for (const word of words) {41+ const candidate = line ? `${line} ${word}` : word;42+ if (line && ctx.measureText(candidate).width > maxWidth) {43+ lines.push(line);44+ line = word;45+ } else line = candidate;46+ }47+ if (line) lines.push(line);48+ return lines.length ? lines : [''];49+}3550 3651 /** Canvas-only Gift controller. It never installs a pack itself. */37-export function createGiftScene({ pack = null, collision = false, reducedMotion = false, onAction } = {}) {52+export function createGiftScene({ pack = null, collision = false, warnings: initialWarnings = [], reducedMotion = false, onAction, copyToClipboard } = {}) {3853 let preview = pack ? cloneData(pack) : null;3954 let collides = collision ? (typeof collision === 'object' ? cloneData(collision) : { id: '', name: 'this pack' }) : null;55+ let warnings = Array.isArray(initialWarnings) ? cloneData(initialWarnings) : [];4056 let mode = 'prompt';4157 let selected = 0;4258 let open = Boolean(pack);59+ let detailsOpen = false;4360 let buttons = [];61+ let hintButtons = [];4462 const primaryChoices = () => ['keep', 'later'];4563 const collisionChoices = () => ['replace', 'copy', 'later'];4664 const choices = () => mode === 'collision' ? collisionChoices() : primaryChoices();47- function setPack(value, options = {}) { const collisionValue = options.collision ?? collision; preview = value ? cloneData(value) : null; collides = collisionValue ? (typeof collisionValue === 'object' ? cloneData(collisionValue) : { id: '', name: 'this pack' }) : null; mode = 'prompt'; selected = 0; open = Boolean(value); buttons = []; return api; }65+ function setPack(value, options = {}) {66+ const collisionValue = options.collision ?? collision;67+ preview = value ? cloneData(value) : null;68+ collides = collisionValue ? (typeof collisionValue === 'object' ? cloneData(collisionValue) : { id: '', name: 'this pack' }) : null;69+ warnings = Array.isArray(options.warnings) ? cloneData(options.warnings) : [];70+ mode = 'prompt'; selected = 0; detailsOpen = false; open = Boolean(value); buttons = [];71+ return api;72+ }4873 function show(value = preview, options = {}) { return setPack(value, options); }4974 function hide() { open = false; buttons = []; return api; }5075 function choose(action = choices()[selected]) {
@@ -53,24 +78,48 @@ export function createGiftScene({ pack = null, collision = false, reducedMotion
5378 const result = { ok: true, action, pack: cloneData(preview), install: action === 'keep' ? {} : action === 'replace' ? { replace: true } : action === 'copy' ? { keepBoth: true } : null };5479 hide(); onAction?.(result); return result;5580 }81+ function toggleDetails() { if (!warnings.length) return false; detailsOpen = !detailsOpen; return detailsOpen; }82+ function technicalNotes() {83+ return warnings.map(warning => [warning.word, warning.levelId, warning.sentence, warning.detail].filter(Boolean).join(' | ')).join('\n');84+ }85+ function copyNotes() {86+ if (!warnings.length || typeof copyToClipboard !== 'function') return { ok: false, action: 'copy' };87+ const textToCopy = technicalNotes();88+ const result = copyToClipboard(textToCopy);89+ return { ok: true, action: 'copy', text: textToCopy, copied: result };90+ }5691 function move(direction) { const values = choices(); if (direction === 'up' || direction === 'left') selected = (selected - 1 + values.length) % values.length; if (direction === 'down' || direction === 'right') selected = (selected + 1) % values.length; return values[selected]; }5792 function input(action) {5893 if (!open) return { ok: false, action: null };94+ if (action === 'details' || action === 'info' || action === 'i') return { ok: true, action: 'details', open: toggleDetails() };95+ if (action === 'copy' || action === 'c') return copyNotes();5996 if (action === 'up' || action === 'down' || action === 'left' || action === 'right') return { ok: true, selected: move(action) };6097 if (action === 'confirm' || action === 'enter') return choose();6198 if (action === 'back' || action === 'escape' || action === 'cancel') return choose('later');6299 return { ok: false, action: null };63100 }64- function hit(x, y) { const button = buttons.find(item => x >= item.x && x <= item.x + item.width && y >= item.y && y <= item.y + item.height); if (!button) return { ok: false, action: null }; selected = button.index; return choose(button.action); }101+ function hit(x, y) {102+ const button = buttons.find(item => x >= item.x && x <= item.x + item.width && y >= item.y && y <= item.y + item.height);103+ if (button) { selected = button.index; return choose(button.action); }104+ const hint = hintButtons.find(item => x >= item.x && x <= item.x + item.width && y >= item.y && y <= item.y + item.height);105+ if (!hint) return { ok: false, action: null };106+ return input(hint.action);107+ }65108 function draw(ctx, now = 0, options = {}) {66109 if (!open || !preview || !ctx) return null;67110 const width = options.width || ctx.canvas?.width || 800;68111 const height = options.height || ctx.canvas?.height || 600;69112 const narrow = width < 760;70113 const cardWidth = narrow ? Math.max(0, width - 24) : Math.min(760, width - 48);71- const cardHeight = narrow ? Math.min(620, height - 24) : Math.min(560, height - 48);114+ const cardHeight = narrow ? Math.min(detailsOpen ? 760 : 620, height - 24) : Math.min(detailsOpen ? 680 : 560, height - 48);72115 const left = (width - cardWidth) / 2; const top = (height - cardHeight) / 2;73116 const title = packName(preview); const author = packAuthor(preview); const levels = levelList(preview); const selectedAction = choices()[selected];117+ const warningLines = detailsOpen ? warnings.slice(0, 5).flatMap(warning => {118+ const levelIndex = levels.findIndex(level => level.id === warning.levelId);119+ const proof = levelIndex >= 0 ? `Proof ${levelIndex + 1}` : 'Proof';120+ const titleText = warning.levelTitle || levels[levelIndex]?.title || warning.levelId || 'Untitled';121+ return wrapText(ctx, `${proof} · ${titleText} · ${String(warning.word || '').toUpperCase()}: ${warning.message || warning.sentence || 'may not behave as a rule.'}`, cardWidth - 68, 9, 650);122+ }) : [];74123 ctx.save(); ctx.fillStyle = 'rgba(0,0,0,.78)'; ctx.fillRect(0, 0, width, height);75124 ctx.fillStyle = COLORS.lip; rounded(ctx, left + 8, top + 8, cardWidth, cardHeight, 5); ctx.fill(); ctx.fillStyle = COLORS.board; rounded(ctx, left, top, cardWidth, cardHeight, 5); ctx.fill(); ctx.strokeStyle = COLORS.edge; ctx.lineWidth = 1; rounded(ctx, left, top, cardWidth, cardHeight, 5); ctx.stroke();76125 text(ctx, mode === 'collision' ? 'ALREADY HERE' : 'A PACK FOR YOU', left + 32, top + 38, { size: 12, weight: 800, color: COLORS.accent });
@@ -82,6 +131,42 @@ export function createGiftScene({ pack = null, collision = false, reducedMotion
82131 } else {83132 const metadata = fitLabel(ctx, `${levels.length} ${levels.length === 1 ? 'proof' : 'proofs'}${author ? ` | by ${author}` : ''}`, cardWidth - 68, 11, 700);84133 text(ctx, metadata.value, left + 34, top + 112, { size: metadata.size, weight: 700, color: COLORS.muted });134+ if (warnings.length) {135+ const names = [...new Set(warnings.map(warning => String(warning.word || '').toUpperCase()).filter(Boolean))];136+ const noteSummary = `${names.length} ${names.length === 1 ? 'word' : 'words'} may not work as rules: ${names.join(', ')}`;137+ const noteX = left + 34;138+ const noteY = top + 122;139+ const noteWidth = 42;140+ const noteHeight = 22;141+ const noteChamfer = 5;142+ ctx.save();143+ ctx.fillStyle = COLORS.accent;144+ ctx.beginPath();145+ ctx.moveTo(noteX, noteY);146+ ctx.lineTo(noteX + noteWidth - noteChamfer, noteY);147+ ctx.lineTo(noteX + noteWidth, noteY + noteChamfer);148+ ctx.lineTo(noteX + noteWidth, noteY + noteHeight);149+ ctx.lineTo(noteX, noteY + noteHeight);150+ ctx.closePath();151+ ctx.fill();152+ text(ctx, 'NOTE', noteX + noteWidth / 2, noteY + noteHeight / 2, { size: 9, weight: 850, color: COLORS.ink, align: 'center' });153+ ctx.restore();154+ const noteLabel = fitLabel(ctx, noteSummary, cardWidth - 68 - noteWidth - 8, 10, 800);155+ text(ctx, noteLabel.value, noteX + noteWidth + 8, top + 132, { size: noteLabel.size, weight: 800, color: COLORS.accent });156+ if (detailsOpen) {157+ const detailTop = top + 157;158+ const visible = warnings.slice(0, 5);159+ let lineIndex = 0;160+ visible.forEach(warning => {161+ const levelIndex = levels.findIndex(level => level.id === warning.levelId);162+ const proof = levelIndex >= 0 ? `Proof ${levelIndex + 1}` : 'Proof';163+ const titleText = warning.levelTitle || levels[levelIndex]?.title || warning.levelId || 'Untitled';164+ const lines = wrapText(ctx, `${proof} · ${titleText} · ${String(warning.word || '').toUpperCase()}: ${warning.message || warning.sentence || 'may not behave as a rule.'}`, cardWidth - 68, 9, 650);165+ lines.forEach(line => { text(ctx, line, left + 34, detailTop + lineIndex++ * 16, { size: 9, weight: 650, color: COLORS.muted }); });166+ });167+ if (warnings.length > visible.length) text(ctx, `and ${warnings.length - visible.length} more`, left + 34, detailTop + lineIndex++ * 16, { size: 9, weight: 700, color: COLORS.muted });168+ }169+ }85170 }86171 const labels = mode === 'collision' ? [['replace', 'REPLACE'], ['copy', 'KEEP BOTH AS A COPY'], ['later', 'CANCEL']] : [['keep', 'KEEP AND PLAY'], ['later', 'NOT NOW']];87172 const buttonHeight = 44; const buttonGap = 10; const buttonWidth = narrow ? Math.max(0, cardWidth - 48) : Math.min(260, cardWidth - 64);
@@ -90,7 +175,7 @@ export function createGiftScene({ pack = null, collision = false, reducedMotion
90175 const buttonLeft = narrow ? left + 24 : left + cardWidth - buttonWidth - 32;91176 const first = levels[0];92177 const previewX = narrow ? left + 24 : left + 32;93- const previewY = top + 140;178+ const previewY = top + (warnings.length ? (detailsOpen ? 175 + warningLines.length * 16 : 158) : 140);94179 const actionColumnLeft = narrow ? previewX : buttonLeft;95180 const previewWidth = narrow ? Math.max(0, cardWidth - 48) : Math.max(0, Math.min(400, actionColumnLeft - previewX - 32));96181 const previewHeight = narrow ? Math.max(0, Math.min(220, buttonTop - previewY - 20)) : Math.max(0, Math.min(cardHeight * 0.48, buttonTop - previewY - 20));
@@ -112,8 +197,9 @@ export function createGiftScene({ pack = null, collision = false, reducedMotion
112197 text(ctx, proofTitle.value, previewX, Math.min(buttonTop - 10, previewY + previewHeight + 18), { size: proofTitle.size, weight: 800 });113198 }114199 buttons = [];200+ hintButtons = [];115201 labels.forEach(([action, label], index) => { const y = buttonTop + index * (buttonHeight + buttonGap); const active = selectedAction === action; buttons.push({ action, index, x: buttonLeft, y, width: buttonWidth, height: buttonHeight }); ctx.save(); ctx.fillStyle = COLORS.lip; rounded(ctx, buttonLeft + 4, y + 4, buttonWidth, buttonHeight, 4); ctx.fill(); ctx.fillStyle = active ? (action === 'replace' ? COLORS.danger : COLORS.accent) : COLORS.board; rounded(ctx, buttonLeft, y, buttonWidth, buttonHeight, 4); ctx.fill(); ctx.strokeStyle = active ? COLORS.fg : COLORS.edge; ctx.lineWidth = active ? 2 : 1; rounded(ctx, buttonLeft, y, buttonWidth, buttonHeight, 4); ctx.stroke(); text(ctx, label, buttonLeft + buttonWidth / 2, y + buttonHeight / 2, { size: 12, weight: 850, color: active ? COLORS.ink : COLORS.fg, align: 'center' }); ctx.restore(); });116- const hints = [['↑ ↓', 'choose'], ['ENTER', 'select'], ['ESC', 'not now']];202+ const hints = warnings.length ? [['↑ ↓', 'choose'], ['I', detailsOpen ? 'close notes' : 'details'], ['C', 'copy notes'], ['ENTER', 'select'], ['ESC', 'not now']] : [['↑ ↓', 'choose'], ['ENTER', 'select'], ['ESC', 'not now']];117203 const hintSize = narrow ? 8 : 10; const hintLabelSize = narrow ? 8 : 10; const hintGap = narrow ? 7 : 16;118204 const hintMetrics = hints.map(([key, label]) => {119205 ctx.font = `800 ${hintSize}px ${FONT}`;
@@ -125,10 +211,10 @@ export function createGiftScene({ pack = null, collision = false, reducedMotion
125211 const hintTotal = hintMetrics.reduce((sum, item) => sum + item.width, 0) + hintGap * (hintMetrics.length - 1);126212 const hintStart = left + Math.max(12, (cardWidth - hintTotal) / 2);127213 let hintX = hintStart;128- hintMetrics.forEach(({ key, label, labelSize, keyWidth, width: itemWidth }) => { ctx.save(); ctx.fillStyle = COLORS.board; rounded(ctx, hintX, top + cardHeight - 35, keyWidth, 20, 3); ctx.fill(); ctx.strokeStyle = COLORS.edge; ctx.lineWidth = 1; rounded(ctx, hintX, top + cardHeight - 35, keyWidth, 20, 3); ctx.stroke(); text(ctx, key, hintX + keyWidth / 2, top + cardHeight - 25, { size: hintSize - 1, weight: 850, color: COLORS.fg, align: 'center' }); text(ctx, label, hintX + keyWidth + 6, top + cardHeight - 25, { size: labelSize, weight: 700, color: COLORS.muted }); hintX += itemWidth + hintGap; }); ctx.restore();129- return { x: left, y: top, width: cardWidth, height: cardHeight, narrow, preview: { x: previewX, y: previewY, width: previewWidth, height: previewHeight }, actions: buttons.map(button => ({ ...button })), hints: { x: hintStart, width: hintTotal }, hintY: top + cardHeight - 25, selected: selectedAction, now };214+ hintMetrics.forEach(({ key, label, labelSize, keyWidth, width: itemWidth }, index) => { const hintAction = warnings.length ? [['noop'], ['details'], ['copy'], ['noop'], ['noop']][index]?.[0] : 'noop'; if (hintAction !== 'noop') hintButtons.push({ action: hintAction, x: hintX - 4, y: top + cardHeight - 39, width: keyWidth + 8, height: 28 }); ctx.save(); ctx.fillStyle = COLORS.board; rounded(ctx, hintX, top + cardHeight - 35, keyWidth, 20, 3); ctx.fill(); ctx.strokeStyle = COLORS.edge; ctx.lineWidth = 1; rounded(ctx, hintX, top + cardHeight - 35, keyWidth, 20, 3); ctx.stroke(); text(ctx, key, hintX + keyWidth / 2, top + cardHeight - 25, { size: hintSize - 1, weight: 850, color: COLORS.fg, align: 'center' }); text(ctx, label, hintX + keyWidth + 6, top + cardHeight - 25, { size: labelSize, weight: 700, color: COLORS.muted }); hintX += itemWidth + hintGap; }); ctx.restore();215+ return { x: left, y: top, width: cardWidth, height: cardHeight, narrow, preview: { x: previewX, y: previewY, width: previewWidth, height: previewHeight }, actions: buttons.map(button => ({ ...button })), hintActions: hintButtons.map(button => ({ ...button })), hints: { x: hintStart, width: hintTotal }, hintY: top + cardHeight - 25, selected: selectedAction, now };130216 }131- const api = { show, hide, setPack, choose, input, hit, draw, get open() { return open; }, get mode() { return mode; }, get selected() { return choices()[selected]; }, get pack() { return preview ? cloneData(preview) : null; }, get collision() { return collides ? cloneData(collides) : null; } };217+ const api = { show, hide, setPack, choose, input, hit, draw, get open() { return open; }, get mode() { return mode; }, get selected() { return choices()[selected]; }, get pack() { return preview ? cloneData(preview) : null; }, get collision() { return collides ? cloneData(collides) : null; }, get warnings() { return cloneData(warnings); }, get detailsOpen() { return detailsOpen; } };132218 return Object.freeze(api);133219 }134220
M tests/gift.test.js +63 -0
@@ -110,3 +110,66 @@ test('long Gift copy is ellipsized and the narrow footer stays inside the card',
110110 assert.ok(layout.hints.x >= layout.x + 8);111111 assert.ok(layout.hints.x + layout.hints.width <= layout.x + layout.width - 8);112112 });113+114+test('Gift keeps warning notes nonblocking, expands player details, and copies technical context', () => {115+ let copied = '';116+ const warnings = [{117+ word: 'SHUT', levelId: 'intro', levelTitle: 'Read the board',118+ sentence: 'DOOR IS SHUT', message: 'SHUT has no behaviour in this proof.', detail: 'levels[0].objects[3]'119+ }];120+ const scene = createGiftScene({ pack, warnings, copyToClipboard: value => { copied = value; } });121+ assert.deepEqual(scene.warnings, warnings);122+ assert.equal(scene.detailsOpen, false);123+ assert.deepEqual(scene.input('i'), { ok: true, action: 'details', open: true });124+ assert.equal(scene.detailsOpen, true);125+ assert.deepEqual(scene.input('c'), { ok: true, action: 'copy', text: 'SHUT | intro | DOOR IS SHUT | levels[0].objects[3]', copied: undefined });126+ assert.equal(copied, 'SHUT | intro | DOOR IS SHUT | levels[0].objects[3]');127+ assert.equal(scene.input('enter').action, 'keep');128+});129+130+test('Gift warning details render an amber NOTE and plain language proof line', () => {131+ const labels = [];132+ const fills = [];133+ const scene = createGiftScene({ pack, warnings: [{134+ word: 'SHUT', levelId: 'intro', levelTitle: 'Read the board',135+ sentence: 'DOOR IS SHUT', message: 'SHUT has no behaviour in this proof.'136+ }] });137+ const ctx = {138+ canvas: { width: 800, height: 600 }, save() {}, restore() {}, beginPath() {}, roundRect() {}, rect() {}, fill() { fills.push(this.fillStyle); }, stroke() {}, arc() {}, fillRect() {}, moveTo() {}, lineTo() {}, closePath() {}, clip() {}, ellipse() {},139+ fillText(value) { labels.push(String(value)); }, measureText(value) { return { width: String(value).length * 8 }; },140+ };141+ scene.input('details');142+ scene.draw(ctx, 0, { width: 800, height: 600 });143+ assert.ok(labels.some(value => value.includes('NOTE')));144+ assert.ok(labels.some(value => value.includes('1 word may not work as rules')));145+ assert.ok(fills.includes('#FFB020'));146+ assert.ok(labels.some(value => value.includes('SHUT has no behaviour')));147+});148+149+test('Gift note keycaps are tappable on mobile and details stay above the preview', () => {150+ let copied = '';151+ const warnings = Array.from({ length: 5 }, (_, index) => ({152+ word: `WORD${index}`, levelId: 'intro', levelTitle: 'Read the board',153+ sentence: `DOOR IS WORD${index}`,154+ message: `WORD${index} has no behaviour in this proof and will not change the door.`,155+ detail: `levels[0].objects[${index}]`,156+ }));157+ const scene = createGiftScene({ pack, warnings, copyToClipboard: value => { copied = value; } });158+ let layout = scene.draw({159+ canvas: { width: 390, height: 844 }, save() {}, restore() {}, beginPath() {}, roundRect() {}, rect() {}, fill() {}, stroke() {}, arc() {}, fillRect() {}, moveTo() {}, lineTo() {}, closePath() {}, clip() {}, ellipse() {},160+ fillText() {}, measureText(value) { return { width: String(value).length * 5 }; },161+ }, 0, { width: 390, height: 844 });162+ assert.equal(layout.hintActions.length, 2);163+ const detailsKey = layout.hintActions.find(item => item.action === 'details');164+ const copyKey = layout.hintActions.find(item => item.action === 'copy');165+ assert.ok(scene.hit(detailsKey.x + detailsKey.width / 2, detailsKey.y + detailsKey.height / 2).open);166+ layout = scene.draw({167+ canvas: { width: 390, height: 844 }, save() {}, restore() {}, beginPath() {}, roundRect() {}, rect() {}, fill() {}, stroke() {}, arc() {}, fillRect() {}, moveTo() {}, lineTo() {}, closePath() {}, clip() {}, ellipse() {},168+ fillText() {}, measureText(value) { return { width: String(value).length * 5 }; },169+ }, 0, { width: 390, height: 844 });170+ assert.ok(layout.preview.y > layout.y);171+ assert.ok(layout.preview.y + layout.preview.height <= layout.actions[0].y);172+ const expandedCopyKey = layout.hintActions.find(item => item.action === 'copy');173+ assert.ok(scene.hit(expandedCopyKey.x + expandedCopyKey.width / 2, expandedCopyKey.y + expandedCopyKey.height / 2).ok);174+ assert.match(copied, /WORD0/);175+});