Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 3 additions & 3 deletions REUSE.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]]
Expand Down
4 changes: 2 additions & 2 deletions src/app/(bench)/bench/layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }));
Expand Down Expand Up @@ -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/);
});

Expand Down
6 changes: 3 additions & 3 deletions src/bench/styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
8 changes: 8 additions & 0 deletions src/bench/styles/tokens.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))) {
Expand Down
12 changes: 3 additions & 9 deletions src/lib/fonts.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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 `<html>`. */
export const FONT_CLASSES = `${geistSans.variable} ${geistMono.variable} ${funnelDisplay.variable}`;
export const FONT_CLASSES = `${geistSans.variable} ${geistMono.variable}`;
Binary file removed src/lib/og-assets/FunnelDisplay-Medium.ttf
Binary file not shown.
Binary file added src/lib/og-assets/Geist-Medium.ttf
Binary file not shown.
10 changes: 5 additions & 5 deletions src/lib/og.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -204,7 +204,7 @@ export async function renderPostOgImage(props: {
<div style={{ display: "flex", flexDirection: "column", gap: 28 }}>
<div
style={{
fontFamily: "Funnel Display",
fontFamily: "Geist",
fontSize: props.title.length > 32 ? 60 : 68,
fontWeight: 500,
lineHeight: 1.04,
Expand All @@ -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" }
]
}
Expand Down
4 changes: 2 additions & 2 deletions src/styles/vortex-tokens.css
Original file line number Diff line number Diff line change
@@ -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 <site> --sections base,brand,site,blog,tables,focus --faces --vx-face --base
sha256 a7ae816f9bc980e73641f7b6b1b8f885494472fa08d1f6c8488a99bbb8170352 */
sha256 bc548874946fb5a4c2dcd0a7d31446b38ef81bba86ca56268214600c39893b8c */
:root, [data-theme="dark"] {
--white: #ffffff;
--grey-50: #fcfcf9;
Expand Down Expand Up @@ -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;
}
Expand Down
49 changes: 42 additions & 7 deletions src/styles/vortex.css
Original file line number Diff line number Diff line change
@@ -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 <site> --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. */
Expand All @@ -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);
Expand Down Expand Up @@ -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; }
}
Expand Down Expand Up @@ -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; }
Expand Down Expand Up @@ -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); }
Expand Down
5 changes: 3 additions & 2 deletions tests/unit/og.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,15 @@ 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))
.length > 10_000,
f
);
}
assert.match(og, /fontFamily: "Funnel Display"/);
assert.match(og, /fontFamily: "Geist"/);
assert.doesNotMatch(og, /Funnel/);
});
});
9 changes: 6 additions & 3 deletions tests/unit/styles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
Expand Down
Loading