1import { drawBoard } from '../render/board.js';2import { createGame } from '../engine.js';3import { playableLevelFromView } from '../level-view.js';4import { cloneData } from '../data/clone.js';5import { warningLines } from './warning-lines.js';6import { actionMatches, keycapPressFrame } from '../render/motion.js';78const FONT = 'Recursive, ui-monospace, monospace';9const COLORS = Object.freeze({10 stage: '#0F0E0D', board: '#191816', edge: '#2E2B28', lip: '#000000',11 tile: '#F2EEE6', ink: '#0F0E0D', fg: '#F2EEE6', muted: '#8E877C',12 YOU: '#FF4B3E', WIN: '#2EE6A8', accent: '#FFB020', danger: '#FF4FA3',13});1415const manifestOf = pack => pack?.manifest || pack || {};16const levelList = pack => Array.isArray(pack?.levels) ? pack.levels : [];17const packName = pack => manifestOf(pack).name || pack?.name || 'Untitled pack';18const packAuthor = pack => manifestOf(pack).author || pack?.author || '';19const rounded = (ctx, x, y, width, height, radius = 4) => { ctx.beginPath(); if (typeof ctx.roundRect === 'function') ctx.roundRect(x, y, width, height, radius); else ctx.rect(x, y, width, height); };20function text(ctx, value, x, y, options = {}) {21 const { size = 16, weight = 700, color = COLORS.fg, align = 'left', alpha = 1, baseline = 'middle' } = options;22 ctx.save(); ctx.globalAlpha *= alpha; ctx.font = `${weight} ${size}px ${FONT}`; ctx.fillStyle = color; ctx.textAlign = align; ctx.textBaseline = baseline; ctx.fillText(String(value), x, y); ctx.restore();23}24function fitLabel(ctx, value, maxWidth, size = 16, weight = 800) {25 const source = String(value);26 let current = size;27 while (current > 9) {28 ctx.font = `${weight} ${current}px ${FONT}`;29 if (ctx.measureText(source).width <= maxWidth) return { value: source, size: current };30 current -= 1;31 }32 ctx.font = `${weight} ${current}px ${FONT}`;33 if (ctx.measureText(source).width <= maxWidth) return { value: source, size: current };34 let valueText = [...source];35 while (valueText.length > 1 && ctx.measureText(`${valueText.join('')}…`).width > maxWidth) valueText.pop();36 return { value: `${valueText.join('')}…`, size: current };37}38function wrapText(ctx, value, maxWidth, size = 9, weight = 650) {39 const words = String(value).split(/\s+/).filter(Boolean);40 const lines = [];41 let line = '';42 ctx.font = `${weight} ${size}px ${FONT}`;43 for (const word of words) {44 const candidate = line ? `${line} ${word}` : word;45 if (line && ctx.measureText(candidate).width > maxWidth) {46 lines.push(line);47 line = word;48 } else line = candidate;49 }50 if (line) lines.push(line);51 return lines.length ? lines : [''];52}5354function clampMotion(value) { return Math.max(0, Math.min(1, Number(value) || 0)); }55function easeOutCubic(value) {56 const t = clampMotion(value);57 return 1 - ((1 - t) ** 3);58}59function easeOutBack(value) {60 const t = clampMotion(value);61 const overshoot = 1.70158;62 return 1 + (overshoot + 1) * ((t - 1) ** 3) + overshoot * ((t - 1) ** 2);63}64export function giftMotionFrame(sceneMotion = {}, reducedMotion = false) {65 if (reducedMotion || sceneMotion.phase === 'settled') return { offsetY: 0, alpha: 1 };66 if (sceneMotion.phase === 'exiting') {67 const exit = clampMotion(sceneMotion.exitProgress);68 return { offsetY: 16 * (exit ** 3), alpha: 1 - exit * 0.22 };69 }70 const entry = clampMotion(sceneMotion.entryProgress);71 return { offsetY: -48 * (1 - easeOutBack(entry)), alpha: 0.78 + easeOutCubic(entry) * 0.22 };72}7374/** Canvas-only Gift controller. It never installs a pack itself. */75export function createGiftScene({ pack = null, collision = false, warnings: initialWarnings = [], reducedMotion = false, onAction, copyToClipboard } = {}) {76 let preview = pack ? cloneData(pack) : null;77 let collides = collision ? (typeof collision === 'object' ? cloneData(collision) : { id: '', name: 'this pack' }) : null;78 let warnings = Array.isArray(initialWarnings) ? cloneData(initialWarnings) : [];79 let mode = 'prompt';80 let selected = 0;81 let open = Boolean(pack);82 let detailsOpen = false;83 let buttons = [];84 let hintButtons = [];85 let noteButtons = [];86 const primaryChoices = () => ['keep', 'later'];87 const collisionChoices = () => ['replace', 'copy', 'later'];88 const choices = () => mode === 'collision' ? collisionChoices() : primaryChoices();89 function setPack(value, options = {}) {90 const collisionValue = options.collision ?? collision;91 preview = value ? cloneData(value) : null;92 collides = collisionValue ? (typeof collisionValue === 'object' ? cloneData(collisionValue) : { id: '', name: 'this pack' }) : null;93 warnings = Array.isArray(options.warnings) ? cloneData(options.warnings) : [];94 mode = 'prompt'; selected = 0; detailsOpen = false; open = Boolean(value); buttons = [];95 return api;96 }97 function show(value = preview, options = {}) { return setPack(value, options); }98 function hide() { open = false; buttons = []; return api; }99 function choose(action = choices()[selected]) {100 if (!open || !preview) return { ok: false, action: null };101 if (action === 'keep' && collides) { mode = 'collision'; selected = 0; return { ok: true, action: 'collision' }; }102 const result = { ok: true, action, pack: cloneData(preview), install: action === 'keep' ? {} : action === 'replace' ? { replace: true } : action === 'copy' ? { keepBoth: true } : null };103 hide(); onAction?.(result); return result;104 }105 function toggleDetails() { if (!warnings.length) return false; detailsOpen = !detailsOpen; return detailsOpen; }106 function technicalNotes() {107 return warnings.map(warning => [warning.word, warning.levelId, warning.sentence, warning.detail].filter(Boolean).join(' | ')).join('\n');108 }109 function copyNotes() {110 if (!warnings.length || typeof copyToClipboard !== 'function') return { ok: false, action: 'copy' };111 const textToCopy = technicalNotes();112 const result = copyToClipboard(textToCopy);113 return { ok: true, action: 'copy', text: textToCopy, copied: result };114 }115 function accessibilityText() {116 if (!open || !preview) return 'Gift closed.';117 const levels = levelList(preview);118 const intro = `A pack for you. ${packName(preview)}. ${levels.length} ${levels.length === 1 ? 'proof' : 'proofs'}.`;119 const notes = warnings.length ? ` ${warnings.length} ${warnings.length === 1 ? 'note' : 'notes'}: ${[...new Set(warnings.map(warning => warning.word))].join(', ')}. Press I for details or C to copy.` : '';120 const details = detailsOpen ? ` ${warnings.slice(0, 5).map(warning => `${warning.levelTitle || warning.levelId || 'Proof'}: ${warning.message}`).join(' ')}${warnings.length > 5 ? ` And ${warnings.length - 5} more.` : ''}` : '';121 return `${intro}${notes}${details} ${choices()[selected] === 'keep' ? 'Keep and play selected.' : `${choices()[selected]} selected.`}`;122 }123 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]; }124 function input(action) {125 if (!open) return { ok: false, action: null };126 if (action === 'details' || action === 'info' || action === 'i') return { ok: true, action: 'details', open: toggleDetails() };127 if (action === 'copy' || action === 'c') return copyNotes();128 if (action === 'up' || action === 'down' || action === 'left' || action === 'right') return { ok: true, selected: move(action) };129 if (action === 'confirm' || action === 'enter') return choose();130 if (action === 'back' || action === 'escape' || action === 'cancel') return choose('later');131 return { ok: false, action: null };132 }133 function hit(x, y) {134 const button = buttons.find(item => x >= item.x && x <= item.x + item.width && y >= item.y && y <= item.y + item.height);135 if (button) { selected = button.index; return choose(button.action); }136 const note = noteButtons.find(item => x >= item.x && x <= item.x + item.width && y >= item.y && y <= item.y + item.height);137 if (note) return note.action === 'copy' ? copyNotes() : { ok: true, action: 'details', open: toggleDetails() };138 const hint = hintButtons.find(item => x >= item.x && x <= item.x + item.width && y >= item.y && y <= item.y + item.height);139 if (!hint) return { ok: false, action: null };140 return input(hint.action);141 }142 function draw(ctx, now = 0, options = {}) {143 if (!open || !preview || !ctx) return null;144 const width = options.width || ctx.canvas?.width || 800;145 const height = options.height || ctx.canvas?.height || 600;146 const motion = giftMotionFrame(options.sceneMotion, options.reducedMotion);147 const narrow = width < 760;148 const coarsePointer = options.coarsePointer ?? globalThis.matchMedia?.('(pointer: coarse)').matches ?? false;149 const touchLayout = narrow || coarsePointer;150 const cardWidth = narrow ? Math.max(0, width - 24) : Math.min(760, width - 48);151 const cardHeight = narrow ? Math.min(detailsOpen ? 760 : 620, height - 24) : Math.min(detailsOpen ? 680 : 560, height - 48);152 const left = (width - cardWidth) / 2; const top = (height - cardHeight) / 2;153 const title = packName(preview); const author = packAuthor(preview); const levels = levelList(preview); const selectedAction = choices()[selected];154 const noteLines = warningLines(preview, warnings);155 const displayWarningLines = detailsOpen ? noteLines.slice(0, 5).flatMap(line => wrapText(ctx, line, cardWidth - 68, 9, 650)) : [];156 noteButtons = [];157 ctx.save(); ctx.fillStyle = 'rgba(0,0,0,.78)'; ctx.fillRect(0, 0, width, height);158 const motionContext = typeof ctx.translate === 'function';159 if (motionContext) {160 ctx.save();161 ctx.globalAlpha *= motion.alpha;162 ctx.translate(0, motion.offsetY);163 }164 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();165 text(ctx, mode === 'collision' ? 'ALREADY HERE' : 'A PACK FOR YOU', left + 32, top + 38, { size: 12, weight: 800, color: COLORS.accent });166 const titleLabel = fitLabel(ctx, title, cardWidth - 64, 34, 850);167 text(ctx, titleLabel.value, left + 32, top + 82, { size: titleLabel.size, weight: 850 });168 if (mode === 'collision') {169 const collisionText = fitLabel(ctx, `You have ${collides?.name || title}. Replace it, or keep both as a copy?`, cardWidth - 64, 13, 650);170 text(ctx, collisionText.value, left + 32, top + 112, { size: collisionText.size, weight: 650, color: COLORS.muted });171 } else {172 const metadata = fitLabel(ctx, `${levels.length} ${levels.length === 1 ? 'proof' : 'proofs'}${author ? ` | by ${author}` : ''}`, cardWidth - 68, 11, 700);173 text(ctx, metadata.value, left + 34, top + 112, { size: metadata.size, weight: 700, color: COLORS.muted });174 if (warnings.length) {175 const names = [...new Set(warnings.map(warning => String(warning.word || '').toUpperCase()).filter(Boolean))];176 const noteSummary = `${names.length} ${names.length === 1 ? 'word' : 'words'} may not work as rules: ${names.join(', ')}`;177 const noteX = left + 34;178 const noteY = top + 122;179 const noteWidth = 42;180 const noteHeight = 22;181 const noteChamfer = 5;182 ctx.save();183 ctx.fillStyle = COLORS.accent;184 ctx.beginPath();185 ctx.moveTo(noteX, noteY);186 ctx.lineTo(noteX + noteWidth - noteChamfer, noteY);187 ctx.lineTo(noteX + noteWidth, noteY + noteChamfer);188 ctx.lineTo(noteX + noteWidth, noteY + noteHeight);189 ctx.lineTo(noteX, noteY + noteHeight);190 ctx.closePath();191 ctx.fill();192 text(ctx, 'NOTE', noteX + noteWidth / 2, noteY + noteHeight / 2, { size: 9, weight: 850, color: COLORS.ink, align: 'center' });193 ctx.restore();194 const noteLabel = fitLabel(ctx, noteSummary, cardWidth - 68 - noteWidth - 8, 10, 800);195 text(ctx, noteLabel.value, noteX + noteWidth + 8, top + 132, { size: noteLabel.size, weight: 800, color: COLORS.accent });196 noteButtons = [{ action: 'details', x: noteX - 6, y: noteY - 5, width: Math.min(cardWidth - 56, noteWidth + 8 + ctx.measureText(noteLabel.value).width + 8), height: 32 }];197 if (detailsOpen) {198 const detailTop = top + 157;199 let lineIndex = 0;200 noteLines.slice(0, 5).forEach(line => wrapText(ctx, line, cardWidth - 68, 9, 650).forEach(wrapped => { text(ctx, wrapped, left + 34, detailTop + lineIndex++ * 16, { size: 9, weight: 650, color: COLORS.muted }); }));201 if (noteLines.length > 5) text(ctx, `and ${noteLines.length - 5} more`, left + 34, detailTop + lineIndex++ * 16, { size: 9, weight: 700, color: COLORS.muted });202 const copyY = detailTop + lineIndex * 16 + 3;203 text(ctx, 'COPY', left + 34, copyY, { size: 9, weight: 850, color: COLORS.accent });204 noteButtons.push({ action: 'copy', x: left + 25, y: copyY - 12, width: 55, height: 26 });205 }206 }207 }208 const labels = mode === 'collision' ? [['replace', 'REPLACE'], ['copy', 'KEEP BOTH AS A COPY'], ['later', 'CANCEL']] : [['keep', 'KEEP AND PLAY'], ['later', 'NOT NOW']];209 const buttonHeight = 44; const buttonGap = 10; const buttonWidth = narrow ? Math.max(0, cardWidth - 48) : Math.min(260, cardWidth - 64);210 const buttonTotal = labels.length * buttonHeight + (labels.length - 1) * buttonGap;211 const footerSpace = narrow && warnings.length ? 96 : narrow ? 58 : 72;212 const buttonTop = top + cardHeight - buttonTotal - footerSpace;213 const buttonLeft = narrow ? left + 24 : left + cardWidth - buttonWidth - 32;214 const first = levels[0];215 const previewX = narrow ? left + 24 : left + 32;216 const previewY = top + (warnings.length ? (detailsOpen ? 175 + displayWarningLines.length * 16 : 158) : 140);217 const actionColumnLeft = narrow ? previewX : buttonLeft;218 const previewWidth = narrow ? Math.max(0, cardWidth - 48) : Math.max(0, Math.min(400, actionColumnLeft - previewX - 32));219 const previewHeight = narrow ? Math.max(0, Math.min(220, buttonTop - previewY - 20)) : Math.max(0, Math.min(cardHeight * 0.48, buttonTop - previewY - 20));220 if (first && Number.isInteger(first.width) && Number.isInteger(first.height) && previewWidth > 0 && previewHeight > 0) {221 // Include the slab's 22% pad and hard shadow in the allocated preview222 // rect. This keeps wide authored grids inside the card on mobile.223 const cell = Math.max(1, Math.floor(Math.min((previewWidth - 8) / (first.width + 0.44), (previewHeight - 8) / (first.height + 0.44))));224 const boardX = previewX + cell * 0.22;225 const boardY = previewY + cell * 0.22;226 // The raw pack level has no derived rules. Use the engine snapshot so227 // active words, sentence bars, YOU markers, and the lost-control overlay228 // all match how the proof will actually play.229 const previewGame = createGame(playableLevelFromView(first));230 const previewState = previewGame.getState();231 drawBoard(ctx, previewState, { x: boardX, y: boardY, cell, availableWidth: cell * first.width, availableHeight: cell * first.height, palette: preview.palette || {}, game: previewGame });232 } else text(ctx, 'NO PREVIEW LEVEL', previewX, previewY + 65, { size: 13, weight: 800, color: COLORS.muted });233 if (first?.title && previewHeight > 0) {234 const proofTitle = fitLabel(ctx, first.title, previewWidth, 13, 800);235 text(ctx, proofTitle.value, previewX, Math.min(buttonTop - 10, previewY + previewHeight + 18), { size: proofTitle.size, weight: 800 });236 }237 buttons = [];238 hintButtons = [];239 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(); });240 const hints = warnings.length ? [['↑ ↓', 'choose'], ['I', detailsOpen ? 'close notes' : 'details'], ['C', 'copy notes'], ['ENTER', 'select'], ['ESC', 'not now']] : [['↑ ↓', 'choose'], ['ENTER', 'select'], ['ESC', 'not now']];241 const visibleHints = touchLayout ? [] : hints;242 const hintSize = narrow ? 8 : 10; const hintLabelSize = narrow ? 8 : 10; const hintGap = narrow ? 7 : 16;243 const hintMetrics = visibleHints.map(([key, label]) => {244 ctx.font = `800 ${hintSize}px ${FONT}`;245 const keyWidth = Math.max(29, ctx.measureText(key).width + 12);246 const labelFit = fitLabel(ctx, label, narrow ? 44 : Infinity, hintLabelSize, 700);247 const labelWidth = ctx.measureText(labelFit.value).width;248 return { key, label: labelFit.value, labelSize: labelFit.size, keyWidth, labelWidth, width: keyWidth + 6 + labelWidth };249 });250 const hintRows = hintMetrics.length ? [hintMetrics] : [];251 const rowGap = 6;252 const rowHeight = 20;253 const rowWidths = hintRows.map(row => row.reduce((sum, item) => sum + item.width, 0) + hintGap * Math.max(0, row.length - 1));254 const hintTotal = rowWidths.length ? Math.max(...rowWidths) : 0;255 const hintStart = left + Math.max(12, (cardWidth - hintTotal) / 2);256 const hintBottom = top + cardHeight - 15;257 hintRows.forEach((row, rowIndex) => {258 const rowWidth = rowWidths[rowIndex];259 let hintX = left + Math.max(12, (cardWidth - rowWidth) / 2);260 const hintY = hintBottom - (hintRows.length - rowIndex) * rowHeight - (hintRows.length - rowIndex - 1) * rowGap;261 row.forEach(({ key, label, labelSize, keyWidth, width: itemWidth }, rowItemIndex) => {262 const index = hintRows.slice(0, rowIndex).reduce((sum, previous) => sum + previous.length, 0) + rowItemIndex;263 const hintAction = warnings.length ? [['noop'], ['details'], ['copy'], ['noop'], ['noop']][index]?.[0] : 'noop';264 if (hintAction !== 'noop') {265 const targetWidth = Math.max(44, keyWidth + 16);266 hintButtons.push({ action: hintAction, x: hintX - (targetWidth - keyWidth) / 2, y: hintY - 12, width: targetWidth, height: 44 });267 }268 const action = key === 'I' ? 'details' : key === 'C' ? 'copy' : key === 'ENTER' ? 'confirm' : key === 'ESC' ? 'menu' : 'navigate';269 const press = options.keycapPress && actionMatches(action, options.keycapPress.action)270 ? keycapPressFrame({ now, startedAt: options.keycapPress.startedAt, reducedMotion: options.reducedMotion })271 : { offsetY: 0 };272 const offsetY = press.offsetY || 0;273 ctx.save(); ctx.fillStyle = COLORS.board; rounded(ctx, hintX, hintY + offsetY, keyWidth, rowHeight, 3); ctx.fill(); ctx.strokeStyle = COLORS.edge; ctx.lineWidth = 1; rounded(ctx, hintX, hintY + offsetY, keyWidth, rowHeight, 3); ctx.stroke(); text(ctx, key, hintX + keyWidth / 2, hintY + rowHeight / 2 + offsetY, { size: hintSize - 1, weight: 850, color: COLORS.fg, align: 'center' }); text(ctx, label, hintX + keyWidth + 6, hintY + rowHeight / 2 + offsetY, { size: labelSize, weight: 700, color: COLORS.muted }); ctx.restore(); hintX += itemWidth + hintGap;274 });275 });276 const layout = { x: left, y: top, width: cardWidth, height: cardHeight, narrow, touchLayout, preview: { x: previewX, y: previewY, width: previewWidth, height: previewHeight }, actions: buttons.map(button => ({ ...button })), hintActions: hintButtons.map(button => ({ ...button })), noteActions: noteButtons.map(button => ({ ...button })), hints: { x: hintStart, width: hintTotal }, hintY: hintBottom - rowHeight / 2, hintRows: hintRows.length, selected: selectedAction, now };277 if (motionContext) ctx.restore();278 ctx.restore();279 return layout;280 }281 const api = { show, hide, setPack, choose, input, hit, draw, accessibilityText, 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; } };282 return Object.freeze(api);283}284285export const giftScene = Object.freeze({ id: 'gift', eyebrow: 'A PACK FOR YOU', title: 'A pack of proofs has arrived', prompt: 'KEEP AND PLAY', help: 'ENTER KEEP ESC NOT NOW', create: createGiftScene });286export default createGiftScene;