- commit
- f7fafa617b50d26f2057720b13a920041301f482
- parent
- 1f9c6011c1f03eeffd0dfba4ab89fc8b4e8812b5
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 30 | +++++++++++++++++++++++------- |
| M | tests/ | 16 | +++++++++++++++- |
@@ -13,6 +13,9 @@ export const INDEX_COLORS = Object.freeze({
1313 });1414 1515 const FONT = "'Recursive', ui-monospace, monospace";16+const INDEX_KEYCAPS = Object.freeze({ ENTER: 42, ESC: 30, SPACE: 44 });17+const INDEX_KEYCAP_HEIGHT = 22;18+const INDEX_POINTER_TARGET = 44;1619 1720 function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); }1821 function easeOut(value) { return 1 - ((1 - value) ** 3); }@@ -199,26 +202,39 @@ export function drawIndex(ctx, layout, navigator, options = {}) {
199202 : `${String(bannerNode.number).padStart(2, '0')} ${sentenceCase(bannerNode.title)}`;200203 const footerY = canvasHeight - margin * 0.55;201204 const narrowFooter = canvasWidth < 560;202- const keyHeight = Math.max(20, cell * 0.3);205+ // Keep visible caps at the compact scale used by the other scene206+ // keycaps. Pointer targets are expanded separately below.207+ const keyHeight = INDEX_KEYCAP_HEIGHT;203208 const groups = [204- { key: 'ENTER', label: 'play', width: Math.max(56, cell * 0.9) },205- ...(canvasWidth >= 340 ? [{ key: 'ESC', label: 'menu', width: Math.max(42, cell * 0.65) }] : []),206- { key: 'SPACE', label: 'workshop', width: Math.max(58, cell * 0.95) },209+ { key: 'ENTER', label: 'play', width: INDEX_KEYCAPS.ENTER },210+ ...(canvasWidth >= 340 ? [{ key: 'ESC', label: 'menu', width: INDEX_KEYCAPS.ESC }] : []),211+ { key: 'SPACE', label: 'workshop', width: INDEX_KEYCAPS.SPACE },207212 ];208213 const groupGap = Math.max(8, cell * 0.12);209214 const labelGap = Math.max(5, cell * 0.08);210215 const labelWidthFor = group => group.label === 'workshop' ? Math.max(62, cell * 0.72) : Math.max(26, cell * 0.42);211216 const keyRowWidth = groups.reduce((total, group) => total + group.width + labelGap + labelWidthFor(group), 0) + groupGap * Math.max(0, groups.length - 1);212217 const keyStart = canvasWidth - margin - keyRowWidth;213- const bannerWidth = Math.max(84, keyStart - margin * 1.2);214- ctx.font = fitFont(ctx, banner, bannerWidth, Math.max(12, cell * 0.15), 800);218+ // On narrow screens the banner gets the full row above the controls, so219+ // it stays legible instead of being squeezed into their gap.220+ const bannerWidth = narrowFooter ? Math.max(1, canvasWidth - margin * 2) : Math.max(84, keyStart - margin * 1.2);221+ ctx.font = fitFont(ctx, banner, bannerWidth, Math.max(16, cell * 0.15), 800);215222 ctx.fillText(banner, margin, narrowFooter ? footerY - keyHeight * 1.15 : footerY);216223 if (bannerNode.locked) { ctx.restore(); return geometry; }217224 let keyX = keyStart;218225 for (const group of groups) {219226 const keycapY = footerY - keyHeight / 2;220227 const labelWidth = labelWidthFor(group);221- if (group.label === 'workshop') geometry.footerActions.workshop = { x: keyX, y: Math.max(0, keycapY), width: group.width + labelGap + labelWidth, height: Math.max(0, Math.floor(Math.min(keyHeight, canvasHeight - keycapY) * 100) / 100) };228+ if (group.label === 'workshop') geometry.footerActions.workshop = {229+ x: keyX,230+ y: clamp(footerY - INDEX_POINTER_TARGET / 2, 0, Math.max(0, canvasHeight - INDEX_POINTER_TARGET)),231+ width: group.width + labelGap + labelWidth,232+ height: INDEX_POINTER_TARGET,233+ };234+ // The hit rectangle is intentionally larger than the visible cap. The235+ // stage owns pointer dispatch; this geometry only exposes its target.236+ ctx.fillStyle = colors.lip;237+ roundRect(ctx, keyX + 3, keycapY + 3, group.width, keyHeight, keyHeight * 0.18); ctx.fill();222238 roundRect(ctx, keyX, keycapY, group.width, keyHeight, keyHeight * 0.18);223239 ctx.fillStyle = colors.board; ctx.fill(); ctx.strokeStyle = colors.edge; ctx.lineWidth = 1; ctx.stroke();224240 ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = colors.fg;@@ -13,7 +13,7 @@ function context(width = 640, height = 420) {
1313 moveTo(...args) { calls.push(['moveTo', ...args]); }, lineTo(...args) { calls.push(['lineTo', ...args]); },1414 rect(...args) { calls.push(['rect', ...args]); }, roundRect(...args) { calls.push(['roundRect', ...args]); },1515 fill() { calls.push(['fill']); }, stroke() { calls.push(['stroke']); }, fillRect(...args) { calls.push(['fillRect', ...args]); },16- arc(...args) { calls.push(['arc', ...args]); }, fillText(...args) { calls.push(['fillText', ...args]); },16+ arc(...args) { calls.push(['arc', ...args]); }, fillText(...args) { calls.push(['fillText', ...args, this.font]); },1717 measureText(text) { return { width: String(text).length * 8 }; },1818 };1919 }@@ -149,5 +149,19 @@ test('drawIndex returns an exact Workshop footer hit rectangle at desktop and mo
149149 assert.ok(hit, `workshop hit at ${width}px`);150150 assert.ok(hit.x >= 0 && hit.x + hit.width <= width, `workshop footer stays within ${width}px`);151151 assert.ok(hit.y >= 0 && hit.y + hit.height <= 420, `workshop footer y stays within ${width}px`);152+ assert.ok(hit.height >= 44, `workshop footer has a pointer target at ${width}px`);152153 }153154 });155+156+test('drawIndex keeps the mobile banner readable above compact keycaps', () => {157+ const layout = decorateIndex(createIndexLayout([level('core', 'one', 'Break the rule')]), null);158+ const navigator = createIndexNavigator(layout);159+ const ctx = context(390, 420);160+ drawIndex(ctx, layout, navigator, { availableWidth: 390, availableHeight: 420 });161+ const banner = ctx.calls.find(call => call[0] === 'fillText' && call[1] === '01 Break the rule');162+ const enter = ctx.calls.find(call => call[0] === 'fillText' && call[1] === 'ENTER');163+ assert.ok(banner && enter);164+ assert.ok(Number.parseFloat(banner[4]) >= 16, `banner font should be at least 16px: ${banner[4]}`);165+ assert.ok(banner[4] !== enter[4], 'banner should use the larger readable text size');166+ assert.ok(banner[2] < enter[2], 'banner should sit above the keycap row');167+});