Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,76 @@ jobs:

- name: Run tests (lint + type check) 🔍
run: bun run test

# Build here so the content lint below sees the real rendered output,
# including YouTube descriptions fetched at build time that never
# appear in src/. Deliberately after the offline checks above, so an
# unreachable feed cannot mask the lint and type-check verdict.
- name: Build site 🏗️
run: bun run build

- name: Content lint (prose) 📝
run: |
cat > "$RUNNER_TEMP/lint-content.mjs" <<'LINT'
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";

const RULES = [
[/—|&mdash;|&#8212;|&#x2014;/gi, "em dash"],
[/\bdelv(e|es|ed|ing)\b/gi, "delve"],
];

// Non-prose regions, blanked before matching. Order matters: the
// escape hatch and code samples must go before the catch-all tag strip.
const STRIP = [
/<script\b[\s\S]*?<\/script>/gi,
/<style\b[\s\S]*?<\/style>/gi,
/<pre\b[\s\S]*?<\/pre>/gi,
/<code\b[\s\S]*?<\/code>/gi,
/<(\w+)\b[^>]*\sdata-lint-ignore\b[\s\S]*?<\/\1>/gi,
/<!--[\s\S]*?-->/g,
/<[^>]+>/g, // tags: attributes, URLs, embed params
];

const root = process.argv[2] ?? "dist";
const blank = (m) => m.replace(/[^\n]/g, " "); // keep byte offsets stable

let failures = 0;
const files = readdirSync(root, { recursive: true }).filter((f) =>
f.endsWith(".html"),
);

// A missing or empty dist must fail loudly: a lint that passes
// because it found nothing to check is worse than no lint at all.
if (files.length === 0) {
console.error(`content lint: no HTML found under ${root} — did the build run?`);
process.exit(1);
}

for (const file of files.sort()) {
const path = join(root, file);
const html = readFileSync(path, "utf8");
const prose = STRIP.reduce((s, re) => s.replace(re, blank), html);

for (const [rule, name] of RULES) {
for (const m of prose.matchAll(rule)) {
const line = html.slice(0, m.index).split("\n").length;
const col = m.index - (html.lastIndexOf("\n", m.index - 1) + 1) + 1;
const context = html
.slice(Math.max(0, m.index - 70), m.index + 70)
.replace(/\s+/g, " ");
console.error(`${path}:${line}:${col} ${name}: …${context}…`);
failures++;
}
}
}

if (failures > 0) {
console.error(
`\n${failures} content lint failure(s) in ${root}. Fix the source, or wrap a deliberate use in an element carrying data-lint-ignore.`,
);
process.exit(1);
}
console.log(`content lint: ${files.length} pages clean`);
LINT
bun "$RUNNER_TEMP/lint-content.mjs" dist
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -1169,3 +1169,4 @@ web-bundles/
docs/architecture/
docs/prd/
.claude/settings.local.json
qa/screenshots/
350 changes: 232 additions & 118 deletions bun.lock

Large diffs are not rendered by default.

10 changes: 5 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@
"@biomejs/biome"
],
"dependencies": {
"@astrojs/check": "^0.9.5",
"@astrojs/mdx": "^5.0.3",
"@astrojs/sitemap": "^3.6.0",
"astro": "^6.1.8",
"@astrojs/check": "^0.9.9",
"@astrojs/mdx": "^7.0.3",
"@astrojs/sitemap": "^3.7.3",
"astro": "^7.1.3",
"fast-xml-parser": "^5.3.6",
"typescript": "^5.9.3",
"unist-util-visit": "^5.1.0"
Expand All @@ -31,6 +31,6 @@
"@biomejs/biome": "2.4.4",
"@types/hast": "^3.0.4",
"@types/node": "^25.5.0",
"sharp": "^0.34.5"
"sharp": "^0.35.3"
}
}
107 changes: 107 additions & 0 deletions qa/measure.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// Seam audit + screenshots. Run: bunx --bun playwright ... no — plain node/bun:
// bun qa/measure.mjs <url> <label>
// Writes qa/screenshots/<label>-{1440,390}.png and prints the seam table.
import { chromium } from 'playwright';

