Files
runicgateway.com/scripts/checkScreens.mjs
wtclaude c29ec94f46
All checks were successful
PR checks / checks (pull_request) Successful in 1m25s
feat(screens): phase 9 — real screenshots, from a real shard
D4 asked for screenshots of the review stack rather than placeholders. Seventeen
of them: eleven of the site in a browser, six of the app on a phone, all from one
demo deployment wired to a running ServUO shard over a real sidecar, captured on
one day (D42).

The deployment is branded "Runic Gateway Demo" rather than a real community (D43),
and the captures sit beside the claims they support — the homepage, /features/, and
five of the administration pages phase 7 could describe but not show (D44).

The rig is committed rather than remembered (D45):

  scripts/seedDemo.mjs        content, by driving the site's own API — never SQL,
                              because a row the product could not have produced is
                              a screenshot of a product that does not exist
  src/data/screens.mjs        every capture: route, viewport, scroll, alt, caption
  scripts/captureScreens.mjs  npm run screens:capture
  scripts/checkScreens.mjs    the ninth check script, in CI

Shard-side dressing is servuo-plugins' scaffolding (D46), never deployed.

The rig found five things nothing else had. One is fixed upstream — a fresh
module-uo install pinned wire protocol 3 against a sidecar speaking 4, released as
v1.0.2, which this repo's own facts check then caught in platform.json. Four are
raised as product observations and worked around in the rig: a renamed guild
member never reaches the site, a guild deleted while the shard is down is a ghost
row forever, "Houses in danger" cannot show a house that was already collapsing,
and the app's news list prints raw ISO timestamps.

Players online reads 0. Logging a character in needs a UO client driven by hand,
and that is where this stopped — PLAN.md §10 says exactly why, and how to retake
the two frames that would change.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-25 09:55:55 -05:00

172 lines
6.1 KiB
JavaScript

