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) => ( + +
+
+
{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);