diff --git a/plan/site-copy.md b/plan/site-copy.md index 939a6ed..4d05bc0 100644 --- a/plan/site-copy.md +++ b/plan/site-copy.md @@ -56,6 +56,12 @@ lance.blue asks for, not brand copy. **The waiting screen.** Its lines rotate real trivia from `web/src/content/trivia.csv`. +**The alpha stamp**, in `web/src/alpha-stamp.ts` and stamped on the masthead, +the landing hero and the footer. One word, and it is a fact about the state of +the software rather than a claim about the project. It leaves when the software +is no longer alpha, and until then it is the one thing on the page saying so +while the disclaimer above it is still filler. + **The About page's status list** — now the list on the Development goals page, in `web/src/content/about.json` — and the FAQ answers (`faq.json`). These say what works today, in prose, and nothing checks them against reality. That is accepted: they are human copy, and a check that diff --git a/web/scripts/alpha-stamp.test.mjs b/web/scripts/alpha-stamp.test.mjs new file mode 100644 index 0000000..149fe44 --- /dev/null +++ b/web/scripts/alpha-stamp.test.mjs @@ -0,0 +1,75 @@ +/** + * The stamp says one word, in three places, and two of those places cannot + * import the file that defines it. + * + * alpha-stamp.ts builds the hero's and the app footer's. Base.astro's masthead + * and Footer.astro write the same span by hand, because the blog's pages never + * run the app. Nothing errors when they drift: the page still renders, with + * two different words on it, or with a stamp that has lost its styling and is + * a bare word in the middle of the masthead. + * + * Run with `npm test`. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +const web = join(new URL(".", import.meta.url).pathname, ".."); +const read = (path) => readFile(join(web, path), "utf8"); + +const stamp = await read("src/alpha-stamp.ts"); +const layout = await read("src/layouts/Base.astro"); +const astroFooter = await read("src/components/Footer.astro"); +const chrome = await read("src/chrome.ts"); +const home = await read("src/screens/home.ts"); +const css = await read("src/styles.css"); + +const word = stamp.match(/ALPHA = "([^"]+)"/)?.[1]; +assert.ok(word, "alpha-stamp.ts no longer declares ALPHA as a literal"); + +test("the two hand-written stamps say what alpha-stamp.ts says", () => { + for (const [name, source] of [ + ["Base.astro", layout], + ["Footer.astro", astroFooter], + ]) { + assert.match( + source, + new RegExp(`class="stamp stamp-\\w+"[^>]*>${word}<`), + `${name} does not stamp ${word}`, + ); + } +}); + +test("the masthead's stamp is outside the brand link", () => { + // Inside it, the anchor's aria-label would replace it and the one copy on + // the page a screen reader is given would be the one it never reads. + const brand = layout.slice( + layout.indexOf('"), + ); + assert.doesNotMatch(brand, /class="stamp/, "the stamp is inside the anchor"); +}); + +test("every place that stamps has a rule to draw it with", () => { + const variants = new Set([ + ...[...stamp.matchAll(/"(hero|foot)"/g)].map((m) => m[1]), + ...[...layout.matchAll(/class="stamp stamp-(\w+)"/g)].map((m) => m[1]), + ...[...astroFooter.matchAll(/class="stamp stamp-(\w+)"/g)].map((m) => m[1]), + ]); + + assert.ok(css.includes(".stamp {"), "styles.css has no .stamp"); + for (const variant of variants) { + assert.ok( + css.includes(`.stamp-${variant} {`), + `styles.css has no .stamp-${variant}`, + ); + } + // The masthead's sits beside the wordmark in a box of its own. + assert.ok(css.includes(".brand-block {"), "styles.css has no .brand-block"); +}); + +test("the app's own two stamps are still stamped", () => { + assert.match(home, /alphaStamp\("hero"\)/, "the hero lost its stamp"); + assert.match(chrome, /alphaStamp\("foot"\)/, "the app footer lost its stamp"); +}); diff --git a/web/src/alpha-stamp.ts b/web/src/alpha-stamp.ts new file mode 100644 index 0000000..12f03fc --- /dev/null +++ b/web/src/alpha-stamp.ts @@ -0,0 +1,39 @@ +/** + * The word ALPHA, stamped on the site. + * + * Three places, on purpose: beside the wordmark, where it is on every page the + * site has; across the corner of the landing hero, where a first visit lands; + * and above the footer's version line, at the bottom of whatever page a reader + * ends up on. It is a status and not decoration — nothing here is finished, + * and a player should not have to find that out from a match that breaks. + * + * One word in one style, so the three read as the same mark rather than three + * badges. The look is in styles.css under `.stamp`. + * + * The masthead's copy is not built here. It has to be in the HTML the server + * sends, because the blog's pages never run the app, so layouts/Base.astro + * writes the same span by hand — as does components/Footer.astro, the footer + * on those same pages. scripts/alpha-stamp.test.mjs keeps the copies together. + */ + +import { el } from "./dom"; + +/** Uppercased by the stylesheet, so this is the word and not a shout. */ +export const ALPHA = "Alpha"; + +/** + * A stamp for the app's own screens. `variant` picks the size and the angle: + * "hero" is the big one over the landing panel, "foot" the small one above + * the version line. + * + * Hidden from screen readers, both of them. The masthead is above every one of + * these on the page and already says it; a reader who has heard it once does + * not need it twice more, and there is nothing here to act on. + */ +export function alphaStamp(variant: "hero" | "foot"): HTMLElement { + return el("span", { + className: `stamp stamp-${variant}`, + textContent: ALPHA, + "aria-hidden": "true", + }); +} diff --git a/web/src/chrome.ts b/web/src/chrome.ts index 631f621..59778d5 100644 --- a/web/src/chrome.ts +++ b/web/src/chrome.ts @@ -6,6 +6,7 @@ * layout that renders it reads too. */ +import { alphaStamp } from "./alpha-stamp"; import { fetchVersion } from "./api"; import { type AboutPageId, destinationForRoute } from "./destinations"; import { el } from "./dom"; @@ -99,6 +100,11 @@ export function footer(): HTMLElement { }); return el("footer", {}, [ + // The bottom of whatever screen the player stopped on. Same word as the + // masthead's, so the page says it where a reader arrives and where they + // run out of page; components/Footer.astro carries it on the pages the + // app never runs on. + alphaStamp("foot"), // Placeholder standing in for a disclaimer. The line it replaces said the // project is non-commercial and unaffiliated, which is worth having back on // every page — this is the one placeholder that costs something while it diff --git a/web/src/components/Footer.astro b/web/src/components/Footer.astro index 351c514..01e87bf 100644 --- a/web/src/components/Footer.astro +++ b/web/src/components/Footer.astro @@ -12,6 +12,12 @@ const version = import.meta.env.DEV ---