diff --git a/web/default/bun.lock b/web/default/bun.lock
index 88e1bf82..815e17f2 100644
--- a/web/default/bun.lock
+++ b/web/default/bun.lock
@@ -5,6 +5,7 @@
"name": "newapi-web",
"dependencies": {
"@base-ui/react": "^1.5.0",
+ "@fontsource-variable/lora": "^5.2.8",
"@fontsource-variable/public-sans": "^5.2.7",
"@hookform/resolvers": "^5.4.0",
"@hugeicons/core-free-icons": "^4.1.4",
@@ -86,7 +87,7 @@
},
},
"overrides": {
- "brace-expansion": "5.0.6",
+ "brace-expansion": "2.1.1",
"dompurify": "3.4.5",
"fast-uri": "3.1.2",
"hono": "4.12.22",
@@ -267,6 +268,8 @@
"@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
+ "@fontsource-variable/lora": ["@fontsource-variable/lora@5.2.8", "", {}, "sha512-cxjTJ9BbOWIzusewR4UMBLVePvTSWV6dtNaNsCkF/oKoyA68fJGWfaYCILOOP1BObE4dmjfZ3xo6m9hdHhtYhg=="],
+
"@fontsource-variable/public-sans": ["@fontsource-variable/public-sans@5.2.7", "", {}, "sha512-4mvade2J3slKkvwRkS+p8T3szet/0vhWoSnuUJTVU81Uo2pRpSZY/Y8bSLRqpSwzIPxjVmRJ53oq6JKP/l/PSg=="],
"@giscus/react": ["@giscus/react@3.1.0", "", { "dependencies": { "giscus": "^1.6.0" }, "peerDependencies": { "react": "^16 || ^17 || ^18 || ^19", "react-dom": "^16 || ^17 || ^18 || ^19" } }, "sha512-0TCO2TvL43+oOdyVVGHDItwxD1UMKP2ZYpT6gXmhFOqfAJtZxTzJ9hkn34iAF/b6YzyJ4Um89QIt9z/ajmAEeg=="],
@@ -981,13 +984,13 @@
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
- "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
+ "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.17", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA=="],
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
- "brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="],
+ "brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
diff --git a/web/default/package.json b/web/default/package.json
index 1e228d51..9976b6ae 100644
--- a/web/default/package.json
+++ b/web/default/package.json
@@ -19,6 +19,7 @@
},
"dependencies": {
"@base-ui/react": "^1.5.0",
+ "@fontsource-variable/lora": "^5.2.8",
"@fontsource-variable/public-sans": "^5.2.7",
"@hookform/resolvers": "^5.4.0",
"@hugeicons/core-free-icons": "^4.1.4",
@@ -98,7 +99,7 @@
"typescript-eslint": "^8.59.4"
},
"overrides": {
- "brace-expansion": "5.0.6",
+ "brace-expansion": "2.1.1",
"dompurify": "3.4.5",
"fast-uri": "3.1.2",
"hono": "4.12.22",
diff --git a/web/default/src/components/config-drawer.tsx b/web/default/src/components/config-drawer.tsx
index 4ece3da2..b1c806bf 100644
--- a/web/default/src/components/config-drawer.tsx
+++ b/web/default/src/components/config-drawer.tsx
@@ -34,6 +34,7 @@ import { IconThemeSystem } from '@/assets/custom/icon-theme-system'
import {
type ContentLayout,
THEME_PRESETS,
+ type ThemeFont,
type ThemePreset,
type ThemeRadius,
type ThemeScale,
@@ -104,6 +105,7 @@ export function ConfigDrawer() {
+
@@ -302,6 +304,90 @@ function PresetConfig() {
)
}
+/**
+ * Font options shown in the theme drawer.
+ *
+ * Each option renders a live "Aa" preview in the font it represents.
+ * `Auto` deliberately leaves `fontFamily` undefined so the preview inherits
+ * the currently active body font — that way the user sees what `Auto` will
+ * actually look like for the active preset (Anthropic → serif glyphs,
+ * everything else → sans glyphs) without us having to duplicate the
+ * preset-default mapping in the UI.
+ */
+const FONT_OPTIONS: {
+ value: ThemeFont
+ label: string
+ // CSS font-family applied to the "Aa" preview. `undefined` = inherit
+ // from the current theme (used by the `default` option).
+ preview?: string
+}[] = [
+ { value: 'default', label: 'Auto', preview: undefined },
+ { value: 'sans', label: 'Sans', preview: 'var(--font-sans)' },
+ { value: 'serif', label: 'Serif', preview: 'var(--font-serif)' },
+]
+
+function FontConfig() {
+ const { t } = useTranslation()
+ const { defaults, customization, setFont } = useThemeCustomization()
+ return (
+
+
setFont(defaults.font)}
+ />
+ setFont(v as ThemeFont)}
+ className='grid w-full grid-cols-3 gap-4'
+ aria-label={t('Select body font')}
+ >
+ {FONT_OPTIONS.map((option) => (
+ -
+
+
+
+ Aa
+
+
+ {option.label}
+
+ ))}
+
+
+ )
+}
+
const RADIUS_OPTIONS: {
value: ThemeRadius
label: string
diff --git a/web/default/src/components/group-badge.tsx b/web/default/src/components/group-badge.tsx
index 4f64d33d..2de122e5 100644
--- a/web/default/src/components/group-badge.tsx
+++ b/web/default/src/components/group-badge.tsx
@@ -94,7 +94,7 @@ export function GroupBadge(props: GroupBadgeProps) {
{badge}
diff --git a/web/default/src/components/status-badge.tsx b/web/default/src/components/status-badge.tsx
index 3e1054ae..a3b63bb1 100644
--- a/web/default/src/components/status-badge.tsx
+++ b/web/default/src/components/status-badge.tsx
@@ -74,8 +74,8 @@ export const textColorMap = {
export type StatusVariant = keyof typeof dotColorMap
const sizeMap = {
- sm: 'h-6 gap-1 px-2 text-sm leading-none',
- md: 'h-6 gap-1 px-2 text-sm leading-none',
+ sm: 'h-5 gap-1 px-2 text-xs leading-none',
+ md: 'h-6 gap-1 px-2 text-xs leading-none',
lg: 'h-7 gap-1.5 px-2.5 text-sm leading-none',
} as const
@@ -168,7 +168,7 @@ export function StatusBadge({
title={copyable ? `Click to copy: ${copyText || label || ''}` : undefined}
{...props}
>
- {Icon && }
+ {Icon && }
{content}
)
diff --git a/web/default/src/context/theme-customization-provider.tsx b/web/default/src/context/theme-customization-provider.tsx
index 9629f1ec..0bead8fe 100644
--- a/web/default/src/context/theme-customization-provider.tsx
+++ b/web/default/src/context/theme-customization-provider.tsx
@@ -29,11 +29,14 @@ import {
CONTENT_LAYOUT_VALUES,
type ContentLayout,
DEFAULT_THEME_CUSTOMIZATION,
+ resolveThemeFont,
THEME_COOKIE_KEYS,
+ THEME_FONT_VALUES,
THEME_PRESET_VALUES,
THEME_RADIUS_VALUES,
THEME_SCALE_VALUES,
type ThemeCustomization,
+ type ThemeFont,
type ThemePreset,
type ThemeRadius,
type ThemeScale,
@@ -65,6 +68,7 @@ type ThemeCustomizationContextType = {
defaults: ThemeCustomization
customization: ThemeCustomization
setPreset: (preset: ThemePreset) => void
+ setFont: (font: ThemeFont) => void
setRadius: (radius: ThemeRadius) => void
setScale: (scale: ThemeScale) => void
setContentLayout: (contentLayout: ContentLayout) => void
@@ -79,6 +83,7 @@ const FALLBACK_CONTEXT: ThemeCustomizationContextType = {
defaults: DEFAULT_THEME_CUSTOMIZATION,
customization: DEFAULT_THEME_CUSTOMIZATION,
setPreset: () => {},
+ setFont: () => {},
setRadius: () => {},
setScale: () => {},
setContentLayout: () => {},
@@ -98,6 +103,13 @@ export function ThemeCustomizationProvider(props: {
DEFAULT_THEME_CUSTOMIZATION.preset
)
)
+ const [font, _setFont] = useState
(() =>
+ readCookie(
+ THEME_COOKIE_KEYS.font,
+ THEME_FONT_VALUES,
+ DEFAULT_THEME_CUSTOMIZATION.font
+ )
+ )
const [radius, _setRadius] = useState(() =>
readCookie(
THEME_COOKIE_KEYS.radius,
@@ -129,6 +141,16 @@ export function ThemeCustomizationProvider(props: {
)
}, [preset])
+ // Font is the one axis where we resolve before writing the attribute:
+ // the persisted preference may be `default`, but CSS works in terms of
+ // the concrete `sans`/`serif` choice that should drive the cascade.
+ // Resolving here (instead of in CSS via `:not()` selectors) keeps the
+ // stylesheet to one simple `[data-theme-font='serif']` selector and lets
+ // future presets opt into typography via `PRESET_DEFAULT_FONT` alone.
+ useEffect(() => {
+ applyAttribute('data-theme-font', resolveThemeFont(font, preset))
+ }, [font, preset])
+
useEffect(() => {
applyAttribute(
'data-theme-radius',
@@ -156,6 +178,15 @@ export function ThemeCustomizationProvider(props: {
}
}, [])
+ const setFont = useCallback((value: ThemeFont) => {
+ _setFont(value)
+ if (value === DEFAULT_THEME_CUSTOMIZATION.font) {
+ removeCookie(THEME_COOKIE_KEYS.font)
+ } else {
+ setCookie(THEME_COOKIE_KEYS.font, value, COOKIE_MAX_AGE)
+ }
+ }, [])
+
const setRadius = useCallback((value: ThemeRadius) => {
_setRadius(value)
if (value === DEFAULT_THEME_CUSTOMIZATION.radius) {
@@ -185,16 +216,18 @@ export function ThemeCustomizationProvider(props: {
const resetCustomization = useCallback(() => {
setPreset(DEFAULT_THEME_CUSTOMIZATION.preset)
+ setFont(DEFAULT_THEME_CUSTOMIZATION.font)
setRadius(DEFAULT_THEME_CUSTOMIZATION.radius)
setScale(DEFAULT_THEME_CUSTOMIZATION.scale)
setContentLayout(DEFAULT_THEME_CUSTOMIZATION.contentLayout)
- }, [setPreset, setRadius, setScale, setContentLayout])
+ }, [setPreset, setFont, setRadius, setScale, setContentLayout])
const value = useMemo(
() => ({
defaults: DEFAULT_THEME_CUSTOMIZATION,
- customization: { preset, radius, scale, contentLayout },
+ customization: { preset, font, radius, scale, contentLayout },
setPreset,
+ setFont,
setRadius,
setScale,
setContentLayout,
@@ -202,10 +235,12 @@ export function ThemeCustomizationProvider(props: {
}),
[
preset,
+ font,
radius,
scale,
contentLayout,
setPreset,
+ setFont,
setRadius,
setScale,
setContentLayout,
diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json
index 8d6909b1..2fdb75fd 100644
--- a/web/default/src/i18n/locales/en.json
+++ b/web/default/src/i18n/locales/en.json
@@ -1756,6 +1756,7 @@
"FluentRead extension not detected. Please ensure it is installed and active.": "FluentRead extension not detected. Please ensure it is installed and active.",
"Flush interval (minutes)": "Flush interval (minutes)",
"Follow the guided steps to prepare your workspace before the first login.": "Follow the guided steps to prepare your workspace before the first login.",
+ "Font": "Font",
"Footer": "Footer",
"Footer text displayed at the bottom of pages": "Footer text displayed at the bottom of pages",
"footer.columns.about.links.aboutProject": "About Project",
@@ -3009,6 +3010,7 @@
"Preset recharge amounts displayed to users": "Preset recharge amounts displayed to users",
"Preset Template": "Preset Template",
"Preset templates": "Preset templates",
+ "preset.anthropic": "Anthropic",
"preset.default": "Default",
"preset.forest-whisper": "Forest Whisper",
"preset.lake-view": "Lake View",
@@ -3519,6 +3521,7 @@
"Select announcement type": "Select announcement type",
"Select at least one field to overwrite.": "Select at least one field to overwrite.",
"Select at least one target model": "Select at least one target model",
+ "Select body font": "Select body font",
"Select border radius": "Select border radius",
"Select channel type": "Select channel type",
"Select color preset": "Select color preset",
diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json
index abdb263d..e02e01ad 100644
--- a/web/default/src/i18n/locales/fr.json
+++ b/web/default/src/i18n/locales/fr.json
@@ -1756,6 +1756,7 @@
"FluentRead extension not detected. Please ensure it is installed and active.": "Extension FluentRead non détectée. Veuillez vous assurer qu'elle est installée et activée.",
"Flush interval (minutes)": "Intervalle d’écriture (minutes)",
"Follow the guided steps to prepare your workspace before the first login.": "Suivez les étapes guidées pour préparer votre espace de travail avant la première connexion.",
+ "Font": "Police",
"Footer": "Pied de page",
"Footer text displayed at the bottom of pages": "Texte de pied de page affiché en bas des pages",
"footer.columns.about.links.aboutProject": "À propos du projet",
@@ -3009,6 +3010,7 @@
"Preset recharge amounts displayed to users": "Montants de recharge prédéfinis affichés aux utilisateurs",
"Preset Template": "Modèle prédéfini",
"Preset templates": "Modèles prédéfinis",
+ "preset.anthropic": "Anthropic",
"preset.default": "Par défaut",
"preset.forest-whisper": "Murmure de la forêt",
"preset.lake-view": "Vue sur le lac",
@@ -3519,6 +3521,7 @@
"Select announcement type": "Sélectionner le type d'annonce",
"Select at least one field to overwrite.": "Sélectionnez au moins un champ à écraser.",
"Select at least one target model": "Sélectionnez au moins un modèle cible",
+ "Select body font": "Sélectionner la police du corps de texte",
"Select border radius": "Sélectionner le rayon de bordure",
"Select channel type": "Sélectionner le type de canal",
"Select color preset": "Sélectionner un préréglage de couleur",
diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json
index b814585c..a2beaf96 100644
--- a/web/default/src/i18n/locales/ja.json
+++ b/web/default/src/i18n/locales/ja.json
@@ -1756,6 +1756,7 @@
"FluentRead extension not detected. Please ensure it is installed and active.": "FluentRead 拡張機能が検出されませんでした。インストールされていて有効になっていることを確認してください。",
"Flush interval (minutes)": "書き込み間隔(分)",
"Follow the guided steps to prepare your workspace before the first login.": "初回ログイン前に、ガイド付きの手順に従ってワークスペースを準備してください。",
+ "Font": "フォント",
"Footer": "フッター",
"Footer text displayed at the bottom of pages": "ページ下部に表示されるフッターテキスト",
"footer.columns.about.links.aboutProject": "プロジェクトについて",
@@ -3009,6 +3010,7 @@
"Preset recharge amounts displayed to users": "ユーザーに表示されるプリセットチャージ金額",
"Preset Template": "プリセットテンプレート",
"Preset templates": "プリセットのテンプレート",
+ "preset.anthropic": "Anthropic",
"preset.default": "デフォルト",
"preset.forest-whisper": "フォレストウィスパー",
"preset.lake-view": "レイクビュー",
@@ -3519,6 +3521,7 @@
"Select announcement type": "アナウンスメントタイプを選択",
"Select at least one field to overwrite.": "上書きするフィールドを少なくとも 1 つ選択してください。",
"Select at least one target model": "少なくとも1つの対象モデルを選択してください",
+ "Select body font": "本文フォントを選択",
"Select border radius": "角丸を選択",
"Select channel type": "チャネルタイプを選択",
"Select color preset": "カラープリセットを選択",
diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json
index 33ff90ef..eee01191 100644
--- a/web/default/src/i18n/locales/ru.json
+++ b/web/default/src/i18n/locales/ru.json
@@ -1756,6 +1756,7 @@
"FluentRead extension not detected. Please ensure it is installed and active.": "Расширение FluentRead не обнаружено. Убедитесь, что оно установлено и активно.",
"Flush interval (minutes)": "Интервал записи (минуты)",
"Follow the guided steps to prepare your workspace before the first login.": "Следуйте пошаговым инструкциям, чтобы подготовить рабочее пространство перед первым входом.",
+ "Font": "Шрифт",
"Footer": "Подвал",
"Footer text displayed at the bottom of pages": "Текст нижнего колонтитула, отображаемый внизу страниц",
"footer.columns.about.links.aboutProject": "О проекте",
@@ -3009,6 +3010,7 @@
"Preset recharge amounts displayed to users": "Предустановленные суммы пополнения, отображаемые пользователям",
"Preset Template": "Предустановленный шаблон",
"Preset templates": "Предустановленные шаблоны",
+ "preset.anthropic": "Anthropic",
"preset.default": "По умолчанию",
"preset.forest-whisper": "Лесной шёпот",
"preset.lake-view": "Озёрный вид",
@@ -3519,6 +3521,7 @@
"Select announcement type": "Выбрать тип объявления",
"Select at least one field to overwrite.": "Выберите хотя бы одно поле для перезаписи.",
"Select at least one target model": "Выберите хотя бы одну целевую модель",
+ "Select body font": "Выберите шрифт текста",
"Select border radius": "Выберите радиус скругления",
"Select channel type": "Выбрать тип канала",
"Select color preset": "Выберите цветовую предустановку",
diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json
index 12d03a2c..6f7d7a49 100644
--- a/web/default/src/i18n/locales/vi.json
+++ b/web/default/src/i18n/locales/vi.json
@@ -1756,6 +1756,7 @@
"FluentRead extension not detected. Please ensure it is installed and active.": "Không phát hiện tiện ích mở rộng FluentRead. Vui lòng đảm bảo nó đã được cài đặt và kích hoạt.",
"Flush interval (minutes)": "Khoảng ghi xuống DB (phút)",
"Follow the guided steps to prepare your workspace before the first login.": "Thực hiện theo các bước hướng dẫn để chuẩn bị không gian làm việc của bạn trước lần đăng nhập đầu tiên.",
+ "Font": "Phông chữ",
"Footer": "Chân trang",
"Footer text displayed at the bottom of pages": "Văn bản chân trang hiển thị ở cuối các trang",
"footer.columns.about.links.aboutProject": "Về Dự án",
@@ -3009,6 +3010,7 @@
"Preset recharge amounts displayed to users": "Các mức nạp tiền đặt trước hiển thị cho người dùng",
"Preset Template": "Mẫu cài sẵn",
"Preset templates": "Mẫu sẵn",
+ "preset.anthropic": "Anthropic",
"preset.default": "Mặc định",
"preset.forest-whisper": "Thì thầm rừng",
"preset.lake-view": "Hồ nước",
@@ -3519,6 +3521,7 @@
"Select announcement type": "Select notification type",
"Select at least one field to overwrite.": "Chọn ít nhất một trường để ghi đè.",
"Select at least one target model": "Chọn ít nhất một mô hình đích",
+ "Select body font": "Chọn phông chữ nội dung",
"Select border radius": "Chọn độ bo góc",
"Select channel type": "Chọn loại kênh",
"Select color preset": "Chọn cài đặt màu sẵn",
diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json
index af4fdab2..7e36f2a7 100644
--- a/web/default/src/i18n/locales/zh.json
+++ b/web/default/src/i18n/locales/zh.json
@@ -1756,6 +1756,7 @@
"FluentRead extension not detected. Please ensure it is installed and active.": "未检测到 FluentRead 扩展。请确保已安装并激活。",
"Flush interval (minutes)": "刷库间隔(分钟)",
"Follow the guided steps to prepare your workspace before the first login.": "请按照引导步骤在首次登录前准备您的工作区。",
+ "Font": "字体",
"Footer": "页脚",
"Footer text displayed at the bottom of pages": "显示在页面底部的页脚文本",
"footer.columns.about.links.aboutProject": "关于项目",
@@ -3009,6 +3010,7 @@
"Preset recharge amounts displayed to users": "向用户显示的预设充值金额",
"Preset Template": "预设模板",
"Preset templates": "预设模板",
+ "preset.anthropic": "Anthropic",
"preset.default": "默认",
"preset.forest-whisper": "森林低语",
"preset.lake-view": "湖光",
@@ -3519,6 +3521,7 @@
"Select announcement type": "选择公告类型",
"Select at least one field to overwrite.": "请选择至少一个要覆盖的字段。",
"Select at least one target model": "请至少选择一个目标模型",
+ "Select body font": "选择正文字体",
"Select border radius": "选择圆角大小",
"Select channel type": "选择渠道类型",
"Select color preset": "选择颜色预设",
diff --git a/web/default/src/lib/theme-customization.ts b/web/default/src/lib/theme-customization.ts
index 4039954d..0a3c4c26 100644
--- a/web/default/src/lib/theme-customization.ts
+++ b/web/default/src/lib/theme-customization.ts
@@ -29,6 +29,14 @@ export const THEME_PRESETS = [
name: 'Default',
swatches: ['oklch(0.13 0 0)', 'oklch(0.95 0 0)'],
},
+ {
+ // Inspired by Anthropic's official brand language: warm cream canvas
+ // (#faf9f5) paired with clay/coral (#d97757) as the single accent.
+ // Swatches preview the canvas → accent gradient that defines the system.
+ value: 'anthropic',
+ name: 'Anthropic',
+ swatches: ['oklch(0.984 0.005 95)', 'oklch(0.685 0.142 38)'],
+ },
{
value: 'underground',
name: 'Underground',
@@ -71,8 +79,32 @@ export type ThemeRadius = 'default' | 'none' | 'sm' | 'md' | 'lg' | 'xl'
export type ThemeScale = 'default' | 'sm' | 'lg'
export type ContentLayout = 'full' | 'centered'
+/**
+ * Font axis for the theme.
+ *
+ * - `default` — resolve at runtime from the active preset
+ * (see `PRESET_DEFAULT_FONT`). The shipped `default` and `anthropic`
+ * presets resolve to serif; other named color presets fall back to
+ * sans unless they list a different choice. Mirrors how
+ * `radius: 'default'` defers to a per-preset hint.
+ * - `sans` — humanist sans (Public Sans), the project's UI fallback.
+ * - `serif` — editorial serif (Lora + CJK fallbacks), the project's
+ * "soul" typography. Inherits across the whole UI; monospace contexts
+ * keep their own family via Tailwind preflight and `.font-mono`.
+ */
+export type ThemeFont = 'default' | 'sans' | 'serif'
+
+/**
+ * The resolved (non-`default`) font value applied to the DOM. The provider
+ * always sets `data-theme-font` to one of these concrete values so CSS only
+ * needs simple attribute selectors (no `:not()` gymnastics, no per-preset
+ * font branches).
+ */
+export type ResolvedThemeFont = Exclude
+
export type ThemeCustomization = {
preset: ThemePreset
+ font: ThemeFont
radius: ThemeRadius
scale: ThemeScale
contentLayout: ContentLayout
@@ -80,6 +112,7 @@ export type ThemeCustomization = {
export const DEFAULT_THEME_CUSTOMIZATION: ThemeCustomization = {
preset: 'default',
+ font: 'default',
radius: 'default',
scale: 'default',
contentLayout: 'full',
@@ -89,6 +122,12 @@ export const THEME_PRESET_VALUES = new Set(
THEME_PRESETS.map((p) => p.value)
) as ReadonlySet
+export const THEME_FONT_VALUES: ReadonlySet = new Set([
+ 'default',
+ 'sans',
+ 'serif',
+])
+
export const THEME_RADIUS_VALUES: ReadonlySet = new Set([
'default',
'none',
@@ -111,7 +150,42 @@ export const CONTENT_LAYOUT_VALUES: ReadonlySet = new Set([
export const THEME_COOKIE_KEYS = {
preset: 'theme_preset',
+ font: 'theme_font',
radius: 'theme_radius',
scale: 'theme_scale',
contentLayout: 'theme_content_layout',
} as const
+
+/**
+ * Preset → default font mapping. Used by the provider to resolve the user's
+ * `font: 'default'` preference against the active preset.
+ *
+ * Co-located with the preset registry so a preset's signature typography
+ * is declared in one place. Presets not listed here fall back to the
+ * `resolveThemeFont` default of `sans`. The shipped `default` preset
+ * opts into serif so the editorial Lora voice is the out-of-the-box
+ * experience; vivid color presets stay on the humanist sans so their
+ * accents read clearly without competing with the body type.
+ */
+export const PRESET_DEFAULT_FONT: Partial<
+ Record
+> = {
+ default: 'serif',
+ anthropic: 'serif',
+}
+
+/**
+ * Resolve a user font preference + active preset into the concrete font that
+ * should drive the DOM. Pure function so it's safe to call inside both the
+ * effect that applies the attribute and the UI preview that hints at what
+ * `default` will render as.
+ */
+export function resolveThemeFont(
+ font: ThemeFont,
+ preset: ThemePreset
+): ResolvedThemeFont {
+ if (font === 'default') {
+ return PRESET_DEFAULT_FONT[preset] ?? 'sans'
+ }
+ return font
+}
diff --git a/web/default/src/styles/index.css b/web/default/src/styles/index.css
index 36cc6a52..c38d9a11 100644
--- a/web/default/src/styles/index.css
+++ b/web/default/src/styles/index.css
@@ -20,6 +20,12 @@ For commercial licensing, please contact support@quantumnous.com
@import 'tw-animate-css';
@import 'shadcn/tailwind.css';
@import '@fontsource-variable/public-sans';
+/* Editorial serif (Lora) backing the `serif` font axis and the Anthropic
+ * preset's default typography. See `--font-serif` in theme.css for the
+ * full Latin + CJK fallback stack and `theme-presets.css` for the cascade
+ * that activates it. Loaded globally so font-switching is instantaneous
+ * with no FOUT once the variable is fetched. */
+@import '@fontsource-variable/lora';
@import './theme.css';
@import './theme-presets.css';
@@ -44,7 +50,11 @@ For commercial licensing, please contact support@quantumnous.com
@apply overflow-x-hidden font-sans;
}
body {
- @apply bg-background text-foreground has-[div[data-variant='inset']]:bg-sidebar min-h-svh w-full font-sans;
+ @apply bg-background text-foreground has-[div[data-variant='inset']]:bg-sidebar min-h-svh w-full;
+ /* Font is driven by the theme's font axis via `--font-body`
+ * (defined in theme.css, swapped by `[data-theme-font='...']` blocks
+ * in theme-presets.css). Defaults to the project's humanist sans. */
+ font-family: var(--font-body);
}
/* Keep sticky headers stable while primitives lock body scrolling. */
diff --git a/web/default/src/styles/theme-presets.css b/web/default/src/styles/theme-presets.css
index cb87717c..11be0720 100644
--- a/web/default/src/styles/theme-presets.css
+++ b/web/default/src/styles/theme-presets.css
@@ -294,8 +294,20 @@ For commercial licensing, please contact support@quantumnous.com
/* ── Semantic surface bridge ──────────────────────────────────────────── */
/* Color presets should tint the surfaces most components actually use, not
* only primary buttons. These derived tokens keep the app theme-aware without
- * duplicating per-component dark-mode overrides. */
-[data-theme-preset]:not([data-theme-preset='default']) {
+ * duplicating per-component dark-mode overrides.
+ *
+ * NOTE: `:not()` contributes its argument's specificity, so this selector
+ * resolves to (0,2,0). Presets that define bespoke surfaces below need to
+ * either match that specificity or opt out here — the latter is cleaner.
+ *
+ * Opt-outs:
+ * - `default`: keeps neutral surfaces from :root.
+ * - `anthropic`: warm cream surfaces are a brand choice, NOT a primary-mix
+ * derivation (the Anthropic system deliberately uses warm neutrals for
+ * cards/borders rather than tinting them with the clay accent). */
+[data-theme-preset]:not([data-theme-preset='default']):not(
+ [data-theme-preset='anthropic']
+ ) {
--card: color-mix(in oklch, var(--primary) 3%, var(--background));
--popover: color-mix(in oklch, var(--primary) 5%, var(--background));
--muted: color-mix(in oklch, var(--primary) 7%, var(--background));
@@ -317,7 +329,10 @@ For commercial licensing, please contact support@quantumnous.com
--info: var(--chart-1);
--neutral: var(--muted-foreground);
}
-.dark [data-theme-preset]:not([data-theme-preset='default']) {
+.dark
+ [data-theme-preset]:not([data-theme-preset='default']):not(
+ [data-theme-preset='anthropic']
+ ) {
--card: color-mix(in oklch, var(--primary) 8%, var(--background));
--popover: color-mix(in oklch, var(--primary) 12%, var(--background));
--muted: color-mix(in oklch, var(--primary) 12%, var(--background));
@@ -334,6 +349,213 @@ For commercial licensing, please contact support@quantumnous.com
--sidebar-border: color-mix(in oklch, var(--primary) 22%, var(--background));
}
+/* ── Anthropic ────────────────────────────────────────────────────────── */
+/*
+ * Inspired by Anthropic's official brand language: warm cream canvas
+ * (#faf9f5) on warm slate ink (#141413), with clay/coral (#d97757) as the
+ * single primary accent. The dormant accent palette (olive, sky, fig,
+ * cactus) is wired into chart and semantic tokens.
+ *
+ * Defining counter-positioning: a tinted (non-white) canvas with warm
+ * neutral cards and borders — NOT primary-tinted surfaces. This is the
+ * brand's deliberate counter-positioning against every cool-gray AI tool.
+ *
+ * Anthropic is opted out of the semantic surface bridge above so these
+ * bespoke warm-neutral surface tokens win the cascade. Without the opt-out,
+ * the bridge selector (specificity 0,2,0 because of `:not()`) would override
+ * this block (specificity 0,1,0) and tint every surface with the clay
+ * accent — producing the peach/pink look that doesn't match Anthropic.
+ *
+ * OKLCH hue 95 = warm yellow-cream (matches #faf9f5 family);
+ * OKLCH hue 60 = warm slate (matches #141413 family);
+ * OKLCH hue 38 = clay/coral (matches #d97757).
+ */
+[data-theme-preset='anthropic'] {
+ /* Canvas + ink — the defining pair. */
+ --background: oklch(0.984 0.004 95); /* ≈ #faf9f5 cream */
+ --foreground: oklch(0.205 0.005 60); /* ≈ #141413 ink */
+
+ /* Warm-neutral surfaces (NOT primary-tinted). Stepped opacity matches
+ * the Anthropic surface ladder: canvas → secondary → card → strong. */
+ --card: oklch(0.945 0.008 92); /* ≈ #efe9de */
+ --card-foreground: oklch(0.205 0.005 60);
+ --popover: oklch(0.97 0.006 92); /* slight cream lift */
+ --popover-foreground: oklch(0.205 0.005 60);
+
+ /* Clay/coral — Anthropic's signature accent, used scarcely on CTAs. */
+ --primary: oklch(0.685 0.142 38); /* ≈ #d97757 */
+ --primary-foreground: oklch(0.99 0.005 95);
+
+ --secondary: oklch(0.925 0.008 92);
+ --secondary-foreground: oklch(0.255 0.005 60);
+
+ --muted: oklch(0.94 0.007 92);
+ --muted-foreground: oklch(0.51 0.006 75); /* ≈ #5e5d59 warm gray */
+ --accent: oklch(0.92 0.009 92);
+ --accent-foreground: oklch(0.205 0.005 60);
+
+ --destructive: oklch(0.55 0.18 27);
+ --destructive-foreground: oklch(0.985 0 0);
+ --success: oklch(0.59 0.082 130); /* olive #788c5d */
+ --success-foreground: oklch(0.985 0 0);
+ --warning: oklch(0.78 0.13 70); /* kraft amber */
+ --warning-foreground: oklch(0.205 0.005 60);
+ --info: oklch(0.67 0.075 248); /* sky #6a9bcc */
+ --info-foreground: oklch(0.985 0 0);
+ --neutral: oklch(0.51 0.006 75);
+ --neutral-foreground: oklch(0.205 0.005 60);
+
+ /* Hairline borders — warm gray, not coral. */
+ --border: oklch(0.895 0.008 92); /* ≈ #e8e6dc */
+ --input: oklch(0.895 0.008 92);
+ --ring: oklch(0.685 0.142 38);
+
+ /* Chart palette uses Anthropic's dormant accent swatches. */
+ --chart-1: oklch(0.685 0.142 38); /* clay */
+ --chart-2: oklch(0.59 0.082 130); /* olive */
+ --chart-3: oklch(0.67 0.075 248); /* sky */
+ --chart-4: oklch(0.7 0.115 0); /* fig */
+ --chart-5: oklch(0.83 0.027 175); /* cactus */
+
+ --sidebar: oklch(0.955 0.008 92);
+ --sidebar-foreground: oklch(0.255 0.005 60);
+ --sidebar-primary: oklch(0.685 0.142 38);
+ --sidebar-primary-foreground: oklch(0.99 0.005 95);
+ --sidebar-accent: oklch(0.915 0.009 92);
+ --sidebar-accent-foreground: oklch(0.205 0.005 60);
+ --sidebar-border: oklch(0.895 0.008 92);
+ --sidebar-ring: oklch(0.685 0.142 38);
+
+ --skeleton-base: oklch(0.93 0.008 92);
+ --skeleton-highlight: oklch(0.96 0.006 92);
+
+ --radius: 0.625rem;
+
+ /* Default typography for the Anthropic preset is the editorial serif.
+ * Users can override this with the Font axis (`data-theme-font='sans'`).
+ * The `--font-serif` token itself is declared once in theme.css. */
+ --font-body: var(--font-serif);
+}
+.dark [data-theme-preset='anthropic'] {
+ /* Warm near-black product surfaces, not pure black — keeps the editorial
+ * personality even when inverted. Coral lifts slightly for legibility. */
+ --background: oklch(0.205 0.004 60); /* ≈ #181715 */
+ --foreground: oklch(0.965 0.005 92); /* ≈ #faf9f5 */
+ --card: oklch(0.245 0.004 60);
+ --card-foreground: oklch(0.965 0.005 92);
+ --popover: oklch(0.265 0.004 60);
+ --popover-foreground: oklch(0.965 0.005 92);
+
+ --primary: oklch(0.72 0.135 40);
+ --primary-foreground: oklch(0.18 0.005 60);
+ --secondary: oklch(0.295 0.004 60);
+ --secondary-foreground: oklch(0.945 0.005 92);
+
+ --muted: oklch(0.275 0.004 60);
+ --muted-foreground: oklch(0.76 0.006 75);
+ --accent: oklch(0.32 0.006 60);
+ --accent-foreground: oklch(0.985 0.005 92);
+
+ --destructive: oklch(0.7 0.19 22);
+ --destructive-foreground: oklch(0.985 0 0);
+ --success: oklch(0.7 0.105 135);
+ --success-foreground: oklch(0.18 0.005 60);
+ --warning: oklch(0.78 0.13 70);
+ --warning-foreground: oklch(0.18 0.005 60);
+ --info: oklch(0.72 0.085 248);
+ --info-foreground: oklch(0.18 0.005 60);
+ --neutral: oklch(0.76 0.006 75);
+ --neutral-foreground: oklch(0.18 0.005 60);
+
+ --border: oklch(1 0 0 / 10%);
+ --input: oklch(1 0 0 / 16%);
+ --ring: oklch(0.72 0.135 40);
+
+ --chart-1: oklch(0.72 0.135 40);
+ --chart-2: oklch(0.7 0.105 135);
+ --chart-3: oklch(0.72 0.085 248);
+ --chart-4: oklch(0.78 0.13 0);
+ --chart-5: oklch(0.83 0.027 175);
+
+ --sidebar: oklch(0.175 0.004 60);
+ --sidebar-foreground: oklch(0.95 0.005 92);
+ --sidebar-primary: oklch(0.72 0.135 40);
+ --sidebar-primary-foreground: oklch(0.18 0.005 60);
+ --sidebar-accent: oklch(0.31 0.006 60);
+ --sidebar-accent-foreground: oklch(0.985 0.005 92);
+ --sidebar-border: oklch(1 0 0 / 10%);
+ --sidebar-ring: oklch(0.72 0.135 40);
+
+ --skeleton-base: oklch(0.295 0.004 60);
+ --skeleton-highlight: oklch(0.4 0.004 60);
+}
+
+/* ── Font axis ────────────────────────────────────────────────────────────
+ * Mirrors how `data-theme-radius` overrides a preset's default radius:
+ * presets may set `--font-body` (Anthropic → serif), and the user's
+ * explicit Font choice wins because these blocks sit AFTER preset blocks.
+ *
+ * The provider resolves `font: 'default'` → `'sans' | 'serif'` against the
+ * active preset before writing the attribute, so the DOM always carries a
+ * concrete value and CSS only needs the simple `[data-theme-font='serif']`
+ * selector below (no `:not()`, no per-preset branches). */
+[data-theme-font='sans'] {
+ --font-body: var(--font-sans);
+}
+[data-theme-font='serif'] {
+ --font-body: var(--font-serif);
+}
+
+/* ── Serif typography refinements ────────────────────────────────────────
+ * When the body runs in serif, three things happen:
+ * 1. Editorial OpenType features (kern/liga + tabular numerals so numeric
+ * columns stay grid-aligned even with proportional serif glyphs).
+ * 2. Every UI surface inherits the editorial voice — buttons, inputs,
+ * tabs, sidebar, table headers, badges, pagination, popovers — all
+ * through Tailwind preflight's `button, input, ... { font: inherit }`
+ * rule plus natural HTML inheritance. We intentionally do NOT carry
+ * a per-slot opt-out list: it adds maintenance cost and only blunts
+ * the Anthropic editorial intent. Monospace contexts are excluded
+ * automatically because:
+ * - ``, ``, ``, `` are typed monospace by
+ * Tailwind preflight (specificity wins over body inheritance).
+ * - `.font-mono` and `.tabular-nums` utilities declare their own
+ * font-family / font-variant-numeric on the element itself.
+ * 3. Headings adopt the Anthropic display setting (medium weight, slight
+ * negative tracking).
+ *
+ * All keyed off `[data-theme-font='serif']` so they apply to any preset
+ * paired with the serif font — not just Anthropic. */
+[data-theme-font='serif'] {
+ font-feature-settings:
+ 'kern' 1,
+ 'liga' 1,
+ 'calt' 1,
+ 'tnum' 1;
+}
+
+/* Heading tuning — applies to tags and the shadcn title slots so
+ * card/sheet/dialog titles read with the same authoritative voice as
+ * page headers. */
+[data-theme-font='serif'] :is(h1, h2, h3, h4, h5, h6),
+[data-theme-font='serif'] [data-slot='sheet-title'],
+[data-theme-font='serif'] [data-slot='dialog-title'],
+[data-theme-font='serif'] [data-slot='alert-dialog-title'],
+[data-theme-font='serif'] [data-slot='drawer-title'],
+[data-theme-font='serif'] [data-slot='card-title'] {
+ font-weight: 500;
+ letter-spacing: -0.012em;
+}
+
+/* Larger displays earn tighter tracking, matching Copernicus/Tiempos
+ * editorial display setting (~ -0.02em at 30px+). */
+[data-theme-font='serif'] h1,
+[data-theme-font='serif'] .text-3xl,
+[data-theme-font='serif'] .text-4xl,
+[data-theme-font='serif'] .text-5xl {
+ letter-spacing: -0.02em;
+}
+
/* ── Border radius ────────────────────────────────────────────────────── */
[data-theme-radius='none'] {
--radius: 0rem;
diff --git a/web/default/src/styles/theme.css b/web/default/src/styles/theme.css
index c67f9822..5464a4dd 100644
--- a/web/default/src/styles/theme.css
+++ b/web/default/src/styles/theme.css
@@ -20,6 +20,19 @@ For commercial licensing, please contact support@quantumnous.com
@theme inline {
--font-sans: 'Public Sans', sans-serif;
+ /* Editorial serif token — the body face used by the `serif` font axis
+ * and by presets that opt in (currently Anthropic). Lora carries only
+ * Latin glyphs, so the stack walks through CJK serifs (Noto / Source Han
+ * / Songti / SimSun) before the generic `serif` fallback. Without these
+ * explicit CJK fonts the browser's generic-serif pick on Windows is
+ * non-deterministic at small sizes and often appears sans-like. */
+ --font-serif:
+ 'Lora Variable', 'Lora', 'Source Serif Pro', 'Source Serif 4',
+ 'Noto Serif SC', 'Noto Serif TC', 'Noto Serif JP', 'Noto Serif KR',
+ 'Source Han Serif SC', 'Source Han Serif TC', 'Source Han Serif',
+ 'Songti SC', 'STSong', 'STSongti-SC-Regular', 'PingFang SC', 'SimSun',
+ 'NSimSun', '宋体', 'FangSong', '仿宋', 'KaiTi', '楷体', Georgia,
+ 'Times New Roman', Cambria, 'Liberation Serif', serif;
--font-inter:
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu',
'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
@@ -82,6 +95,11 @@ For commercial licensing, please contact support@quantumnous.com
--app-header-height: 3rem;
/* Static build-channel fallback consumed when JS hasn't booted yet. */
--app-rev: '2k6e8r7p';
+ /* Currently active body font. The font axis swaps this between
+ * `--font-sans` and `--font-serif` via `[data-theme-font='...']` blocks
+ * in theme-presets.css. Default mirrors `--font-sans` so behavior is
+ * identical when no font preference is set. */
+ --font-body: var(--font-sans);
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);