clone

a browser grid puzzle game where you push words around to rewrite the rules of each level

Polish world index hierarchy and touch navigation

Dami committed 4344e41
commit
4344e41de0994fed9e0e929298be2ffb216cc2d6
parent
6c3654dc3e84021fe6199003b6b951e6e08cfdac
author
Dami <dami.etoile@gmail.com>
5 files changed, +391 -40
Msrc/render/index.js164+++++++++++++++++++++++++-----
Msrc/stage.js10++-
Msrc/stage/events.js18+++
Atests/browser/world-index.mjs161+++++++++++++++++++++++++++++
Mtests/index-render.test.js78+++++++++++++--
M src/render/index.js +134 -30
@@ -67,6 +67,22 @@ function worldAccent(world, colors) {
6767 return WORLD_ACCENT_COLORS[token] || colors.fg;6868 }6969 70+function 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+}76+77+function 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+}85+7086 function drawWorldHeader(ctx, world, worlds, geometry, colors, options) {7187 if (!world) return null;7288 const margin = geometry.margin;
@@ -75,44 +91,73 @@ function drawWorldHeader(ctx, world, worlds, geometry, colors, options) {
7591 const worldCount = worlds.length;7692 const completed = Number.isFinite(world.completedCount) ? world.completedCount : 0;7793 const total = Number.isFinite(world.levelCount) ? world.levelCount : 0;78- const name = `${world.glyph ? `${world.glyph} ` : ''}${world.name || world.id || 'World'}`;79- const leftWidth = Math.max(120, geometry.canvasWidth * 0.52);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}`;8097 ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillStyle = colors.muted;81- ctx.font = fitFont(ctx, `WORLD ${worldNumber}`, leftWidth, Math.max(10, geometry.cell * 0.13), 800);82- ctx.fillText(`WORLD ${worldNumber}`, margin, top + Math.max(28, geometry.cell * 0.25));98+ ctx.font = '800 11px ' + FONT;99+ ctx.letterSpacing = '2px';100+ ctx.fillText(eyebrow, margin, top);101+ ctx.letterSpacing = '0px';83102 ctx.fillStyle = colors.fg;84- const nameFit = fitSceneTitle(ctx, name, { maxWidth: leftWidth, maxSize: Math.max(16, geometry.cell * 0.2), minSize: 12, maxLines: 1 });103+ const nameFit = fitSceneTitle(ctx, world.name || world.id || 'World', { maxWidth: leftWidth, maxSize: mobile ? 24 : 28, minSize: 16, maxLines: 1 });85104 ctx.font = `800 ${nameFit.size}px ${FONT}`;86- ctx.fillText(nameFit.lines[0], margin, top + Math.max(43, geometry.cell * 0.4));105+ ctx.fillText(nameFit.lines[0], margin, top + 19);87106 ctx.fillStyle = colors.muted;88- ctx.font = fitFont(ctx, 'walk the path, pick a proof', leftWidth, Math.max(10, geometry.cell * 0.14), 600);89- ctx.fillText('walk the path, pick a proof', margin, top + Math.max(64, geometry.cell * 0.61));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);90110 91111 const stripGap = Math.max(5, geometry.cell * 0.06);92- const dotSize = Math.max(3, Math.min(5, geometry.cell * 0.045));93- const arrowWidth = Math.max(12, geometry.cell * 0.15);94- const stripWidth = Math.min(geometry.canvasWidth * 0.43, arrowWidth * 2 + stripGap * (worldCount + 1) + dotSize * worldCount);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));95117 const stripX = geometry.canvasWidth - margin - stripWidth;96- const stripY = top + Math.max(35, geometry.cell * 0.32);118+ const stripY = mobile ? top + 19 + nameFit.size + 8 + 25 : top + 19 + nameFit.size / 2;97119 ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = colors.muted;98- ctx.font = `800 ${Math.max(12, geometry.cell * 0.13)}px ${FONT}`;99- ctx.fillText('◀', stripX + arrowWidth / 2, stripY);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;100129 let dotX = stripX + arrowWidth + stripGap;130+ if (!mobile) dotX = controlsX + arrowWidth + stripGap;101131 const dots = [];102132 for (let index = 0; index < worldCount; index += 1) {103133 const active = index === world.number - 1;104- ctx.beginPath(); ctx.arc(dotX + dotSize / 2, stripY, dotSize * (active ? 1.25 : 0.75), 0, Math.PI * 2);105- ctx.fillStyle = active ? worldAccent(world, colors) : colors.dot; ctx.fill();106- dots.push({ index, x: dotX, y: stripY - dotSize, width: dotSize, height: dotSize * 2, active });134+ const dot = { index, x: dotX, y: stripY - dotSize / 2, width: dotSize, height: dotSize, active };135+ dots.push(dot);107136 dotX += dotSize + stripGap;108137 }109- ctx.fillStyle = colors.muted; ctx.fillText('▶', geometry.canvasWidth - margin - arrowWidth / 2, stripY);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);110155 ctx.textAlign = 'right'; ctx.textBaseline = 'top'; ctx.fillStyle = colors.muted;111- ctx.font = fitFont(ctx, `★ ${completed} / ${total}`, geometry.canvasWidth * 0.25, Math.max(11, geometry.cell * 0.14), 700);156+ ctx.font = fitFont(ctx, `★ ${completed} / ${total}`, geometry.canvasWidth * 0.25, 13, 700);112157 ctx.fillText(`★ ${completed} / ${total}`, geometry.canvasWidth - margin, top);113158 return {114- x: stripX, y: stripY - Math.max(12, geometry.cell * 0.16), width: stripWidth,115- height: Math.max(24, geometry.cell * 0.3), dots, worldAccent: worldAccent(world, colors),159+ x: stripX, y: stripY - (mobile ? 22 : 12), width: stripWidth,160+ height: mobile ? 44 : 24, dots, arrows, worldAccent: worldAccent(world, colors),116161 worldNumber: world.number, worldCount, worldName: world.name || world.id || 'World',117162 };118163 }
@@ -224,6 +269,42 @@ function sentenceCase(value) {
224269 export function drawIndex(ctx, layout, navigator, options = {}) {225270 if (!ctx || !layout || !Array.isArray(layout.sheets)) throw new TypeError('drawIndex(ctx, layout, navigator): layout.sheets is required');226271 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+ }227308 const sheet = currentSheet(layout, navigator, options);228309 if (!sheet) return { sheetId: null, nodes: [], width: 0, height: 0 };229310 const canvasWidth = options.availableWidth ?? ctx.canvas?.width ?? 640;
@@ -253,13 +334,16 @@ export function drawIndex(ctx, layout, navigator, options = {}) {
253334 const current = currentNode(navigator);254335 255336 ctx.save();256- ctx.fillStyle = colors.stage; ctx.fillRect(0, 0, canvasWidth, canvasHeight);257- drawSlab(ctx, geometry, renderColors);258- drawPath(ctx, sheet, geometry, renderColors);259- for (const node of sheet.nodes) geometry.nodes.push(drawNode(ctx, node, nodeCenter(sheet, node, geometry), geometry, current, renderColors, options));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+ }343+344+ if (options.boardOnly) { ctx.restore(); return geometry; }260345 261346 ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillStyle = colors.fg;262- ctx.font = fitFont(ctx, 'INDEX', canvasWidth * 0.24, Math.max(18, cell * 0.3)); ctx.fillText('INDEX', margin, margin * 0.55);263347 geometry.world = world ? {264348 number: world.number, count: layout.worlds?.length || 1, name: world.name || world.id,265349 glyph: world.glyph || null, tagline: world.tagline || null, accent: world.accent || null,
@@ -277,10 +361,10 @@ export function drawIndex(ctx, layout, navigator, options = {}) {
277361 ? `Locked. Prove ${String(predecessor?.number || Math.max(1, bannerNode.number - 1)).padStart(2, '0')} first.`278362 : `${String(bannerNode.number).padStart(2, '0')} ${sentenceCase(bannerNode.title)}`;279363 const footerY = canvasHeight - margin * 0.55;280- const narrowFooter = canvasWidth < 560;281364 // Keep visible caps at the compact scale used by the other scene282365 // keycaps. Pointer targets are expanded separately below.283366 const keyHeight = INDEX_KEYCAP_HEIGHT;367+ const mobileFooter = canvasWidth < 760 || options.coarsePointer === true;284368 const groups = [285369 { key: 'ENTER', label: 'play', action: 'confirm', width: INDEX_KEYCAPS.ENTER },286370 ...(canvasWidth >= 340 ? [{ key: 'ESC', label: 'menu', action: 'menu', width: INDEX_KEYCAPS.ESC }] : []),
@@ -293,17 +377,37 @@ export function drawIndex(ctx, layout, navigator, options = {}) {
293377 const keyStart = canvasWidth - margin - keyRowWidth;294378 // On narrow screens the banner gets the full row above the controls, so295379 // it stays legible instead of being squeezed into their gap.296- const bannerWidth = narrowFooter ? Math.max(1, canvasWidth - margin * 2) : Math.max(84, keyStart - margin * 1.2);380+ const bannerWidth = mobileFooter ? Math.max(1, canvasWidth - margin * 2) : Math.max(84, keyStart - margin * 1.2);297381 let bannerFit = fitSceneTitle(ctx, banner, { maxWidth: bannerWidth, maxSize: 22, minSize: 16, maxLines: 2 });298382 if (canvasWidth <= 760 && bannerFit.overflow)299383 bannerFit = fitSceneTitle(ctx, banner, { maxWidth: Math.max(1, canvasWidth - margin * 2), maxSize: 16, minSize: 16, maxLines: 3 });300384 const bannerLineHeight = bannerFit.lineHeight;301- const bannerTop = narrowFooter302- ? footerY - keyHeight * 1.15 - (bannerFit.lines.length - 1) * bannerLineHeight385+ const bannerTop = mobileFooter386+ ? footerY - keyHeight * 2 - (bannerFit.lines.length - 1) * bannerLineHeight303387 : footerY - (bannerFit.lines.length - 1) * bannerLineHeight;304388 ctx.font = `800 ${bannerFit.size}px ${FONT}`;305389 bannerFit.lines.forEach((line, index) => ctx.fillText(line, margin, bannerTop + index * bannerLineHeight));306390 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+ }307411 if (bannerNode.locked) { ctx.restore(); return geometry; }308412 let keyX = keyStart;309413 for (const group of groups) {
M src/stage.js +9 -1
@@ -267,6 +267,7 @@ export function createStage(options = {}) {
267267 const world = result.world || indexLayout?.getWorld?.(result.node?.worldId);268268 indexWorldTransition = {269269 from: result.from?.worldId || null,270+ fromNodeId: result.from?.id || null,270271 to: world?.id || result.node?.worldId || null,271272 nodeId: result.node?.id || null,272273 delta: Math.sign(delta),
@@ -307,6 +308,8 @@ export function createStage(options = {}) {
307308 return true;308309 }309310 function animateVisuals(now) {311+ if (indexWorldTransition && now - indexWorldTransition.startedAt >= (indexWorldTransition.duration || 280))312+ indexWorldTransition = null;310313 if (sceneMotion.phase === 'opening' && now - sceneMotion.openedAt >= SCENE_MOTION_TIMING.entry) {311314 sceneMotion = { ...sceneMotion, phase: 'settled' };312315 }
@@ -322,6 +325,8 @@ export function createStage(options = {}) {
322325 const keycapActive = keycapPress && now - keycapPress.startedAt < 90;323326 const indexActive =324327 scene === "index" && indexPopStart && now - indexPopStart < 520;328+ const worldTransitionActive =329+ scene === "index" && indexWorldTransition && now - indexWorldTransition.startedAt < (indexWorldTransition.duration || 280);325330 const sceneMotionActive = sceneMotion.phase !== 'settled';326331 if (scene === "pause" && pauseMotionPhase === "opening" && now - pauseOpenedAt >= pauseMotionTimings.open)327332 pauseMotionPhase = "idle";
@@ -331,7 +336,7 @@ export function createStage(options = {}) {
331336 now - selectedAt < pauseMotionTimings.slide);332337 if (333338 !reducedMotion &&334- (winActive || titleActive || stepActive || indexActive || pauseActive || keycapActive || sceneMotionActive)339+ (winActive || titleActive || stepActive || indexActive || worldTransitionActive || pauseActive || keycapActive || sceneMotionActive)335340 )336341 visualFrame(animateVisuals);337342 }
@@ -431,6 +436,8 @@ export function createStage(options = {}) {
431436 ? sceneMotionPhase({ now: visualNow(), openedAt: sceneMotion.openedAt, exitStartedAt: sceneMotion.exitStartedAt, reducedMotion }).exitProgress432437 : 0,433438 reducedMotion,439+ coarsePointer: globalThis.matchMedia?.("(pointer: coarse)").matches ?? false,440+ worldTransition: indexWorldTransition,434441 lockedNode: indexLockedNode,435442 now: visualNow(),436443 });
@@ -801,6 +808,7 @@ export function createStage(options = {}) {
801808 getWorkshop: () => workshop,802809 getIndexGeometry: () => indexGeometry,803810 getIndexNavigator: () => indexNavigator,811+ moveIndexWorld,804812 getWorldCard: () => worldCard,805813 clearIndexLockedNode: () => { indexLockedNode = null; },806814 getPauseMotionPhase: () => pauseMotionPhase,
M src/stage/events.js +18 -0
A tests/browser/world-index.mjs +161 -0
@@ -0,0 +1,161 @@
1+#!/usr/bin/env node2+3+/** Browser acceptance for D20 world switching and INDEX world cards. */4+5+import assert from 'node:assert/strict';6+import { mkdir } from 'node:fs/promises';7+8+const configuredPlaywright = process.env.PLAYWRIGHT_MODULE || 'playwright';9+const playwrightModule = configuredPlaywright.startsWith('/')10+ ? `${configuredPlaywright.replace(/\/$/, '')}${configuredPlaywright.endsWith('.js') ? '' : '/index.js'}`11+ : configuredPlaywright;12+const playwright = await import(playwrightModule);13+const { chromium } = playwright.default || playwright;14+const baseUrl = process.env.MIAU_URL15+ || process.argv.find(argument => argument.startsWith('--url='))?.slice(6)16+ || 'http://127.0.0.1:5202/';17+const playerUrl = (() => {18+ const value = new URL(baseUrl);19+ value.searchParams.delete('dev');20+ value.hash = '';21+ return value.toString();22+})();23+const screenshotDir = '/private/tmp/miau-stage-qa';24+await mkdir(screenshotDir, { recursive: true });25+26+function report(message) { process.stdout.write(`✓ ${message}\n`); }27+28+async function waitScene(page, name) {29+ await page.waitForFunction(value => document.querySelector('.stage-shell')?.dataset.scene === value, name, { timeout: 10_000 });30+}31+32+async function canvasLabel(page) {33+ return page.locator('.stage-canvas').getAttribute('aria-label');34+}35+36+async function openIndex(page) {37+ await page.goto(playerUrl, { waitUntil: 'networkidle' });38+ await page.keyboard.press('Enter');39+ await waitScene(page, 'index');40+ await page.waitForFunction(() => document.querySelector('.stage-canvas')?.getAttribute('aria-label')?.includes('Press Enter to enter'));41+}42+43+async function dismissCard(page) {44+ await page.keyboard.press('Enter');45+ await page.waitForFunction(() => !document.querySelector('.stage-canvas')?.getAttribute('aria-label')?.includes('Press Enter to enter'));46+ assert.equal(await page.locator('.stage-shell').getAttribute('data-scene'), 'index');47+}48+49+async function switchWorld(page, key, name, number) {50+ await page.keyboard.press(key);51+ await page.waitForFunction(value => {52+ const label = document.querySelector('.stage-canvas')?.getAttribute('aria-label') || '';53+ return label.includes(value) && label.includes('Press Enter to enter');54+ }, name);55+ const cardLabel = await canvasLabel(page);56+ assert.match(cardLabel, new RegExp(`World ${number}|${name}`));57+ await page.waitForTimeout(360);58+ await page.screenshot({ path: `${screenshotDir}/d20-world-${number}-card.png` });59+ await dismissCard(page);60+ const settledLabel = await canvasLabel(page);61+ assert.match(settledLabel, new RegExp(`World ${number}: ${name}`));62+ return settledLabel;63+}64+65+const errors = [];66+const browser = await chromium.launch({ headless: !process.argv.includes('--headed') });67+const context = await browser.newContext({ viewport: { width: 1280, height: 800 } });68+const page = await context.newPage();69+page.on('console', message => { if (message.type() === 'error') errors.push(`console: ${message.text()}`); });70+page.on('pageerror', error => errors.push(`pageerror: ${error.message}`));71+72+try {73+ await openIndex(page);74+ await page.waitForTimeout(360);75+ assert.match(await canvasLabel(page), /Miau Core/);76+ await page.screenshot({ path: `${screenshotDir}/d20-world-1-card.png` });77+ report('Title enters INDEX with the Core world card');78+79+ await dismissCard(page);80+ assert.match(await canvasLabel(page), /World 1: Miau Core/);81+ await page.keyboard.press('Enter');82+ await waitScene(page, 'proof');83+ report('one Enter dismisses the first-entry card, while a second Enter enters the proof');84+85+ await page.keyboard.press('Escape');86+ await waitScene(page, 'pause');87+ await page.keyboard.press('ArrowDown');88+ await page.keyboard.press('ArrowDown');89+ await page.keyboard.press('Enter');90+ await waitScene(page, 'index');91+ assert.doesNotMatch(await canvasLabel(page), /Press Enter to enter/);92+ assert.match(await canvasLabel(page), /World 1: Miau Core/);93+ report('returning to INDEX does not replay the Core world card');94+95+ await switchWorld(page, 'e', 'Crossword', 2);96+ await switchWorld(page, 'e', 'Crate Works', 3);97+ const tidewaterLabel = await switchWorld(page, 'e', 'Tidewater', 4);98+ assert.match(tidewaterLabel, /0 of \d+ proofs complete/);99+ assert.match(tidewaterLabel, /Q and E switch worlds/);100+ report('Q/E follows the ordered worlds and exposes per-world progress/accessibility');101+102+ await page.keyboard.press('e');103+ await page.waitForTimeout(80);104+ assert.equal(await page.locator('.stage-shell').getAttribute('data-scene'), 'index');105+ assert.match(await page.locator('.stage-sr').textContent(), /Already at the last world/);106+ await page.keyboard.press('q');107+ await page.waitForFunction(() => document.querySelector('.stage-canvas')?.getAttribute('aria-label')?.includes('World 3: Crate Works'));108+ await page.keyboard.press('q');109+ await page.waitForFunction(() => document.querySelector('.stage-canvas')?.getAttribute('aria-label')?.includes('World 2: Crossword'));110+ await page.keyboard.press('q');111+ await page.waitForFunction(() => document.querySelector('.stage-canvas')?.getAttribute('aria-label')?.includes('World 1: Miau Core'));112+ await page.keyboard.press('q');113+ await page.waitForTimeout(80);114+ assert.match(await page.locator('.stage-sr').textContent(), /Already at the first world/);115+ report('outer worlds stop with accessible boundary feedback and never wrap');116+117+ const workshopPage = await context.newPage();118+ const workshopErrors = [];119+ workshopPage.on('console', message => { if (message.type() === 'error') workshopErrors.push(`console: ${message.text()}`); });120+ workshopPage.on('pageerror', error => workshopErrors.push(`pageerror: ${error.message}`));121+ await workshopPage.goto(playerUrl, { waitUntil: 'networkidle' });122+ await workshopPage.keyboard.press(' ');123+ await waitScene(workshopPage, 'workshop');124+ await workshopPage.keyboard.press('ArrowDown');125+ await workshopPage.keyboard.press('Enter');126+ await waitScene(workshopPage, 'index');127+ await workshopPage.waitForFunction(() => document.querySelector('.stage-canvas')?.getAttribute('aria-label')?.includes('Press Enter to enter'));128+ await workshopPage.waitForTimeout(360);129+ assert.match(await canvasLabel(workshopPage), /Miau Core/);130+ await workshopPage.screenshot({ path: `${screenshotDir}/d20-workshop-play-card.png` });131+ assert.deepEqual(workshopErrors, []);132+ await workshopPage.close();133+ report('Workshop PLAY opens the same first-entry card origin');134+135+ const mobile = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });136+ const mobilePage = await mobile.newPage();137+ const mobileErrors = [];138+ mobilePage.on('console', message => { if (message.type() === 'error') mobileErrors.push(`console: ${message.text()}`); });139+ mobilePage.on('pageerror', error => mobileErrors.push(`pageerror: ${error.message}`));140+ await openIndex(mobilePage);141+ await mobilePage.waitForTimeout(360);142+ await mobilePage.screenshot({ path: `${screenshotDir}/d20-world-card-mobile.png` });143+ await mobilePage.touchscreen.tap(195, 620);144+ await mobilePage.waitForFunction(() => !document.querySelector('.stage-canvas')?.getAttribute('aria-label')?.includes('Press Enter to enter'));145+ assert.equal(await mobilePage.locator('.stage-shell').getAttribute('data-scene'), 'index');146+ await mobilePage.touchscreen.tap(337, 92);147+ await mobilePage.waitForFunction(() => document.querySelector('.stage-canvas')?.getAttribute('aria-label')?.includes('Crossword') && document.querySelector('.stage-canvas')?.getAttribute('aria-label')?.includes('Press Enter to enter'));148+ await mobilePage.touchscreen.tap(195, 620);149+ await mobilePage.waitForFunction(() => !document.querySelector('.stage-canvas')?.getAttribute('aria-label')?.includes('Press Enter to enter'));150+ await mobilePage.touchscreen.tap(321, 827);151+ await waitScene(mobilePage, 'pause');152+ assert.deepEqual(mobileErrors, []);153+ await mobile.close();154+ report('mobile card, world arrow, and menu touch targets work without entering a proof');155+156+ assert.deepEqual(errors, []);157+ process.stdout.write(`screenshots=${screenshotDir}/d20-world-1-card.png\n`);158+} finally {159+ await context.close();160+ await browser.close();161+}
M tests/index-render.test.js +69 -9
@@ -26,7 +26,7 @@ const level = (packId, localId, title = localId) => ({
2626 test('drawIndex returns adaptive non-overlapping node geometry and renders the path', () => {2727 const layout = decorateIndex(createIndexLayout(Array.from({ length: 6 }, (_, i) => level('core', `p${i + 1}`))), null);2828 const navigator = createIndexNavigator(layout);29- const ctx = context(480, 320);29+ const ctx = context(800, 420);3030 const geometry = drawIndex(ctx, layout, navigator);3131 assert.equal(geometry.sheetId, 'core:sheet-1');3232 assert.equal(geometry.nodes.length, 6);
@@ -44,6 +44,9 @@ test('drawIndex returns adaptive non-overlapping node geometry and renders the p
4444 assert.ok(footerLabels[1][2] < footerLabels[2][2]);4545 assert.ok(footerLabels[2][2] < footerLabels[3][2]);4646 assert.equal(ctx.calls.some(call => call[0] === 'fillText' && call[1] === 'MIAU CORE / SHEET 1'), false);47+ assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === 'INDEX | WORLD 01'));48+ assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === '◀'));49+ assert.ok(ctx.calls.some(call => call[0] === 'fillText' && call[1] === '▶'));4750 assert.equal(ctx.calls.some(call => call[0] === 'fillText' && call[1] === 'LOCKED'), false);4851 for (let i = 0; i < geometry.nodes.length; i++) for (let j = i + 1; j < geometry.nodes.length; j++) {4952 const a = geometry.nodes[i]; const b = geometry.nodes[j];
@@ -73,11 +76,15 @@ test('drawIndex renders the active world header and strip on mobile', () => {
7376 const ctx = context(390, 844);7477 const geometry = drawIndex(ctx, layout, navigator, { availableWidth: 390, availableHeight: 844 });7578 const labels = ctx.calls.filter(call => call[0] === 'fillText').map(call => call[1]);76- assert.ok(labels.includes('WORLD 01'));79+ assert.ok(labels.includes('INDEX | WORLD 01'));7780 assert.ok(labels.includes('🌊 Tidewater') === false, 'inactive world names stay out of the active header');7881 assert.ok(labels.includes('★ 2 / 9'));7982 assert.ok(labels.includes('◀'));8083 assert.ok(labels.includes('▶'));84+ assert.equal(labels.includes('ENTER'), false);85+ assert.equal(labels.includes('SPACE'), false);86+ assert.ok(labels.includes('tap the cat to play'));87+ assert.ok(labels.includes('☰'));8188 assert.equal(geometry.world.number, 1);8289 assert.equal(geometry.worldStrip.dots.length, 3);8390 assert.ok(geometry.worldStrip.worldAccent === '#F2EEE6');
@@ -183,10 +190,10 @@ test('drawIndex mirrors the native-left cat only for right-facing cursor travel'
183190 assert.ok(rightContext.calls.some(call => call[0] === 'scale' && call[1] === -1 && call[2] === 1));184191 });185192 186-test('drawIndex returns an exact Workshop footer hit rectangle at desktop and mobile widths', () => {193+test('drawIndex returns an exact desktop Workshop footer hit rectangle', () => {187194 const layout = decorateIndex(createIndexLayout([level('core', 'one')]), null);188195 const navigator = createIndexNavigator(layout);189- for (const width of [390, 1280]) {196+ for (const width of [1280]) {190197 const geometry = drawIndex(context(width, 420), layout, navigator, { availableWidth: width, availableHeight: 420 });191198 const hit = geometry.footerActions.workshop;192199 assert.ok(hit, `workshop hit at ${width}px`);
@@ -196,17 +203,70 @@ test('drawIndex returns an exact Workshop footer hit rectangle at desktop and mo
196203 }197204 });198205 199-test('drawIndex keeps the mobile banner readable above compact keycaps', () => {206+test('drawIndex keeps the mobile banner readable above its touch prompt', () => {200207 const layout = decorateIndex(createIndexLayout([level('core', 'one', 'Break the rule')]), null);201208 const navigator = createIndexNavigator(layout);202209 const ctx = context(390, 420);203210 drawIndex(ctx, layout, navigator, { availableWidth: 390, availableHeight: 420 });204211 const banner = ctx.calls.find(call => call[0] === 'fillText' && call[1] === '01 Break the rule');205- const enter = ctx.calls.find(call => call[0] === 'fillText' && call[1] === 'ENTER');206- assert.ok(banner && enter);212+ const prompt = ctx.calls.find(call => call[0] === 'fillText' && call[1] === 'tap the cat to play');213+ assert.ok(banner && prompt);207214 assert.ok(Number.parseFloat(banner[4]) >= 16, `banner font should be at least 16px: ${banner[4]}`);208- assert.ok(banner[4] !== enter[4], 'banner should use the larger readable text size');209- assert.ok(banner[2] < enter[2], 'banner should sit above the keycap row');215+ assert.ok(banner[3] < prompt[3], 'banner should sit above the touch prompt');216+});217+218+test('drawIndex uses the world tagline and exposes touch sized world arrows', () => {219+ const base = decorateIndex(createIndexLayout([level('core', 'one')]), null);220+ const worlds = [221+ { ...base.worlds[0], name: 'Miau Core', tagline: 'one proof at a time', number: 1, levelCount: 1 },222+ { ...base.worlds[0], id: 'other', name: 'Other', number: 2, levelCount: 1 },223+ ];224+ const layout = { ...base, worlds };225+ const navigator = createIndexNavigator(layout);226+ const geometry = drawIndex(context(390, 844), layout, navigator, { coarsePointer: true, now: 1000 });227+ const labels = geometry.worldStrip;228+ assert.ok(labels.arrows.previous.width >= 44);229+ assert.ok(labels.arrows.next.width >= 44);230+ assert.ok(labels.dots.every(dot => dot.width === 10));231+ assert.ok(geometry.footerActions.menu.height >= 44);232+ assert.equal(geometry.footerActions.workshop, undefined);233+});234+235+test('drawIndex slides the previous and next world while preserving target hit geometry', () => {236+ const levels = [level('core', 'one'), level('other', 'one')];237+ const base = decorateIndex(createIndexLayout(levels), null);238+ const layout = {239+ ...base,240+ worlds: base.worlds.map((world, index) => ({ ...world, number: index + 1, levelCount: 1 })),241+ };242+ const navigator = createIndexNavigator(layout);243+ assert.equal(navigator.moveWorld(1).moved, true);244+ const settledContext = context(800, 420);245+ const settled = drawIndex(settledContext, layout, navigator, { availableWidth: 800, availableHeight: 420 });246+ const transitionContext = context(800, 420);247+ const transition = drawIndex(transitionContext, layout, navigator, {248+ availableWidth: 800, availableHeight: 420, now: 1140,249+ worldTransition: { from: 'core', fromNodeId: 'core:one', to: 'other', nodeId: 'other:one', delta: 1, startedAt: 1000, duration: 280 },250+ });251+ assert.equal(transition.transition.progress, 0.5);252+ assert.deepEqual(transition.nodes.map(node => [node.id, node.x, node.y]), settled.nodes.map(node => [node.id, node.x, node.y]));253+ assert.ok(transition.worldStrip?.arrows?.next);254+ assert.ok(transition.footerActions?.workshop);255+ assert.equal(transitionContext.calls.filter(call => call[0] === 'fillRect' && call[1] === 0 && call[2] === 0 && call[3] === 800 && call[4] === 420).length, 1);256+ assert.ok(transitionContext.calls.some(call => call[0] === 'translate' && call[1] === -400 && call[2] === 0));257+ assert.ok(transitionContext.calls.some(call => call[0] === 'translate' && call[1] === 400 && call[2] === 0));258+ assert.equal(transitionContext.calls.filter(call => call[0] === 'fillText' && call[1] === 'INDEX | WORLD 02').length, 1);259+ assert.equal(transitionContext.calls.filter(call => call[0] === 'fillText' && call[1] === '★ 0 / 1').length, 1);260+});261+262+test('locked mobile INDEX still exposes the menu touch target', () => {263+ const layout = decorateIndex(createIndexLayout([level('core', 'one'), level('core', 'two')]), null);264+ const navigator = createIndexNavigator(layout);265+ const lockedNode = layout.nodes[1];266+ const ctx = context(390, 844);267+ const geometry = drawIndex(ctx, layout, navigator, { lockedNode, availableWidth: 390, availableHeight: 844 });268+ assert.ok(geometry.footerActions.menu.height >= 44);269+ assert.equal(ctx.calls.some(call => call[0] === 'fillText' && call[1] === 'tap the cat to play'), false);210270 });211271 212272 test('drawIndex keeps a long authored banner complete across two lines', () => {