From 670c6c9eac56ae64cc2e5135306f84d52846591b Mon Sep 17 00:00:00 2001 From: Mohamad Salah <98090972+Mo999salah@users.noreply.github.com> Date: Fri, 17 Apr 2026 22:44:34 +0300 Subject: [PATCH] feat(web): add configurable RTL chat fonts Add a client setting for chat font selection and apply it across chat markdown, user messages, and the composer. Keep Arabic content readable in RTL while preserving code blocks and default app typography. Also fix the settings hook typing so components can subscribe to setting slices cleanly. --- apps/web/package.json | 1 + apps/web/src/components/ChatMarkdown.tsx | 18 +++++- .../src/components/ComposerPromptEditor.tsx | 26 +++++++- .../src/components/chat/MessagesTimeline.tsx | 26 +++++++- .../settings/SettingsAppearancePanel.tsx | 50 ++++++++++++++- .../settings/SettingsGeneralPanel.tsx | 2 + apps/web/src/hooks/useSettings.ts | 4 +- apps/web/src/index.css | 61 +++++++++++++++---- apps/web/src/lib/chatTypography.ts | 35 +++++++++++ apps/web/src/lib/textDirection.test.ts | 23 +++++++ apps/web/src/lib/textDirection.ts | 21 +++++++ apps/web/src/main.tsx | 3 + bun.lock | 3 + packages/contracts/src/settings.ts | 5 ++ 14 files changed, 257 insertions(+), 21 deletions(-) create mode 100644 apps/web/src/lib/chatTypography.ts create mode 100644 apps/web/src/lib/textDirection.test.ts create mode 100644 apps/web/src/lib/textDirection.ts diff --git a/apps/web/package.json b/apps/web/package.json index e871ae81..17e4b324 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -21,6 +21,7 @@ "@dnd-kit/utilities": "^3.2.2", "@effect/atom-react": "catalog:", "@fontsource/dm-sans": "^5.2.8", + "@fontsource/noto-sans-arabic": "^5.2.10", "@formkit/auto-animate": "^0.9.0", "@lexical/react": "^0.41.0", "@pierre/diffs": "^1.1.0-beta.16", diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 525a4210..6437d063 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -20,10 +20,14 @@ import { openInPreferredEditor } from "../editorPreferences"; import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; import { fnv1a32 } from "../lib/diffRendering"; import { LRUCache } from "../lib/lruCache"; +import { useSettings } from "../hooks/useSettings"; import { useTheme } from "../hooks/useTheme"; import { resolveMarkdownFileLinkTarget, rewriteMarkdownFileUriHref } from "../markdown-links"; import { readNativeApi } from "../nativeApi"; import type { DiffsHighlighter, SupportedLanguages } from "@pierre/diffs"; +import { resolveChatTypographyClassName } from "~/lib/chatTypography"; +import { cn } from "~/lib/utils"; +import { resolveTextDirection } from "~/lib/textDirection"; class CodeHighlightErrorBoundary extends React.Component< { fallback: ReactNode; children: ReactNode }, @@ -255,7 +259,9 @@ function SuspenseShikiCodeBlock({ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { const { resolvedTheme } = useTheme(); + const chatFontFamily = useSettings((settings) => settings.chatFontFamily); const diffThemeName = resolveDiffThemeName(resolvedTheme); + const textDirection = useMemo(() => resolveTextDirection(text), [text]); const markdownUrlTransform = useCallback((href: string) => { return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href); }, []); @@ -310,7 +316,17 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { ); return ( -
+
settings.chatFontFamily); + const composerDirection = resolveTextDirection(value); + const composerTypographyClassName = useMemo( + () => + resolveChatTypographyClassName({ + direction: composerDirection, + fontFamily: chatFontFamily, + variant: "composer", + }), + [chatFontFamily, composerDirection], + ); const terminalContextsSignature = terminalContextSignature(terminalContexts); const terminalContextsSignatureRef = useRef(terminalContextsSignature); const snapshotRef = useRef({ @@ -1092,8 +1106,11 @@ function ComposerPromptEditorInner({ 0 ? null : ( -
+
{placeholder}
) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 35ffd234..aa2fbfdf 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -54,6 +54,7 @@ import { type ParsedTerminalContextEntry, } from "~/lib/terminalContext"; import { cn } from "~/lib/utils"; +import { useSettings } from "../../hooks/useSettings"; import { type TimestampFormat } from "@t3tools/contracts/settings"; import { formatTimestamp } from "../../timestampFormat"; import { parseReviewReport, type ReviewFinding } from "../../review"; @@ -63,6 +64,8 @@ import { formatInlineTerminalContextLabel, textContainsInlineTerminalContextLabels, } from "./userMessageTerminalContexts"; +import { resolveChatTypographyClassName } from "~/lib/chatTypography"; +import { resolveTextDirection } from "~/lib/textDirection"; const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8; @@ -708,6 +711,17 @@ const UserMessageBody = memo(function UserMessageBody(props: { text: string; terminalContexts: ParsedTerminalContextEntry[]; }) { + const chatFontFamily = useSettings((settings) => settings.chatFontFamily); + const textDirection = resolveTextDirection(props.text); + const bodyClassName = cn( + "chat-message-inline-body whitespace-pre-wrap text-sm leading-relaxed text-foreground", + textDirection !== "rtl" && "font-mono", + resolveChatTypographyClassName({ + direction: textDirection, + fontFamily: chatFontFamily, + }), + ); + if (props.terminalContexts.length > 0) { const hasEmbeddedInlineLabels = textContainsInlineTerminalContextLabels( props.text, @@ -752,7 +766,11 @@ const UserMessageBody = memo(function UserMessageBody(props: { } return ( -
+
{inlineNodes}
); @@ -780,7 +798,11 @@ const UserMessageBody = memo(function UserMessageBody(props: { } return ( -
+
{inlineNodes}
); diff --git a/apps/web/src/components/settings/SettingsAppearancePanel.tsx b/apps/web/src/components/settings/SettingsAppearancePanel.tsx index 55f01dc0..9b0d2d24 100644 --- a/apps/web/src/components/settings/SettingsAppearancePanel.tsx +++ b/apps/web/src/components/settings/SettingsAppearancePanel.tsx @@ -1,4 +1,4 @@ -import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; +import { DEFAULT_UNIFIED_SETTINGS, type ChatFontFamily } from "@t3tools/contracts/settings"; import { useTheme } from "../../hooks/useTheme"; import { useSettings, useUpdateSettings } from "../../hooks/useSettings"; @@ -23,6 +23,12 @@ const TIMESTAMP_FORMAT_LABELS = { "24-hour": "24-hour", } as const; +const CHAT_FONT_LABELS = { + auto: "Auto", + "dm-sans": "DM Sans", + "noto-sans-arabic": "Noto Sans Arabic", +} as const satisfies Record; + export function SettingsAppearancePanel() { const { theme, setTheme } = useTheme(); const settings = useSettings(); @@ -64,6 +70,48 @@ export function SettingsAppearancePanel() { } /> + + updateSettings({ + chatFontFamily: DEFAULT_UNIFIED_SETTINGS.chatFontFamily, + }) + } + /> + ) : null + } + control={ + + } + /> + void) { const changedSettingLabels = useMemo( () => [ ...(theme !== "system" ? ["Theme"] : []), + ...(settings.chatFontFamily !== DEFAULT_UNIFIED_SETTINGS.chatFontFamily ? ["Chat font"] : []), ...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat ? ["Time format"] : []), @@ -87,6 +88,7 @@ export function useSettingsRestore(onRestored?: () => void) { isGitWritingModelDirty, isPromptEnhanceModelDirty, settings.askModelSelection, + settings.chatFontFamily, settings.codeModelSelection, settings.commitMessageStyle, settings.confirmThreadArchive, diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index dbcd3118..120731cb 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -63,9 +63,7 @@ function splitPatch(patch: SettingsUpdatePatch): { * 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/index.css b/apps/web/src/index.css index 9c664b5b..159d8973 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -66,6 +66,12 @@ --desktop-window-safe-inset: 0px; --desktop-window-corner-radius: 0px; --radius: 0.625rem; + --font-sans-ui: + "DM Sans", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, + sans-serif; + --font-sans-chat-rtl: + "Noto Sans Arabic", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, + sans-serif; --background: var(--color-white); --foreground: var(--color-neutral-800); --card: var(--color-white); @@ -123,13 +129,7 @@ } body { - font-family: - "DM Sans", - -apple-system, - BlinkMacSystemFont, - "Segoe UI", - system-ui, - sans-serif; + font-family: var(--font-sans-ui); margin: 0; padding: 0; min-height: 100vh; @@ -252,10 +252,43 @@ label:has(> select#reasoning-effort) select { } /* Chat markdown rendering */ +.chat-direction-rtl, +.chat-composer-direction-rtl { + direction: rtl; + text-align: start; + font-family: var(--font-sans-chat-rtl); +} + +.chat-direction-ltr, +.chat-composer-direction-ltr { + direction: ltr; + text-align: start; +} + +.chat-direction-auto, +.chat-composer-direction-auto { + text-align: start; +} + +.chat-font-dm-sans { + font-family: var(--font-sans-ui); +} + +.chat-font-noto-sans-arabic { + font-family: var(--font-sans-chat-rtl); +} + +.chat-message-inline-body { + min-width: 0; + overflow-wrap: anywhere; + word-break: break-word; +} + .chat-markdown { min-width: 0; overflow-wrap: anywhere; word-break: break-word; + text-align: start; } .chat-markdown > :first-child { @@ -276,12 +309,12 @@ label:has(> select#reasoning-effort) select { } .chat-markdown ul { - padding-left: 1.25rem; + padding-inline-start: 1.25rem; list-style-type: disc; } .chat-markdown ol { - padding-left: 1.25rem; + padding-inline-start: 1.25rem; list-style-type: decimal; } @@ -316,8 +349,8 @@ label:has(> select#reasoning-effort) select { } .chat-markdown blockquote { - border-left: 2px solid var(--border); - padding-left: 0.8rem; + border-inline-start: 2px solid var(--border); + padding-inline-start: 0.8rem; color: var(--muted-foreground); } @@ -328,6 +361,7 @@ label:has(> select#reasoning-effort) select { padding: 0.1rem 0.35rem; color: var(--foreground); font-size: 0.75rem; + direction: ltr; } .chat-markdown pre { @@ -350,6 +384,7 @@ label:has(> select#reasoning-effort) select { .chat-markdown .chat-markdown-codeblock { position: relative; margin: 0.65rem 0; + direction: ltr; } .chat-markdown .chat-markdown-codeblock pre { @@ -359,7 +394,7 @@ label:has(> select#reasoning-effort) select { .chat-markdown .chat-markdown-copy-button { position: absolute; top: 0.5rem; - right: 0.5rem; + inset-inline-end: 0.5rem; z-index: 1; display: inline-flex; align-items: center; @@ -402,7 +437,7 @@ label:has(> select#reasoning-effort) select { .chat-markdown td { border: 1px solid var(--border); padding: 0.35rem 0.45rem; - text-align: left; + text-align: start; } /* Diffs theme bridge (match diff surfaces to app palette) */ diff --git a/apps/web/src/lib/chatTypography.ts b/apps/web/src/lib/chatTypography.ts new file mode 100644 index 00000000..bb21ecb4 --- /dev/null +++ b/apps/web/src/lib/chatTypography.ts @@ -0,0 +1,35 @@ +import type { ChatFontFamily } from "@t3tools/contracts/settings"; +import type { TextDirection } from "./textDirection"; +import { cn } from "./utils"; + +type ChatTypographyVariant = "chat" | "composer"; + +const CHAT_DIRECTION_CLASS_BY_VARIANT = { + chat: { + rtl: "chat-direction-rtl", + ltr: "chat-direction-ltr", + auto: "chat-direction-auto", + }, + composer: { + rtl: "chat-composer-direction-rtl", + ltr: "chat-composer-direction-ltr", + auto: "chat-composer-direction-auto", + }, +} as const satisfies Record>; + +const CHAT_FONT_CLASS_BY_FAMILY = { + "dm-sans": "chat-font-dm-sans", + "noto-sans-arabic": "chat-font-noto-sans-arabic", +} as const satisfies Record, string>; + +export function resolveChatTypographyClassName(options: { + direction: TextDirection; + fontFamily: ChatFontFamily; + variant?: ChatTypographyVariant; +}) { + const variant = options.variant ?? "chat"; + return cn( + CHAT_DIRECTION_CLASS_BY_VARIANT[variant][options.direction], + options.fontFamily !== "auto" && CHAT_FONT_CLASS_BY_FAMILY[options.fontFamily], + ); +} diff --git a/apps/web/src/lib/textDirection.test.ts b/apps/web/src/lib/textDirection.test.ts new file mode 100644 index 00000000..c8510aaf --- /dev/null +++ b/apps/web/src/lib/textDirection.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { isRtlText, resolveTextDirection } from "./textDirection"; + +describe("resolveTextDirection", () => { + it("detects Arabic text as rtl", () => { + expect(resolveTextDirection("مرحبا بك في كودو")).toBe("rtl"); + expect(isRtlText("تحليل المشروع")).toBe(true); + }); + + it("treats Latin text as ltr", () => { + expect(resolveTextDirection("Review the latest diff")).toBe("ltr"); + expect(isRtlText("Run tests")).toBe(false); + }); + + it("ignores leading punctuation before the first strong rtl character", () => { + expect(resolveTextDirection('... "مرحبا"')).toBe("rtl"); + }); + + it("falls back to auto when no strong directional characters exist", () => { + expect(resolveTextDirection("1234 [] ()")).toBe("auto"); + }); +}); diff --git a/apps/web/src/lib/textDirection.ts b/apps/web/src/lib/textDirection.ts new file mode 100644 index 00000000..690e53c7 --- /dev/null +++ b/apps/web/src/lib/textDirection.ts @@ -0,0 +1,21 @@ +export type TextDirection = "ltr" | "rtl" | "auto"; + +const RTL_CHAR_REGEX = /[\u0591-\u08FF\uFB1D-\uFDFD\uFE70-\uFEFC]/; +const LTR_CHAR_REGEX = /[A-Za-z\u00C0-\u024F\u0370-\u03FF\u0400-\u052F]/; + +export function resolveTextDirection(text: string): TextDirection { + for (const character of text) { + if (RTL_CHAR_REGEX.test(character)) { + return "rtl"; + } + if (LTR_CHAR_REGEX.test(character)) { + return "ltr"; + } + } + + return "auto"; +} + +export function isRtlText(text: string): boolean { + return resolveTextDirection(text) === "rtl"; +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 06037b9a..fc58d31f 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -6,6 +6,9 @@ import { createHashHistory, createBrowserHistory } from "@tanstack/react-router" import "@fontsource/dm-sans/400.css"; import "@fontsource/dm-sans/500.css"; import "@fontsource/dm-sans/700.css"; +import "@fontsource/noto-sans-arabic/400.css"; +import "@fontsource/noto-sans-arabic/500.css"; +import "@fontsource/noto-sans-arabic/700.css"; import "@xterm/xterm/css/xterm.css"; import "./index.css"; diff --git a/bun.lock b/bun.lock index a65e3499..e70568ae 100644 --- a/bun.lock +++ b/bun.lock @@ -70,6 +70,7 @@ "@dnd-kit/utilities": "^3.2.2", "@effect/atom-react": "catalog:", "@fontsource/dm-sans": "^5.2.8", + "@fontsource/noto-sans-arabic": "^5.2.10", "@formkit/auto-animate": "^0.9.0", "@lexical/react": "^0.41.0", "@pierre/diffs": "^1.1.0-beta.16", @@ -335,6 +336,8 @@ "@fontsource/dm-sans": ["@fontsource/dm-sans@5.2.8", "", {}, "sha512-tlovG42m9ESG28WiHpLq3F5umAlm64rv0RkqTbYowRn70e9OlRr5a3yTJhrhrY+k5lftR/OFJjPzOLQzk8EfCA=="], + "@fontsource/noto-sans-arabic": ["@fontsource/noto-sans-arabic@5.2.10", "", {}, "sha512-Hgj3NwQJ0NquIADW3hFboFjsts4UvEOJQ8tOX0bhQZgpNKHsMawjv/1SZnq0QvRbP6efHTZM/A1k7tdy9S6sbA=="], + "@formkit/auto-animate": ["@formkit/auto-animate@0.9.0", "", {}, "sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA=="], "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 90be6c79..4c1f7318 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -29,7 +29,12 @@ export const SidebarThreadSortOrder = Schema.Literals(["updated_at", "created_at export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type; export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at"; +export const ChatFontFamily = Schema.Literals(["auto", "dm-sans", "noto-sans-arabic"]); +export type ChatFontFamily = typeof ChatFontFamily.Type; +export const DEFAULT_CHAT_FONT_FAMILY: ChatFontFamily = "auto"; + export const ClientSettingsSchema = Schema.Struct({ + chatFontFamily: ChatFontFamily.pipe(Schema.withDecodingDefault(() => DEFAULT_CHAT_FONT_FAMILY)), confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(() => true)), diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)),