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. */} +