- commit
- 4344e41de0994fed9e0e929298be2ffb216cc2d6
- parent
- 6c3654dc3e84021fe6199003b6b951e6e08cfdac
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 164 | +++++++++++++++++++++++++----- |
| M | src/ | 10 | ++- |
| M | src/ | 18 | +++ |
| A | tests/ | 161 | +++++++++++++++++++++++++++++ |
| M | tests/ | 78 | +++++++++++++-- |
@@ -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) {@@ -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,@@ -17,6 +17,7 @@ export function installStageEvents({
1717 getGift,1818 getWorkshop,1919 getIndexGeometry,20+ moveIndexWorld,2021 getIndexNavigator,2122 getWorldCard,2223 clearIndexLockedNode,@@ -185,6 +186,23 @@ export function installStageEvents({
185186 }186187 return;187188 }189+ const worldStrip = geometry.worldStrip;190+ const previousWorld = worldStrip?.arrows?.previous;191+ const nextWorld = worldStrip?.arrows?.next;192+ const inTarget = (target) => target && x >= target.x && x <= target.x + target.width && y >= target.y && y <= target.y + target.height;193+ if (inTarget(previousWorld) || inTarget(nextWorld)) {194+ const delta = inTarget(nextWorld) ? 1 : -1;195+ noteInput?.({ type: "world", delta, source: "touch" });196+ moveIndexWorld?.(delta);197+ render();198+ return;199+ }200+ const menuHit = geometry.footerActions?.menu;201+ if (inTarget(menuHit)) {202+ noteInput?.({ type: "menu", source: "touch" });203+ dispatchOverlayIntent({ type: "menu" });204+ return;205+ }188206 const workshopHit = geometry.footerActions?.workshop;189207 if (workshopHit && x >= workshopHit.x && x <= workshopHit.x + workshopHit.width &&190208 y >= workshopHit.y && y <= workshopHit.y + workshopHit.height) {@@ -0,0 +1,161 @@
1+2+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+}@@ -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', () => {