diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index b364a8e3a1..fd1657cc19 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -289,7 +289,7 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { ); return ( -
+{props.text} - +); }); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index d534eefaa4..fb61c3f9a6 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -18,7 +18,7 @@ import { type ServerProviderModel, ThreadId, } from "@t3tools/contracts"; -import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; +import { DEFAULT_UNIFIED_SETTINGS, type UnifiedSettings } from "@t3tools/contracts/settings"; import { normalizeModelSlug } from "@t3tools/shared/model"; import { Equal } from "effect"; import { APP_VERSION } from "../../branding"; @@ -48,6 +48,18 @@ import { import { ensureNativeApi, readNativeApi } from "../../nativeApi"; import { useStore } from "../../store"; import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat"; +import { + CHAT_TYPOGRAPHY_FONT_SIZE_OPTIONS, + CODE_TYPOGRAPHY_FONT_SIZE_OPTIONS, + FONT_FAMILY_OPTIONS, + TYPOGRAPHY_LINE_HEIGHT_OPTIONS, + isChatTypographyFontSize, + isCodeTypographyFontSize, + isFontFamilySetting, + isTypographyLineHeight, + isUserMessageFontSetting, + USER_MESSAGE_FONT_OPTIONS, +} from "../../typography"; import { cn } from "../../lib/utils"; import { Button } from "../ui/button"; import { Collapsible, CollapsibleContent } from "../ui/collapsible"; @@ -315,6 +327,57 @@ function SettingsPageContainer({ children }: { children: ReactNode }) { ); } +function TypographySettingSelect(props: { + ariaLabel: string; + label: string; + value: string; + options: ReadonlyArray<{ value: string; label: string }>; + onValueChange: (value: string | null) => void; +}) { + return ( +++ ); +} + +function getTypographyDirtyState( + settings: Pick< + UnifiedSettings, + | "fontFamily" + | "userMessageFont" + | "chatFontSize" + | "chatLineHeight" + | "codeFontSize" + | "codeLineHeight" + >, +) { + return { + isFontFamilyDirty: settings.fontFamily !== DEFAULT_UNIFIED_SETTINGS.fontFamily, + isUserMessageFontDirty: settings.userMessageFont !== DEFAULT_UNIFIED_SETTINGS.userMessageFont, + isChatTypographyDirty: + settings.chatFontSize !== DEFAULT_UNIFIED_SETTINGS.chatFontSize || + settings.chatLineHeight !== DEFAULT_UNIFIED_SETTINGS.chatLineHeight, + isCodeTypographyDirty: + settings.codeFontSize !== DEFAULT_UNIFIED_SETTINGS.codeFontSize || + settings.codeLineHeight !== DEFAULT_UNIFIED_SETTINGS.codeLineHeight, + } as const; +} + function AboutVersionTitle() { return ( @@ -446,6 +509,12 @@ export function useSettingsRestore(onRestored?: () => void) { const { theme, setTheme } = useTheme(); const settings = useSettings(); const { resetSettings } = useUpdateSettings(); + const { + isFontFamilyDirty, + isUserMessageFontDirty, + isChatTypographyDirty, + isCodeTypographyDirty, + } = getTypographyDirtyState(settings); const isGitWritingModelDirty = !Equal.equals( settings.textGenerationModelSelection ?? null, @@ -460,6 +529,10 @@ export function useSettingsRestore(onRestored?: () => void) { const changedSettingLabels = useMemo( () => [ ...(theme !== "system" ? ["Theme"] : []), + ...(isFontFamilyDirty ? ["Font family"] : []), + ...(isUserMessageFontDirty ? ["User messages"] : []), + ...(isChatTypographyDirty ? ["Chat text"] : []), + ...(isCodeTypographyDirty ? ["Code text"] : []), ...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat ? ["Time format"] : []), @@ -483,7 +556,11 @@ export function useSettingsRestore(onRestored?: () => void) { ], [ areProviderSettingsDirty, + isChatTypographyDirty, + isCodeTypographyDirty, + isFontFamilyDirty, isGitWritingModelDirty, + isUserMessageFontDirty, settings.confirmThreadArchive, settings.confirmThreadDelete, settings.defaultThreadEnvMode, @@ -597,6 +674,12 @@ export function GeneralSettingsPanel() { settings.textGenerationModelSelection ?? null, DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null, ); + const { + isFontFamilyDirty, + isUserMessageFontDirty, + isChatTypographyDirty, + isCodeTypographyDirty, + } = getTypographyDirtyState(settings); const openInPreferredEditor = useCallback( (target: "keybindings" | "logsDirectory", path: string | null, failureMessage: string) => { @@ -812,6 +895,177 @@ export function GeneralSettingsPanel() { } /> +{props.label}+ ++ updateSettings({ + fontFamily: DEFAULT_UNIFIED_SETTINGS.fontFamily, + }) + } + /> + ) : null + } + control={ + + } + /> + + + updateSettings({ + userMessageFont: DEFAULT_UNIFIED_SETTINGS.userMessageFont, + }) + } + /> + ) : null + } + control={ + + } + /> + + + updateSettings({ + chatFontSize: DEFAULT_UNIFIED_SETTINGS.chatFontSize, + chatLineHeight: DEFAULT_UNIFIED_SETTINGS.chatLineHeight, + }) + } + /> + ) : null + } + control={ + ++ } + /> + +{ + if (value && isChatTypographyFontSize(value)) { + updateSettings({ chatFontSize: value }); + } + }} + /> + { + if (value && isTypographyLineHeight(value)) { + updateSettings({ chatLineHeight: value }); + } + }} + /> + + updateSettings({ + codeFontSize: DEFAULT_UNIFIED_SETTINGS.codeFontSize, + codeLineHeight: DEFAULT_UNIFIED_SETTINGS.codeLineHeight, + }) + } + /> + ) : null + } + control={ + ++ } + /> +{ + if (value && isCodeTypographyFontSize(value)) { + updateSettings({ codeFontSize: value }); + } + }} + /> + { + if (value && isTypographyLineHeight(value)) { + updateSettings({ codeLineHeight: value }); + } + }} + /> + { text: "hello", attachments: [{ id: "1" }], }), - ).toBe(234); + ).toBe(233); expect( estimateTimelineMessageHeight({ @@ -38,7 +39,7 @@ describe("estimateTimelineMessageHeight", () => { text: "hello", attachments: [{ id: "1" }, { id: "2" }], }), - ).toBe(234); + ).toBe(233); }); it("adds a second attachment row for three or four user attachments", () => { @@ -48,7 +49,7 @@ describe("estimateTimelineMessageHeight", () => { text: "hello", attachments: [{ id: "1" }, { id: "2" }, { id: "3" }], }), - ).toBe(350); + ).toBe(349); expect( estimateTimelineMessageHeight({ @@ -56,7 +57,7 @@ describe("estimateTimelineMessageHeight", () => { text: "hello", attachments: [{ id: "1" }, { id: "2" }, { id: "3" }, { id: "4" }], }), - ).toBe(350); + ).toBe(349); }); it("does not cap long user message estimates", () => { @@ -65,7 +66,7 @@ describe("estimateTimelineMessageHeight", () => { role: "user", text: "a".repeat(56 * 120), }), - ).toBe(2736); + ).toBe(2301); }); it("counts explicit newlines for user message estimates", () => { @@ -74,7 +75,7 @@ describe("estimateTimelineMessageHeight", () => { role: "user", text: "first\nsecond\nthird", }), - ).toBe(162); + ).toBe(159); }); it("adds terminal context chrome without counting the hidden block as message text", () => { @@ -112,8 +113,8 @@ describe("estimateTimelineMessageHeight", () => { text: "a".repeat(52), }; - expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(140); - expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 768 })).toBe(118); + expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(138); + expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 768 })).toBe(117); }); it("does not clamp user wrapping too aggressively on very narrow layouts", () => { @@ -122,8 +123,53 @@ describe("estimateTimelineMessageHeight", () => { text: "a".repeat(20), }; - expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 100 })).toBe(184); - expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(118); + expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 100 })).toBe(180); + expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(117); + }); + + it("lets user message typography change wrapping estimates", () => { + const message = { + role: "user" as const, + text: "a".repeat(53), + }; + + expect( + estimateTimelineMessageHeight(message, { + timelineWidthPx: 320, + typography: { + ...DEFAULT_TYPOGRAPHY_SETTINGS, + userMessageFont: "monospace", + }, + }), + ).toBe(159); + + expect( + estimateTimelineMessageHeight(message, { + timelineWidthPx: 320, + typography: { + ...DEFAULT_TYPOGRAPHY_SETTINGS, + userMessageFont: "sans", + }, + }), + ).toBe(141.5); + }); + + it("lets code font size change monospace user message estimates", () => { + const message = { + role: "user" as const, + text: "a".repeat(53), + }; + + expect( + estimateTimelineMessageHeight(message, { + timelineWidthPx: 320, + typography: { + ...DEFAULT_TYPOGRAPHY_SETTINGS, + userMessageFont: "monospace", + codeFontSize: "12px", + }, + }), + ).toBe(132); }); it("uses narrower width to increase assistant line wrapping", () => { diff --git a/apps/web/src/components/timelineHeight.ts b/apps/web/src/components/timelineHeight.ts index 776fe9ad88..73819be5dc 100644 --- a/apps/web/src/components/timelineHeight.ts +++ b/apps/web/src/components/timelineHeight.ts @@ -1,10 +1,18 @@ import { deriveDisplayedUserMessageState } from "../lib/terminalContext"; +import { + DEFAULT_TYPOGRAPHY_SETTINGS, + resolveChatAverageCharacterWidthPx, + resolveTypographyFontSizePx, + resolveTypographyLineHeightValue, + resolveUserMessageAverageCharacterWidthPx, + resolveUserMessageFontSizePx, + resolveUserMessageLineHeightValue, + type TypographySettings, +} from "../typography"; import { buildInlineTerminalContextText } from "./chat/userMessageTerminalContexts"; const ASSISTANT_CHARS_PER_LINE_FALLBACK = 72; -const USER_CHARS_PER_LINE_FALLBACK = 56; -const USER_LINE_HEIGHT_PX = 22; -const ASSISTANT_LINE_HEIGHT_PX = 22.75; +const USER_CHARS_PER_LINE_FALLBACK = 64; // Assistant rows render as markdown content plus a compact timestamp meta line. // The DOM baseline is much smaller than the user bubble chrome, so model it // separately instead of reusing the old shared constant. @@ -17,8 +25,6 @@ const USER_ATTACHMENT_ROW_HEIGHT_PX = 116; const USER_BUBBLE_WIDTH_RATIO = 0.8; const USER_BUBBLE_HORIZONTAL_PADDING_PX = 32; const ASSISTANT_MESSAGE_HORIZONTAL_PADDING_PX = 8; -const USER_MONO_AVG_CHAR_WIDTH_PX = 8.4; -const ASSISTANT_AVG_CHAR_WIDTH_PX = 7.2; const MIN_USER_CHARS_PER_LINE = 4; const MIN_ASSISTANT_CHARS_PER_LINE = 20; @@ -30,6 +36,7 @@ interface TimelineMessageHeightInput { interface TimelineHeightEstimateLayout { timelineWidthPx: number | null; + typography?: TypographySettings | undefined; } function estimateWrappedLineCount(text: string, charsPerLine: number): number { @@ -55,34 +62,52 @@ function isFinitePositiveNumber(value: number | null | undefined): value is numb return typeof value === "number" && Number.isFinite(value) && value > 0; } -function estimateCharsPerLineForUser(timelineWidthPx: number | null): number { +function estimateCharsPerLineForUser( + timelineWidthPx: number | null, + averageCharWidthPx: number, +): number { if (!isFinitePositiveNumber(timelineWidthPx)) return USER_CHARS_PER_LINE_FALLBACK; const bubbleWidthPx = timelineWidthPx * USER_BUBBLE_WIDTH_RATIO; const textWidthPx = Math.max(bubbleWidthPx - USER_BUBBLE_HORIZONTAL_PADDING_PX, 0); - return Math.max(MIN_USER_CHARS_PER_LINE, Math.floor(textWidthPx / USER_MONO_AVG_CHAR_WIDTH_PX)); + return Math.max(MIN_USER_CHARS_PER_LINE, Math.floor(textWidthPx / averageCharWidthPx)); } -function estimateCharsPerLineForAssistant(timelineWidthPx: number | null): number { +function estimateCharsPerLineForAssistant( + timelineWidthPx: number | null, + averageCharWidthPx: number, +): number { if (!isFinitePositiveNumber(timelineWidthPx)) return ASSISTANT_CHARS_PER_LINE_FALLBACK; const textWidthPx = Math.max(timelineWidthPx - ASSISTANT_MESSAGE_HORIZONTAL_PADDING_PX, 0); - return Math.max( - MIN_ASSISTANT_CHARS_PER_LINE, - Math.floor(textWidthPx / ASSISTANT_AVG_CHAR_WIDTH_PX), - ); + return Math.max(MIN_ASSISTANT_CHARS_PER_LINE, Math.floor(textWidthPx / averageCharWidthPx)); } export function estimateTimelineMessageHeight( message: TimelineMessageHeightInput, layout: TimelineHeightEstimateLayout = { timelineWidthPx: null }, ): number { + const typography = layout.typography ?? DEFAULT_TYPOGRAPHY_SETTINGS; + const assistantLineHeightPx = + resolveTypographyFontSizePx(typography.chatFontSize) * + resolveTypographyLineHeightValue(typography.chatLineHeight); + const userLineHeightPx = + resolveUserMessageFontSizePx(typography) * resolveUserMessageLineHeightValue(typography); + const averageCharWidthPx = resolveChatAverageCharacterWidthPx(typography); + if (message.role === "assistant") { - const charsPerLine = estimateCharsPerLineForAssistant(layout.timelineWidthPx); + const charsPerLine = estimateCharsPerLineForAssistant( + layout.timelineWidthPx, + averageCharWidthPx, + ); const estimatedLines = estimateWrappedLineCount(message.text, charsPerLine); - return ASSISTANT_BASE_HEIGHT_PX + estimatedLines * ASSISTANT_LINE_HEIGHT_PX; + return ASSISTANT_BASE_HEIGHT_PX + estimatedLines * assistantLineHeightPx; } if (message.role === "user") { - const charsPerLine = estimateCharsPerLineForUser(layout.timelineWidthPx); + const userAverageCharWidthPx = resolveUserMessageAverageCharacterWidthPx(typography); + const charsPerLine = estimateCharsPerLineForUser( + layout.timelineWidthPx, + userAverageCharWidthPx, + ); const displayedUserMessage = deriveDisplayedUserMessageState(message.text); const renderedText = displayedUserMessage.contexts.length > 0 @@ -97,12 +122,12 @@ export function estimateTimelineMessageHeight( const attachmentCount = message.attachments?.length ?? 0; const attachmentRows = Math.ceil(attachmentCount / ATTACHMENTS_PER_ROW); const attachmentHeight = attachmentRows * USER_ATTACHMENT_ROW_HEIGHT_PX; - return USER_BASE_HEIGHT_PX + estimatedLines * USER_LINE_HEIGHT_PX + attachmentHeight; + return USER_BASE_HEIGHT_PX + estimatedLines * userLineHeightPx + attachmentHeight; } // `system` messages are not rendered in the chat timeline, but keep a stable // explicit branch in case they are present in timeline data. - const charsPerLine = estimateCharsPerLineForAssistant(layout.timelineWidthPx); + const charsPerLine = estimateCharsPerLineForAssistant(layout.timelineWidthPx, averageCharWidthPx); const estimatedLines = estimateWrappedLineCount(message.text, charsPerLine); - return ASSISTANT_BASE_HEIGHT_PX + estimatedLines * ASSISTANT_LINE_HEIGHT_PX; + return ASSISTANT_BASE_HEIGHT_PX + estimatedLines * assistantLineHeightPx; } diff --git a/apps/web/src/hooks/useAppliedTypographySettings.ts b/apps/web/src/hooks/useAppliedTypographySettings.ts new file mode 100644 index 0000000000..bd8586e73f --- /dev/null +++ b/apps/web/src/hooks/useAppliedTypographySettings.ts @@ -0,0 +1,11 @@ +import { useEffect } from "react"; +import { applyTypographySettings } from "../typography"; +import { useTypographySettings } from "./useTypographySettings"; + +export function useAppliedTypographySettings() { + const typographySettings = useTypographySettings(); + + useEffect(() => { + applyTypographySettings(document.documentElement, typographySettings); + }, [typographySettings]); +} diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index f6b43f9a77..a212b582fe 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -33,9 +33,7 @@ import { Predicate, Schema, Struct } from "effect"; import { DeepMutable } from "effect/Types"; import { deepMerge } from "@t3tools/shared/Struct"; import { applySettingsUpdated, getServerConfig, useServerSettings } from "~/rpc/serverState"; - -const CLIENT_SETTINGS_STORAGE_KEY = "t3code:client-settings:v1"; -const OLD_SETTINGS_KEY = "t3code:app-settings:v1"; +import { CLIENT_SETTINGS_STORAGE_KEY, OLD_SETTINGS_KEY } from "../settingsStorage"; // ── Key sets for routing patches ───────────────────────────────────── @@ -67,9 +65,7 @@ function splitPatch(patch: Partial ): { * only re-render when the slice they care about changes. */ -export function useSettings ( - selector?: (s: UnifiedSettings) => T, -): T { +export function useSettings (selector?: (s: UnifiedSettings) => T): T { const serverSettings = useServerSettings(); const [clientSettings] = useLocalStorage( CLIENT_SETTINGS_STORAGE_KEY, diff --git a/apps/web/src/hooks/useTypographySettings.ts b/apps/web/src/hooks/useTypographySettings.ts new file mode 100644 index 0000000000..87ce6121d5 --- /dev/null +++ b/apps/web/src/hooks/useTypographySettings.ts @@ -0,0 +1,28 @@ +import { useMemo } from "react"; +import { pickTypographySettings, type TypographySettings } from "../typography"; +import { useSettings } from "./useSettings"; + +export function useTypographySettings(): TypographySettings { + const settings = useSettings(); + const { + fontFamily, + userMessageFont, + chatFontSize, + chatLineHeight, + codeFontSize, + codeLineHeight, + } = settings; + + return useMemo( + () => + pickTypographySettings({ + fontFamily, + userMessageFont, + chatFontSize, + chatLineHeight, + codeFontSize, + codeLineHeight, + }), + [fontFamily, userMessageFont, chatFontSize, chatLineHeight, codeFontSize, codeLineHeight], + ); +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index ea76f24fac..c6f3d959df 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -64,6 +64,19 @@ :root { color-scheme: light; --radius: 0.625rem; + --app-font-family-default: + "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + --app-code-font-family: + "SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + --app-ui-font-family: var(--app-font-family-default); + --app-chat-font-family: var(--app-font-family-default); + --app-user-message-font-family: var(--app-code-font-family); + --app-user-message-font-size: 14px; + --app-user-message-line-height: 1.5; + --app-chat-font-size: 14px; + --app-chat-line-height: 1.625; + --app-code-font-size: 14px; + --app-code-line-height: 1.5; --background: var(--color-white); --foreground: var(--color-neutral-800); --card: var(--color-white); @@ -121,13 +134,7 @@ } body { - font-family: - "DM Sans", - -apple-system, - BlinkMacSystemFont, - "Segoe UI", - system-ui, - sans-serif; + font-family: var(--app-ui-font-family); margin: 0; padding: 0; min-height: 100vh; @@ -157,10 +164,20 @@ body::after { } pre, -code, -textarea, -input { - font-family: "SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; +code { + font-family: var(--app-code-font-family); +} + +.app-chat-typography { + font-family: var(--app-chat-font-family); + font-size: var(--app-chat-font-size); + line-height: var(--app-chat-line-height); +} + +.app-user-message-typography { + font-family: var(--app-user-message-font-family); + font-size: var(--app-user-message-font-size); + line-height: var(--app-user-message-line-height); } /* Window drag region (frameless titlebar) */ @@ -315,7 +332,9 @@ label:has(> select#reasoning-effort) select { background: var(--muted); padding: 0.1rem 0.35rem; color: var(--foreground); - font-size: 0.75rem; + font-family: var(--app-code-font-family); + font-size: var(--app-code-font-size); + line-height: var(--app-code-line-height); } .chat-markdown pre { @@ -325,14 +344,18 @@ label:has(> select#reasoning-effort) select { border-radius: 0.75rem; background: var(--muted); padding: 0.8rem 0.9rem; + font-family: var(--app-code-font-family); + font-size: var(--app-code-font-size); + line-height: var(--app-code-line-height); } .chat-markdown pre code { border: none; background: transparent; padding: 0; - line-height: 1.5; - font-size: 0.875rem; + font-family: var(--app-code-font-family); + font-size: var(--app-code-font-size); + line-height: var(--app-code-line-height); } .chat-markdown .chat-markdown-codeblock { @@ -379,6 +402,9 @@ label:has(> select#reasoning-effort) select { .chat-markdown .chat-markdown-shiki .shiki { background: color-mix(in srgb, var(--muted) 78%, var(--background)) !important; + font-family: var(--app-code-font-family) !important; + font-size: var(--app-code-font-size) !important; + line-height: var(--app-code-line-height) !important; } .chat-markdown table { diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index fda5913c97..54c8fde81d 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -9,6 +9,7 @@ import "./index.css"; import { isElectron } from "./env"; import { getRouter } from "./router"; import { APP_DISPLAY_NAME } from "./branding"; +import { applyStoredTypographySettings } from "./typography"; // Electron loads the app from a file-backed shell, so hash history avoids path resolution issues. const history = isElectron ? createHashHistory() : createBrowserHistory(); @@ -16,6 +17,7 @@ const history = isElectron ? createHashHistory() : createBrowserHistory(); const router = getRouter(history); document.title = APP_DISPLAY_NAME; +applyStoredTypographySettings(); ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 8f3667d937..a1a2f07647 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -42,6 +42,7 @@ import { useStore } from "../store"; import { useUiStateStore } from "../uiStateStore"; import { useTerminalStateStore } from "../terminalStateStore"; import { migrateLocalSettingsToServer } from "../hooks/useSettings"; +import { useAppliedTypographySettings } from "../hooks/useAppliedTypographySettings"; import { providerQueryKeys } from "../lib/providerReactQuery"; import { projectQueryKeys } from "../lib/projectReactQuery"; import { collectActiveTerminalThreadIds } from "../lib/terminalStateCleanup"; @@ -61,6 +62,8 @@ export const Route = createRootRouteWithContext<{ }); function RootRouteView() { + useAppliedTypographySettings(); + if (!readNativeApi()) { return ( diff --git a/apps/web/src/settingsStorage.ts b/apps/web/src/settingsStorage.ts new file mode 100644 index 0000000000..f7b541f394 --- /dev/null +++ b/apps/web/src/settingsStorage.ts @@ -0,0 +1,2 @@ +export const CLIENT_SETTINGS_STORAGE_KEY = "t3code:client-settings:v1"; +export const OLD_SETTINGS_KEY = "t3code:app-settings:v1"; diff --git a/apps/web/src/typography.ts b/apps/web/src/typography.ts new file mode 100644 index 0000000000..abee593a10 --- /dev/null +++ b/apps/web/src/typography.ts @@ -0,0 +1,224 @@ +import { + ClientSettingsSchema, + DEFAULT_CLIENT_SETTINGS, + type ClientSettings, + type ChatTypographyFontSize, + type CodeTypographyFontSize, + type FontFamilySetting, + type UserMessageFontSetting, + type TypographyLineHeight, +} from "@t3tools/contracts/settings"; +import { getLocalStorageItem } from "./hooks/useLocalStorage"; +import { CLIENT_SETTINGS_STORAGE_KEY } from "./settingsStorage"; + +export type TypographySettings = Pick< + ClientSettings, + | "fontFamily" + | "userMessageFont" + | "chatFontSize" + | "chatLineHeight" + | "codeFontSize" + | "codeLineHeight" +>; + +export const DEFAULT_TYPOGRAPHY_SETTINGS: TypographySettings = { + ...pickTypographySettings(DEFAULT_CLIENT_SETTINGS), +}; + +export const FONT_FAMILY_OPTIONS = [ + { value: "default", label: "DM Sans" }, + { value: "system", label: "System UI" }, +] as const satisfies ReadonlyArray<{ + value: FontFamilySetting; + label: string; +}>; + +export const CHAT_TYPOGRAPHY_FONT_SIZE_OPTIONS = [ + { value: "13px", label: "13 px" }, + { value: "14px", label: "14 px" }, + { value: "15px", label: "15 px" }, + { value: "16px", label: "16 px" }, + { value: "17px", label: "17 px" }, + { value: "18px", label: "18 px" }, +] as const satisfies ReadonlyArray<{ + value: ChatTypographyFontSize; + label: string; +}>; + +export const CODE_TYPOGRAPHY_FONT_SIZE_OPTIONS = [ + { value: "12px", label: "12 px" }, + { value: "13px", label: "13 px" }, + { value: "14px", label: "14 px" }, + { value: "15px", label: "15 px" }, + { value: "16px", label: "16 px" }, + { value: "17px", label: "17 px" }, + { value: "18px", label: "18 px" }, +] as const satisfies ReadonlyArray<{ + value: CodeTypographyFontSize; + label: string; +}>; + +export const TYPOGRAPHY_LINE_HEIGHT_OPTIONS = [ + { value: "1.4", label: "1.4" }, + { value: "1.5", label: "1.5" }, + { value: "1.625", label: "1.625" }, + { value: "1.75", label: "1.75" }, + { value: "1.875", label: "1.875" }, +] as const satisfies ReadonlyArray<{ + value: TypographyLineHeight; + label: string; +}>; + +export const USER_MESSAGE_FONT_OPTIONS = [ + { value: "monospace", label: "Monospace" }, + { value: "sans", label: "Sans" }, +] as const satisfies ReadonlyArray<{ + value: UserMessageFontSetting; + label: string; +}>; + +const FONT_FAMILY_STACKS: Record= { + default: '"DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif', + system: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif', +}; + +const DEFAULT_CHAT_CHAR_WIDTH_FACTOR = 7.2 / 14; +const SYSTEM_CHAT_CHAR_WIDTH_FACTOR = 7.6 / 14; +const USER_MESSAGE_MONO_CHAR_WIDTH_FACTOR = 8.4 / 14; + +const FONT_FAMILY_VALUES = new Set ( + FONT_FAMILY_OPTIONS.map((option) => option.value), +); +const USER_MESSAGE_FONT_VALUES = new Set ( + USER_MESSAGE_FONT_OPTIONS.map((option) => option.value), +); +const CHAT_FONT_SIZE_VALUES = new Set ( + CHAT_TYPOGRAPHY_FONT_SIZE_OPTIONS.map((option) => option.value), +); +const CODE_FONT_SIZE_VALUES = new Set ( + CODE_TYPOGRAPHY_FONT_SIZE_OPTIONS.map((option) => option.value), +); +const LINE_HEIGHT_VALUES = new Set ( + TYPOGRAPHY_LINE_HEIGHT_OPTIONS.map((option) => option.value), +); + +export function isFontFamilySetting(value: string): value is FontFamilySetting { + return FONT_FAMILY_VALUES.has(value as FontFamilySetting); +} + +export function isUserMessageFontSetting(value: string): value is UserMessageFontSetting { + return USER_MESSAGE_FONT_VALUES.has(value as UserMessageFontSetting); +} + +export function isChatTypographyFontSize(value: string): value is ChatTypographyFontSize { + return CHAT_FONT_SIZE_VALUES.has(value as ChatTypographyFontSize); +} + +export function isCodeTypographyFontSize(value: string): value is CodeTypographyFontSize { + return CODE_FONT_SIZE_VALUES.has(value as CodeTypographyFontSize); +} + +export function isTypographyLineHeight(value: string): value is TypographyLineHeight { + return LINE_HEIGHT_VALUES.has(value as TypographyLineHeight); +} + +export function pickTypographySettings(settings: TypographySettings): TypographySettings { + return { + fontFamily: settings.fontFamily, + userMessageFont: settings.userMessageFont, + chatFontSize: settings.chatFontSize, + chatLineHeight: settings.chatLineHeight, + codeFontSize: settings.codeFontSize, + codeLineHeight: settings.codeLineHeight, + }; +} + +export function buildTypographyCssVariables( + settings: TypographySettings, +): Readonly > { + const userMessageUsesCodeTypography = settings.userMessageFont === "monospace"; + + return { + "--app-ui-font-family": FONT_FAMILY_STACKS[settings.fontFamily], + "--app-chat-font-family": FONT_FAMILY_STACKS[settings.fontFamily], + "--app-user-message-font-family": userMessageUsesCodeTypography + ? "var(--app-code-font-family)" + : FONT_FAMILY_STACKS[settings.fontFamily], + "--app-user-message-font-size": userMessageUsesCodeTypography + ? settings.codeFontSize + : settings.chatFontSize, + "--app-user-message-line-height": userMessageUsesCodeTypography + ? settings.codeLineHeight + : settings.chatLineHeight, + "--app-chat-font-size": settings.chatFontSize, + "--app-chat-line-height": settings.chatLineHeight, + "--app-code-font-size": settings.codeFontSize, + "--app-code-line-height": settings.codeLineHeight, + }; +} + +export function resolveTypographyFontSizePx( + fontSize: ChatTypographyFontSize | CodeTypographyFontSize, +): number { + return Number.parseFloat(fontSize); +} + +export function resolveTypographyLineHeightValue(lineHeight: TypographyLineHeight): number { + return Number.parseFloat(lineHeight); +} + +export function resolveChatAverageCharacterWidthPx(settings: TypographySettings): number { + const factor = + settings.fontFamily === "system" + ? SYSTEM_CHAT_CHAR_WIDTH_FACTOR + : DEFAULT_CHAT_CHAR_WIDTH_FACTOR; + return resolveTypographyFontSizePx(settings.chatFontSize) * factor; +} + +export function resolveUserMessageFontSizePx(settings: TypographySettings): number { + return settings.userMessageFont === "monospace" + ? resolveTypographyFontSizePx(settings.codeFontSize) + : resolveTypographyFontSizePx(settings.chatFontSize); +} + +export function resolveUserMessageLineHeightValue(settings: TypographySettings): number { + return resolveTypographyLineHeightValue( + settings.userMessageFont === "monospace" ? settings.codeLineHeight : settings.chatLineHeight, + ); +} + +export function resolveUserMessageAverageCharacterWidthPx(settings: TypographySettings): number { + if (settings.userMessageFont === "monospace") { + return resolveUserMessageFontSizePx(settings) * USER_MESSAGE_MONO_CHAR_WIDTH_FACTOR; + } + + return resolveChatAverageCharacterWidthPx(settings); +} + +export function applyTypographySettings( + target: HTMLElement | null | undefined, + settings: TypographySettings, +): void { + if (!target) { + return; + } + + for (const [name, value] of Object.entries(buildTypographyCssVariables(settings))) { + target.style.setProperty(name, value); + } +} + +export function applyStoredTypographySettings(): void { + if (typeof document === "undefined") { + return; + } + + try { + const storedSettings = + getLocalStorageItem(CLIENT_SETTINGS_STORAGE_KEY, ClientSettingsSchema) ?? + DEFAULT_CLIENT_SETTINGS; + applyTypographySettings(document.documentElement, pickTypographySettings(storedSettings)); + } catch { + applyTypographySettings(document.documentElement, DEFAULT_TYPOGRAPHY_SETTINGS); + } +} diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 6633ce42a6..dda1c1d6f1 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -15,6 +15,42 @@ export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]) export type TimestampFormat = typeof TimestampFormat.Type; export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; +export const FontFamilySetting = Schema.Literals(["default", "system"]); +export type FontFamilySetting = typeof FontFamilySetting.Type; +export const DEFAULT_FONT_FAMILY_SETTING: FontFamilySetting = "default"; + +export const UserMessageFontSetting = Schema.Literals(["monospace", "sans"]); +export type UserMessageFontSetting = typeof UserMessageFontSetting.Type; +export const DEFAULT_USER_MESSAGE_FONT_SETTING: UserMessageFontSetting = "monospace"; + +export const ChatTypographyFontSize = Schema.Literals([ + "13px", + "14px", + "15px", + "16px", + "17px", + "18px", +]); +export type ChatTypographyFontSize = typeof ChatTypographyFontSize.Type; +export const DEFAULT_CHAT_FONT_SIZE: ChatTypographyFontSize = "14px"; + +export const CodeTypographyFontSize = Schema.Literals([ + "12px", + "13px", + "14px", + "15px", + "16px", + "17px", + "18px", +]); +export type CodeTypographyFontSize = typeof CodeTypographyFontSize.Type; +export const DEFAULT_CODE_FONT_SIZE: CodeTypographyFontSize = "14px"; + +export const TypographyLineHeight = Schema.Literals(["1.4", "1.5", "1.625", "1.75", "1.875"]); +export type TypographyLineHeight = typeof TypographyLineHeight.Type; +export const DEFAULT_CHAT_LINE_HEIGHT: TypographyLineHeight = "1.625"; +export const DEFAULT_CODE_LINE_HEIGHT: TypographyLineHeight = "1.5"; + export const SidebarProjectSortOrder = Schema.Literals(["updated_at", "created_at", "manual"]); export type SidebarProjectSortOrder = typeof SidebarProjectSortOrder.Type; export const DEFAULT_SIDEBAR_PROJECT_SORT_ORDER: SidebarProjectSortOrder = "updated_at"; @@ -27,6 +63,22 @@ export const ClientSettingsSchema = Schema.Struct({ confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(() => true)), diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), + fontFamily: FontFamilySetting.pipe(Schema.withDecodingDefault(() => DEFAULT_FONT_FAMILY_SETTING)), + userMessageFont: UserMessageFontSetting.pipe( + Schema.withDecodingDefault(() => DEFAULT_USER_MESSAGE_FONT_SETTING), + ), + chatFontSize: ChatTypographyFontSize.pipe( + Schema.withDecodingDefault(() => DEFAULT_CHAT_FONT_SIZE), + ), + chatLineHeight: TypographyLineHeight.pipe( + Schema.withDecodingDefault(() => DEFAULT_CHAT_LINE_HEIGHT), + ), + codeFontSize: CodeTypographyFontSize.pipe( + Schema.withDecodingDefault(() => DEFAULT_CODE_FONT_SIZE), + ), + codeLineHeight: TypographyLineHeight.pipe( + Schema.withDecodingDefault(() => DEFAULT_CODE_LINE_HEIGHT), + ), sidebarProjectSortOrder: SidebarProjectSortOrder.pipe( Schema.withDecodingDefault(() => DEFAULT_SIDEBAR_PROJECT_SORT_ORDER), ),