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; + } +}