From 0a6bd1f15183fba371d133191c71831a2ac9a3f8 Mon Sep 17 00:00:00 2001 From: Will Manning Date: Fri, 30 Oct 2026 11:33:06 -0400 Subject: [PATCH 1/5] Show the focus ring on the blog's theme switch, and let source lines wrap The theme switch's buttons reset with all: unset, which outranked the shared focus rule, so keyboard focus drew no ring. The focus rule now uses :is() instead of :where(), so it outranks every reset control. A figure's source line is also a nav link, and the nav link's nowrap made a long source overflow a phone. A source line now wraps. Both fixes come from the design system. A browser test checks the switch's ring. Signed-off-by: Will Manning --- src/styles/vortex.css | 7 +++---- src/vendor/vortex-design/benchmark-card.d.ts | 2 +- tests/smoke.spec.ts | 21 ++++++++++++++++++++ 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/styles/vortex.css b/src/styles/vortex.css index fa6e0991..ff082400 100644 --- a/src/styles/vortex.css +++ b/src/styles/vortex.css @@ -83,6 +83,7 @@ .vx-source { display: inline-flex; flex-wrap: wrap; + white-space: normal; align-items: center; gap: var(--space-4); } @@ -484,8 +485,6 @@ narrow panel. A 4px gap keeps "1.07× larger than Parquet" on one line in six columns. */ .vx-bench-big { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-4); color: var(--ink); } .vx-bench-note { color: var(--ink-muted); } -/* The sources of panels whose figures are not bench's, under the panels. */ - /* A bar chart's rows: name, a bar on its track, the value. Vortex is the one Blue row. */ .vx-bars { display: grid; gap: var(--space-8); } .vx-bar { @@ -896,5 +895,5 @@ /* ------------------------------------------------------------------ focus One rule for every focusable element, last so that it wins over the resets above. Rows and segments inside a clipped container draw it inside their edge. */ -:where(a, button, input, select, textarea, summary, [tabindex]):focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; } -:where(.vx-docs-nav a, .vx-toc a, .vx-post-row, .vx-tree-node, .vx-filemap-bar > button, .vx-tabs-list button):focus-visible { outline-offset: -2px; } +:is(a, button, input, select, textarea, summary, [tabindex]):focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; } +:is(.vx-docs-nav a, .vx-toc a, .vx-post-row, .vx-tree-node, .vx-filemap-bar > button, .vx-tabs-list button):focus-visible { outline-offset: -2px; } diff --git a/src/vendor/vortex-design/benchmark-card.d.ts b/src/vendor/vortex-design/benchmark-card.d.ts index b3178f0f..c087a335 100644 --- a/src/vendor/vortex-design/benchmark-card.d.ts +++ b/src/vendor/vortex-design/benchmark-card.d.ts @@ -28,7 +28,7 @@ export type BenchPanel = { rows: readonly BenchRow[]; /** The systems the panel shows, in order. It must name Vortex and Parquet. Omitted, every row shows. */ systems?: readonly string[]; - /** For figures that are not bench's: where they come from, which the card prints under the panels. */ + /** For figures that are not bench's: where they come from, as the panel title's hover title and screen-reader text. */ source?: PanelSource; }; diff --git a/tests/smoke.spec.ts b/tests/smoke.spec.ts index 579d973d..c457ef24 100644 --- a/tests/smoke.spec.ts +++ b/tests/smoke.spec.ts @@ -1042,6 +1042,27 @@ test.describe("logo banner under reduced motion", () => { // carries: the header with a ThemeSwitch, Field, prose lists, numeric cells // and the post rows. test.describe("design system rules on the blog", () => { + // Audit, 30 Oct 2026: the switch's buttons reset with all: unset, which + // beat the shared focus rule, so keyboard focus showed no ring. + test("the theme switch shows the focus ring", async ({ + page, + browserName + }) => { + test.skip( + browserName === "webkit", + "WebKit's Tab key skips buttons by default" + ); + await page.goto("/blog"); + const button = page.locator(".vx-theme-switch button").first(); + await button.focus(); + await page.keyboard.press("Shift+Tab"); + await page.keyboard.press("Tab"); + await expect(button).toBeFocused(); + expect(await button.evaluate((b) => getComputedStyle(b).outlineStyle)).toBe( + "solid" + ); + }); + const POST = "/blog/btrblocks-compressor"; // A row's text starts with its meta (the date), which sits in its first column. const x = async (page: Page, selector: string) => From 046a823e244c06aec753376b5b5651f3e7bbc268 Mon Sep 17 00:00:00 2001 From: Will Manning Date: Fri, 30 Oct 2026 11:43:36 -0400 Subject: [PATCH 2/5] Fade the card's other formats, set a still mark on the blog, and label bench's chrome On the homepage card, Parquet's, Lance's, and Arrow's bars draw at 42%, so Vortex's Blue is the one solid bar and leads even where another bar is longer. Every bar still prints its value. The blog index's heading carries a dim still of the ASCII mark in its top right corner, as plain mono text. scripts/mark-frame.mjs captures it from the homepage's reduced-motion frame. Phones leave it out. Bench's title and toolbar buttons are mono capitals, its ranks are muted so Blue marks only ratios, and its count badges are square. The skin's tests now allow font size, tracking, and case for these labels. Signed-off-by: Will Manning --- bench/web/app/vortex-skin.css | 24 +++++++++++ bench/web/app/vortex-skin.test.ts | 16 ++++++++ scripts/mark-frame.mjs | 66 +++++++++++++++++++++++++++++++ src/app/blog/page.tsx | 9 ++++- src/components/blog/mark-frame.ts | 23 +++++++++++ src/styles/vortex.css | 29 +++++++++++++- 6 files changed, 163 insertions(+), 4 deletions(-) create mode 100644 scripts/mark-frame.mjs create mode 100644 src/components/blog/mark-frame.ts 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..f75494fb --- /dev/null +++ b/scripts/mark-frame.mjs @@ -0,0 +1,66 @@ +// 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]); + return fill.call(this, text, x, y, ...rest); + }; +}); +await page.goto(url, { waitUntil: "networkidle" }); +await page.waitForFunction(() => document.querySelector("[data-mark-mode]")); +const frame = await page.evaluate(() => { + const chars = window.__chars; + 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; + // The mark's own characters only: the dots of the field behind it go. + const lines = Array.from({ length: rows }, (_, r) => + Array.from({ length: cols }, (_, c) => grid.get(`${r},${c}`) ?? " ") + .join("") + .replaceAll(".", " ") + .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..17747e24 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,10 @@ export default function BlogPage() { return (
-
+
+

Blog