Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
4ec9863
feat(themes): primary-as-text token, 3:1 control boundaries, and self…
sparkyfen Sep 15, 2026
14ca96e
fix(themes): review round 1 for the primary-text and self-hosted font…
sparkyfen Sep 15, 2026
a5ac94a
fix(themes): review round 2 for the primary-text and self-hosted font…
sparkyfen Sep 15, 2026
4378072
fix(themes): review round 3 for the primary-text and self-hosted font…
sparkyfen Sep 16, 2026
ae36afb
fix(themes): review round 4 for the primary-text and self-hosted font…
sparkyfen Sep 16, 2026
313fc4d
fix(themes): review round 5 for the primary-text and self-hosted font…
sparkyfen Sep 16, 2026
fa2e462
fix(themes): review round 6 polish for the subsetter comment and test…
sparkyfen Sep 16, 2026
2e0fe9e
chore(stickers): update a comment to name the primary-text token it n…
sparkyfen Sep 16, 2026
a763798
docs(themes): state the font cache tradeoff, mark the subsetter POSIX…
sparkyfen Sep 16, 2026
ade62fa
fix(themes): address the CodeRabbit review of the primary-text and se…
sparkyfen Sep 16, 2026
4ff9fbb
fix(themes): measure the platform chip on its tint, pin the manifest …
sparkyfen Sep 16, 2026
4a9f845
fix(themes): refuse an unrecorded cached font when a manifest exists,…
sparkyfen Sep 16, 2026
9ce6364
fix(themes): dashed add buttons draw with --input, and a face source …
sparkyfen Sep 16, 2026
cb5b879
fix(themes): every mobile add row and dashed-button hover meets 3:1, …
sparkyfen Sep 16, 2026
3ff307a
fix(themes): upload add tile uses passing boundary tokens, and the su…
sparkyfen Sep 16, 2026
9cc6cfa
fix(themes): refuse a partial CSS2 response, pin the Geist sources, a…
sparkyfen Sep 16, 2026
75e49c6
fix(themes): state boundaries draw with --primary-text and the light-…
sparkyfen Sep 16, 2026
e380621
fix(themes): refuse symlinks at font paths, write fetched fonts throu…
sparkyfen Sep 16, 2026
46c1afb
test(themes): name the generator's temporary font fixtures so the man…
sparkyfen Sep 16, 2026
6752d23
feat(themes): drop the Japanese Plex slices and the subsetter; Terrac…
sparkyfen Sep 16, 2026
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
24 changes: 20 additions & 4 deletions NOTICE
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,23 @@ the Sona project. The Apache License 2.0 does not grant permission to use
these trade names except as required for reasonable and customary use in
describing the origin of the work (see License section 6).

