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)),