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
+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>