This product bundles the Geist typeface (Copyright 2023 Vercel, in
collaboration with basement.studio), used under the SIL Open Font
License 1.1 (https://openfontlicense.org). Font files are in
static/fonts/.
This product bundles four typefaces, all used under the SIL Open Font
License 1.1. The license text is in static/fonts/OFL.txt, next to the font
files it covers:

Copyright 2024 The Geist Project Authors
(https://github.com/vercel/geist-font.git)

Copyright 2020 The JetBrains Mono Project Authors
(https://github.com/JetBrains/JetBrainsMono)

Copyright 2018 The Chakra Petch Project Authors
(https://github.com/m4rc1e/Chakra-Petch.git)

Copyright (c) 2017 IBM Corp. with Reserved Font Name "Plex"
(https://github.com/IBM/plex)

The IBM Plex Sans JP files here are Google Fonts' own Latin slices,
unmodified. "Plex" is a Reserved Font Name under the license, which does not
allow a Modified Version to use that name. Before changing these files in
any way, read static/fonts/OFL.txt and rename the result.
24 changes: 24 additions & 0 deletions UPDATING.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,30 @@ you sync — or to catch up on what shipped since you last did — read the
the merged changes since the previous one. `git log --oneline <last-tag>..upstream/main`
after a fetch gives the same view from a clone.

## Read before upgrading: self-hosted fonts and new theme colors (SONA-181, SONA-126)

The typefaces ship with the site. Nothing on a page load reaches Google's font
CDN anymore, because the files are in `static/fonts/` and the CSP names no
external style or font origin.

- **Check your privacy text.** The built-in privacy policy dropped its Google
Fonts sentence, because the transfer no longer happens. If you pasted your own
privacy text in Settings, Legal, **it was not updated**: delete this sentence
from it, or it discloses a transfer your site no longer makes.

> Public pages also load web fonts from Google Fonts, so Google receives your
> IP address, browser user-agent, and the page you are viewing when those files
> are fetched.

Your wording may differ. Remove whichever sentence names Google Fonts as a
recipient.
- **Theme colors moved with this release too.** Every theme gained a
`--primary-text` color for the primary color used as small text, and form
fields and outline buttons now draw a border that meets 3:1 contrast. If you
edited theme colors, they live in `src/lib/themes/<id>.theme.ts` — edit the
theme file, run `npm run themes`, and commit the regenerated
`src/lib/themes/generated.css` alongside it.

## Read before upgrading: tag suggestions call entail.dev (SONA-220)

This release adds an admin-only tag suggestion lookup for artwork. It appears in
Expand Down
12 changes: 12 additions & 0 deletions _headers
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Cloudflare Pages headers. The adapter copies this file into the build and
# appends its own block for SvelteKit's hashed assets, so only hand-written
# rules belong here.
#
# The self-hosted typefaces (SONA-181) change only when a font script is run
# on purpose, and their names carry no content hash. A month of caching means a
# returning visitor keeps the old bytes for up to a month after such a change;
# that is accepted, because the change is rare and the old file still renders
# (a widened glyph set falls back per glyph). To force a refresh, rename the
# file in the theme's `faces` list.
/fonts/*
Cache-Control: public, max-age=2592000
20 changes: 19 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ graph TB
Avatars[🖼️ Bluesky + X — profile pictures, tweet media]
Entail[🏷️ entail.dev — image tag classifier]
UT[☁️ UploadThing — optional]
GoogleCSS2[🔤 Google CSS2 API — build time only]
end

subgraph "GitHub Actions"
Expand All @@ -62,6 +63,8 @@ graph TB
ThemeData[🎨 src/lib/themes/*.theme.ts — theme data]
ThemeBuild[🛠️ scripts/build-themes.ts — runs on prepare]
ThemeCSS[📄 src/lib/themes/generated.css — committed, imported by src/app.css]
FetchFonts[🔤 scripts/fetch-fonts.mjs — developer-run, not CI]
FontFiles[📁 static/fonts/ — committed woff2 + manifest]
end

Forks[🌍 Forks — independent deployments, sync via releases]
Expand Down Expand Up @@ -115,6 +118,10 @@ graph TB

ThemeData --> ThemeBuild
ThemeBuild -->|renders| ThemeCSS
GoogleCSS2 -->|woff2 slices + unicode-range| FetchFonts
FetchFonts --> FontFiles
FontFiles -->|every face src must exist| ThemeBuild
FontFiles -->|/fonts/* same-origin at runtime| Visitor
ThemeCSS -->|styles every page| Public
ThemeCSS --> Admin
CI -->|tracked, no drift, themes:check| ThemeCSS
Expand Down Expand Up @@ -183,7 +190,18 @@ graph TB
ways: `git ls-files --error-unmatch` proves it is still tracked, `git diff
--exit-code` after the install catches a palette edit that was never
regenerated, and `npm run themes:check` runs the renderer with its exit code
exposed, because `prepare` swallows failures.
exposed, because `prepare` swallows failures. The renderer also emits the
`@font-face` blocks for the self-hosted typefaces in `static/fonts/`, so no
page load reaches a font CDN and the CSP names no external stylesheet or font
origin. `static/fonts/` is an input to the renderer as well as an output of the
font script: a face whose `src` has no file behind it fails the build rather
than rendering as CSS the browser silently falls back from.
- The font script is developer-run, not part of CI or the deploy. `node
scripts/fetch-fonts.mjs` asks Google's CSS2 API for the Latin woff2 slices. It
writes into `static/fonts/` and records a sha256 per file in `manifest.json`,
and the woff2 files are committed, so a normal build and every fork deploy
never run it. At runtime the browser fetches `/fonts/*` from the site's own
origin — see `static/fonts/README.md`.
- Forks are independent deployments of the same stack on their owners' own
Cloudflare accounts. They adopt changes by pulling the tagged releases that
`release.yml` publishes — see `UPDATING.md` — not by tracking `main`.
99 changes: 97 additions & 2 deletions scripts/build-themes.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { spawnSync } from 'node:child_process';
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { renderThemesCss, checkThemesCss, OUTPUT_PATH } from './build-themes.ts';
import { ALL_THEMES } from '../src/lib/themes/all.ts';
import type { ThemeDefinition, TokenKey } from '../src/lib/themes/types.ts';
import type { FontFace, ThemeDefinition, TokenKey } from '../src/lib/themes/types.ts';

// A two-token fixture rather than the real palettes: this asserts the LAYOUT
// (selectors, order, aliases, fonts, the inherit rule), and pinning it to the
Expand Down Expand Up @@ -210,6 +210,101 @@ describe('renderThemesCss', () => {
expect(renderThemesCss(ok)).toContain("--font-primary: 'ヒラギノ角ゴシック', sans-serif;");
});

// A face's family and src reach the CSS unescaped too — inside a quoted string
// and inside url() — and a src that resolves to no file renders as a valid
// @font-face the browser silently falls back from, so the page just wears the
// wrong typeface. Each of these is a build failure instead.
const withFace = (face: Partial<FontFace>): ThemeDefinition[] => [
{
id: 'default',
label: 'Faces',
dark: {},
light: {},
fonts: {
primary: "'A', monospace",
secondary: "'B', sans-serif",
faces: [
{
family: 'JetBrains Mono',
weight: 400,
src: '/fonts/JetBrainsMono-latin.woff2',
...face
}
]
}
}
];

it.each([
['a src that climbs out of /fonts/', { src: '/fonts/../../etc/passwd.woff2' }, /must be a \/fonts\/\*\.woff2 path/],
['a src that closes the url()', { src: '/fonts/x).woff2' }, /must be a \/fonts\/\*\.woff2 path/],
['a family with a quote', { family: "Jet'Brains" }, /must not contain a quote/],
['a weight that is neither three digits nor a range', { weight: 40 }, /not a 3- or 4-digit weight/],
['a weight of zero, which is three digits but names nothing', { weight: '000' }, /outside the 1-1000 CSS range/],
['a weight past the top of the CSS range', { weight: 1001 }, /outside the 1-1000 CSS range/],
['a weight range that runs from high to low', { weight: '700 400' }, /runs from high to low/],
['a unicode-range past the last codepoint', { unicodeRange: 'U+110000' }, /above U\+10FFFF/],
['a unicode-range interval that runs backwards', { unicodeRange: 'U+4E00-3040' }, /runs from high to low/],
['a unicode-range wildcard that is not trailing', { unicodeRange: 'U+4?0' }, /CSS does not accept/],
['a unicode-range wildcard with an explicit high end', { unicodeRange: 'U+4E??-5000' }, /CSS does not accept/],
['a unicode-range value longer than six digits', { unicodeRange: 'U+0000000' }, /CSS does not accept/],
['a style that is not normal, italic or oblique', { style: 'slanted' }, /is not normal, italic or oblique/],
['a malformed unicode-range', { unicodeRange: 'U+ZZZZ' }, /is not a comma-separated list of U\+ ranges/],
['a src with no file behind it', { src: '/fonts/NotHere-400-latin.woff2' }, /has no file at/]
])('rejects %s', (_name, face, message) => {
expect(() => renderThemesCss(withFace(face))).toThrow(message);
});

// A directory named like a font passes an existence check and has a size,
// and the browser cannot load it. Made and removed here, under static/fonts/.
// A symlink to a real font elsewhere passes every stat check, and the face
// would then point out of static/fonts/. Both fixtures below carry the Geist-
// prefix because the font manifest test lists static/fonts/ and skips that
// family, and vitest may run the two files at the same time.
it('rejects a src that is a symlink', () => {
const link = new URL('../static/fonts/Geist-NotALink-fixture.woff2', import.meta.url);
symlinkSync(new URL('../static/fonts/Geist-Regular.woff2', import.meta.url), link);
try {
expect(() => renderThemesCss(withFace({ src: '/fonts/Geist-NotALink-fixture.woff2' }))).toThrow(/has no file at/);
} finally {
rmSync(link, { force: true });
}
});

it('rejects a src that is a directory', () => {
const dir = new URL('../static/fonts/Geist-NotAFile-fixture.woff2', import.meta.url);
mkdirSync(dir);
try {
expect(() => renderThemesCss(withFace({ src: '/fonts/Geist-NotAFile-fixture.woff2' }))).toThrow(/has no file at/);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

it('emits a face with font-display: swap and its unicode-range', () => {
const css = renderThemesCss(withFace({ unicodeRange: 'U+0000-00FF, U+2122' }));
const block = css.match(/@font-face \{([^}]*)\}/)?.[1] ?? '';
expect(block).toContain("font-family: 'JetBrains Mono';");
expect(block).toContain('font-style: normal;');
expect(block).toContain('font-weight: 400;');
expect(block).toContain('font-display: swap;');
expect(block).toContain("src: url('/fonts/JetBrainsMono-latin.woff2') format('woff2');");
expect(block).toContain('unicode-range: U+0000-00FF, U+2122;');
});

it('accepts a variable face declared over a weight range', () => {
const css = renderThemesCss(withFace({ weight: '400 700' }));
expect(css).toContain('font-weight: 400 700;');
});

it('accepts 1000, the top of the CSS weight range', () => {
expect(renderThemesCss(withFace({ weight: 1000 }))).toContain('font-weight: 1000;');
});

it('accepts a wildcard range inside the codepoint space', () => {
expect(renderThemesCss(withFace({ unicodeRange: 'U+4E??' }))).toContain('unicode-range: U+4E??;');
});

it('rejects a theme list whose first entry is not the default theme', () => {
const bad: ThemeDefinition[] = [{ id: 'alt', label: 'Alt', dark: {}, light: {} }];
expect(() => renderThemesCss(bad)).toThrow(/first theme must be the default one/);
Expand Down
Loading
Loading