Offer the three proposed font groups in Themes

The theme menu now carries a fourth choice beside light/dark and the accent:
the font pairing. All three pairings are free for commercial use (SIL OFL),
have a Vietnamese subset, and are self-hosted — the landing still makes no
CDN request.

- styles/fonts.css: 22 @font-face blocks for Plus Jakarta Sans, Inter,
  JetBrains Mono, Fraunces, Be Vietnam Pro, Courier Prime and Space Mono,
  vietnamese/latin-ext/latin subsets only, under public/assets/fonts.
- styles/tokens.css: [data-fonts="studio|editorial|native"] sets --font,
  --font-heading and --mono. Studio (Plus Jakarta Sans + Inter + JetBrains
  Mono) is the default.
- theme/: FontSetId + FONT_SETS, persisted as rc.fonts, applied as
  <html data-fonts> next to data-theme and data-accent.
- TopBar and the landing nav both get the picker; on the landing the theme
  tool now opens a small popover (light/dark + font group) instead of
  toggling on click.
- landing.css: --lp-display/--lp-mono now resolve to the chosen group, so the
  picker retypes the whole page. Syne.woff2 goes with its @font-face.
This commit is contained in:
2026-09-17 19:20:23 +07:00
parent 51e572b9ce
commit cc88471256
33 changed files with 361 additions and 42 deletions
+15 -8
View File
@@ -1,29 +1,36 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
import { ACCENTS, applyTheme, readAccent, readTheme, type AccentId, type ThemeMode } from './themes';
import { ACCENTS, applyTheme, readAccent, readFonts, readTheme, type AccentId, type FontSetId, type ThemeMode } from './themes';
interface ThemeValue {
mode: ThemeMode;
accent: AccentId;
fonts: FontSetId;
setMode: (mode: ThemeMode) => void;
setAccent: (accent: AccentId) => void;
setFonts: (fonts: FontSetId) => void;
}
const Ctx = createContext<ThemeValue | null>(null);
// The tokens live in CSS; this provider only owns the two attributes on <html>
// and the persisted choice. No component ever reads a colour from JS.
// The tokens live in CSS; this provider only owns the three attributes on
// <html> and the persisted choice. No component ever reads a colour from JS.
export function ThemeProvider({ children }: { children: ReactNode }) {
const [mode, setModeState] = useState<ThemeMode>(readTheme);
const [accent, setAccentState] = useState<AccentId>(readAccent);
const [fonts, setFontsState] = useState<FontSetId>(readFonts);
useEffect(() => {
applyTheme(mode, accent);
}, [mode, accent]);
applyTheme(mode, accent, fonts);
}, [mode, accent, fonts]);
const setMode = useCallback((next: ThemeMode) => setModeState(next), []);
const setAccent = useCallback((next: AccentId) => setAccentState(next), []);
const setFonts = useCallback((next: FontSetId) => setFontsState(next), []);
const value = useMemo(() => ({ mode, accent, setMode, setAccent }), [mode, accent, setMode, setAccent]);
const value = useMemo(
() => ({ mode, accent, fonts, setMode, setAccent, setFonts }),
[mode, accent, fonts, setMode, setAccent, setFonts]
);
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
@@ -33,5 +40,5 @@ export function useTheme(): ThemeValue {
return value;
}
export { ACCENTS };
export type { AccentId, ThemeMode };
export { ACCENTS, FONT_SETS } from './themes';
export type { AccentId, FontSetId, ThemeMode } from './themes';
+20 -1
View File
@@ -3,6 +3,7 @@
export type ThemeMode = 'light' | 'dark';
export type AccentId = 'amber' | 'blue' | 'emerald' | 'rose' | 'violet';
export type FontSetId = 'studio' | 'editorial' | 'native';
export const ACCENTS: { id: AccentId; label: string }[] = [
{ id: 'amber', label: 'Amber' },
@@ -12,6 +13,16 @@ export const ACCENTS: { id: AccentId; label: string }[] = [
{ id: 'violet', label: 'Violet' },
];
// The three pairings proposed for the landing: heading / body / mono. All free
// for commercial use (SIL OFL), all with a Vietnamese subset, and all
// self-hosted under public/assets/fonts — the page still needs no CDN.
// The stacks themselves live in styles/tokens.css, keyed by data-fonts.
export const FONT_SETS: { id: FontSetId; label: string; note: string }[] = [
{ id: 'studio', label: 'Studio', note: 'Plus Jakarta Sans · Inter · JetBrains Mono' },
{ id: 'editorial', label: 'Editorial', note: 'Fraunces · Be Vietnam Pro · Courier Prime' },
{ id: 'native', label: 'Thuần Việt', note: 'Be Vietnam Pro · Space Mono' },
];
export const THEME_COLOR: Record<ThemeMode, string> = {
light: '#ffffff',
dark: '#0a0a0a',
@@ -19,6 +30,7 @@ export const THEME_COLOR: Record<ThemeMode, string> = {
const KEY_THEME = 'rc.theme';
const KEY_ACCENT = 'rc.accent';
const KEY_FONTS = 'rc.fonts';
export function readTheme(): ThemeMode {
const stored = localStorage.getItem(KEY_THEME);
@@ -31,11 +43,18 @@ export function readAccent(): AccentId {
return ACCENTS.some((a) => a.id === stored) ? (stored as AccentId) : 'amber';
}
export function applyTheme(mode: ThemeMode, accent: AccentId): void {
export function readFonts(): FontSetId {
const stored = localStorage.getItem(KEY_FONTS);
return FONT_SETS.some((f) => f.id === stored) ? (stored as FontSetId) : 'studio';
}
export function applyTheme(mode: ThemeMode, accent: AccentId, fonts: FontSetId): void {
const root = document.documentElement;
root.dataset.theme = mode;
root.dataset.accent = accent;
root.dataset.fonts = fonts;
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', THEME_COLOR[mode]);
localStorage.setItem(KEY_THEME, mode);
localStorage.setItem(KEY_ACCENT, accent);
localStorage.setItem(KEY_FONTS, fonts);
}