🎨 feat(web/default): add Anthropic theme preset and configurable serif typography

Introduce a switchable Anthropic-inspired color preset and a new Font customization axis so users can adopt the editorial serif look across the entire UI, including sidebar navigation, tabs, form controls, buttons, and table headers.

Theme preset

Add anthropic to the theme preset registry with warm cream canvas, slate foreground, and clay/coral accent tokens for light and dark modes
Define explicit surface colors for the Anthropic preset instead of relying on the semantic surface bridge
Exclude anthropic from the primary-color surface bridge so bespoke warm neutrals are not overridden by accent-tinted mixes
Typography system

Add @fontsource-variable/lora and a global --font-serif token with CJK serif fallbacks (Noto Serif SC, Source Han Serif, Songti SC, etc.)
Introduce a --font-body token and drive <body> font-family from it
Add a Font axis (default | sans | serif) parallel to radius/scale
Resolve font: 'default' against preset defaults (anthropic → serif)
Persist font preference via cookie and apply data-theme-font on <body>
Apply serif OpenType features (kern, liga, calt, tnum) and heading display tuning when serif is active
Remove per-component sans opt-outs so serif inherits through sidebar, tabs, inputs, buttons, badges, and table headers via natural CSS cascade
Keep monospace contexts unchanged via Tailwind preflight and .font-mono
UI and i18n

Add Font selector to the theme config drawer (Auto / Sans / Serif)
Add "Font" and "Select body font" translations for en, zh, fr, ja, ru, vi
Misc

Tighten group and status badge sizing for better balance with serif text
This commit is contained in:
t0ng7u 2026-05-26 04:31:13 +08:00
parent 3360882642
commit a64f26d1d2
16 changed files with 481 additions and 14 deletions

View File

@ -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=="],

View File

@ -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",

View File

@ -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() {
<div className={sideDrawerFormClassName()}>
<ThemeConfig />
<PresetConfig />
<FontConfig />
<RadiusConfig />
<ScaleConfig />
<SidebarConfig />
@ -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 (
<div>
<SectionTitle
title={t('Font')}
showReset={customization.font !== defaults.font}
onReset={() => setFont(defaults.font)}
/>
<Radio
value={customization.font}
onValueChange={(v) => setFont(v as ThemeFont)}
className='grid w-full grid-cols-3 gap-4'
aria-label={t('Select body font')}
>
{FONT_OPTIONS.map((option) => (
<Item
key={option.value}
value={option.value}
className='group flex flex-col items-stretch outline-none'
aria-label={
option.value === 'default' ? t('System default') : option.label
}
>
<div
className={cn(
'ring-border relative h-12 rounded-md ring-[1px] transition',
'group-data-checked:ring-primary group-data-checked:shadow-md',
'group-focus-visible:ring-2',
'group-hover:ring-primary/60'
)}
>
<CircleCheck
className={cn(
'fill-primary absolute top-0 right-0 z-10 size-5 translate-x-1/2 -translate-y-1/2 stroke-white',
'group-data-unchecked:hidden'
)}
aria-hidden='true'
/>
<span
aria-hidden='true'
className='text-foreground absolute inset-0 flex items-center justify-center text-lg leading-none font-medium'
style={
option.preview
? { fontFamily: option.preview }
: // `font: inherit` defers to the active theme so the
// "Auto" tile previews what the resolved font will be.
{ font: 'inherit', fontSize: '1.125rem' }
}
>
Aa
</span>
</div>
<div className='mt-1.5 text-center text-xs'>{option.label}</div>
</Item>
))}
</Radio>
</div>
)
}
const RADIUS_OPTIONS: {
value: ThemeRadius
label: string

View File

@ -94,7 +94,7 @@ export function GroupBadge(props: GroupBadgeProps) {
{badge}
<span
className={cn(
'inline-flex h-6 items-center rounded-full px-2 font-mono text-sm leading-none font-medium tabular-nums',
'inline-flex h-5 items-center rounded-full px-2 font-mono text-xs leading-none font-medium tabular-nums',
getGroupRatioClassName(ratio)
)}
>

View File

@ -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 className='size-3.5 shrink-0' />}
{Icon && <Icon className='size-3 shrink-0' />}
{content}
</span>
)

View File

@ -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<ThemeFont>(() =>
readCookie<ThemeFont>(
THEME_COOKIE_KEYS.font,
THEME_FONT_VALUES,
DEFAULT_THEME_CUSTOMIZATION.font
)
)
const [radius, _setRadius] = useState<ThemeRadius>(() =>
readCookie<ThemeRadius>(
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<ThemeCustomizationContextType>(
() => ({
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,

View File

@ -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",

View File

@ -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",

View File

@ -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": "カラープリセットを選択",

View File

@ -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": "Выберите цветовую предустановку",

View File

@ -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",

View File

@ -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": "选择颜色预设",

View File

@ -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<ThemeFont, 'default'>
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<ThemePreset>
export const THEME_FONT_VALUES: ReadonlySet<ThemeFont> = new Set([
'default',
'sans',
'serif',
])
export const THEME_RADIUS_VALUES: ReadonlySet<ThemeRadius> = new Set([
'default',
'none',
@ -111,7 +150,42 @@ export const CONTENT_LAYOUT_VALUES: ReadonlySet<ContentLayout> = 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<ThemePreset, ResolvedThemeFont>
> = {
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
}

View File

@ -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. */

View File

@ -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:
* - `<code>`, `<kbd>`, `<pre>`, `<samp>` 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 <h*> 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;

View File

@ -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);