fix(chat): scale rich chat content with the font-size slider (#68)

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) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-05-01 15:24:45 +02:00
parent a41c81c048
commit df1b9caabf
5 changed files with 121 additions and 22 deletions
@@ -3,12 +3,22 @@ import SwiftUI
struct MarkdownContentView: View { struct MarkdownContentView: View {
let content: String 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 { var body: some View {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
ForEach(Array(parseBlocks().enumerated()), id: \.offset) { _, block in ForEach(Array(parseBlocks().enumerated()), id: \.offset) { _, block in
blockView(block) 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 @ViewBuilder
@@ -37,15 +47,19 @@ struct MarkdownContentView: View {
// MARK: - Block Views // MARK: - Block Views
private func headingView(level: Int, text: String) -> some View { private func headingView(level: Int, text: String) -> some View {
let font: Font = switch level { // Heading sizes scale with `chatFontScale` (issue #68). Bases
case 1: .title.bold() // mirror the SwiftUI semantic tokens we used previously
case 2: .title2.bold() // (`.title` 28, `.title2` 22, `.title3` 20, `.headline`
case 3: .title3.bold() // 17, `.subheadline` 15) so 100% matches today's UI.
case 4: .headline let baseSize: CGFloat = switch level {
default: .subheadline.bold() case 1: 28
case 2: 22
case 3: 20
case 4: 17
default: 15
} }
return Text(MarkdownRenderer.inlineAttributedString(text)) return Text(MarkdownRenderer.inlineAttributedString(text))
.font(font) .font(.system(size: baseSize * chatFontScale, weight: .semibold))
.textSelection(.enabled) .textSelection(.enabled)
.padding(.top, level <= 2 ? 8 : 4) .padding(.top, level <= 2 ? 8 : 4)
} }
@@ -54,11 +68,11 @@ struct MarkdownContentView: View {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
if let lang = language, !lang.isEmpty { if let lang = language, !lang.isEmpty {
Text(lang) Text(lang)
.font(.caption2.bold()) .font(ChatFontScale.caption2(chatFontScale).bold())
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
Text(code) Text(code)
.font(.system(.callout, design: .monospaced)) .font(ChatFontScale.codeInline(chatFontScale))
.textSelection(.enabled) .textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
@@ -106,4 +106,74 @@ enum ChatFontScale {
let pct = Int((scale * 100).rounded()) let pct = Int((scale * 100).rounded())
return "\(pct)%" 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 }
}
} }
@@ -7,12 +7,16 @@ struct CodeBlockView: View {
@State private var copied = false @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 { var body: some View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
if let language, !language.isEmpty { if let language, !language.isEmpty {
HStack { HStack {
Text(language) Text(language)
.font(.caption2.bold()) .font(ChatFontScale.caption2(chatFontScale).bold())
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Spacer() Spacer()
copyButton copyButton
@@ -31,7 +35,7 @@ struct CodeBlockView: View {
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
Text(code) 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))) .foregroundStyle(Color(nsColor: NSColor(red: 0.85, green: 0.87, blue: 0.91, alpha: 1.0)))
.textSelection(.enabled) .textSelection(.enabled)
.padding(.horizontal, 10) .padding(.horizontal, 10)
@@ -64,6 +64,11 @@ struct RichChatView: View {
} }
.frame(minHeight: 0, idealHeight: 500, maxHeight: .infinity) .frame(minHeight: 0, idealHeight: 500, maxHeight: .infinity)
.environment(\.dynamicTypeSize, ChatFontScale.dynamicTypeSize(for: fontScale)) .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 // Animate side-pane shows/hides so the transcript reflows
// smoothly rather than snapping. ~180ms feels responsive // smoothly rather than snapping. ~180ms feels responsive
// without being jarring. // without being jarring.
@@ -14,6 +14,11 @@ struct RichMessageBubble: View, Equatable {
@Environment(ChatViewModel.self) private var chatViewModel @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 /// Scarf-local chat density preferences (issues #47 / #48). All
/// three default to today's UI. Read here so the reasoning + tool- /// three default to today's UI. Read here so the reasoning + tool-
/// call switches don't have to thread the values through every /// call switches don't have to thread the values through every
@@ -68,7 +73,7 @@ struct RichMessageBubble: View, Equatable {
HStack { HStack {
Spacer(minLength: 80) Spacer(minLength: 80)
Text(message.content) Text(message.content)
.scarfStyle(.body) .font(ChatFontScale.body(chatFontScale))
.foregroundStyle(ScarfColor.onAccent) .foregroundStyle(ScarfColor.onAccent)
.textSelection(.enabled) .textSelection(.enabled)
.padding(.horizontal, 14) .padding(.horizontal, 14)
@@ -91,7 +96,7 @@ struct RichMessageBubble: View, Equatable {
.font(.system(size: 9)) .font(.system(size: 9))
.foregroundStyle(ScarfColor.success) .foregroundStyle(ScarfColor.success)
Text(time, style: .time) Text(time, style: .time)
.font(ScarfFont.caption2) .font(ChatFontScale.caption2(chatFontScale))
.foregroundStyle(ScarfColor.foregroundFaint) .foregroundStyle(ScarfColor.foregroundFaint)
} }
.padding(.trailing, 4) .padding(.trailing, 4)
@@ -183,7 +188,7 @@ struct RichMessageBubble: View, Equatable {
private var reasoningDisclosure: some View { private var reasoningDisclosure: some View {
DisclosureGroup { DisclosureGroup {
Text(message.preferredReasoning ?? "") Text(message.preferredReasoning ?? "")
.font(ScarfFont.monoSmall) .font(ChatFontScale.monoSmall(chatFontScale))
.foregroundStyle(ScarfColor.foregroundMuted) .foregroundStyle(ScarfColor.foregroundMuted)
.italic() .italic()
.textSelection(.enabled) .textSelection(.enabled)
@@ -194,11 +199,11 @@ struct RichMessageBubble: View, Equatable {
Image(systemName: "brain") Image(systemName: "brain")
.font(.system(size: 11)) .font(.system(size: 11))
Text("REASONING") Text("REASONING")
.scarfStyle(.captionStrong) .font(ChatFontScale.captionStrong(chatFontScale))
.tracking(0.5) .tracking(0.5)
if let tokens = message.tokenCount, tokens > 0 { if let tokens = message.tokenCount, tokens > 0 {
Text("· \(tokens) tok") Text("· \(tokens) tok")
.font(ScarfFont.monoSmall) .font(ChatFontScale.monoSmall(chatFontScale))
.foregroundStyle(ScarfColor.foregroundFaint) .foregroundStyle(ScarfColor.foregroundFaint)
} }
} }
@@ -222,7 +227,7 @@ struct RichMessageBubble: View, Equatable {
.font(.system(size: 9)) .font(.system(size: 9))
.foregroundStyle(ScarfColor.warning) .foregroundStyle(ScarfColor.warning)
Text(message.preferredReasoning ?? "") Text(message.preferredReasoning ?? "")
.font(ScarfFont.caption) .font(ChatFontScale.caption(chatFontScale))
.italic() .italic()
.foregroundStyle(ScarfColor.foregroundFaint) .foregroundStyle(ScarfColor.foregroundFaint)
.textSelection(.enabled) .textSelection(.enabled)
@@ -281,7 +286,7 @@ struct RichMessageBubble: View, Equatable {
.font(.system(size: 10)) .font(.system(size: 10))
.foregroundStyle(color) .foregroundStyle(color)
Text(call.functionName) Text(call.functionName)
.font(ScarfFont.monoSmall) .font(ChatFontScale.monoSmall(chatFontScale))
.fontWeight(.medium) .fontWeight(.medium)
.foregroundStyle(ScarfColor.foregroundPrimary) .foregroundStyle(ScarfColor.foregroundPrimary)
.lineLimit(1) .lineLimit(1)
@@ -341,25 +346,26 @@ struct RichMessageBubble: View, Equatable {
HStack(spacing: 8) { HStack(spacing: 8) {
if let tokens = message.tokenCount, tokens > 0 { if let tokens = message.tokenCount, tokens > 0 {
Text("\(tokens) tok") Text("\(tokens) tok")
.font(ScarfFont.monoSmall) .font(ChatFontScale.monoSmall(chatFontScale))
} }
if let reason = message.finishReason, !reason.isEmpty { if let reason = message.finishReason, !reason.isEmpty {
Text("·") Text("·")
Text(reason) Text(reason)
.scarfStyle(.caption) .font(ChatFontScale.caption(chatFontScale))
} }
if let time = message.timestamp { if let time = message.timestamp {
Text("·") Text("·")
Text(time, style: .time) Text(time, style: .time)
.scarfStyle(.caption) .font(ChatFontScale.caption(chatFontScale))
} }
if let seconds = turnDuration { if let seconds = turnDuration {
Text("·") Text("·")
Text(RichChatViewModel.formatTurnDuration(seconds)) Text(RichChatViewModel.formatTurnDuration(seconds))
.font(ScarfFont.monoSmall) .font(ChatFontScale.monoSmall(chatFontScale))
.help("Wall-clock duration of this turn") .help("Wall-clock duration of this turn")
} }
} }
.font(ChatFontScale.caption(chatFontScale))
.foregroundStyle(ScarfColor.foregroundFaint) .foregroundStyle(ScarfColor.foregroundFaint)
.padding(.leading, 4) .padding(.leading, 4)
} }