#!/usr/bin/env node
/**
* checkScreens.mjs — the screenshots agree with what the pages say about them.
*
* PLAN.md §12, §13 phase 9, D45.
*
* node scripts/checkScreens.mjs
*
* ---------------------------------------------------------------------------------------
* WHAT IT PROVES, AND WHY EACH ONE IS WORTH A CHECK
* ---------------------------------------------------------------------------------------
* 1. EVERY DECLARED SCREEN HAS A FILE. A missing image is invisible in review — the page
* still builds, still lays out, and only a reader sees the broken frame.
*
* 2. EVERY FILE IS THE DECLARED SIZE. `width` and `height` reach the markup as intrinsic
* attributes, and an attribute that disagrees with the file is a page that jumps as the
* image decodes. It also catches a re-capture taken at the wrong viewport, which looks
* fine on its own and wrong beside the others.
*
* 3. NOTHING IN public/screens IS ORPHANED. A capture that stopped being referenced is a
* file the container still ships and nobody looks at — and, worse, one that never gets
* retaken, so it silently becomes the oldest thing in the repository.
*
* 4. EVERY DECLARED SCREEN IS ACTUALLY USED. The mirror of 3: an entry in `screens.mjs`
* that no page renders is a capture being maintained for nothing. Usage is a literal
* search for the id across `src/`, which is how both readers of the data refer to one —
* `<Screenshot id="admin-users" />` and the `groupScreens` map on `/features/`.
*
* 5. THE ALT TEXT AND CAPTION SAY SOMETHING. An empty alt on an editorial image is an
* accessibility failure the build cannot otherwise see, and a caption is the sentence
* that makes a screenshot evidence rather than decoration.
*
* ---------------------------------------------------------------------------------------
* WHY IT READS THE PNG HEADER ITSELF
* ---------------------------------------------------------------------------------------
* It does not: it reads the WebP header, and it does it with twenty lines rather than a
* dependency. `sharp` is already here for the brand assets and could answer this, but this
* check runs in CI on every pull request and a check that needs a native image library to
* tell you a file is 1920 pixels wide is a check that will one day fail for a reason that
* has nothing to do with screenshots.
*/
import { readdirSync, readFileSync, existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { screens, WEB, PHONE } from '../src/data/screens.mjs';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(HERE, '..');
const DIR = path.join(ROOT, 'public', 'screens');
const SRC = path.join(ROOT, 'src');
const problems = [];
/**
* The pixel size of a WebP file, from its header.
*
* A RIFF container: "RIFF" size "WEBP" then one of three chunk types. Lossy ("VP8 ") and
* lossless ("VP8L") pack the dimensions differently, and an animated or extended file
* ("VP8X") states them outright. `cwebp` at quality 82 writes VP8 , but a future change of
* encoder should not turn this check into a mystery, so all three are handled.
*/
function webpSize(file) {
const buf = readFileSync(file);
if (buf.length < 30 || buf.toString('ascii', 0, 4) !== 'RIFF' || buf.toString('ascii', 8, 12) !== 'WEBP') {
return null;
}
const chunk = buf.toString('ascii', 12, 16);
if (chunk === 'VP8X') {
return {
width: 1 + (buf[24] | (buf[25] << 8) | (buf[26] << 16)),
height: 1 + (buf[27] | (buf[28] << 8) | (buf[29] << 16)),
};
}
if (chunk === 'VP8L') {
const bits = buf[21] | (buf[22] << 8) | (buf[23] << 16) | (buf[24] << 24);
return { width: 1 + (bits & 0x3fff), height: 1 + ((bits >> 14) & 0x3fff) };
}
if (chunk === 'VP8 ') {
return {
width: buf.readUInt16LE(26) & 0x3fff,
height: buf.readUInt16LE(28) & 0x3fff,
};
}
return null;
}
/** Every file under `src/`, read once, so usage is a search rather than a guess. */
function sourceText() {
const out = [];
const walk = (dir) => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full);
else if (/\.(astro|mdx?|mjs|js|ts|tsx)$/.test(entry.name)) out.push(readFileSync(full, 'utf8'));
}
};
walk(SRC);
return out;
}
const sources = sourceText();
const declared = new Set();
for (const shot of screens) {
const name = `${shot.id}.webp`;
const file = path.join(DIR, name);
declared.add(name);
if (!existsSync(file)) {
problems.push(
`${shot.id}: no file at public/screens/${name}. ` +
`Retake it: node scripts/captureScreens.mjs ${shot.id}`,
);
continue;
}
const want = shot.family === 'web' ? WEB : PHONE;
const size = webpSize(file);
if (!size) {
problems.push(`${shot.id}: public/screens/${name} is not a WebP this check can read.`);
} else if (size.width !== want.width || size.height !== want.height) {
problems.push(
`${shot.id}: file is ${size.width}x${size.height}, ` +
`declared ${want.width}x${want.height} for the "${shot.family}" family.`,
);
}
if (!shot.alt || shot.alt.length < 20) {
problems.push(`${shot.id}: alt text is missing or too short to describe the screen.`);
}
if (!shot.caption) {
problems.push(`${shot.id}: no caption.`);
}
const used = sources.some((text) => text.includes(`'${shot.id}'`) || text.includes(`"${shot.id}"`));
if (!used) {
problems.push(
`${shot.id}: declared but no page renders it. Use it, or delete the entry and its file.`,
);
}
}
if (existsSync(DIR)) {
for (const name of readdirSync(DIR)) {
if (!declared.has(name)) {
problems.push(`public/screens/${name}: not declared in src/data/screens.mjs.`);
}
}
}
if (problems.length > 0) {
console.error(`\ncheckScreens: ${problems.length} problem(s)\n`);
for (const problem of problems) console.error(` - ${problem}`);
console.error('');
process.exit(1);
}
console.log(`checkScreens: ${screens.length} screens, all present, sized and used.`);