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 [