eleven / messenger Public

ios+mac: text size — ⌘+/⌘− + Settings slider on Mac, Dynamic Type on iOS (#420)

* ios+mac: text size — ⌘+/⌘− + Settings slider on Mac, Dynamic Type on iOS

MessageBubble reads its size from a new \.chatFontSize environment value
(default 16 = old behaviour); bold/italic/inline-code/fence/quote sizes
keep their ratios, and the rich-text memo keys on size so ⌘+ rebuilds
runs instead of replaying the old size.

Mac: chatTextSize pref (13pt default — Mac iMessage's size, matching the
web's new 13px) over the web's seven-stop ladder. View menu gains Make
Text Bigger/Smaller (⌘+/⌘−, plus an AppDelegate monitor mapping the
unshifted ⌘= press) and Default Text Size (⌘0); Settings → General gains
the A——A slider. The native feed + composer take the pref via the
environment; the webview fallback gets it as the page's --msg-font-size
variable (removed at the default so the web's own slider still rules),
re-applied after navigations.

iOS: the native feed honours the system Text Size — a @ScaledMetric body
size (17 at default, the web's touch size) feeds \.chatFontSize, and the
composer's UITextView/placeholder set adjustsFontForContentSizeCategory
so they live-track the setting. The webview surfaces get Dynamic Type
from the web PR's -apple-system-body CSS with no bridging.

No changelog entry yet — it rides the PR that mints the next iOS/Mac
release, per convention.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac: Settings window — one fixed 600×560 frame on the TabView, same size across tabs

Per-pane frames (420×400 General, 420×100 Updates) made the window
resize on every tab switch. The TabView now owns the single frame;
panes size to fill, and the Updates pane goes grouped to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac: default text size 13 → 14 — the ladder's middle stop, matching Messages.app

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac: text-size ladder tops out at 16 — default one from the end (9…14, 16)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* mac: 15pt default — ladder 10…16, default second from top

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 4ab23a47d65950d0ace54ccd0ac59b9c494865e9 parent acc188a
10 files changed, +162 −23
  • ios/LChat/Views/ComposerField.swift +4 −0
  • ios/LChat/Views/NativeChatScreen.swift +4 −0
  • ios/LChatCore/Sources/LChatCore/MessageBubble.swift +34 −11
  • ios/LChatMac/ChatFeedWebView.swift +18 −0
  • ios/LChatMac/LChatMacApp.swift +33 −0
  • ios/LChatMac/MacSettingsView.swift +25 −5
  • ios/LChatMac/NativeChatView.swift +4 −0
  • ios/LChatMac/NativeFeedView.swift +2 −1
  • ios/LChatMac/TextSize.swift +35 −0
  • ios/LChatMac/UpdaterUI.swift +3 −6
diff --git a/ios/LChat/Views/ComposerField.swift b/ios/LChat/Views/ComposerField.swift
index bc99d20..400a079 100644
--- a/ios/LChat/Views/ComposerField.swift
+++ b/ios/LChat/Views/ComposerField.swift
@@ -33,6 +33,9 @@ struct ComposerField: UIViewRepresentable {
tv.onEditLast = onEditLast
tv.delegate = context.coordinator
tv.font = .preferredFont(forTextStyle: .body)
+ // Live-track the system Text Size (Dynamic Type) — preferredFont only
+ // sizes at creation without this.
+ tv.adjustsFontForContentSizeCategory = true
tv.backgroundColor = .clear
tv.textContainerInset = .zero
tv.textContainer.lineFragmentPadding = 0
@@ -136,6 +139,7 @@ final class GrowingTextView: UITextView {
override init(frame: CGRect, textContainer: NSTextContainer?) {
super.init(frame: frame, textContainer: textContainer)
placeholderLabel.font = .preferredFont(forTextStyle: .body)
+ placeholderLabel.adjustsFontForContentSizeCategory = true
placeholderLabel.textColor = .placeholderText
placeholderLabel.numberOfLines = 1
addSubview(placeholderLabel)
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift
index 99674c0..97b6402 100644
--- a/ios/LChat/Views/NativeChatScreen.swift
+++ b/ios/LChat/Views/NativeChatScreen.swift
@@ -124,6 +124,9 @@ struct NativeChatScreen: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.scenePhase) private var scenePhase
@AppStorage("nativeTheme") private var themeName = "classic"
+ // Dynamic Type: read at the system body size (17 at the default setting —
+ // also the web's touch size), so Settings → Text Size scales the feed.
+ @ScaledMetric(relativeTo: .body) private var chatFontSize: CGFloat = 17
@State private var avatarPop = false
// Whether the bottom sentinel is on screen — gates the scroll pins.
@State private var bottomVisible = false
@@ -505,6 +508,7 @@ struct NativeChatScreen: View {
onCopy: { UIPasteboard.general.string = m.text },
onReact: { model.toggleReaction(m.id, emoji: $0) }),
unfurl: { await model.unfurl($0) })
+ .environment(\.chatFontSize, chatFontSize)
if !m.mine { Spacer(minLength: 40) }
}
// Web-parity breathing room: a touch more air between bubbles,
diff --git a/ios/LChatCore/Sources/LChatCore/MessageBubble.swift b/ios/LChatCore/Sources/LChatCore/MessageBubble.swift
index f78ae87..359bd31 100644
--- a/ios/LChatCore/Sources/LChatCore/MessageBubble.swift
+++ b/ios/LChatCore/Sources/LChatCore/MessageBubble.swift
@@ -18,6 +18,21 @@ private extension View {
}
}
+// The feed's reading size, in points — web parity: --msg-font-size in
+// style.css (15px desktop default, 17px touch). The Mac injects its Text Size
+// pref (View menu ⌘+/⌘−, Settings slider); iOS injects the Dynamic Type body
+// size so the system Text Size setting scales the feed. The default is the old
+// fixed 16, so a surface that doesn't inject renders exactly as before.
+private struct ChatFontSizeKey: EnvironmentKey {
+ static let defaultValue: CGFloat = 16
+}
+public extension EnvironmentValues {
+ var chatFontSize: CGFloat {
+ get { self[ChatFontSizeKey.self] }
+ set { self[ChatFontSizeKey.self] = newValue }
+ }
+}
+
// The action catalog a bubble offers — one definition feeding both the hover
// strip and the right-click menu (the design doc's "consistent actions").
public struct MessageActions {
@@ -87,6 +102,7 @@ public struct MessageBubble: View {
public let unfurl: ((URL) async -> LinkPreview?)?
@Environment(\.colorScheme) private var scheme
+ @Environment(\.chatFontSize) private var fontSize
public init(message: ChatFeedModel.Message, showSender: Bool,
theme: ChatTheme = .classic, onJump: ((Int64) -> Void)? = nil,
@@ -262,7 +278,9 @@ public struct MessageBubble: View {
Text(name).font(.system(size: 11)).foregroundStyle(.secondary)
}
Text(reply.snippet ?? "")
- .font(.system(size: 13))
+ // two notches under the reading size at every size, like
+ // the web's .reply-quote-text (calc(var(--msg-font-size) - 2px))
+ .font(.system(size: fontSize - 2))
.foregroundStyle(.secondary)
.lineLimit(1)
.frame(maxWidth: 220, alignment: message.mine ? .trailing : .leading)
@@ -378,9 +396,11 @@ public struct MessageBubble: View {
switch block {
case .p(let text):
Text(richText(text))
- // The web inherits the browser's 16px default (style.css sets
- // no body font-size), so 16pt keeps the two feeds identical.
- .font(.system(size: 16))
+ // The reading size comes from \.chatFontSize (web parity:
+ // style.css --msg-font-size) — the Mac's pref or iOS's
+ // Dynamic Type body size; derived sizes below keep the fixed
+ // ratios they've always had against it.
+ .font(.system(size: fontSize))
.lineSpacing(2)
.tint(ink) // links inherit the bubble's ink (style.css), underlined
case .code(let text, _):
@@ -388,7 +408,7 @@ public struct MessageBubble: View {
// lines scrolling rather than wrapping (.msg .bubble pre).
ScrollView(.horizontal, showsIndicators: false) {
Text(text)
- .font(.system(size: 13.5, design: .monospaced))
+ .font(.system(size: fontSize * (13.5 / 16), design: .monospaced))
}
.padding(EdgeInsets(top: 8, leading: 10, bottom: 8, trailing: 10))
.background(RoundedRectangle(cornerRadius: 8, style: .continuous)
@@ -396,7 +416,7 @@ public struct MessageBubble: View {
case .quote(let text):
// "> " blockquote — quiet left rule, slightly dimmed text.
Text(richText(text))
- .font(.system(size: 16))
+ .font(.system(size: fontSize))
.lineSpacing(2)
.tint(ink)
.opacity(0.75)
@@ -437,10 +457,13 @@ public struct MessageBubble: View {
}
private func richText(_ text: String) -> AttributedString {
- if let hit = Self.richMemo[text] { return hit }
+ // The built runs bake in \.chatFontSize, so it's part of the key — a
+ // ⌘+ / Text Size change must rebuild, not replay the old size.
+ let key = "\(fontSize)|\(text)"
+ if let hit = Self.richMemo[key] { return hit }
if Self.richMemo.count > 600 { Self.richMemo.removeAll() }
let built = buildRichText(text)
- Self.richMemo[text] = built
+ Self.richMemo[key] = built
return built
}
@@ -452,11 +475,11 @@ public struct MessageBubble: View {
out += AttributedString(s)
case .bold(let s):
var a = AttributedString(s)
- a.font = .system(size: 16, weight: .bold)
+ a.font = .system(size: fontSize, weight: .bold)
out += a
case .italic(let s):
var a = AttributedString(s)
- a.font = .system(size: 16).italic()
+ a.font = .system(size: fontSize).italic()
out += a
case .strike(let s):
var a = AttributedString(s)
@@ -466,7 +489,7 @@ public struct MessageBubble: View {
// Narrow no-break spaces give the chip its side padding — an
// AttributedString background can't pad a run (web: 1px 5px).
var a = AttributedString("\u{202F}\(s)\u{202F}")
- a.font = .system(size: 14.7, design: .monospaced) // 0.92em of 16
+ a.font = .system(size: fontSize * 0.92, design: .monospaced) // 0.92em (style.css)
a.backgroundColor = ink.opacity(message.mine ? 0.22 : 0.08)
out += a
case .link(let s, let url):
diff --git a/ios/LChatMac/ChatFeedWebView.swift b/ios/LChatMac/ChatFeedWebView.swift
index ef9e838..f0c9030 100644
--- a/ios/LChatMac/ChatFeedWebView.swift
+++ b/ios/LChatMac/ChatFeedWebView.swift
@@ -28,9 +28,23 @@ struct ChatFeedWebView: NSViewRepresentable {
let chat: SavedChat
let app: MacAppState
let isActive: Bool
+ // Re-runs updateNSView when the Text Size pref changes so the live page
+ // re-renders at the new size without a reload.
+ @AppStorage(ChatTextSize.key) private var chatTextSize = ChatTextSize.defaultSize
func makeCoordinator() -> Coordinator { Coordinator(chat: chat, app: app) }
+ // Drive the web feed's text size from the native pref via the page's
+ // --msg-font-size variable (style.css). At the default the override is
+ // REMOVED, so the web's own theme-sheet slider (a browser-local pref)
+ // still rules until the Mac pref is deliberately moved.
+ static func textSizeJS(_ size: Double) -> String {
+ let px = Int(ChatTextSize.snap(size))
+ return px == Int(ChatTextSize.defaultSize)
+ ? "document.documentElement.style.removeProperty('--msg-font-size')"
+ : "document.documentElement.style.setProperty('--msg-font-size','\(px)px')"
+ }
+
func makeNSView(context: Context) -> WKWebView {
let cfg = WKWebViewConfiguration()
cfg.websiteDataStore = .default() // persist localStorage/session per origin
@@ -88,6 +102,7 @@ struct ChatFeedWebView: NSViewRepresentable {
// chat switch re-registered the visible webview.
if isActive { app.activeWebView = web }
web.registerForDraggedTypes([.fileURL])
+ web.evaluateJavaScript(Self.textSizeJS(chatTextSize), completionHandler: nil)
}
// Runs on every page load. (1) Window drag: start a native window-drag when the
@@ -326,6 +341,9 @@ struct ChatFeedWebView: NSViewRepresentable {
// WKWebView clobbers the view's registered drag types during navigation,
// so re-assert file-drop registration after every page load.
webView.registerForDraggedTypes([.fileURL])
+ // A navigation wipes inline styles — re-apply the text-size pref.
+ webView.evaluateJavaScript(ChatFeedWebView.textSizeJS(ChatTextSize.current),
+ completionHandler: nil)
// Only the instance's OWN origin counts — for the join fallback AND the
// identity extraction. The page can navigate to another origin (a link,
// or the passkey-backup flow visiting the home server): "no session" on
diff --git a/ios/LChatMac/LChatMacApp.swift b/ios/LChatMac/LChatMacApp.swift
index 904eaf0..0c57831 100644
--- a/ios/LChatMac/LChatMacApp.swift
+++ b/ios/LChatMac/LChatMacApp.swift
@@ -24,6 +24,8 @@ extension Logger {
struct LChatMacApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@StateObject private var app = MacAppState()
+ // Observed here only so the View-menu items' enabled state tracks the pref.
+ @AppStorage(ChatTextSize.key) private var chatTextSize = ChatTextSize.defaultSize
init() {
// Match the web feed's text rendering: style.css sets
@@ -69,6 +71,22 @@ struct LChatMacApp: App {
Button("Find Chat…") { app.focusSearchTick += 1 }
.keyboardShortcut("f", modifiers: .command)
}
+ // View → Make Text Bigger/Smaller (Messages' menu), stepping the
+ // same ladder as the Settings slider and the web's. The menu key
+ // equivalent is "+", which strictly means ⌘⇧=; AppDelegate's key
+ // monitor also maps the unshifted ⌘= press — what "cmd and plus"
+ // physically is on most keyboards — like Messages/Safari accept.
+ CommandGroup(before: .toolbar) {
+ Button("Make Text Bigger") { ChatTextSize.bump(+1) }
+ .keyboardShortcut("+", modifiers: .command)
+ .disabled(ChatTextSize.snap(chatTextSize) >= ChatTextSize.stops.last!)
+ Button("Make Text Smaller") { ChatTextSize.bump(-1) }
+ .keyboardShortcut("-", modifiers: .command)
+ .disabled(ChatTextSize.snap(chatTextSize) <= ChatTextSize.stops.first!)
+ Button("Default Text Size") { ChatTextSize.reset() }
+ .keyboardShortcut("0", modifiers: .command)
+ Divider()
+ }
// ⌘1…⌘9 jump straight to a server, like browser/Slack tabs.
CommandMenu("Servers") {
Button("Quick Switch…") { app.showPalette = true }
@@ -92,6 +110,11 @@ struct LChatMacApp: App {
.tabItem { Label("Updates", systemImage: "arrow.down.circle") }
}
.environmentObject(app)
+ // ONE fixed frame for the whole Settings window, here on the
+ // TabView — per-pane frames made the window resize on every tab
+ // switch (and shrink-and-stick after visiting the short Updates
+ // pane). Panes size to fill; a grouped Form scrolls if it must.
+ .frame(width: 600, height: 560)
}
}
}
@@ -105,6 +128,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil)
func applicationDidFinishLaunching(_ notification: Notification) {
+ // "cmd and plus": the View menu's ⌘+ equivalent strictly needs the
+ // shifted key (⌘⇧=), but people press ⌘= and expect bigger text —
+ // Messages and Safari both accept it, so map it here. (⌘− needs no
+ // help; "-" is unshifted already.)
+ NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
+ guard event.modifierFlags.intersection(.deviceIndependentFlagsMask) == .command,
+ event.charactersIgnoringModifiers == "=" else { return event }
+ ChatTextSize.bump(+1)
+ return nil
+ }
let center = UNUserNotificationCenter.current()
center.delegate = self
center.requestAuthorization(options: [.alert, .sound]) { _, _ in }
diff --git a/ios/LChatMac/MacSettingsView.swift b/ios/LChatMac/MacSettingsView.swift
index 31f86a7..e8e6493 100644
--- a/ios/LChatMac/MacSettingsView.swift
+++ b/ios/LChatMac/MacSettingsView.swift
@@ -7,6 +7,7 @@ import SwiftUI
struct MacSettingsView: View {
@EnvironmentObject var app: MacAppState
@AppStorage("nativeTheme") private var themeName = "classic"
+ @AppStorage(ChatTextSize.key) private var textSize = ChatTextSize.defaultSize
@State private var name = ""
@State private var saving = false
@@ -31,6 +32,19 @@ struct MacSettingsView: View {
}
.pickerStyle(.radioGroup)
}
+ Section {
+ // The iMessage A——A slider: seven stops over the same ladder
+ // as the web slider and the View menu's ⌘+/⌘−.
+ HStack(spacing: 10) {
+ Text("A").font(.system(size: 11)).foregroundStyle(.secondary)
+ Slider(value: textSizeIndex,
+ in: 0...Double(ChatTextSize.stops.count - 1), step: 1)
+ Text("A").font(.system(size: 19)).foregroundStyle(.secondary)
+ }
+ .accessibilityLabel("Text size")
+ } header: {
+ Text("Text size")
+ }
Section("Profile") {
Picker("Server", selection: $selectedInstanceID) {
ForEach(app.instances) { inst in
@@ -56,11 +70,8 @@ struct MacSettingsView: View {
}
}
.formStyle(.grouped)
- // Fixed height (not just width): the Settings window sizes per tab, and
- // after visiting the short Updates pane a height-flexible pane stays
- // shrunk instead of regrowing. The grouped Form scrolls if it ever
- // outgrows this.
- .frame(width: 420, height: 400)
+ // No frame here — the Settings window's single fixed size lives on the
+ // TabView (LChatMacApp), so every tab shares it.
.onAppear {
selectedInstanceID = instance?.id
name = instance?.screenName ?? ""
@@ -71,6 +82,15 @@ struct MacSettingsView: View {
}
}
+ // The slider moves by stop index; the pref stores the point size itself
+ // (snap() forgives any stored off-ladder value).
+ private var textSizeIndex: Binding<Double> {
+ Binding(
+ get: { Double(ChatTextSize.stops.firstIndex(of: ChatTextSize.snap(textSize)) ?? 5) },
+ set: { textSize = ChatTextSize.stops[Int($0.rounded())] }
+ )
+ }
+
private func save() {
guard let inst = instance else { return }
let trimmed = name.trimmingCharacters(in: .whitespaces)
diff --git a/ios/LChatMac/NativeChatView.swift b/ios/LChatMac/NativeChatView.swift
index 74afece..fbc7a94 100644
--- a/ios/LChatMac/NativeChatView.swift
+++ b/ios/LChatMac/NativeChatView.swift
@@ -36,6 +36,7 @@ struct NativeChatView: View {
@State private var dragStartWidth: Double?
@Environment(\.colorScheme) private var scheme
@AppStorage("nativeTheme") private var themeName = "classic"
+ @AppStorage(ChatTextSize.key) private var chatTextSize = ChatTextSize.defaultSize
private var theme: ChatTheme { .named(themeName) }
private var collapsed: Bool { listWidth < 170 }
@@ -348,6 +349,9 @@ struct NativeChatView: View {
})
.id(thread.id + "|" + thread.threadKeyB64 + "|"
+ String(UInt(bitPattern: ObjectIdentifier(model).hashValue)))
+ // The Text Size pref (View menu ⌘+/⌘−, Settings slider) drives the
+ // feed's reading size — bubbles + composer; the list stays put.
+ .environment(\.chatFontSize, CGFloat(ChatTextSize.snap(chatTextSize)))
}
private func feedHeader(_ t: ChatThread) -> some View {
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift
index c30732e..a320116 100644
--- a/ios/LChatMac/NativeFeedView.swift
+++ b/ios/LChatMac/NativeFeedView.swift
@@ -8,6 +8,7 @@ import AVFoundation
// (via .id above) to the picked thread.
struct NativeFeedView: View {
@StateObject private var model: ChatFeedModel
+ @Environment(\.chatFontSize) private var chatFontSize
private let isGroup: Bool
private let roster: [RosterEntry]
private let myUserId: String
@@ -774,7 +775,7 @@ struct NativeFeedView: View {
handlePaste(providers)
}
.textFieldStyle(.plain)
- .font(.system(size: 16)) // the feed's reading size, like the web composer
+ .font(.system(size: chatFontSize)) // the feed's reading size, like the web composer
.lineLimit(1...9) // grows to ~9 lines, then scrolls within itself
.padding(.horizontal, 14)
.padding(.vertical, 9)
diff --git a/ios/LChatMac/TextSize.swift b/ios/LChatMac/TextSize.swift
new file mode 100644
index 0000000..6003f0e
--- /dev/null
+++ b/ios/LChatMac/TextSize.swift
@@ -0,0 +1,35 @@
+import Foundation
+
+// 🤖 The Mac's chat Text Size preference, in points — the same seven-stop
+// ladder as the web slider (TEXT_SIZES in ui.js). The default sits ONE FROM
+// THE TOP: 16 is the only workable bigger size (beyond that bubbles read as
+// shouting), so the room to taste lies below. One UserDefaults key that
+// @AppStorage views observe; the View-menu commands, the ⌘= key monitor and
+// the Settings slider all step through here so every entry point moves the
+// same ladder.
+enum ChatTextSize {
+ static let stops: [Double] = [10, 11, 12, 13, 14, 15, 16]
+ static let defaultSize: Double = 15
+ static let key = "chatTextSize"
+
+ static var current: Double {
+ let v = UserDefaults.standard.double(forKey: key)
+ return v > 0 ? snap(v) : defaultSize
+ }
+
+ // Nearest stop, so a value stored by a future (or hand-edited) ladder
+ // still lands somewhere sane.
+ static func snap(_ v: Double) -> Double {
+ stops.min(by: { abs($0 - v) < abs($1 - v) }) ?? defaultSize
+ }
+
+ static func bump(_ direction: Int) {
+ guard let i = stops.firstIndex(of: current) else { return }
+ let j = min(max(i + direction, 0), stops.count - 1)
+ UserDefaults.standard.set(stops[j], forKey: key)
+ }
+
+ static func reset() {
+ UserDefaults.standard.set(defaultSize, forKey: key)
+ }
+}
diff --git a/ios/LChatMac/UpdaterUI.swift b/ios/LChatMac/UpdaterUI.swift
index 8cbd033..7671e66 100644
--- a/ios/LChatMac/UpdaterUI.swift
+++ b/ios/LChatMac/UpdaterUI.swift
@@ -53,11 +53,8 @@ struct UpdatesSettingsView: View {
updater.automaticallyDownloadsUpdates = enabled
}
}
- .padding(20)
- // A FIXED frame, matching the General pane's width: SwiftUI's Settings
- // window sizes itself per tab, and a content-sized pane doesn't reliably
- // regrow the window when you switch back to a taller tab — explicit
- // heights make every switch land on the right size.
- .frame(width: 420, height: 100)
+ // Grouped like the General pane; the TabView in LChatMacApp owns the
+ // Settings window's one fixed frame, so no size is pinned here.
+ .formStyle(.grouped)
}
}