From df1b9caabf7c2af030a1f0c8c6288eddf3826624 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Fri, 1 May 2026 15:24:45 +0200 Subject: [PATCH] fix(chat): scale rich chat content with the font-size slider (#68) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chat font-size slider only set `\.dynamicTypeSize` on the chat root, but ScarfFont tokens are fixed-point (`Font.system(size: 14, …)`) so dynamic type didn't reach bubble text, reasoning, tool chips, code blocks, or markdown headings. Slider moved between 85%–130% with little visible effect. Plumb a separate `\.chatFontScale: Double` env value from `RichChatView` and have the chat content views read it: - `RichMessageBubble` — user bubble body, reasoning (disclosure + inline), REASONING label, token chip, tool-chip name, metadata footer. - `MarkdownContentView` — paragraphs (now pinned to a scaled body font instead of inheriting), headings (1..5), inline-rendered code blocks, code-language label. - `CodeBlockView` — code body and language label. `ChatFontScale.{body, callout, caption, captionStrong, caption2, mono, monoSmall, codeBlock, codeInline}(_ scale:)` helpers mirror `ScarfFont`'s base sizes so scale = 1.0 is byte-for-byte identical to today's UI; the slider now actually moves the visible chat text. Other surfaces (settings, sidebar, etc.) still use the static ScarfFont tokens — chat scaling stays scoped to the chat surface. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Core/Utilities/MarkdownContentView.swift | 32 ++++++--- .../Features/Chat/ChatDensitySettings.swift | 70 +++++++++++++++++++ .../Features/Chat/Views/CodeBlockView.swift | 8 ++- .../Features/Chat/Views/RichChatView.swift | 5 ++ .../Chat/Views/RichMessageBubble.swift | 28 +++++--- 5 files changed, 121 insertions(+), 22 deletions(-) diff --git a/scarf/scarf/Core/Utilities/MarkdownContentView.swift b/scarf/scarf/Core/Utilities/MarkdownContentView.swift index e531d6a..e578d00 100644 --- a/scarf/scarf/Core/Utilities/MarkdownContentView.swift +++ b/scarf/scarf/Core/Utilities/MarkdownContentView.swift @@ -3,12 +3,22 @@ import SwiftUI struct MarkdownContentView: View { let content: String + /// Chat font scale plumbed from `RichChatView` (issue #68). Defaults + /// to 1.0 when this view is used outside the chat surface so other + /// callers see the un-scaled rendering. + @Environment(\.chatFontScale) private var chatFontScale: Double + var body: some View { VStack(alignment: .leading, spacing: 6) { ForEach(Array(parseBlocks().enumerated()), id: \.offset) { _, block in blockView(block) } } + // Paragraphs are rendered as plain `Text(AttributedString)` and + // inherit whatever font is set on the enclosing scope. Pin the + // scope to the scaled body font so the chat slider actually + // moves the visible text. + .font(ChatFontScale.body(chatFontScale)) } @ViewBuilder @@ -37,15 +47,19 @@ struct MarkdownContentView: View { // MARK: - Block Views private func headingView(level: Int, text: String) -> some View { - let font: Font = switch level { - case 1: .title.bold() - case 2: .title2.bold() - case 3: .title3.bold() - case 4: .headline - default: .subheadline.bold() + // Heading sizes scale with `chatFontScale` (issue #68). Bases + // mirror the SwiftUI semantic tokens we used previously + // (`.title` ≈ 28, `.title2` ≈ 22, `.title3` ≈ 20, `.headline` + // ≈ 17, `.subheadline` ≈ 15) so 100% matches today's UI. + let baseSize: CGFloat = switch level { + case 1: 28 + case 2: 22 + case 3: 20 + case 4: 17 + default: 15 } return Text(MarkdownRenderer.inlineAttributedString(text)) - .font(font) + .font(.system(size: baseSize * chatFontScale, weight: .semibold)) .textSelection(.enabled) .padding(.top, level <= 2 ? 8 : 4) } @@ -54,11 +68,11 @@ struct MarkdownContentView: View { VStack(alignment: .leading, spacing: 4) { if let lang = language, !lang.isEmpty { Text(lang) - .font(.caption2.bold()) + .font(ChatFontScale.caption2(chatFontScale).bold()) .foregroundStyle(.secondary) } Text(code) - .font(.system(.callout, design: .monospaced)) + .font(ChatFontScale.codeInline(chatFontScale)) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) } diff --git a/scarf/scarf/Features/Chat/ChatDensitySettings.swift b/scarf/scarf/Features/Chat/ChatDensitySettings.swift index 6f0dd6d..f23f892 100644 --- a/scarf/scarf/Features/Chat/ChatDensitySettings.swift +++ b/scarf/scarf/Features/Chat/ChatDensitySettings.swift @@ -106,4 +106,74 @@ enum ChatFontScale { let pct = Int((scale * 100).rounded()) return "\(pct)%" } + + // MARK: - Scaled font helpers + // + // ScarfFont's tokens are fixed-point (`Font.system(size: 14, …)`), + // so `.environment(\.dynamicTypeSize, …)` doesn't reach them — the + // Mac chat slider had no visible effect on bubbles, reasoning, + // tool chips, or code blocks (issue #68). These helpers mirror the + // ScarfFont base sizes, multiplied by the user's chat scale, and + // are used by `RichMessageBubble`, `MarkdownContentView`, and + // `CodeBlockView` in place of the static tokens. At scale = 1.0 + // they're byte-for-byte identical to ScarfFont so the default UI + // is unchanged. + + static func body(_ scale: Double) -> Font { + .system(size: 14 * scale, weight: .regular) + } + + static func bodyEmph(_ scale: Double) -> Font { + .system(size: 14 * scale, weight: .medium) + } + + static func callout(_ scale: Double) -> Font { + .system(size: 15 * scale, weight: .regular) + } + + static func caption(_ scale: Double) -> Font { + .system(size: 12 * scale, weight: .regular) + } + + static func captionStrong(_ scale: Double) -> Font { + .system(size: 12 * scale, weight: .semibold) + } + + static func caption2(_ scale: Double) -> Font { + .system(size: 10 * scale, weight: .medium) + } + + static func mono(_ scale: Double) -> Font { + .system(size: 13 * scale, weight: .regular, design: .monospaced) + } + + static func monoSmall(_ scale: Double) -> Font { + .system(size: 12 * scale, weight: .regular, design: .monospaced) + } + + /// Code-block body — matches `CodeBlockView`'s 12pt mono. + static func codeBlock(_ scale: Double) -> Font { + .system(size: 12 * scale, weight: .regular, design: .monospaced) + } + + /// Inline code in markdown paragraphs — `.callout` (15pt) mono. + static func codeInline(_ scale: Double) -> Font { + .system(size: 15 * scale, weight: .regular, design: .monospaced) + } +} + +// MARK: - Environment plumbing + +private struct ChatFontScaleKey: EnvironmentKey { + static let defaultValue: Double = ChatFontScale.default +} + +extension EnvironmentValues { + /// Multiplier applied to chat content fonts. Set once on + /// `RichChatView`'s root so message bubbles, markdown paragraphs, + /// and code blocks scale together. Default 1.0 = today's UI. + var chatFontScale: Double { + get { self[ChatFontScaleKey.self] } + set { self[ChatFontScaleKey.self] = newValue } + } } diff --git a/scarf/scarf/Features/Chat/Views/CodeBlockView.swift b/scarf/scarf/Features/Chat/Views/CodeBlockView.swift index 485a179..1de209e 100644 --- a/scarf/scarf/Features/Chat/Views/CodeBlockView.swift +++ b/scarf/scarf/Features/Chat/Views/CodeBlockView.swift @@ -7,12 +7,16 @@ struct CodeBlockView: View { @State private var copied = false + /// Chat font scale plumbed from `RichChatView` (issue #68). Defaults + /// to 1.0 outside the chat surface. + @Environment(\.chatFontScale) private var chatFontScale: Double + var body: some View { VStack(alignment: .leading, spacing: 0) { if let language, !language.isEmpty { HStack { Text(language) - .font(.caption2.bold()) + .font(ChatFontScale.caption2(chatFontScale).bold()) .foregroundStyle(.secondary) Spacer() copyButton @@ -31,7 +35,7 @@ struct CodeBlockView: View { ScrollView(.horizontal, showsIndicators: false) { Text(code) - .font(.system(size: 12, design: .monospaced)) + .font(ChatFontScale.codeBlock(chatFontScale)) .foregroundStyle(Color(nsColor: NSColor(red: 0.85, green: 0.87, blue: 0.91, alpha: 1.0))) .textSelection(.enabled) .padding(.horizontal, 10) diff --git a/scarf/scarf/Features/Chat/Views/RichChatView.swift b/scarf/scarf/Features/Chat/Views/RichChatView.swift index c2ee6c0..5b1176e 100644 --- a/scarf/scarf/Features/Chat/Views/RichChatView.swift +++ b/scarf/scarf/Features/Chat/Views/RichChatView.swift @@ -64,6 +64,11 @@ struct RichChatView: View { } .frame(minHeight: 0, idealHeight: 500, maxHeight: .infinity) .environment(\.dynamicTypeSize, ChatFontScale.dynamicTypeSize(for: fontScale)) + // ScarfFont tokens are fixed-point so dynamicTypeSize alone + // doesn't move bubble / markdown / code-block text. Plumb the + // raw scale via `\.chatFontScale` so chat content views can + // read it and scale their explicit sizes too (issue #68). + .environment(\.chatFontScale, fontScale) // Animate side-pane shows/hides so the transcript reflows // smoothly rather than snapping. ~180ms feels responsive // without being jarring. diff --git a/scarf/scarf/Features/Chat/Views/RichMessageBubble.swift b/scarf/scarf/Features/Chat/Views/RichMessageBubble.swift index 8d8bb23..d9c792d 100644 --- a/scarf/scarf/Features/Chat/Views/RichMessageBubble.swift +++ b/scarf/scarf/Features/Chat/Views/RichMessageBubble.swift @@ -14,6 +14,11 @@ struct RichMessageBubble: View, Equatable { @Environment(ChatViewModel.self) private var chatViewModel + /// Chat-only font scale set on `RichChatView`. Chat content uses + /// these multiplied sizes (issue #68); other surfaces still see + /// the static ScarfFont tokens at scale = 1.0. + @Environment(\.chatFontScale) private var chatFontScale: Double + /// Scarf-local chat density preferences (issues #47 / #48). All /// three default to today's UI. Read here so the reasoning + tool- /// call switches don't have to thread the values through every @@ -68,7 +73,7 @@ struct RichMessageBubble: View, Equatable { HStack { Spacer(minLength: 80) Text(message.content) - .scarfStyle(.body) + .font(ChatFontScale.body(chatFontScale)) .foregroundStyle(ScarfColor.onAccent) .textSelection(.enabled) .padding(.horizontal, 14) @@ -91,7 +96,7 @@ struct RichMessageBubble: View, Equatable { .font(.system(size: 9)) .foregroundStyle(ScarfColor.success) Text(time, style: .time) - .font(ScarfFont.caption2) + .font(ChatFontScale.caption2(chatFontScale)) .foregroundStyle(ScarfColor.foregroundFaint) } .padding(.trailing, 4) @@ -183,7 +188,7 @@ struct RichMessageBubble: View, Equatable { private var reasoningDisclosure: some View { DisclosureGroup { Text(message.preferredReasoning ?? "") - .font(ScarfFont.monoSmall) + .font(ChatFontScale.monoSmall(chatFontScale)) .foregroundStyle(ScarfColor.foregroundMuted) .italic() .textSelection(.enabled) @@ -194,11 +199,11 @@ struct RichMessageBubble: View, Equatable { Image(systemName: "brain") .font(.system(size: 11)) Text("REASONING") - .scarfStyle(.captionStrong) + .font(ChatFontScale.captionStrong(chatFontScale)) .tracking(0.5) if let tokens = message.tokenCount, tokens > 0 { Text("· \(tokens) tok") - .font(ScarfFont.monoSmall) + .font(ChatFontScale.monoSmall(chatFontScale)) .foregroundStyle(ScarfColor.foregroundFaint) } } @@ -222,7 +227,7 @@ struct RichMessageBubble: View, Equatable { .font(.system(size: 9)) .foregroundStyle(ScarfColor.warning) Text(message.preferredReasoning ?? "") - .font(ScarfFont.caption) + .font(ChatFontScale.caption(chatFontScale)) .italic() .foregroundStyle(ScarfColor.foregroundFaint) .textSelection(.enabled) @@ -281,7 +286,7 @@ struct RichMessageBubble: View, Equatable { .font(.system(size: 10)) .foregroundStyle(color) Text(call.functionName) - .font(ScarfFont.monoSmall) + .font(ChatFontScale.monoSmall(chatFontScale)) .fontWeight(.medium) .foregroundStyle(ScarfColor.foregroundPrimary) .lineLimit(1) @@ -341,25 +346,26 @@ struct RichMessageBubble: View, Equatable { HStack(spacing: 8) { if let tokens = message.tokenCount, tokens > 0 { Text("\(tokens) tok") - .font(ScarfFont.monoSmall) + .font(ChatFontScale.monoSmall(chatFontScale)) } if let reason = message.finishReason, !reason.isEmpty { Text("·") Text(reason) - .scarfStyle(.caption) + .font(ChatFontScale.caption(chatFontScale)) } if let time = message.timestamp { Text("·") Text(time, style: .time) - .scarfStyle(.caption) + .font(ChatFontScale.caption(chatFontScale)) } if let seconds = turnDuration { Text("·") Text(RichChatViewModel.formatTurnDuration(seconds)) - .font(ScarfFont.monoSmall) + .font(ChatFontScale.monoSmall(chatFontScale)) .help("Wall-clock duration of this turn") } } + .font(ChatFontScale.caption(chatFontScale)) .foregroundStyle(ScarfColor.foregroundFaint) .padding(.leading, 4) }