diff --git a/REUSE.toml b/REUSE.toml
index 5736bf1f..612818cf 100644
--- a/REUSE.toml
+++ b/REUSE.toml
@@ -15,6 +15,8 @@ path = [
"src/**.css",
# The design system's modules, vendored unchanged (src/vendor/vortex-design/README.md).
"src/vendor/**.js",
+ # The hashes that guard those copies (tests/unit/styles.test.ts).
+ "src/vendor/vortex-design/SHA256SUMS",
"scripts/**",
"tests/**",
".github/**",
diff --git a/bench/web/app/vortex-skin.css b/bench/web/app/vortex-skin.css
index ab5e4608..ab83d7ab 100644
--- a/bench/web/app/vortex-skin.css
+++ b/bench/web/app/vortex-skin.css
@@ -148,3 +148,27 @@ body {
border-color: var(--border);
color: var(--fg);
}
+
+/* The chrome in the system's label voice: the page title and the toolbar buttons read as mono
+ capitals, ranks are muted so Blue marks only ratios, and the count badges are square. */
+.site-title,
+.control-btn {
+ font-family: 'Geist Mono', ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace;
+ font-weight: 500;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+.site-title {
+ font-size: 12px;
+ color: var(--muted);
+}
+.control-btn {
+ font-size: 11px;
+}
+.score-rank {
+ color: var(--muted);
+}
+.filter-badge,
+.group-count {
+ border-radius: 2px;
+}
diff --git a/bench/web/app/vortex-skin.test.ts b/bench/web/app/vortex-skin.test.ts
index d3890e62..fa8844ed 100644
--- a/bench/web/app/vortex-skin.test.ts
+++ b/bench/web/app/vortex-skin.test.ts
@@ -16,6 +16,10 @@ const VISUAL = new Set([
'accent-color',
'font-family',
'font-weight',
+ // The label voice: size, tracking, and case change how text looks.
+ 'font-size',
+ 'letter-spacing',
+ 'text-transform',
]);
const declsOf = (path: string) =>
@@ -131,6 +135,18 @@ describe('vortex-skin.css', () => {
}
});
+ it('sets the title and toolbar buttons as mono labels, mutes ranks, and squares the badges', () => {
+ for (const sel of ['.site-title', '.control-btn']) {
+ expect(valueOf(sel, 'font-family'), sel).toMatch(/Geist Mono/);
+ expect(valueOf(sel, 'text-transform'), sel).toBe('uppercase');
+ }
+ expect(valueOf('.score-rank', 'color')).toBe('var(--muted)');
+ expect(valueOf('.site-title', 'color')).toBe('var(--muted)');
+ for (const sel of ['.filter-badge', '.group-count']) {
+ expect(valueOf(sel, 'border-radius'), sel).toBe('2px');
+ }
+ });
+
it('loads after globals.css in the root layout', () => {
const layout = read('./layout.tsx');
const globals = layout.indexOf("import './globals.css';");
diff --git a/scripts/mark-frame.mjs b/scripts/mark-frame.mjs
new file mode 100644
index 00000000..753f12fb
--- /dev/null
+++ b/scripts/mark-frame.mjs
@@ -0,0 +1,86 @@
+// Prints a still of the homepage's ASCII mark as text, for src/components/blog/mark-frame.ts.
+// It loads the page with reduced motion, records each character the mark's canvas draws,
+// and lays them back on their grid, without the dot field behind the mark.
+// Usage: bun scripts/mark-frame.mjs [site url]
+import { chromium } from "@playwright/test";
+
+const url = process.argv[2] ?? "http://localhost:3000/";
+const browser = await chromium.launch();
+const page = await browser.newPage({
+ viewport: { width: 1512, height: 860 },
+ reducedMotion: "reduce"
+});
+await page.addInitScript(() => {
+ window.__chars = [];
+ const fill = CanvasRenderingContext2D.prototype.fillText;
+ CanvasRenderingContext2D.prototype.fillText = function (text, x, y, ...rest) {
+ window.__chars.push([text, x, y, this.fillStyle]);
+ return fill.call(this, text, x, y, ...rest);
+ };
+});
+await page.goto(url, { waitUntil: "networkidle" });
+// The still frame is the reduced-motion mark. Without WebGL the mark falls back
+// to its static symbol (or errors), and there is no frame to capture.
+const mode = await page
+ .waitForFunction(
+ () => document.querySelector("[data-mark-mode]")?.dataset.markMode
+ )
+ .then((handle) => handle.jsonValue());
+if (mode !== "reduced") {
+ await browser.close();
+ throw new Error(
+ `the mark drew in "${mode}" mode, not "reduced": no frame to capture`
+ );
+}
+const frame = await page.evaluate(() => {
+ // The dot field behind the mark is drawn in its own colour, and every
+ // character in it is a ".". The mark's dimmest pixels are dots too, in an ink
+ // colour that also draws other characters, so only the field's colour goes.
+ const byColor = new Map();
+ for (const [text, , , color] of window.__chars) {
+ byColor.set(color, (byColor.get(color) ?? new Set()).add(text));
+ }
+ const field = [...byColor]
+ .filter(([, texts]) => texts.size === 1 && texts.has("."))
+ .map(([color]) => color);
+ const chars = window.__chars.filter((c) => !field.includes(c[3]));
+ const step = (values) => {
+ const sorted = [...new Set(values)].sort((a, b) => a - b);
+ return Math.min(
+ ...sorted
+ .slice(1)
+ .map((v, i) => v - sorted[i])
+ .filter((d) => d > 0.5)
+ );
+ };
+ const xs = chars.map((c) => c[1]);
+ const ys = chars.map((c) => c[2]);
+ const [x0, y0, dx, dy] = [
+ Math.min(...xs),
+ Math.min(...ys),
+ step(xs),
+ step(ys)
+ ];
+ const grid = new Map(
+ chars.map(([t, x, y]) => [
+ `${Math.round((y - y0) / dy)},${Math.round((x - x0) / dx)}`,
+ t
+ ])
+ );
+ const keys = [...grid.keys()].map((k) => k.split(",").map(Number));
+ const rows = Math.max(...keys.map(([r]) => r)) + 1;
+ const cols = Math.max(...keys.map(([, c]) => c)) + 1;
+ const lines = Array.from({ length: rows }, (_, r) =>
+ Array.from({ length: cols }, (_, c) => grid.get(`${r},${c}`) ?? " ")
+ .join("")
+ .trimEnd()
+ );
+ while (lines.length && !lines[0]) lines.shift();
+ while (lines.length && !lines.at(-1)) lines.pop();
+ const indent = Math.min(
+ ...lines.filter(Boolean).map((l) => l.length - l.trimStart().length)
+ );
+ return lines.map((l) => l.slice(indent)).join("\n");
+});
+console.log(frame);
+await browser.close();
diff --git a/src/app/blog/page.tsx b/src/app/blog/page.tsx
index 29df066f..a78f6b4d 100644
--- a/src/app/blog/page.tsx
+++ b/src/app/blog/page.tsx
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
+import { MARK_FRAME } from "@/components/blog/mark-frame";
import { PostRows } from "@/components/blog/post-rows";
import { NewsletterForm } from "@/components/newsletter-form";
import { externalPosts } from "@/content/links";
@@ -30,7 +31,8 @@ const FEEDS = [
] as const;
/**
- * The design system's BlogIndex card: the heading with the feeds, then one
+ * The design system's BlogIndex card: the heading with the feeds, over a dim
+ * still of the ASCII mark (`MARK_FRAME`), then one
* list, newest first, of the site's own posts and the posts on other sites
* (`src/content/links.ts`). The newsletter sign-up closes the list.
*/
@@ -39,7 +41,13 @@ export default function BlogPage() {
return (
-
+
+ {/* The frame is the ::before's content, so it is decoration, not text. */}
+
Blog