- commit
- 27d2bd72c10cb88d9f5d90d57ee973cd4344d945
- parent
- 2c984a7b38b09c761d743aa4b7470ae36049681d
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 4 | +++ |
| M | src/ | 4 | ++- |
| M | src/ | 5 | ++- |
| M | src/ | 42 | ++++++++++++++++++++++-------- |
| M | src/ | 4 | ++- |
| M | tests/ | 37 | ++++++++++++++++++++++++++ |
| M | tests/ | 12 | +++++++++ |
| M | tests/ | 11 | ++++++++ |
@@ -15,6 +15,7 @@ export const CURRENT_RULES_VERSION = 2;
1515 export const LEGACY_RULES_VERSION = 1;1616 export const MAX_MOD_SOURCE_LENGTH = 512 * 1024;1717 export const MAX_MOD_LEVELS = 64;18+export const MAX_LEVEL_TITLE_LENGTH = 64;1819 export const MAX_LEVEL_OBJECTS = 4096;1920 export const MAX_LEVEL_AUTOMATA = 32;2021 export const MAX_AUTOMATON_PATTERN = 32;@@ -110,6 +111,9 @@ function validateLevel(level, index, errors) {
110111 if (!isInteger(level.width) || level.width < 1 || level.width > 128) errors.push(issue(`${path}.width`, 'must be an integer from 1 to 128'));111112 if (!isInteger(level.height) || level.height < 1 || level.height > 128) errors.push(issue(`${path}.height`, 'must be an integer from 1 to 128'));112113 if (level.title !== undefined && typeof level.title !== 'string') errors.push(issue(`${path}.title`, 'must be a string'));114+ if (typeof level.title === 'string' && level.title.length > MAX_LEVEL_TITLE_LENGTH) {115+ errors.push(issue(`${path}.title`, 'Titles can be at most 64 characters so they fit on phones'));116+ }113117 if (level.hint !== undefined && typeof level.hint !== 'string') errors.push(issue(`${path}.hint`, 'must be a string'));114118 const declaredProperties = new Set();115119 if (Array.isArray(level.automata)) for (const automaton of level.automata) {@@ -294,7 +294,9 @@ export function drawIndex(ctx, layout, navigator, options = {}) {
294294 // On narrow screens the banner gets the full row above the controls, so295295 // it stays legible instead of being squeezed into their gap.296296 const bannerWidth = narrowFooter ? Math.max(1, canvasWidth - margin * 2) : Math.max(84, keyStart - margin * 1.2);297- const bannerFit = fitSceneTitle(ctx, banner, { maxWidth: bannerWidth, maxSize: 22, minSize: 16, maxLines: 2 });297+ let bannerFit = fitSceneTitle(ctx, banner, { maxWidth: bannerWidth, maxSize: 22, minSize: 16, maxLines: 2 });298+ if (canvasWidth <= 760 && bannerFit.overflow)299+ bannerFit = fitSceneTitle(ctx, banner, { maxWidth: Math.max(1, canvasWidth - margin * 2), maxSize: 16, minSize: 16, maxLines: 3 });298300 const bannerLineHeight = bannerFit.lineHeight;299301 const bannerTop = narrowFooter300302 ? footerY - keyHeight * 1.15 - (bannerFit.lines.length - 1) * bannerLineHeight@@ -58,9 +58,10 @@ export function drawStepCounter(ctx, value, x, y, options = {}) {
5858 ctx.save();5959 ctx.textAlign = 'right';6060 ctx.textBaseline = 'middle';61- ctx.font = '800 40px Recursive, monospace';61+ const fontSize = options.size || 40;62+ ctx.font = `800 ${fontSize}px Recursive, monospace`;6263 const digitWidth = Math.max(20, ctx.measureText('0').width);63- const digitHeight = 34;64+ const digitHeight = options.digitHeight || (fontSize === 40 ? 34 : fontSize + 2);6465 const right = x;6566 const top = y - digitHeight / 2;6667 for (let index = 0; index < digits.length; index += 1) {@@ -77,7 +77,16 @@ export function renderProofScene(ctx, state, view = {}) {
7777 );7878 const counterWidth = counterDigits * Math.max(20, ctx.measureText('0').width);7979 const titleWidth = Math.max(1, counterRight - counterWidth - titleToCounterGap - titleX);80- const titleFit = fitSceneTitle(ctx, fullProofTitle, { maxWidth: titleWidth, maxSize: 22, minSize: 16, maxLines: 2 });80+ let titleFit = fitSceneTitle(ctx, fullProofTitle, { maxWidth: titleWidth, maxSize: 22, minSize: 16, maxLines: 2 });81+ let inlineCounter = false;82+ // At 390px a 51–64 character title can require three 16px lines. Give it83+ // the full title band and move the compact odometer into the subline row.84+ if (width <= 760 && titleFit.overflow) {85+ titleFit = fitSceneTitle(ctx, fullProofTitle, {86+ maxWidth: Math.max(1, width - titleX - 16), maxSize: 16, minSize: 16, maxLines: 3,87+ });88+ inlineCounter = true;89+ }8190 const titleTop = 30;8291 const sublineTop = titleTop + titleFit.lines.length * titleFit.lineHeight + 3;8392 const hudBottom = sublineTop + 12;@@ -93,15 +102,26 @@ export function renderProofScene(ctx, state, view = {}) {
93102 sublineTop,94103 );95104 ctx.fillStyle = "#F2EEE6";96- drawStepCounter(ctx, state.moves || 0, counterRight, 39, {97- now,98- reducedMotion: view.reducedMotion,99- roll: view.stepRoll,100- });101- ctx.textAlign = "right";102- ctx.font = "600 10px Recursive, monospace";103- ctx.fillStyle = "#8E877C";104- ctx.fillText("STEPS", counterRight, 70);105+ if (inlineCounter) {106+ ctx.textAlign = "right";107+ ctx.font = "800 22px Recursive, monospace";108+ const compactDigitWidth = Math.max(20, ctx.measureText("0").width);109+ const compactCounterWidth = counterDigits * compactDigitWidth;110+ ctx.font = "600 10px Recursive, monospace";111+ ctx.fillStyle = "#8E877C";112+ ctx.fillText("STEPS", counterRight - compactCounterWidth - 8, sublineTop + 8);113+ drawStepCounter(ctx, state.moves || 0, counterRight, sublineTop + 8, {114+ now, size: 22, reducedMotion: view.reducedMotion, roll: view.stepRoll,115+ });116+ } else {117+ drawStepCounter(ctx, state.moves || 0, counterRight, 39, {118+ now, reducedMotion: view.reducedMotion, roll: view.stepRoll,119+ });120+ ctx.textAlign = "right";121+ ctx.font = "600 10px Recursive, monospace";122+ ctx.fillStyle = "#8E877C";123+ ctx.fillText("STEPS", counterRight, 70);124+ }105125 ctx.textAlign = "left";106126 const geometry = drawBoard(ctx, state, {107127 x,@@ -199,7 +219,7 @@ export function renderProofScene(ctx, state, view = {}) {
199219 );200220 return {201221 geometry,202- titleLayout: { ...titleFit, x: titleX, y: titleTop, width: titleWidth, sublineTop, hudBottom, boardTop: geometry.y },222+ titleLayout: { ...titleFit, x: titleX, y: titleTop, width: inlineCounter ? Math.max(1, width - titleX - 16) : titleWidth, sublineTop, hudBottom, boardTop: geometry.y, inlineCounter },203223 controlledCenter,204224 ariaLabel: `Miau Is True proof: ${fullProofTitle}`,205225 };@@ -254,7 +254,9 @@ function drawCard(ctx, view, frame, winningRule) {
254254 ctx.strokeRect(cardX, cardY, cardWidth, cardHeight);255255 256256 const proofTitle = String(view.boardState?.title || '').trim();257- const titleFit = fitSceneTitle(ctx, proofTitle, { maxWidth: cardWidth - 64, maxSize: 22, minSize: 16, maxLines: 2 });257+ let titleFit = fitSceneTitle(ctx, proofTitle, { maxWidth: cardWidth - 64, maxSize: 22, minSize: 16, maxLines: 2 });258+ if (w <= 760 && titleFit.overflow)259+ titleFit = fitSceneTitle(ctx, proofTitle, { maxWidth: cardWidth - 32, maxSize: 16, minSize: 16, maxLines: 3 });258260 const titleTop = cardY + 20;259261 ctx.fillStyle = '#F2EEE6';260262 ctx.font = `800 ${titleFit.size}px Recursive, monospace`;@@ -90,6 +90,14 @@ try {
9090 ]) {9191 const context = await browser.newContext({ viewport, isMobile: name === 'mobile', hasTouch: name === 'mobile' });9292 const page = await context.newPage();93+ await page.addInitScript(() => {94+ window.__miauTextCalls = [];95+ const original = CanvasRenderingContext2D.prototype.fillText;96+ CanvasRenderingContext2D.prototype.fillText = function(value, x, y, ...rest) {97+ if (typeof value === 'string') window.__miauTextCalls.push({ value, x, y, font: this.font, align: this.textAlign });98+ return original.call(this, value, x, y, ...rest);99+ };100+ });93101 page.on('console', message => { if (message.type() === 'error') errors.push(`${name}: console: ${message.text()}`); });94102 page.on('pageerror', error => errors.push(`${name}: page: ${error.message}`));95103 await page.goto(shareUrl, { waitUntil: 'networkidle' });@@ -97,6 +105,9 @@ try {
97105 await page.keyboard.press('Enter');98106 await waitScene(page, 'index');99107 await page.waitForTimeout(700);108+ assert.match(await page.locator('.stage-canvas').getAttribute('aria-label') || '', /Press Enter to enter/, `${name}: first entry shows a world card`);109+ await page.keyboard.press('Enter');110+ await page.waitForTimeout(100);100111 let metrics = await assertViewport(page, `${name} INDEX`);101112 assert.equal(metrics.state.level, authoredTitle, `${name}: INDEX must retain authored title`);102113 await page.screenshot({ path: `${screenshotDir}/long-title-${name}-index.png` });@@ -104,9 +115,35 @@ try {
104115 await page.keyboard.press('Enter');105116 await waitScene(page, 'proof');106117 await page.waitForTimeout(700);118+ await page.evaluate(() => {119+ window.__miauTextCalls = [];120+ window.dispatchEvent(new Event('resize'));121+ });122+ await page.waitForTimeout(100);107123 metrics = await assertViewport(page, `${name} proof`);108124 assert.equal(metrics.state.level, authoredTitle, `${name}: proof must retain authored title`);109125 assert.equal(metrics.state.moves, 0, `${name}: proof starts at zero moves`);126+ const proofText = await page.evaluate(() => window.__miauTextCalls);127+ const titleWords = authoredTitle.split(/\s+/);128+ assert.ok(titleWords.every(word => proofText.some(call => call.value === word || call.value.includes(word))), `${name}: every title word remains visible`);129+ assert.equal(proofText.some(call => call.value.includes('…')), false, `${name}: authored title has no ellipsis`);130+ if (name === 'mobile') {131+ const steps = proofText.find(call => call.value === 'STEPS');132+ const proofLine = proofText.find(call => call.value.startsWith('PROOF '));133+ assert.ok(steps && proofLine, 'mobile: compact counter and proof subline are both drawn');134+ assert.equal(Math.round(steps.y), Math.round(proofLine.y + 8), 'mobile: STEPS shares the proof subline row');135+ const titleCalls = proofText.filter(call => titleWords.some(word => call.value.includes(word)) && call.font.includes('16px') && call.align === 'left');136+ assert.ok(titleCalls.length >= 2 && titleCalls.length <= 3, 'mobile: full-width fallback uses at most three 16px lines');137+ const measured = await page.evaluate(() => {138+ const canvas = document.querySelector('.stage-canvas');139+ const ctx = canvas.getContext('2d');140+ return window.__miauTextCalls.filter(call => call.font.includes('16px') && call.value.length > 0).map(call => {141+ ctx.font = call.font;142+ return { value: call.value, right: call.x + ctx.measureText(call.value).width };143+ });144+ });145+ assert.ok(measured.filter(call => titleWords.some(word => call.value.includes(word))).every(call => call.right <= 374), 'mobile: title lines clear the viewport inset and counter row');146+ }110147 await page.screenshot({ path: `${screenshotDir}/long-title-${name}-proof.png` });111148 112149 await page.keyboard.press('ArrowRight');@@ -106,6 +106,18 @@ test('warns on long proof titles without changing authored data', () => {
106106 });107107 });108108 109+test('rejects authored proof titles over the phone fitting ceiling', () => {110+ const title = 'x'.repeat(65);111+ assert.throws(() => loadMod(`module.exports = ${JSON.stringify({112+ manifest: { id: 'title-too-long', name: 'Title Too Long', version: '1.0.0' },113+ levels: [{ id: 'too-long', title, width: 1, height: 1, objects: [] }],114+ })};`), error => {115+ assert.ok(error instanceof ModValidationError);116+ assert.match(error.message, /levels\[0\]\.title: Titles can be at most 64 characters so they fit on phones/);117+ return true;118+ });119+});120+109121 test('loads the minimal playable fox automata proof', () => {110122 const mod = loadMod(automataExampleSource, { filename: 'automata-example.cjs' });111123 assert.equal(mod.manifest.id, 'automata-example');@@ -39,3 +39,14 @@ test('title fitting reports an unbreakable overflow without dropping copy', () =
3939 assert.equal(fit.lines.join(' '), fit.text);4040 assert.equal(fit.overflow, true);4141 });42+43+test('mobile fallback fits a 51-character title in three 16px lines', () => {44+ const text = 'A Very Long Authored Proof Title for Narrow Screens';45+ const twoLine = fitSceneTitle(context(), text, { maxWidth: 180, maxSize: 22, minSize: 16, maxLines: 2 });46+ assert.equal(twoLine.overflow, true);47+ const fallback = fitSceneTitle(context(), text, { maxWidth: 180, maxSize: 16, minSize: 16, maxLines: 3 });48+ assert.equal(fallback.overflow, false);49+ assert.equal(fallback.size, 16);50+ assert.equal(fallback.lines.join(' '), text);51+ assert.equal(fallback.lines.length, 3);52+});