- commit
- d8d3e317ed99ceee19b086b74dfeb8eef4d2db28
- parent
- 3e75be9f5229c1deb89955feaf5dc2d7c1eb37f8
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 24 | ++++++- |
| M | src/ | 104 | +++++++++++++++++++++++++++--- |
| M | tests/ | 63 | ++++++++++++++++++ |
@@ -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');@@ -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 @@ -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+});