- commit
- 6ec67ec381914e39b14d9cc87ea1c847183888d7
- parent
- 2e7e754636743de9746a6c420f515436706912f2
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 81 | +++++++++++++++++++++++------- |
| M | tests/ | 42 | ++++++++++++++++ |
@@ -18,10 +18,19 @@ function text(ctx, value, x, y, options = {}) {
1818 const { size = 16, weight = 700, color = COLORS.fg, align = 'left', alpha = 1, baseline = 'middle' } = options;1919 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();2020 }21-function fit(ctx, value, maxWidth, size = 16) {21+function fitLabel(ctx, value, maxWidth, size = 16, weight = 800) {22+ const source = String(value);2223 let current = size;23- while (current > 9) { ctx.font = `800 ${current}px ${FONT}`; if (ctx.measureText(value).width <= maxWidth) break; current -= 1; }24- return current;24+ while (current > 9) {25+ ctx.font = `${weight} ${current}px ${FONT}`;26+ if (ctx.measureText(source).width <= maxWidth) return { value: source, size: current };27+ current -= 1;28+ }29+ ctx.font = `${weight} ${current}px ${FONT}`;30+ if (ctx.measureText(source).width <= maxWidth) return { value: source, size: current };31+ let valueText = [...source];32+ while (valueText.length > 1 && ctx.measureText(`${valueText.join('')}…`).width > maxWidth) valueText.pop();33+ return { value: `${valueText.join('')}…`, size: current };2534 }2635 2736 /** Canvas-only Gift controller. It never installs a pack itself. */@@ -57,31 +66,67 @@ export function createGiftScene({ pack = null, collision = false, reducedMotion
5766 if (!open || !preview || !ctx) return null;5867 const width = options.width || ctx.canvas?.width || 800;5968 const height = options.height || ctx.canvas?.height || 600;60- const cardWidth = Math.min(720, width - 48); const cardHeight = Math.min(520, height - 48); const left = (width - cardWidth) / 2; const top = (height - cardHeight) / 2;69+ const narrow = width < 760;70+ 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);72+ const left = (width - cardWidth) / 2; const top = (height - cardHeight) / 2;6173 const title = packName(preview); const author = packAuthor(preview); const levels = levelList(preview); const selectedAction = choices()[selected];6274 ctx.save(); ctx.fillStyle = 'rgba(0,0,0,.78)'; ctx.fillRect(0, 0, width, height);6375 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();6476 text(ctx, mode === 'collision' ? 'ALREADY HERE' : 'A PACK FOR YOU', left + 32, top + 38, { size: 12, weight: 800, color: COLORS.accent });65- text(ctx, title, left + 32, top + 82, { size: fit(ctx, title, cardWidth - 64, 34), weight: 850 });66- if (mode === 'collision') text(ctx, `You have ${collides?.name || title}. Replace it, or keep both as a copy?`, left + 32, top + 112, { size: fit(ctx, `You have ${collides?.name || title}. Replace it, or keep both as a copy?`, cardWidth - 64, 13), weight: 650, color: COLORS.muted });67- else text(ctx, `${levels.length} ${levels.length === 1 ? 'proof' : 'proofs'}${author ? ` | by ${author}` : ''}`, left + 34, top + 112, { size: 11, weight: 700, color: COLORS.muted });68- const first = levels[0]; const previewWidth = Math.min(cardWidth * 0.64, 450); const previewHeight = Math.min(cardHeight * 0.48, 260);69- if (first && Number.isInteger(first.width) && Number.isInteger(first.height)) {70- const cell = Math.max(18, Math.floor(Math.min(previewWidth / first.width, previewHeight / first.height)));77+ const titleLabel = fitLabel(ctx, title, cardWidth - 64, 34, 850);78+ text(ctx, titleLabel.value, left + 32, top + 82, { size: titleLabel.size, weight: 850 });79+ if (mode === 'collision') {80+ const collisionText = fitLabel(ctx, `You have ${collides?.name || title}. Replace it, or keep both as a copy?`, cardWidth - 64, 13, 650);81+ text(ctx, collisionText.value, left + 32, top + 112, { size: collisionText.size, weight: 650, color: COLORS.muted });82+ } else {83+ const metadata = fitLabel(ctx, `${levels.length} ${levels.length === 1 ? 'proof' : 'proofs'}${author ? ` | by ${author}` : ''}`, cardWidth - 68, 11, 700);84+ text(ctx, metadata.value, left + 34, top + 112, { size: metadata.size, weight: 700, color: COLORS.muted });85+ }86+ const labels = mode === 'collision' ? [['replace', 'REPLACE'], ['copy', 'KEEP BOTH AS A COPY'], ['later', 'CANCEL']] : [['keep', 'KEEP AND PLAY'], ['later', 'NOT NOW']];87+ const buttonHeight = 44; const buttonGap = 10; const buttonWidth = narrow ? Math.max(0, cardWidth - 48) : Math.min(260, cardWidth - 64);88+ const buttonTotal = labels.length * buttonHeight + (labels.length - 1) * buttonGap;89+ const buttonTop = top + cardHeight - buttonTotal - (narrow ? 58 : 72);90+ const buttonLeft = narrow ? left + 24 : left + cardWidth - buttonWidth - 32;91+ const first = levels[0];92+ const previewX = narrow ? left + 24 : left + 32;93+ const previewY = top + 140;94+ const actionColumnLeft = narrow ? previewX : buttonLeft;95+ const previewWidth = narrow ? Math.max(0, cardWidth - 48) : Math.max(0, Math.min(400, actionColumnLeft - previewX - 32));96+ const previewHeight = narrow ? Math.max(0, Math.min(220, buttonTop - previewY - 20)) : Math.max(0, Math.min(cardHeight * 0.48, buttonTop - previewY - 20));97+ if (first && Number.isInteger(first.width) && Number.isInteger(first.height) && previewWidth > 0 && previewHeight > 0) {98+ // Include the slab's 22% pad and hard shadow in the allocated preview99+ // rect. This keeps wide authored grids inside the card on mobile.100+ const cell = Math.max(1, Math.floor(Math.min((previewWidth - 8) / (first.width + 0.44), (previewHeight - 8) / (first.height + 0.44))));101+ const boardX = previewX + cell * 0.22;102+ const boardY = previewY + cell * 0.22;71103 // The raw pack level has no derived rules. Use the engine snapshot so72104 // active words, sentence bars, YOU markers, and the lost-control overlay73105 // all match how the proof will actually play.74106 const previewGame = createGame(first);75107 const previewState = previewGame.getState();76- drawBoard(ctx, previewState, { x: left + 32, y: top + 140, cell, availableWidth: cell * first.width, availableHeight: cell * first.height, palette: preview.palette || {}, game: previewGame });77- } else text(ctx, 'NO PREVIEW LEVEL', left + 32, top + 205, { size: 13, weight: 800, color: COLORS.muted });78- if (first?.title) text(ctx, first.title, left + 32, top + cardHeight - 92, { size: 13, weight: 800 });79- const labels = mode === 'collision' ? [['replace', 'REPLACE'], ['copy', 'KEEP BOTH AS A COPY'], ['later', 'CANCEL']] : [['keep', 'KEEP AND PLAY'], ['later', 'NOT NOW']];80- buttons = []; const buttonHeight = 42; const buttonGap = 10; const buttonWidth = Math.min(260, cardWidth - 64); const buttonLeft = left + cardWidth - buttonWidth - 32; const buttonTop = top + cardHeight - 190;108+ drawBoard(ctx, previewState, { x: boardX, y: boardY, cell, availableWidth: cell * first.width, availableHeight: cell * first.height, palette: preview.palette || {}, game: previewGame });109+ } else text(ctx, 'NO PREVIEW LEVEL', previewX, previewY + 65, { size: 13, weight: 800, color: COLORS.muted });110+ if (first?.title && previewHeight > 0) {111+ const proofTitle = fitLabel(ctx, first.title, previewWidth, 13, 800);112+ text(ctx, proofTitle.value, previewX, Math.min(buttonTop - 10, previewY + previewHeight + 18), { size: proofTitle.size, weight: 800 });113+ }114+ buttons = [];81115 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(); });82- const hints = [['↑ ↓', 'choose'], ['ENTER', 'select'], ['ESC', 'not now']]; let hintX = left + cardWidth / 2 - 108;83- hints.forEach(([key, label]) => { ctx.save(); ctx.font = `800 10px ${FONT}`; const width = Math.max(29, ctx.measureText(key).width + 12); ctx.fillStyle = COLORS.board; rounded(ctx, hintX, top + cardHeight - 35, width, 20, 3); ctx.fill(); ctx.strokeStyle = COLORS.edge; ctx.lineWidth = 1; rounded(ctx, hintX, top + cardHeight - 35, width, 20, 3); ctx.stroke(); text(ctx, key, hintX + width / 2, top + cardHeight - 25, { size: 9, weight: 850, color: COLORS.fg, align: 'center' }); text(ctx, label, hintX + width + 6, top + cardHeight - 25, { size: 10, weight: 700, color: COLORS.muted }); hintX += width + 6 + ctx.measureText(label).width + 16; }); ctx.restore();84- return { x: left, y: top, width: cardWidth, height: cardHeight, selected: selectedAction, now };116+ const hints = [['↑ ↓', 'choose'], ['ENTER', 'select'], ['ESC', 'not now']];117+ const hintSize = narrow ? 8 : 10; const hintLabelSize = narrow ? 8 : 10; const hintGap = narrow ? 7 : 16;118+ const hintMetrics = hints.map(([key, label]) => {119+ ctx.font = `800 ${hintSize}px ${FONT}`;120+ const keyWidth = Math.max(29, ctx.measureText(key).width + 12);121+ const labelFit = fitLabel(ctx, label, narrow ? 44 : Infinity, hintLabelSize, 700);122+ const labelWidth = ctx.measureText(labelFit.value).width;123+ return { key, label: labelFit.value, labelSize: labelFit.size, keyWidth, labelWidth, width: keyWidth + 6 + labelWidth };124+ });125+ const hintTotal = hintMetrics.reduce((sum, item) => sum + item.width, 0) + hintGap * (hintMetrics.length - 1);126+ const hintStart = left + Math.max(12, (cardWidth - hintTotal) / 2);127+ 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 };85130 }86131 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 ? clone(preview) : null; }, get collision() { return collides; } };87132 return Object.freeze(api);@@ -68,3 +68,45 @@ test('escape dismisses without installing', () => {
6868 assert.equal(actions.length, 1);6969 assert.equal(actions[0].install, null);7070 });71+72+test('gift preview and actions stay in separate regions on desktop', () => {73+ const scene = createGiftScene({ pack: { ...pack, levels: [{ ...pack.levels[0], width: 12, height: 9 }] } });74+ const ctx = {75+ canvas: { width: 800, height: 600 }, save() {}, restore() {}, beginPath() {}, roundRect() {}, rect() {}, fill() {}, stroke() {}, arc() {}, fillRect() {}, moveTo() {}, lineTo() {}, closePath() {}, clip() {}, ellipse() {},76+ fillText() {}, measureText(value) { return { width: String(value).length * 8 }; },77+ };78+ const layout = scene.draw(ctx, 0, { width: 800, height: 600 });79+ assert.ok(layout.preview.x + layout.preview.width + 24 <= Math.min(...layout.actions.map(action => action.x)));80+});81+82+test('collision gift stacks preview, three actions, and footer safely on narrow mobile', () => {83+ const scene = createGiftScene({ pack: { ...pack, levels: [{ ...pack.levels[0], width: 12, height: 9 }] }, collision: { id: 'old', name: 'Old Pack' } });84+ scene.input('confirm');85+ const ctx = {86+ canvas: { width: 390, height: 844 }, save() {}, restore() {}, beginPath() {}, roundRect() {}, rect() {}, fill() {}, stroke() {}, arc() {}, fillRect() {}, moveTo() {}, lineTo() {}, closePath() {}, clip() {}, ellipse() {},87+ fillText() {}, measureText(value) { return { width: String(value).length * 8 }; },88+ };89+ const layout = scene.draw(ctx, 0, { width: 390, height: 844 });90+ assert.equal(layout.narrow, true);91+ assert.equal(layout.actions.length, 3);92+ assert.equal(layout.preview.width, 318);93+ assert.ok(layout.preview.y + layout.preview.height + 10 <= layout.actions[0].y);94+ assert.ok(layout.actions.at(-1).y + layout.actions.at(-1).height + 20 <= layout.hintY);95+});96+97+test('long Gift copy is ellipsized and the narrow footer stays inside the card', () => {98+ const longPack = {99+ manifest: { id: 'long', name: 'An Extremely Long Shared Pack Name That Must Fit', author: 'A Very Long Author Name That Must Fit', version: '1.0.0' },100+ levels: [{ id: 'intro', title: 'An Extremely Long First Proof Title That Must Fit', width: 12, height: 9, objects: [] }],101+ };102+ const labels = [];103+ const ctx = {104+ canvas: { width: 320, height: 844 }, save() {}, restore() {}, beginPath() {}, roundRect() {}, rect() {}, fill() {}, stroke() {}, arc() {}, fillRect() {}, moveTo() {}, lineTo() {}, closePath() {}, clip() {}, ellipse() {},105+ fillText(value) { labels.push(String(value)); }, measureText(value) { return { width: String(value).length * 8 }; },106+ };107+ const layout = createGiftScene({ pack: longPack }).draw(ctx, 0, { width: 320, height: 844 });108+ assert.equal(layout.narrow, true);109+ assert.ok(labels.some(value => value.endsWith('…')));110+ assert.ok(layout.hints.x >= layout.x + 8);111+ assert.ok(layout.hints.x + layout.hints.width <= layout.x + layout.width - 8);112+});