- commit
- 7b6e2c92a8bffb82f73f455cf4cb036db746e375
- parent
- e02efa12a5ab63ea22221df64660738818d67bdc
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 70 | ++++++++++++++++++++---------- |
| M | tests/ | 31 | ++++++++++++-- |
@@ -1,9 +1,8 @@
11 import { BOARD_COLORS } from './board/model.js';22 33 /**4- * Colours for the world-entry card. The property accent is only a signal5- * colour; the card itself keeps the ink-and-signal neutral surfaces used by6- * the other menu renderers.4+ * Colours for the world-entry card. The property accent identifies the card5+ * border and action; the surfaces use the neutral menu palette.76 */87 export const WORLD_CARD_COLORS = Object.freeze({98 ink: '#0F0E0D',@@ -82,7 +81,7 @@ function accentColor(world, colors) {
8281 8382 function drawKeycap(ctx, x, y, options = {}) {8483 const key = 'ENTER';85- const label = 'enter';84+ const label = 'enter world';8685 ctx.save();8786 ctx.font = `800 10px ${FONT}`;8887 const keyWidth = Math.max(42, ctx.measureText(key).width + 14);@@ -114,6 +113,22 @@ function drawKeycap(ctx, x, y, options = {}) {
114113 };115114 }116115 116+function drawMobileAction(ctx, card, y, accent, colors, pressed = false) {117+ const geometry = { x: card.x + 16, y, width: card.width - 32, height: 48 };118+ ctx.save();119+ ctx.fillStyle = colors.lip;120+ ctx.fillRect(geometry.x + 4, geometry.y + 4, geometry.width, geometry.height);121+ ctx.fillStyle = accent;122+ ctx.fillRect(geometry.x, geometry.y + (pressed ? 4 : 0), geometry.width, geometry.height);123+ ctx.fillStyle = colors.ink;124+ ctx.font = `800 12px ${FONT}`;125+ ctx.textAlign = 'center';126+ ctx.textBaseline = 'middle';127+ ctx.fillText('ENTER WORLD', geometry.x + geometry.width / 2, geometry.y + geometry.height / 2 + (pressed ? 2 : 0));128+ ctx.restore();129+ return { ...geometry, visual: geometry };130+}131+117132 function colorsFor(options) {118133 return options.colors || WORLD_CARD_COLORS;119134 }@@ -135,7 +150,9 @@ export function drawWorldCard(ctx, world, options = {}) {
135150 const progress = reducedMotion ? 1 : clamp(Number(options.irisProgress ?? 1), 0, 1);136151 const mobile = width < 560 || options.coarsePointer === true;137152 const cardWidth = Math.min(width - 32, mobile ? 420 : 620);138- const cardHeight = Math.min(height - 32, mobile ? 452 : 430);153+ // The action belongs to the content group. Keeping the card compact leaves154+ // the same 16px rhythm between metadata and the choice on every viewport.155+ const cardHeight = Math.min(height - 32, mobile ? 372 : 336);139156 const card = {140157 x: (width - cardWidth) / 2,141158 y: (height - cardHeight) / 2,@@ -144,13 +161,18 @@ export function drawWorldCard(ctx, world, options = {}) {
144161 };145162 const accent = accentColor(world, colors);146163 const name = worldValue(world, 'name', 'Untitled world');147- const author = worldValue(world, 'author', 'Unknown author');164+ const author = worldValue(world, 'author');148165 const glyph = worldValue(world, 'glyph', '✦') || '✦';149166 const tagline = worldValue(world, 'tagline');167+ const worldNumber = String(world?.number || 1).padStart(2, '0');168+ const proofTotal = Number.isFinite(world?.levelCount)169+ ? Math.max(0, world.levelCount)170+ : Array.isArray(world?.levels) ? world.levels.length : 0;171+ const completed = Number.isFinite(world?.completedCount) ? Math.max(0, world.completedCount) : 0;150172 const innerWidth = card.width - (mobile ? 32 : 64);151173 const glyphSize = 96;152- const titleY = card.y + (mobile ? 196 : 190);153- const titleFont = fitFont(ctx, name, innerWidth, mobile ? 28 : 32, 16);174+ const titleY = card.y + 192;175+ const titleFont = fitFont(ctx, name, innerWidth, 32, 16);154176 ctx.save();155177 ctx.fillStyle = colors.ink;156178 ctx.fillRect(0, 0, width, height);@@ -167,8 +189,8 @@ export function drawWorldCard(ctx, world, options = {}) {
167189 roundRect(ctx, card.x, card.y, card.width, card.height, 5);168190 ctx.fillStyle = colors.board;169191 ctx.fill();170- ctx.strokeStyle = colors.edge;171- ctx.lineWidth = 1;192+ ctx.strokeStyle = accent;193+ ctx.lineWidth = 2;172194 ctx.stroke();173195 174196 ctx.textAlign = 'center';@@ -178,30 +200,32 @@ export function drawWorldCard(ctx, world, options = {}) {
178200 ctx.fillText(glyph, width / 2, card.y + 92);179201 180202 ctx.fillStyle = accent;181- ctx.font = `700 11px ${FONT}`;182- ctx.fillText('A WORLD TO PROVE', width / 2, card.y + 142);203+ ctx.font = `800 11px ${FONT}`;204+ ctx.fillText(`WORLD ${worldNumber}`, width / 2, card.y + 144);183205 ctx.fillStyle = colors.fg;184206 ctx.font = titleFont;185207 ctx.fillText(name, width / 2, titleY);186208 187- const taglineLines = fitWrappedText(ctx, tagline, innerWidth, mobile ? 12 : 13, 9);209+ const taglineLines = fitWrappedText(ctx, tagline, innerWidth, 15, 10, 2);188210 ctx.fillStyle = colors.muted;189211 taglineLines.forEach((line, index) => ctx.fillText(line, width / 2, titleY + 35 + index * 16));190- const bylineY = titleY + 35 + taglineLines.length * 16 + 19;191- ctx.font = `600 11px ${FONT}`;192- ctx.fillText(`by ${author}`, width / 2, bylineY);193- const enterY = card.y + card.height - (mobile ? 48 : 52);194- const enter = drawKeycap(ctx, width / 2, enterY, {195- colors,196- press: options.press === true,197- coarsePointer: mobile,198- });212+ const meta = `${proofTotal} ${proofTotal === 1 ? 'proof' : 'proofs'} | ★ ${completed}${proofTotal ? ` / ${proofTotal}` : ' / 0'}${author ? ` | by ${author}` : ''}`;213+ const metaY = titleY + 35 + taglineLines.length * 16 + 24;214+ ctx.fillStyle = colors.muted;215+ ctx.font = fitFont(ctx, meta, innerWidth, 11, 8, 600);216+ ctx.fillText(meta, width / 2, metaY);217+ const enter = mobile218+ ? drawMobileAction(ctx, card, metaY + 32, accent, colors, options.press === true)219+ : drawKeycap(ctx, width / 2, metaY + 28, {220+ colors,221+ press: options.press === true,222+ });199223 ctx.restore();200224 201225 return {202226 card,203227 enter,204228 iris: { x: width / 2, y: height / 2, progress, radius: Math.hypot(width, height) * easeOut(progress) },205- world: { name, author, glyph, tagline, accent },229+ world: { name, author, glyph, tagline, accent, number: worldNumber, proofTotal, completed, meta },206230 };207231 }@@ -29,16 +29,21 @@ test('drawWorldCard returns enter hit geometry and normalized display values', (
2929 const result = drawWorldCard(ctx, {3030 name: 'Tidewater', author: 'Dami', glyph: '🌊',3131 tagline: "Everything floats until it doesn't.", accent: 'sink',32+ number: 3, levelCount: 9, completedCount: 4,3233 }, { width: 640, height: 420 });3334 assert.deepEqual(result.world, {3435 name: 'Tidewater', author: 'Dami', glyph: '🌊',3536 tagline: "Everything floats until it doesn't.", accent: WORLD_CARD_COLORS.sink,37+ number: '03', proofTotal: 9, completed: 4,38+ meta: "9 proofs | ★ 4 / 9 | by Dami",3639 });3740 assert.ok(result.enter.width > 60);3841 assert.equal(result.enter.height, 20);3942 assert.ok(result.enter.x >= 0 && result.enter.x + result.enter.width <= 640);4043 assert.ok(ctx.calls.some((call) => call[0] === 'fillText' && call[1] === '🌊'));41- assert.ok(ctx.calls.some((call) => call[0] === 'fillText' && call[1] === 'by Dami'));44+ assert.ok(ctx.calls.some((call) => call[0] === 'fillText' && call[1] === 'WORLD 03'));45+ assert.ok(ctx.calls.some((call) => call[0] === 'fillText' && call[1] === result.world.meta));46+ assert.ok(ctx.calls.some((call) => call[0] === 'stroke' && call[1] === WORLD_CARD_COLORS.sink));4247 });4348 4449 test('world card fits a narrow mobile canvas and wraps the tagline', () => {@@ -46,17 +51,35 @@ test('world card fits a narrow mobile canvas and wraps the tagline', () => {
4651 const result = drawWorldCard(ctx, {4752 name: 'A Very Long World Name', author: 'Dami', glyph: '🧭',4853 tagline: 'Every proof turns the compass toward another room.', accent: 'tick',54+ number: 2, levelCount: 3, completedCount: 1,4955 }, { width: 390, height: 844, coarsePointer: true });5056 assert.equal(result.card.x, 16);5157 assert.equal(result.card.width, 358);5258 assert.ok(result.card.y >= 16);5359 assert.ok(result.enter.y + result.enter.height <= result.card.y + result.card.height);5460 const lines = ctx.calls.filter((call) => call[0] === 'fillText').map((call) => call[1]);55- assert.ok(lines.length >= 7, 'mobile card should include two tagline lines plus card copy');61+ assert.ok(lines.length >= 7, 'mobile card should include wrapped tagline plus card copy');5662 assert.ok(lines.some((line) => line.startsWith('Every proof turns')));5763 assert.ok(lines.some((line) => line.endsWith('room.')));58- assert.equal(result.enter.height, 44, 'coarse-pointer ENTER hit target should be touch sized');59- assert.equal(result.enter.visual.height, 20, 'touch hit target should remain larger than visible keycap');64+ assert.equal(result.enter.height, 48, 'mobile action should be a 48px touch target');65+ assert.equal(result.enter.width, result.card.width - 32, 'mobile action should span the card content width');66+ assert.ok(lines.includes('ENTER WORLD'), 'mobile action should use the full button label');67+ const metaCall = ctx.calls.find((call) => call[0] === 'fillText' && call[1] === result.world.meta);68+ assert.ok(metaCall, 'mobile card should draw proof metadata');69+ assert.ok(result.enter.y - metaCall[3] <= 48, 'mobile action should stay close to metadata');70+});71+72+test('world card omits an absent byline while preserving proof metadata', () => {73+ const ctx = context();74+ const result = drawWorldCard(ctx, {75+ name: 'No Author', glyph: '✦', tagline: 'A small proof.',76+ number: 7, levelCount: 1, completedCount: 0,77+ }, { width: 390, height: 844 });78+ assert.equal(result.world.author, '');79+ assert.equal(result.world.meta, '1 proof | ★ 0 / 1');80+ const labels = ctx.calls.filter((call) => call[0] === 'fillText').map((call) => call[1]);81+ assert.ok(labels.includes(result.world.meta));82+ assert.ok(!labels.some((label) => String(label).includes('by ')));6083 });6184 6285 test('reduced motion settles the iris without clipping or changing card geometry', () => {