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:
@@ -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,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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user