diff --git a/CLAUDE.md b/CLAUDE.md index b35798f2..11926323 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,7 +50,7 @@ This is a Next.js 16 marketing website for Vortex, a columnar file format, drawn - **Design system**: `src/styles/vortex-tokens.css` and `src/styles/vortex.css` are copies of the Vortex design system's styles (change the system, not the copies), and the markup uses its `vx-` classes - **ASCII mark**: `src/components/ascii-mark` mounts the system's renderer (`src/vendor/vortex-design/`) on the homepage and the 404, with the static symbol as its fallback - **Themes**: Dark only, except the blog, which carries the ThemeSwitch (`src/lib/theme.ts`) -- **Custom Fonts**: Geist, Geist Mono and Funnel Display, self-hosted through `next/font` +- **Custom Fonts**: Geist and Geist Mono, self-hosted through `next/font` ### Component Structure diff --git a/REUSE.toml b/REUSE.toml index c5c45697..d6ee6f1c 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -152,13 +152,13 @@ SPDX-FileCopyrightText = "The Apache Software Foundation" SPDX-License-Identifier = "LicenseRef-Third-Party" # The fonts the post Open Graph images set their text in (src/lib/og.tsx). They are the design -# system's Funnel Display and Geist Mono, as Google Fonts distributes them, under the SIL Open +# system's Geist and Geist Mono, as the Geist project distributes them, under the SIL Open # Font License. next/og cannot read the woff2 files next/font serves. The Geist Mono file has its # GSUB table removed, because next/og's font parser cannot read its contextual alternates. The # licence allows a modified version, and the font names no Reserved Font Name. [[annotations]] -path = "src/lib/og-assets/FunnelDisplay-Medium.ttf" -SPDX-FileCopyrightText = "2024 The Funnel Project Authors (https://github.com/Dicotype/Funnel)" +path = "src/lib/og-assets/Geist-Medium.ttf" +SPDX-FileCopyrightText = "2024 The Geist Project Authors (https://github.com/vercel/geist-font.git)" SPDX-License-Identifier = "OFL-1.1" [[annotations]] diff --git a/src/app/(bench)/bench/layout.test.ts b/src/app/(bench)/bench/layout.test.ts index 5431e5d8..b29da629 100644 --- a/src/app/(bench)/bench/layout.test.ts +++ b/src/app/(bench)/bench/layout.test.ts @@ -22,7 +22,7 @@ vi.mock('@/components/layout/footer', () => ({ createElement('footer', { 'data-origin': origin, 'data-solid': String(solid) }), })); // next/font is a build step; its classes set the design system's --vx-face-* variables. -vi.mock('@/lib/fonts', () => ({ FONT_CLASSES: 'face-sans face-mono face-display' })); +vi.mock('@/lib/fonts', () => ({ FONT_CLASSES: 'face-sans face-mono' })); // The layout reads the request's host for the base of bench's paths. const requestHeaders = vi.hoisted(() => new Headers({ host: 'bench.vortex.dev' })); @@ -58,7 +58,7 @@ describe('RootLayout', () => { }); it("self-hosts the system's faces, as the site does, with no request to Google Fonts", () => { - expect(load(false).documentElement.className).toBe('no-js face-sans face-mono face-display'); + expect(load(false).documentElement.className).toBe('no-js face-sans face-mono'); expect(html).not.toMatch(/googleapis|gstatic/); }); diff --git a/src/bench/styles/globals.css b/src/bench/styles/globals.css index 2f84020d..d9049238 100644 --- a/src/bench/styles/globals.css +++ b/src/bench/styles/globals.css @@ -53,15 +53,15 @@ body { line-height: 1.6; } -/* All headings use Funnel Display, mirroring v2's - * `h1, h2, h3, h4, h5, h6` rule. */ +/* All headings use Geist Mono, like the group names: bench is mono first, + * and the system sets a post's section headings in Geist Mono too. */ h1, h2, h3, h4, h5, h6 { - font-family: var(--font-display); + font-family: var(--font-mono); letter-spacing: -0.02em; } diff --git a/src/bench/styles/tokens.test.ts b/src/bench/styles/tokens.test.ts index 26eb5956..662bb805 100644 --- a/src/bench/styles/tokens.test.ts +++ b/src/bench/styles/tokens.test.ts @@ -88,6 +88,14 @@ describe("bench's stylesheet on the system's tokens", () => { } }); + // Will, 4 Nov: bench's group names in Geist Mono are the model for its headings. bench is mono + // first, and the system sets a post's section headings in Geist Mono too. + it('sets every heading in Geist Mono', () => { + for (const h of ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']) { + expect(lastValue(h, 'font-family'), h).toBe('var(--font-mono)'); + } + }); + it('writes its own colours only into its own variables', () => { const HEX = /#[0-9a-f]{3,8}\b/i; for (const d of decls.filter((x) => HEX.test(x.value))) { diff --git a/src/lib/fonts.ts b/src/lib/fonts.ts index fd868548..7c93e7d2 100644 --- a/src/lib/fonts.ts +++ b/src/lib/fonts.ts @@ -1,6 +1,6 @@ -import { Funnel_Display, Geist, Geist_Mono } from "next/font/google"; +import { Geist, Geist_Mono } from "next/font/google"; -// The design system's three faces, self-hosted by next/font, for both root +// The design system's two faces, self-hosted by next/font, for both root // layouts: the marketing site's and bench's. The tokens read them through // these variables (src/styles/vortex-tokens.css), which carry next/font's // metrics-matched fallback faces as well as the fonts. @@ -14,11 +14,5 @@ const geistMono = Geist_Mono({ subsets: ["latin"] }); -const funnelDisplay = Funnel_Display({ - weight: ["500", "600"], - variable: "--vx-face-display", - subsets: ["latin"] -}); - /** The classes that set the faces' variables, for the root ``. */ -export const FONT_CLASSES = `${geistSans.variable} ${geistMono.variable} ${funnelDisplay.variable}`; +export const FONT_CLASSES = `${geistSans.variable} ${geistMono.variable}`; diff --git a/src/lib/og-assets/FunnelDisplay-Medium.ttf b/src/lib/og-assets/FunnelDisplay-Medium.ttf deleted file mode 100644 index 161adb50..00000000 Binary files a/src/lib/og-assets/FunnelDisplay-Medium.ttf and /dev/null differ diff --git a/src/lib/og-assets/Geist-Medium.ttf b/src/lib/og-assets/Geist-Medium.ttf new file mode 100644 index 00000000..97712e55 Binary files /dev/null and b/src/lib/og-assets/Geist-Medium.ttf differ diff --git a/src/lib/og.tsx b/src/lib/og.tsx index 91e6a339..60370ffc 100644 --- a/src/lib/og.tsx +++ b/src/lib/og.tsx @@ -93,13 +93,13 @@ export const OG_COVER_COLOURS = { accent: "#2f63ff" // --accent in Dark (blue-bright) } as const; -// The faces as Google Fonts ships them, except that the Geist Mono file has +// The faces as the Geist project ships them, except that the Geist Mono file has // no GSUB table: satori's parser cannot read its contextual alternates. The // paths are literal, so the build traces only these two files. let ogFonts: Promise<{ display: Buffer; mono: Buffer }> | undefined; function loadOgFonts() { ogFonts ??= Promise.all([ - readFile(join(process.cwd(), "src/lib/og-assets/FunnelDisplay-Medium.ttf")), + readFile(join(process.cwd(), "src/lib/og-assets/Geist-Medium.ttf")), readFile(join(process.cwd(), "src/lib/og-assets/GeistMono-Regular.ttf")) ]).then(([display, mono]) => ({ display, mono })); return ogFonts; @@ -116,7 +116,7 @@ const OG_COVER = { width: 792, height: 408, clear: 384 } as const; /** * A post's Open Graph image: the post's Cover, drawn from its slug, on the * right, and on the left the site and the blog in Geist Mono over its title - * in Funnel Display. + * in Geist. */ export async function renderPostOgImage(props: { slug: string; @@ -204,7 +204,7 @@ export async function renderPostOgImage(props: {
32 ? 60 : 68, fontWeight: 500, lineHeight: 1.04, @@ -230,7 +230,7 @@ export async function renderPostOgImage(props: { { ...OG_SIZE, fonts: [ - { name: "Funnel Display", data: display, weight: 500, style: "normal" }, + { name: "Geist", data: display, weight: 500, style: "normal" }, { name: "Geist Mono", data: mono, weight: 400, style: "normal" } ] } diff --git a/src/styles/vortex-tokens.css b/src/styles/vortex-tokens.css index 0a69aeb4..94323240 100644 --- a/src/styles/vortex-tokens.css +++ b/src/styles/vortex-tokens.css @@ -1,7 +1,7 @@ /* Vortex design system tokens, copied by tools/site-styles.mjs. Edit the system, then run tools/site-styles.mjs; do not edit here. Made by: node tools/site-styles.mjs --sections base,brand,site,blog,tables,focus --faces --vx-face --base - sha256 a7ae816f9bc980e73641f7b6b1b8f885494472fa08d1f6c8488a99bbb8170352 */ + sha256 bc548874946fb5a4c2dcd0a7d31446b38ef81bba86ca56268214600c39893b8c */ :root, [data-theme="dark"] { --white: #ffffff; --grey-50: #fcfcf9; @@ -167,7 +167,7 @@ --control-sm: 28px; --app-row: 36px; --vx-motion: 150ms ease-out; - --font-display: var(--vx-face-display, "Funnel Display"), var(--vx-face-sans, Geist), sans-serif; + --font-display: var(--font-sans); --font-sans: var(--vx-face-sans, Geist), system-ui, sans-serif; --font-mono: var(--vx-face-mono, "Geist Mono"), ui-monospace, monospace; } diff --git a/src/styles/vortex.css b/src/styles/vortex.css index 529d09ff..1352d676 100644 --- a/src/styles/vortex.css +++ b/src/styles/vortex.css @@ -1,7 +1,7 @@ /* Vortex design system components (sections: base, brand, site, blog, tables, focus), copied by tools/site-styles.mjs. Edit the system, then run tools/site-styles.mjs; do not edit here. Made by: node tools/site-styles.mjs --sections base,brand,site,blog,tables,focus --faces --vx-face --base - sha256 82d318845b84ff15660bcad8ab61386594b28ae71d0a9d75c09c2a5616c54ed6 */ + sha256 d109f7cc38896a0f9542ab6b9205736a54db6c79b33bb82111ac83874e109e13 */ /* ------------------------------------------------------------------ base */ /* Every component sizes with border-box, so it draws the same under a site's reset or none. */ @@ -19,6 +19,37 @@ clip-path: inset(50%); white-space: nowrap; } +/* The skip link, a page's first tab stop (WCAG 2.4.1): "Skip to content", to #main. Hidden as + vx-sr-only until it has focus, then at the top left above a sticky header, on ground in ink in + the label voice, with the shared focus ring. No script: the fragment moves focus to main, which + takes it (tabindex="-1"), so the next Tab starts in the content. Main draws no ring, the system's + or the browser's: around the whole page, it marks nothing a reader can use. */ +.vx-skip-link { + position: fixed; + top: var(--space-8); + left: var(--space-8); + z-index: 1200; + padding: var(--space-8) var(--space-12); + border-radius: var(--radius-control); + background: var(--ground); + color: var(--ink); + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.3; + font-weight: 500; + text-decoration: none; +} +.vx-skip-link:not(:focus) { + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} +main[tabindex="-1"]:focus { + outline: none; +} .vx-card { background: var(--surface); border: 1px solid var(--line); @@ -392,9 +423,11 @@ [data-theme="system"] .vx-header-mark.is-light { display: block; } } .vx-header-label { margin: 0; padding-left: var(--space-12); border-left: 1px solid var(--edge); line-height: 16px; } -.vx-header-nav { display: flex; gap: var(--space-24); margin-left: auto; } +/* The links follow the wordmark and the end group takes the slack, so the links stay put when the + end group's width changes (the blog's ThemeSwitch). */ +.vx-header-nav { display: flex; gap: var(--space-24); } .vx-header-end { display: flex; align-items: center; gap: var(--space-16); margin-left: auto; } -.vx-header-nav + .vx-header-end { margin-left: 0; } +.vx-header-nav + .vx-header-end { margin-left: auto; } @media (max-width: 760px) { .vx-btn-count { display: none; } } @@ -815,7 +848,9 @@ html:has(.vx-header.has-tools) { scroll-padding-top: calc(var(--space-64) + var( .vx-post-row:hover { background: var(--wash); } .vx-post-row-meta { grid-row: span 2; display: grid; align-content: start; gap: var(--space-4); padding-top: var(--space-4); } .vx-post-row-title { margin: 0; } -.vx-post-row-excerpt { margin: 0; color: var(--ink-muted); } +/* A reading measure: at 14px, 54ch of Geist (a ch is its zero, wider than most letters) holds about + 72 characters of prose. */ +.vx-post-row-excerpt { max-width: 54ch; margin: 0; color: var(--ink-muted); } @container (max-width: 560px) { .vx-post-row { grid-template-columns: minmax(0, 1fr); } .vx-post-row-meta { grid-row: auto; display: flex; flex-wrap: wrap; gap: var(--space-4) var(--space-12); padding-top: 0; } @@ -862,9 +897,9 @@ html:has(.vx-header.has-tools) { scroll-padding-top: calc(var(--space-64) + var( } .vx-article-byline { margin: 0; } .vx-prose > * { margin: 0 0 1.1em; } -.vx-prose > h2 { margin: 2em 0 0.6em; font: 500 28px/1.15 var(--font-display); letter-spacing: -0.02em; color: var(--ink); } -.vx-prose > h3 { margin: 1.6em 0 0.5em; font: 600 22px/1.2 var(--font-display); letter-spacing: -0.02em; color: var(--ink); } -.vx-prose > h4 { margin: 1.4em 0 0.4em; font: 600 18px/1.25 var(--font-display); letter-spacing: -0.01em; color: var(--ink); } +.vx-prose > h2 { margin: 2em 0 0.6em; font: 500 28px/1.15 var(--font-mono); letter-spacing: -0.02em; color: var(--ink); } +.vx-prose > h3 { margin: 1.6em 0 0.5em; font: 600 22px/1.2 var(--font-mono); letter-spacing: -0.02em; color: var(--ink); } +.vx-prose > h4 { margin: 1.4em 0 0.4em; font: 600 18px/1.25 var(--font-mono); letter-spacing: -0.01em; color: var(--ink); } .vx-prose > h5, .vx-prose > h6 { margin: 1.4em 0 0.4em; font-size: 1em; font-weight: 600; color: var(--ink); } .vx-prose > :is(h2, h3, h4, h5, h6) { position: relative; scroll-margin-top: var(--space-24); } diff --git a/tests/unit/og.test.ts b/tests/unit/og.test.ts index c0932525..d49ce64b 100644 --- a/tests/unit/og.test.ts +++ b/tests/unit/og.test.ts @@ -24,7 +24,7 @@ describe("a post's Open Graph image", () => { new URL("../../src/lib/og.tsx", import.meta.url), "utf8" ); - for (const f of ["FunnelDisplay-Medium.ttf", "GeistMono-Regular.ttf"]) { + for (const f of ["Geist-Medium.ttf", "GeistMono-Regular.ttf"]) { assert.ok(og.includes(f), f); assert.ok( readFileSync(new URL(`../../src/lib/og-assets/${f}`, import.meta.url)) @@ -32,6 +32,7 @@ describe("a post's Open Graph image", () => { f ); } - assert.match(og, /fontFamily: "Funnel Display"/); + assert.match(og, /fontFamily: "Geist"/); + assert.doesNotMatch(og, /Funnel/); }); }); diff --git a/tests/unit/styles.test.ts b/tests/unit/styles.test.ts index 7c4a2e6e..1c633969 100644 --- a/tests/unit/styles.test.ts +++ b/tests/unit/styles.test.ts @@ -9,24 +9,27 @@ const read = (path: string) => describe("the site's copy of the design system styles", () => { const tokens = read("src/styles/vortex-tokens.css"); - it("names a font family only in the three --font-* tokens", () => { + it("names a font family only in the --font-sans and --font-mono tokens", () => { // A literal name would skip next/font's metrics-matched fallback face and shift layout on swap. assert.doesNotMatch( tokens, /font-family:\s*("Funnel Display"|Geist|"Geist Mono")/ ); - for (const role of ["display", "sans", "mono"]) { + for (const role of ["sans", "mono"]) { assert.match( tokens, new RegExp(`--font-${role}: var\\(--vx-face-`), role ); } + // The heading role follows the sans face: the system has two faces. + assert.match(tokens, /--font-display: var\(--font-sans\);/); }); it("gets each --vx-face-* variable from next/font, on both root layouts", () => { const fonts = read("src/lib/fonts.ts"); - for (const face of ["display", "sans", "mono"]) { + assert.doesNotMatch(fonts, /Funnel|--vx-face-display/); + for (const face of ["sans", "mono"]) { assert.match(fonts, new RegExp(`variable: "--vx-face-${face}"`), face); } for (const layout of [