1import { nativeHorizontalFacing } from './facing.js';2import { actionMatches, keycapPressFrame } from './motion.js';3import { fitSceneTitle } from './title-fitting.js';45/**6 * Pure canvas renderer for the INDEX map.7 *8 * This consumes the data from scenes/map.js and a navigator's current cursor;9 * it never mutates either object. Scene transitions and input remain outside10 * the renderer.11 */1213export const INDEX_COLORS = Object.freeze({14 stage: '#0F0E0D', board: '#191816', edge: '#2E2B28', lip: '#000000',15 ink: '#0F0E0D', fg: '#F2EEE6', muted: '#8E877C', dot: '#4A4540',16 tile: '#F2EEE6', coral: '#FF4B3E', solved: '#2EE6A8', locked: '#8E877C', lockedTile: '#3B3733',17});1819const FONT = "'Recursive', ui-monospace, monospace";20const INDEX_KEYCAPS = Object.freeze({ ENTER: 42, ESC: 30, SPACE: 44 });21const INDEX_KEYCAP_HEIGHT = 22;22const INDEX_POINTER_TARGET = 44;23const WORLD_ACCENT_COLORS = Object.freeze({24 you: '#FF4B3E', push: '#FFB020', stop: '#8B91FF', win: '#2EE6A8',25 sink: '#3AB8FF', defeat: '#FF4FA3', tick: '#C6F432',26});2728function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); }29function easeOut(value) { return 1 - ((1 - value) ** 3); }30function roundRect(ctx, x, y, width, height, radius) {31 ctx.beginPath();32 if (typeof ctx.roundRect === 'function') ctx.roundRect(x, y, width, height, radius);33 else ctx.rect(x, y, width, height);34}35function fitFont(ctx, text, maxWidth, size, weight = 800, family = FONT) {36 let current = size;37 if (typeof ctx.measureText !== 'function') return `${weight} ${current}px ${family}`;38 while (current > size * 0.55) {39 ctx.font = `${weight} ${current}px ${family}`;40 if (ctx.measureText(text).width <= maxWidth) break;41 current *= 0.92;42 }43 return `${weight} ${Math.max(current, size * 0.55)}px ${family}`;44}4546function currentNode(navigator) {47 if (!navigator) return null;48 if (navigator.current?.node) return navigator.current.node;49 if (navigator.node) return navigator.node;50 return null;51}5253function currentSheet(layout, navigator, options) {54 const node = currentNode(navigator);55 const requested = options.sheetId || node?.sheetId;56 return layout.sheets.find(sheet => sheet.id === requested) || layout.sheets[options.sheetIndex || 0] || layout.sheets[0] || null;57}5859function currentWorld(layout, navigator, sheet) {60 const current = navigator?.current;61 const worldId = current?.worldId || current?.node?.worldId || sheet?.worldId;62 return layout.worlds?.find(world => world.id === worldId) || layout.worlds?.[0] || null;63}6465function worldAccent(world, colors) {66 const token = String(world?.accent || '').toLowerCase();67 return WORLD_ACCENT_COLORS[token] || colors.fg;68}6970function easeBack(value) {71 const t = clamp(value, 0, 1);72 const c1 = 1.70158;73 const c3 = c1 + 1;74 return 1 + c3 * ((t - 1) ** 3) + c1 * ((t - 1) ** 2);75}7677function drawKeycap(ctx, key, x, y, width, height, colors) {78 ctx.fillStyle = colors.lip;79 roundRect(ctx, x + 3, y + 3, width, height, height * 0.18); ctx.fill();80 roundRect(ctx, x, y, width, height, height * 0.18);81 ctx.fillStyle = colors.board; ctx.fill(); ctx.strokeStyle = colors.edge; ctx.lineWidth = 1; ctx.stroke();82 ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = colors.fg;83 ctx.font = fitFont(ctx, key, width * 0.88, 10, 800); ctx.fillText(key, x + width / 2, y + height / 2);84}8586function drawWorldHeader(ctx, world, worlds, geometry, colors, options) {87 if (!world) return null;88 const margin = geometry.margin;89 const top = geometry.headerTop;90 const worldNumber = String(world.number || 1).padStart(2, '0');91 const worldCount = worlds.length;92 const completed = Number.isFinite(world.completedCount) ? world.completedCount : 0;93 const total = Number.isFinite(world.levelCount) ? world.levelCount : 0;94 const mobile = geometry.canvasWidth < 760 || options.coarsePointer === true;95 const leftWidth = mobile ? Math.max(120, geometry.canvasWidth - margin * 2) : Math.max(180, geometry.canvasWidth * 0.52);96 const eyebrow = `INDEX | WORLD ${worldNumber}`;97 ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillStyle = colors.muted;98 ctx.font = '800 11px ' + FONT;99 ctx.letterSpacing = '2px';100 ctx.fillText(eyebrow, margin, top);101 ctx.letterSpacing = '0px';102 ctx.fillStyle = colors.fg;103 const nameFit = fitSceneTitle(ctx, world.name || world.id || 'World', { maxWidth: leftWidth, maxSize: mobile ? 24 : 28, minSize: 16, maxLines: 1 });104 ctx.font = `800 ${nameFit.size}px ${FONT}`;105 ctx.fillText(nameFit.lines[0], margin, top + 19);106 ctx.fillStyle = colors.muted;107 const tagline = world.tagline || 'walk the path, pick a proof';108 ctx.font = fitFont(ctx, tagline, leftWidth, mobile ? 12 : 13, 600);109 ctx.fillText(tagline, margin, top + 19 + nameFit.size + 8);110111 const stripGap = Math.max(5, geometry.cell * 0.06);112 const dotSize = 10;113 const arrowWidth = mobile ? 44 : 16;114 const keycapWidth = 25;115 const keycapGap = 6;116 const stripWidth = Math.min(geometry.canvasWidth * (mobile ? 0.9 : 0.48), arrowWidth * 2 + stripGap * (worldCount + 1) + dotSize * worldCount + (mobile ? 0 : keycapWidth * 2 + keycapGap * 2));117 const stripX = geometry.canvasWidth - margin - stripWidth;118 const stripY = mobile ? top + 19 + nameFit.size + 8 + 25 : top + 19 + nameFit.size / 2;119 ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = colors.muted;120 ctx.font = `800 14px ${FONT}`;121 const arrows = { previous: { x: stripX, y: stripY - (mobile ? 22 : 11), width: arrowWidth, height: mobile ? 44 : 22 }, next: null };122 let controlsX = stripX;123 if (!mobile) {124 drawKeycap(ctx, 'Q', controlsX, stripY - INDEX_KEYCAP_HEIGHT / 2, keycapWidth, INDEX_KEYCAP_HEIGHT, colors);125 controlsX += keycapWidth + keycapGap;126 }127 ctx.fillText('◀', controlsX + arrowWidth / 2, stripY);128 arrows.previous.x = controlsX;129 let dotX = stripX + arrowWidth + stripGap;130 if (!mobile) dotX = controlsX + arrowWidth + stripGap;131 const dots = [];132 for (let index = 0; index < worldCount; index += 1) {133 const active = index === world.number - 1;134 const dot = { index, x: dotX, y: stripY - dotSize / 2, width: dotSize, height: dotSize, active };135 dots.push(dot);136 dotX += dotSize + stripGap;137 }138 const transition = options.worldTransition;139 const elapsed = transition && Number.isFinite(options.now) && Number.isFinite(transition.startedAt)140 ? options.now - transition.startedAt : Infinity;141 const transitionProgress = transition ? clamp(elapsed / 110, 0, 1) : 1;142 const fromIndex = transition ? worlds.findIndex(item => item.id === transition.from) : -1;143 const toIndex = transition ? worlds.findIndex(item => item.id === transition.to) : -1;144 for (const dot of dots) {145 const active = dot.index === world.number - 1;146 const moving = transitionProgress < 1 && dot.index === toIndex && fromIndex >= 0;147 const centerX = moving ? dots[fromIndex].x + dotSize / 2 + (dot.x - dots[fromIndex].x) * easeBack(transitionProgress) : dot.x + dotSize / 2;148 ctx.beginPath(); ctx.arc(centerX, stripY, dotSize / 2, 0, Math.PI * 2);149 ctx.fillStyle = active ? worldAccent(world, colors) : colors.dot; ctx.fill();150 }151 controlsX = dotX;152 ctx.fillStyle = colors.muted; ctx.fillText('▶', controlsX + arrowWidth / 2, stripY);153 arrows.next = { x: controlsX, y: stripY - (mobile ? 22 : 11), width: arrowWidth, height: mobile ? 44 : 22 };154 if (!mobile) drawKeycap(ctx, 'E', controlsX + arrowWidth + keycapGap, stripY - INDEX_KEYCAP_HEIGHT / 2, keycapWidth, INDEX_KEYCAP_HEIGHT, colors);155 ctx.textAlign = 'right'; ctx.textBaseline = 'top'; ctx.fillStyle = colors.muted;156 ctx.font = fitFont(ctx, `★ ${completed} / ${total}`, geometry.canvasWidth * 0.25, 13, 700);157 ctx.fillText(`★ ${completed} / ${total}`, geometry.canvasWidth - margin, top);158 return {159 x: stripX, y: stripY - (mobile ? 22 : 12), width: stripWidth,160 height: mobile ? 44 : 24, dots, arrows, worldAccent: worldAccent(world, colors),161 worldNumber: world.number, worldCount, worldName: world.name || world.id || 'World',162 };163}164165function nodeCenter(sheet, node, geometry) {166 return {167 x: geometry.x + (node.column + 0.5) * geometry.cell,168 y: geometry.y + (node.row + 0.5) * geometry.cell,169 };170}171172function drawSlab(ctx, geometry, colors) {173 const pad = geometry.cell * 0.2;174 ctx.fillStyle = colors.lip;175 ctx.fillRect(geometry.x - pad + 8, geometry.y - pad + 8, geometry.width + pad * 2, geometry.height + pad * 2);176 roundRect(ctx, geometry.x - pad, geometry.y - pad, geometry.width + pad * 2, geometry.height + pad * 2, 4);177 ctx.fillStyle = colors.board; ctx.fill();178 ctx.strokeStyle = colors.edge; ctx.lineWidth = 1; ctx.stroke();179 ctx.fillStyle = colors.dot;180 for (let row = 0; row <= geometry.rows; row++) for (let column = 0; column <= geometry.columns; column++) {181 ctx.beginPath(); ctx.arc(geometry.x + column * geometry.cell, geometry.y + row * geometry.cell, Math.max(1, geometry.cell * 0.018), 0, Math.PI * 2); ctx.fill();182 }183}184185function drawPath(ctx, sheet, geometry, colors) {186 const byId = new Map(sheet.nodes.map(node => [node.id, node]));187 ctx.save();188 ctx.strokeStyle = colors.worldAccent || colors.dot; ctx.lineWidth = Math.max(1, geometry.cell * 0.045); ctx.lineCap = 'round';189 for (const node of sheet.nodes) {190 const next = byId.get(node.nextId);191 if (!next || next.sheetId !== sheet.id) continue;192 const from = nodeCenter(sheet, node, geometry); const to = nodeCenter(sheet, next, geometry);193 ctx.beginPath(); ctx.moveTo(from.x, from.y); ctx.lineTo(to.x, to.y); ctx.stroke();194 }195 ctx.restore();196}197198function drawNode(ctx, node, center, geometry, current, colors, options) {199 const tileWidth = geometry.cell * 0.68;200 const tileHeight = geometry.cell * 0.54;201 const left = center.x - tileWidth / 2;202 const top = center.y - tileHeight / 2;203 const locked = node.locked === true;204 const solved = node.solved === true;205 const active = current?.id === node.id;206 const signal = colors.worldAccent || (solved ? colors.solved : colors.coral);207 const popping = options.popNodeId === node.id;208 const popProgress = popping ? clamp(Number.isFinite(options.popProgress) ? options.popProgress : 1, 0, 1) : 1;209 const popScale = popping && !options.reducedMotion ? 0.76 + 0.24 * easeOut(popProgress) : 1;210 const exitProgress = clamp(Number.isFinite(options.exitProgress) ? options.exitProgress : 0, 0, 1);211 const exitScale = 1 - exitProgress * 0.08;212 const rect = { id: node.id, x: left, y: top, width: tileWidth, height: tileHeight, centerX: center.x, centerY: center.y, locked, solved, active, popScale: popScale * exitScale, sparks: popping && !options.reducedMotion && popProgress < 1 };213 ctx.save();214 if (popScale !== 1 || exitProgress > 0) { ctx.translate(center.x, center.y); ctx.scale(popScale * exitScale, popScale * exitScale); ctx.translate(-center.x, -center.y); }215 // The cat replaces the tile; only its outline remains under the glyph.216 if (active) {217 roundRect(ctx, left - geometry.cell * 0.055, top - geometry.cell * 0.055, tileWidth + geometry.cell * 0.11, tileHeight + geometry.cell * 0.11, geometry.cell * 0.035);218 ctx.fillStyle = colors.board; ctx.fill();219 ctx.strokeStyle = signal; ctx.lineWidth = Math.max(2, geometry.cell * 0.045); ctx.stroke();220 ctx.font = `${Math.max(18, geometry.cell * 0.42)}px "Apple Color Emoji", "Segoe UI Emoji", sans-serif`;221 ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = colors.fg;222 const catGlyph = options.cat || '🐈';223 const nativeFacing = nativeHorizontalFacing('MIAU', catGlyph);224 if (nativeFacing && options.catFacing && options.catFacing !== nativeFacing) {225 ctx.save();226 ctx.translate(center.x, 0);227 ctx.scale(-1, 1);228 ctx.translate(-center.x, 0);229 ctx.fillText(catGlyph, center.x, center.y);230 ctx.restore();231 } else {232 ctx.fillText(catGlyph, center.x, center.y);233 }234 } else {235 roundRect(ctx, left + geometry.cell * 0.04, top + geometry.cell * 0.04, tileWidth, tileHeight, geometry.cell * 0.025);236 ctx.fillStyle = colors.lip; ctx.fill();237 roundRect(ctx, left, top, tileWidth, tileHeight, geometry.cell * 0.025);238 ctx.fillStyle = solved ? colors.solved : locked ? colors.lockedTile : colors.tile; ctx.fill();239 ctx.strokeStyle = solved ? colors.solved : locked ? colors.locked : colors.edge; ctx.lineWidth = Math.max(1, geometry.cell * 0.025); ctx.stroke();240 ctx.font = fitFont(ctx, String(node.number).padStart(2, '0'), tileWidth * 0.75, geometry.cell * 0.22);241 ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = locked ? colors.locked : colors.ink; ctx.fillText(String(node.number).padStart(2, '0'), center.x, center.y);242 }243 ctx.restore();244 if (popping && !options.reducedMotion && popProgress < 1 && exitProgress < 1) {245 const sparkAlpha = 1 - popProgress;246 const radius = geometry.cell * (0.42 + popProgress * 0.3);247 ctx.save(); ctx.globalAlpha = sparkAlpha; ctx.fillStyle = colors.solved;248 ctx.font = `${Math.max(10, geometry.cell * 0.13)}px ${FONT}`;249 ctx.textAlign = 'center'; ctx.textBaseline = 'middle';250 for (let index = 0; index < 4; index++) {251 const angle = index * Math.PI / 2 + popProgress * Math.PI * 0.5;252 ctx.fillText('★', center.x + Math.cos(angle) * radius, center.y + Math.sin(angle) * radius);253 }254 ctx.restore();255 }256 return rect;257}258259function sentenceCase(value) {260 const text = String(value || '').trim().toLowerCase();261 return text ? `${text[0].toUpperCase()}${text.slice(1)}` : '';262}263264/**265 * Draw one INDEX sheet and return geometry for hit testing and camera motion.266 * `navigator.current.node` determines the sheet and the cat's replacement267 * tile. Pass `sheetId` to draw a sheet before moving the cursor there.268 */269export function drawIndex(ctx, layout, navigator, options = {}) {270 if (!ctx || !layout || !Array.isArray(layout.sheets)) throw new TypeError('drawIndex(ctx, layout, navigator): layout.sheets is required');271 const colors = { ...INDEX_COLORS, ...(options.colors || {}) };272 const transition = options.worldTransition;273 const transitionElapsed = transition && Number.isFinite(options.now) && Number.isFinite(transition.startedAt)274 ? options.now - transition.startedAt : Infinity;275 const transitionProgress = transition ? clamp(transitionElapsed / (transition.duration || 280), 0, 1) : 1;276 if (transition && !options._transitionPass && !options.reducedMotion && transitionProgress < 1) {277 const direction = transition.delta < 0 ? -1 : 1;278 const canvasWidth = options.availableWidth ?? ctx.canvas?.width ?? 640;279 const canvasHeight = options.availableHeight ?? ctx.canvas?.height ?? 420;280 const sourceNode = layout.getNode?.(transition.fromNodeId)281 || layout.nodes?.find(node => node.id === transition.fromNodeId)282 || layout.nodes?.find(node => node.worldId === transition.from);283 const sourceNavigator = sourceNode ? { current: { node: sourceNode, world: layout.getWorld?.(sourceNode.worldId) || layout.worlds?.find(world => world.id === sourceNode.worldId) } } : null;284 const sourceSheet = sourceNode ? layout.getSheet?.(sourceNode.sheetId) || layout.sheets.find(sheet => sheet.id === sourceNode.sheetId) : null;285 ctx.save();286 ctx.fillStyle = colors.stage; ctx.fillRect(0, 0, canvasWidth, canvasHeight);287 if (sourceNavigator && sourceSheet) {288 ctx.save();289 ctx.translate(-direction * transitionProgress * canvasWidth, 0);290 drawIndex(ctx, layout, sourceNavigator, {291 ...options, _transitionPass: true, boardOnly: true, worldTransition: null, sheetId: sourceSheet.id,292 });293 ctx.restore();294 }295 ctx.save();296 ctx.translate(direction * (1 - transitionProgress) * canvasWidth, 0);297 const geometry = drawIndex(ctx, layout, navigator, { ...options, _transitionPass: true, boardOnly: true, worldTransition: null });298 ctx.restore();299 const chrome = drawIndex(ctx, layout, navigator, { ...options, _transitionPass: true, chromeOnly: true, worldTransition: transition });300 ctx.restore();301 geometry.world = chrome.world;302 geometry.worldStrip = chrome.worldStrip;303 geometry.banner = chrome.banner;304 geometry.footerActions = chrome.footerActions;305 geometry.transition = { progress: transitionProgress, direction, duration: transition.duration || 280 };306 return geometry;307 }308 const sheet = currentSheet(layout, navigator, options);309 if (!sheet) return { sheetId: null, nodes: [], width: 0, height: 0 };310 const canvasWidth = options.availableWidth ?? ctx.canvas?.width ?? 640;311 const canvasHeight = options.availableHeight ?? ctx.canvas?.height ?? 420;312 const margin = options.margin ?? clamp(Math.min(canvasWidth, canvasHeight) * 0.08, 18, 52);313 const hudHeight = options.hudHeight ?? clamp(canvasHeight * 0.18, 54, 96);314 const hideFooter = options.hideFooter === true || options.hideBanner === true;315 const footerHeight = hideFooter ? 0 : (options.footerHeight ?? clamp(canvasHeight * 0.14, 34, 64));316 const usedRows = Math.max(1, Math.min(3, Math.ceil(sheet.nodes.length / 5)));317 const availableWidth = Math.max(1, canvasWidth - margin * 2);318 const availableHeight = Math.max(1, canvasHeight - hudHeight - footerHeight - margin * 2);319 const cell = options.cell ?? clamp(Math.min(availableWidth / 5, availableHeight / usedRows), 28, 150);320 const width = cell * 5;321 const height = cell * usedRows;322 const x = options.x ?? (canvasWidth - width) / 2;323 const y = options.y ?? hudHeight + (availableHeight - height) / 2 + margin;324 const world = currentWorld(layout, navigator, sheet);325 const renderColors = {326 ...colors,327 worldAccent: world?.accent ? worldAccent(world, colors) : null,328 };329 const geometry = {330 x, y, cell, width, height, columns: 5, rows: usedRows, sheetId: sheet.id,331 packId: sheet.packId, sheetIndex: sheet.globalSheetIndex, nodes: [], footerActions: {},332 margin, canvasWidth, canvasHeight, headerTop: margin * 0.55,333 };334 const current = currentNode(navigator);335336 ctx.save();337 if (!options.boardOnly && !options.chromeOnly) { ctx.fillStyle = colors.stage; ctx.fillRect(0, 0, canvasWidth, canvasHeight); }338 if (!options.chromeOnly) {339 drawSlab(ctx, geometry, renderColors);340 drawPath(ctx, sheet, geometry, renderColors);341 for (const node of sheet.nodes) geometry.nodes.push(drawNode(ctx, node, nodeCenter(sheet, node, geometry), geometry, current, renderColors, options));342 }343344 if (options.boardOnly) { ctx.restore(); return geometry; }345346 ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillStyle = colors.fg;347 geometry.world = world ? {348 number: world.number, count: layout.worlds?.length || 1, name: world.name || world.id,349 glyph: world.glyph || null, tagline: world.tagline || null, accent: world.accent || null,350 completedCount: world.completedCount || 0, levelCount: world.levelCount || 0,351 } : null;352 geometry.worldStrip = drawWorldHeader(ctx, world, layout.worlds || [], geometry, colors, options);353 const lockedNode = options.lockedNode?.locked ? options.lockedNode : null;354 const bannerNode = lockedNode || current;355 if (bannerNode && bannerNode.sheetId === sheet.id && !hideFooter) {356 ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillStyle = bannerNode.locked ? colors.locked : colors.fg;357 const predecessor = bannerNode.locked358 ? layout.nodes.find(node => node.id === bannerNode.previousId)359 : null;360 const banner = bannerNode.locked361 ? `Locked. Prove ${String(predecessor?.number || Math.max(1, bannerNode.number - 1)).padStart(2, '0')} first.`362 : `${String(bannerNode.number).padStart(2, '0')} ${sentenceCase(bannerNode.title)}`;363 const footerY = canvasHeight - margin * 0.55;364 // Keep visible caps at the compact scale used by the other scene365 // keycaps. Pointer targets are expanded separately below.366 const keyHeight = INDEX_KEYCAP_HEIGHT;367 const mobileFooter = canvasWidth < 760 || options.coarsePointer === true;368 const groups = [369 { key: 'ENTER', label: 'play', action: 'confirm', width: INDEX_KEYCAPS.ENTER },370 ...(canvasWidth >= 340 ? [{ key: 'ESC', label: 'menu', action: 'menu', width: INDEX_KEYCAPS.ESC }] : []),371 { key: 'SPACE', label: 'workshop', action: 'workshop', width: INDEX_KEYCAPS.SPACE },372 ];373 const groupGap = Math.max(8, cell * 0.12);374 const labelGap = Math.max(5, cell * 0.08);375 const labelWidthFor = group => group.label === 'workshop' ? Math.max(62, cell * 0.72) : Math.max(26, cell * 0.42);376 const keyRowWidth = groups.reduce((total, group) => total + group.width + labelGap + labelWidthFor(group), 0) + groupGap * Math.max(0, groups.length - 1);377 const keyStart = canvasWidth - margin - keyRowWidth;378 // On narrow screens the banner gets the full row above the controls, so379 // it stays legible instead of being squeezed into their gap.380 const bannerWidth = mobileFooter ? Math.max(1, canvasWidth - margin * 2) : Math.max(84, keyStart - margin * 1.2);381 let bannerFit = fitSceneTitle(ctx, banner, { maxWidth: bannerWidth, maxSize: 22, minSize: 16, maxLines: 2 });382 if (canvasWidth <= 760 && bannerFit.overflow)383 bannerFit = fitSceneTitle(ctx, banner, { maxWidth: Math.max(1, canvasWidth - margin * 2), maxSize: 16, minSize: 16, maxLines: 3 });384 const bannerLineHeight = bannerFit.lineHeight;385 const bannerTop = mobileFooter386 ? footerY - keyHeight * 2 - (bannerFit.lines.length - 1) * bannerLineHeight387 : footerY - (bannerFit.lines.length - 1) * bannerLineHeight;388 ctx.font = `800 ${bannerFit.size}px ${FONT}`;389 bannerFit.lines.forEach((line, index) => ctx.fillText(line, margin, bannerTop + index * bannerLineHeight));390 geometry.banner = { x: margin, y: bannerTop, width: bannerWidth, height: bannerFit.lines.length * bannerLineHeight, ...bannerFit };391 if (mobileFooter) {392 if (!bannerNode.locked) {393 ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillStyle = colors.muted;394 ctx.font = fitFont(ctx, 'tap the cat to play', canvasWidth - margin * 2, 12, 600);395 ctx.fillText('tap the cat to play', margin, footerY - keyHeight / 2 - 8);396 }397 const menuWidth = 76;398 const menuX = canvasWidth - margin - menuWidth;399 const menuY = footerY - INDEX_KEYCAP_HEIGHT / 2;400 ctx.fillStyle = colors.board; roundRect(ctx, menuX, menuY, menuWidth, INDEX_KEYCAP_HEIGHT, 4); ctx.fill();401 ctx.strokeStyle = colors.edge; ctx.stroke();402 ctx.fillStyle = colors.fg; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';403 ctx.font = `800 16px ${FONT}`; ctx.fillText('☰', menuX + 20, menuY + INDEX_KEYCAP_HEIGHT / 2);404 ctx.textAlign = 'left'; ctx.font = `700 10px ${FONT}`; ctx.fillText('menu', menuX + 34, menuY + INDEX_KEYCAP_HEIGHT / 2);405 geometry.footerActions.menu = {406 x: menuX, y: clamp(footerY - INDEX_POINTER_TARGET / 2, 0, Math.max(0, canvasHeight - INDEX_POINTER_TARGET)),407 width: menuWidth, height: INDEX_POINTER_TARGET,408 };409 ctx.restore(); return geometry;410 }411 if (bannerNode.locked) { ctx.restore(); return geometry; }412 let keyX = keyStart;413 for (const group of groups) {414 const keycapY = footerY - keyHeight / 2;415 const press = options.keycapPress && actionMatches(group.action, options.keycapPress.action)416 ? keycapPressFrame({ now: options.now, startedAt: options.keycapPress.startedAt, reducedMotion: options.reducedMotion })417 : { offsetY: 0, press: 0 };418 const offsetY = press.offsetY || 0;419 const labelWidth = labelWidthFor(group);420 if (group.label === 'workshop') geometry.footerActions.workshop = {421 x: keyX,422 y: clamp(footerY - INDEX_POINTER_TARGET / 2, 0, Math.max(0, canvasHeight - INDEX_POINTER_TARGET)),423 width: group.width + labelGap + labelWidth,424 height: INDEX_POINTER_TARGET,425 };426 // The hit rectangle is intentionally larger than the visible cap. The427 // stage owns pointer dispatch; this geometry only exposes its target.428 ctx.fillStyle = colors.lip;429 roundRect(ctx, keyX + 3, keycapY + 3, group.width, keyHeight, keyHeight * 0.18); ctx.fill();430 roundRect(ctx, keyX, keycapY + offsetY, group.width, keyHeight, keyHeight * 0.18);431 ctx.fillStyle = colors.board; ctx.fill(); ctx.strokeStyle = colors.edge; ctx.lineWidth = 1; ctx.stroke();432 ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = colors.fg;433 ctx.font = fitFont(ctx, group.key, group.width * 0.88, Math.max(10, cell * 0.11), 800); ctx.fillText(group.key, keyX + group.width / 2, keycapY + keyHeight / 2 + offsetY);434 ctx.textAlign = 'left'; ctx.font = fitFont(ctx, group.label, labelWidth, Math.max(10, cell * 0.11), 700); ctx.fillText(group.label, keyX + group.width + labelGap, keycapY + keyHeight / 2 + offsetY);435 keyX += group.width + labelGap + labelWidth + groupGap;436 }437 }438 ctx.restore();439 return geometry;440}441442export default drawIndex;