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 {
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)
}
@@ -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 }
}
}
@@ -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)
@@ -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.
@@ -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)
}