From 5176560f05a34aace85d7c8bfdd41c6119e67ad3 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Sat, 15 Aug 2026 14:17:50 -0400 Subject: [PATCH] feat(daily-challenge): make the card worth looking at The card was a heading, a board and a row of figures stacked down the page. It is now one tight panel: the board on the left, everything readable on the right, and the same panel the archive stacks - so a challenge looks like itself wherever it is read. The two forces are the largest thing on it, head to head in the colours they fight in, with a word for how close the match is rather than two numbers to compare by eye. The board reads as a screen: a scanline wash, a lit edge and an inner shadow, with every machine tagged by chassis and designation over the ground its side deploys on. The opposition is red now rather than the orange it read as before. Attempts is gone. A figure reading "0" for most of a day says the fight is unplayed rather than new, which is the opposite of an invitation. Co-Authored-By: Claude Opus 5 (1M context) --- web/scripts/daily-play.test.mjs | 17 +-- web/src/scenario/designation.ts | 49 ++++++++ web/src/scenario/preview.ts | 27 +---- web/src/screens/daily-archive.ts | 66 +--------- web/src/screens/daily.ts | 202 +++++++++++++++++++++++-------- web/src/styles.css | 188 ++++++++++++++++++++++++++++ 6 files changed, 405 insertions(+), 144 deletions(-) create mode 100644 web/src/scenario/designation.ts diff --git a/web/scripts/daily-play.test.mjs b/web/scripts/daily-play.test.mjs index 3044b18..f30b615 100644 --- a/web/scripts/daily-play.test.mjs +++ b/web/scripts/daily-play.test.mjs @@ -159,10 +159,10 @@ test("the card hands its dailyStatus answer to the button", () => { // `attempted` and `score`. Dropping this wiring costs nothing visible - the // count still fills in, and Play simply stays pressable for a player who // has already had their go. - const card = daily.match( - /export function dailyCard\(\): HTMLElement \| null \{[\s\S]*?\n\}/, - ); - assert.ok(card, "dailyCard moved or was renamed"); + // The wiring lives in challengeCard, which the front page and the archive + // both render; dailyCard is now just "the one whose day is today". + const card = daily.match(/export function challengeCard\([\s\S]*?\n\}/); + assert.ok(card, "challengeCard moved or was renamed"); assert.match( card[0], /play\.applyStatus\(status\)/, @@ -213,11 +213,14 @@ test("the lobby screen is imported on the press, not by the front page", () => { }); test("the archive carries the same control, and says it does not score", () => { + // The archive stacks the same card the front page shows, so it carries the + // control by construction rather than by repeating it. assert.match( archive, - /\.\.\.playControl\(challenge, today\)\.nodes/, - "the archive rows no longer carry a Play control - this file's own doc " + - "says a backlog challenge is playable, and nothing here let anyone", + /challengeCard\(challenge, today\)/, + "the archive rows no longer render the shared challenge card - this " + + "file's own doc says a backlog challenge is playable, and nothing " + + "here let anyone", ); assert.ok(control, "playControl moved, was renamed, or was never added"); assert.match( diff --git a/web/src/scenario/designation.ts b/web/src/scenario/designation.ts new file mode 100644 index 0000000..a74b096 --- /dev/null +++ b/web/src/scenario/designation.ts @@ -0,0 +1,49 @@ +/** + * A unit's name, split into the part that names the machine and the part that + * names the variant. + * + * Its own module, importing nothing, and that is the point: `preview.ts` also + * wants `chassisOf`, but it names every unit sprite the site has, so anything + * importing it eagerly pulls that whole map into the front page's first + * bundle. `analytics.test.mjs` holds a ceiling on exactly that. + */ + +/** + * The chassis: the head of the name, with the variant's designation dropped. + * + * "Thor (Summoner) A" reads better as its parenthetical-free head; the count + * suffix ("×4") is display text from the catalog and is kept. + */ +export function chassisOf(unit: string): string { + const count = / ×\d+$/.exec(unit)?.[0] ?? ""; + const head = unit.replace(/ ×\d+$/, ""); + const words = head.split(" "); + // Drop trailing model codes: a word with a digit, or a single letter. + while ( + words.length > 1 && + (/[0-9]/.test(words[words.length - 1]!) || + /^[A-Z]$/.test(words[words.length - 1]!) || + /^\(.*\)$/.test(words[words.length - 1]!) || + words[words.length - 1] === "Prime" || + words[words.length - 1] === "(Standard)") + ) { + words.pop(); + } + return words.join(" ") + count; +} + +/** + * The designation, without the chassis: `AS7-D` of `Atlas AS7-D`. + * + * What is left after the chassis can still be an alias rather than a model — + * `Hellhound (Conjurer) (Standard)` leaves `(Conjurer) (Standard)`, which + * identifies nothing and is wider than the machine it labels. Parentheticals + * go, and what remains is the designation or nothing. + */ +export function modelOf(unit: string, chassis = chassisOf(unit)): string { + return unit + .slice(chassis.length) + .replace(/\([^)]*\)/g, "") + .replace(/\s+/g, " ") + .trim(); +} diff --git a/web/src/scenario/preview.ts b/web/src/scenario/preview.ts index ed74057..cd5fd07 100644 --- a/web/src/scenario/preview.ts +++ b/web/src/scenario/preview.ts @@ -179,28 +179,11 @@ function tile(url: string): Promise { return loading; } -/** The short name for the caption: the chassis, not the whole designation. - * Exported for lobby.ts's force cards, whose unit bullets caption the same - * way this panel's hexes do. */ -export function chassisOf(unit: string): string { - // "Thor (Summoner) D" reads better as its parenthetical-free head; the - // count suffix ("×4") is display text from the catalog and is kept. - const count = / ×\d+$/.exec(unit)?.[0] ?? ""; - const head = unit.replace(/ ×\d+$/, ""); - const words = head.split(" "); - // Drop trailing model codes: a word with a digit, or a single letter. - while ( - words.length > 1 && - (/[0-9]/.test(words[words.length - 1]!) || - /^[A-Z]$/.test(words[words.length - 1]!) || - /^\(.*\)$/.test(words[words.length - 1]!) || - words[words.length - 1] === "Prime" || - words[words.length - 1] === "(Standard)") - ) { - words.pop(); - } - return words.join(" ") + count; -} +// The caption's short name, and the variant beside it. Defined in +// designation.ts because the daily card wants them without this module's +// sprite map; re-exported here so lobby.ts's force cards keep their import. +export { chassisOf, modelOf } from "./designation"; +import { chassisOf } from "./designation"; /** * One unit's art in the given camo (null for bare sprite grey), with nothing diff --git a/web/src/screens/daily-archive.ts b/web/src/screens/daily-archive.ts index 97fe3f2..da227e5 100644 --- a/web/src/screens/daily-archive.ts +++ b/web/src/screens/daily-archive.ts @@ -17,69 +17,7 @@ import { footer } from "../chrome"; import { el } from "../dom"; -import { - archive, - boardCanvas, - eraStat, - playControl, - scenarioHref, - utcDay, - type Challenge, -} from "./daily"; - -const number = new Intl.NumberFormat(); - -/** One past day. */ -function entry(challenge: Challenge, today: string): HTMLElement { - const current = challenge.date === today; - return el("section", { className: "card daily-archive-entry" }, [ - el("div", { className: "daily-head" }, [ - el("p", { className: "daily-eyebrow", textContent: challenge.date }), - el("p", { - className: "daily-soon", - // The one row where playing still counts. Everything below it is a - // backlog fight, and saying which is which here is cheaper than - // explaining it once at the top and hoping it is read. - textContent: current ? "Today — scored" : "Backlog — not scored", - }), - ]), - el("h2", { textContent: challenge.name }), - el("p", { className: "hint daily-blurb", textContent: challenge.blurb }), - el("div", { className: "daily-body" }, [ - el("div", { className: "daily-map-frame" }, [boardCanvas(challenge)]), - el("div", { className: "daily-stats" }, [ - ...challenge.sides.map((side) => - el("div", { className: "daily-stat" }, [ - el("p", { - className: "daily-value", - textContent: number.format(side.bv), - }), - el("p", { - className: "daily-label", - textContent: `${side.name} BV, ${side.units.length} units`, - }), - ]), - ), - eraStat(challenge), - ]), - ]), - el("p", { className: "daily-actions" }, [ - // The same control the front-page card carries. This page's own doc - // already said a backlog challenge is playable; until now nothing here - // let anyone play one, and the control says on its face that it does - // not score. It asks the control plane nothing to render, so this page - // still draws with the API down — a press is the first thing that - // needs it. - ...playControl(challenge, today).nodes, - el("a", { - className: "daily-download", - href: scenarioHref(challenge.slug), - download: `${challenge.slug}.mms`, - textContent: "Download for MegaMek", - }), - ]), - ]); -} +import { archive, challengeCard, utcDay } from "./daily"; export function dailyArchiveScreen(): Node[] { const today = utcDay(); @@ -95,7 +33,7 @@ export function dailyArchiveScreen(): Node[] { ? "Nothing has been set yet." : "One fight a day, the same for everyone. Today's is scored; the rest are here to play.", }), - ...past.map((challenge) => entry(challenge, today)), + ...past.map((challenge) => challengeCard(challenge, today)), ]), footer(), ]; diff --git a/web/src/screens/daily.ts b/web/src/screens/daily.ts index 9faa6d7..3ae54b8 100644 --- a/web/src/screens/daily.ts +++ b/web/src/screens/daily.ts @@ -34,6 +34,10 @@ import { el, render } from "../dom"; import { nav } from "../nav"; import { SPRITE_H, SPRITE_W } from "../camo/mech"; import dailyJson from "../content/daily.json"; +// From designation.ts and not preview.ts: that module names every unit +// sprite the site has, and importing it here would put the whole map in the +// front page's eager bundle - which analytics.test.mjs fails the build over. +import { chassisOf, modelOf } from "../scenario/designation"; import { eraFor } from "../content/eras"; import { ERA_ICONS } from "../content/trivia"; @@ -254,9 +258,18 @@ function drawMap(canvas: HTMLCanvasElement, challenge: Challenge): void { */ export const SIDE_COLOURS: readonly (readonly [number, number, number])[] = [ [86, 132, 199], - [186, 60, 52], + // Green *below* blue, which puts the hue just past 0 into the cool half of + // red. The previous value had green above blue - hue about 4 degrees, on + // the orange side - and MegaMek's sprite art is warm enough that it read as + // orange once the camo was multiplied through it. (Written without a + // bracketed triple on purpose: daily-play.test.mjs counts those to check + // there are exactly two sides.) + [201, 42, 58], ]; +/** The same two colours as CSS, for the figures beside the board. */ +export const SIDE_CSS = SIDE_COLOURS.map(([r, g, b]) => `rgb(${r} ${g} ${b})`); + /** A whole sprite in one colour, for a force with no camo of its own. */ export function flatCamo([r, g, b]: readonly [ number, @@ -320,20 +333,64 @@ export function eraStat(challenge: Challenge): HTMLElement { ]); } -/** The board picture, sized to the schematic grid. */ -export function boardCanvas(challenge: Challenge): HTMLCanvasElement { - const map = el("canvas", { className: "daily-map" }); - map.width = challenge.board.columns * SPRITE_W; - map.height = challenge.board.rows * SPRITE_H; +/** + * The board picture, and a tag under every machine on it. + * + * The tags are HTML over the canvas rather than text drawn into it: real text + * stays crisp at any zoom, picks up the theme's colour, and can be read by + * something that is not an eye. They are positioned with the same + * `(i + 0.5) / n` the sprites are, so the two cannot drift apart. + * + * `chassisOf` and not the full designation - eight units across a board this + * wide leaves about eighty pixels each, which fits "Archer" and not + * "Archer ARC-2R". + */ +export function boardFigure(challenge: Challenge): HTMLElement { + const canvas = el("canvas", { className: "daily-map" }); + canvas.width = challenge.board.columns * SPRITE_W; + canvas.height = challenge.board.rows * SPRITE_H; // What the picture shows, for anyone who cannot see it. The forces are // named and counted rather than described hex by hex: the map is a summary, // and so is this. - map.role = "img"; - map.ariaLabel = challenge.sides + canvas.role = "img"; + canvas.ariaLabel = challenge.sides .map((side) => `${side.name}, ${side.units.length} units`) .join("; "); - drawMap(map, challenge); - return map; + drawMap(canvas, challenge); + + const tags: HTMLElement[] = []; + for (const [index, side] of challenge.sides.entries()) { + for (const row of new Set(side.units.map((unit) => unit.row))) { + const line = side.units.filter((unit) => unit.row === row); + for (const [i, unit] of line.entries()) { + const chassis = chassisOf(unit.name); + const model = modelOf(unit.name, chassis); + const tag = el("span", { className: "daily-unit-tag" }, [ + el("span", { className: "daily-unit-chassis", textContent: chassis }), + ...(model + ? [ + el("span", { + className: "daily-unit-model", + textContent: model, + }), + ] + : []), + ]); + tag.style.left = `${((i + 0.5) / line.length) * 100}%`; + // Anchored to the edge of its row that faces the middle of the board, + // so every tag floats over the empty ground between the two forces + // rather than over a machine or off the picture. + // Anchored to the board's own outer edge on this side's half, so the + // two forces' tags use the top and bottom bands rather than both + // crowding the empty ground between them, where they overlapped. + tag.style.top = index === 0 ? "100%" : "0%"; + tag.dataset.side = String(index); + tags.push(tag); + } + } + } + + return el("div", { className: "daily-map-frame" }, [canvas, ...tags]); } /** The Play button and the line beside it, plus a way to tell it what the @@ -415,6 +472,45 @@ export function playControl(challenge: Challenge, day: string): PlayControl { }; } +/** + * The two forces, head to head. + * + * The pairing is the whole proposition of a daily challenge — the fights are + * matched on battle value, and this is where a reader sees that before they + * commit. Two numerals in the colours their forces fight in on the board + * beside it, so the pairing needs no legend, and a word for how close it is, + * because "6,633 against 6,633" is a fact a reader should not have to work out + * by comparing digits. + */ +function bvHeadToHead(challenge: Challenge): HTMLElement { + const [a, b] = challenge.sides; + if (!a || !b) return el("div"); + const gap = + a.bv > 0 && b.bv > 0 ? Math.abs(a.bv - b.bv) / Math.max(a.bv, b.bv) : 0; + + const side = (force: Side, index: number, align: string): HTMLElement => { + const block = el("div", { className: `daily-bv-side ${align}` }, [ + el("p", { + className: "daily-bv-n", + textContent: number.format(force.bv), + }), + el("p", { className: "daily-bv-l", textContent: force.name }), + ]); + block.style.setProperty("--side", SIDE_CSS[index] ?? SIDE_CSS[0] ?? ""); + return block; + }; + + return el("div", { className: "daily-bv" }, [ + side(a, 0, "daily-bv-left"), + el("span", { + className: "daily-bv-vs", + // Rounded, so a hair of difference does not read as a real edge. + textContent: gap < 0.015 ? "even" : `${Math.round(gap * 100)}%`, + }), + side(b, 1, "daily-bv-right"), + ]); +} + /** * The card, for whichever fight today's is. * @@ -425,58 +521,62 @@ export function dailyCard(): HTMLElement | null { const day = utcDay(); const challenge = challengeFor(day); if (!challenge) return null; + return challengeCard(challenge, day); +} - const played = el("p", { className: "daily-value", textContent: "—" }); +/** + * One challenge, as a card: the board on the left and everything readable on + * the right. + * + * The same panel the archive stacks, so a challenge looks like itself wherever + * it is read, and the front page's copy is simply the one whose day is today. + */ +export function challengeCard(challenge: Challenge, day: string): HTMLElement { const play = playControl(challenge, day); - // The one figure that is not in the bundle, and now also the one answer that - // says whether this reader has already had their go. A card that waited for - // either would be a card that goes blank when the control plane does, so - // Play starts pressable and a spent attempt disables it a moment later — - // and if the answer never comes, the API refuses the press itself. + // The one thing here that is not in the bundle: whether this reader has + // already had their go. A card that waited for it would go blank whenever + // the control plane did, so Play starts pressable and a spent attempt + // disables it a moment later - and if the answer never comes, the API + // refuses the press itself. + // + // The attempt *count* is deliberately not shown. A figure reading "0" for + // most of a day says the fight is unplayed rather than new, which is the + // opposite of an invitation. void dailyStatus(challenge.slug) .then((status) => { - played.textContent = number.format(status.attempts); play.applyStatus(status); }) .catch(() => { - /* the count is the least of what is on this card */ + /* the press itself is still guarded, server-side */ }); return el("section", { className: "card daily" }, [ - el("div", { className: "daily-head" }, [ - el("p", { className: "daily-eyebrow", textContent: "Daily challenge" }), - el("p", { - className: "daily-soon", - textContent: - challenge.date === day ? "Today" : `Since ${challenge.date}`, - }), - ]), - el("h2", { textContent: challenge.name }), - el("p", { className: "hint daily-blurb", textContent: challenge.blurb }), - el("div", { className: "daily-body" }, [ - el("div", { className: "daily-map-frame" }, [boardCanvas(challenge)]), - el("div", { className: "daily-stats" }, [ - ...challenge.sides.map((side) => - stat(`${side.name} BV`, number.format(side.bv)), - ), - eraStat(challenge), - el("div", { className: "daily-stat" }, [ - played, - el("p", { className: "daily-label", textContent: "Attempts" }), - ]), + el("div", { className: "daily-board" }, [boardFigure(challenge)]), + el("div", { className: "daily-text" }, [ + el("div", { className: "daily-head" }, [ + el("p", { className: "daily-eyebrow", textContent: "Daily challenge" }), + el("p", { + className: "daily-soon", + textContent: + challenge.date === day ? "Today" : `Since ${challenge.date}`, + }), + ]), + el("h2", { textContent: challenge.name }), + el("p", { className: "hint daily-blurb", textContent: challenge.blurb }), + bvHeadToHead(challenge), + eraStat(challenge), + el("p", { className: "daily-actions" }, [ + ...play.nodes, + el("a", { + className: "daily-download", + href: scenarioHref(challenge.slug), + // download rather than navigate: a .mms is text, and a browser that + // decides to render it instead of saving it has eaten the link. + download: `${challenge.slug}.mms`, + textContent: "Download for MegaMek", + }), + el("a", { href: "/#daily", textContent: "Past challenges" }), ]), - ]), - el("p", { className: "daily-actions" }, [ - ...play.nodes, - el("a", { - className: "daily-download", - href: scenarioHref(challenge.slug), - // download rather than navigate: a .mms is text, and a browser that - // decides to render it instead of saving it has eaten the link. - download: `${challenge.slug}.mms`, - textContent: "Download for MegaMek", - }), - el("a", { href: "/#daily", textContent: "Past challenges" }), ]), ]); } diff --git a/web/src/styles.css b/web/src/styles.css index 06e52d8..ed9a729 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1657,6 +1657,63 @@ body:has(.hero) { margin-top: 1rem; } +/* A machine's name under it on the board. Positioned by daily.ts against the + same fraction the sprite is, so the two cannot drift apart; the near side's + hang below their units and the far side's sit above, both running into the + empty middle of the board rather than off its edges. */ +.daily-map-frame { + position: relative; +} + +.daily-unit-tag { + position: absolute; + /* Over the board, along the edge each force deploys on: the near side's + tags sit inside the bottom, the far side's inside the top. Both bands are + empty ground - the machines stand in from the edge - and putting them + here keeps the two sets from overlapping in the middle. */ + transform: translate(-50%, -100%) translateY(-0.15rem); + display: grid; + justify-items: center; + line-height: 1.15; + letter-spacing: 0.04em; + text-transform: uppercase; + white-space: nowrap; + pointer-events: none; + /* Floating over unit art and hex lines, so it carries its own contrast + rather than relying on whatever happens to be behind it. */ + text-shadow: + 0 1px 2px rgb(0 0 0 / 90%), + 0 0 6px rgb(0 0 0 / 80%); +} + +.daily-unit-chassis { + font-size: 0.6rem; + font-weight: 600; + color: rgb(233 238 245); +} + +.daily-unit-model { + font-size: 0.54rem; + color: rgb(233 238 245 / 62%); +} + +/* The far side sits inside the top edge instead. */ +.daily-unit-tag[data-side="1"] { + transform: translate(-50%, 0.15rem); +} + +/* A force's battle value, in the colour that force fights in on the board + beside it. The bar under the figure is the same colour at full strength - + the number is set in the theme's text colour, because a coloured numeral at + this size fails contrast on the light theme. */ +.daily-stat-side { + border-left: 3px solid var(--side); +} + +.daily-stat-side .daily-label { + color: color-mix(in srgb, var(--side) 78%, var(--text)); +} + /* --- the three facts ----------------------------------------------------- What is true today, including the part that is a limitation. */ @@ -4281,3 +4338,134 @@ footer .debug { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 45%, transparent); } + +/* --- the daily challenge card ---------------------------------------------- + Board on the left, everything readable on the right. Tight rather than + full-bleed: the card is an invitation, not the page. + + The board is a screen: see the scanline treatment below. */ + +.daily { + display: grid; + grid-template-columns: minmax(0, 320px) minmax(0, 1fr); + gap: 1.25rem; + align-items: center; +} + +.daily-text { + display: grid; + gap: 0.5rem; + align-content: center; +} + +.daily-text h2 { + margin: 0; + font-size: 1.5rem; +} + +.daily-blurb { + margin: 0; + max-width: 46ch; +} + +.daily-board .daily-map { + width: 100%; + height: auto; + image-rendering: pixelated; +} + +/* The two forces, head to head. */ +.daily-bv { + display: flex; + align-items: center; + gap: 0.9rem; + margin: 0.25rem 0; +} + +.daily-bv-side { + display: grid; + gap: 0.05rem; +} + +.daily-bv-right { + text-align: right; +} + +.daily-bv-n { + margin: 0; + font-size: 1.85rem; + font-weight: 700; + line-height: 1; + font-variant-numeric: tabular-nums; + color: var(--side); +} + +.daily-bv-l { + margin: 0; + font-size: 0.6rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: color-mix(in srgb, var(--text) 55%, transparent); +} + +.daily-bv-vs { + flex: 1; + text-align: center; + font-size: 0.62rem; + letter-spacing: 0.16em; + text-transform: uppercase; + color: color-mix(in srgb, var(--text) 42%, transparent); +} + +@media (max-width: 46rem) { + .daily { + grid-template-columns: minmax(0, 1fr); + } +} + +/* The board reads as a screen: a scanline wash over it, a lit edge, and an + inner shadow that sinks it into the card. The wash is 3.5% white on a 3px + period - enough to register as a surface, not enough to fight the sprites. */ +.daily-map-frame { + border: 1px solid color-mix(in srgb, var(--brand) 35%, transparent); + border-radius: 0.3rem; + background: rgb(0 0 0 / 35%); + box-shadow: inset 0 0 30px rgb(0 0 0 / 60%); +} + +.daily-map-frame::after { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + border-radius: inherit; + background: repeating-linear-gradient( + to bottom, + rgb(255 255 255 / 3.5%) 0 1px, + transparent 1px 3px + ); +} + +.daily-eyebrow { + color: var(--brand); +} + +/* Each figure lit in its own force's colour. */ +.daily-bv-n { + text-shadow: 0 0 18px color-mix(in srgb, var(--side) 55%, transparent); +} + +.daily-play { + box-shadow: 0 0 0 0 color-mix(in srgb, var(--brand) 60%, transparent); + transition: box-shadow 150ms ease; +} + +.daily-play:hover:not(:disabled) { + box-shadow: 0 0 16px 0 color-mix(in srgb, var(--brand) 60%, transparent); +} + +@media (prefers-reduced-motion: reduce) { + .daily-play { + transition: none; + } +} -- 2.51.2