diff --git a/apps/web/package.json b/apps/web/package.json index e871ae81..7c4aa1a1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -21,6 +21,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", diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 525a4210..46f4e597 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 { resolveChatReadabilityClassName } from "~/lib/chatReadability"; +import { cn } from "~/lib/utils"; +import { resolveTextDirection } from "~/lib/textDirection"; class CodeHighlightErrorBoundary extends React.Component< { fallback: ReactNode; children: ReactNode }, @@ -255,7 +259,10 @@ function SuspenseShikiCodeBlock({ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { const { resolvedTheme } = useTheme(); + const chatFontFamily = useSettings((settings) => settings.chatFontFamily); + const chatTextSize = useSettings((settings) => settings.chatTextSize); const diffThemeName = resolveDiffThemeName(resolvedTheme); + const textDirection = useMemo(() => resolveTextDirection(text), [text]); const markdownUrlTransform = useCallback((href: string) => { return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href); }, []); @@ -310,7 +317,17 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { ); return ( -
+
settings.chatFontFamily); + const chatTextSize = useSettings((settings) => settings.chatTextSize); + const composerDirection = resolveTextDirection(value); + const composerReadabilityClassName = useMemo( + () => + resolveChatReadabilityClassName({ + direction: composerDirection, + fontFamily: chatFontFamily, + textSize: chatTextSize, + }), + [chatFontFamily, chatTextSize, composerDirection], + ); const terminalContextsSignature = terminalContextSignature(terminalContexts); const terminalContextsSignatureRef = useRef(terminalContextsSignature); const snapshotRef = useRef({ @@ -1092,8 +1107,10 @@ function ComposerPromptEditorInner({ 0 ? null : ( -
+
{placeholder}
) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 16b02ea9..4b5ce371 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1,4 +1,4 @@ -import { type MessageId } from "@t3tools/contracts"; +import { type ChatTextSize, type MessageId } from "@t3tools/contracts"; import { type TimelineEntry, type WorkLogEntry } from "../../session-logic"; import { buildTurnDiffTree, type TurnDiffTreeNode } from "../../lib/turnDiffTree"; import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types"; @@ -133,6 +133,7 @@ export function estimateMessagesTimelineRowHeight( row: MessagesTimelineRow, input: { timelineWidthPx: number | null; + chatTextSize?: ChatTextSize; expandedWorkGroups?: Readonly>; turnDiffSummaryByAssistantMessageId?: ReadonlyMap; }, @@ -147,6 +148,7 @@ export function estimateMessagesTimelineRowHeight( case "message": { let estimate = estimateTimelineMessageHeight(row.message, { timelineWidthPx: input.timelineWidthPx, + ...(input.chatTextSize ? { chatTextSize: input.chatTextSize } : {}), }); const turnDiffSummary = input.turnDiffSummaryByAssistantMessageId?.get(row.message.id); if (turnDiffSummary && turnDiffSummary.files.length > 0) { diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index e6e85a07..8d33d933 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1,8 +1,12 @@ import { MessageId } from "@t3tools/contracts"; +import { ClientSettingsSchema, DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; import { renderToStaticMarkup } from "react-dom/server"; -import { beforeAll, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { removeLocalStorageItem, setLocalStorageItem } from "../../hooks/useLocalStorage"; import { MessagesTimeline } from "./MessagesTimeline"; +const CLIENT_SETTINGS_STORAGE_KEY = "t3code:client-settings:v1"; + function matchMedia() { return { matches: false, @@ -43,6 +47,10 @@ beforeAll(() => { }); }); +beforeEach(() => { + removeLocalStorageItem(CLIENT_SETTINGS_STORAGE_KEY); +}); + describe("MessagesTimeline", () => { it("renders inline terminal labels with the composer chip UI", () => { const markup = renderToStaticMarkup( @@ -143,4 +151,71 @@ describe("MessagesTimeline", () => { expect(markup).toContain("Context compacted"); expect(markup).toContain("Work log"); }); + + it("keeps terminal context user bubbles monospace with explicit chat fonts", () => { + setLocalStorageItem( + CLIENT_SETTINGS_STORAGE_KEY, + { + ...DEFAULT_CLIENT_SETTINGS, + chatFontFamily: "noto-sans", + chatTextSize: "default", + }, + ClientSettingsSchema, + ); + + const markup = renderToStaticMarkup( + ", + "- Terminal 1 lines 1-2:", + " 1 | bun install", + " 2 | bun run dev", + "", + ].join("\n"), + createdAt: "2026-03-17T19:12:28.000Z", + streaming: false, + }, + }, + ]} + completionDividerBeforeEntryId={null} + completionSummary={null} + turnDiffSummaryByAssistantMessageId={new Map()} + nowIso="2026-03-17T19:12:30.000Z" + expandedWorkGroups={{}} + onToggleWorkGroup={() => {}} + changedFilesExpandedByTurnId={{}} + onSetChangedFilesExpanded={() => {}} + onOpenTurnDiff={() => {}} + revertTurnCountByUserMessageId={new Map()} + onRevertUserMessage={() => {}} + isRevertingCheckpoint={false} + onImageExpand={() => {}} + markdownCwd={undefined} + resolvedTheme="light" + timestampFormat="locale" + workspaceRoot={undefined} + />, + ); + + expect(markup).toContain('dir="auto"'); + expect(markup).toContain("chat-readability-direction-auto"); + expect(markup).toContain("chat-message-inline-body"); + expect(markup).toContain("font-mono"); + expect(markup).not.toContain("chat-readability-font-noto-sans"); + }); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 35ffd234..b3af346e 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 { resolveChatReadabilityClassName } from "~/lib/chatReadability"; +import { resolveTextDirection } from "~/lib/textDirection"; const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8; @@ -95,6 +98,7 @@ interface MessagesTimelineProps { onReviewImplementAll?: (findings: ReviewFinding[]) => void; onReviewDismissAll?: () => void; onVirtualizerSnapshot?: (snapshot: { + measurementScopeKey: string; totalSize: number; measurements: ReadonlyArray<{ id: string; @@ -140,6 +144,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const timelineRootRef = useRef(null); const pendingMeasureFrameRef = useRef(null); const [timelineWidthPx, setTimelineWidthPx] = useState(null); + const chatFontFamily = useSettings((settings) => settings.chatFontFamily); + const chatTextSize = useSettings((settings) => settings.chatTextSize); + const typographyMeasurementScopeKey = `${chatFontFamily}:${chatTextSize}`; useLayoutEffect(() => { const timelineRoot = timelineRootRef.current; @@ -224,7 +231,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ maximum: rows.length, }); const virtualMeasurementScopeKey = - timelineWidthPx === null ? "width:unknown" : `width:${Math.round(timelineWidthPx)}`; + timelineWidthPx === null + ? `width:unknown:typography:${typographyMeasurementScopeKey}` + : `width:${Math.round(timelineWidthPx)}:typography:${typographyMeasurementScopeKey}`; const rowVirtualizer = useVirtualizer({ count: virtualizedRowCount, @@ -239,6 +248,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const row = rows[index]; if (!row) return 96; return estimateMessagesTimelineRowHeight(row, { + chatTextSize, expandedWorkGroups, timelineWidthPx, turnDiffSummaryByAssistantMessageId, @@ -259,6 +269,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ if (timelineWidthPx === null) return; rowVirtualizer.measure(); }, [rowVirtualizer, timelineWidthPx]); + useEffect(() => { + scheduleVirtualizerMeasure(); + }, [scheduleVirtualizerMeasure, typographyMeasurementScopeKey]); useEffect(() => { if (typeof document === "undefined" || document.fonts === undefined) { return; @@ -280,7 +293,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ fontSet.removeEventListener("loadingdone", handleFontSettle); fontSet.removeEventListener("loadingerror", handleFontSettle); }; - }, [scheduleVirtualizerMeasure]); + }, [scheduleVirtualizerMeasure, typographyMeasurementScopeKey]); useEffect(() => { rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => { const viewportHeight = instance.scrollRect?.height ?? 0; @@ -313,6 +326,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ return; } onVirtualizerSnapshot({ + measurementScopeKey: typographyMeasurementScopeKey, totalSize: rowVirtualizer.getTotalSize(), measurements: rowVirtualizer.measurementsCache .slice(0, virtualizedRowCount) @@ -333,7 +347,13 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ]; }), }); - }, [onVirtualizerSnapshot, rowVirtualizer, rows, virtualizedRowCount]); + }, [ + onVirtualizerSnapshot, + rowVirtualizer, + rows, + typographyMeasurementScopeKey, + virtualizedRowCount, + ]); const virtualRows = rowVirtualizer.getVirtualItems(); const nonVirtualizedRows = rows.slice(virtualizedRowCount); @@ -708,6 +728,19 @@ const UserMessageBody = memo(function UserMessageBody(props: { text: string; terminalContexts: ParsedTerminalContextEntry[]; }) { + const chatTextSize = useSettings((settings) => settings.chatTextSize); + const textDirection = resolveTextDirection(props.text); + const bodyClassName = cn( + "chat-message-inline-body whitespace-pre-wrap font-mono text-foreground", + resolveChatReadabilityClassName({ + direction: textDirection, + // Terminal-context bubbles stay monospaced even when chat copy uses a + // proportional font override. + fontFamily: "auto", + textSize: chatTextSize, + }), + ); + if (props.terminalContexts.length > 0) { const hasEmbeddedInlineLabels = textContainsInlineTerminalContextLabels( props.text, @@ -752,7 +785,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { } return ( -
+
{inlineNodes}
); @@ -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)),