From fceaadaabfc510066f8c2ee2831ebbf8c1ec24c0 Mon Sep 17 00:00:00 2001 From: Tyro Sageframe Date: Tue, 28 Jul 2026 19:14:17 -0400 Subject: [PATCH 1/4] Add an accessibility text size for the app chrome There is no in-app way to enlarge the sidebar, tab bar, toolbars, or Settings without changing the OS-wide display scale, which resizes the terminal along with everything else. macOS SwiftUI does not resize text in response to dynamicTypeSize, so the size is applied by resolving each semantic font explicitly: a uiTextScale environment value is published at every window root, and chrome text opts in through appFont(_:weight:monospaced:) in place of .font(.body). The Ghostty terminal surface is NSView-backed with its own font system and is unaffected. The toolbar title publishes the size a second time inside its TerminalSchemeHost closure, because that host re-hosts its content in a fresh NSHostingView rather than inheriting the window's environment. Section headers take appFontInheriting instead. List styles its own headers and SwiftUI does not expose the font it resolved, so there is nothing to scale in place; the modifier leaves them alone at Default and only supplies a point size above that, so the repository name and the Active and Pinned titles grow with the rows beneath them. A missing or unrecognized chromeTextSize falls back to the default rather than throwing, which would reset the rest of the settings file. macOS has no Dynamic Type step table to inherit, so the two steps are chosen to land near where iOS's .xLarge and .xxLarge sit, and scaled point sizes round to whole points. The Accessibility pane holding the picker is reachable at supacode://settings/accessibility and `supacode settings accessibility`, like every other pane. Tests cover the case order and raw-value stability, the decode migration from a file predating the setting, the fallback for an unrecognized value, and that an unrelated settings change no longer resets the size. --- Resources/Skills/supacode-cli.md | 2 +- Resources/Skills/supacode-deeplinks.md | 2 +- .../Reducer/SettingsFeature.swift | 6 +- .../Views/AccessibilitySettingsView.swift | 30 ++++ .../Views/AppVisibilityOptionCardView.swift | 2 +- .../Views/AppearanceOptionCardView.swift | 2 +- .../Views/AppearanceSettingsView.swift | 3 +- .../Views/GlobalScriptsSettingsView.swift | 2 +- .../Views/RepositoryScriptsSettingsView.swift | 6 +- .../Views/RepositorySettingsView.swift | 2 +- .../Views/SettingsSection.swift | 1 + .../Models/ChromeTextSize.swift | 38 +++++ .../Models/GlobalSettings.swift | 17 ++- .../Support/UITextScale.swift | 137 ++++++++++++++++++ supacode-cli/Commands/SettingsCommand.swift | 8 + supacode/App/CLIReferenceView.swift | 5 +- supacode/App/DeeplinkReferenceView.swift | 7 +- supacode/App/supacodeApp.swift | 5 + supacode/Domain/Deeplink.swift | 1 + .../Views/CodingAgentsSidebarCardView.swift | 2 +- .../Features/App/Reducer/AppFeature.swift | 1 + .../App/Views/MenuBarNotificationsMenu.swift | 2 +- .../Views/ArchivedWorktreeRowView.swift | 6 +- .../Views/ArchivedWorktreesDetailView.swift | 6 +- .../Views/CloneRepositoryFormView.swift | 2 +- .../Repositories/Views/EmptyStateView.swift | 6 +- .../Views/GitEnvironmentErrorCardView.swift | 11 +- .../HighlightRelevantOnboardingCardView.swift | 5 +- .../Views/MenuBarOnboardingCardView.swift | 5 +- .../NestedWorktreesOnboardingCardView.swift | 5 +- .../Views/NotificationPopoverView.swift | 7 +- .../Views/PullRequestBadgeView.swift | 3 +- .../Views/PullRequestChecksPopoverView.swift | 16 +- .../RemoteRepositoriesBetaCardView.swift | 9 +- .../Views/RepoSectionHeaderView.swift | 4 + .../Views/SidebarHighlightSectionsView.swift | 1 + .../Repositories/Views/SidebarItemView.swift | 16 +- .../Repositories/Views/SidebarItemsView.swift | 4 +- .../Repositories/Views/SidebarListView.swift | 6 +- ...erminalPersistenceOnboardingCardView.swift | 5 +- .../Views/WorktreeCreationPromptView.swift | 8 +- .../Views/WorktreeDetailTitleView.swift | 6 +- .../Views/WorktreeDetailView.swift | 25 ++-- .../Views/WorktreeLoadingView.swift | 7 +- .../Views/WorktreeStatusInspector.swift | 28 ++-- .../Views/DeveloperSettingsView.swift | 6 +- .../Settings/Views/GithubSettingsView.swift | 8 +- .../Settings/Views/HotkeyRecorderView.swift | 10 +- .../Settings/Views/SettingsView.swift | 4 + .../Settings/Views/ShortcutRowView.swift | 2 +- .../TabBar/Views/TerminalTabLabelView.swift | 2 +- .../TabBar/Views/TerminalTabView.swift | 7 +- .../Views/EmptyTerminalPaneView.swift | 7 +- supacode/Support/SidebarCardView.swift | 7 +- supacodeTests/ChromeTextSizeTests.swift | 31 ++++ supacodeTests/SettingsFeatureTests.swift | 35 +++++ .../SettingsFilePersistenceTests.swift | 64 ++++++++ 57 files changed, 524 insertions(+), 131 deletions(-) create mode 100644 SupacodeSettingsFeature/Views/AccessibilitySettingsView.swift create mode 100644 SupacodeSettingsShared/Models/ChromeTextSize.swift create mode 100644 SupacodeSettingsShared/Support/UITextScale.swift create mode 100644 supacodeTests/ChromeTextSizeTests.swift diff --git a/Resources/Skills/supacode-cli.md b/Resources/Skills/supacode-cli.md index 33f419aa1..6dad6c6a3 100644 --- a/Resources/Skills/supacode-cli.md +++ b/Resources/Skills/supacode-cli.md @@ -144,7 +144,7 @@ supacode repo worktree-new [-r ] [--branch ] [--base ] [--upstrea ### Settings ``` -supacode settings [
] # Open settings (general|notifications|worktrees|developer|shortcuts|scripts|updates|github). +supacode settings [
] # Open settings (general|accessibility|notifications|worktrees|developer|shortcuts|scripts|updates|github). supacode settings repo [-r ] # Open repository settings. supacode settings repo scripts [-r ] # Open repository Scripts settings. ``` diff --git a/Resources/Skills/supacode-deeplinks.md b/Resources/Skills/supacode-deeplinks.md index 6a82351fc..a02d9f575 100644 --- a/Resources/Skills/supacode-deeplinks.md +++ b/Resources/Skills/supacode-deeplinks.md @@ -83,7 +83,7 @@ supacode://repo//worktree/new?branch=&base=&upstream=&f ``` supacode://settings # Open settings. -supacode://settings/
# general|notifications|worktrees|developer|shortcuts|scripts|updates|github. +supacode://settings/
# general|accessibility|notifications|worktrees|developer|shortcuts|scripts|updates|github. supacode://settings/repo/ # Open repository settings. supacode://settings/repo//scripts # Open repository Scripts settings. ``` diff --git a/SupacodeSettingsFeature/Reducer/SettingsFeature.swift b/SupacodeSettingsFeature/Reducer/SettingsFeature.swift index 89d42fdd1..362c3e78b 100644 --- a/SupacodeSettingsFeature/Reducer/SettingsFeature.swift +++ b/SupacodeSettingsFeature/Reducer/SettingsFeature.swift @@ -82,6 +82,7 @@ public struct SettingsFeature { public var remoteSessionPersistenceEnabled: Bool public var appVisibility: AppVisibility public var terminalHibernationEnabled: Bool + public var chromeTextSize: ChromeTextSize public var cliInstallState = CLIInstallState.checking /// Installed editors in menu order, resolved once off the picker's body. public var installedOpenActions: [OpenWorktreeAction] @@ -158,6 +159,7 @@ public struct SettingsFeature { remoteSessionPersistenceEnabled = settings.remoteSessionPersistenceEnabled appVisibility = settings.appVisibility terminalHibernationEnabled = settings.terminalHibernationEnabled + chromeTextSize = settings.chromeTextSize defaultWorktreeBaseDirectoryPath = SupacodePaths.normalizedWorktreeBaseDirectoryPath(settings.defaultWorktreeBaseDirectoryPath) ?? "" } @@ -200,7 +202,8 @@ public struct SettingsFeature { terminateSessionsOnQuit: terminateSessionsOnQuit, remoteSessionPersistenceEnabled: remoteSessionPersistenceEnabled, appVisibility: appVisibility, - terminalHibernationEnabled: terminalHibernationEnabled + terminalHibernationEnabled: terminalHibernationEnabled, + chromeTextSize: chromeTextSize ) } } @@ -340,6 +343,7 @@ public struct SettingsFeature { state.remoteSessionPersistenceEnabled = normalizedSettings.remoteSessionPersistenceEnabled state.appVisibility = normalizedSettings.appVisibility state.terminalHibernationEnabled = normalizedSettings.terminalHibernationEnabled + state.chromeTextSize = normalizedSettings.chromeTextSize state.defaultWorktreeBaseDirectoryPath = normalizedSettings.defaultWorktreeBaseDirectoryPath ?? "" state.syncGlobalDefaults(from: normalizedSettings) synchronizeRepositorySelection(for: &state) diff --git a/SupacodeSettingsFeature/Views/AccessibilitySettingsView.swift b/SupacodeSettingsFeature/Views/AccessibilitySettingsView.swift new file mode 100644 index 000000000..047a8ed75 --- /dev/null +++ b/SupacodeSettingsFeature/Views/AccessibilitySettingsView.swift @@ -0,0 +1,30 @@ +import ComposableArchitecture +import SupacodeSettingsShared +import SwiftUI + +/// Accessibility pane. Holds the chrome text size, which is separate from the +/// terminal's own font zoom. +public struct AccessibilitySettingsView: View { + @Bindable var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + Form { + Section { + Picker(selection: $store.chromeTextSize) { + ForEach(ChromeTextSize.allCases) { size in + Text(size.label).tag(size) + } + } label: { + Text("Text size") + Text("Sizes the sidebar, tab bar, toolbars, and Settings text. The terminal has its own font size.") + } + } + } + .formStyle(.grouped) + .navigationTitle("Accessibility") + } +} diff --git a/SupacodeSettingsFeature/Views/AppVisibilityOptionCardView.swift b/SupacodeSettingsFeature/Views/AppVisibilityOptionCardView.swift index d5a3d9bbc..54446ea55 100644 --- a/SupacodeSettingsFeature/Views/AppVisibilityOptionCardView.swift +++ b/SupacodeSettingsFeature/Views/AppVisibilityOptionCardView.swift @@ -23,7 +23,7 @@ struct AppVisibilityOptionCardView: View { ) } Text(visibility.title) - .font(.callout) + .appFont(.callout) .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) .foregroundStyle(isSelected ? .primary : .secondary) diff --git a/SupacodeSettingsFeature/Views/AppearanceOptionCardView.swift b/SupacodeSettingsFeature/Views/AppearanceOptionCardView.swift index 071ee6d20..507561110 100644 --- a/SupacodeSettingsFeature/Views/AppearanceOptionCardView.swift +++ b/SupacodeSettingsFeature/Views/AppearanceOptionCardView.swift @@ -22,7 +22,7 @@ struct AppearanceOptionCardView: View { ) } Text(mode.title) - .font(.callout) + .appFont(.callout) .foregroundStyle(isSelected ? .primary : .secondary) } } diff --git a/SupacodeSettingsFeature/Views/AppearanceSettingsView.swift b/SupacodeSettingsFeature/Views/AppearanceSettingsView.swift index 9bc9ad1d0..86552f5c0 100644 --- a/SupacodeSettingsFeature/Views/AppearanceSettingsView.swift +++ b/SupacodeSettingsFeature/Views/AppearanceSettingsView.swift @@ -161,8 +161,7 @@ public struct AppearanceSettingsView: View { private struct BetaBadge: View { var body: some View { Text("Beta") - .font(.caption2) - .fontWeight(.semibold) + .appFont(.caption2, weight: .semibold) .foregroundStyle(.secondary) .padding(.horizontal, 6) .padding(.vertical, 2) diff --git a/SupacodeSettingsFeature/Views/GlobalScriptsSettingsView.swift b/SupacodeSettingsFeature/Views/GlobalScriptsSettingsView.swift index d8c60fd19..086d83c07 100644 --- a/SupacodeSettingsFeature/Views/GlobalScriptsSettingsView.swift +++ b/SupacodeSettingsFeature/Views/GlobalScriptsSettingsView.swift @@ -59,7 +59,7 @@ public struct GlobalScriptsSettingsView: View { } header: { Label { Text("\(script.displayName) Script") - .font(.body) + .appFont(.body) .bold() } icon: { Image(systemName: script.resolvedSystemImage) diff --git a/SupacodeSettingsFeature/Views/RepositoryScriptsSettingsView.swift b/SupacodeSettingsFeature/Views/RepositoryScriptsSettingsView.swift index 43b3c3c1c..98aeb2f60 100644 --- a/SupacodeSettingsFeature/Views/RepositoryScriptsSettingsView.swift +++ b/SupacodeSettingsFeature/Views/RepositoryScriptsSettingsView.swift @@ -66,7 +66,7 @@ public struct RepositoryScriptsSettingsView: View { } header: { Label { Text("\(script.displayName) Script") - .font(.body) + .appFont(.body) .bold() } icon: { Image(systemName: script.resolvedSystemImage).foregroundStyle(script.resolvedTintColor.color) @@ -134,11 +134,11 @@ private struct LifecycleScriptSection: View { Label { VStack(alignment: .leading, spacing: 0) { Text(title) - .font(.body) + .appFont(.body) .bold() .lineLimit(1) Text(subtitle) - .font(.footnote) + .appFont(.footnote) .foregroundStyle(.secondary) .lineLimit(1) } diff --git a/SupacodeSettingsFeature/Views/RepositorySettingsView.swift b/SupacodeSettingsFeature/Views/RepositorySettingsView.swift index 4727b6c41..f77633e25 100644 --- a/SupacodeSettingsFeature/Views/RepositorySettingsView.swift +++ b/SupacodeSettingsFeature/Views/RepositorySettingsView.swift @@ -64,7 +64,7 @@ public struct RepositorySettingsView: View { .disabled(store.isBareRepository) if store.isBareRepository { Text("Copy flags are ignored for bare repositories.") - .font(.footnote) + .appFont(.footnote) .foregroundStyle(.tertiary) } TextField( diff --git a/SupacodeSettingsFeature/Views/SettingsSection.swift b/SupacodeSettingsFeature/Views/SettingsSection.swift index f1019f171..732bd858f 100644 --- a/SupacodeSettingsFeature/Views/SettingsSection.swift +++ b/SupacodeSettingsFeature/Views/SettingsSection.swift @@ -2,6 +2,7 @@ import Foundation public enum SettingsSection: Hashable { case general + case accessibility case notifications case worktree case developer diff --git a/SupacodeSettingsShared/Models/ChromeTextSize.swift b/SupacodeSettingsShared/Models/ChromeTextSize.swift new file mode 100644 index 000000000..59d1b4ce5 --- /dev/null +++ b/SupacodeSettingsShared/Models/ChromeTextSize.swift @@ -0,0 +1,38 @@ +import Foundation + +/// Size of the app chrome's text: the sidebar, tab bar, toolbars, and Settings. +/// A fixed set of sizes rather than a free-form scale, so the layout only has to +/// hold at three known points. +/// +/// The size is applied by resolving each semantic font explicitly (see +/// `View.appFont`), because macOS SwiftUI does not resize text in response to +/// `dynamicTypeSize`. The Ghostty terminal surface is NSView-backed with its own +/// font system and is unaffected. +public nonisolated enum ChromeTextSize: String, CaseIterable, Codable, Identifiable, Sendable { + case standard + case large + case extraLarge + + public static let `default` = ChromeTextSize.standard + + public var id: String { rawValue } + + public var label: String { + switch self { + case .standard: "Default" + case .large: "Large" + case .extraLarge: "Extra Large" + } + } + + /// Multiplier applied to each semantic font's system point size. macOS has no + /// Dynamic Type step table to inherit, so the two steps are chosen to land + /// near where iOS's `.xLarge` and `.xxLarge` sit. + public var scale: Double { + switch self { + case .standard: 1.0 + case .large: 1.15 + case .extraLarge: 1.3 + } + } +} diff --git a/SupacodeSettingsShared/Models/GlobalSettings.swift b/SupacodeSettingsShared/Models/GlobalSettings.swift index bb665163a..10ff26135 100644 --- a/SupacodeSettingsShared/Models/GlobalSettings.swift +++ b/SupacodeSettingsShared/Models/GlobalSettings.swift @@ -104,6 +104,9 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { /// Beta: hidden terminal tabs release their renderer after a few minutes of /// inactivity and reconnect when viewed. On by default. public var terminalHibernationEnabled: Bool + /// Accessibility size for the app chrome's text. Drives the scale published at + /// each window root. Defaults to the unmodified system size. + public var chromeTextSize: ChromeTextSize public static let `default` = GlobalSettings( appearanceMode: .dark, @@ -139,7 +142,8 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { confirmCloseSurface: true, terminateSessionsOnQuit: false, remoteSessionPersistenceEnabled: true, - appVisibility: .dockAndMenuBar + appVisibility: .dockAndMenuBar, + chromeTextSize: .default ) public init( @@ -177,7 +181,8 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { terminateSessionsOnQuit: Bool = false, remoteSessionPersistenceEnabled: Bool = true, appVisibility: AppVisibility = .dockAndMenuBar, - terminalHibernationEnabled: Bool = true + terminalHibernationEnabled: Bool = true, + chromeTextSize: ChromeTextSize = .default ) { self.appearanceMode = appearanceMode self.defaultEditorID = defaultEditorID @@ -214,6 +219,7 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { self.remoteSessionPersistenceEnabled = remoteSessionPersistenceEnabled self.appVisibility = appVisibility self.terminalHibernationEnabled = terminalHibernationEnabled + self.chromeTextSize = chromeTextSize } /// Keys for reading renamed settings fields that no longer @@ -388,5 +394,12 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { terminalHibernationEnabled = try container.decodeIfPresent(Bool.self, forKey: .terminalHibernationEnabled) ?? Self.default.terminalHibernationEnabled + // Old settings files predate this key; they migrate to the system size. An + // unrecognized value falls back the same way rather than throwing, which + // would reset the whole file to defaults. + chromeTextSize = + ((try? container.decodeIfPresent(String.self, forKey: .chromeTextSize)) ?? nil) + .flatMap(ChromeTextSize.init(rawValue:)) + ?? Self.default.chromeTextSize } } diff --git a/SupacodeSettingsShared/Support/UITextScale.swift b/SupacodeSettingsShared/Support/UITextScale.swift new file mode 100644 index 000000000..5e5b4e337 --- /dev/null +++ b/SupacodeSettingsShared/Support/UITextScale.swift @@ -0,0 +1,137 @@ +import AppKit +import SwiftUI + +/// Environment carrier for the app-wide UI text scale (1.0 = system default). +/// Set at each window root from `GlobalSettings.chromeTextSize`; read by +/// `View.appFont(_:)`. Using an explicit scale (rather than `dynamicTypeSize`) +/// is required because macOS SwiftUI does not resize text for Dynamic Type. +/// +/// Lives in the shared module so every UI module (app + settings feature) routes +/// its chrome text through the same helper. +private struct UITextScaleKey: EnvironmentKey { + static let defaultValue: Double = 1.0 +} + +extension EnvironmentValues { + public var uiTextScale: Double { + get { self[UITextScaleKey.self] } + set { self[UITextScaleKey.self] = newValue } + } +} + +/// Base point sizes and default weights for the semantic text styles, read from +/// the live system fonts so scaled sizes track the platform instead of a +/// hardcoded table. Read once: macOS resolves the text-style table at launch and +/// does not change it while the app runs, and the scaled path would otherwise +/// hit AppKit on every body evaluation of every scaled view. +private enum AppFontMetrics { + private static let pointSizes: [Font.TextStyle: CGFloat] = { + let styles: [Font.TextStyle] = [ + .largeTitle, .title, .title2, .title3, .headline, .subheadline, + .body, .callout, .footnote, .caption, .caption2, + ] + return Dictionary( + uniqueKeysWithValues: styles.map { + ($0, NSFont.preferredFont(forTextStyle: nsTextStyle(for: $0)).pointSize) + } + ) + }() + + static func pointSize(for style: Font.TextStyle) -> CGFloat { + pointSizes[style] ?? NSFont.preferredFont(forTextStyle: .body).pointSize + } + + /// The semantic style's default weight, preserved when the caller doesn't + /// override it. Only `.headline` deviates from regular on macOS. + static func defaultWeight(for style: Font.TextStyle) -> Font.Weight { + style == .headline ? .semibold : .regular + } + + private static func nsTextStyle(for style: Font.TextStyle) -> NSFont.TextStyle { + switch style { + case .largeTitle: .largeTitle + case .title: .title1 + case .title2: .title2 + case .title3: .title3 + case .headline: .headline + case .subheadline: .subheadline + case .body: .body + case .callout: .callout + case .footnote: .footnote + case .caption: .caption1 + case .caption2: .caption2 + @unknown default: .body + } + } +} + +private struct AppFontModifier: ViewModifier { + @Environment(\.uiTextScale) private var scale + let style: Font.TextStyle + let weight: Font.Weight? + let monospaced: Bool + + func body(content: Content) -> some View { + content.font(resolvedFont) + } + + private var resolvedFont: Font { + // At 1.0× use the exact semantic font, so Default leaves text untouched. + if scale == 1.0 { + var font = Font.system(style) + if let weight { font = font.weight(weight) } + if monospaced { font = font.monospaced() } + return font + } + // Rounded: a fractional point size gives a fractional line height, which + // drifts baselines across the sidebar's dense rows. + let scaledSize = (AppFontMetrics.pointSize(for: style) * scale).rounded() + let font = Font.system(size: scaledSize, design: monospaced ? .monospaced : .default) + return font.weight(weight ?? AppFontMetrics.defaultWeight(for: style)) + } +} + +/// Scales text that has no font of its own. `List` styles its own section +/// headers and SwiftUI does not expose the font it resolved, so there is nothing +/// to scale in place — `base` stands in for it. At 1.0× the view is left alone, +/// which keeps the platform header styling as it ships. +private struct AppFontInheritedModifier: ViewModifier { + @Environment(\.uiTextScale) private var scale + let base: Font.TextStyle + let weight: Font.Weight? + + func body(content: Content) -> some View { + if scale == 1.0 { + content + } else { + content.font( + Font.system(size: (AppFontMetrics.pointSize(for: base) * scale).rounded()) + .weight(weight ?? AppFontMetrics.defaultWeight(for: base)) + ) + } + } +} + +extension View { + /// Publishes the chrome text size to descendants. Applied at each window root; + /// text opts in with `.appFont(_:)`. + public func appChromeTextSize(_ size: ChromeTextSize) -> some View { + environment(\.uiTextScale, size.scale) + } + + /// Applies a semantic text style that honors the chrome text size. Use this in + /// place of `.font(.body)` etc. for chrome text that should follow the user's + /// chosen size. `weight` overrides the style's default weight; `monospaced` + /// picks the monospaced design. + public func appFont(_ style: Font.TextStyle, weight: Font.Weight? = nil, monospaced: Bool = false) -> some View { + modifier(AppFontModifier(style: style, weight: weight, monospaced: monospaced)) + } + + /// Scales text whose font comes from an enclosing container rather than from + /// this view — the sidebar's `Section` headers. At 1.0× the container's + /// styling is left in place; `base` only sets the point size the text grows + /// from above that. + public func appFontInheriting(_ base: Font.TextStyle, weight: Font.Weight? = nil) -> some View { + modifier(AppFontInheritedModifier(base: base, weight: weight)) + } +} diff --git a/supacode-cli/Commands/SettingsCommand.swift b/supacode-cli/Commands/SettingsCommand.swift index 138563074..677bd182c 100644 --- a/supacode-cli/Commands/SettingsCommand.swift +++ b/supacode-cli/Commands/SettingsCommand.swift @@ -6,6 +6,7 @@ struct SettingsCommand: ParsableCommand { abstract: "Open Supacode settings.", subcommands: [ General.self, + Accessibility.self, Notifications.self, Worktrees.self, Developer.self, @@ -31,6 +32,7 @@ extension SettingsCommand { /// Raw values must match `Deeplink.DeeplinkSettingsSection` on the app side. fileprivate enum Section: String { case general + case accessibility case notifications case worktrees case developer @@ -46,6 +48,12 @@ extension SettingsCommand { func run() throws { try dispatchSettings(.general, timeoutSeconds: timeoutOption.timeout) } } + struct Accessibility: ParsableCommand { + static let configuration = CommandConfiguration(abstract: "Open Accessibility settings.") + @OptionGroup var timeoutOption: TimeoutOption + func run() throws { try dispatchSettings(.accessibility, timeoutSeconds: timeoutOption.timeout) } + } + struct Notifications: ParsableCommand { static let configuration = CommandConfiguration(abstract: "Open Notifications settings.") @OptionGroup var timeoutOption: TimeoutOption diff --git a/supacode/App/CLIReferenceView.swift b/supacode/App/CLIReferenceView.swift index abc52838c..021b1296b 100644 --- a/supacode/App/CLIReferenceView.swift +++ b/supacode/App/CLIReferenceView.swift @@ -1,3 +1,4 @@ +import SupacodeSettingsShared import SwiftUI struct CLIReferenceView: View { @@ -19,7 +20,7 @@ struct CLIReferenceView: View { .foregroundStyle(.secondary) // swiftlint:enable line_length } header: { - Text("CLI Reference").font(.title.bold()) + Text("CLI Reference").appFont(.title, weight: .bold) Text("Control Supacode from the terminal.") } @@ -185,7 +186,7 @@ private struct CLISection: View { ForEach(rows) { row in GridRow { Text(row.command) - .font(.body.monospaced()) + .appFont(.body, monospaced: true) .gridColumnAlignment(.leading) Text(row.description) .foregroundStyle(.secondary) diff --git a/supacode/App/DeeplinkReferenceView.swift b/supacode/App/DeeplinkReferenceView.swift index 74a81b33b..ce7fe19d4 100644 --- a/supacode/App/DeeplinkReferenceView.swift +++ b/supacode/App/DeeplinkReferenceView.swift @@ -1,4 +1,5 @@ import ComposableArchitecture +import SupacodeSettingsShared import SwiftUI struct DeeplinkReferenceView: View { @@ -26,7 +27,7 @@ struct DeeplinkReferenceView: View { ) .foregroundStyle(.secondary) } header: { - Text("Deeplink Reference").font(.title.bold()) + Text("Deeplink Reference").appFont(.title, weight: .bold) Text("Use the \(code("supacode://")) URL scheme to control Supacode from the terminal, scripts, or other apps.") } @@ -129,7 +130,7 @@ struct DeeplinkReferenceView: View { .init( url: "supacode://settings/
", description: "Open a specific section.", - params: "general|notifications|worktrees|developer|shortcuts|scripts|updates|github" + params: "general|accessibility|notifications|worktrees|developer|shortcuts|scripts|updates|github" ), .init(url: "supacode://settings/repo/", description: "Open repository settings."), .init( @@ -163,7 +164,7 @@ private struct DeeplinkSection: View { ForEach(rows) { row in GridRow { Text(row.url) - .font(.body.monospaced()) + .appFont(.body, monospaced: true) .gridColumnAlignment(.leading) row.descriptionText .foregroundStyle(.secondary) diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index 10e104011..005e4e7bb 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -539,6 +539,7 @@ struct SupacodeApp: App { .environment(ghosttyShortcuts) .environment(commandKeyObserver) .environment(openActionIcons) + .appChromeTextSize(store.settings.chromeTextSize) } .openSettingsOnSelection(store: store) .openDeeplinkReferenceOnRequest(store: store) @@ -601,6 +602,7 @@ struct SupacodeApp: App { SettingsView(store: store) .environment(ghosttyShortcuts) .environment(commandKeyObserver) + .appChromeTextSize(store.settings.chromeTextSize) .toolbarBackground(.hidden, for: .windowToolbar) .toolbarColorScheme(store.settings.appearanceMode.colorScheme, for: .windowToolbar) .movesSettingsWindowToActiveSpace() @@ -611,6 +613,7 @@ struct SupacodeApp: App { .restorationBehavior(.disabled) Window("Deeplink Reference", id: WindowID.deeplinkReference) { DeeplinkReferenceView() + .appChromeTextSize(store.settings.chromeTextSize) } .handlesExternalEvents(matching: []) .windowToolbarStyle(.unified) @@ -618,6 +621,7 @@ struct SupacodeApp: App { .restorationBehavior(.disabled) Window("CLI Reference", id: WindowID.cliReference) { CLIReferenceView() + .appChromeTextSize(store.settings.chromeTextSize) } .handlesExternalEvents(matching: []) .windowToolbarStyle(.unified) @@ -625,6 +629,7 @@ struct SupacodeApp: App { .restorationBehavior(.disabled) MenuBarExtra(isInserted: menuBarInserted) { MenuBarNotificationsMenu(store: store) + .appChromeTextSize(store.settings.chromeTextSize) } label: { MenuBarNotificationsLabel(unreadCount: store.notificationIndicatorCount) } diff --git a/supacode/Domain/Deeplink.swift b/supacode/Domain/Deeplink.swift index 8ac97d6a8..0f45762c2 100644 --- a/supacode/Domain/Deeplink.swift +++ b/supacode/Domain/Deeplink.swift @@ -58,6 +58,7 @@ enum Deeplink: Equatable, Sendable { /// Settings sections reachable via deeplink. enum DeeplinkSettingsSection: String, Equatable, Sendable { case general + case accessibility case notifications case worktrees case developer diff --git a/supacode/Features/AgentPresence/Views/CodingAgentsSidebarCardView.swift b/supacode/Features/AgentPresence/Views/CodingAgentsSidebarCardView.swift index 97819eacd..64dc00036 100644 --- a/supacode/Features/AgentPresence/Views/CodingAgentsSidebarCardView.swift +++ b/supacode/Features/AgentPresence/Views/CodingAgentsSidebarCardView.swift @@ -97,7 +97,7 @@ private struct CodingAgentsCardBody: View { openWindow(id: WindowID.settings) } .buttonStyle(.link) - .font(.caption) + .appFont(.caption) .padding(.top, 2) } } header: { diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 3e9fc609d..7d01c3082 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -3356,6 +3356,7 @@ struct AppFeature { let settingsSection: SettingsSection = switch section { case .general: .general + case .accessibility: .accessibility case .notifications: .notifications case .worktrees: .worktree case .developer: .developer diff --git a/supacode/Features/App/Views/MenuBarNotificationsMenu.swift b/supacode/Features/App/Views/MenuBarNotificationsMenu.swift index 7fd6c049e..719eca716 100644 --- a/supacode/Features/App/Views/MenuBarNotificationsMenu.swift +++ b/supacode/Features/App/Views/MenuBarNotificationsMenu.swift @@ -209,7 +209,7 @@ private struct MenuBarSectionHeader: View { var body: some View { HStack(spacing: 4) { Text(title) - .font(.caption) + .appFont(.caption) .fontWeight(.semibold) .foregroundStyle(.secondary) if let dotColor { diff --git a/supacode/Features/Repositories/Views/ArchivedWorktreeRowView.swift b/supacode/Features/Repositories/Views/ArchivedWorktreeRowView.swift index 6a5515c14..2b64510c6 100644 --- a/supacode/Features/Repositories/Views/ArchivedWorktreeRowView.swift +++ b/supacode/Features/Repositories/Views/ArchivedWorktreeRowView.swift @@ -28,7 +28,7 @@ struct ArchivedWorktreeRowView: View { VStack(alignment: .leading, spacing: 2) { HStack(alignment: .firstTextBaseline, spacing: 8) { Image(systemName: "archivebox") - .font(.caption) + .appFont(.caption) .foregroundStyle(.secondary) .accessibilityHidden(true) .frame(width: 16, height: 16) @@ -36,7 +36,7 @@ struct ArchivedWorktreeRowView: View { bodyFontAscender } let titleText = Text(displayName) - .font(.body) + .appFont(.body) .lineLimit(1) if let customTint, backgroundProminence != .increased { titleText.foregroundStyle(customTint.color) @@ -71,7 +71,7 @@ struct ArchivedWorktreeRowView: View { Spacer(minLength: 0) WorktreePullRequestAccessoryView(display: display) } - .font(.caption) + .appFont(.caption) .lineLimit(1) .frame(minHeight: 14) .padding(.leading, 24) diff --git a/supacode/Features/Repositories/Views/ArchivedWorktreesDetailView.swift b/supacode/Features/Repositories/Views/ArchivedWorktreesDetailView.swift index e497a75ec..557873c95 100644 --- a/supacode/Features/Repositories/Views/ArchivedWorktreesDetailView.swift +++ b/supacode/Features/Repositories/Views/ArchivedWorktreesDetailView.swift @@ -137,16 +137,16 @@ private struct ArchivedWorktreeSectionHeader: View { } label: { HStack(spacing: 6) { Image(systemName: "chevron.right") - .font(.caption2) + .appFont(.caption2) .rotationEffect(.degrees(isCollapsed ? 0 : 90)) .foregroundStyle(.secondary) .accessibilityHidden(true) Text(name) - .font(.headline) + .appFont(.headline) .foregroundStyle(.primary) .lineLimit(1) Text("(\(worktreeCount))") - .font(.headline) + .appFont(.headline) .foregroundStyle(.secondary) Spacer() } diff --git a/supacode/Features/Repositories/Views/CloneRepositoryFormView.swift b/supacode/Features/Repositories/Views/CloneRepositoryFormView.swift index 4d8275551..74d0cbe54 100644 --- a/supacode/Features/Repositories/Views/CloneRepositoryFormView.swift +++ b/supacode/Features/Repositories/Views/CloneRepositoryFormView.swift @@ -67,7 +67,7 @@ struct CloneRepositoryFormView: View { ProgressView().controlSize(.small) if let progress = store.progressLine, !progress.isEmpty { Text(store.compactProgressLine ?? progress) - .font(.callout) + .appFont(.callout) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.middle) diff --git a/supacode/Features/Repositories/Views/EmptyStateView.swift b/supacode/Features/Repositories/Views/EmptyStateView.swift index d67108d25..4a6865a69 100644 --- a/supacode/Features/Repositories/Views/EmptyStateView.swift +++ b/supacode/Features/Repositories/Views/EmptyStateView.swift @@ -12,18 +12,18 @@ struct EmptyStateView: View { VStack(spacing: 12) { Image(systemName: "tray") - .font(.title) + .appFont(.title) .imageScale(.large) .accessibilityHidden(true) .foregroundStyle(.secondary) VStack(spacing: 4) { Text("Open a repository or folder") - .font(.title3) + .appFont(.title3) Text( "Press \(openRepo?.display ?? AppShortcuts.openRepository.display) " + "or click Open Repository or Folder to choose one." ) - .font(.subheadline) + .appFont(.subheadline) .foregroundStyle(.secondary) } Button("Open Repository or Folder...") { diff --git a/supacode/Features/Repositories/Views/GitEnvironmentErrorCardView.swift b/supacode/Features/Repositories/Views/GitEnvironmentErrorCardView.swift index 2737cd569..96a96f27b 100644 --- a/supacode/Features/Repositories/Views/GitEnvironmentErrorCardView.swift +++ b/supacode/Features/Repositories/Views/GitEnvironmentErrorCardView.swift @@ -1,4 +1,5 @@ import AppKit +import SupacodeSettingsShared import SwiftUI /// Non-dismissible sidebar banner shown while the `git` binary is blocked at the @@ -18,7 +19,7 @@ struct GitEnvironmentErrorCardView: View { private struct GitEnvironmentErrorCardIcon: View { var body: some View { Image(systemName: "exclamationmark.triangle.fill") - .font(.title2) + .appFont(.title2) .foregroundStyle(.orange) .accessibilityHidden(true) } @@ -30,10 +31,10 @@ private struct GitEnvironmentErrorCardContent: View { var body: some View { VStack(alignment: .leading, spacing: 8) { Text(error.title) - .font(.subheadline) + .appFont(.subheadline) .fontWeight(.semibold) Text(error.message) - .font(.caption) + .appFont(.caption) .foregroundStyle(.secondary) GitEnvironmentRemedyRow(command: error.remedyCommand) } @@ -46,7 +47,7 @@ private struct GitEnvironmentRemedyRow: View { var body: some View { HStack(spacing: 6) { Text(command) - .font(.caption.monospaced()) + .appFont(.caption, monospaced: true) .textSelection(.enabled) .lineLimit(1) // Truncate the tail so the leading verb (sudo / xcode-select) stays @@ -71,7 +72,7 @@ private struct CopyCommandButton: View { pasteboard.setString(command, forType: .string) } label: { Image(systemName: "doc.on.doc") - .font(.caption) + .appFont(.caption) .contentShape(.rect) } .buttonStyle(.plain) diff --git a/supacode/Features/Repositories/Views/HighlightRelevantOnboardingCardView.swift b/supacode/Features/Repositories/Views/HighlightRelevantOnboardingCardView.swift index 262e658fe..7102d25ab 100644 --- a/supacode/Features/Repositories/Views/HighlightRelevantOnboardingCardView.swift +++ b/supacode/Features/Repositories/Views/HighlightRelevantOnboardingCardView.swift @@ -1,4 +1,5 @@ import Sharing +import SupacodeSettingsShared import SwiftUI /// Bottom-of-sidebar onboarding card surfacing the new "Highlight Relevant @@ -54,14 +55,14 @@ private struct HighlightRelevantOnboardingCardBody: View { VStack(alignment: .leading, spacing: 4) { SidebarCardLabel(title: "Pinned and Active at a glance", description: description) Text("Toggle in View → Group Relevant Sidebar Rows") - .font(.caption2) + .appFont(.caption2) .foregroundStyle(.tertiary) .padding(.top, 2) } }, header: { Image(systemName: "sparkles") - .font(.title2) + .appFont(.title2) .foregroundStyle(.orange) .accessibilityHidden(true) } diff --git a/supacode/Features/Repositories/Views/MenuBarOnboardingCardView.swift b/supacode/Features/Repositories/Views/MenuBarOnboardingCardView.swift index 80c19ed9e..64b4c3dcc 100644 --- a/supacode/Features/Repositories/Views/MenuBarOnboardingCardView.swift +++ b/supacode/Features/Repositories/Views/MenuBarOnboardingCardView.swift @@ -1,4 +1,5 @@ import Sharing +import SupacodeSettingsShared import SwiftUI /// Bottom-of-sidebar onboarding card announcing that Supacode now lives in the @@ -45,14 +46,14 @@ private struct MenuBarOnboardingCardBody: View { VStack(alignment: .leading, spacing: 4) { SidebarCardLabel(title: "Supacode in the menu bar", description: description) Text("Turn off in Settings → General") - .font(.caption2) + .appFont(.caption2) .foregroundStyle(.tertiary) .padding(.top, 2) } }, header: { Image(systemName: "menubar.rectangle") - .font(.title2) + .appFont(.title2) .foregroundStyle(.green) .accessibilityHidden(true) } diff --git a/supacode/Features/Repositories/Views/NestedWorktreesOnboardingCardView.swift b/supacode/Features/Repositories/Views/NestedWorktreesOnboardingCardView.swift index 422e518d9..81ea69b8e 100644 --- a/supacode/Features/Repositories/Views/NestedWorktreesOnboardingCardView.swift +++ b/supacode/Features/Repositories/Views/NestedWorktreesOnboardingCardView.swift @@ -1,4 +1,5 @@ import Sharing +import SupacodeSettingsShared import SwiftUI /// Pinned bottom-of-sidebar onboarding card surfacing the new branch-nesting @@ -48,14 +49,14 @@ private struct NestedWorktreesOnboardingCardBody: View { VStack(alignment: .leading, spacing: 4) { SidebarCardLabel(title: "Worktrees nested by branch", description: description) Text("Toggle in View → Nest Worktrees by Branch") - .font(.caption2) + .appFont(.caption2) .foregroundStyle(.tertiary) .padding(.top, 2) } }, header: { Image(systemName: "list.bullet.indent") - .font(.title2) + .appFont(.title2) .foregroundStyle(.blue) .accessibilityHidden(true) } diff --git a/supacode/Features/Repositories/Views/NotificationPopoverView.swift b/supacode/Features/Repositories/Views/NotificationPopoverView.swift index 6a6e789c7..e8b5085f7 100644 --- a/supacode/Features/Repositories/Views/NotificationPopoverView.swift +++ b/supacode/Features/Repositories/Views/NotificationPopoverView.swift @@ -1,3 +1,4 @@ +import SupacodeSettingsShared import SwiftUI struct NotificationPopoverView: View { @@ -10,9 +11,9 @@ struct NotificationPopoverView: View { ScrollView { VStack(alignment: .leading) { Text("Notifications") - .font(.headline) + .appFont(.headline) Text("\(count) \(countLabel)") - .font(.subheadline) + .appFont(.subheadline) .foregroundStyle(.secondary) Divider() ForEach(notifications) { notification in @@ -30,7 +31,7 @@ struct NotificationPopoverView: View { .frame(maxWidth: .infinity, alignment: .leading) } .buttonStyle(.plain) - .font(.caption) + .appFont(.caption) .help(notification.content.isEmpty ? "Focus pane" : notification.content) } } diff --git a/supacode/Features/Repositories/Views/PullRequestBadgeView.swift b/supacode/Features/Repositories/Views/PullRequestBadgeView.swift index db1821ee6..f1ac8e46a 100644 --- a/supacode/Features/Repositories/Views/PullRequestBadgeView.swift +++ b/supacode/Features/Repositories/Views/PullRequestBadgeView.swift @@ -1,3 +1,4 @@ +import SupacodeSettingsShared import SwiftUI enum PullRequestBadgeStyle { @@ -38,7 +39,7 @@ struct PullRequestBadgeView: View { var body: some View { Text(text) - .font(.caption2) + .appFont(.caption2) .foregroundStyle(color) .padding(.horizontal, 6) .padding(.vertical, 2) diff --git a/supacode/Features/Repositories/Views/PullRequestChecksPopoverView.swift b/supacode/Features/Repositories/Views/PullRequestChecksPopoverView.swift index f05e75dc7..1561b3c0e 100644 --- a/supacode/Features/Repositories/Views/PullRequestChecksPopoverView.swift +++ b/supacode/Features/Repositories/Views/PullRequestChecksPopoverView.swift @@ -67,14 +67,14 @@ struct PullRequestChecksPopoverView: View { .focusable(false) .help("Open pull request on GitHub (\(effectiveOpenPR?.display ?? "none"))") .appKeyboardShortcut(effectiveOpenPR) - .font(.headline) + .appFont(.headline) } else { titleLine .lineLimit(1) - .font(.headline) + .appFont(.headline) } summaryLine - .font(.subheadline) + .appFont(.subheadline) .lineLimit(1) HStack { additionsText @@ -88,7 +88,7 @@ struct PullRequestChecksPopoverView: View { .foregroundStyle(.red) } } - .font(.subheadline) + .appFont(.subheadline) if let mergeQueueStatus = PullRequestMergeQueueStatus(pullRequest: pullRequest) { PullRequestMergeQueueRow(status: mergeQueueStatus) @@ -100,7 +100,7 @@ struct PullRequestChecksPopoverView: View { Text(breakdown.summaryText) .foregroundStyle(.secondary) } - .font(.caption) + .appFont(.caption) } if !sortedChecks.isEmpty { @@ -131,7 +131,7 @@ struct PullRequestChecksPopoverView: View { Text(style.label) .foregroundStyle(.secondary) } - .font(.caption) + .appFont(.caption) } } } @@ -157,10 +157,10 @@ struct PullRequestChecksPopoverView: View { Text(status.summary) .foregroundStyle(.brown) } - .font(.subheadline) + .appFont(.subheadline) if let detail = status.detail { Text(detail) - .font(.caption) + .appFont(.caption) .foregroundStyle(.secondary) } } diff --git a/supacode/Features/Repositories/Views/RemoteRepositoriesBetaCardView.swift b/supacode/Features/Repositories/Views/RemoteRepositoriesBetaCardView.swift index 97d9590e5..311174274 100644 --- a/supacode/Features/Repositories/Views/RemoteRepositoriesBetaCardView.swift +++ b/supacode/Features/Repositories/Views/RemoteRepositoriesBetaCardView.swift @@ -1,4 +1,5 @@ import Sharing +import SupacodeSettingsShared import SwiftUI /// Bottom-of-sidebar card announcing the remote SSH repositories feature, marked @@ -41,18 +42,18 @@ private struct RemoteRepositoriesBetaCardBody: View { VStack(alignment: .leading, spacing: 4) { HStack(spacing: 6) { Text("Remote repositories") - .font(.subheadline) + .appFont(.subheadline) .fontWeight(.semibold) BetaBadge() } Text(description) - .font(.caption) + .appFont(.caption) .foregroundStyle(.secondary) } }, header: { Image(systemName: "wifi") - .font(.title2) + .appFont(.title2) .foregroundStyle(.teal) .accessibilityHidden(true) } @@ -71,7 +72,7 @@ private struct RemoteRepositoriesBetaCardBody: View { private struct BetaBadge: View { var body: some View { Text("Beta") - .font(.caption2) + .appFont(.caption2) .fontWeight(.semibold) .foregroundStyle(.teal) .padding(.horizontal, 6) diff --git a/supacode/Features/Repositories/Views/RepoSectionHeaderView.swift b/supacode/Features/Repositories/Views/RepoSectionHeaderView.swift index 852cc01d1..b7532adb2 100644 --- a/supacode/Features/Repositories/Views/RepoSectionHeaderView.swift +++ b/supacode/Features/Repositories/Views/RepoSectionHeaderView.swift @@ -28,6 +28,10 @@ struct RepoSectionHeaderView: View { .accessibilityLabel("Remote host \(hostInfo)") } } + // The repository name is the sidebar's primary label, so it has to follow + // the text scale like the rows beneath it. As a `Section` header it draws + // with the list's font rather than one of its own. + .appFontInheriting(.subheadline, weight: .semibold) if isRemoving { ProgressView() .controlSize(.small) diff --git a/supacode/Features/Repositories/Views/SidebarHighlightSectionsView.swift b/supacode/Features/Repositories/Views/SidebarHighlightSectionsView.swift index 35fbb2c08..a4e20bbed 100644 --- a/supacode/Features/Repositories/Views/SidebarHighlightSectionsView.swift +++ b/supacode/Features/Repositories/Views/SidebarHighlightSectionsView.swift @@ -32,6 +32,7 @@ struct SidebarHighlightSection: View { Text(kind.title) SidebarHighlightHeaderDot(color: kind.indicatorColor) } + .appFontInheriting(.subheadline, weight: .semibold) } } } diff --git a/supacode/Features/Repositories/Views/SidebarItemView.swift b/supacode/Features/Repositories/Views/SidebarItemView.swift index e0378d1a4..42c1825ac 100644 --- a/supacode/Features/Repositories/Views/SidebarItemView.swift +++ b/supacode/Features/Repositories/Views/SidebarItemView.swift @@ -292,7 +292,7 @@ private struct TitleView: View, Equatable { let accentStyle = accent.shapeStyle(emphasized: isEmphasized) VStack(alignment: .leading, spacing: 0) { let titleText = Text(name) - .font(.body) + .appFont(.body) .lineLimit(1) if let customTint, !isEmphasized { titleText.foregroundStyle(customTint.color).shimmer(isActive: isBusy) @@ -304,7 +304,7 @@ private struct TitleView: View, Equatable { EmptyView() case .plain(let text): Text(text) - .font(.footnote) + .appFont(.footnote) .foregroundStyle(accentStyle) .lineLimit(1) case .highlight(let repo, let repoColor, let trail, let hostInfo): @@ -336,7 +336,7 @@ private struct TitleView: View, Equatable { .lineLimit(1) } } - .font(.footnote) + .appFont(.footnote) .accessibilityElement(children: .combine) .accessibilityLabel(trail.map { "\(repo), \($0)" } ?? repo) } @@ -529,7 +529,7 @@ private struct TrailingView: View { if store.kind == .folder, let host = store.host { Image(systemName: "wifi") .imageScale(.small) - .font(.subheadline) + .appFont(.subheadline) .foregroundStyle(.secondary) .help(host.displayAuthority) .accessibilityLabel("Remote host \(host.displayAuthority)") @@ -564,7 +564,7 @@ private struct TrailingView: View { .allowsHitTesting(!hasHint) Text(shortcutHint ?? "") - .font(.caption) + .appFont(.caption) .foregroundStyle(.secondary) .opacity(hasHint ? 1 : 0) } @@ -580,7 +580,7 @@ private struct SidebarDormantIndicator: View, Equatable { // descending tail skews the optical center; compensate to match the wifi glyph. Image(systemName: "zzz") .imageScale(.small) - .font(.subheadline.weight(.semibold)) + .appFont(.subheadline, weight: .semibold) .offset(y: 0.5) .foregroundStyle(.secondary) .help("Hibernated to save resources. Select to reconnect.") @@ -593,7 +593,7 @@ private struct PullRequestBadgeContent: View, Equatable { var body: some View { Text(text) - .font(.caption) + .appFont(.caption) .foregroundStyle(.secondary) .transition(.blurReplace) } @@ -625,7 +625,7 @@ private struct DiffStatsContent: View, Equatable { Text("-\(removedLines)") .foregroundStyle(isEmphasized ? AnyShapeStyle(.secondary) : AnyShapeStyle(.red)) } - .font(.caption) + .appFont(.caption) .monospacedDigit() .transition(.blurReplace) } diff --git a/supacode/Features/Repositories/Views/SidebarItemsView.swift b/supacode/Features/Repositories/Views/SidebarItemsView.swift index 1f338e07f..5cd91a0fa 100644 --- a/supacode/Features/Repositories/Views/SidebarItemsView.swift +++ b/supacode/Features/Repositories/Views/SidebarItemsView.swift @@ -276,7 +276,7 @@ private struct SidebarPathGroupHeaderRow: View { } label: { HStack(spacing: 6) { Image(systemName: "chevron.right") - .font(.caption.weight(.semibold)) + .appFont(.caption, weight: .semibold) .foregroundStyle(.secondary) .rotationEffect(.degrees(isCollapsed ? 0 : 90)) .animation(.easeInOut(duration: 0.15), value: isCollapsed) @@ -286,7 +286,7 @@ private struct SidebarPathGroupHeaderRow: View { ) .accessibilityHidden(true) Text(label) - .font(.body) + .appFont(.body) .lineLimit(1) .foregroundStyle(.primary) Spacer(minLength: 0) diff --git a/supacode/Features/Repositories/Views/SidebarListView.swift b/supacode/Features/Repositories/Views/SidebarListView.swift index 055d5c16c..b7c705022 100644 --- a/supacode/Features/Repositories/Views/SidebarListView.swift +++ b/supacode/Features/Repositories/Views/SidebarListView.swift @@ -339,7 +339,7 @@ private struct SidebarHoistSummaryRow: View { } Spacer(minLength: 0) } - .font(.caption) + .appFont(.caption) .foregroundStyle(.secondary) .lineLimit(1) .contentShape(.interaction, .rect) @@ -544,12 +544,12 @@ private struct SidebarPlaceholderView: View { Label { VStack(alignment: .leading, spacing: 2) { Text("placeholder-branch") - .font(.body) + .appFont(.body) .lineLimit(1) .redacted(reason: .placeholder) .shimmer(isActive: true) Text("placeholder") - .font(.footnote) + .appFont(.footnote) .lineLimit(1) .redacted(reason: .placeholder) .shimmer(isActive: true) diff --git a/supacode/Features/Repositories/Views/TerminalPersistenceOnboardingCardView.swift b/supacode/Features/Repositories/Views/TerminalPersistenceOnboardingCardView.swift index 3cf32ba2e..f4da04202 100644 --- a/supacode/Features/Repositories/Views/TerminalPersistenceOnboardingCardView.swift +++ b/supacode/Features/Repositories/Views/TerminalPersistenceOnboardingCardView.swift @@ -1,4 +1,5 @@ import Sharing +import SupacodeSettingsShared import SwiftUI /// Bottom-of-sidebar onboarding card announcing zmx-backed session persistence. @@ -41,14 +42,14 @@ private struct TerminalPersistenceOnboardingCardBody: View { VStack(alignment: .leading, spacing: 4) { SidebarCardLabel(title: "Sessions persist across quits", description: description) Text("Manage in Settings → General") - .font(.caption2) + .appFont(.caption2) .foregroundStyle(.tertiary) .padding(.top, 2) } }, header: { Image(systemName: "infinity") - .font(.title2) + .appFont(.title2) .foregroundStyle(.purple) .accessibilityHidden(true) } diff --git a/supacode/Features/Repositories/Views/WorktreeCreationPromptView.swift b/supacode/Features/Repositories/Views/WorktreeCreationPromptView.swift index 7ad862b1c..9c6e185c0 100644 --- a/supacode/Features/Repositories/Views/WorktreeCreationPromptView.swift +++ b/supacode/Features/Repositories/Views/WorktreeCreationPromptView.swift @@ -185,7 +185,7 @@ private struct WorktreeRefPickerField: View { VStack(alignment: .leading, spacing: 2) { Text(title) Text(caption) - .font(.caption) + .appFont(.caption) .foregroundStyle(.secondary) } HStack(spacing: 8) { @@ -300,7 +300,7 @@ private struct WorktreeRefFilterResults: View { var body: some View { if matches.isEmpty { Text("No matching branches") - .font(.callout) + .appFont(.callout) .foregroundStyle(.secondary) } else { VStack(alignment: .leading, spacing: 0) { @@ -320,7 +320,7 @@ private struct WorktreeRefFilterResults: View { .padding(.horizontal, -4) if total > matches.count { Text("\(rangeStart) to \(rangeEnd), out of \(total)") - .font(.caption) + .appFont(.caption) .foregroundStyle(.secondary) .padding(.top, 2) } @@ -350,7 +350,7 @@ private struct WorktreeRefResultRow: View { .truncationMode(.middle) Spacer(minLength: 8) Text(display.scope) - .font(.caption) + .appFont(.caption) .foregroundStyle(.secondary) } .padding(.vertical, 3) diff --git a/supacode/Features/Repositories/Views/WorktreeDetailTitleView.swift b/supacode/Features/Repositories/Views/WorktreeDetailTitleView.swift index c8f2de4fb..a947f8906 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailTitleView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailTitleView.swift @@ -126,7 +126,7 @@ struct WorktreeToolbarTitleView: View { case .folder(let name, let tint, let hostInfo): HStack(spacing: 4) { Text(name) - .font(.callout.weight(.semibold)) + .appFont(.callout, weight: .semibold) .foregroundStyle(tint?.color ?? .primary) .lineLimit(1) .truncationMode(.middle) @@ -140,7 +140,7 @@ struct WorktreeToolbarTitleView: View { } case .git(let payload): Text(payload.displayTitle) - .font(.callout.weight(.semibold)) + .appFont(.callout, weight: .semibold) .foregroundStyle(payload.worktreeTint?.color ?? .primary) .lineLimit(1) .truncationMode(.middle) @@ -164,7 +164,7 @@ struct WorktreeToolbarTitleView: View { trail } } - .font(.footnote) + .appFont(.footnote) .lineLimit(1) } } diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index 25beb0283..617569347 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -640,6 +640,11 @@ struct WorktreeDetailView: View { ToolbarItem(placement: .navigation) { TerminalSchemeHost(scheme: scheme) { WorktreeToolbarTitleView(content: toolbarState.titleContent) + // `TerminalSchemeHost` re-hosts its content in a fresh + // `NSHostingView`, which starts a new environment rather than + // inheriting the window's. Publish the size inside the closure so it + // travels with the content value. + .appChromeTextSize(settingsFile.global.chromeTextSize) } } .sharedBackgroundVisibility(.hidden) @@ -1014,7 +1019,7 @@ private struct DetailPlaceholderView: View { ProgressView() .controlSize(.large) Text(Self.messages[messageIndex]) - .font(.title3) + .appFont(.title3) .foregroundStyle(.secondary) .contentTransition(.numericText()) .shimmer(isActive: true) @@ -1056,7 +1061,7 @@ private struct ToolbarPlaceholderContent: ToolbarContent { .foregroundStyle(.secondary) Text("feature/branch") } - .font(.headline) + .appFont(.headline) } .redacted(reason: .placeholder) .shimmer(isActive: true) @@ -1135,7 +1140,7 @@ private struct MultiSelectedWorktreesDetailView: View { let deleteShortcut = KeyboardShortcut(.delete, modifiers: [.command, .shift]).display VStack(alignment: .leading, spacing: 20) { Text("\(rows.count) items selected") - .font(.title3) + .appFont(.title3) if !worktreeRows.isEmpty { selectionSection( @@ -1167,12 +1172,12 @@ private struct MultiSelectedWorktreesDetailView: View { if isMixedKindSelection { VStack(alignment: .leading, spacing: 6) { Label("No bulk action available", systemImage: "exclamationmark.triangle") - .font(.headline) + .appFont(.headline) Text( "Worktrees and folders don't share bulk actions. Deselect " + "one kind to archive/delete worktrees or remove folders." ) - .font(.caption) + .appFont(.caption) .foregroundStyle(.secondary) } } @@ -1191,7 +1196,7 @@ private struct MultiSelectedWorktreesDetailView: View { ) -> some View { VStack(alignment: .leading, spacing: 8) { Text(title) - .font(.headline) + .appFont(.headline) ForEach(Array(rows.prefix(visibleRowsLimit))) { row in HStack(alignment: .firstTextBaseline, spacing: 8) { Text(row.name) @@ -1202,23 +1207,23 @@ private struct MultiSelectedWorktreesDetailView: View { .lineLimit(1) } } - .font(.body) + .appFont(.body) } if rows.count > visibleRowsLimit { Text("+\(rows.count - visibleRowsLimit) more") - .font(.caption) + .appFont(.caption) .foregroundStyle(.secondary) } if !actions.isEmpty { VStack(alignment: .leading, spacing: 4) { Text("Available actions") - .font(.subheadline) + .appFont(.subheadline) .foregroundStyle(.secondary) ForEach(actions, id: \.self) { action in Text(action) } } - .font(.caption) + .appFont(.caption) .foregroundStyle(.secondary) .padding(.top, 4) } diff --git a/supacode/Features/Repositories/Views/WorktreeLoadingView.swift b/supacode/Features/Repositories/Views/WorktreeLoadingView.swift index dd69e04eb..5460b5ba7 100644 --- a/supacode/Features/Repositories/Views/WorktreeLoadingView.swift +++ b/supacode/Features/Repositories/Views/WorktreeLoadingView.swift @@ -1,3 +1,4 @@ +import SupacodeSettingsShared import SwiftUI struct WorktreeLoadingView: View { @@ -10,17 +11,17 @@ struct WorktreeLoadingView: View { .controlSize(.large) VStack(spacing: 4) { Text(info.name) - .font(.title3) + .appFont(.title3) if let command = info.progress?.statusCommand { Text(command) - .font(.subheadline) + .appFont(.subheadline) .monospaced() .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.middle) } Text(subtitle) - .font(.subheadline) + .appFont(.subheadline) .monospaced() .foregroundStyle(.tertiary) .lineLimit(5, reservesSpace: true) diff --git a/supacode/Features/Repositories/Views/WorktreeStatusInspector.swift b/supacode/Features/Repositories/Views/WorktreeStatusInspector.swift index eec0a7640..d58b6b4a1 100644 --- a/supacode/Features/Repositories/Views/WorktreeStatusInspector.swift +++ b/supacode/Features/Repositories/Views/WorktreeStatusInspector.swift @@ -119,7 +119,7 @@ private struct GitInspectorContent: View { VStack(spacing: 0) { HStack { Text("Pull Request") - .font(.headline) + .appFont(.headline) Spacer() if let url { Button { @@ -167,12 +167,12 @@ private struct GitInspectorContent: View { .foregroundStyle(.secondary) } Text(pullRequest.title) - .font(.headline) + .appFont(.headline) .textSelection(.enabled) Text( "`\(pullRequest.baseRefName ?? "base")` ← `\(pullRequest.headRefName ?? "branch")`" ) - .font(.subheadline) + .appFont(.subheadline) .monospaced() .foregroundStyle(.secondary) } @@ -199,7 +199,7 @@ private struct GitInspectorContent: View { PullRequestChecksRingView(breakdown: breakdown) Text(breakdown.summaryText) .foregroundStyle(.secondary) - .font(.callout) + .appFont(.callout) } ForEach(sortedChecks, id: \.self) { check in CheckRow(check: check) @@ -370,7 +370,7 @@ private struct CheckRowLabel: View { .lineLimit(1) Spacer() Text(style.label) - .font(.caption) + .appFont(.caption) .foregroundStyle(.secondary) } } @@ -393,7 +393,7 @@ private struct PullRequestMergeQueueRow: View { } if let detail = status.detail { Text(detail) - .font(.caption) + .appFont(.caption) .foregroundStyle(.secondary) } } @@ -442,7 +442,7 @@ private struct NotificationsInspectorContent: View { VStack(spacing: 0) { HStack { Text("Notifications") - .font(.headline) + .appFont(.headline) Spacer() Button("Dismiss All", action: onDismissAll) .buttonStyle(.borderless) @@ -535,7 +535,7 @@ private struct NotificationWorktreeHeader: View { .foregroundStyle(.secondary) } } - .font(.subheadline.weight(.medium)) + .appFont(.subheadline, weight: .medium) .lineLimit(1) .textCase(nil) } @@ -564,14 +564,14 @@ private struct NotificationRow: View { VStack(alignment: .leading, spacing: 2) { HStack(alignment: .firstTextBaseline, spacing: 6) { Text(title) - .font(.subheadline.weight(.semibold)) + .appFont(.subheadline, weight: .semibold) .foregroundStyle(notification.isRead ? Color.secondary : Color.primary) .lineLimit(1) Spacer(minLength: 6) // Self-updating relative time; no shared clock needed, so a row's // markdown body is never re-parsed just to advance the timestamp. Text(notification.createdAt, style: .relative) - .font(.caption) + .appFont(.caption) .foregroundStyle(.tertiary) .lineLimit(1) .fixedSize() @@ -583,7 +583,7 @@ private struct NotificationRow: View { } if !notification.body.isEmpty { Text(Self.markdown(notification.body)) - .font(.callout) + .appFont(.callout) .foregroundStyle(notification.isRead ? Color.secondary : Color.primary) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) @@ -622,7 +622,7 @@ private struct PrunedNotificationRow: View { VStack(alignment: .leading, spacing: 2) { HStack(alignment: .firstTextBaseline, spacing: 6) { Text(title) - .font(.subheadline.weight(.semibold)) + .appFont(.subheadline, weight: .semibold) .foregroundStyle(.primary) .lineLimit(1) Spacer(minLength: 6) @@ -632,7 +632,7 @@ private struct PrunedNotificationRow: View { .accessibilityHidden(true) } Text("Cleared per your Notification settings.") - .font(.callout) + .appFont(.callout) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) @@ -661,7 +661,7 @@ private struct NotificationSourceIcon: View { AgentBadgeView(agent: agent, size: 22) } else { Image(systemName: "bell.fill") - .font(.caption2) + .appFont(.caption2) .foregroundStyle(.secondary) .frame(width: 22, height: 22) .background(.bar, in: .circle) diff --git a/supacode/Features/Settings/Views/DeveloperSettingsView.swift b/supacode/Features/Settings/Views/DeveloperSettingsView.swift index d906e64b4..b90eac01e 100644 --- a/supacode/Features/Settings/Views/DeveloperSettingsView.swift +++ b/supacode/Features/Settings/Views/DeveloperSettingsView.swift @@ -102,7 +102,7 @@ private struct AgentInstallPromptRow: View { VStack(alignment: .leading, spacing: 2) { Text("Agent integrations") Text(subtitle) - .font(.subheadline) + .appFont(.subheadline) .foregroundStyle(.secondary) } } @@ -256,10 +256,10 @@ private struct AgentIntegrationRow: View { VStack(alignment: .leading, spacing: 2) { Text(agent.displayName) Text(agent.integrationSubtitle) - .font(.subheadline) + .appFont(.subheadline) .foregroundStyle(.secondary) if let message = state.errorMessage { - Text(message).font(.subheadline).foregroundStyle(.red) + Text(message).appFont(.subheadline).foregroundStyle(.red) } } Spacer() diff --git a/supacode/Features/Settings/Views/GithubSettingsView.swift b/supacode/Features/Settings/Views/GithubSettingsView.swift index b1d3fbf45..94f18079a 100644 --- a/supacode/Features/Settings/Views/GithubSettingsView.swift +++ b/supacode/Features/Settings/Views/GithubSettingsView.swift @@ -78,7 +78,7 @@ struct GithubSettingsView: View { Text("GitHub CLI not found") Text("Install `gh` to enable pull request checks.") .foregroundStyle(.secondary) - .font(.callout) + .appFont(.callout) } } icon: { Image(systemName: "xmark.circle") @@ -92,7 +92,7 @@ struct GithubSettingsView: View { Text("Not authenticated") Text("Run `gh auth login` in a terminal to authenticate.") .foregroundStyle(.secondary) - .font(.callout) + .appFont(.callout) } } icon: { Image(systemName: "exclamationmark.triangle") @@ -106,7 +106,7 @@ struct GithubSettingsView: View { Text("GitHub CLI outdated") Text("Update to the latest version for full support.") .foregroundStyle(.secondary) - .font(.callout) + .appFont(.callout) } } icon: { Image(systemName: "exclamationmark.triangle") @@ -128,7 +128,7 @@ struct GithubSettingsView: View { Text("Error checking status") Text(message) .foregroundStyle(.secondary) - .font(.callout) + .appFont(.callout) } } icon: { Image(systemName: "exclamationmark.triangle") diff --git a/supacode/Features/Settings/Views/HotkeyRecorderView.swift b/supacode/Features/Settings/Views/HotkeyRecorderView.swift index 4bc22153d..6528bdaee 100644 --- a/supacode/Features/Settings/Views/HotkeyRecorderView.swift +++ b/supacode/Features/Settings/Views/HotkeyRecorderView.swift @@ -9,7 +9,7 @@ struct Keycap: View { var body: some View { Text(symbol) - .font(.body.weight(.medium).monospaced()) + .appFont(.body, weight: .medium, monospaced: true) .padding(.horizontal, 6) .frame(minWidth: 28, minHeight: 28) .background(.quaternary, in: .rect(cornerRadius: 6)) @@ -43,13 +43,13 @@ struct HotkeyRecorderPopover: View { Image(systemName: "checkmark.circle.fill") .accessibilityHidden(true) } - .font(.caption) + .appFont(.caption) .foregroundStyle(.green) case .conflict(let override, let name): KeycapsView(override: override) Text("Already used by \(name).") - .font(.caption) + .appFont(.caption) .foregroundStyle(.red) .fixedSize(horizontal: true, vertical: false) @@ -72,7 +72,7 @@ struct HotkeyRecorderPopover: View { } .frame(minHeight: 28) Text("Recording…") - .font(.caption) + .appFont(.caption) .foregroundStyle(.secondary) } } @@ -87,7 +87,7 @@ struct HotkeyRecorderPopover: View { onCancelled() } label: { Image(systemName: "xmark") - .font(.caption2) + .appFont(.caption2) .foregroundStyle(.secondary) .accessibilityLabel("Cancel") } diff --git a/supacode/Features/Settings/Views/SettingsView.swift b/supacode/Features/Settings/Views/SettingsView.swift index 7a8af9417..a11e988df 100644 --- a/supacode/Features/Settings/Views/SettingsView.swift +++ b/supacode/Features/Settings/Views/SettingsView.swift @@ -128,6 +128,8 @@ private struct SettingsSidebarView: View { List(selection: $settingsStore.selection.sending(\.setSelection)) { Label("General", systemImage: "gearshape") .tag(SettingsSection.general) + Label("Accessibility", systemImage: "accessibility") + .tag(SettingsSection.accessibility) Label("Notifications", systemImage: "bell") .tag(SettingsSection.notifications) Label("Worktrees", systemImage: "list.dash") @@ -186,6 +188,8 @@ private struct SettingsDetailView: View { switch selection { case .general: AppearanceSettingsView(store: settingsStore) + case .accessibility: + AccessibilitySettingsView(store: settingsStore) case .notifications: NotificationsSettingsView(store: settingsStore) case .worktree: diff --git a/supacode/Features/Settings/Views/ShortcutRowView.swift b/supacode/Features/Settings/Views/ShortcutRowView.swift index 740fc37f1..81ab8fad1 100644 --- a/supacode/Features/Settings/Views/ShortcutRowView.swift +++ b/supacode/Features/Settings/Views/ShortcutRowView.swift @@ -29,7 +29,7 @@ struct HotkeyCellView: View { .foregroundStyle(isModified ? .primary : .secondary) if let warning { Image(systemName: "exclamationmark.triangle.fill") - .font(.caption2) + .appFont(.caption2) .foregroundStyle(.yellow) .accessibilityLabel("Warning") .help(warning) diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabLabelView.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabLabelView.swift index d0414a9d0..dbec62b3e 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabLabelView.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabLabelView.swift @@ -46,7 +46,7 @@ private struct TerminalTabTitleLabel: View, Equatable { var body: some View { Text(title) - .font(.caption) + .appFont(.caption) .fontWeight(isActive ? .semibold : .regular) .lineLimit(1) .foregroundStyle(TerminalTabBarColors.activeText) diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift index 0bcb242bf..0226ea219 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift @@ -115,7 +115,7 @@ struct TerminalTabView: View { if isEditing { TextField("", text: $editingTitle) .textFieldStyle(.plain) - .font(.caption) + .appFont(.caption) .focused($isFieldFocused) .foregroundStyle(TerminalTabBarColors.activeText) .accessibilityLabel("Rename tab") @@ -283,10 +283,9 @@ private struct TerminalTabShortcutHintText: View { var body: some View { Text(hint) - .font(.caption) // Explicit `.regular` because the tab bar lacks the sidebar's List/vibrancy - // context, where `.font(.caption)` would otherwise render heavier. - .fontWeight(.regular) + // context, where `.caption` would otherwise render heavier. + .appFont(.caption, weight: .regular) .foregroundStyle(.secondary) .lineLimit(1) .fixedSize() diff --git a/supacode/Features/Terminal/Views/EmptyTerminalPaneView.swift b/supacode/Features/Terminal/Views/EmptyTerminalPaneView.swift index 9e4402945..1d0c16563 100644 --- a/supacode/Features/Terminal/Views/EmptyTerminalPaneView.swift +++ b/supacode/Features/Terminal/Views/EmptyTerminalPaneView.swift @@ -1,3 +1,4 @@ +import SupacodeSettingsShared import SwiftUI struct EmptyTerminalPaneView: View { @@ -6,15 +7,15 @@ struct EmptyTerminalPaneView: View { var body: some View { VStack(spacing: 12) { Image(systemName: "apple.terminal.on.rectangle") - .font(.title) + .appFont(.title) .imageScale(.large) .accessibilityHidden(true) .foregroundStyle(.secondary) VStack(spacing: 4) { Text(message) - .font(.title3) + .appFont(.title3) Text("Use the \(Text("+").bold()) button to open a terminal.") - .font(.subheadline) + .appFont(.subheadline) .foregroundStyle(.secondary) } } diff --git a/supacode/Support/SidebarCardView.swift b/supacode/Support/SidebarCardView.swift index 194487f43..8c4d03d75 100644 --- a/supacode/Support/SidebarCardView.swift +++ b/supacode/Support/SidebarCardView.swift @@ -1,3 +1,4 @@ +import SupacodeSettingsShared import SwiftUI /// Pinned sidebar card surface (glass background, 10pt radius, leading-aligned). @@ -30,7 +31,7 @@ struct SidebarCard: View { onDismiss() } label: { Image(systemName: "xmark") - .font(.caption2) + .appFont(.caption2) .foregroundStyle(.secondary) .frame(width: 18, height: 18) .contentShape(.rect) @@ -64,11 +65,11 @@ struct SidebarCardLabel: View { var body: some View { VStack(alignment: .leading, spacing: 2) { Text(title) - .font(.subheadline) + .appFont(.subheadline) .fontWeight(.semibold) if let description { Text(description) - .font(.caption) + .appFont(.caption) .foregroundStyle(.secondary) } } diff --git a/supacodeTests/ChromeTextSizeTests.swift b/supacodeTests/ChromeTextSizeTests.swift new file mode 100644 index 000000000..51348cbf3 --- /dev/null +++ b/supacodeTests/ChromeTextSizeTests.swift @@ -0,0 +1,31 @@ +import Testing + +@testable import SupacodeSettingsShared + +struct ChromeTextSizeTests { + @Test func defaultIsTheUnmodifiedSystemSize() { + #expect(ChromeTextSize.default == .standard) + #expect(ChromeTextSize.default.scale == 1.0) + } + + @Test func sizesGrowMonotonically() { + let scales = ChromeTextSize.allCases.map(\.scale) + #expect(scales == scales.sorted()) + #expect(Set(scales).count == scales.count) + } + + @Test func casesAreOrderedSmallestFirst() { + // `allCases` is the order the picker renders, so reordering the cases + // reorders the control. + #expect(ChromeTextSize.allCases == [.standard, .large, .extraLarge]) + #expect(ChromeTextSize.allCases.first == .default) + } + + @Test func rawValuesAreStableAcrossReleases() { + // The raw values are the on-disk representation in the settings file; + // renaming one silently resets a user's chosen size back to the default. + #expect(ChromeTextSize.standard.rawValue == "standard") + #expect(ChromeTextSize.large.rawValue == "large") + #expect(ChromeTextSize.extraLarge.rawValue == "extraLarge") + } +} diff --git a/supacodeTests/SettingsFeatureTests.swift b/supacodeTests/SettingsFeatureTests.swift index 83866ec23..f708db014 100644 --- a/supacodeTests/SettingsFeatureTests.swift +++ b/supacodeTests/SettingsFeatureTests.swift @@ -130,6 +130,41 @@ struct SettingsFeatureTests { #expect(settingsFile.global.terminalHibernationEnabled == false) } + @Test(.dependencies) func chromeTextSizePersistsChanges() async { + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global = .default } + + let store = TestStore(initialState: SettingsFeature.State()) { + SettingsFeature() + } + + await store.send(.binding(.set(\.chromeTextSize, .extraLarge))) { + $0.chromeTextSize = .extraLarge + } + await store.receive(\.delegate.settingsChanged) + #expect(settingsFile.global.chromeTextSize == .extraLarge) + } + + @Test(.dependencies) func unrelatedSettingsChangeKeepsChromeTextSize() async { + // `persist` assigns `$0.global` wholesale, so a field missing from this + // feature's state would be written back as its default on every unrelated + // change. + var initialSettings = GlobalSettings.default + initialSettings.chromeTextSize = .large + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global = initialSettings } + + let store = TestStore(initialState: SettingsFeature.State(settings: initialSettings)) { + SettingsFeature() + } + + await store.send(.binding(.set(\.terminalHibernationEnabled, false))) { + $0.terminalHibernationEnabled = false + } + await store.receive(\.delegate.settingsChanged) + #expect(settingsFile.global.chromeTextSize == .large) + } + @Test(.dependencies) func confirmCloseSurfacePersistsChanges() async { var initialSettings = GlobalSettings.default initialSettings.confirmCloseSurface = true diff --git a/supacodeTests/SettingsFilePersistenceTests.swift b/supacodeTests/SettingsFilePersistenceTests.swift index 5531839cc..b2b9a205c 100644 --- a/supacodeTests/SettingsFilePersistenceTests.swift +++ b/supacodeTests/SettingsFilePersistenceTests.swift @@ -631,6 +631,70 @@ struct SettingsFilePersistenceTests { #expect(settings.global.systemNotificationsEnabled == true) } + @Test(.dependencies) func decodesMissingChromeTextSizeAsDefault() throws { + // A file predating the accessibility text size has no `chromeTextSize` key + // and must migrate to the system default size rather than failing to load. + let legacy = LegacySettingsFile( + global: LegacyGlobalSettings( + appearanceMode: .dark, + updatesAutomaticallyCheckForUpdates: false, + updatesAutomaticallyDownloadUpdates: true + ), + repositories: [:] + ) + let data = try JSONEncoder().encode(legacy) + let storage = MutableTestStorage(initialData: data) + + let settings: SettingsFile = withDependencies { + $0.settingsFileStorage = storage.storage + } operation: { + @Shared(.settingsFile) var settings: SettingsFile + return settings + } + + #expect(settings.global.chromeTextSize == .default) + } + + @Test(.dependencies) func decodesUnrecognizedChromeTextSizeAsDefaultWithoutDiscardingTheFile() throws { + // An unknown size (older build, hand-edit) must fall back by itself rather + // than throwing, which would reset every other setting in the file. + let json = """ + {"global":{"appearanceMode":"light","updatesAutomaticallyCheckForUpdates":false,\ + "updatesAutomaticallyDownloadUpdates":false,"chromeTextSize":"gigantic"},"repositories":{}} + """ + let storage = MutableTestStorage(initialData: Data(json.utf8)) + + let settings: SettingsFile = withDependencies { + $0.settingsFileStorage = storage.storage + } operation: { + @Shared(.settingsFile) var settings: SettingsFile + return settings + } + + #expect(settings.global.chromeTextSize == .default) + #expect(settings.global.appearanceMode == .light) + } + + @Test(.dependencies) func roundTripsExplicitChromeTextSize() throws { + let storage = SettingsTestStorage() + + withDependencies { + $0.settingsFileStorage = storage.storage + } operation: { + @Shared(.settingsFile) var settings: SettingsFile + $settings.withLock { $0.global.chromeTextSize = .extraLarge } + } + + let reloaded: SettingsFile = withDependencies { + $0.settingsFileStorage = storage.storage + } operation: { + @Shared(.settingsFile) var reloaded: SettingsFile + return reloaded + } + + #expect(reloaded.global.chromeTextSize == .extraLarge) + } + @Test(.dependencies) func roundTripsExplicitNotificationRetentionLimit() throws { let storage = SettingsTestStorage() From d29f7665bc57478deb0f26ba4c68701a424548ae Mon Sep 17 00:00:00 2001 From: Stefano Bertagno Date: Mon, 10 Aug 2026 21:53:45 +0200 Subject: [PATCH 2/4] Extend chrome text scaling to the reworked terminal chrome and harden it Re-apply the chrome text size to the pane strip, pane window header, and the Form-based pull request inspector that the terminal-layer rework replaced, so tab titles, the rename field, the shortcut hint, and the inspector follow the setting again. Fixed-size control and indicator glyphs stay unscaled. The pane tree and windowed panes mount in fresh NSHostingViews that do not inherit the scene environment, so republish the size at each hosted root (LayoutPaneTreeView, WindowedPaneRootView) the same way the toolbar host does; without it the tab bar never scales. Reach the remaining chrome the same way: - Command palette: republish the size inside its hosted overlay and scale the query field and row text. - Settings and reference windows: raise the base font (appChromeBaseFont) so the implicit Form/Picker/Toggle labels scale without a per-label opt-in; left at the system size for Default. Also: - Scale the loading placeholder toolbar title, which re-hosts in a fresh NSHostingView and so needs the size published inside its closure. - Scale the Accessibility pane's own picker label so it previews the size. - Extract the font-resolution core into AppFontMetrics and carry ChromeTextSize through the environment instead of a raw Double, and cover the math with unit tests. Use the canonical no-design system font for regular text. - Add decode coverage for a mistyped chromeTextSize value and a settingsLoaded read-path test. - Trim the doc comments and drop the em dashes. --- .../Views/AccessibilitySettingsView.swift | 3 + .../Models/ChromeTextSize.swift | 12 +- .../Support/UITextScale.swift | 136 ++++++++++-------- supacode/App/supacodeApp.swift | 3 + .../Views/CommandPaletteOverlayView.swift | 11 +- .../Views/WorktreeDetailView.swift | 5 + .../Views/WorktreeStatusInspector.swift | 28 ++-- .../Terminal/Views/LayoutContentView.swift | 5 + .../Terminal/Views/PaneTabStripView.swift | 10 +- .../Terminal/Views/PaneWindowManager.swift | 7 +- supacodeTests/ChromeTextSizeTests.swift | 54 +++++++ supacodeTests/SettingsFeatureTests.swift | 18 +++ .../SettingsFilePersistenceTests.swift | 21 +++ 13 files changed, 226 insertions(+), 87 deletions(-) diff --git a/SupacodeSettingsFeature/Views/AccessibilitySettingsView.swift b/SupacodeSettingsFeature/Views/AccessibilitySettingsView.swift index 047a8ed75..f6dcbf7ee 100644 --- a/SupacodeSettingsFeature/Views/AccessibilitySettingsView.swift +++ b/SupacodeSettingsFeature/Views/AccessibilitySettingsView.swift @@ -19,8 +19,11 @@ public struct AccessibilitySettingsView: View { Text(size.label).tag(size) } } label: { + // Scale the control's own label so the pane previews the chosen size. Text("Text size") + .appFont(.body) Text("Sizes the sidebar, tab bar, toolbars, and Settings text. The terminal has its own font size.") + .appFontInheriting(.footnote) } } } diff --git a/SupacodeSettingsShared/Models/ChromeTextSize.swift b/SupacodeSettingsShared/Models/ChromeTextSize.swift index 59d1b4ce5..a75bf4d57 100644 --- a/SupacodeSettingsShared/Models/ChromeTextSize.swift +++ b/SupacodeSettingsShared/Models/ChromeTextSize.swift @@ -1,13 +1,9 @@ import Foundation -/// Size of the app chrome's text: the sidebar, tab bar, toolbars, and Settings. -/// A fixed set of sizes rather than a free-form scale, so the layout only has to -/// hold at three known points. -/// -/// The size is applied by resolving each semantic font explicitly (see -/// `View.appFont`), because macOS SwiftUI does not resize text in response to -/// `dynamicTypeSize`. The Ghostty terminal surface is NSView-backed with its own -/// font system and is unaffected. +/// Accessibility size for the app chrome's text. A fixed set of sizes rather +/// than a free-form scale, so the layout only has to hold at three known points. +/// Applied by resolving each semantic font explicitly (see `View.appFont`), +/// because macOS SwiftUI does not resize text for Dynamic Type. public nonisolated enum ChromeTextSize: String, CaseIterable, Codable, Identifiable, Sendable { case standard case large diff --git a/SupacodeSettingsShared/Support/UITextScale.swift b/SupacodeSettingsShared/Support/UITextScale.swift index 5e5b4e337..2f4c36518 100644 --- a/SupacodeSettingsShared/Support/UITextScale.swift +++ b/SupacodeSettingsShared/Support/UITextScale.swift @@ -1,30 +1,24 @@ import AppKit import SwiftUI -/// Environment carrier for the app-wide UI text scale (1.0 = system default). -/// Set at each window root from `GlobalSettings.chromeTextSize`; read by -/// `View.appFont(_:)`. Using an explicit scale (rather than `dynamicTypeSize`) -/// is required because macOS SwiftUI does not resize text for Dynamic Type. -/// -/// Lives in the shared module so every UI module (app + settings feature) routes -/// its chrome text through the same helper. -private struct UITextScaleKey: EnvironmentKey { - static let defaultValue: Double = 1.0 +/// Carries the chrome text size to descendants. Set at each window root by +/// `appChromeTextSize`; read by `appFont`. An explicit size is required because +/// macOS SwiftUI does not resize text for Dynamic Type. +private struct UIChromeTextSizeKey: EnvironmentKey { + static let defaultValue: ChromeTextSize = .standard } extension EnvironmentValues { - public var uiTextScale: Double { - get { self[UITextScaleKey.self] } - set { self[UITextScaleKey.self] = newValue } + var uiChromeTextSize: ChromeTextSize { + get { self[UIChromeTextSizeKey.self] } + set { self[UIChromeTextSizeKey.self] = newValue } } } -/// Base point sizes and default weights for the semantic text styles, read from -/// the live system fonts so scaled sizes track the platform instead of a -/// hardcoded table. Read once: macOS resolves the text-style table at launch and -/// does not change it while the app runs, and the scaled path would otherwise -/// hit AppKit on every body evaluation of every scaled view. -private enum AppFontMetrics { +/// Base point sizes and default weights for the semantic text styles, read once +/// from the live system fonts. The text-style table is fixed after launch, so +/// caching it keeps the scaled path off AppKit on every body evaluation. +enum AppFontMetrics { private static let pointSizes: [Font.TextStyle: CGFloat] = { let styles: [Font.TextStyle] = [ .largeTitle, .title, .title2, .title3, .headline, .subheadline, @@ -47,6 +41,48 @@ private enum AppFontMetrics { style == .headline ? .semibold : .regular } + /// Rounded scaled point size. A fractional size gives a fractional line + /// height, which drifts baselines across the sidebar's dense rows. + static func scaledPointSize(for style: Font.TextStyle, size: ChromeTextSize) -> CGFloat { + (pointSize(for: style) * size.scale).rounded() + } + + /// The font `appFont` applies. At `.standard` it returns the exact semantic + /// font so Default text is left untouched. + static func resolvedFont( + for style: Font.TextStyle, size: ChromeTextSize, weight: Font.Weight?, monospaced: Bool + ) -> Font { + guard size != .standard else { + var font = Font.system(style) + if let weight { font = font.weight(weight) } + if monospaced { font = font.monospaced() } + return font + } + let scaledSize = scaledPointSize(for: style, size: size) + // Only name the design when monospaced; the default carries no design box, + // so `.system(size:)` stays the canonical form for regular text. + let font = monospaced ? Font.system(size: scaledSize, design: .monospaced) : Font.system(size: scaledSize) + return font.weight(weight ?? defaultWeight(for: style)) + } + + /// The font `appFontInheriting` applies, or `nil` at `.standard` to leave the + /// container's own styling in place. + static func inheritedFont( + for base: Font.TextStyle, size: ChromeTextSize, weight: Font.Weight? + ) -> Font? { + guard size != .standard else { return nil } + return Font.system(size: scaledPointSize(for: base, size: size)) + .weight(weight ?? defaultWeight(for: base)) + } + + /// Scaled base font for a bounded container, or `nil` at `.standard`. Raising + /// the environment font lets a `Form` derive its own label hierarchy from a + /// larger base, so implicit text scales without a per-label opt-in. + static func baseFont(for size: ChromeTextSize) -> Font? { + guard size != .standard else { return nil } + return Font.system(size: scaledPointSize(for: .body, size: size)) + } + private static func nsTextStyle(for style: Font.TextStyle) -> NSFont.TextStyle { switch style { case .largeTitle: .largeTitle @@ -66,71 +102,59 @@ private enum AppFontMetrics { } private struct AppFontModifier: ViewModifier { - @Environment(\.uiTextScale) private var scale + @Environment(\.uiChromeTextSize) private var size let style: Font.TextStyle let weight: Font.Weight? let monospaced: Bool func body(content: Content) -> some View { - content.font(resolvedFont) - } - - private var resolvedFont: Font { - // At 1.0× use the exact semantic font, so Default leaves text untouched. - if scale == 1.0 { - var font = Font.system(style) - if let weight { font = font.weight(weight) } - if monospaced { font = font.monospaced() } - return font - } - // Rounded: a fractional point size gives a fractional line height, which - // drifts baselines across the sidebar's dense rows. - let scaledSize = (AppFontMetrics.pointSize(for: style) * scale).rounded() - let font = Font.system(size: scaledSize, design: monospaced ? .monospaced : .default) - return font.weight(weight ?? AppFontMetrics.defaultWeight(for: style)) + content.font(AppFontMetrics.resolvedFont(for: style, size: size, weight: weight, monospaced: monospaced)) } } -/// Scales text that has no font of its own. `List` styles its own section -/// headers and SwiftUI does not expose the font it resolved, so there is nothing -/// to scale in place — `base` stands in for it. At 1.0× the view is left alone, -/// which keeps the platform header styling as it ships. +/// Scales text that has no font of its own, such as a `List` section header. +/// `List` styles its headers and SwiftUI does not expose the font it resolved, +/// so `base` stands in for it above Default. private struct AppFontInheritedModifier: ViewModifier { - @Environment(\.uiTextScale) private var scale + @Environment(\.uiChromeTextSize) private var size let base: Font.TextStyle let weight: Font.Weight? func body(content: Content) -> some View { - if scale == 1.0 { - content + if let font = AppFontMetrics.inheritedFont(for: base, size: size, weight: weight) { + content.font(font) } else { - content.font( - Font.system(size: (AppFontMetrics.pointSize(for: base) * scale).rounded()) - .weight(weight ?? AppFontMetrics.defaultWeight(for: base)) - ) + content } } } extension View { /// Publishes the chrome text size to descendants. Applied at each window root; - /// text opts in with `.appFont(_:)`. + /// text opts in with `appFont`. public func appChromeTextSize(_ size: ChromeTextSize) -> some View { - environment(\.uiTextScale, size.scale) + environment(\.uiChromeTextSize, size) + } + + /// Raises the base font for a bounded container whose text is mostly implicit + /// (the Settings and reference windows, built from `Form`/`Picker`/`Toggle` + /// labels that never call `appFont`). Left untouched at Default. Do not apply + /// to the main window, whose chrome opts in explicitly through `appFont`. + public func appChromeBaseFont(_ size: ChromeTextSize) -> some View { + environment(\.font, AppFontMetrics.baseFont(for: size)) } - /// Applies a semantic text style that honors the chrome text size. Use this in - /// place of `.font(.body)` etc. for chrome text that should follow the user's - /// chosen size. `weight` overrides the style's default weight; `monospaced` - /// picks the monospaced design. + /// Applies a semantic text style that honors the chrome text size. Use in + /// place of `.font(.body)` for chrome text that should follow the chosen size. + /// `weight` overrides the style's default weight; `monospaced` picks the + /// monospaced design. public func appFont(_ style: Font.TextStyle, weight: Font.Weight? = nil, monospaced: Bool = false) -> some View { modifier(AppFontModifier(style: style, weight: weight, monospaced: monospaced)) } - /// Scales text whose font comes from an enclosing container rather than from - /// this view — the sidebar's `Section` headers. At 1.0× the container's - /// styling is left in place; `base` only sets the point size the text grows - /// from above that. + /// Scales text whose font comes from an enclosing container rather than this + /// view, such as the sidebar's `Section` headers. At Default the container's + /// styling is left in place; `base` sets the point size it grows from above that. public func appFontInheriting(_ base: Font.TextStyle, weight: Font.Weight? = nil) -> some View { modifier(AppFontInheritedModifier(base: base, weight: weight)) } diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index 6d8385881..de557f677 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -702,6 +702,7 @@ struct SupacodeApp: App { .environment(ghosttyShortcuts) .environment(commandKeyObserver) .appChromeTextSize(store.settings.chromeTextSize) + .appChromeBaseFont(store.settings.chromeTextSize) .toolbarBackground(.hidden, for: .windowToolbar) .toolbarColorScheme(store.settings.appearanceMode.colorScheme, for: .windowToolbar) .movesSettingsWindowToActiveSpace() @@ -713,6 +714,7 @@ struct SupacodeApp: App { Window("Deeplink Reference", id: WindowID.deeplinkReference) { DeeplinkReferenceView() .appChromeTextSize(store.settings.chromeTextSize) + .appChromeBaseFont(store.settings.chromeTextSize) } .handlesExternalEvents(matching: []) .windowToolbarStyle(.unified) @@ -721,6 +723,7 @@ struct SupacodeApp: App { Window("CLI Reference", id: WindowID.cliReference) { CLIReferenceView() .appChromeTextSize(store.settings.chromeTextSize) + .appChromeBaseFont(store.settings.chromeTextSize) } .handlesExternalEvents(matching: []) .windowToolbarStyle(.unified) diff --git a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift index 04de0cc18..11b49caca 100644 --- a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift +++ b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift @@ -10,6 +10,7 @@ struct CommandPaletteOverlayView: View { @FocusState private var isQueryFocused: Bool @State private var hoveredID: CommandPaletteItem.ID? @State private var filteredItems: [CommandPaletteItem] = [] + @Shared(.settingsFile) private var settingsFile var body: some View { // The card fills the glass panel edge to edge; positioning, the material @@ -53,6 +54,9 @@ struct CommandPaletteOverlayView: View { let updatedItems = refreshFilteredItems(items: items) updateSelection(rows: updatedItems) } + // The palette hosts in its own `NSHostingView`, so republish the chrome + // text size here to reach the query field and rows. + .appChromeTextSize(settingsFile.global.chromeTextSize) } private func updateSelection(rows: [CommandPaletteItem]) { @@ -198,7 +202,7 @@ private struct CommandPaletteQuery: View { var body: some View { TextField(placeholder, text: $query) .padding() - .font(.title3.weight(.light)) + .appFont(.title3, weight: .light) .frame(height: Self.fieldHeight) .textFieldStyle(.plain) .focused($isTextFieldFocused) @@ -410,6 +414,7 @@ private struct CommandPaletteRowView: View { // subtitle for git rows and with the title for folders (no subtitle). HStack(spacing: 3) { Text(titleText) + .appFont(.body) .fontWeight(emphasis ? .medium : .regular) .foregroundStyle(titleForegroundStyle) if row.subtitle == nil, let hostInfo = row.worktreeStyle?.hostInfo { @@ -420,7 +425,7 @@ private struct CommandPaletteRowView: View { if let subtitle = row.subtitle { HStack(spacing: 3) { Text(subtitle) - .font(.caption) + .appFont(.caption) .foregroundStyle(subtitleForegroundStyle) if let hostInfo = row.worktreeStyle?.hostInfo { CommandPaletteRemoteHostBadge(hostInfo: hostInfo) @@ -433,7 +438,7 @@ private struct CommandPaletteRowView: View { if let badge, !badge.isEmpty { Text(badge) - .font(.caption2.weight(.medium)) + .appFont(.caption2, weight: .medium) .padding(.horizontal, 7) .padding(.vertical, 3) .background( diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index 20ab4e01c..1e498b6c6 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -1093,6 +1093,8 @@ private struct ToolbarPlaceholderContent: ToolbarContent { // appended by the toolbar) so the group isn't doubled; cold boot keeps them. var includesStatusSkeleton: Bool = true + @Shared(.settingsFile) private var settingsFile + var body: some ToolbarContent { ToolbarItem(placement: .navigation) { TerminalSchemeHost(scheme: scheme) { @@ -1107,6 +1109,9 @@ private struct ToolbarPlaceholderContent: ToolbarContent { } .redacted(reason: .placeholder) .shimmer(isActive: true) + // `TerminalSchemeHost` re-hosts in a fresh `NSHostingView`, so the size + // must be published inside the closure to travel with the content. + .appChromeTextSize(settingsFile.global.chromeTextSize) } } .sharedBackgroundVisibility(.hidden) diff --git a/supacode/Features/Repositories/Views/WorktreeStatusInspector.swift b/supacode/Features/Repositories/Views/WorktreeStatusInspector.swift index a6525bed6..b748daf2b 100644 --- a/supacode/Features/Repositories/Views/WorktreeStatusInspector.swift +++ b/supacode/Features/Repositories/Views/WorktreeStatusInspector.swift @@ -148,7 +148,7 @@ private struct GitInspectorHeader: View { var body: some View { HStack { Text("Pull Request") - .font(.headline) + .appFont(.headline) Spacer() if let url = URL(string: pullRequest.url) { Button { @@ -213,12 +213,12 @@ private struct GitInspectorContent: View { .foregroundStyle(.secondary) } Text(pullRequest.title) - .font(.headline) + .appFont(.headline) .textSelection(.enabled) Text( "`\(pullRequest.baseRefName ?? "base")` ← `\(pullRequest.headRefName ?? "branch")`" ) - .font(.subheadline) + .appFont(.subheadline) .monospaced() .foregroundStyle(.secondary) } @@ -245,7 +245,7 @@ private struct GitInspectorContent: View { PullRequestChecksRingView(breakdown: breakdown) Text(breakdown.summaryText) .foregroundStyle(.secondary) - .font(.callout) + .appFont(.callout) } ForEach(sortedChecks, id: \.self) { check in CheckRow(check: check) @@ -416,7 +416,7 @@ private struct CheckRowLabel: View { .lineLimit(1) Spacer() Text(style.label) - .font(.caption) + .appFont(.caption) .foregroundStyle(.secondary) } } @@ -439,7 +439,7 @@ private struct PullRequestMergeQueueRow: View { } if let detail = status.detail { Text(detail) - .font(.caption) + .appFont(.caption) .foregroundStyle(.secondary) } } @@ -523,7 +523,7 @@ private struct NotificationsInspectorContent: View { if !groups.isEmpty { HStack { Text("Notifications") - .font(.headline) + .appFont(.headline) Spacer() Button("Dismiss All", action: onDismissAll) .buttonStyle(.borderless) @@ -586,7 +586,7 @@ private struct NotificationWorktreeHeader: View { .foregroundStyle(.secondary) } } - .font(.subheadline.weight(.medium)) + .appFont(.subheadline, weight: .medium) .lineLimit(1) .textCase(nil) } @@ -615,14 +615,14 @@ private struct NotificationRow: View { VStack(alignment: .leading, spacing: 2) { HStack(alignment: .firstTextBaseline, spacing: 6) { Text(title) - .font(.subheadline.weight(.semibold)) + .appFont(.subheadline, weight: .semibold) .foregroundStyle(notification.isRead ? Color.secondary : Color.primary) .lineLimit(1) Spacer(minLength: 6) // Self-updating relative time; no shared clock needed, so a row's // markdown body is never re-parsed just to advance the timestamp. Text(notification.createdAt, style: .relative) - .font(.caption) + .appFont(.caption) .foregroundStyle(.tertiary) .lineLimit(1) .fixedSize() @@ -634,7 +634,7 @@ private struct NotificationRow: View { } if !notification.body.isEmpty { Text(Self.markdown(notification.body)) - .font(.callout) + .appFont(.callout) .foregroundStyle(notification.isRead ? Color.secondary : Color.primary) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) @@ -673,7 +673,7 @@ private struct PrunedNotificationRow: View { VStack(alignment: .leading, spacing: 2) { HStack(alignment: .firstTextBaseline, spacing: 6) { Text(title) - .font(.subheadline.weight(.semibold)) + .appFont(.subheadline, weight: .semibold) .foregroundStyle(.primary) .lineLimit(1) Spacer(minLength: 6) @@ -683,7 +683,7 @@ private struct PrunedNotificationRow: View { .accessibilityHidden(true) } Text("Cleared per your Notification settings.") - .font(.callout) + .appFont(.callout) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) @@ -712,7 +712,7 @@ private struct NotificationSourceIcon: View { AgentBadgeView(agent: agent, size: 22) } else { Image(systemName: "bell.fill") - .font(.caption2) + .appFont(.caption2) .foregroundStyle(.secondary) .frame(width: 22, height: 22) .background(.bar, in: .circle) diff --git a/supacode/Features/Terminal/Views/LayoutContentView.swift b/supacode/Features/Terminal/Views/LayoutContentView.swift index af0e1bb37..9ac0bad59 100644 --- a/supacode/Features/Terminal/Views/LayoutContentView.swift +++ b/supacode/Features/Terminal/Views/LayoutContentView.swift @@ -75,6 +75,8 @@ struct LayoutPaneTreeView: View { var ghosttyShortcuts: GhosttyShortcutManager? var commandKeyObserver: CommandKeyObserver? + @Shared(.settingsFile) private var settingsFile + var body: some View { Group { if let node = store.layout.tree.visibleNode { @@ -92,6 +94,9 @@ struct LayoutPaneTreeView: View { } .environment(ghosttyShortcuts) .environment(commandKeyObserver) + // The pane tree is hosted in a fresh `NSHostingView`, so the scene's chrome + // text size has to be republished here to reach the tab strip. + .appChromeTextSize(settingsFile.global.chromeTextSize) } } diff --git a/supacode/Features/Terminal/Views/PaneTabStripView.swift b/supacode/Features/Terminal/Views/PaneTabStripView.swift index 5ee682338..3b88ed00e 100644 --- a/supacode/Features/Terminal/Views/PaneTabStripView.swift +++ b/supacode/Features/Terminal/Views/PaneTabStripView.swift @@ -330,7 +330,7 @@ private struct PaneTabDragPreview: View { var body: some View { Text(title) - .font(.callout) + .appFont(.callout) .lineLimit(1) .padding(.horizontal, 10) .padding(.vertical, 5) @@ -616,7 +616,7 @@ private struct PaneTabView: View { private var renameField: some View { TextField("", text: $editingTitle) .textFieldStyle(.plain) - .font(.caption) + .appFont(.caption) .focused($isFieldFocused) .foregroundStyle(TerminalTabBarColors.activeText) .accessibilityLabel("Rename tab") @@ -724,7 +724,7 @@ private struct PaneTabTitleLabel: View, Equatable { var body: some View { Text(title) - .font(.caption) + .appFont(.caption) .fontWeight(isSelected ? .semibold : .regular) .lineLimit(1) .foregroundStyle(TerminalTabBarColors.activeText) @@ -737,9 +737,9 @@ private struct PaneTabShortcutHintText: View { var body: some View { Text(hint) - .font(.caption) + .appFont(.caption) // Explicit `.regular` because the tab bar lacks the sidebar's - // List/vibrancy context, where `.font(.caption)` would otherwise render + // List/vibrancy context, where the semantic caption would otherwise render // heavier. .fontWeight(.regular) .foregroundStyle(.secondary) diff --git a/supacode/Features/Terminal/Views/PaneWindowManager.swift b/supacode/Features/Terminal/Views/PaneWindowManager.swift index 8b2616abb..b03d69552 100644 --- a/supacode/Features/Terminal/Views/PaneWindowManager.swift +++ b/supacode/Features/Terminal/Views/PaneWindowManager.swift @@ -544,6 +544,8 @@ private struct WindowedPaneRootView: View { /// The manager-maintained repository and worktree line. let header: PaneWindowHeaderModel + @Shared(.settingsFile) private var settingsFile + var body: some View { // Re-read config-derived colors on every Ghostty config reload. let _ = manager.configGeneration @@ -666,6 +668,9 @@ private struct WindowedPaneRootView: View { .background(WindowChromeObserver(runtime: manager.ghosttyRuntime)) .environment(ghosttyShortcuts) .environment(commandKeyObserver) + // Windowed panes host in their own `NSHostingView`, so republish the chrome + // text size here the same way the main pane tree does. + .appChromeTextSize(settingsFile.global.chromeTextSize) } private func requestCloseSelectedTab(of pane: Pane) { @@ -695,7 +700,7 @@ private struct PaneWindowHeaderView: View { var body: some View { Text(title) - .font(.caption) + .appFont(.caption) .fontWeight(.semibold) .foregroundStyle(.secondary) .lineLimit(1) diff --git a/supacodeTests/ChromeTextSizeTests.swift b/supacodeTests/ChromeTextSizeTests.swift index 51348cbf3..d7ccdf361 100644 --- a/supacodeTests/ChromeTextSizeTests.swift +++ b/supacodeTests/ChromeTextSizeTests.swift @@ -1,3 +1,4 @@ +import SwiftUI import Testing @testable import SupacodeSettingsShared @@ -29,3 +30,56 @@ struct ChromeTextSizeTests { #expect(ChromeTextSize.extraLarge.rawValue == "extraLarge") } } + +/// The font-resolution core behind `appFont` / `appFontInheriting`. Extracted +/// from the view modifiers so the size math is testable without a view host. +struct AppFontMetricsTests { + @Test func standardResolvesToTheExactSemanticFont() { + // The Default path must leave text byte-identical to `.font(...)`, so no + // scaled point size is substituted. + #expect(AppFontMetrics.resolvedFont(for: .body, size: .standard, weight: nil, monospaced: false) == .system(.body)) + #expect( + AppFontMetrics.resolvedFont(for: .body, size: .standard, weight: .semibold, monospaced: false) + == .system(.body).weight(.semibold) + ) + #expect( + AppFontMetrics.resolvedFont(for: .body, size: .standard, weight: nil, monospaced: true) + == .system(.body).monospaced() + ) + } + + @Test func scaledPathRoundsAndKeepsTheStyleDefaultWeight() { + let size = AppFontMetrics.scaledPointSize(for: .body, size: .large) + #expect(size == size.rounded()) + #expect( + AppFontMetrics.resolvedFont(for: .body, size: .large, weight: nil, monospaced: false) + == .system(size: size).weight(.regular) + ) + } + + @Test func headlineKeepsSemiboldWhenScaled() { + // `.headline` is the one macOS style that isn't regular; the scaled path + // re-applies the weight explicitly, so it must preserve it. + let size = AppFontMetrics.scaledPointSize(for: .headline, size: .extraLarge) + #expect( + AppFontMetrics.resolvedFont(for: .headline, size: .extraLarge, weight: nil, monospaced: false) + == .system(size: size).weight(.semibold) + ) + #expect(AppFontMetrics.defaultWeight(for: .headline) == .semibold) + #expect(AppFontMetrics.defaultWeight(for: .body) == .regular) + } + + @Test func scaledPointSizeGrowsWithTheSize() { + let standard = AppFontMetrics.scaledPointSize(for: .body, size: .standard) + let large = AppFontMetrics.scaledPointSize(for: .body, size: .large) + let extraLarge = AppFontMetrics.scaledPointSize(for: .body, size: .extraLarge) + #expect(standard <= large) + #expect(large <= extraLarge) + #expect(standard < extraLarge) + } + + @Test func inheritedFontIsAbsentAtDefaultAndPresentAbove() { + #expect(AppFontMetrics.inheritedFont(for: .subheadline, size: .standard, weight: nil) == nil) + #expect(AppFontMetrics.inheritedFont(for: .subheadline, size: .large, weight: .semibold) != nil) + } +} diff --git a/supacodeTests/SettingsFeatureTests.swift b/supacodeTests/SettingsFeatureTests.swift index 799f13f4e..ee7fabd0e 100644 --- a/supacodeTests/SettingsFeatureTests.swift +++ b/supacodeTests/SettingsFeatureTests.swift @@ -145,6 +145,24 @@ struct SettingsFeatureTests { #expect(settingsFile.global.chromeTextSize == .extraLarge) } + @Test(.dependencies) func settingsLoadedAppliesChromeTextSize() async { + // The write path is covered above; this covers the read side, that a size + // on disk reaches feature state instead of showing Default in the picker. + var loaded = GlobalSettings.default + loaded.chromeTextSize = .extraLarge + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global = loaded } + + let store = TestStore(initialState: SettingsFeature.State()) { + SettingsFeature() + } + store.exhaustivity = .off(showSkippedAssertions: false) + + await store.send(.settingsLoaded(loaded)) + #expect(store.state.chromeTextSize == .extraLarge) + await store.skipReceivedActions() + } + @Test(.dependencies) func unrelatedSettingsChangeKeepsChromeTextSize() async { // `persist` assigns `$0.global` wholesale, so a field missing from this // feature's state would be written back as its default on every unrelated diff --git a/supacodeTests/SettingsFilePersistenceTests.swift b/supacodeTests/SettingsFilePersistenceTests.swift index 7ba685253..e199c7bae 100644 --- a/supacodeTests/SettingsFilePersistenceTests.swift +++ b/supacodeTests/SettingsFilePersistenceTests.swift @@ -770,6 +770,27 @@ struct SettingsFilePersistenceTests { #expect(settings.global.appearanceMode == .light) } + @Test(.dependencies) func decodesMistypedChromeTextSizeAsDefaultWithoutDiscardingTheFile() throws { + // A hand-edit can produce the wrong JSON type, not just an unknown string; + // the `try?` on the String decode must swallow it so one bad field can't + // reset the file. + let json = """ + {"global":{"appearanceMode":"light","updatesAutomaticallyCheckForUpdates":false,\ + "updatesAutomaticallyDownloadUpdates":false,"chromeTextSize":3},"repositories":{}} + """ + let storage = MutableTestStorage(initialData: Data(json.utf8)) + + let settings: SettingsFile = withDependencies { + $0.settingsFileStorage = storage.storage + } operation: { + @Shared(.settingsFile) var settings: SettingsFile + return settings + } + + #expect(settings.global.chromeTextSize == .default) + #expect(settings.global.appearanceMode == .light) + } + @Test(.dependencies) func roundTripsExplicitChromeTextSize() throws { let storage = SettingsTestStorage() From ed6619e69e5a6fa7fc297fac642eac17d1f8fa1e Mon Sep 17 00:00:00 2001 From: Stefano Bertagno Date: Mon, 10 Aug 2026 22:48:16 +0200 Subject: [PATCH 3/4] Fold the text size into General and scale the file explorer Move the chrome text size out of a standalone Accessibility pane into an Accessibility section in General, before Analytics, and drop the now-unused pane, its settings section, deeplink, and CLI subcommand. Label the system size Regular with the shared Default tag, and mark the setting Beta. Scale the file explorer's AppKit outline view, which never sees the SwiftUI chrome environment: thread the size into the cell and breadcrumb fonts and rebuild rows when it changes. A non-custom rowSizeStyle makes the table override the cell font, so switch to custom above the system size; the exact preferred font is kept at Default. A parity test pins the NSFont scale to AppFontMetrics. Scale the two remaining chrome surfaces that opt out of the environment: raise the base font on the pull request inspector Form, and size the settings sidebar rows explicitly since the sidebar list style pins its own font. --- Resources/Skills/supacode-cli.md | 2 +- Resources/Skills/supacode-deeplinks.md | 2 +- .../Views/AccessibilitySettingsView.swift | 33 --------- .../Views/AppearanceSettingsView.swift | 13 ++++ .../Views/SettingsSection.swift | 1 - .../Models/ChromeTextSize.swift | 2 +- supacode-cli/Commands/SettingsCommand.swift | 8 --- supacode/Domain/Deeplink.swift | 1 - .../Features/App/Reducer/AppFeature.swift | 1 - .../Views/FileExplorerOutlineView.swift | 72 +++++++++++++++---- .../Views/WorktreeFilesInspectorView.swift | 9 ++- .../Views/WorktreeStatusInspector.swift | 5 ++ .../Settings/Views/SettingsView.swift | 18 +++-- supacodeTests/FileExplorerFeatureTests.swift | 24 +++++++ 14 files changed, 126 insertions(+), 65 deletions(-) delete mode 100644 SupacodeSettingsFeature/Views/AccessibilitySettingsView.swift diff --git a/Resources/Skills/supacode-cli.md b/Resources/Skills/supacode-cli.md index df0a01143..7aecfa994 100644 --- a/Resources/Skills/supacode-cli.md +++ b/Resources/Skills/supacode-cli.md @@ -170,7 +170,7 @@ supacode repo worktree-new [-r ] [--branch ] [--base ] [--upstrea ### Settings ``` -supacode settings [
] # Open settings (general|accessibility|notifications|worktrees|developer|shortcuts|scripts|updates|github). +supacode settings [
] # Open settings (general|notifications|worktrees|developer|shortcuts|scripts|updates|github). supacode settings repo [-r ] # Open repository settings. supacode settings repo scripts [-r ] # Open repository Scripts settings. ``` diff --git a/Resources/Skills/supacode-deeplinks.md b/Resources/Skills/supacode-deeplinks.md index 706e16210..7d131038a 100644 --- a/Resources/Skills/supacode-deeplinks.md +++ b/Resources/Skills/supacode-deeplinks.md @@ -110,7 +110,7 @@ supacode://repo//worktree/new?branch=&base=&upstream=&f ``` supacode://settings # Open settings. -supacode://settings/
# general|accessibility|notifications|worktrees|developer|shortcuts|scripts|updates|github. +supacode://settings/
# general|notifications|worktrees|developer|shortcuts|scripts|updates|github. supacode://settings/repo/ # Open repository settings. supacode://settings/repo//scripts # Open repository Scripts settings. ``` diff --git a/SupacodeSettingsFeature/Views/AccessibilitySettingsView.swift b/SupacodeSettingsFeature/Views/AccessibilitySettingsView.swift deleted file mode 100644 index f6dcbf7ee..000000000 --- a/SupacodeSettingsFeature/Views/AccessibilitySettingsView.swift +++ /dev/null @@ -1,33 +0,0 @@ -import ComposableArchitecture -import SupacodeSettingsShared -import SwiftUI - -/// Accessibility pane. Holds the chrome text size, which is separate from the -/// terminal's own font zoom. -public struct AccessibilitySettingsView: View { - @Bindable var store: StoreOf - - public init(store: StoreOf) { - self.store = store - } - - public var body: some View { - Form { - Section { - Picker(selection: $store.chromeTextSize) { - ForEach(ChromeTextSize.allCases) { size in - Text(size.label).tag(size) - } - } label: { - // Scale the control's own label so the pane previews the chosen size. - Text("Text size") - .appFont(.body) - Text("Sizes the sidebar, tab bar, toolbars, and Settings text. The terminal has its own font size.") - .appFontInheriting(.footnote) - } - } - } - .formStyle(.grouped) - .navigationTitle("Accessibility") - } -} diff --git a/SupacodeSettingsFeature/Views/AppearanceSettingsView.swift b/SupacodeSettingsFeature/Views/AppearanceSettingsView.swift index 3ace61ce4..8534cea48 100644 --- a/SupacodeSettingsFeature/Views/AppearanceSettingsView.swift +++ b/SupacodeSettingsFeature/Views/AppearanceSettingsView.swift @@ -138,6 +138,19 @@ public struct AppearanceSettingsView: View { Text("Applies to Worktrees without repository overrides.") } } + Section("Accessibility") { + Picker(selection: $store.chromeTextSize) { + ForEach(ChromeTextSize.allCases) { size in + DefaultTaggedLabel(label: size.label, isDefault: size == .default).tag(size) + } + } label: { + HStack(spacing: 6) { + Text("Text size") + BetaBadge() + } + Text("Sizes all non-terminal text. The terminal keeps its own font size.") + } + } Section { Toggle(isOn: $store.analyticsEnabled) { Text("Share analytics") diff --git a/SupacodeSettingsFeature/Views/SettingsSection.swift b/SupacodeSettingsFeature/Views/SettingsSection.swift index 732bd858f..f1019f171 100644 --- a/SupacodeSettingsFeature/Views/SettingsSection.swift +++ b/SupacodeSettingsFeature/Views/SettingsSection.swift @@ -2,7 +2,6 @@ import Foundation public enum SettingsSection: Hashable { case general - case accessibility case notifications case worktree case developer diff --git a/SupacodeSettingsShared/Models/ChromeTextSize.swift b/SupacodeSettingsShared/Models/ChromeTextSize.swift index a75bf4d57..83a4fbcbb 100644 --- a/SupacodeSettingsShared/Models/ChromeTextSize.swift +++ b/SupacodeSettingsShared/Models/ChromeTextSize.swift @@ -15,7 +15,7 @@ public nonisolated enum ChromeTextSize: String, CaseIterable, Codable, Identifia public var label: String { switch self { - case .standard: "Default" + case .standard: "Regular" case .large: "Large" case .extraLarge: "Extra Large" } diff --git a/supacode-cli/Commands/SettingsCommand.swift b/supacode-cli/Commands/SettingsCommand.swift index a5ddbcbac..60dd404f6 100644 --- a/supacode-cli/Commands/SettingsCommand.swift +++ b/supacode-cli/Commands/SettingsCommand.swift @@ -6,7 +6,6 @@ struct SettingsCommand: ParsableCommand { abstract: "Open Supacode settings.", subcommands: [ General.self, - Accessibility.self, Notifications.self, Worktrees.self, Developer.self, @@ -32,7 +31,6 @@ extension SettingsCommand { /// Raw values must match `Deeplink.DeeplinkSettingsSection` on the app side. fileprivate enum Section: String { case general - case accessibility case notifications case worktrees case developer @@ -48,12 +46,6 @@ extension SettingsCommand { func run() throws { try dispatchSettings(.general, timeoutSeconds: timeoutOption.timeout) } } - struct Accessibility: ParsableCommand { - static let configuration = CommandConfiguration(abstract: "Open Accessibility settings.") - @OptionGroup var timeoutOption: TimeoutOption - func run() throws { try dispatchSettings(.accessibility, timeoutSeconds: timeoutOption.timeout) } - } - struct Notifications: ParsableCommand { static let configuration = CommandConfiguration(abstract: "Open Notifications settings.") @OptionGroup var timeoutOption: TimeoutOption diff --git a/supacode/Domain/Deeplink.swift b/supacode/Domain/Deeplink.swift index 013667fd5..9b7b7091e 100644 --- a/supacode/Domain/Deeplink.swift +++ b/supacode/Domain/Deeplink.swift @@ -72,7 +72,6 @@ enum Deeplink: Equatable, Sendable { /// Settings sections reachable via deeplink. enum DeeplinkSettingsSection: String, Equatable, Sendable { case general - case accessibility case notifications case worktrees case developer diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 74a0ff70d..10b391a0f 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -3680,7 +3680,6 @@ struct AppFeature { let settingsSection: SettingsSection = switch section { case .general: .general - case .accessibility: .accessibility case .notifications: .notifications case .worktrees: .worktree case .developer: .developer diff --git a/supacode/Features/FileExplorer/Views/FileExplorerOutlineView.swift b/supacode/Features/FileExplorer/Views/FileExplorerOutlineView.swift index 3a20a6625..4092eb732 100644 --- a/supacode/Features/FileExplorer/Views/FileExplorerOutlineView.swift +++ b/supacode/Features/FileExplorer/Views/FileExplorerOutlineView.swift @@ -35,6 +35,9 @@ struct FileExplorerOutlineView: NSViewRepresentable { /// Menu icon per open action: a baked app icon or an SF Symbol. let menuIcon: (OpenWorktreeAction) -> NSImage? let actions: FileExplorerOutlineActions + /// Chrome text size for the row cells; AppKit draws them, so they miss the + /// SwiftUI `appChromeTextSize` environment and take the size explicitly. + let chromeTextSize: ChromeTextSize /// Height of the SwiftUI breadcrumb bar the outline draws under, so its rows /// still clear the bar. SwiftUI zeroes the ignored safe area for this opaque /// view, so we feed the inset in explicitly. @@ -43,6 +46,13 @@ struct FileExplorerOutlineView: NSViewRepresentable { /// Extra distance above the breadcrumb bar over which the blur fades out. private static let blurFadeHeight: CGFloat = 20 + /// `.default` at the system size keeps the shipped row metrics and lets the + /// table font the label; a scaled size needs `.custom` so the table stops + /// overriding the cell's font. + static func rowSizeStyle(for size: ChromeTextSize) -> NSTableView.RowSizeStyle { + size == .standard ? .default : .custom + } + func makeCoordinator() -> Coordinator { Coordinator() } @@ -54,7 +64,7 @@ struct FileExplorerOutlineView: NSViewRepresentable { // Inset style: rounded selection and side margins, matching the sidebar's // modern look without source-list vibrancy fighting the forced appearance. outlineView.style = .inset - outlineView.rowSizeStyle = .default + outlineView.rowSizeStyle = Self.rowSizeStyle(for: chromeTextSize) outlineView.usesAutomaticRowHeights = true outlineView.intercellSpacing = NSSize(width: 0, height: 2) outlineView.indentationPerLevel = 14 @@ -131,7 +141,8 @@ struct FileExplorerOutlineView: NSViewRepresentable { fileOpenActions: fileOpenActions, resolvedOpenAction: resolvedOpenAction, menuIcon: menuIcon, - actions: actions + actions: actions, + chromeTextSize: chromeTextSize ) // Inset the rows past the breadcrumb bar the outline now draws under. Added // to the safe area so it composes with the automatic titlebar inset up top. @@ -176,6 +187,9 @@ struct FileExplorerOutlineView: NSViewRepresentable { var blurHeightConstraint: NSLayoutConstraint? private var keyMonitor: Any? private(set) var tree: FileExplorerFeature.TreeState? + /// Applied to every cell in `viewFor`; a change forces a rebuild so rows + /// re-measure at the new font. + private(set) var chromeTextSize: ChromeTextSize = .standard private var fileOpenActions: [OpenWorktreeAction] = [] private var resolvedOpenAction: OpenWorktreeAction? private var menuIcon: ((OpenWorktreeAction) -> NSImage?)? @@ -194,17 +208,24 @@ struct FileExplorerOutlineView: NSViewRepresentable { fileOpenActions: [OpenWorktreeAction], resolvedOpenAction: OpenWorktreeAction?, menuIcon: @escaping (OpenWorktreeAction) -> NSImage?, - actions: FileExplorerOutlineActions + actions: FileExplorerOutlineActions, + chromeTextSize: ChromeTextSize ) { self.fileOpenActions = fileOpenActions self.resolvedOpenAction = resolvedOpenAction self.menuIcon = menuIcon self.actions = actions guard let outlineView else { return } + let sizeChanged = self.chromeTextSize != chromeTextSize + self.chromeTextSize = chromeTextSize + if sizeChanged { + outlineView.rowSizeStyle = FileExplorerOutlineView.rowSizeStyle(for: chromeTextSize) + } let previous = self.tree self.tree = tree let structureChanged = - previous?.root != tree.root + sizeChanged + || previous?.root != tree.root || previous?.directories != tree.directories || previous?.expanded != tree.expanded // Deletions add/remove tombstone rows, which only `refreshItems` builds, @@ -818,7 +839,8 @@ extension FileExplorerOutlineView.Coordinator: NSOutlineViewDelegate { isLoading: isLoading && (hasListing || reduceMotion), isShimmering: isFirstTimeLoading && !reduceMotion, failure: childNode?.failure, - decoration: decoration + decoration: decoration, + chromeTextSize: chromeTextSize ) return cell case .showMore(let remaining, let isLoading): @@ -827,7 +849,7 @@ extension FileExplorerOutlineView.Coordinator: NSOutlineViewDelegate { withIdentifier: FileExplorerShowMoreCellView.identifier, owner: nil ) as? FileExplorerShowMoreCellView ?? FileExplorerShowMoreCellView() let directory = item.path - cell.configure(remaining: remaining, isLoading: isLoading) { [weak self] in + cell.configure(remaining: remaining, isLoading: isLoading, chromeTextSize: chromeTextSize) { [weak self] in self?.actions?.showMore(directory) } return cell @@ -1170,6 +1192,27 @@ enum FileExplorerFileIcon { } } +/// AppKit-side font scaling for the file explorer, mirroring `AppFontMetrics` +/// for the SwiftUI chrome. Returns the exact preferred font at Default so +/// unscaled rows keep the system size. +enum FileExplorerCellFont { + static func scaled(_ style: NSFont.TextStyle, _ size: ChromeTextSize) -> NSFont { + let base = NSFont.preferredFont(forTextStyle: style) + guard size != .standard else { return base } + return .systemFont(ofSize: (base.pointSize * CGFloat(size.scale)).rounded()) + } + + static func label(_ size: ChromeTextSize) -> NSFont { + scaled(.body, size) + } + + static func badge(_ size: ChromeTextSize, weight: NSFont.Weight) -> NSFont { + let base = NSFont.preferredFont(forTextStyle: .caption1).pointSize + let point = size == .standard ? base : (base * CGFloat(size.scale)).rounded() + return .monospacedSystemFont(ofSize: point, weight: weight) + } +} + /// Entry cell: the file's native Finder icon beside a body-sized label with /// middle truncation. private final class FileExplorerEntryCellView: NSTableCellView { @@ -1189,6 +1232,8 @@ private final class FileExplorerEntryCellView: NSTableCellView { private var renderedName = "" private var renderedDecoration: GitRowDecoration? private var renderedIsSymbolicLink = false + /// Set per row so the badge redraw on selection uses the same scaled size. + private var chromeTextSize: ChromeTextSize = .standard /// Inline-rename callbacks and state, live only while the label is editable. private var renameCommit: ((String) -> Void)? private var renameCancelled = false @@ -1286,8 +1331,11 @@ private final class FileExplorerEntryCellView: NSTableCellView { isLoading: Bool, isShimmering: Bool, failure: FileExplorerListingError?, - decoration: GitRowDecoration? + decoration: GitRowDecoration?, + chromeTextSize: ChromeTextSize ) { + self.chromeTextSize = chromeTextSize + label.font = FileExplorerCellFont.label(chromeTextSize) iconView.image = entry.isDirectory ? FileExplorerFileIcon.folder() : FileExplorerFileIcon.file(named: entry.name) iconView.setAccessibilityLabel(entry.isDirectory ? "Folder" : "File") // A read failure owns the trailing slot, so its warning wins over a badge. @@ -1392,10 +1440,7 @@ private final class FileExplorerEntryCellView: NSTableCellView { badge.isHidden = false badge.stringValue = Self.letter(for: state) badge.textColor = isEmphasized ? .alternateSelectedControlTextColor : Self.tint(for: state) - badge.font = .monospacedSystemFont( - ofSize: NSFont.preferredFont(forTextStyle: .caption1).pointSize, - weight: isStaged ? .semibold : .regular - ) + badge.font = FileExplorerCellFont.badge(chromeTextSize, weight: isStaged ? .semibold : .regular) let help = Self.badgeHelp(state: state, isStaged: isStaged) badge.toolTip = help badge.setAccessibilityLabel(help) @@ -1544,7 +1589,10 @@ private final class FileExplorerShowMoreCellView: NSTableCellView { @available(*, unavailable) required init?(coder: NSCoder) { nil } - func configure(remaining: Int, isLoading: Bool, onTap: @escaping () -> Void) { + func configure( + remaining: Int, isLoading: Bool, chromeTextSize: ChromeTextSize, onTap: @escaping () -> Void + ) { + button.font = FileExplorerCellFont.label(chromeTextSize) button.title = "Show \(remaining) More" button.isEnabled = !isLoading button.toolTip = "Load the next chunk of this folder's entries." diff --git a/supacode/Features/FileExplorer/Views/WorktreeFilesInspectorView.swift b/supacode/Features/FileExplorer/Views/WorktreeFilesInspectorView.swift index bde5ef31a..2cf2e5702 100644 --- a/supacode/Features/FileExplorer/Views/WorktreeFilesInspectorView.swift +++ b/supacode/Features/FileExplorer/Views/WorktreeFilesInspectorView.swift @@ -36,13 +36,14 @@ struct WorktreeFilesInspectorView: View { private struct FileExplorerPaneFooter: View { let root: URL? + @Shared(.settingsFile) private var settingsFile var body: some View { // Only the breadcrumb, and only when there's a root: no root means the pane // is showing an unavailable state, which gets no footer bar. if let root { HStack { - FileExplorerRootPathControl(url: root) + FileExplorerRootPathControl(url: root, chromeTextSize: settingsFile.global.chromeTextSize) Spacer(minLength: 0) } .padding(.horizontal) @@ -57,6 +58,7 @@ private struct FileExplorerPaneFooter: View { /// component in Finder. private struct FileExplorerRootPathControl: NSViewRepresentable { let url: URL + let chromeTextSize: ChromeTextSize func makeCoordinator() -> Coordinator { Coordinator() } @@ -66,7 +68,7 @@ private struct FileExplorerRootPathControl: NSViewRepresentable { control.isEditable = false control.focusRingType = .none control.backgroundColor = .clear - control.font = .preferredFont(forTextStyle: .callout) + control.font = FileExplorerCellFont.scaled(.callout, chromeTextSize) control.target = context.coordinator control.action = #selector(Coordinator.reveal(_:)) // Let the breadcrumb elide rather than force the pane wider. @@ -77,6 +79,7 @@ private struct FileExplorerRootPathControl: NSViewRepresentable { func updateNSView(_ control: NSPathControl, context: Context) { control.url = url + control.font = FileExplorerCellFont.scaled(.callout, chromeTextSize) control.toolTip = (url.path(percentEncoded: false) as NSString).abbreviatingWithTildeInPath } @@ -134,6 +137,7 @@ private struct FileExplorerTreeContent: View { let bottomBarInset: CGFloat @State private var quickLookURL: URL? @Environment(OpenActionIconStore.self) private var iconStore: OpenActionIconStore? + @Shared(.settingsFile) private var settingsFile var body: some View { Group { @@ -159,6 +163,7 @@ private struct FileExplorerTreeContent: View { createItem: { store.send(.createItemRequested(directory: $0, isDirectory: $1)) }, consumePendingRename: { store.send(.pendingRenameConsumed) } ), + chromeTextSize: settingsFile.global.chromeTextSize, bottomBarInset: bottomBarInset ) // The outline draws under the toolbar (top) and breadcrumb bar (bottom). diff --git a/supacode/Features/Repositories/Views/WorktreeStatusInspector.swift b/supacode/Features/Repositories/Views/WorktreeStatusInspector.swift index b748daf2b..05d7136f0 100644 --- a/supacode/Features/Repositories/Views/WorktreeStatusInspector.swift +++ b/supacode/Features/Repositories/Views/WorktreeStatusInspector.swift @@ -18,6 +18,8 @@ struct WorktreeStatusInspectorContainer: View { let onPullRequestAction: (RepositoriesFeature.PullRequestAction) -> Void let onOpenFile: (URL, OpenWorktreeAction?) -> Void + @Shared(.settingsFile) private var settingsFile + var body: some View { Group { switch pane { @@ -44,6 +46,9 @@ struct WorktreeStatusInspectorContainer: View { ) } } + // The panes are Forms of implicit-font labels (LabeledContent, checks, + // actions); raise the base font so they scale like the explicit chrome. + .appChromeBaseFont(settingsFile.global.chromeTextSize) .inspectorForcedAppearance(terminalManager.surfaceBackgroundColorScheme()) } } diff --git a/supacode/Features/Settings/Views/SettingsView.swift b/supacode/Features/Settings/Views/SettingsView.swift index a11e988df..9eafd1340 100644 --- a/supacode/Features/Settings/Views/SettingsView.swift +++ b/supacode/Features/Settings/Views/SettingsView.swift @@ -1,6 +1,7 @@ import ComposableArchitecture import Kingfisher import SupacodeSettingsFeature +import SupacodeSettingsShared import SwiftUI /// Sidebar label that shows a GitHub owner avatar next to the @@ -35,6 +36,7 @@ private struct RepositoryLabel: View { .accessibilityHidden(true) } } + .appFont(.body) .task(id: rootURL) { guard isGitRepository else { avatarURL = nil @@ -95,8 +97,10 @@ private struct SettingsRepositoryRow: View { ) DisclosureGroup(isExpanded: isExpanded) { Label("General", systemImage: "gearshape") + .appFont(.body) .tag(SettingsSection.repository(repository.id)) Label("Scripts", systemImage: "terminal") + .appFont(.body) .tag(SettingsSection.repositoryScripts(repository.id)) } label: { RepositoryDisclosureLabel( @@ -126,23 +130,31 @@ private struct SettingsSidebarView: View { var body: some View { List(selection: $settingsStore.selection.sending(\.setSelection)) { + // The `.sidebar` list style pins its own font, so each row opts into the + // chrome text size explicitly rather than inheriting the window's. Label("General", systemImage: "gearshape") + .appFont(.body) .tag(SettingsSection.general) - Label("Accessibility", systemImage: "accessibility") - .tag(SettingsSection.accessibility) Label("Notifications", systemImage: "bell") + .appFont(.body) .tag(SettingsSection.notifications) Label("Worktrees", systemImage: "list.dash") + .appFont(.body) .tag(SettingsSection.worktree) Label("Developer", systemImage: "hammer") + .appFont(.body) .tag(SettingsSection.developer) Label("GitHub", image: "github-mark") + .appFont(.body) .tag(SettingsSection.github) Label("Shortcuts", systemImage: "keyboard") + .appFont(.body) .tag(SettingsSection.shortcuts) Label("Global Scripts", systemImage: "terminal") + .appFont(.body) .tag(SettingsSection.scripts) Label("Updates", systemImage: "arrow.down.circle") + .appFont(.body) .tag(SettingsSection.updates) let localRepositories = settingsStore.repositorySummaries.filter { !$0.isRemote } @@ -188,8 +200,6 @@ private struct SettingsDetailView: View { switch selection { case .general: AppearanceSettingsView(store: settingsStore) - case .accessibility: - AccessibilitySettingsView(store: settingsStore) case .notifications: NotificationsSettingsView(store: settingsStore) case .worktree: diff --git a/supacodeTests/FileExplorerFeatureTests.swift b/supacodeTests/FileExplorerFeatureTests.swift index 56effc57f..63e589273 100644 --- a/supacodeTests/FileExplorerFeatureTests.swift +++ b/supacodeTests/FileExplorerFeatureTests.swift @@ -1,6 +1,8 @@ +import AppKit import Clocks import ComposableArchitecture import Foundation +import SwiftUI import Testing @testable import SupacodeSettingsShared @@ -2177,3 +2179,25 @@ struct FileExplorerFeatureTests { await store.send(.contextChanged(nil, isVisible: false)) } } + +/// The file explorer draws with AppKit, so it reimplements the chrome scale for +/// `NSFont`. These pin it to the SwiftUI `AppFontMetrics` math it must match. +struct FileExplorerCellFontTests { + @Test(arguments: [ChromeTextSize.large, .extraLarge]) + func labelSizeMatchesTheSwiftUIChromeScale(_ size: ChromeTextSize) { + #expect( + FileExplorerCellFont.label(size).pointSize + == AppFontMetrics.scaledPointSize(for: .body, size: size) + ) + } + + @Test func standardKeepsTheExactPreferredFonts() { + #expect(FileExplorerCellFont.label(.standard) == NSFont.preferredFont(forTextStyle: .body)) + #expect(FileExplorerCellFont.scaled(.callout, .standard) == NSFont.preferredFont(forTextStyle: .callout)) + #expect( + FileExplorerCellFont.badge(.standard, weight: .regular) + == .monospacedSystemFont( + ofSize: NSFont.preferredFont(forTextStyle: .caption1).pointSize, weight: .regular) + ) + } +} From 929b8c653c481366919e4c481bcd8026d109a02b Mon Sep 17 00:00:00 2001 From: Stefano Bertagno Date: Tue, 11 Aug 2026 00:33:58 +0200 Subject: [PATCH 4/4] Silence function_parameter_count for the chrome-text-size accessors --- .../Features/FileExplorer/Views/FileExplorerOutlineView.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/supacode/Features/FileExplorer/Views/FileExplorerOutlineView.swift b/supacode/Features/FileExplorer/Views/FileExplorerOutlineView.swift index 4092eb732..5501249a1 100644 --- a/supacode/Features/FileExplorer/Views/FileExplorerOutlineView.swift +++ b/supacode/Features/FileExplorer/Views/FileExplorerOutlineView.swift @@ -203,6 +203,7 @@ struct FileExplorerOutlineView: NSViewRepresentable { /// Suppresses delegate feedback while state is applied programmatically. private var isApplyingState = false + // swiftlint:disable:next function_parameter_count func apply( tree: FileExplorerFeature.TreeState, fileOpenActions: [OpenWorktreeAction], @@ -1326,6 +1327,7 @@ private final class FileExplorerEntryCellView: NSTableCellView { @available(*, unavailable) required init?(coder: NSCoder) { nil } + // swiftlint:disable:next function_parameter_count func configure( with entry: FileExplorerEntry, isLoading: Bool,