);
@@ -780,7 +813,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {
}
return (
-
+
{inlineNodes}
);
diff --git a/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx b/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx
index b021cecd..e95d35b3 100644
--- a/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx
+++ b/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx
@@ -1,6 +1,7 @@
import "../../index.css";
import { MessageId, type TurnId } from "@t3tools/contracts";
+import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";
import { page } from "vitest/browser";
import { useCallback, useState, type ComponentProps } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -19,6 +20,7 @@ const DEFAULT_VIEWPORT = {
height: 1_100,
};
const MARKDOWN_CWD = "/repo/project";
+const CLIENT_SETTINGS_STORAGE_KEY = "t3code:client-settings:v1";
interface RowMeasurement {
actualHeightPx: number;
@@ -36,6 +38,7 @@ interface VirtualizationScenario {
}
interface VirtualizerSnapshot {
+ measurementScopeKey: string;
totalSize: number;
measurements: ReadonlyArray<{
id: string;
@@ -407,6 +410,16 @@ function buildStaticScenarios(): VirtualizationScenario[] {
].join(" "),
maxEstimateDeltaPx: 28,
}),
+ createAssistantMessageScenario({
+ name: "assistant mixed-direction row with inline code",
+ rowId: "target-assistant-mixed-direction",
+ text: [
+ "Validate the bidi layout for `src/main.tsx` before shipping this branch.",
+ "مرحبا من واجهة الدردشة مع نص عربي وانجليزي مختلط.",
+ "שלום שוב כדי نتפוס עטיפה מימין לשמאל.",
+ ].join(" "),
+ maxEstimateDeltaPx: 40,
+ }),
createChangedFilesScenario({
name: "assistant changed-files row with a compacted single-chain directory",
rowId: "target-assistant-changed-files-single-chain",
@@ -486,6 +499,33 @@ async function waitForDocumentFonts(): Promise
{
await waitForLayout();
}
+async function setClientSettings(patch: Partial): Promise {
+ localStorage.setItem(
+ CLIENT_SETTINGS_STORAGE_KEY,
+ JSON.stringify({
+ ...DEFAULT_CLIENT_SETTINGS,
+ ...patch,
+ }),
+ );
+ window.dispatchEvent(
+ new CustomEvent("t3code:local_storage_change", {
+ detail: { key: CLIENT_SETTINGS_STORAGE_KEY },
+ }),
+ );
+ await waitForDocumentFonts();
+ await waitForLayout();
+}
+
+function requireVirtualizerSnapshot(
+ snapshot: VirtualizerSnapshot | null,
+ message: string,
+): VirtualizerSnapshot {
+ if (!snapshot) {
+ throw new Error(message);
+ }
+ return snapshot;
+}
+
async function waitForElement(
query: () => T | null,
errorMessage: string,
@@ -641,11 +681,13 @@ async function measureRenderedRowActualHeight(input: {
describe("MessagesTimeline virtualization harness", () => {
beforeEach(async () => {
document.body.innerHTML = "";
+ localStorage.clear();
await setViewport(DEFAULT_VIEWPORT);
});
afterEach(() => {
document.body.innerHTML = "";
+ localStorage.clear();
});
it.each(buildStaticScenarios())("keeps the $name estimate within tolerance", async (scenario) => {
@@ -902,6 +944,7 @@ describe("MessagesTimeline virtualization harness", () => {
]),
onVirtualizerSnapshot: (snapshot) => {
latestSnapshot = {
+ measurementScopeKey: snapshot.measurementScopeKey,
totalSize: snapshot.totalSize,
measurements: snapshot.measurements,
};
@@ -996,6 +1039,7 @@ describe("MessagesTimeline virtualization harness", () => {
messages: [...beforeMessages, targetMessage, ...afterMessages],
onVirtualizerSnapshot: (snapshot) => {
latestSnapshot = {
+ measurementScopeKey: snapshot.measurementScopeKey,
totalSize: snapshot.totalSize,
measurements: snapshot.measurements,
};
@@ -1063,4 +1107,117 @@ describe("MessagesTimeline virtualization harness", () => {
await mounted.cleanup();
}
});
+
+ it("re-measures virtualized rows after chat text size changes", async () => {
+ const scenario = createAssistantMessageScenario({
+ name: "assistant text-size remeasure",
+ rowId: "target-assistant-text-size-remeasure",
+ text: [
+ "This message should wrap differently once the shared chat text size changes.",
+ "It needs enough content to create a visible virtualizer delta after the setting update.",
+ "We want the live timeline cache to invalidate instead of holding on to stale heights.",
+ ].join(" "),
+ maxEstimateDeltaPx: 40,
+ });
+
+ const mounted = await mountMessagesTimeline({
+ props: scenario.props,
+ viewport: { width: 420, height: 760 },
+ });
+
+ try {
+ const beforeChange = await measureTimelineRow({
+ host: mounted.host,
+ props: scenario.props,
+ targetRowId: scenario.targetRowId,
+ });
+
+ await setClientSettings({ chatTextSize: "large" });
+
+ await vi.waitFor(
+ async () => {
+ const afterChange = await measureTimelineRow({
+ host: mounted.host,
+ props: scenario.props,
+ targetRowId: scenario.targetRowId,
+ });
+ expect(afterChange.actualHeightPx).toBeGreaterThan(beforeChange.actualHeightPx + 8);
+ expect(
+ Math.abs(afterChange.actualHeightPx - afterChange.virtualizerSizePx),
+ ).toBeLessThanOrEqual(8);
+ },
+ { timeout: 8_000, interval: 16 },
+ );
+ } finally {
+ await mounted.cleanup();
+ }
+ });
+
+ it("re-measures virtualized rows after chat font family changes", async () => {
+ await setClientSettings({ chatFontFamily: "dm-sans" });
+
+ const scenario = createAssistantMessageScenario({
+ name: "assistant font-family remeasure",
+ rowId: "target-assistant-font-family-remeasure",
+ text: [
+ "Typography changes should invalidate the virtualizer cache for already measured rows.",
+ "This paragraph is narrow on purpose so a font-family switch can move line breaks enough to matter.",
+ "The browser view should settle on the new measured height instead of keeping the old cached size.",
+ ].join(" "),
+ maxEstimateDeltaPx: 40,
+ });
+
+ let latestSnapshot: VirtualizerSnapshot | null = null;
+
+ const mounted = await mountMessagesTimeline({
+ props: {
+ ...scenario.props,
+ onVirtualizerSnapshot: (snapshot) => {
+ latestSnapshot = {
+ measurementScopeKey: snapshot.measurementScopeKey,
+ totalSize: snapshot.totalSize,
+ measurements: snapshot.measurements,
+ };
+ },
+ },
+ viewport: { width: 320, height: 760 },
+ });
+
+ try {
+ await measureTimelineRow({
+ host: mounted.host,
+ props: scenario.props,
+ targetRowId: scenario.targetRowId,
+ });
+ const beforeScopeKey = requireVirtualizerSnapshot(
+ latestSnapshot,
+ "Expected an initial virtualizer snapshot before changing chat fonts.",
+ ).measurementScopeKey;
+ expect(beforeScopeKey).toBe("dm-sans:default");
+
+ await setClientSettings({ chatFontFamily: "noto-sans-multiscript" });
+
+ await vi.waitFor(
+ async () => {
+ const afterChange = await measureTimelineRow({
+ host: mounted.host,
+ props: scenario.props,
+ targetRowId: scenario.targetRowId,
+ });
+ const afterSnapshot = requireVirtualizerSnapshot(
+ latestSnapshot,
+ "Expected a virtualizer snapshot after changing chat fonts.",
+ );
+ expect(afterSnapshot.measurementScopeKey).toBe("noto-sans-multiscript:default");
+ expect(afterSnapshot.measurementScopeKey).not.toBe(beforeScopeKey);
+ expect(
+ Math.abs(afterChange.actualHeightPx - afterChange.virtualizerSizePx),
+ ).toBeLessThanOrEqual(8);
+ },
+ { timeout: 8_000, interval: 16 },
+ );
+ } finally {
+ await mounted.cleanup();
+ }
+ });
});
diff --git a/apps/web/src/components/settings/SettingsAppearancePanel.tsx b/apps/web/src/components/settings/SettingsAppearancePanel.tsx
index 55f01dc0..9514e73f 100644
--- a/apps/web/src/components/settings/SettingsAppearancePanel.tsx
+++ b/apps/web/src/components/settings/SettingsAppearancePanel.tsx
@@ -1,4 +1,8 @@
-import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
+import {
+ DEFAULT_UNIFIED_SETTINGS,
+ type ChatFontFamily,
+ type ChatTextSize,
+} from "@t3tools/contracts/settings";
import { useTheme } from "../../hooks/useTheme";
import { useSettings, useUpdateSettings } from "../../hooks/useSettings";
@@ -23,6 +27,33 @@ const TIMESTAMP_FORMAT_LABELS = {
"24-hour": "24-hour",
} as const;
+const CHAT_FONT_LABELS = {
+ auto: "Auto",
+ "dm-sans": "DM Sans",
+ "noto-sans": "Noto Sans",
+ "noto-sans-multiscript": "Noto Sans Multilingual",
+} as const satisfies Record;
+
+const CHAT_FONT_OPTIONS = [
+ { value: "auto", label: CHAT_FONT_LABELS.auto },
+ { value: "dm-sans", label: CHAT_FONT_LABELS["dm-sans"] },
+ { value: "noto-sans", label: CHAT_FONT_LABELS["noto-sans"] },
+ {
+ value: "noto-sans-multiscript",
+ label: CHAT_FONT_LABELS["noto-sans-multiscript"],
+ },
+] as const satisfies ReadonlyArray<{ value: ChatFontFamily; label: string }>;
+
+function isChatFontFamily(value: string | null): value is ChatFontFamily {
+ return value !== null && CHAT_FONT_OPTIONS.some((option) => option.value === value);
+}
+
+const CHAT_TEXT_SIZE_LABELS = {
+ small: "Small",
+ default: "Default",
+ large: "Large",
+} as const satisfies Record;
+
export function SettingsAppearancePanel() {
const { theme, setTheme } = useTheme();
const settings = useSettings();
@@ -64,6 +95,86 @@ export function SettingsAppearancePanel() {
}
/>
+
+ updateSettings({
+ chatFontFamily: DEFAULT_UNIFIED_SETTINGS.chatFontFamily,
+ })
+ }
+ />
+ ) : null
+ }
+ control={
+
+ }
+ />
+
+
+ updateSettings({
+ chatTextSize: DEFAULT_UNIFIED_SETTINGS.chatTextSize,
+ })
+ }
+ />
+ ) : null
+ }
+ control={
+
+ }
+ />
+
void) {
const changedSettingLabels = useMemo(
() => [
...(theme !== "system" ? ["Theme"] : []),
+ ...(settings.chatFontFamily !== DEFAULT_UNIFIED_SETTINGS.chatFontFamily
+ ? ["Chat typography"]
+ : []),
+ ...(settings.chatTextSize !== DEFAULT_UNIFIED_SETTINGS.chatTextSize
+ ? ["Chat text size"]
+ : []),
...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat
? ["Time format"]
: []),
@@ -87,6 +93,8 @@ export function useSettingsRestore(onRestored?: () => void) {
isGitWritingModelDirty,
isPromptEnhanceModelDirty,
settings.askModelSelection,
+ settings.chatFontFamily,
+ settings.chatTextSize,
settings.codeModelSelection,
settings.commitMessageStyle,
settings.confirmThreadArchive,
diff --git a/apps/web/src/components/timelineHeight.test.ts b/apps/web/src/components/timelineHeight.test.ts
index a295a0b3..610c7876 100644
--- a/apps/web/src/components/timelineHeight.test.ts
+++ b/apps/web/src/components/timelineHeight.test.ts
@@ -125,6 +125,40 @@ describe("estimateTimelineMessageHeight", () => {
expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 768 })).toBe(117.5);
});
+ it("increases message estimates when chat text size is larger", () => {
+ const assistantMessage = {
+ role: "assistant" as const,
+ text: "a".repeat(180),
+ };
+ const userMessage = {
+ role: "user" as const,
+ text: "a".repeat(120),
+ };
+
+ expect(
+ estimateTimelineMessageHeight(assistantMessage, {
+ timelineWidthPx: 420,
+ chatTextSize: "large",
+ }),
+ ).toBeGreaterThan(
+ estimateTimelineMessageHeight(assistantMessage, {
+ timelineWidthPx: 420,
+ chatTextSize: "default",
+ }),
+ );
+ expect(
+ estimateTimelineMessageHeight(userMessage, {
+ timelineWidthPx: 420,
+ chatTextSize: "large",
+ }),
+ ).toBeGreaterThan(
+ estimateTimelineMessageHeight(userMessage, {
+ timelineWidthPx: 420,
+ chatTextSize: "default",
+ }),
+ );
+ });
+
it("does not clamp user wrapping too aggressively on very narrow layouts", () => {
const message = {
role: "user" as const,
diff --git a/apps/web/src/components/timelineHeight.ts b/apps/web/src/components/timelineHeight.ts
index 5b546d97..28e55f88 100644
--- a/apps/web/src/components/timelineHeight.ts
+++ b/apps/web/src/components/timelineHeight.ts
@@ -1,3 +1,4 @@
+import { type ChatTextSize } from "@t3tools/contracts/settings";
import { deriveDisplayedUserMessageState } from "../lib/terminalContext";
import { buildInlineTerminalContextText } from "./chat/userMessageTerminalContexts";
@@ -27,6 +28,36 @@ const ASSISTANT_AVG_CHAR_WIDTH_PX = 7.2;
const MIN_USER_CHARS_PER_LINE = 4;
const MIN_ASSISTANT_CHARS_PER_LINE = 20;
+const TEXT_SIZE_WIDTH_SCALE = {
+ small: 13 / 14,
+ default: 1,
+ large: 15 / 14,
+} as const satisfies Record;
+
+const USER_LINE_HEIGHT_PX_BY_TEXT_SIZE = {
+ small: 18.75,
+ default: USER_LINE_HEIGHT_PX,
+ large: 23.25,
+} as const satisfies Record;
+
+const ASSISTANT_LINE_HEIGHT_PX_BY_TEXT_SIZE = {
+ small: 20.25,
+ default: ASSISTANT_LINE_HEIGHT_PX,
+ large: 25.5,
+} as const satisfies Record;
+
+const USER_BASE_HEIGHT_PX_BY_TEXT_SIZE = {
+ small: 72,
+ default: USER_BASE_HEIGHT_PX,
+ large: 81,
+} as const satisfies Record;
+
+const ASSISTANT_BASE_HEIGHT_PX_BY_TEXT_SIZE = {
+ small: 38,
+ default: ASSISTANT_BASE_HEIGHT_PX,
+ large: 45,
+} as const satisfies Record;
+
interface TimelineMessageHeightInput {
role: "user" | "assistant" | "system";
text: string;
@@ -35,6 +66,7 @@ interface TimelineMessageHeightInput {
interface TimelineHeightEstimateLayout {
timelineWidthPx: number | null;
+ chatTextSize?: ChatTextSize;
}
function estimateWrappedLineCount(text: string, charsPerLine: number): number {
@@ -63,30 +95,38 @@ function isFinitePositiveNumber(value: number | null | undefined): value is numb
function estimateCharsPerLineForUser(
timelineWidthPx: number | null,
averageCharWidthPx: number,
+ chatTextSize: ChatTextSize,
): 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 / averageCharWidthPx));
+ const scaledCharWidthPx = averageCharWidthPx * TEXT_SIZE_WIDTH_SCALE[chatTextSize];
+ return Math.max(MIN_USER_CHARS_PER_LINE, Math.floor(textWidthPx / scaledCharWidthPx));
}
-function estimateCharsPerLineForAssistant(timelineWidthPx: number | null): number {
+function estimateCharsPerLineForAssistant(
+ timelineWidthPx: number | null,
+ chatTextSize: ChatTextSize,
+): 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),
- );
+ const scaledCharWidthPx = ASSISTANT_AVG_CHAR_WIDTH_PX * TEXT_SIZE_WIDTH_SCALE[chatTextSize];
+ return Math.max(MIN_ASSISTANT_CHARS_PER_LINE, Math.floor(textWidthPx / scaledCharWidthPx));
}
export function estimateTimelineMessageHeight(
message: TimelineMessageHeightInput,
layout: TimelineHeightEstimateLayout = { timelineWidthPx: null },
): number {
+ const chatTextSize = layout.chatTextSize ?? "default";
+
if (message.role === "assistant") {
- const charsPerLine = estimateCharsPerLineForAssistant(layout.timelineWidthPx);
+ const charsPerLine = estimateCharsPerLineForAssistant(layout.timelineWidthPx, chatTextSize);
const estimatedLines = estimateWrappedLineCount(message.text, charsPerLine);
- return ASSISTANT_BASE_HEIGHT_PX + estimatedLines * ASSISTANT_LINE_HEIGHT_PX;
+ return (
+ ASSISTANT_BASE_HEIGHT_PX_BY_TEXT_SIZE[chatTextSize] +
+ estimatedLines * ASSISTANT_LINE_HEIGHT_PX_BY_TEXT_SIZE[chatTextSize]
+ );
}
if (message.role === "user") {
@@ -109,17 +149,25 @@ export function estimateTimelineMessageHeight(
displayedUserMessage.contexts.length > 0
? USER_MONO_AVG_CHAR_WIDTH_PX
: USER_MARKDOWN_AVG_CHAR_WIDTH_PX,
+ chatTextSize,
);
const estimatedLines = estimateWrappedLineCount(renderedText, charsPerLine);
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_BY_TEXT_SIZE[chatTextSize] +
+ estimatedLines * USER_LINE_HEIGHT_PX_BY_TEXT_SIZE[chatTextSize] +
+ 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, chatTextSize);
const estimatedLines = estimateWrappedLineCount(message.text, charsPerLine);
- return ASSISTANT_BASE_HEIGHT_PX + estimatedLines * ASSISTANT_LINE_HEIGHT_PX;
+ return (
+ ASSISTANT_BASE_HEIGHT_PX_BY_TEXT_SIZE[chatTextSize] +
+ estimatedLines * ASSISTANT_LINE_HEIGHT_PX_BY_TEXT_SIZE[chatTextSize]
+ );
}
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..38f2438f 100644
--- a/apps/web/src/index.css
+++ b/apps/web/src/index.css
@@ -66,6 +66,17 @@
--desktop-window-safe-inset: 0px;
--desktop-window-corner-radius: 0px;
--radius: 0.625rem;
+ --font-sans-ui:
+ "DM Sans", "Noto Sans", "Noto Sans Arabic", "Noto Sans Hebrew", "Noto Sans Devanagari",
+ "Noto Sans Thai", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
+ --font-sans-chat-dm-sans:
+ "DM Sans", "Noto Sans", "Noto Sans Arabic", "Noto Sans Hebrew", "Noto Sans Devanagari",
+ "Noto Sans Thai", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
+ --font-sans-chat-noto:
+ "Noto Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
+ --font-sans-chat-multiscript:
+ "Noto Sans", "Noto Sans Arabic", "Noto Sans Hebrew", "Noto Sans Devanagari", "Noto Sans Thai",
+ -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
--background: var(--color-white);
--foreground: var(--color-neutral-800);
--card: var(--color-white);
@@ -123,13 +134,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;
@@ -251,7 +256,53 @@ label:has(> select#reasoning-effort) select {
height: 100%;
}
-/* Chat markdown rendering */
+/* Chat readability */
+.chat-readability-surface {
+ text-align: start;
+ unicode-bidi: isolate;
+}
+
+.chat-readability-direction-rtl {
+ direction: rtl;
+}
+
+.chat-readability-direction-ltr {
+ direction: ltr;
+}
+
+.chat-readability-font-dm-sans {
+ font-family: var(--font-sans-chat-dm-sans);
+}
+
+.chat-readability-font-noto-sans {
+ font-family: var(--font-sans-chat-noto);
+}
+
+.chat-readability-font-noto-sans-multiscript {
+ font-family: var(--font-sans-chat-multiscript);
+}
+
+.chat-readability-text-small {
+ font-size: 0.8125rem;
+ line-height: 1.55;
+}
+
+.chat-readability-text-default {
+ font-size: 0.875rem;
+ line-height: 1.625;
+}
+
+.chat-readability-text-large {
+ font-size: 0.9375rem;
+ line-height: 1.7;
+}
+
+.chat-message-inline-body {
+ min-width: 0;
+ overflow-wrap: anywhere;
+ word-break: break-word;
+}
+
.chat-markdown {
min-width: 0;
overflow-wrap: anywhere;
@@ -276,12 +327,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 +367,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);
}
@@ -327,7 +378,9 @@ label:has(> select#reasoning-effort) select {
background: var(--muted);
padding: 0.1rem 0.35rem;
color: var(--foreground);
- font-size: 0.75rem;
+ font-size: 0.9em;
+ direction: ltr;
+ unicode-bidi: isolate;
}
.chat-markdown pre {
@@ -344,12 +397,14 @@ label:has(> select#reasoning-effort) select {
background: transparent;
padding: 0;
line-height: 1.5;
- font-size: 0.875rem;
+ font-size: 0.95em;
}
.chat-markdown .chat-markdown-codeblock {
position: relative;
margin: 0.65rem 0;
+ direction: ltr;
+ unicode-bidi: isolate;
}
.chat-markdown .chat-markdown-codeblock pre {
@@ -359,7 +414,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 +457,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/chatReadability.test.ts b/apps/web/src/lib/chatReadability.test.ts
new file mode 100644
index 00000000..8146e95b
--- /dev/null
+++ b/apps/web/src/lib/chatReadability.test.ts
@@ -0,0 +1,41 @@
+import { describe, expect, it } from "vitest";
+import { resolveChatReadabilityClassName } from "./chatReadability";
+
+describe("resolveChatReadabilityClassName", () => {
+ it("keeps auto typography free of script-specific font overrides", () => {
+ const className = resolveChatReadabilityClassName({
+ direction: "rtl",
+ fontFamily: "auto",
+ textSize: "default",
+ });
+
+ expect(className).toContain("chat-readability-surface");
+ expect(className).toContain("chat-readability-direction-rtl");
+ expect(className).toContain("chat-readability-text-default");
+ expect(className).not.toContain("chat-readability-font-noto-sans-multiscript");
+ });
+
+ it("applies explicit font and size classes when selected", () => {
+ const className = resolveChatReadabilityClassName({
+ direction: "ltr",
+ fontFamily: "noto-sans",
+ textSize: "large",
+ });
+
+ expect(className).toContain("chat-readability-direction-ltr");
+ expect(className).toContain("chat-readability-font-noto-sans");
+ expect(className).toContain("chat-readability-text-large");
+ });
+
+ it("supports the bundled multiscript font stack", () => {
+ const className = resolveChatReadabilityClassName({
+ direction: "auto",
+ fontFamily: "noto-sans-multiscript",
+ textSize: "small",
+ });
+
+ expect(className).toContain("chat-readability-direction-auto");
+ expect(className).toContain("chat-readability-font-noto-sans-multiscript");
+ expect(className).toContain("chat-readability-text-small");
+ });
+});
diff --git a/apps/web/src/lib/chatReadability.ts b/apps/web/src/lib/chatReadability.ts
new file mode 100644
index 00000000..5714b8a0
--- /dev/null
+++ b/apps/web/src/lib/chatReadability.ts
@@ -0,0 +1,34 @@
+import type { ChatFontFamily, ChatTextSize } from "@t3tools/contracts/settings";
+import type { TextDirection } from "./textDirection";
+import { cn } from "./utils";
+
+const CHAT_DIRECTION_CLASS = {
+ rtl: "chat-readability-direction-rtl",
+ ltr: "chat-readability-direction-ltr",
+ auto: "chat-readability-direction-auto",
+} as const satisfies Record;
+
+const CHAT_FONT_CLASS_BY_FAMILY = {
+ "dm-sans": "chat-readability-font-dm-sans",
+ "noto-sans": "chat-readability-font-noto-sans",
+ "noto-sans-multiscript": "chat-readability-font-noto-sans-multiscript",
+} as const satisfies Record, string>;
+
+const CHAT_TEXT_SIZE_CLASS = {
+ small: "chat-readability-text-small",
+ default: "chat-readability-text-default",
+ large: "chat-readability-text-large",
+} as const satisfies Record;
+
+export function resolveChatReadabilityClassName(options: {
+ direction: TextDirection;
+ fontFamily: ChatFontFamily;
+ textSize: ChatTextSize;
+}) {
+ return cn(
+ "chat-readability-surface",
+ CHAT_DIRECTION_CLASS[options.direction],
+ CHAT_TEXT_SIZE_CLASS[options.textSize],
+ 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..a07af9ec
--- /dev/null
+++ b/apps/web/src/lib/textDirection.test.ts
@@ -0,0 +1,28 @@
+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("detects Hebrew text as rtl without assuming Arabic", () => {
+ 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..b28dd5da 100644
--- a/apps/web/src/main.tsx
+++ b/apps/web/src/main.tsx
@@ -6,6 +6,21 @@ 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/400.css";
+import "@fontsource/noto-sans/500.css";
+import "@fontsource/noto-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 "@fontsource/noto-sans-devanagari/400.css";
+import "@fontsource/noto-sans-devanagari/500.css";
+import "@fontsource/noto-sans-devanagari/700.css";
+import "@fontsource/noto-sans-hebrew/400.css";
+import "@fontsource/noto-sans-hebrew/500.css";
+import "@fontsource/noto-sans-hebrew/700.css";
+import "@fontsource/noto-sans-thai/400.css";
+import "@fontsource/noto-sans-thai/500.css";
+import "@fontsource/noto-sans-thai/700.css";
import "@xterm/xterm/css/xterm.css";
import "./index.css";
diff --git a/bun.lock b/bun.lock
index a65e3499..332ddc19 100644
--- a/bun.lock
+++ b/bun.lock
@@ -70,6 +70,11 @@
"@dnd-kit/utilities": "^3.2.2",
"@effect/atom-react": "catalog:",
"@fontsource/dm-sans": "^5.2.8",
+ "@fontsource/noto-sans": "^5.2.10",
+ "@fontsource/noto-sans-arabic": "^5.2.10",
+ "@fontsource/noto-sans-devanagari": "^5.2.8",
+ "@fontsource/noto-sans-hebrew": "^5.2.8",
+ "@fontsource/noto-sans-thai": "^5.2.8",
"@formkit/auto-animate": "^0.9.0",
"@lexical/react": "^0.41.0",
"@pierre/diffs": "^1.1.0-beta.16",
@@ -335,6 +340,16 @@
"@fontsource/dm-sans": ["@fontsource/dm-sans@5.2.8", "", {}, "sha512-tlovG42m9ESG28WiHpLq3F5umAlm64rv0RkqTbYowRn70e9OlRr5a3yTJhrhrY+k5lftR/OFJjPzOLQzk8EfCA=="],
+ "@fontsource/noto-sans": ["@fontsource/noto-sans@5.2.10", "", {}, "sha512-J58RVfS/C0Z2VBF+PoU260Tx8cdRGYuS+e3yQe4hYaIYDl0sEVn5CzlLo5zVRvQD0HaIUTV8AZMfqR7rtdEpqQ=="],
+
+ "@fontsource/noto-sans-arabic": ["@fontsource/noto-sans-arabic@5.2.10", "", {}, "sha512-Hgj3NwQJ0NquIADW3hFboFjsts4UvEOJQ8tOX0bhQZgpNKHsMawjv/1SZnq0QvRbP6efHTZM/A1k7tdy9S6sbA=="],
+
+ "@fontsource/noto-sans-devanagari": ["@fontsource/noto-sans-devanagari@5.2.8", "", {}, "sha512-UyCfyCEX2+QVUDQWb9y2MRTg1O3bTcXixzD2xQfapFOtw4hpH1rfDIbTXulZ4SUEsbB/wWeyyEL9b93Nmwd6Gg=="],
+
+ "@fontsource/noto-sans-hebrew": ["@fontsource/noto-sans-hebrew@5.2.8", "", {}, "sha512-FN/GDpE709JQN5f7vmqlbIwz2/vAM6ZnXqRsw47Piq731hvti55V/FLbyU2RWUvlis16ezPf0r+zKTjfdeXF/g=="],
+
+ "@fontsource/noto-sans-thai": ["@fontsource/noto-sans-thai@5.2.8", "", {}, "sha512-HUQAxOzclod9PXQx4uMoGRAsor+64snW91QuBg39jT9lbsp7jJx1i3jAtHPJ7mQxHEHnhRjNp54fb8GqPPC6zg=="],
+
"@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..14bd3a35 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -29,7 +29,22 @@ 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",
+ "noto-sans-multiscript",
+]);
+export type ChatFontFamily = typeof ChatFontFamily.Type;
+export const DEFAULT_CHAT_FONT_FAMILY: ChatFontFamily = "auto";
+
+export const ChatTextSize = Schema.Literals(["small", "default", "large"]);
+export type ChatTextSize = typeof ChatTextSize.Type;
+export const DEFAULT_CHAT_TEXT_SIZE: ChatTextSize = "default";
+
export const ClientSettingsSchema = Schema.Struct({
+ chatFontFamily: ChatFontFamily.pipe(Schema.withDecodingDefault(() => DEFAULT_CHAT_FONT_FAMILY)),
+ chatTextSize: ChatTextSize.pipe(Schema.withDecodingDefault(() => DEFAULT_CHAT_TEXT_SIZE)),
confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)),
confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(() => true)),
diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)),