const url = process.argv[2] ?? 'http://localhost:4321/';
const label = process.argv[3] ?? 'shot';
const debugOutline = process.argv.includes('--outline');

// Each block is a background region; the seam between two is where the colour
// can change, so the boundary is always the shared edge of adjacent siblings.
const COLLECT = `(() => {
const blocks = [];
const push = (el, name) => { if (el) blocks.push([el, name]); };
push(document.querySelector('header'), 'header');
const shell = document.querySelector('.page-shell');
const names = ['hero', 'map', 'quest', 'listen', 'arcane', 'party'];
[...shell.children].forEach((el, i) => push(el, names[i] ?? 'block' + i));
push(document.querySelector('footer'), 'footer');

// Extremes of painted content, so margins and wrapper padding are counted as gap.
const extremes = (root) => {
// Visually-hidden wrappers still lay their children out at full size, so a
// screen-reader-only list reports a rect hundreds of pixels past the section.
// Collect the clipped wrappers first and skip everything inside them.
const clipped = [...root.querySelectorAll('*')].filter((el) => {
const s = getComputedStyle(el);
return s.clip === 'rect(0px, 0px, 0px, 0px)' || s.clipPath === 'inset(50%)';
});
let top = Infinity, bottom = -Infinity;
for (const el of root.querySelectorAll('*')) {
const r = el.getBoundingClientRect();
if (r.width < 2 || r.height < 2) continue;
const s = getComputedStyle(el);
if (s.visibility === 'hidden' || s.opacity === '0') continue;
if (clipped.some((c) => c.contains(el))) continue;
top = Math.min(top, r.top); bottom = Math.max(bottom, r.bottom);
}
return { top, bottom };
};

return blocks.map(([el, name]) => {
const r = el.getBoundingClientRect();
const e = extremes(el);
return {
name,
top: Math.round(r.top + scrollY),
bottom: Math.round(r.bottom + scrollY),
contentTop: Math.round(e.top + scrollY),
contentBottom: Math.round(e.bottom + scrollY),
bg: getComputedStyle(el).backgroundColor,
};
});
})()`;

// System Chrome, so this needs no bundled-browser download.
const browser = await chromium.launch({ channel: 'chrome' });
const rows = [];

for (const [w, h] of [
[1440, 900],
[390, 844],
]) {
// `viewport`, not `viewportSize` — the latter is silently ignored by newPage
// and every shot comes out at Chrome's default 1280.
const page = await browser.newPage({ viewport: { width: w, height: h } });
await page.goto(url, { waitUntil: 'networkidle' });
if (debugOutline) {
await page.addStyleTag({ content: '* { outline: 1px solid rgba(255,0,0,.4) !important; }' });
}
await page.screenshot({ path: `qa/screenshots/${label}-${w}.png`, fullPage: true });
rows.push([w, await page.evaluate(COLLECT)]);
await page.close();
}

await browser.close();

// The colour edge between two blocks is the top of the lower one: adjacent
// siblings share it, and where a wrapper sits between them (header/main) the
// lower block's own top is still where its background starts.
// --space-section steps down at 768px, so the target does too.
for (const [w, blocks] of rows) {
const target = w <= 768 ? 56 : 96;
console.log(`\n${label} @${w} — seam bisection (target ${target} / ${target})\n`);
console.log('seam above below total bisected');
for (let i = 0; i < blocks.length - 1; i++) {
const a = blocks[i];
const b = blocks[i + 1];
const edge = b.top;
const above = edge - a.contentBottom;
const below = b.contentTop - edge;
const ok = above === target && below === target;
console.log(
`${`${a.name} -> ${b.name}`.padEnd(26)} ${String(above).padStart(5)} ${String(below).padStart(5)} ${String(above + below).padStart(5)} ${ok ? 'yes' : 'NO'}`,
);
}

// Content escaping its own block means a child margin or overflow is driving
// the seam, not the section padding — the numbers above cannot be trusted
// until it is gone.
const overflow = blocks.filter((r) => r.contentBottom > r.bottom || r.contentTop < r.top);
for (const r of overflow) {
console.log(
` overflow: ${r.name} box ${r.top}..${r.bottom}, content ${r.contentTop}..${r.contentBottom}`,
);
}
}
Loading
Loading