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
+9 -5
View File
@@ -48,11 +48,15 @@ project (`@shopify/react-native-skia` is aliased to `src/engine/skiaShim.ts` in
`cinemaShader` `colorUtils` `defaultRecipes` `exifWrite` `frameUtils` `jpegDpi`
`paramDefs` `recipeShare` `skiaImage` `toneShader`.
The landing page needs no CDN: `frontend/public/assets/Syne.woff2` is the
bundled display face and `frontend/public/assets/samples/s*.jpg` are the six
placeholder negatives the film strip, preset tester and QR card show (swap them
for real graded stills whenever we have them). Its one foreign request is the
QR image from `api.qrserver.com`, which degrades to an empty slot offline.
The landing page needs no CDN: `frontend/public/assets/fonts/*.woff2` are the
seven self-hosted faces behind the three font groups the Themes menu offers
(Plus Jakarta Sans / Inter / JetBrains Mono, Fraunces / Be Vietnam Pro /
Courier Prime, Be Vietnam Pro / Space Mono — all SIL OFL, pulled from Google
Fonts, vietnamese + latin + latin-ext subsets), and
`frontend/public/assets/samples/s*.jpg` are the six placeholder negatives the
film strip, preset tester and QR card show (swap them for real graded stills
whenever we have them). Its one foreign request is the QR image from
`api.qrserver.com`, which degrades to an empty slot offline.
When the app changes one of them, copy it back in — the renderer is only
"parity" for as long as these stay in sync:
+1 -1
View File
@@ -1,5 +1,5 @@
<!doctype html>
<html lang="vi" data-theme="dark" data-accent="amber">
<html lang="vi" data-theme="dark" data-accent="amber" data-fonts="studio">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
Binary file not shown.
+64 -14
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useI18n } from './i18n/I18nProvider';
import { useTheme } from './theme/ThemeProvider';
import { FONT_SETS, useTheme } from './theme/ThemeProvider';
// Landing page, built to the landing blueprint: the 10-section funnel. The
// marketing prose is bilingual and lives here next to the markup as `Txt`
@@ -99,8 +99,10 @@ function Icon({ name }: { name: Feature['icon'] }) {
export function Landing() {
const { t, lang, setLang } = useI18n();
const { mode, setMode } = useTheme();
const { mode, fonts, setMode, setFonts } = useTheme();
const [menu, setMenu] = useState(false);
const [themeMenu, setThemeMenu] = useState(false);
const tools = useRef<HTMLSpanElement>(null);
const [presetId, setPresetId] = useState(PRESETS[0].id);
const [temp, setTemp] = useState(0);
const [sat, setSat] = useState(100);
@@ -121,6 +123,16 @@ export function Landing() {
}, [c]);
useEffect(() => () => clearTimeout(toastTimer.current), []);
// The theme popover closes on a click anywhere else, like the workspace's.
useEffect(() => {
if (!themeMenu) return;
const away = (e: MouseEvent) => {
if (!tools.current?.contains(e.target as Node)) setThemeMenu(false);
};
document.addEventListener('mousedown', away);
return () => document.removeEventListener('mousedown', away);
}, [themeMenu]);
// The simulator's output: three knobs, one CSS filter string, applied live.
const labFilter = `sepia(${(Math.abs(temp) / 260).toFixed(3)}) hue-rotate(${(-temp * 0.45).toFixed(1)}deg) saturate(${sat}%) contrast(${con}%)`;
@@ -149,18 +161,56 @@ export function Landing() {
{nav.map((entry) => <a key={entry.href} href={entry.href}>{c(entry.label)}</a>)}
</div>
<span className="lp-spacer" />
{/* Theme + language, the same two switches the workspace TopBar has. */}
<span className="lp-tools">
<button
type="button"
className="lp-tool"
title={t('nav.theme')}
aria-label={t('nav.theme')}
aria-pressed={mode === 'light'}
onClick={() => setMode(mode === 'dark' ? 'light' : 'dark')}
>
◐
</button>
{/* Theme + language, the same two switches the workspace TopBar has.
Theme opens a popover: light/dark plus the font group, which
retypes the whole page. */}
<span className="lp-tools" ref={tools}>
<div className="menu-wrap">
<button
type="button"
className="lp-tool"
title={t('nav.theme')}
aria-label={t('nav.theme')}
aria-expanded={themeMenu}
onClick={() => setThemeMenu((v) => !v)}
>
◐
</button>
{themeMenu ? (
<div className="popover lp-theme-pop">
<div className="chip-row">
<button
type="button"
className={`chip${mode === 'light' ? ' on' : ''}`}
onClick={() => setMode('light')}
>
Light
</button>
<button
type="button"
className={`chip${mode === 'dark' ? ' on' : ''}`}
onClick={() => setMode('dark')}
>
Dark
</button>
</div>
<div className="chip-row" style={{ marginTop: 8 }}>
{FONT_SETS.map((entry) => (
<button
key={entry.id}
type="button"
data-key={`fonts-${entry.id}`}
title={entry.note}
className={`chip${fonts === entry.id ? ' on' : ''}`}
onClick={() => setFonts(entry.id)}
>
{entry.label}
</button>
))}
</div>
</div>
) : null}
</div>
<button
type="button"
className="lp-tool"
+1
View File
@@ -1,4 +1,5 @@
import { createRoot } from 'react-dom/client';
import './styles/fonts.css';
import './styles/tokens.css';
import './styles/app.css';
import './styles/landing.css';
+202
View File
@@ -0,0 +1,202 @@
/* Self-hosted faces for the three font groups offered in Themes: pulled once
from Google Fonts (all SIL OFL) and trimmed to the vietnamese/latin-ext/latin
subsets. To regenerate, request the css2 API for a family with a browser
User-Agent and save the woff2 each subset points at. */
@font-face {
font-family: 'Plus Jakarta Sans';
font-style: normal;
font-weight: 200 800;
font-display: swap;
src: url('/assets/fonts/PlusJakartaSans-vietnamese.woff2') format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
@font-face {
font-family: 'Plus Jakarta Sans';
font-style: normal;
font-weight: 200 800;
font-display: swap;
src: url('/assets/fonts/PlusJakartaSans-latin-ext.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'Plus Jakarta Sans';
font-style: normal;
font-weight: 200 800;
font-display: swap;
src: url('/assets/fonts/PlusJakartaSans-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('/assets/fonts/Inter-vietnamese.woff2') format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('/assets/fonts/Inter-latin-ext.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('/assets/fonts/Inter-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 100 800;
font-display: swap;
src: url('/assets/fonts/JetBrainsMono-vietnamese.woff2') format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 100 800;
font-display: swap;
src: url('/assets/fonts/JetBrainsMono-latin-ext.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 100 800;
font-display: swap;
src: url('/assets/fonts/JetBrainsMono-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Fraunces';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('/assets/fonts/Fraunces-vietnamese.woff2') format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
@font-face {
font-family: 'Fraunces';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('/assets/fonts/Fraunces-latin-ext.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'Fraunces';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('/assets/fonts/Fraunces-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Courier Prime';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/assets/fonts/CourierPrime-400-latin-ext.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'Courier Prime';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/assets/fonts/CourierPrime-400-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Courier Prime';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/assets/fonts/CourierPrime-700-latin-ext.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'Courier Prime';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/assets/fonts/CourierPrime-700-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Space Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/assets/fonts/SpaceMono-400-vietnamese.woff2') format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
@font-face {
font-family: 'Space Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/assets/fonts/SpaceMono-400-latin-ext.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'Space Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/assets/fonts/SpaceMono-400-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Space Mono';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/assets/fonts/SpaceMono-700-vietnamese.woff2') format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
@font-face {
font-family: 'Space Mono';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/assets/fonts/SpaceMono-700-latin-ext.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'Space Mono';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/assets/fonts/SpaceMono-700-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
+11 -11
View File
@@ -1,14 +1,8 @@
/* Landing page — dark cinematic theme per the landing blueprint. Self-contained:
its own palette, its own class prefix (.lp-*), no dependency on the workspace
theme, so the marketing page looks the same in light or dark mode. */
@font-face {
font-family: 'Syne';
font-style: normal;
font-weight: 700 800;
font-display: swap;
src: url('/assets/Syne.woff2') format('woff2');
}
theme, so the marketing page looks the same in light or dark mode. The two
faces it does borrow — heading + mono — come from the Themes font group
(styles/tokens.css), so the nav picker retypes the whole page. */
.lp {
--lp-bg: #090a0f;
@@ -25,8 +19,8 @@
/* Amber for text sitting on the page background — darker in light mode,
where plain #fbbf24 would fail contrast on white. */
--lp-ink-amber: var(--lp-amber);
--lp-mono: 'Cousine', ui-monospace, SFMono-Regular, Menlo, monospace;
--lp-display: 'Syne', ui-sans-serif, system-ui, sans-serif;
--lp-mono: var(--mono);
--lp-display: var(--font-heading);
background: var(--lp-bg);
color: var(--lp-text);
@@ -137,6 +131,12 @@ html { scroll-behavior: smooth; scroll-padding-top: 78px; }
cursor: pointer;
}
.lp-tool:hover { color: var(--lp-text); border-color: rgba(251, 191, 36, 0.5); }
/* The theme popover borrows the workspace's .popover/.chip shell, re-skinned for
the landing palette. */
.lp-theme-pop { background: var(--lp-card); border-color: var(--lp-line); }
.lp-theme-pop .chip { background: transparent; border-color: var(--lp-line); color: var(--lp-muted); }
.lp-theme-pop .chip:hover { color: var(--lp-text); }
.lp-theme-pop .chip.on { color: var(--lp-ink-amber); border-color: var(--lp-ink-amber); background: rgba(251, 191, 36, 0.12); }
.lp-burger { display: none; }
@media (max-width: 1160px) {
+22
View File
@@ -8,9 +8,31 @@
--rail-w: 84px;
--chip-w: 300px;
--font: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-heading: var(--font);
--mono: ui-monospace, SFMono-Regular, Menlo, monospace;
}
/* --- font groups -------------------------------------------------------- */
/* The three pairings offered in Themes; the faces themselves are self-hosted
by styles/fonts.css, and the picker's list lives in theme/themes.ts. */
[data-fonts="studio"] {
--font: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-heading: "Plus Jakarta Sans", "Inter", ui-sans-serif, system-ui, sans-serif;
--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
}
[data-fonts="editorial"] {
--font: "Be Vietnam Pro", "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-heading: "Fraunces", "Be Vietnam Pro", Georgia, serif;
--mono: "Courier Prime", ui-monospace, SFMono-Regular, Menlo, monospace;
}
[data-fonts="native"] {
--font: "Be Vietnam Pro", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-heading: "Be Vietnam Pro", ui-sans-serif, system-ui, sans-serif;
--mono: "Space Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
}
/* --- base themes --------------------------------------------------------- */
[data-theme="light"] {
--bg: #ffffff;
+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);
}
+16 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { useI18n, LANGS } from '../i18n/I18nProvider';
import { ACCENTS, useTheme } from '../theme/ThemeProvider';
import { ACCENTS, FONT_SETS, useTheme } from '../theme/ThemeProvider';
import type { User } from '../api';
// Header: the app's TopBar minus everything camera-only, plus the two controls
@@ -29,7 +29,7 @@ export function TopBar({
onLogout: () => void;
}) {
const { t, lang, setLang } = useI18n();
const { mode, accent, setMode, setAccent } = useTheme();
const { mode, accent, fonts, setMode, setAccent, setFonts } = useTheme();
const [menu, setMenu] = useState<'theme' | 'lang' | 'account' | null>(null);
const wrap = useRef<HTMLDivElement>(null);
@@ -79,6 +79,20 @@ export function TopBar({
</button>
))}
</div>
<div className="chip-row" style={{ marginTop: 8 }}>
{FONT_SETS.map((entry) => (
<button
key={entry.id}
type="button"
data-key={`fonts-${entry.id}`}
title={entry.note}
className={`chip${fonts === entry.id ? ' on' : ''}`}
onClick={() => setFonts(entry.id)}
>
{entry.label}
</button>
))}
</div>
</div>
) : null}
</div>