- commit
- c9df4ae38e3ecc2a8d52cea1560c13adcd6b0535
- parent
- b02ae7f2311941d46eae464fd505f2f83af4b5a6
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 27 | +++++++++++++++++++++------ |
| M | src/ | 26 | ++++++++++++++++++-------- |
| M | tests/ | 5 | +++++ |
| M | tests/ | 6 | ++++++ |
@@ -171,7 +171,8 @@ export function createGiftScene({ pack = null, collision = false, warnings: init
171171 const labels = mode === 'collision' ? [['replace', 'REPLACE'], ['copy', 'KEEP BOTH AS A COPY'], ['later', 'CANCEL']] : [['keep', 'KEEP AND PLAY'], ['later', 'NOT NOW']];172172 const buttonHeight = 44; const buttonGap = 10; const buttonWidth = narrow ? Math.max(0, cardWidth - 48) : Math.min(260, cardWidth - 64);173173 const buttonTotal = labels.length * buttonHeight + (labels.length - 1) * buttonGap;174- const buttonTop = top + cardHeight - buttonTotal - (narrow ? 58 : 72);174+ const footerSpace = narrow && warnings.length ? 96 : narrow ? 58 : 72;175+ const buttonTop = top + cardHeight - buttonTotal - footerSpace;175176 const buttonLeft = narrow ? left + 24 : left + cardWidth - buttonWidth - 32;176177 const first = levels[0];177178 const previewX = narrow ? left + 24 : left + 32;@@ -199,7 +200,7 @@ export function createGiftScene({ pack = null, collision = false, warnings: init
199200 buttons = [];200201 hintButtons = [];201202 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(); });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']];203+ const hints = warnings.length ? [['↑ ↓', 'choose'], ['I', detailsOpen ? (narrow ? 'close' : 'close notes') : 'details'], ['C', narrow ? 'copy' : 'copy notes'], ['ENTER', 'select'], ['ESC', 'not now']] : [['↑ ↓', 'choose'], ['ENTER', 'select'], ['ESC', 'not now']];203204 const hintSize = narrow ? 8 : 10; const hintLabelSize = narrow ? 8 : 10; const hintGap = narrow ? 7 : 16;204205 const hintMetrics = hints.map(([key, label]) => {205206 ctx.font = `800 ${hintSize}px ${FONT}`;@@ -208,11 +209,25 @@ export function createGiftScene({ pack = null, collision = false, warnings: init
208209 const labelWidth = ctx.measureText(labelFit.value).width;209210 return { key, label: labelFit.value, labelSize: labelFit.size, keyWidth, labelWidth, width: keyWidth + 6 + labelWidth };210211 });211- const hintTotal = hintMetrics.reduce((sum, item) => sum + item.width, 0) + hintGap * (hintMetrics.length - 1);212+ const hintRows = narrow && warnings.length ? [hintMetrics.slice(0, 3), hintMetrics.slice(3)] : [hintMetrics];213+ const rowGap = 6;214+ const rowHeight = 20;215+ const rowWidths = hintRows.map(row => row.reduce((sum, item) => sum + item.width, 0) + hintGap * Math.max(0, row.length - 1));216+ const hintTotal = Math.max(...rowWidths);212217 const hintStart = left + Math.max(12, (cardWidth - hintTotal) / 2);213- let hintX = hintStart;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 };218+ const hintBottom = top + cardHeight - 15;219+ hintRows.forEach((row, rowIndex) => {220+ const rowWidth = rowWidths[rowIndex];221+ let hintX = left + Math.max(12, (cardWidth - rowWidth) / 2);222+ const hintY = hintBottom - (hintRows.length - rowIndex) * rowHeight - (hintRows.length - rowIndex - 1) * rowGap;223+ row.forEach(({ key, label, labelSize, keyWidth, width: itemWidth }, rowItemIndex) => {224+ const index = hintRows.slice(0, rowIndex).reduce((sum, previous) => sum + previous.length, 0) + rowItemIndex;225+ const hintAction = warnings.length ? [['noop'], ['details'], ['copy'], ['noop'], ['noop']][index]?.[0] : 'noop';226+ if (hintAction !== 'noop') hintButtons.push({ action: hintAction, x: hintX - 4, y: hintY - 4, width: keyWidth + 8, height: 28 });227+ ctx.save(); ctx.fillStyle = COLORS.board; rounded(ctx, hintX, hintY, keyWidth, rowHeight, 3); ctx.fill(); ctx.strokeStyle = COLORS.edge; ctx.lineWidth = 1; rounded(ctx, hintX, hintY, keyWidth, rowHeight, 3); ctx.stroke(); text(ctx, key, hintX + keyWidth / 2, hintY + rowHeight / 2, { size: hintSize - 1, weight: 850, color: COLORS.fg, align: 'center' }); text(ctx, label, hintX + keyWidth + 6, hintY + rowHeight / 2, { size: labelSize, weight: 700, color: COLORS.muted }); ctx.restore(); hintX += itemWidth + hintGap;228+ });229+ });230+ 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: hintBottom - rowHeight / 2, hintRows: hintRows.length, selected: selectedAction, now };216231 }217232 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; } };218233 return Object.freeze(api);@@ -186,7 +186,13 @@ export function createWorkshopScene(options = {}) {
186186 const choice = hitLayout.confirm.choices.findIndex(cell => pointerX >= cell.x && pointerX <= cell.x + cell.width && pointerY >= cell.y && pointerY <= cell.y + cell.height);187187 if (choice >= 0) { confirm.choice = choice; return activate(); }188188 }189- if (note && pointerY >= hitLayout.noteCopy.y && pointerY <= hitLayout.noteCopy.y + hitLayout.noteCopy.height && pointerX >= hitLayout.noteCopy.x) return copyDetails();189+ if (note) {190+ if (pointerY >= hitLayout.noteCopy.y && pointerY <= hitLayout.noteCopy.y + hitLayout.noteCopy.height && pointerX >= hitLayout.noteCopy.x) return copyDetails();191+ if (hitLayout.noteCard && (pointerX < hitLayout.noteCard.x || pointerX > hitLayout.noteCard.x + hitLayout.noteCard.width || pointerY < hitLayout.noteCard.y || pointerY > hitLayout.noteCard.y + hitLayout.noteCard.height)) {192+ note = null; feedback = null; return { ...view(), event: { type: 'notes-close' } };193+ }194+ if (hitLayout.noteCard) return view();195+ }190196 const target = hitLayout.rows.find(row => pointerY >= row.y && pointerY <= row.y + row.height);191197 if (!target) return view();192198 rowIndex = target.index;@@ -224,7 +230,7 @@ export function createWorkshopScene(options = {}) {
224230 const rowHeight = compact ? 120 : Math.max(62, Math.min(84, (height - top - 42) / Math.max(rows().length, 1)));225231 const visibleCount = Math.max(1, Math.floor((height - top - 20) / rowHeight));226232 const firstRow = Math.min(Math.max(0, rowIndex - visibleCount + 1), Math.max(0, rows().length - visibleCount));227- hitLayout = { width, height, rows: [], noteCopy: { x: width, y: height, height: 0 } };233+ hitLayout = { width, height, rows: [], noteCopy: { x: width, y: height, height: 0 }, noteCard: null };228234 rows().forEach((row, index) => {229235 if (index < firstRow || index >= firstRow + visibleCount) return;230236 const y = top + (index - firstRow) * rowHeight; const selected = index === rowIndex;@@ -273,15 +279,19 @@ export function createWorkshopScene(options = {}) {
273279 }274280 });275281 if (note) {276- const noteHeight = Math.min(height * 0.45, 76 + note.visible.length * 19); const noteY = height - noteHeight - 24;277- hitLayout.noteCopy = { x: width * 0.52, y: noteY + noteHeight - 38, height: 34 };282+ const noteHeight = Math.min(height * 0.45, 76 + note.visible.length * 19); const noteWidth = compact ? width - 48 : width - 96;283+ const noteX = compact ? 24 : 48; const noteY = compact ? Math.max(24, (height - noteHeight) / 2) : height - noteHeight - 24;284+ hitLayout.noteCard = { x: noteX, y: noteY, width: noteWidth, height: noteHeight };285+ hitLayout.noteCopy = { x: noteX + noteWidth * 0.52, y: noteY + noteHeight - 38, height: 34 };278286 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);287+ if (compact) { ctx.fillStyle = 'rgba(0,0,0,.72)'; ctx.fillRect(0, 0, width, height); }288+ ctx.fillStyle = colors.raised; ctx.fillRect(noteX, noteY, noteWidth, noteHeight); ctx.strokeStyle = noteColor; ctx.strokeRect(noteX, noteY, noteWidth, noteHeight);289+ drawText(ctx, note.kind === 'warning' ? 'PACK NOTES' : 'This pack has problems', noteX + 20, noteY + 22, 11, noteColor, 'left', 850);281290 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);291+ ctx.font = fit(ctx, error, Math.max(40, noteWidth - 40), 10, 600); ctx.fillStyle = colors.fg; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillText(error, noteX + 20, noteY + 44 + index * 18);283292 });284- drawText(ctx, `${note.remaining ? `AND ${note.remaining} MORE | ` : ''}COPY DETAILS`, width - 68, noteY + noteHeight - 18, 10, noteColor, 'right', 800);293+ drawText(ctx, compact ? 'ESC CLOSE' : 'ESC CLOSE', noteX + 20, noteY + noteHeight - 18, 10, colors.muted, 'left', 800);294+ drawText(ctx, `${note.remaining ? `AND ${note.remaining} MORE | ` : ''}COPY DETAILS`, noteX + noteWidth - 20, noteY + noteHeight - 18, 10, noteColor, 'right', 800);285295 } 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);286296 if (confirm) {287297 const cardWidth = Math.min(540, width - 96); const cardHeight = 190; const cardX = (width - cardWidth) / 2; const cardY = (height - cardHeight) / 2;@@ -169,6 +169,11 @@ test('Gift note keycaps are tappable on mobile and details stay above the previe
169169 }, 0, { width: 390, height: 844 });170170 assert.ok(layout.preview.y > layout.y);171171 assert.ok(layout.preview.y + layout.preview.height <= layout.actions[0].y);172+ assert.equal(layout.hintRows, 2);173+ assert.ok(layout.hints.x >= layout.x + 8);174+ assert.ok(layout.hints.x + layout.hints.width <= layout.x + layout.width - 8);175+ assert.ok(layout.hintActions.every(item => item.x >= layout.x && item.x + item.width <= layout.x + layout.width));176+ assert.ok(layout.actions.at(-1).y + layout.actions.at(-1).height + 8 <= Math.min(...layout.hintActions.map(item => item.y)));172177 const expandedCopyKey = layout.hintActions.find(item => item.action === 'copy');173178 assert.ok(scene.hit(expandedCopyKey.x + expandedCopyKey.width / 2, expandedCopyKey.y + expandedCopyKey.height / 2).ok);174179 assert.match(copied, /WORD0/);@@ -120,6 +120,12 @@ test('warning notes add an amber Workshop chip and NOTES action', async () => {
120120 const mobileRow = scene.hitLayout.rows.find(row => row.index === 1);121121 assert.ok(mobileRow.actions.every(action => action.x >= 24 && action.x + action.width <= 366));122122 assert.ok(mobileRow.actions.every(action => action.height >= 44));123+ assert.ok(scene.hitLayout.noteCard.x > 0 && scene.hitLayout.noteCard.y > 0);124+ assert.ok(scene.hitLayout.noteCard.x + scene.hitLayout.noteCard.width <= 390);125+ assert.ok(scene.hitLayout.noteCard.y + scene.hitLayout.noteCard.height <= 844);126+ const closed = scene.hit(2, 2);127+ assert.equal(closed.event.type, 'notes-close');128+ await scene.handleInput('enter');123129 assert.equal(await scene.handleInput('c'), true);124130 assert.match(copied, /SHUT/);125131 scene.handleInput('escape');