diff --git a/.swiftlint.yml b/.swiftlint.yml index a40bda77b..533909d9a 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -43,6 +43,21 @@ custom_rules: message: "Do not mutate store state directly in views. Send actions instead." severity: error included: "supacode/.*/Views/.*\\.swift" + interface_font_in_chrome: + name: "Interface Font In Chrome" + # `included` is the record of how far interface-text coverage reaches. It + # grows as directories are converted; it is never widened ahead of them, + # because a rule that fails on landing gets disabled rather than obeyed. + # Absence of any font modifier is invisible here — a view that inherits the + # system default matches nothing. Set a resolved default on the container + # for those, which this rule cannot check for you. + regex: "\\.font\\(" + message: "Chrome text must honour the interface text settings. Use .interfaceFont(style) instead, or .fixedInterfaceFont(...) when the size is a layout constant." + severity: error + match_kinds: + - identifier + included: "supacode/(Features|App|Support)/.*\\.swift" + excluded: "supacode/Support/InterfaceTextMetrics.swift" dictionary_unique_keys_with_values: name: "Dictionary Unique Keys With Values" regex: "\\bDictionary\\s*\\(\\s*uniqueKeysWithValues\\s*:" diff --git a/docs/components/settings.md b/docs/components/settings.md index b3eeaa7f2..7ebf7e0df 100644 --- a/docs/components/settings.md +++ b/docs/components/settings.md @@ -25,7 +25,7 @@ and opens that section's root. | Section | Controls | |-----|----------| -| **General** | Appearance (system/light/dark), default app for opening worktrees, diff tool, confirm-before-quit, default view mode, window chrome tint, automatic repository icon detection, toolbar buttons (Run / Open-in-editor), dim unfocused splits, Active Agents panel auto-show & terminal titles. | +| **General** | Appearance (system/light/dark), minimum text size (System / 11–16 pt — a floor like Safari's "Never use font sizes smaller than": chrome text below the floor is lifted to it, covering the sidebar, Active Agents panel, toolbar, tab bar, and their popovers; terminal text is unaffected), default app for opening worktrees, diff tool, confirm-before-quit, default view mode, window chrome tint, automatic repository icon detection, toolbar buttons (Run / Open-in-editor), dim unfocused splits, Active Agents panel auto-show & terminal titles. | | **Notifications** | In-app alerts, notification sound picker (Never / system sounds / Prowl Classic), macOS system notifications, move-notified-to-top, command-finished notification + threshold, Dock badge & bounce. → [notifications](notifications.md) | | **Shortcuts** | Remap app keyboard shortcuts; view defaults; resolve conflicts. → [keyboard-shortcuts](../reference/keyboard-shortcuts.md) | | **Worktree** | Worktree creation/deletion defaults: prompt on create, fetch before create, base directory, copy ignored/untracked files, automatic local-branch cleanup, merged-worktree action, archived auto-delete period. | diff --git a/docs/reference/settings-fields.md b/docs/reference/settings-fields.md index fdb62d181..445541bd1 100644 --- a/docs/reference/settings-fields.md +++ b/docs/reference/settings-fields.md @@ -10,11 +10,11 @@ For the UI grouping of these into tabs, see [`components/settings.md`](../compon ## On-disk locations -| Scope | Path | -|-------|------| -| Global settings | `~/.prowl/settings.json` | -| Global custom commands + agent profiles | `~/.prowl/global.onevcat.json` | -| Per-repository settings | `~/.prowl/repo//prowl.json` | +| Scope | Path | +| ----------------------------------------------------- | ---------------------------------------------- | +| Global settings | `~/.prowl/settings.json` | +| Global custom commands + agent profiles | `~/.prowl/global.onevcat.json` | +| Per-repository settings | `~/.prowl/repo//prowl.json` | | Per-repository custom commands + agent-profile memory | `~/.prowl/repo//prowl.onevcat.json` | JSON is pretty-printed with sorted keys. Legacy `~/.supacode` is migrated to @@ -22,73 +22,75 @@ JSON is pretty-printed with sorted keys. Legacy `~/.supacode` is migrated to ## Global settings (`GlobalSettings`) -| Field | Type | Default | Effect | -|-------|------|---------|--------| -| `appearanceMode` | enum (`system`/`light`/`dark`) | `dark` | App appearance. | -| `defaultEditorID` | String | `auto` | Default app to open worktrees (overridable per repo); `auto` prefers an app matching the detected project type. | -| `confirmBeforeQuit` | Bool | `true` | Confirm before quitting Prowl. | -| `updatesAutomaticallyCheckForUpdates` | Bool | `true` | Background update checks. | -| `updatesAutomaticallyDownloadUpdates` | Bool | `false` | Auto-download updates. | -| `inAppNotificationsEnabled` | Bool | `true` | In-app alerts / bell indicators. | -| `notificationSound` | enum (`never` / system sound raw values like `hero` / `supacodeClassic`) | `supacodeClassic` | Sound played for notifications when system banners are off; `never` disables it. Migrates the legacy `notificationSoundEnabled` Bool (`true` → `supacodeClassic`, `false` → `never`); unknown raw values fall back to the default. | -| `systemNotificationsEnabled` | Bool | `false` | macOS system banners. | -| `muteNotificationsForActiveSurface` | Bool | `true` | Suppress the banner, sound, and dock bounce when the notification's pane is the one you're actively viewing (selected worktree, focused pane, key + visible window). | -| `moveNotifiedWorktreeToTop` | Bool | `true` | Float a notified worktree to top. | -| `commandFinishedNotificationEnabled` | Bool | `true` | Notify when a long command finishes. | -| `commandFinishedNotificationThreshold` | Int (seconds) | `10` | Minimum duration before that notification fires. | -| `analyticsEnabled` | Bool | `true` | Send usage analytics (PostHog; off in Debug). | -| `crashReportsEnabled` | Bool | `true` | Send crash reports (Sentry). | -| `githubIntegrationEnabled` | Bool | `true` | Enable GitHub/PR features (via `gh`). | -| `deleteBranchOnAutomaticCleanup` | Bool | `false` | Delete the local branch when automatic cleanup (merged-PR delete action, archived auto-delete) removes a Prowl-created worktree. Migrates the legacy `deleteBranchOnDeleteWorktree` key. The manual delete dialog is independent: it remembers the last confirmed choice in UserDefaults (`deleteBranchOnManualWorktreeDelete`). | -| `mergedWorktreeAction` | enum? | `nil` | What to do with a merged worktree (e.g. auto-archive); `nil` = ask. | -| `promptForWorktreeCreation` | Bool | `true` | Show the creation dialog vs. auto-create. | -| `fetchOriginBeforeWorktreeCreation` | Bool | `true` | `git fetch` before creating a worktree. | -| `defaultWorktreeBaseDirectoryPath` | String? | `nil` | Default parent directory for new worktrees. | -| `copyIgnoredOnWorktreeCreate` | Bool | `false` | Copy `.gitignore`'d files into new worktrees. | -| `copyUntrackedOnWorktreeCreate` | Bool | `false` | Copy untracked files into new worktrees. | -| `pullRequestMergeStrategy` | enum (`merge`/`squash`/`rebase`) | `merge` | Default PR merge strategy. | -| `restoreTerminalLayoutOnLaunch` | Bool | `false` | Restore tabs/splits on launch. | -| `terminalFontSize` | Float32? | `nil` | Remembered terminal font size. | -| `archivedAutoDeletePeriod` | enum? (days) | `nil` | Auto-delete archived worktrees after N days; `nil` = never. | -| `keybindingUserOverrides` | object | empty | User keyboard-shortcut remappings. | -| `defaultViewMode` | enum (`normal`/`shelf`/`canvas`) | `normal` | View mode on launch. | -| `canvasDefaultLayout` | enum (`uniform`/`tile`) | `tile` | Initial Canvas layout: `uniform` packs same-size cards, `tile` resizes cards to fill the screen. | -| `dimUnfocusedSplits` | Bool | `true` | Dim panes that aren't focused. | -| `autoShowActiveAgentsPanel` | Bool | `false` | Auto-open the Active Agents panel on a new agent. | -| `showActiveAgentTabTitles` | Bool | `false` | Show pane titles (surface title, falling back to the tab title; vs. branch) in the agents panel. | -| `showActiveAgentStatusInShelf` | Bool | `true` | Show agent status markers on Shelf tab icons. | -| `windowTintMode` | enum (`none`/`repositoryColor`/`custom`) | `repositoryColor` | How the window chrome is tinted. | -| `windowTintCustomColor` | color | default | The custom tint color (when `windowTintMode = custom`). | -| `showRunButtonInToolbar` | Bool | `true` | Show the Run Script button in the toolbar. | -| `showDefaultEditorInToolbar` | Bool | `true` | Show the open-in-editor button in the toolbar. | -| `dockBounceMode` | enum (`off`/`once`/`continuous`) | `off` | Dock bounce on notification. | -| `showNotificationDotOnDock` | Bool | `false` | Numeric unread badge on the Dock icon. | -| `shelfSpineTintFallback` | enum (`neutral`/`systemTint`) | `neutral` | Shelf spine color when a repo has no color. | -| `shelfSpineTintFollowsRepositoryColor` | Bool | `true` | Tint shelf spines by repo color. | -| `externalDiffToolID` | String | `built-in` | Tool used by diff badges and Show Diff: `built-in`, `hunk`, `filemerge`, `kaleidoscope`, or `custom`. | -| `externalDiffCustomCommand` | String | `""` | Command template for `externalDiffToolID = custom`; supports `{leftPath}`, `{rightPath}`, `{worktreePath}`, `{repoPath}`, and `{branch}`. | -| `detectRepositoryIconsAutomatically` | Bool | `true` | Scan newly added repositories locally for a high-confidence project icon (app icon, launcher, favicon/logo) and use it as the repo icon. Applies to future additions only; never replaces a manual icon. | +| Field | Type | Default | Effect | +| -------------------------------------- | ------------------------------------------------------------------------ | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `appearanceMode` | enum (`system`/`light`/`dark`) | `dark` | App appearance. | +| `defaultEditorID` | String | `auto` | Default app to open worktrees (overridable per repo); `auto` prefers an app matching the detected project type. | +| `confirmBeforeQuit` | Bool | `true` | Confirm before quitting Prowl. | +| `updatesAutomaticallyCheckForUpdates` | Bool | `true` | Background update checks. | +| `updatesAutomaticallyDownloadUpdates` | Bool | `false` | Auto-download updates. | +| `inAppNotificationsEnabled` | Bool | `true` | In-app alerts / bell indicators. | +| `notificationSound` | enum (`never` / system sound raw values like `hero` / `supacodeClassic`) | `supacodeClassic` | Sound played for notifications when system banners are off; `never` disables it. Migrates the legacy `notificationSoundEnabled` Bool (`true` → `supacodeClassic`, `false` → `never`); unknown raw values fall back to the default. | +| `systemNotificationsEnabled` | Bool | `false` | macOS system banners. | +| `muteNotificationsForActiveSurface` | Bool | `true` | Suppress the banner, sound, and dock bounce when the notification's pane is the one you're actively viewing (selected worktree, focused pane, key + visible window). | +| `moveNotifiedWorktreeToTop` | Bool | `true` | Float a notified worktree to top. | +| `commandFinishedNotificationEnabled` | Bool | `true` | Notify when a long command finishes. | +| `commandFinishedNotificationThreshold` | Int (seconds) | `10` | Minimum duration before that notification fires. | +| `analyticsEnabled` | Bool | `true` | Send usage analytics (PostHog; off in Debug). | +| `crashReportsEnabled` | Bool | `true` | Send crash reports (Sentry). | +| `githubIntegrationEnabled` | Bool | `true` | Enable GitHub/PR features (via `gh`). | +| `deleteBranchOnAutomaticCleanup` | Bool | `false` | Delete the local branch when automatic cleanup (merged-PR delete action, archived auto-delete) removes a Prowl-created worktree. Migrates the legacy `deleteBranchOnDeleteWorktree` key. The manual delete dialog is independent: it remembers the last confirmed choice in UserDefaults (`deleteBranchOnManualWorktreeDelete`). | +| `mergedWorktreeAction` | enum? | `nil` | What to do with a merged worktree (e.g. auto-archive); `nil` = ask. | +| `promptForWorktreeCreation` | Bool | `true` | Show the creation dialog vs. auto-create. | +| `fetchOriginBeforeWorktreeCreation` | Bool | `true` | `git fetch` before creating a worktree. | +| `defaultWorktreeBaseDirectoryPath` | String? | `nil` | Default parent directory for new worktrees. | +| `copyIgnoredOnWorktreeCreate` | Bool | `false` | Copy `.gitignore`'d files into new worktrees. | +| `copyUntrackedOnWorktreeCreate` | Bool | `false` | Copy untracked files into new worktrees. | +| `pullRequestMergeStrategy` | enum (`merge`/`squash`/`rebase`) | `merge` | Default PR merge strategy. | +| `restoreTerminalLayoutOnLaunch` | Bool | `false` | Restore tabs/splits on launch. | +| `terminalFontSize` | Float32? | `nil` | Remembered terminal font size. | +| `archivedAutoDeletePeriod` | enum? (days) | `nil` | Auto-delete archived worktrees after N days; `nil` = never. | +| `keybindingUserOverrides` | object | empty | User keyboard-shortcut remappings. | +| `defaultViewMode` | enum (`normal`/`shelf`/`canvas`) | `normal` | View mode on launch. | +| `canvasDefaultLayout` | enum (`uniform`/`tile`) | `tile` | Initial Canvas layout: `uniform` packs same-size cards, `tile` resizes cards to fill the screen. | +| `dimUnfocusedSplits` | Bool | `true` | Dim panes that aren't focused. | +| `autoShowActiveAgentsPanel` | Bool | `false` | Auto-open the Active Agents panel on a new agent. | +| `showActiveAgentTabTitles` | Bool | `false` | Show pane titles (surface title, falling back to the tab title; vs. branch) in the agents panel. | +| `showActiveAgentStatusInShelf` | Bool | `true` | Show agent status markers on Shelf tab icons. | +| `windowTintMode` | enum (`none`/`repositoryColor`/`custom`) | `repositoryColor` | How the window chrome is tinted. | +| `windowTintCustomColor` | color | default | The custom tint color (when `windowTintMode = custom`). | +| `showRunButtonInToolbar` | Bool | `true` | Show the Run Script button in the toolbar. | +| `showDefaultEditorInToolbar` | Bool | `true` | Show the open-in-editor button in the toolbar. | +| `dockBounceMode` | enum (`off`/`once`/`continuous`) | `off` | Dock bounce on notification. | +| `showNotificationDotOnDock` | Bool | `false` | Numeric unread badge on the Dock icon. | +| `shelfSpineTintFallback` | enum (`neutral`/`systemTint`) | `neutral` | Shelf spine color when a repo has no color. | +| `shelfSpineTintFollowsRepositoryColor` | Bool | `true` | Tint shelf spines by repo color. | +| `externalDiffToolID` | String | `built-in` | Tool used by diff badges and Show Diff: `built-in`, `hunk`, `filemerge`, `kaleidoscope`, or `custom`. | +| `externalDiffCustomCommand` | String | `""` | Command template for `externalDiffToolID = custom`; supports `{leftPath}`, `{rightPath}`, `{worktreePath}`, `{repoPath}`, and `{branch}`. | +| `detectRepositoryIconsAutomatically` | Bool | `true` | Scan newly added repositories locally for a high-confidence project icon (app icon, launcher, favicon/logo) and use it as the repo icon. Applies to future additions only; never replaces a manual icon. | +| `interfaceTextScale` | enum (`system`/`110`/`125`/`150`) | `system` | Multiplies every app text style by this percentage, so headings stay larger than body text and body text stays larger than captions. Applied before `minimumTextSize`. Terminal content is unaffected (see `terminalFontSize` and Ghostty config). Unknown raw values fall back to the default. | +| `minimumTextSize` | enum (`system`/`11`/`12`/`13`/`14`/`16`) | `system` | Point-size floor applied after `interfaceTextScale`, like Safari's "Never use font sizes smaller than": styles below the floor are lifted to it, larger styles are untouched. A floor therefore makes every style beneath it render at the same size — at 16pt, caption through headline are all 16 — so use `interfaceTextScale` to enlarge text while keeping the styles distinct. Terminal content is unaffected. Unknown raw values fall back to the default. | ## Per-repository settings (`RepositorySettings`) Stored at `~/.prowl/repo//prowl.json` (schema v2). For the tri-state `Bool?` fields, `nil` means "inherit the global setting." -| Field | Type | Default | Effect | -|-------|------|---------|--------| -| `setupScript` | String | `""` | Script run automatically after a worktree is created. | -| `archiveScript` | String | `""` | Script run automatically before a worktree is archived. | -| `runScript` | String | `""` | The on-demand Run Script (`⌘R`). | -| `openActionID` | String | `auto` | App to open this repo's worktrees (overrides `defaultEditorID`); `auto` prefers an app matching the detected project type. | -| `worktreeBaseRef` | String? | `nil` | Default base branch/ref for new worktrees. | -| `worktreeBaseDirectoryPath` | String? | `nil` | Parent directory for new worktrees (overrides global). | -| `copyIgnoredOnWorktreeCreate` | Bool? | `nil` | Copy ignored files; `nil` = use global. | -| `copyUntrackedOnWorktreeCreate` | Bool? | `nil` | Copy untracked files; `nil` = use global. | -| `pullRequestMergeStrategy` | enum? | `nil` | PR merge strategy; `nil` = use global. | -| `githubAccountOverride` | object? | `nil` | Optional `{ "host": "...", "login": "..." }`; Prowl temporarily switches `gh` to this account for GitHub operations in this repo. | -| `customTitle` | String? | `nil` | Display name override for the repository. | -| `observeLineDiffsAutomatically` | Bool? | `nil` (= on) | Keep worktree line-change badges updated; set `false` for large repos. | -| `fetchPullRequestState` | Bool? | `nil` (= on) | Background-fetch PR state; set `false` to save GitHub rate limit. | +| Field | Type | Default | Effect | +| ------------------------------- | ------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------- | +| `setupScript` | String | `""` | Script run automatically after a worktree is created. | +| `archiveScript` | String | `""` | Script run automatically before a worktree is archived. | +| `runScript` | String | `""` | The on-demand Run Script (`⌘R`). | +| `openActionID` | String | `auto` | App to open this repo's worktrees (overrides `defaultEditorID`); `auto` prefers an app matching the detected project type. | +| `worktreeBaseRef` | String? | `nil` | Default base branch/ref for new worktrees. | +| `worktreeBaseDirectoryPath` | String? | `nil` | Parent directory for new worktrees (overrides global). | +| `copyIgnoredOnWorktreeCreate` | Bool? | `nil` | Copy ignored files; `nil` = use global. | +| `copyUntrackedOnWorktreeCreate` | Bool? | `nil` | Copy untracked files; `nil` = use global. | +| `pullRequestMergeStrategy` | enum? | `nil` | PR merge strategy; `nil` = use global. | +| `githubAccountOverride` | object? | `nil` | Optional `{ "host": "...", "login": "..." }`; Prowl temporarily switches `gh` to this account for GitHub operations in this repo. | +| `customTitle` | String? | `nil` | Display name override for the repository. | +| `observeLineDiffsAutomatically` | Bool? | `nil` (= on) | Keep worktree line-change badges updated; set `false` for large repos. | +| `fetchPullRequestState` | Bool? | `nil` (= on) | Background-fetch PR state; set `false` to save GitHub rate limit. | **Custom Commands** live separately in `prowl.onevcat.json`. Each command has an `isEnabled` Boolean that defaults to `true`; turning it off preserves its structure diff --git a/supacode/App/AppLoadingView.swift b/supacode/App/AppLoadingView.swift index e517dc47e..70e899c71 100644 --- a/supacode/App/AppLoadingView.swift +++ b/supacode/App/AppLoadingView.swift @@ -30,7 +30,7 @@ struct AppLoadingView: View { var body: some View { VStack { Text(Self.messages[messageIndex]) - .font(.title3) + .interfaceFont(.title3) .bold() ProgressView() .controlSize(.large) diff --git a/supacode/App/ContentView.swift b/supacode/App/ContentView.swift index a06608905..4f0d20766 100644 --- a/supacode/App/ContentView.swift +++ b/supacode/App/ContentView.swift @@ -210,7 +210,11 @@ struct ContentView: View { ) return NavigationSplitView(columnVisibility: visibility) { SidebarView(store: repositoriesStore, terminalManager: terminalManager) - .navigationSplitViewColumnWidth(min: 220, ideal: 260, max: 320) + .navigationSplitViewColumnWidth( + min: 220, + ideal: 260, + max: 320 + ) } detail: { WorktreeDetailView(store: store, terminalManager: terminalManager) } @@ -282,7 +286,7 @@ private struct RunScriptPromptView: View { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 4) { Text("Run") - .font(.title3) + .interfaceFont(.title3) Text("Enter a command to run in this worktree. It will be saved to repository settings.") .foregroundStyle(.secondary) } diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index d77ed8e78..daa6e8142 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -1734,6 +1734,13 @@ struct SupacodeApp: App { .environment(ghosttyShortcuts) .environment(commandKeyObserver) .environment(\.resolvedKeybindings, store.resolvedKeybindings) + .environment( + \.interfaceText, + InterfaceTextResolution( + scale: store.settings.interfaceTextScale.factor, + minimumSize: store.settings.minimumTextSize.points ?? 0 + ) + ) .environment(askAgentHelp) .sheet( isPresented: Binding( diff --git a/supacode/Features/ActiveAgents/Views/ActiveAgentRow.swift b/supacode/Features/ActiveAgents/Views/ActiveAgentRow.swift index 88c0cb05e..1c3705a3f 100644 --- a/supacode/Features/ActiveAgents/Views/ActiveAgentRow.swift +++ b/supacode/Features/ActiveAgents/Views/ActiveAgentRow.swift @@ -15,7 +15,7 @@ struct ActiveAgentRow: View { VStack(alignment: .leading, spacing: 2) { title Text(subtitle) - .font(.caption) + .interfaceFont(.caption) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.tail) @@ -32,13 +32,13 @@ struct ActiveAgentRow: View { private var title: some View { HStack(alignment: .firstTextBaseline, spacing: 3) { Text(entry.displayName) - .font(.body.weight(.medium)) + .interfaceFont(.body, weight: .medium) .foregroundStyle(.primary) Text("·") - .font(.caption.weight(.semibold)) + .interfaceFont(.caption, weight: .semibold) .foregroundStyle(.tertiary) Text(repositoryName) - .font(.callout.weight(.medium)) + .interfaceFont(.callout, weight: .medium) .foregroundStyle(repositoryColor?.color ?? .secondary) } .lineLimit(1) @@ -73,7 +73,7 @@ struct ActiveAgentRow: View { private var statusText: some View { Text(entry.displayState.label) - .font(.caption2.weight(.semibold)) + .interfaceFont(.caption2, weight: .semibold) .lineLimit(1) } } @@ -108,7 +108,7 @@ struct BaguaWorkingIndicator: View { private func frameText(_ frame: String) -> some View { Text(frame) - .font(.system(size: 17, weight: .bold, design: .monospaced)) + .fixedInterfaceFont(.system(size: 17, weight: .bold, design: .monospaced)) .lineLimit(1) .frame(width: 20, height: 18) .accessibilityHidden(true) diff --git a/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift b/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift index 7755b9f86..f6fcec361 100644 --- a/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift +++ b/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift @@ -24,7 +24,7 @@ struct ActiveAgentsPanel: View { resizeHandle HStack { Text("Active Agents") - .font(.caption) + .interfaceFont(.caption) .foregroundStyle(.secondary) Spacer() if let navigationShortcutHint, !store.entries.isEmpty { @@ -40,7 +40,7 @@ struct ActiveAgentsPanel: View { if store.entries.isEmpty { Spacer(minLength: 0) Text("New agents will appear here") - .font(.callout) + .interfaceFont(.callout) .foregroundStyle(.secondary) // Nudge up slightly off dead-center for better visual balance. .offset(y: -8) diff --git a/supacode/Features/Canvas/Views/CanvasCardView.swift b/supacode/Features/Canvas/Views/CanvasCardView.swift index e17f94d99..7c30417d0 100644 --- a/supacode/Features/Canvas/Views/CanvasCardView.swift +++ b/supacode/Features/Canvas/Views/CanvasCardView.swift @@ -147,7 +147,7 @@ struct CanvasCardView: View { ) } Text(repositoryName) - .font(.caption.bold()) + .interfaceFont(.caption, weight: .bold) .lineLimit(1) HStack(spacing: 3) { Text("/") @@ -156,7 +156,7 @@ struct CanvasCardView: View { } Text(worktreeName) } - .font(.caption) + .interfaceFont(.caption) .foregroundStyle(.secondary) .lineLimit(1) Spacer() @@ -203,7 +203,7 @@ struct CanvasCardView: View { ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right" ) - .font(.caption2.weight(.semibold)) + .interfaceFont(.caption2, weight: .semibold) .frame(width: 18, height: 18) .contentShape(.rect) } @@ -216,7 +216,7 @@ struct CanvasCardView: View { onClose() } label: { Image(systemName: "xmark") - .font(.caption2.weight(.semibold)) + .interfaceFont(.caption2, weight: .semibold) .frame(width: 18, height: 18) .contentShape(.rect) } diff --git a/supacode/Features/Canvas/Views/CanvasHelpButton.swift b/supacode/Features/Canvas/Views/CanvasHelpButton.swift index 3a4d5c1db..58065d2be 100644 --- a/supacode/Features/Canvas/Views/CanvasHelpButton.swift +++ b/supacode/Features/Canvas/Views/CanvasHelpButton.swift @@ -18,7 +18,7 @@ struct CanvasHelpButton: View { togglePresentation() } label: { Image(systemName: "questionmark.circle") - .font(.body) + .interfaceFont(.body) .accessibilityLabel("Canvas navigation help") } .buttonStyle(.bordered) @@ -61,7 +61,7 @@ struct CanvasHelpButton: View { return VStack(alignment: .leading, spacing: 14) { Text("Canvas Navigation") - .font(.headline) + .interfaceFont(.headline) VStack(alignment: .leading, spacing: 12) { canvasHelpRow( @@ -100,9 +100,9 @@ struct CanvasHelpButton: View { .frame(width: 18) .accessibilityHidden(true) VStack(alignment: .leading, spacing: 2) { - Text(title).font(.callout).fontWeight(.medium) + Text(title).interfaceFont(.callout).fontWeight(.medium) Text(detail) - .font(.caption) + .interfaceFont(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } diff --git a/supacode/Features/Canvas/Views/CanvasSidebarButton.swift b/supacode/Features/Canvas/Views/CanvasSidebarButton.swift index 08cabbe13..5b6a27427 100644 --- a/supacode/Features/Canvas/Views/CanvasSidebarButton.swift +++ b/supacode/Features/Canvas/Views/CanvasSidebarButton.swift @@ -13,7 +13,7 @@ struct CanvasSidebarButton: View { } label: { HStack(spacing: 6) { Label("Canvas", systemImage: "square.grid.2x2") - .font(.callout) + .interfaceFont(.callout) .frame(maxWidth: .infinity, alignment: .leading) if commandKeyObserver.isPressed, let shortcut = AppShortcuts.display(for: AppShortcuts.CommandID.toggleCanvas, in: resolvedKeybindings) diff --git a/supacode/Features/Canvas/Views/CanvasView.swift b/supacode/Features/Canvas/Views/CanvasView.swift index f9b66f1bd..e2a2cc1a4 100644 --- a/supacode/Features/Canvas/Views/CanvasView.swift +++ b/supacode/Features/Canvas/Views/CanvasView.swift @@ -755,7 +755,7 @@ struct CanvasView: View { "Broadcasting to \(selectionState.selectedTabIDs.count) cards", systemImage: "dot.radiowaves.left.and.right" ) - .font(.callout) + .interfaceFont(.callout) .padding(.horizontal, 10) .padding(.vertical, 6) .background(.bar, in: Capsule()) @@ -765,7 +765,7 @@ struct CanvasView: View { selectAllCards() } label: { Image(systemName: "checkmark.rectangle.stack") - .font(.body) + .interfaceFont(.body) .accessibilityLabel("Select All") } .buttonStyle(.bordered) @@ -782,7 +782,7 @@ struct CanvasView: View { arrangeCardsWithFit() } label: { Image(systemName: "rectangle.3.group") - .font(.body) + .interfaceFont(.body) .accessibilityLabel("Arrange") } .buttonStyle(.bordered) @@ -797,7 +797,7 @@ struct CanvasView: View { organizeCardsWithFit() } label: { Image(systemName: "square.grid.2x2") - .font(.body) + .interfaceFont(.body) .accessibilityLabel("Organize") } .buttonStyle(.bordered) @@ -812,7 +812,7 @@ struct CanvasView: View { tileCardsWithFit() } label: { Image(systemName: "rectangle.split.2x1") - .font(.body) + .interfaceFont(.body) .accessibilityLabel("Tile") } .buttonStyle(.bordered) diff --git a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift index b500e13f7..62a959177 100644 --- a/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift +++ b/supacode/Features/CommandPalette/Views/CommandPaletteOverlayView.swift @@ -498,7 +498,7 @@ private struct CommandPaletteSectionHeader: View { var body: some View { Text(title.uppercased()) - .font(.caption2.weight(.semibold)) + .interfaceFont(.caption2, weight: .semibold) .foregroundStyle(.secondary) .padding(.horizontal, 6) .padding(.bottom, 2) @@ -671,7 +671,7 @@ private struct CommandPaletteRowView: View { } else if let leadingIcon { Image(systemName: leadingIcon) .foregroundStyle(emphasis ? .primary : .secondary) - .font(.subheadline.weight(.medium)) + .interfaceFont(.subheadline, weight: .medium) .frame(width: 16, height: 16, alignment: .center) .accessibilityHidden(true) } @@ -682,7 +682,7 @@ private struct CommandPaletteRowView: View { if let subtitle = row.subtitle { Text(subtitle) - .font(.caption) + .interfaceFont(.caption) .foregroundStyle(.secondary) } } @@ -691,7 +691,7 @@ private struct CommandPaletteRowView: View { if let badge, !badge.isEmpty { Text(badge) - .font(.caption2.weight(.medium)) + .interfaceFont(.caption2, weight: .medium) .padding(.horizontal, 7) .padding(.vertical, 3) .background( diff --git a/supacode/Features/Debug/Views/IconCatalogView.swift b/supacode/Features/Debug/Views/IconCatalogView.swift index f272ca249..b4f46980a 100644 --- a/supacode/Features/Debug/Views/IconCatalogView.swift +++ b/supacode/Features/Debug/Views/IconCatalogView.swift @@ -53,9 +53,9 @@ import SwiftUI .frame(width: 32, height: 32, alignment: .center) VStack(alignment: .leading, spacing: 2) { Text(token) - .font(.body.monospaced().weight(.semibold)) + .interfaceFont(.body, weight: .semibold, design: .monospaced) Text(detailLine) - .font(.caption.monospaced()) + .interfaceFont(.caption, design: .monospaced) .foregroundStyle(.secondary) } Spacer(minLength: 0) diff --git a/supacode/Features/DiffView/DiffWindowContentView.swift b/supacode/Features/DiffView/DiffWindowContentView.swift index dd240ecf4..8e782a636 100644 --- a/supacode/Features/DiffView/DiffWindowContentView.swift +++ b/supacode/Features/DiffView/DiffWindowContentView.swift @@ -214,7 +214,7 @@ struct DiffWindowContentView: View { .foregroundStyle(.orange) .accessibilityHidden(true) Text(renderError.message) - .font(.caption) + .interfaceFont(.caption) .multilineTextAlignment(.center) } .padding(12) @@ -269,17 +269,17 @@ private struct FileRowView: View { var body: some View { HStack(spacing: 6) { Text(file.statusSymbol) - .font(.caption) + .interfaceFont(.caption) .monospaced() .foregroundStyle(file.status.color) .frame(width: 14, alignment: .center) VStack(alignment: .leading, spacing: 1) { Text(file.displayName) - .font(.body) + .interfaceFont(.body) .lineLimit(1) if !file.directoryPath.isEmpty { Text(file.directoryPath) - .font(.caption) + .interfaceFont(.caption) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.head) diff --git a/supacode/Features/HandoffHud/Views/HandoffHudOverlayView.swift b/supacode/Features/HandoffHud/Views/HandoffHudOverlayView.swift index 34b7c7ad2..733d7c7bc 100644 --- a/supacode/Features/HandoffHud/Views/HandoffHudOverlayView.swift +++ b/supacode/Features/HandoffHud/Views/HandoffHudOverlayView.swift @@ -89,9 +89,9 @@ private struct HandoffHudCard: View { private var header: some View { VStack(alignment: .leading, spacing: 2) { Text(headerTitle) - .font(.headline) + .interfaceFont(.headline) Text(headerSubtitle) - .font(.subheadline) + .interfaceFont(.subheadline) .foregroundStyle(.secondary) } .padding(16) @@ -230,10 +230,10 @@ private struct HandoffTargetRow: View { VStack(alignment: .leading, spacing: 1) { HStack(spacing: 6) { Text(target.title) - .font(.body) + .interfaceFont(.body) if target.isCurrentAgent { Text("fresh session") - .font(.caption2) + .interfaceFont(.caption2) .foregroundStyle(.secondary) .padding(.horizontal, 5) .padding(.vertical, 1) @@ -241,7 +241,7 @@ private struct HandoffTargetRow: View { } } Text(target.subtitle) - .font(.caption) + .interfaceFont(.caption) .foregroundStyle(.secondary) } Spacer(minLength: 0) @@ -304,7 +304,7 @@ private struct HandoffHudRunView: View { ProgressView() .controlSize(.small) Text(stageDescription) - .font(.body) + .interfaceFont(.body) Spacer(minLength: 0) } .padding(16) @@ -313,7 +313,7 @@ private struct HandoffHudRunView: View { HStack { Text(footerHint) - .font(.caption) + .interfaceFont(.caption) .foregroundStyle(.secondary) Spacer() if run.stage == .requesting { @@ -378,7 +378,7 @@ private struct HandoffHudFinishedView: View { HStack(spacing: 10) { icon Text(message) - .font(.body) + .interfaceFont(.body) Spacer(minLength: 0) } .padding(16) @@ -403,12 +403,12 @@ private struct HandoffHudFinishedView: View { case .handedOff, .briefSaved: Image(systemName: "checkmark.circle.fill") .foregroundStyle(.green) - .font(.title2) + .interfaceFont(.title2) .accessibilityLabel("Success") case .failed: Image(systemName: "exclamationmark.triangle.fill") .foregroundStyle(.yellow) - .font(.title2) + .interfaceFont(.title2) .accessibilityLabel("Failure") } } diff --git a/supacode/Features/Help/AskAgentHelpView.swift b/supacode/Features/Help/AskAgentHelpView.swift index 3831d2cc7..148b4f508 100644 --- a/supacode/Features/Help/AskAgentHelpView.swift +++ b/supacode/Features/Help/AskAgentHelpView.swift @@ -28,16 +28,16 @@ struct AskAgentHelpView: View { var body: some View { VStack(alignment: .leading, spacing: 16) { Label(strings.title, systemImage: "sparkles") - .font(.headline) + .interfaceFont(.headline) Text(strings.explanation) - .font(.callout) + .interfaceFont(.callout) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) ScrollView { Text(strings.prompt) - .font(.system(.callout, design: .monospaced)) + .interfaceFont(.callout, design: .monospaced) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) .padding(12) diff --git a/supacode/Features/Repositories/Views/AddToProwlView.swift b/supacode/Features/Repositories/Views/AddToProwlView.swift index d33ecd9b3..6d24f3ff9 100644 --- a/supacode/Features/Repositories/Views/AddToProwlView.swift +++ b/supacode/Features/Repositories/Views/AddToProwlView.swift @@ -31,9 +31,9 @@ struct AddToProwlView: View { .accessibilityHidden(true) VStack(alignment: .leading, spacing: 2) { Text("Add to Prowl") - .font(.system(size: 16, weight: .semibold)) + .interfaceFont(size: 16, weight: .semibold) Text("Bring a project in for an agent to work on.") - .font(.system(size: 12.5)) + .interfaceFont(size: 12.5) .foregroundStyle(.secondary) } } @@ -66,7 +66,7 @@ struct AddToProwlView: View { private var dropZone: some View { VStack(spacing: 2) { Image(systemName: "folder.badge.plus") - .font(.system(size: 28)) + .interfaceFont(size: 28) .symbolRenderingMode(.hierarchical) .foregroundStyle(Color.accentColor) .frame(width: 52, height: 52) @@ -78,11 +78,11 @@ struct AddToProwlView: View { .scaleEffect(isDragTargeted ? 1.06 : 1) Text(isDragTargeted ? "Release to add" : "Drag a repo here") - .font(.system(size: 15, weight: .semibold)) + .interfaceFont(size: 15, weight: .semibold) .padding(.top, 10) Text("a Git repository or any folder — opens one project root") - .font(.system(size: 12)) + .interfaceFont(size: 12) .foregroundStyle(.secondary) HStack(spacing: 8) { @@ -128,7 +128,7 @@ struct AddToProwlView: View { HStack(spacing: 12) { Rectangle().fill(.separator).frame(height: 1) Text("OR") - .font(.system(size: 10.5, weight: .semibold)) + .interfaceFont(size: 10.5, weight: .semibold) .foregroundStyle(.tertiary) Rectangle().fill(.separator).frame(height: 1) } @@ -141,7 +141,7 @@ struct AddToProwlView: View { } label: { HStack(spacing: 13) { Image(systemName: "rectangle.stack") - .font(.system(size: 18)) + .interfaceFont(size: 18) .foregroundStyle(.secondary) .frame(width: 38, height: 38) .background(.quaternary, in: .rect(cornerRadius: 10)) @@ -149,9 +149,9 @@ struct AddToProwlView: View { VStack(alignment: .leading, spacing: 1) { Text("Add Workspace") - .font(.system(size: 13.5, weight: .semibold)) + .interfaceFont(size: 13.5, weight: .semibold) Text("A shared task folder spanning multiple repos for one agent.") - .font(.system(size: 11.5)) + .interfaceFont(size: 11.5) .foregroundStyle(.secondary) .lineLimit(2) } @@ -159,7 +159,7 @@ struct AddToProwlView: View { Spacer() Image(systemName: "chevron.right") - .font(.system(size: 12, weight: .semibold)) + .interfaceFont(size: 12, weight: .semibold) .foregroundStyle(.tertiary) .offset(x: isWorkspaceHovered ? 2 : 0) .accessibilityHidden(true) diff --git a/supacode/Features/Repositories/Views/AgentsToolbarButton.swift b/supacode/Features/Repositories/Views/AgentsToolbarButton.swift index 69da146d1..9dd551a6b 100644 --- a/supacode/Features/Repositories/Views/AgentsToolbarButton.swift +++ b/supacode/Features/Repositories/Views/AgentsToolbarButton.swift @@ -3,7 +3,11 @@ import SwiftUI enum LeadingToolbarControlMetrics { static let labelSpacing: CGFloat = 6 static let iconSize: CGFloat = 20 - static let font: Font = .title3.weight(.medium) + /// Carried as style plus weight rather than a resolved `Font` so call sites + /// can apply it through `interfaceFont`, which reads the minimum text size + /// from the environment. A static `Font` cannot see that floor. + static let textStyle: Font.TextStyle = .title3 + static let fontWeight: Font.Weight = .medium } /// What the Agents capsule shows for the selected pane's detected agent. @@ -112,7 +116,7 @@ struct AgentsToolbarButton: View { Text("Agents") } } - .font(LeadingToolbarControlMetrics.font) + .interfaceFont(LeadingToolbarControlMetrics.textStyle, weight: LeadingToolbarControlMetrics.fontWeight) } @ViewBuilder @@ -155,7 +159,7 @@ struct AgentsQuickLaunchButton: View { onLaunch(item.id) } label: { Image(systemName: "play.circle") - .font(LeadingToolbarControlMetrics.font) + .interfaceFont(LeadingToolbarControlMetrics.textStyle, weight: LeadingToolbarControlMetrics.fontWeight) .foregroundStyle(.secondary) } .help("Launch \(item.name) in this worktree") @@ -190,7 +194,7 @@ private struct AgentsPopoverContent: View { } if !launcherItems.isEmpty { Text("New agent in this worktree") - .font(.caption) + .interfaceFont(.caption) .foregroundStyle(.secondary) .padding(.horizontal, 8) .padding(.top, 4) @@ -218,6 +222,7 @@ private struct AgentsPopoverContent: View { ) } .padding(6) + .interfaceFont(.body) .frame(width: 280, alignment: .leading) .accessibilityElement(children: .contain) .accessibilityLabel("Agents") @@ -246,14 +251,15 @@ private struct AgentsPopoverRow: View { @State private var isHovered = false var body: some View { + let iconSlot: CGFloat = 16 Button(action: action) { HStack(alignment: .top, spacing: 8) { if let iconSource { - AgentProfileIconImage(source: iconSource, pointSize: 16) - .frame(width: 16) + AgentProfileIconImage(source: iconSource, pointSize: iconSlot) + .frame(width: iconSlot) } else { Image(systemName: systemImage) - .frame(width: 16) + .frame(width: iconSlot) .accessibilityHidden(true) } VStack(alignment: .leading, spacing: 2) { @@ -263,13 +269,13 @@ private struct AgentsPopoverRow: View { Spacer(minLength: 0) if let trailingText { Text(trailingText) - .font(.caption) + .interfaceFont(.caption) .foregroundStyle(.tertiary) } } if let subtitle { Text(subtitle) - .font(.caption) + .interfaceFont(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } diff --git a/supacode/Features/Repositories/Views/ArchivedWorktreeRowView.swift b/supacode/Features/Repositories/Views/ArchivedWorktreeRowView.swift index befce50e5..201298f41 100644 --- a/supacode/Features/Repositories/Views/ArchivedWorktreeRowView.swift +++ b/supacode/Features/Repositories/Views/ArchivedWorktreeRowView.swift @@ -1,4 +1,3 @@ -import AppKit import SwiftUI struct ArchivedWorktreeRowView: View { @@ -6,6 +5,7 @@ struct ArchivedWorktreeRowView: View { let info: WorktreeInfoEntry? let onUnarchive: () -> Void let onDelete: () -> Void + @Environment(\.interfaceText) private var interfaceText var body: some View { let display = WorktreePullRequestDisplay( @@ -13,11 +13,11 @@ struct ArchivedWorktreeRowView: View { pullRequest: info?.pullRequest ) let deleteShortcut = KeyboardShortcut(.delete, modifiers: [.command, .shift]).display - let bodyFontAscender = NSFont.preferredFont(forTextStyle: .body).ascender + let bodyFontAscender = InterfaceTextMetrics.bodyAscender(resolution: interfaceText) VStack(alignment: .leading, spacing: 2) { HStack(alignment: .firstTextBaseline, spacing: 8) { Image(systemName: "archivebox") - .font(.caption) + .interfaceFont(.caption) .foregroundStyle(.secondary) .accessibilityHidden(true) .frame(width: 16, height: 16) @@ -25,7 +25,7 @@ struct ArchivedWorktreeRowView: View { bodyFontAscender } Text(worktree.name) - .font(.body) + .interfaceFont(.body) .lineLimit(1) Spacer(minLength: 8) HStack(spacing: 8) { @@ -55,15 +55,19 @@ struct ArchivedWorktreeRowView: View { Spacer(minLength: 0) WorktreePullRequestAccessoryView(display: display) } - .font(.caption) + .interfaceFont(.caption) .lineLimit(1) - .frame(minHeight: 14) + .frame(minHeight: 14 + InterfaceTextMetrics.extraHeight(.caption, resolution: interfaceText)) .padding(.leading, 24) } .frame(height: rowHeight, alignment: .center) } + /// Base height plus whatever the text floor adds to the two lines + /// (body name, caption info) the row stacks. private var rowHeight: CGFloat { 50 + + InterfaceTextMetrics.extraHeight(.body, resolution: interfaceText) + + InterfaceTextMetrics.extraHeight(.caption, resolution: interfaceText) } } diff --git a/supacode/Features/Repositories/Views/ArchivedWorktreesDetailView.swift b/supacode/Features/Repositories/Views/ArchivedWorktreesDetailView.swift index 59f7c4756..b618c666d 100644 --- a/supacode/Features/Repositories/Views/ArchivedWorktreesDetailView.swift +++ b/supacode/Features/Repositories/Views/ArchivedWorktreesDetailView.swift @@ -185,16 +185,16 @@ private struct ArchivedWorktreeSectionHeader: View { } label: { HStack(spacing: 6) { Image(systemName: "chevron.right") - .font(.caption2) + .interfaceFont(.caption2) .rotationEffect(.degrees(isCollapsed ? 0 : 90)) .foregroundStyle(.secondary) .accessibilityHidden(true) Text(name) - .font(.headline) + .interfaceFont(.headline) .foregroundStyle(.primary) .lineLimit(1) Text("(\(worktreeCount))") - .font(.headline) + .interfaceFont(.headline) .foregroundStyle(.secondary) Spacer() } diff --git a/supacode/Features/Repositories/Views/CloneRepositoryView.swift b/supacode/Features/Repositories/Views/CloneRepositoryView.swift index 9973b690f..b1eeda6b2 100644 --- a/supacode/Features/Repositories/Views/CloneRepositoryView.swift +++ b/supacode/Features/Repositories/Views/CloneRepositoryView.swift @@ -13,9 +13,9 @@ struct CloneRepositoryView: View { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 4) { Text("Clone") - .font(.system(size: 16, weight: .semibold)) + .interfaceFont(size: 16, weight: .semibold) Text("Clone a remote repository into a local directory") - .font(.system(size: 12.5)) + .interfaceFont(size: 12.5) .foregroundStyle(.secondary) } @@ -45,7 +45,7 @@ struct CloneRepositoryView: View { if let errorMessage { Text(errorMessage) - .font(.caption) + .interfaceFont(.caption) .foregroundStyle(.red) .textSelection(.enabled) } @@ -72,7 +72,7 @@ struct CloneRepositoryView: View { VStack(spacing: 8) { ProgressView() Text("Cloning…") - .font(.callout) + .interfaceFont(.callout) .foregroundStyle(.secondary) } .padding(18) diff --git a/supacode/Features/Repositories/Views/DeleteWorktreeConfirmationView.swift b/supacode/Features/Repositories/Views/DeleteWorktreeConfirmationView.swift index 2b6de67e5..84b1c775f 100644 --- a/supacode/Features/Repositories/Views/DeleteWorktreeConfirmationView.swift +++ b/supacode/Features/Repositories/Views/DeleteWorktreeConfirmationView.swift @@ -10,7 +10,7 @@ struct DeleteWorktreeConfirmationView: View { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 6) { Text(confirmation.title) - .font(.headline) + .interfaceFont(.headline) Text(confirmation.message) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -29,7 +29,7 @@ struct DeleteWorktreeConfirmationView: View { "Protected branches are kept. If safe branch deletion fails, Prowl asks before forcing it. " + "Your choice is remembered for the next delete." ) - .font(.footnote) + .interfaceFont(.footnote) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) diff --git a/supacode/Features/Repositories/Views/FailedRepositoryRow.swift b/supacode/Features/Repositories/Views/FailedRepositoryRow.swift index 52c5b60ad..e9f020567 100644 --- a/supacode/Features/Repositories/Views/FailedRepositoryRow.swift +++ b/supacode/Features/Repositories/Views/FailedRepositoryRow.swift @@ -10,9 +10,10 @@ struct FailedRepositoryRow: View { HStack(spacing: 8) { VStack(alignment: .leading, spacing: 2) { Text(name) + .interfaceFont(.body) .foregroundStyle(.secondary) Text(path) - .font(.caption) + .interfaceFont(.caption) .foregroundStyle(.tertiary) } Spacer(minLength: 8) diff --git a/supacode/Features/Repositories/Views/NotificationPopoverView.swift b/supacode/Features/Repositories/Views/NotificationPopoverView.swift index 593b9fd49..6e042f2e3 100644 --- a/supacode/Features/Repositories/Views/NotificationPopoverView.swift +++ b/supacode/Features/Repositories/Views/NotificationPopoverView.swift @@ -10,9 +10,9 @@ struct NotificationPopoverView: View { ScrollView { VStack(alignment: .leading) { Text("Notifications") - .font(.headline) + .interfaceFont(.headline) Text("\(count) \(countLabel)") - .font(.subheadline) + .interfaceFont(.subheadline) .foregroundStyle(.secondary) Divider() ForEach(notifications) { notification in @@ -30,7 +30,7 @@ struct NotificationPopoverView: View { .frame(maxWidth: .infinity, alignment: .leading) } .buttonStyle(.plain) - .font(.caption) + .interfaceFont(.caption) .help(notification.content.isEmpty ? "Focus pane" : notification.content) } } diff --git a/supacode/Features/Repositories/Views/OpenWorktreeActionMenuLabelView.swift b/supacode/Features/Repositories/Views/OpenWorktreeActionMenuLabelView.swift index 3929c212b..e36fdcb4f 100644 --- a/supacode/Features/Repositories/Views/OpenWorktreeActionMenuLabelView.swift +++ b/supacode/Features/Repositories/Views/OpenWorktreeActionMenuLabelView.swift @@ -22,14 +22,14 @@ struct OpenWorktreeActionMenuLabelView: View { if let shortcutHint { HStack(spacing: 2) { Text(action.labelTitle) - .font(.body) + .interfaceFont(.body) Text("(\(shortcutHint))") - .font(.body) + .interfaceFont(.body) .foregroundStyle(.secondary) } } else { Text(action.labelTitle) - .font(.body) + .interfaceFont(.body) } } } diff --git a/supacode/Features/Repositories/Views/PullRequestBadgeView.swift b/supacode/Features/Repositories/Views/PullRequestBadgeView.swift index 466e239bc..08248f920 100644 --- a/supacode/Features/Repositories/Views/PullRequestBadgeView.swift +++ b/supacode/Features/Repositories/Views/PullRequestBadgeView.swift @@ -43,7 +43,7 @@ struct PullRequestBadgeView: View { var body: some View { Text(text) - .font(.caption2) + .interfaceFont(.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 c7f173923..f97ba5f40 100644 --- a/supacode/Features/Repositories/Views/PullRequestChecksPopoverView.swift +++ b/supacode/Features/Repositories/Views/PullRequestChecksPopoverView.swift @@ -63,14 +63,14 @@ struct PullRequestChecksPopoverView: View { .pointerStyle(.link) .help(openPullRequestHelpText) .modifier(KeyboardShortcutModifier(shortcut: openPullRequestShortcut?.keyboardShortcut)) - .font(.headline) + .interfaceFont(.headline) } else { titleLine .lineLimit(1) - .font(.headline) + .interfaceFont(.headline) } summaryLine - .font(.subheadline) + .interfaceFont(.subheadline) .lineLimit(1) HStack { additionsText @@ -84,7 +84,7 @@ struct PullRequestChecksPopoverView: View { .foregroundStyle(.red) } } - .font(.subheadline) + .interfaceFont(.subheadline) if let mergeQueueStatus = PullRequestMergeQueueStatus(pullRequest: pullRequest) { PullRequestMergeQueueRow(status: mergeQueueStatus) @@ -96,7 +96,7 @@ struct PullRequestChecksPopoverView: View { Text(breakdown.summaryText) .foregroundStyle(.secondary) } - .font(.caption) + .interfaceFont(.caption) } if !sortedChecks.isEmpty { @@ -127,7 +127,7 @@ struct PullRequestChecksPopoverView: View { Text(style.label) .foregroundStyle(.secondary) } - .font(.caption) + .interfaceFont(.caption) } } } @@ -149,10 +149,10 @@ struct PullRequestChecksPopoverView: View { Text(status.summary) .foregroundStyle(.brown) } - .font(.subheadline) + .interfaceFont(.subheadline) if let detail = status.detail { Text(detail) - .font(.caption) + .interfaceFont(.caption) .foregroundStyle(.secondary) } } diff --git a/supacode/Features/Repositories/Views/PullRequestChecksRingView.swift b/supacode/Features/Repositories/Views/PullRequestChecksRingView.swift index 013f71340..6e8b3ace9 100644 --- a/supacode/Features/Repositories/Views/PullRequestChecksRingView.swift +++ b/supacode/Features/Repositories/Views/PullRequestChecksRingView.swift @@ -2,8 +2,15 @@ import SwiftUI struct PullRequestChecksRingView: View { let breakdown: PullRequestCheckBreakdown - @ScaledMetric(relativeTo: .caption) private var diameter: CGFloat = 12 - @ScaledMetric(relativeTo: .caption) private var lineWidth: CGFloat = 2 + @Environment(\.interfaceText) private var interfaceText + // `@ScaledMetric` tracks Dynamic Type, which macOS ignores; the minimum + // text size floor is what actually resizes the caption text next to the + // ring, so the ring follows the resolved caption size. + private var captionScale: CGFloat { + InterfaceTextMetrics.scaleFactor(.caption, resolution: interfaceText) + } + private var diameter: CGFloat { 12 * captionScale } + private var lineWidth: CGFloat { 2 * captionScale } private let segmentGapFraction = 0.05 var body: some View { diff --git a/supacode/Features/Repositories/Views/PullRequestStatusButton.swift b/supacode/Features/Repositories/Views/PullRequestStatusButton.swift index 2c0f84b29..2116b37b7 100644 --- a/supacode/Features/Repositories/Views/PullRequestStatusButton.swift +++ b/supacode/Features/Repositories/Views/PullRequestStatusButton.swift @@ -35,7 +35,7 @@ struct PullRequestStatusButton: View { } } } - .font(.caption) + .interfaceFont(.caption) } private var openPullRequestLabel: String { diff --git a/supacode/Features/Repositories/Views/RemoveWorkspaceConfirmationView.swift b/supacode/Features/Repositories/Views/RemoveWorkspaceConfirmationView.swift index ac2a64e41..0b2627f90 100644 --- a/supacode/Features/Repositories/Views/RemoveWorkspaceConfirmationView.swift +++ b/supacode/Features/Repositories/Views/RemoveWorkspaceConfirmationView.swift @@ -11,12 +11,12 @@ struct RemoveWorkspaceConfirmationView: View { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 6) { Text("Remove workspace?") - .font(.headline) + .interfaceFont(.headline) Text("This removes \(confirmation.workspaceTitle) from Prowl.") .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) Text(confirmation.rootPath) - .font(.footnote.monospaced()) + .interfaceFont(.footnote, design: .monospaced) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.middle) @@ -47,7 +47,7 @@ struct RemoveWorkspaceConfirmationView: View { HStack(spacing: 4) { Text("Delete branch") Text(option.branchName) - .font(.body.monospaced()) + .interfaceFont(.body, design: .monospaced) Text("in \(option.repositoryName)") } } @@ -59,7 +59,7 @@ struct RemoveWorkspaceConfirmationView: View { } Text("Linked repositories stay untouched; only the symlinks inside the workspace folder are removed.") - .font(.footnote) + .interfaceFont(.footnote) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) diff --git a/supacode/Features/Repositories/Views/RenameBranchPromptView.swift b/supacode/Features/Repositories/Views/RenameBranchPromptView.swift index 7989b06c2..c3b16d639 100644 --- a/supacode/Features/Repositories/Views/RenameBranchPromptView.swift +++ b/supacode/Features/Repositories/Views/RenameBranchPromptView.swift @@ -20,9 +20,12 @@ struct RenameBranchPromptView: View { } var body: some View { + // The field and the buttons carry no font of their own. A resolved default + // on the container reaches them, and the explicit styles below override it + // where a row wants something other than body. VStack(alignment: .leading, spacing: 12) { Text("Rename Branch") - .font(.headline) + .interfaceFont(.headline) TextField("Branch name", text: $draftName) .textFieldStyle(.roundedBorder) @@ -49,6 +52,7 @@ struct RenameBranchPromptView: View { } .padding() .frame(width: 280) + .interfaceFont(.body) .task { isFocused = true } } diff --git a/supacode/Features/Repositories/Views/RepoHeaderRow.swift b/supacode/Features/Repositories/Views/RepoHeaderRow.swift index 7a218bf30..c13b7dd5b 100644 --- a/supacode/Features/Repositories/Views/RepoHeaderRow.swift +++ b/supacode/Features/Repositories/Views/RepoHeaderRow.swift @@ -33,10 +33,11 @@ struct RepoHeaderRow: View { customTitle: customTitle, tooltip: nameTooltip ) + .interfaceFont(.body) .foregroundStyle(.secondary) if isRemoving { Text("Removing...") - .font(.caption) + .interfaceFont(.caption) .foregroundStyle(.tertiary) } } @@ -98,7 +99,7 @@ struct TabCountBadge: View { var body: some View { if count > 0 { Text("\(count)") - .font(.caption2) + .interfaceFont(.caption2) .monospacedDigit() .foregroundStyle(.secondary) .padding(.horizontal, 5) diff --git a/supacode/Features/Repositories/Views/RepositoryDetailView.swift b/supacode/Features/Repositories/Views/RepositoryDetailView.swift index 41742a071..a2a557710 100644 --- a/supacode/Features/Repositories/Views/RepositoryDetailView.swift +++ b/supacode/Features/Repositories/Views/RepositoryDetailView.swift @@ -17,19 +17,19 @@ struct RepositoryDetailView: View { private var repositoryDetail: some View { VStack(spacing: 12) { Image(systemName: repository.kind == .git ? "folder.badge.gearshape" : "folder") - .font(.largeTitle) + .interfaceFont(.largeTitle) .accessibilityHidden(true) RepoDisplayName( fallbackName: repository.name, customTitle: customTitle ) - .font(.title3.weight(.semibold)) + .interfaceFont(.title3, weight: .semibold) Text(repository.rootURL.path(percentEncoded: false)) - .font(.subheadline.monospaced()) + .interfaceFont(.subheadline, design: .monospaced) .foregroundStyle(.secondary) .textSelection(.enabled) Text(descriptionText) - .font(.subheadline) + .interfaceFont(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) } diff --git a/supacode/Features/Repositories/Views/SidebarFooterView.swift b/supacode/Features/Repositories/Views/SidebarFooterView.swift index 4bf8ca119..ecbcb7a6e 100644 --- a/supacode/Features/Repositories/Views/SidebarFooterView.swift +++ b/supacode/Features/Repositories/Views/SidebarFooterView.swift @@ -99,7 +99,7 @@ struct SidebarFooterView: View { )) } .buttonStyle(.plain) - .font(.callout) + .interfaceFont(.callout) .padding(.horizontal, 12) .padding(.vertical, 8) .frame(maxWidth: .infinity, alignment: .leading) diff --git a/supacode/Features/Repositories/Views/SidebarListView.swift b/supacode/Features/Repositories/Views/SidebarListView.swift index 5da393feb..546b6d391 100644 --- a/supacode/Features/Repositories/Views/SidebarListView.swift +++ b/supacode/Features/Repositories/Views/SidebarListView.swift @@ -144,7 +144,7 @@ struct SidebarListView: View { .overlay { if repositoryItems.isEmpty { Text("Repositories you add will appear here") - .font(.callout) + .interfaceFont(.callout) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .padding(.horizontal, 24) @@ -318,7 +318,7 @@ struct SidebarListView: View { ) -> some View { HStack(spacing: 4) { Text("Repositories") - .font(.caption) + .interfaceFont(.caption) .foregroundStyle(.tertiary) .frame(maxWidth: .infinity, alignment: .leading) if !expandableRepositoryIDs.isEmpty { diff --git a/supacode/Features/Repositories/Views/ToolbarNotificationsPopoverButton.swift b/supacode/Features/Repositories/Views/ToolbarNotificationsPopoverButton.swift index 0d64040d0..6febd095d 100644 --- a/supacode/Features/Repositories/Views/ToolbarNotificationsPopoverButton.swift +++ b/supacode/Features/Repositories/Views/ToolbarNotificationsPopoverButton.swift @@ -10,6 +10,7 @@ struct ToolbarNotificationsPopoverButton: View { @State private var isHoveringButton = false @State private var isHoveringPopover = false @State private var closeTask: Task? + @Environment(\.interfaceText) private var interfaceText private var notificationCount: Int { groups.reduce(0) { count, repository in @@ -30,7 +31,8 @@ struct ToolbarNotificationsPopoverButton: View { .accessibilityHidden(true) if notificationCount > 0 { Text(notificationCount, format: .number) - .font(.caption.monospacedDigit()) + .interfaceFont(.caption) + .monospacedDigit() } } } diff --git a/supacode/Features/Repositories/Views/ToolbarNotificationsPopoverView.swift b/supacode/Features/Repositories/Views/ToolbarNotificationsPopoverView.swift index 8ad5ce76b..8d0af898a 100644 --- a/supacode/Features/Repositories/Views/ToolbarNotificationsPopoverView.swift +++ b/supacode/Features/Repositories/Views/ToolbarNotificationsPopoverView.swift @@ -16,9 +16,9 @@ struct ToolbarNotificationsPopoverView: View { HStack { VStack(alignment: .leading, spacing: 2) { Text("Notifications") - .font(.headline) + .interfaceFont(.headline) Text("\(notificationCount) \(notificationLabel)") - .font(.subheadline) + .interfaceFont(.subheadline) .foregroundStyle(.secondary) } Spacer() @@ -33,12 +33,12 @@ struct ToolbarNotificationsPopoverView: View { VStack(alignment: .leading, spacing: 8) { Divider() Text(repository.name) - .font(.subheadline) + .interfaceFont(.subheadline) ForEach(repository.worktrees) { worktree in VStack(alignment: .leading, spacing: 6) { HStack(spacing: 6) { Text(worktree.name) - .font(.caption) + .interfaceFont(.caption) .foregroundStyle(.secondary) if worktree.hasUnseenNotifications { Circle() @@ -56,7 +56,7 @@ struct ToolbarNotificationsPopoverView: View { .foregroundStyle(notification.isRead ? Color.secondary : Color.orange) .accessibilityHidden(true) Text(notification.content) - .font(.caption) + .interfaceFont(.caption) .foregroundStyle(notification.isRead ? Color.secondary : Color.primary) .lineLimit(2) } diff --git a/supacode/Features/Repositories/Views/ToolbarStatusView.swift b/supacode/Features/Repositories/Views/ToolbarStatusView.swift index c2e1098e9..4928ab9f6 100644 --- a/supacode/Features/Repositories/Views/ToolbarStatusView.swift +++ b/supacode/Features/Repositories/Views/ToolbarStatusView.swift @@ -13,7 +13,7 @@ struct ToolbarStatusView: View { ProgressView() .controlSize(.small) Text(message) - .font(.footnote) + .interfaceFont(.footnote) .foregroundStyle(.secondary) } .transition(.opacity) @@ -23,7 +23,7 @@ struct ToolbarStatusView: View { .foregroundStyle(.green) .accessibilityHidden(true) Text(message) - .font(.footnote) + .interfaceFont(.footnote) .foregroundStyle(.secondary) } .transition(.opacity) @@ -33,7 +33,7 @@ struct ToolbarStatusView: View { .foregroundStyle(.orange) .accessibilityHidden(true) Text(message) - .font(.footnote) + .interfaceFont(.footnote) .foregroundStyle(.secondary) } .transition(.opacity) @@ -66,11 +66,10 @@ private struct MotivationalStatusView: View { HStack(spacing: 8) { Image(systemName: style.icon) .foregroundStyle(style.color) - .font(.callout) + .interfaceFont(.callout) .accessibilityHidden(true) Text("\(context.date, format: .dateTime.hour().minute()) – \(commandPaletteHint)") - .font(.footnote) - .monospaced() + .interfaceFont(.footnote, design: .monospaced) .foregroundStyle(.secondary) } } diff --git a/supacode/Features/Repositories/Views/WorkspaceCreationPromptView.swift b/supacode/Features/Repositories/Views/WorkspaceCreationPromptView.swift index b9a989ec6..48747554a 100644 --- a/supacode/Features/Repositories/Views/WorkspaceCreationPromptView.swift +++ b/supacode/Features/Repositories/Views/WorkspaceCreationPromptView.swift @@ -10,7 +10,7 @@ struct WorkspaceCreationPromptView: View { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 4) { Text("New Workspace") - .font(.title3) + .interfaceFont(.title3) Text(repositoryCountText) .foregroundStyle(.secondary) } @@ -50,7 +50,7 @@ struct WorkspaceCreationPromptView: View { ) ) .textFieldStyle(.roundedBorder) - .font(.body.monospaced()) + .interfaceFont(.body, design: .monospaced) .disabled(store.isCreating) .overlay { invalidFieldBorder(store.validationTarget == .rootPath) @@ -64,7 +64,7 @@ struct WorkspaceCreationPromptView: View { .disabled(store.isCreating) } Text(store.rootPathPreview) - .font(.footnote.monospaced()) + .interfaceFont(.footnote, design: .monospaced) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.middle) @@ -142,7 +142,7 @@ struct WorkspaceCreationPromptView: View { if let message = store.validationMessage, !message.isEmpty { Text(message) - .font(.footnote) + .interfaceFont(.footnote) .foregroundStyle(.red) } @@ -273,7 +273,7 @@ struct WorkspaceCreationPromptView: View { ) ) .textFieldStyle(.roundedBorder) - .font(.body.monospaced()) + .interfaceFont(.body, design: .monospaced) .disabled(store.isCreating) .overlay { invalidFieldBorder(repositoryFieldIsInvalid(repository, .source)) @@ -351,7 +351,7 @@ struct WorkspaceCreationPromptView: View { if let localBranchName = repository.resettableLocalBranchName { VStack(alignment: .leading, spacing: 4) { Text("Local branch “\(localBranchName)” already exists and would be reset to this ref.") - .font(.footnote) + .interfaceFont(.footnote) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) Picker( @@ -378,7 +378,7 @@ struct WorkspaceCreationPromptView: View { if let prompt = store.remoteRepositoryPrompt { VStack(alignment: .leading, spacing: 16) { Text("Add Remote Repository") - .font(.title3) + .interfaceFont(.title3) VStack(alignment: .leading, spacing: 8) { Text("Remote URL") @@ -391,7 +391,7 @@ struct WorkspaceCreationPromptView: View { ) ) .textFieldStyle(.roundedBorder) - .font(.body.monospaced()) + .interfaceFont(.body, design: .monospaced) .disabled(prompt.isLoading) helpText("Remote git URL to clone into the workspace, such as SSH or HTTPS.") } @@ -413,7 +413,7 @@ struct WorkspaceCreationPromptView: View { if !prompt.branchOptions.isEmpty { Text("\(prompt.branchOptions.count) remote branches loaded") - .font(.footnote) + .interfaceFont(.footnote) .foregroundStyle(.secondary) } else { helpText("Load branches before adding so Prowl can choose an existing branch safely.") @@ -421,7 +421,7 @@ struct WorkspaceCreationPromptView: View { if let message = prompt.validationMessage, !message.isEmpty { Text(message) - .font(.footnote) + .interfaceFont(.footnote) .foregroundStyle(.red) } @@ -553,7 +553,7 @@ struct WorkspaceCreationPromptView: View { private func sourceKindBadge(_ kind: ProjectWorkspaceRepositorySourceKind) -> some View { Label(sourceKindTitle(kind), systemImage: sourceKindIcon(kind)) - .font(.caption) + .interfaceFont(.caption) .foregroundStyle(.secondary) .labelStyle(.titleAndIcon) .lineLimit(1) @@ -608,7 +608,7 @@ struct WorkspaceCreationPromptView: View { private func helpText(_ text: String) -> some View { Text(text) - .font(.footnote) + .interfaceFont(.footnote) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } @@ -676,7 +676,7 @@ private struct WorkspaceBranchRefPickerView: View { ForEach(groupedOptions, id: \.kind) { group in VStack(alignment: .leading, spacing: 4) { Text(group.kind.title) - .font(.caption) + .interfaceFont(.caption) .foregroundStyle(.secondary) ForEach(group.options) { option in Button { diff --git a/supacode/Features/Repositories/Views/WorkspaceDetailView.swift b/supacode/Features/Repositories/Views/WorkspaceDetailView.swift index 9bab292d5..c541d38ec 100644 --- a/supacode/Features/Repositories/Views/WorkspaceDetailView.swift +++ b/supacode/Features/Repositories/Views/WorkspaceDetailView.swift @@ -9,7 +9,7 @@ struct WorkspaceDetailView: View { header if !workspace.description.isEmpty { Text(workspace.description) - .font(.callout) + .interfaceFont(.callout) .foregroundStyle(.secondary) .textSelection(.enabled) } @@ -27,19 +27,19 @@ struct WorkspaceDetailView: View { private var header: some View { HStack(alignment: .top, spacing: 12) { Image(systemName: "folder.badge.person.crop") - .font(.largeTitle) + .interfaceFont(.largeTitle) .foregroundStyle(.secondary) .accessibilityHidden(true) VStack(alignment: .leading, spacing: 4) { Text(workspace.title) - .font(.title3.weight(.semibold)) + .interfaceFont(.title3, weight: .semibold) .textSelection(.enabled) Text(repository.rootURL.path(percentEncoded: false)) - .font(.subheadline.monospaced()) + .interfaceFont(.subheadline, design: .monospaced) .foregroundStyle(.secondary) .textSelection(.enabled) Text(repositoryCountText) - .font(.subheadline) + .interfaceFont(.subheadline) .foregroundStyle(.tertiary) } } @@ -53,10 +53,10 @@ struct WorkspaceDetailView: View { private var taskLinks: some View { VStack(alignment: .leading, spacing: 6) { Text("Task Links") - .font(.headline) + .interfaceFont(.headline) ForEach(workspace.taskLinks, id: \.self) { link in Text(link) - .font(.subheadline.monospaced()) + .interfaceFont(.subheadline, design: .monospaced) .foregroundStyle(.secondary) .textSelection(.enabled) } @@ -66,7 +66,7 @@ struct WorkspaceDetailView: View { private var repositoriesTable: some View { VStack(alignment: .leading, spacing: 8) { Text("Repositories") - .font(.headline) + .interfaceFont(.headline) WorkspaceRepositoriesGridView(workspace: workspace, rootURL: repository.rootURL) } } diff --git a/supacode/Features/Repositories/Views/WorkspaceRepositoriesGridView.swift b/supacode/Features/Repositories/Views/WorkspaceRepositoriesGridView.swift index 665840be7..93432ef6a 100644 --- a/supacode/Features/Repositories/Views/WorkspaceRepositoriesGridView.swift +++ b/supacode/Features/Repositories/Views/WorkspaceRepositoriesGridView.swift @@ -7,7 +7,7 @@ struct WorkspaceRepositoriesGridView: View { var body: some View { if workspace.repositories.isEmpty { Text("No repositories are declared in this workspace metadata.") - .font(.callout) + .interfaceFont(.callout) .foregroundStyle(.secondary) } else { Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) { @@ -24,22 +24,22 @@ struct WorkspaceRepositoriesGridView: View { ForEach(workspace.repositories) { entry in GridRow(alignment: .firstTextBaseline) { Text(entry.name) - .font(.subheadline.weight(.medium)) + .interfaceFont(.subheadline, weight: .medium) Text(entry.role ?? " ") - .font(.subheadline) + .interfaceFont(.subheadline) .foregroundStyle(.secondary) Text(sourceKindTitle(entry.sourceKind)) - .font(.subheadline) + .interfaceFont(.subheadline) .foregroundStyle(.secondary) .help(entry.sourceLocation ?? "") Text(materializationTitle(entry)) - .font(.subheadline) + .interfaceFont(.subheadline) .foregroundStyle(.secondary) Text(entry.branchName ?? entry.baseRef ?? " ") - .font(.subheadline.monospaced()) + .interfaceFont(.subheadline, design: .monospaced) .foregroundStyle(.secondary) Text(entry.resolvedURL(relativeTo: rootURL).path(percentEncoded: false)) - .font(.subheadline.monospaced()) + .interfaceFont(.subheadline, design: .monospaced) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.middle) @@ -52,7 +52,7 @@ struct WorkspaceRepositoriesGridView: View { private func header(_ title: String) -> some View { Text(title) - .font(.caption.weight(.semibold)) + .interfaceFont(.caption, weight: .semibold) .foregroundStyle(.tertiary) } diff --git a/supacode/Features/Repositories/Views/WorktreeCreationPromptView.swift b/supacode/Features/Repositories/Views/WorktreeCreationPromptView.swift index 7713bf54a..97a726d6e 100644 --- a/supacode/Features/Repositories/Views/WorktreeCreationPromptView.swift +++ b/supacode/Features/Repositories/Views/WorktreeCreationPromptView.swift @@ -9,7 +9,7 @@ struct WorktreeCreationPromptView: View { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 4) { Text("New Worktree") - .font(.title3) + .interfaceFont(.title3) Text("Create a branch in \(store.repositoryName)") .foregroundStyle(.secondary) } @@ -37,10 +37,10 @@ struct WorktreeCreationPromptView: View { if let suggested = store.suggestedBranchName { HStack(spacing: 4) { Text("Auto suggestion: ") - .font(.footnote) + .interfaceFont(.footnote) .foregroundStyle(.tertiary) Text(suggested) - .font(.footnote) + .interfaceFont(.footnote) .monospaced() .foregroundStyle(.tertiary) .lineLimit(1) @@ -48,14 +48,14 @@ struct WorktreeCreationPromptView: View { store.send(.useSuggestedBranchName) } label: { Text("Use") - .font(.footnote) + .interfaceFont(.footnote) } .buttonStyle(.plain) .foregroundStyle(.tint) Spacer() Image(systemName: "questionmark.circle") .accessibilityLabel("About auto suggestion") - .font(.footnote) + .interfaceFont(.footnote) .foregroundStyle(.tertiary) .help( "Suggested by on-device AI based on your repository " @@ -82,14 +82,14 @@ struct WorktreeCreationPromptView: View { ) .help("Runs git fetch before creating the worktree.") Text("Keeps remote-tracking base branches current. Fetch failures are logged and creation continues.") - .font(.footnote) + .interfaceFont(.footnote) .foregroundStyle(.secondary) } DisclosureGroup("Advanced", isExpanded: $store.showAdvancedOptions) { VStack(alignment: .leading, spacing: 12) { Text("Override where the new worktree folder is created. Leave a field blank to use its default.") - .font(.footnote) + .interfaceFont(.footnote) .foregroundStyle(.secondary) VStack(alignment: .leading, spacing: 8) { Text("Worktree name") @@ -119,11 +119,11 @@ struct WorktreeCreationPromptView: View { // path the worktree will be created at (mirrors the reducer's resolution). if let message = store.validationMessage ?? store.worktreeNameValidationError, !message.isEmpty { Text(message) - .font(.footnote) + .interfaceFont(.footnote) .foregroundStyle(.red) } else { Text(store.resolvedWorktreeLocationPreview) - .font(.footnote) + .interfaceFont(.footnote) .monospaced() .foregroundStyle(.secondary) .textSelection(.enabled) diff --git a/supacode/Features/Repositories/Views/WorktreeDetailToolbarViews.swift b/supacode/Features/Repositories/Views/WorktreeDetailToolbarViews.swift index 6a5fd56a3..d5a9a667a 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailToolbarViews.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailToolbarViews.swift @@ -15,7 +15,7 @@ struct MultiSelectedWorktreesDetailView: View { let deleteShortcut = KeyboardShortcut(.delete, modifiers: [.command, .shift]).display VStack(alignment: .leading, spacing: 16) { Text("\(rows.count) worktrees selected") - .font(.title3) + .interfaceFont(.title3) VStack(alignment: .leading, spacing: 8) { ForEach(Array(rows.prefix(visibleRowsLimit))) { row in HStack(alignment: .firstTextBaseline, spacing: 8) { @@ -27,23 +27,23 @@ struct MultiSelectedWorktreesDetailView: View { .lineLimit(1) } } - .font(.body) + .interfaceFont(.body) } if rows.count > visibleRowsLimit { Text("+\(rows.count - visibleRowsLimit) more") - .font(.caption) + .interfaceFont(.caption) .foregroundStyle(.secondary) } } Divider() VStack(alignment: .leading, spacing: 6) { Text("Available actions") - .font(.headline) + .interfaceFont(.headline) Text("Archive selected") Text("Delete selected (\(deleteShortcut))") Text("Right-click any selected worktree to apply actions to all selected worktrees.") } - .font(.caption) + .interfaceFont(.caption) .foregroundStyle(.secondary) Spacer(minLength: 0) } @@ -99,12 +99,12 @@ struct RunScriptToolbarButton: View { if commandKeyObserver.isPressed, let shortcut = config.shortcut { Text(shortcut) - .font(.caption) + .interfaceFont(.caption) .foregroundStyle(.secondary) } } } - .font(.caption) + .interfaceFont(.caption) .help(config.helpText) .disabled(!config.isEnabled) } @@ -138,12 +138,12 @@ struct UserCustomCommandToolbarButton: View { Text(title) if commandKeyObserver.isPressed, let shortcut { Text(shortcut) - .font(.caption) + .interfaceFont(.caption) .foregroundStyle(.secondary) } } } - .font(.caption) + .interfaceFont(.caption) .help(helpText) .disabled(!isEnabled) } @@ -174,6 +174,7 @@ struct CustomCommandOverflowButton: View { let onRunCustomCommand: (EffectiveCustomCommand.Identifier) -> Void @State private var isPresented = false + @Environment(\.interfaceText) private var interfaceText private let maxVisibleRows = 10 var body: some View { @@ -181,7 +182,7 @@ struct CustomCommandOverflowButton: View { isPresented.toggle() } label: { Image(systemName: "chevron.down") - .font(.caption2) + .interfaceFont(.caption2) .accessibilityLabel("More custom commands") } .help("More custom commands") @@ -199,11 +200,12 @@ struct CustomCommandOverflowButton: View { .frame(width: 14) .accessibilityHidden(true) Text(entry.command.resolvedTitle) + .interfaceFont(.body) .lineLimit(1) Spacer(minLength: 0) if let shortcut = shortcutDisplay(entry) { Text(shortcut) - .font(.caption.monospaced()) + .interfaceFont(.caption, design: .monospaced) .foregroundStyle(.secondary) .lineLimit(1) } @@ -225,7 +227,8 @@ struct CustomCommandOverflowButton: View { private var popoverHeight: CGFloat { let visibleRows = min(maxVisibleRows, max(entries.count, 1)) - return CGFloat(visibleRows) * 32 + 16 + let rowHeight = 32 + InterfaceTextMetrics.extraHeight(.body, resolution: interfaceText) + return CGFloat(visibleRows) * rowHeight + 16 } private func helpText(for entry: EffectiveCustomCommand) -> String { diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index 483478ec0..c08e23284 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -968,7 +968,7 @@ struct WorktreeDetailView: View { .help("Copy path") } label: { Image(systemName: "chevron.down") - .font(.caption2) + .interfaceFont(.caption2) .accessibilityLabel("Open in menu") } .imageScale(.small) diff --git a/supacode/Features/Repositories/Views/WorktreeLoadingView.swift b/supacode/Features/Repositories/Views/WorktreeLoadingView.swift index a1f07bf36..d9ab4bf4f 100644 --- a/supacode/Features/Repositories/Views/WorktreeLoadingView.swift +++ b/supacode/Features/Repositories/Views/WorktreeLoadingView.swift @@ -10,19 +10,19 @@ struct WorktreeLoadingView: View { ProgressView() .controlSize(.large) Text(info.name) - .font(.title3) + .interfaceFont(.title3) .lineLimit(1) .truncationMode(.middle) if let statusCommand { Text(statusCommand) - .font(.subheadline) + .interfaceFont(.subheadline) .monospaced() .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.middle) } Text(info.statusSubtitle) - .font(.subheadline) + .interfaceFont(.subheadline) .monospaced() .foregroundStyle(.tertiary) .lineLimit(5, reservesSpace: true) diff --git a/supacode/Features/Repositories/Views/WorktreeRow.swift b/supacode/Features/Repositories/Views/WorktreeRow.swift index a5fd5a97c..9d3b2f3cb 100644 --- a/supacode/Features/Repositories/Views/WorktreeRow.swift +++ b/supacode/Features/Repositories/Views/WorktreeRow.swift @@ -1,4 +1,3 @@ -import AppKit import SwiftUI struct WorktreeRow: View { @@ -28,6 +27,7 @@ struct WorktreeRow: View { let onStopRunScript: (() -> Void)? @Environment(\.colorScheme) private var colorScheme @Environment(\.resolvedKeybindings) private var resolvedKeybindings + @Environment(\.interfaceText) private var interfaceText init( name: String, @@ -104,7 +104,7 @@ struct WorktreeRow: View { let showsPullRequestTag = display.pullRequest != nil && display.pullRequestBadgeStyle != nil let nameColor = colorScheme == .dark ? Color.white : Color.primary let detailText = worktreeName.isEmpty ? name : worktreeName - let bodyFontAscender = NSFont.preferredFont(forTextStyle: .body).ascender + let bodyFontAscender = InterfaceTextMetrics.bodyAscender(resolution: interfaceText) VStack(alignment: .leading, spacing: 2) { HStack(alignment: .firstTextBaseline, spacing: 6) { ZStack { @@ -114,14 +114,14 @@ struct WorktreeRow: View { onFocusNotification: onFocusNotification ) { Image(systemName: "bell.fill") - .font(.caption) + .interfaceFont(.caption) .foregroundStyle(.orange) .accessibilityLabel("Unread notifications") } .opacity(showsSpinner ? 0 : 1) } else { Image(systemName: branchIconName) - .font(.caption) + .interfaceFont(.caption) .foregroundStyle(.secondary) .opacity(showsSpinner ? 0 : 1) .accessibilityHidden(true) @@ -136,7 +136,7 @@ struct WorktreeRow: View { bodyFontAscender } Text(name) - .font(.body) + .interfaceFont(.body) .foregroundStyle(nameColor) .lineLimit(1) .truncationMode(.middle) @@ -151,7 +151,7 @@ struct WorktreeRow: View { pinAction?() } label: { Image(systemName: isPinned ? "pin.slash" : "pin") - .font(.caption) + .interfaceFont(.caption) .contentTransition(.symbolEffect(.replace)) .accessibilityLabel(isPinned ? "Unpin worktree" : "Pin worktree") } @@ -163,7 +163,7 @@ struct WorktreeRow: View { archiveAction?() } label: { Image(systemName: "archivebox") - .font(.caption) + .interfaceFont(.caption) .accessibilityLabel("Archive worktree") } .buttonStyle(.plain) @@ -230,8 +230,12 @@ struct WorktreeRow: View { return PullRequestMergeReadiness(pullRequest: pullRequest) } + /// Base height plus whatever the text floor adds to the two lines + /// (body name, caption info) the row stacks. private var worktreeRowHeight: CGFloat { 36 + + InterfaceTextMetrics.extraHeight(.body, resolution: interfaceText) + + InterfaceTextMetrics.extraHeight(.caption, resolution: interfaceText) } } @@ -244,7 +248,7 @@ private struct RunScriptIndicator: View { onStop?() } label: { Image(systemName: isHovering ? "stop.fill" : "play.fill") - .font(.caption) + .interfaceFont(.caption) .foregroundStyle(isHovering ? .red : .green) .contentTransition(.symbolEffect(.replace)) .accessibilityLabel(isHovering ? "Stop run script" : "Run script active") @@ -266,6 +270,7 @@ private struct WorktreeRowInfoView: View { let isQueued: Bool let shortcutHint: String? let showsShortcutHint: Bool + @Environment(\.interfaceText) private var interfaceText var body: some View { HStack(spacing: 4) { @@ -280,8 +285,8 @@ private struct WorktreeRowInfoView: View { .accessibilityHidden(!showsShortcutHint) } } - .font(.caption) - .frame(minHeight: 14) + .interfaceFont(.caption) + .frame(minHeight: 14 + InterfaceTextMetrics.extraHeight(.caption, resolution: interfaceText)) .animation(.easeInOut(duration: 0.15), value: showsShortcutHint) } @@ -454,7 +459,7 @@ private struct WorktreeRowChangeCountView: View { .foregroundStyle(.red) .baselineOffset(-1) } - .font(.caption) + .interfaceFont(.caption) .lineLimit(1) .padding(.horizontal, 4) .padding(.vertical, 0) diff --git a/supacode/Features/RepositorySettings/Views/RepositoryAppearancePickerView.swift b/supacode/Features/RepositorySettings/Views/RepositoryAppearancePickerView.swift index d32ac2b32..8d006a3e6 100644 --- a/supacode/Features/RepositorySettings/Views/RepositoryAppearancePickerView.swift +++ b/supacode/Features/RepositorySettings/Views/RepositoryAppearancePickerView.swift @@ -83,9 +83,9 @@ struct RepositoryAppearancePickerView: View { iconMenu VStack(alignment: .leading, spacing: 4) { Text("Icon") - .font(.headline) + .interfaceFont(.headline) Text(iconHelpText) - .font(.caption) + .interfaceFont(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } @@ -173,7 +173,7 @@ struct RepositoryAppearancePickerView: View { ) } else { Image(systemName: "questionmark") - .font(.system(size: 16, weight: .semibold)) + .interfaceFont(size: 16, weight: .semibold) .foregroundStyle(.tertiary) .accessibilityHidden(true) } @@ -227,7 +227,7 @@ struct RepositoryAppearancePickerView: View { VStack(alignment: .leading, spacing: 6) { HStack(alignment: .center, spacing: 12) { Text("Color") - .font(.headline) + .interfaceFont(.headline) .frame(width: previewSize, alignment: .center) HStack(spacing: 6) { ForEach(RepositoryColorChoice.presets, id: \.self) { choice in @@ -241,7 +241,7 @@ struct RepositoryAppearancePickerView: View { Text( "Tints the row in the sidebar, the shelf spine background, and the canvas card title bar." ) - .font(.caption) + .interfaceFont(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) .padding(.leading, previewSize + 12) @@ -342,7 +342,7 @@ struct RepositoryAppearancePickerView: View { .frame(width: swatchDotSize, height: swatchDotSize) .overlay { Image(systemName: "slash.circle") - .font(.system(size: 11)) + .interfaceFont(size: 11) .foregroundStyle(.secondary) .accessibilityHidden(true) } @@ -384,14 +384,14 @@ struct RepositoryAppearancePickerView: View { .foregroundStyle(.orange) .accessibilityHidden(true) Text(message) - .font(.caption) + .interfaceFont(.caption) .foregroundStyle(.primary) Spacer(minLength: 0) Button("Dismiss") { store.send(.dismissAppearanceImportError) } .buttonStyle(.plain) - .font(.caption) + .interfaceFont(.caption) .foregroundStyle(.secondary) } .padding(.vertical, 4) diff --git a/supacode/Features/RepositorySettings/Views/RepositorySymbolSuggestionsSection.swift b/supacode/Features/RepositorySettings/Views/RepositorySymbolSuggestionsSection.swift index 19fc0df2e..ab25329e9 100644 --- a/supacode/Features/RepositorySettings/Views/RepositorySymbolSuggestionsSection.swift +++ b/supacode/Features/RepositorySettings/Views/RepositorySymbolSuggestionsSection.swift @@ -17,7 +17,7 @@ struct RepositorySymbolSuggestionsSection: View { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 8) { Text("Suggested for this repository") - .font(.subheadline.weight(.medium)) + .interfaceFont(.subheadline, weight: .medium) Spacer(minLength: 0) switch phase { case .idle: @@ -33,21 +33,21 @@ struct RepositorySymbolSuggestionsSection: View { switch phase { case .idle: Text("Uses this project's README to propose fitting symbols. Everything runs on-device.") - .font(.caption) + .interfaceFont(.caption) .foregroundStyle(.secondary) case .loading: HStack(spacing: 8) { ProgressView() .controlSize(.small) Text("Generating suggestions…") - .font(.caption) + .interfaceFont(.caption) .foregroundStyle(.secondary) } case .loaded(let suggestions): loadedContent(suggestions) case .failed: Text("Couldn't generate suggestions. You can retry, or pick a symbol manually below.") - .font(.caption) + .interfaceFont(.caption) .foregroundStyle(.secondary) } } @@ -86,12 +86,12 @@ struct RepositorySymbolSuggestionsSection: View { Spacer(minLength: 0) } Text(suggestions.reason) - .font(.caption) + .interfaceFont(.caption) .foregroundStyle(.secondary) .lineLimit(3) .fixedSize(horizontal: false, vertical: true) Text(sourceLabel(suggestions)) - .font(.caption2) + .interfaceFont(.caption2) .foregroundStyle(.tertiary) } diff --git a/supacode/Features/Settings/Models/GlobalSettings.swift b/supacode/Features/Settings/Models/GlobalSettings.swift index cc158ce18..f40918d4d 100644 --- a/supacode/Features/Settings/Models/GlobalSettings.swift +++ b/supacode/Features/Settings/Models/GlobalSettings.swift @@ -43,6 +43,8 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { var externalDiffToolID: String = ExternalDiffTool.builtIn.settingsID var externalDiffCustomCommand: String = "" var detectRepositoryIconsAutomatically: Bool = true + var minimumTextSize: MinimumTextSize = .system + var interfaceTextScale: InterfaceTextScale = .system static let `default` = GlobalSettings( appearanceMode: .dark, @@ -220,6 +222,8 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { try container.encode(externalDiffToolID, forKey: .externalDiffToolID) try container.encode(externalDiffCustomCommand, forKey: .externalDiffCustomCommand) try container.encode(detectRepositoryIconsAutomatically, forKey: .detectRepositoryIconsAutomatically) + try container.encode(minimumTextSize, forKey: .minimumTextSize) + try container.encode(interfaceTextScale, forKey: .interfaceTextScale) } private enum CodingKeys: String, CodingKey { @@ -267,6 +271,8 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { case externalDiffToolID case externalDiffCustomCommand case detectRepositoryIconsAutomatically + case minimumTextSize + case interfaceTextScale // Legacy keys for migration case automaticallyArchiveMergedWorktrees case notificationSoundEnabled @@ -365,6 +371,7 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { detectRepositoryIconsAutomatically = try container.decodeIfPresent(Bool.self, forKey: .detectRepositoryIconsAutomatically) ?? true + (minimumTextSize, interfaceTextScale) = try Self.decodeInterfaceTextSettings(from: container) let toolbarAndDock = try Self.decodeToolbarAndDockSettings(from: container) showRunButtonInToolbar = toolbarAndDock.showRunButtonInToolbar showDefaultEditorInToolbar = toolbarAndDock.showDefaultEditorInToolbar @@ -472,6 +479,23 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { let showNotificationDotOnDock: Bool } + /// Raw-string decode so a settings file written by a newer build with an + /// unknown size or scale falls back to the default instead of failing + /// wholesale. + private static func decodeInterfaceTextSettings( + from container: KeyedDecodingContainer + ) throws -> (MinimumTextSize, InterfaceTextScale) { + let minimum = + (try container.decodeIfPresent(String.self, forKey: .minimumTextSize)) + .flatMap(MinimumTextSize.init(rawValue:)) + ?? Self.default.minimumTextSize + let scale = + (try container.decodeIfPresent(String.self, forKey: .interfaceTextScale)) + .flatMap(InterfaceTextScale.init(rawValue:)) + ?? Self.default.interfaceTextScale + return (minimum, scale) + } + private static func decodeToolbarAndDockSettings( from container: KeyedDecodingContainer ) throws -> ToolbarAndDockSettings { diff --git a/supacode/Features/Settings/Models/InterfaceTextScale.swift b/supacode/Features/Settings/Models/InterfaceTextScale.swift new file mode 100644 index 000000000..a7452e8d0 --- /dev/null +++ b/supacode/Features/Settings/Models/InterfaceTextScale.swift @@ -0,0 +1,44 @@ +/// Proportional size of text in the app chrome and its sheets, as a multiple +/// of the system size. Distinct from `MinimumTextSize`, and the two answer +/// different questions. +/// +/// A floor lifts anything below it to exactly itself, so it necessarily +/// compresses: at a 16pt floor, caption through headline all resolve to 16 and +/// stop being distinguishable. That is the correct behaviour for an +/// accessibility floor and the wrong behaviour for "make the interface +/// bigger", which has to keep the ramp intact. +/// +/// This setting scales every style by the same factor, so the ordering and the +/// relative gaps survive. The floor is applied after it, which means a reader +/// can raise everything proportionally, put a hard lower bound under the +/// result, or do both. +/// +/// Terminal content is excluded from both: its size comes from the Ghostty +/// font settings. +enum InterfaceTextScale: String, CaseIterable, Identifiable, Codable, Sendable { + case system + case percent110 = "110" + case percent125 = "125" + case percent150 = "150" + + var id: String { rawValue } + + var title: String { + switch self { + case .system: + return "System" + default: + return "\(rawValue)%" + } + } + + /// The multiplier applied to every style's system size. + var factor: Double { + switch self { + case .system: + return 1 + default: + return (Double(rawValue) ?? 100) / 100 + } + } +} diff --git a/supacode/Features/Settings/Models/MinimumTextSize.swift b/supacode/Features/Settings/Models/MinimumTextSize.swift new file mode 100644 index 000000000..049c3c479 --- /dev/null +++ b/supacode/Features/Settings/Models/MinimumTextSize.swift @@ -0,0 +1,36 @@ +/// Floor for text in the app chrome (sidebar, Active Agents panel, toolbar, +/// tab bar, and their popovers), in points — the same mechanism as Safari's +/// "Never use font sizes smaller than" setting. Styles below the floor are +/// lifted to it; styles at or above it are untouched, so body text stays at +/// the system size. macOS ignores `dynamicTypeSize`, which is why the app +/// enforces the floor itself. Terminal content is excluded — its size comes +/// from the Ghostty font settings. +enum MinimumTextSize: String, CaseIterable, Identifiable, Codable, Sendable { + case system + case points11 = "11" + case points12 = "12" + case points13 = "13" + case points14 = "14" + case points16 = "16" + + var id: String { rawValue } + + var title: String { + switch self { + case .system: + return "System" + default: + return "\(rawValue) pt" + } + } + + /// The floor in points; nil means no floor (system sizes as-is). + var points: Double? { + switch self { + case .system: + return nil + default: + return Double(rawValue) + } + } +} diff --git a/supacode/Features/Settings/Reducer/SettingsFeature.swift b/supacode/Features/Settings/Reducer/SettingsFeature.swift index 7455ee3d9..d86e64245 100644 --- a/supacode/Features/Settings/Reducer/SettingsFeature.swift +++ b/supacode/Features/Settings/Reducer/SettingsFeature.swift @@ -53,6 +53,8 @@ struct SettingsFeature { var externalDiffToolID: String var externalDiffCustomCommand: String var detectRepositoryIconsAutomatically: Bool + var minimumTextSize: MinimumTextSize + var interfaceTextScale: InterfaceTextScale var cliInstallStatus: CLIInstallStatus = .notInstalled var cliInstallShowAlert: Bool = true /// Whether macOS will render the Dock notification badge (notification @@ -113,6 +115,8 @@ struct SettingsFeature { externalDiffToolID = settings.externalDiffToolID externalDiffCustomCommand = settings.externalDiffCustomCommand detectRepositoryIconsAutomatically = settings.detectRepositoryIconsAutomatically + minimumTextSize = settings.minimumTextSize + interfaceTextScale = settings.interfaceTextScale } var globalSettings: GlobalSettings { @@ -164,6 +168,8 @@ struct SettingsFeature { settings.externalDiffToolID = externalDiffToolID settings.externalDiffCustomCommand = externalDiffCustomCommand settings.detectRepositoryIconsAutomatically = detectRepositoryIconsAutomatically + settings.minimumTextSize = minimumTextSize + settings.interfaceTextScale = interfaceTextScale return settings } } @@ -287,6 +293,8 @@ struct SettingsFeature { state.externalDiffCustomCommand = normalizedSettings.externalDiffCustomCommand state.canvasDefaultLayout = normalizedSettings.canvasDefaultLayout state.detectRepositoryIconsAutomatically = normalizedSettings.detectRepositoryIconsAutomatically + state.minimumTextSize = normalizedSettings.minimumTextSize + state.interfaceTextScale = normalizedSettings.interfaceTextScale state.syncGlobalDefaults(from: normalizedSettings) return .send(.delegate(.settingsChanged(normalizedSettings))) diff --git a/supacode/Features/Settings/Views/AdvancedSettingsView.swift b/supacode/Features/Settings/Views/AdvancedSettingsView.swift index d100a1d41..3ac0a4faa 100644 --- a/supacode/Features/Settings/Views/AdvancedSettingsView.swift +++ b/supacode/Features/Settings/Views/AdvancedSettingsView.swift @@ -15,10 +15,10 @@ struct AdvancedSettingsView: View { .help("Share anonymous usage data with Prowl (requires restart)") Text("Anonymous usage data helps improve Prowl.") .foregroundStyle(.secondary) - .font(.callout) + .interfaceFont(.callout) Text("Requires app restart.") .foregroundStyle(.secondary) - .font(.callout) + .interfaceFont(.callout) } .frame(maxWidth: .infinity, alignment: .leading) @@ -30,10 +30,10 @@ struct AdvancedSettingsView: View { .help("Share anonymous crash reports with Prowl (requires restart)") Text("Anonymous crash reports help improve stability.") .foregroundStyle(.secondary) - .font(.callout) + .interfaceFont(.callout) Text("Requires app restart.") .foregroundStyle(.secondary) - .font(.callout) + .interfaceFont(.callout) } .frame(maxWidth: .infinity, alignment: .leading) } @@ -46,7 +46,7 @@ struct AdvancedSettingsView: View { ) Text("When enabled, Prowl attempts to restore tabs and splits after restart.") .foregroundStyle(.secondary) - .font(.callout) + .interfaceFont(.callout) Button("Clear saved terminal layout") { store.send(.clearTerminalLayoutSnapshotButtonTapped) } diff --git a/supacode/Features/Settings/Views/AgentProfileEditorView.swift b/supacode/Features/Settings/Views/AgentProfileEditorView.swift index d5cfa376a..db8606aed 100644 --- a/supacode/Features/Settings/Views/AgentProfileEditorView.swift +++ b/supacode/Features/Settings/Views/AgentProfileEditorView.swift @@ -91,11 +91,11 @@ struct AgentProfileEditorView: View { + "It may execute commands and modify files without prompting." : "Extra arguments request the runtime's least-restricted mode." ) - .font(.caption) + .interfaceFont(.caption) .foregroundStyle(.red) case .followsExtraArguments: Text("Effective execution mode follows your extra arguments.") - .font(.caption) + .interfaceFont(.caption) .foregroundStyle(.secondary) } Picker("Open In", selection: $store.profile.placement) { @@ -139,7 +139,7 @@ struct AgentProfileEditorView: View { + "but also separate skills, global instructions, and session history. " + "The first launch signs in through the agent itself." ) - .font(.caption) + .interfaceFont(.caption) .foregroundStyle(.secondary) LabeledContent( "Profile Home", @@ -184,11 +184,11 @@ struct AgentProfileEditorView: View { ) -> some View { HStack(spacing: 8) { TextField("NAME", text: override.name) - .font(.body.monospaced()) + .interfaceFont(.body, design: .monospaced) .frame(width: 180) .accessibilityLabel("Variable name") TextField("value", text: override.value) - .font(.body.monospaced()) + .interfaceFont(.body, design: .monospaced) .accessibilityLabel("Variable value") if let issue = AgentProfileEnvironmentPolicy.issue(for: override.wrappedValue) { Image(systemName: "exclamationmark.triangle.fill") @@ -240,10 +240,10 @@ struct AgentProfileEditorView: View { "Prowl types this command into the new pane. " + "Override values travel in hidden PROWL_ENV variables, never in the command text." ) - .font(.caption) + .interfaceFont(.caption) .foregroundStyle(.secondary) Text(previewText) - .font(.callout.monospaced()) + .interfaceFont(.callout, design: .monospaced) .foregroundStyle(.secondary) .textSelection(.enabled) .lineLimit(nil) @@ -255,13 +255,13 @@ struct AgentProfileEditorView: View { iconMenu VStack(alignment: .leading, spacing: 4) { Text("Icon") - .font(.headline) + .interfaceFont(.headline) Text( store.profile.icon == nil ? "\(AgentRuntimeAdapterRegistry.displayName(for: store.profile.runtime)) brand icon" : "Custom SF Symbol" ) - .font(.caption) + .interfaceFont(.caption) .foregroundStyle(.secondary) } Spacer(minLength: 0) diff --git a/supacode/Features/Settings/Views/AgentSkillsSectionView.swift b/supacode/Features/Settings/Views/AgentSkillsSectionView.swift index 9c39af4df..64c2c00c7 100644 --- a/supacode/Features/Settings/Views/AgentSkillsSectionView.swift +++ b/supacode/Features/Settings/Views/AgentSkillsSectionView.swift @@ -40,11 +40,11 @@ struct AgentSkillsSectionView: View { .foregroundStyle(.secondary) } } - .font(.callout) + .interfaceFont(.callout) } else if store.skills.isEmpty { Text("This app bundles no installable skills.") .foregroundStyle(.secondary) - .font(.callout) + .interfaceFont(.callout) } else { if store.noTargetsDetected { Text( @@ -53,7 +53,7 @@ struct AgentSkillsSectionView: View { + "prowl skills install --target claude|codex|agents." ) .foregroundStyle(.secondary) - .font(.callout) + .interfaceFont(.callout) } ForEach(store.skills) { row in skillRow(row) @@ -68,10 +68,10 @@ struct AgentSkillsSectionView: View { VStack(alignment: .leading, spacing: 10) { HStack(alignment: .firstTextBaseline, spacing: 8) { Text(row.skill.name) - .font(.headline) + .interfaceFont(.headline) if row.skill.name != row.skill.id { Text(row.skill.id) - .font(.callout.monospaced()) + .interfaceFont(.callout, design: .monospaced) .foregroundStyle(.secondary) } Spacer() @@ -86,7 +86,7 @@ struct AgentSkillsSectionView: View { // human-facing text, so it wins whenever the skill provides one. Text(row.skill.summary ?? row.skill.description) .foregroundStyle(.secondary) - .font(.callout) + .interfaceFont(.callout) .fixedSize(horizontal: false, vertical: true) if !row.links.isEmpty { linkTable(row) @@ -105,7 +105,7 @@ struct AgentSkillsSectionView: View { linkLine(skill: row.skill, link: link) } } - .font(.callout) + .interfaceFont(.callout) } private func linkLine(skill: BundledSkill, link: AgentSkillsFeature.SkillLink) -> some View { @@ -217,7 +217,7 @@ struct AgentSkillsSectionView: View { /// Paths keep one line and truncate in the middle so the skill folder name stays visible. private func pathText(_ path: String) -> some View { Text(path) - .font(.callout.monospaced()) + .interfaceFont(.callout, design: .monospaced) .lineLimit(1) .truncationMode(.middle) } diff --git a/supacode/Features/Settings/Views/AppearanceOptionCardView.swift b/supacode/Features/Settings/Views/AppearanceOptionCardView.swift index 9695c4ac7..e3110c50a 100644 --- a/supacode/Features/Settings/Views/AppearanceOptionCardView.swift +++ b/supacode/Features/Settings/Views/AppearanceOptionCardView.swift @@ -39,7 +39,7 @@ struct AppearanceOptionCardView: View { } .aspectRatio(1.6, contentMode: .fit) Text(mode.title) - .font(.headline) + .interfaceFont(.headline) } .frame(maxWidth: .infinity) .padding() diff --git a/supacode/Features/Settings/Views/AppearanceSettingsView.swift b/supacode/Features/Settings/Views/AppearanceSettingsView.swift index 6727b6cb4..5993d7e70 100644 --- a/supacode/Features/Settings/Views/AppearanceSettingsView.swift +++ b/supacode/Features/Settings/Views/AppearanceSettingsView.swift @@ -21,6 +21,27 @@ struct AppearanceSettingsView: View { } } } + Picker("Interface text size", selection: $store.interfaceTextScale) { + ForEach(InterfaceTextScale.allCases) { scale in + Text(scale.title).tag(scale) + } + } + .help( + "Scale all app text by this amount, keeping headings larger than " + + "body text and body text larger than captions. " + + "Terminal text follows your Ghostty font size instead." + ) + Picker("Minimum text size", selection: $store.minimumTextSize) { + ForEach(MinimumTextSize.allCases) { size in + Text(size.title).tag(size) + } + } + .help( + "Never use text smaller than this size, like Safari's minimum font " + + "size. A floor raises small text to meet it, so sizes below the " + + "floor become equal. Use Interface text size to enlarge " + + "everything and keep the differences." + ) VStack(alignment: .leading, spacing: 6) { Text( """ @@ -44,7 +65,7 @@ struct AppearanceSettingsView: View { } .controlSize(.small) } - .font(.footnote) + .interfaceFont(.footnote) .foregroundStyle(.secondary) } Section("Window Tint") { @@ -63,7 +84,7 @@ struct AppearanceSettingsView: View { .help("Tint the nav and toolbar with this color in every view, ignoring repository colors.") } Text(tintFootnote) - .font(.callout) + .interfaceFont(.callout) .foregroundStyle(.secondary) Picker("Tint spines in Shelf View", selection: $store.shelfSpineTintFallback) { @@ -78,7 +99,7 @@ struct AppearanceSettingsView: View { ) .help("When disabled, all Shelf spines use the selected Neutral or System Tint style.") Text(shelfSpineTintFootnote) - .font(.callout) + .interfaceFont(.callout) .foregroundStyle(.secondary) } Section("Repository Icons") { @@ -92,7 +113,7 @@ struct AppearanceSettingsView: View { + "you picked, and turning it off leaves already detected icons unchanged." ) .foregroundStyle(.secondary) - .font(.callout) + .interfaceFont(.callout) } Section("Splits") { Toggle( @@ -109,7 +130,7 @@ struct AppearanceSettingsView: View { .help("Open the Active Agents panel when an agent is detected.") Text("Hidden panels reopen as soon as an agent starts or updates.") .foregroundStyle(.secondary) - .font(.callout) + .interfaceFont(.callout) Toggle( "Show terminal titles in agent rows", isOn: $store.showActiveAgentTabTitles @@ -137,7 +158,7 @@ struct AppearanceSettingsView: View { .help("How cards are arranged the first time you open Canvas for a set of cards.") Text(store.canvasDefaultLayout.settingsDescription) .foregroundStyle(.secondary) - .font(.callout) + .interfaceFont(.callout) } Section("Default Editor") { Toggle( @@ -174,7 +195,7 @@ struct AppearanceSettingsView: View { } .help("Choose what opens when you click a diff badge or run Show Diff.") Text("Tools not installed on this Mac appear disabled.") - .font(.callout) + .interfaceFont(.callout) .foregroundStyle(.secondary) if store.externalDiffToolID == ExternalDiffTool.custom.settingsID { TextField( diff --git a/supacode/Features/Settings/Views/CommandLineToolSettingsView.swift b/supacode/Features/Settings/Views/CommandLineToolSettingsView.swift index 8f2747fcf..8738861ff 100644 --- a/supacode/Features/Settings/Views/CommandLineToolSettingsView.swift +++ b/supacode/Features/Settings/Views/CommandLineToolSettingsView.swift @@ -36,11 +36,11 @@ struct CommandLineToolSettingsView: View { Text("Not installed") } } - .font(.callout) + .interfaceFont(.callout) Text("Install the prowl command to let terminals and coding agents control Prowl.") .foregroundStyle(.secondary) - .font(.callout) + .interfaceFont(.callout) HStack(spacing: 8) { switch store.cliInstallStatus { @@ -80,7 +80,7 @@ struct CommandLineToolSettingsView: View { Section("Connection") { LabeledContent("Socket") { Text(ProwlSocket.defaultPath) - .font(.callout.monospaced()) + .interfaceFont(.callout, design: .monospaced) .foregroundStyle(.secondary) .textSelection(.enabled) .lineLimit(1) @@ -92,7 +92,7 @@ struct CommandLineToolSettingsView: View { + "Set PROWL_CLI_SOCKET for both Prowl and prowl to use a different path." ) .foregroundStyle(.secondary) - .font(.callout) + .interfaceFont(.callout) } if let agentSkillsStore = store.scope(state: \.agentSkills, action: \.agentSkills) { diff --git a/supacode/Features/Settings/Views/CustomCommandsEditor.swift b/supacode/Features/Settings/Views/CustomCommandsEditor.swift index 0b60dc5b9..79daef23a 100644 --- a/supacode/Features/Settings/Views/CustomCommandsEditor.swift +++ b/supacode/Features/Settings/Views/CustomCommandsEditor.swift @@ -139,12 +139,12 @@ struct CustomCommandsEditor: View { Spacer(minLength: 0) Text("\(displayedCommandCount) commands") - .font(.caption) + .interfaceFont(.caption) .foregroundStyle(.secondary) } if let invalidMessage = selectedCommandInvalidMessage { Text(invalidMessage) - .font(.caption) + .interfaceFont(.caption) .foregroundStyle(.red) } else { Text( @@ -152,7 +152,7 @@ struct CustomCommandsEditor: View { ? "Global commands are managed in Settings → Commands." : "Click cells to edit icon, name, command, and shortcut inline." ) - .font(.caption) + .interfaceFont(.caption) .foregroundStyle(.secondary) } } @@ -301,7 +301,7 @@ struct CustomCommandsEditor: View { } label: { VStack(alignment: .leading, spacing: 2) { Text(inlineCommandTitle(for: binding.wrappedValue.execution)) - .font(.caption) + .interfaceFont(.caption) .foregroundStyle(.secondary) Text(inlineCommandScriptPreview(for: binding.wrappedValue.command)) .lineLimit(1) @@ -327,7 +327,7 @@ struct CustomCommandsEditor: View { } label: { VStack(alignment: .leading, spacing: 2) { Text(inlineCommandTitle(for: command.execution)) - .font(.caption) + .interfaceFont(.caption) .foregroundStyle(.secondary) Text(inlineCommandScriptPreview(for: command.command)) .lineLimit(1) @@ -350,7 +350,7 @@ struct CustomCommandsEditor: View { toggleRecording(for: command.id) } label: { Text(isRecording ? "Recording…" : shortcutDisplay) - .font(.body.monospaced()) + .interfaceFont(.body, design: .monospaced) .foregroundStyle(isRecording ? Color.orange : (resolvedBinding == nil ? .secondary : .primary)) .lineLimit(1) } @@ -394,7 +394,7 @@ struct CustomCommandsEditor: View { } .padding(.horizontal, 14) .padding(.vertical, 8) - .font(.headline) + .interfaceFont(.headline) .foregroundStyle(.secondary) } @@ -461,7 +461,7 @@ struct CustomCommandsEditor: View { HStack(spacing: 8) { customCommandRowCell(width: customCommandsDragColumnWidth, alignment: .center) { Image(systemName: "lock.fill") - .font(.caption2) + .interfaceFont(.caption2) .foregroundStyle(.tertiary) .accessibilityHidden(true) } @@ -480,12 +480,12 @@ struct CustomCommandsEditor: View { Text(command.resolvedTitle) .lineLimit(1) Text("Global") - .font(.caption2) + .interfaceFont(.caption2) .foregroundStyle(.secondary) } if !command.isEnabled { Text("Disabled globally") - .font(.caption2) + .interfaceFont(.caption2) .foregroundStyle(.secondary) } } @@ -493,7 +493,7 @@ struct CustomCommandsEditor: View { customCommandRowCell { VStack(alignment: .leading, spacing: 2) { Text(inlineCommandTitle(for: command.execution)) - .font(.caption) + .interfaceFont(.caption) .foregroundStyle(.secondary) Text(inlineCommandScriptPreview(for: command.command)) .lineLimit(1) @@ -504,7 +504,7 @@ struct CustomCommandsEditor: View { for: customCommandBindingID(for: command.id, source: .global) ) Text(binding?.display ?? "Unassigned") - .font(.body.monospaced()) + .interfaceFont(.body, design: .monospaced) .foregroundStyle(binding == nil ? .secondary : .primary) .lineLimit(1) } @@ -612,9 +612,9 @@ struct CustomCommandsEditor: View { ) -> some View { VStack(alignment: .leading, spacing: 10) { Text("Icon") - .font(.headline) + .interfaceFont(.headline) Text("Pick from common symbols or enter any SF Symbol name available in your system.") - .font(.caption) + .interfaceFont(.caption) .foregroundStyle(.secondary) HStack(spacing: 8) { @@ -654,9 +654,9 @@ struct CustomCommandsEditor: View { private func commandEditorPopover(for command: Binding) -> some View { VStack(alignment: .leading, spacing: 10) { Text("Command") - .font(.headline) + .interfaceFont(.headline) Text(commandEditorDescription) - .font(.caption) + .interfaceFont(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -689,7 +689,7 @@ struct CustomCommandsEditor: View { .frame(height: 140) Text(scriptDescription(for: command.wrappedValue.execution)) - .font(.caption) + .interfaceFont(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) diff --git a/supacode/Features/Settings/Views/GithubSettingsView.swift b/supacode/Features/Settings/Views/GithubSettingsView.swift index 6de9cf37c..6cd5bea0d 100644 --- a/supacode/Features/Settings/Views/GithubSettingsView.swift +++ b/supacode/Features/Settings/Views/GithubSettingsView.swift @@ -80,7 +80,7 @@ struct GithubSettingsView: View { .foregroundStyle(.red) Text("Enable GitHub integration and install gh CLI to use pull request checks.") .foregroundStyle(.secondary) - .font(.callout) + .interfaceFont(.callout) } case .notAuthenticated: @@ -89,7 +89,7 @@ struct GithubSettingsView: View { .foregroundStyle(.orange) Text("Run `gh auth login` in terminal to authenticate.") .foregroundStyle(.secondary) - .font(.callout) + .interfaceFont(.callout) } case .outdated: @@ -98,7 +98,7 @@ struct GithubSettingsView: View { .foregroundStyle(.orange) Text("Update GitHub CLI to the latest version to use GitHub integration.") .foregroundStyle(.secondary) - .font(.callout) + .interfaceFont(.callout) } case .authenticated(let snapshot): @@ -107,7 +107,7 @@ struct GithubSettingsView: View { VStack(alignment: .leading, spacing: 8) { LabeledContent("Host") { Text(host) - .font(.body) + .interfaceFont(.body) } ForEach(accounts) { account in HStack { @@ -123,7 +123,7 @@ struct GithubSettingsView: View { .help(status.help) } } - .font(.body) + .interfaceFont(.body) } } } @@ -134,7 +134,7 @@ struct GithubSettingsView: View { .foregroundStyle(.red) Text(message) .foregroundStyle(.secondary) - .font(.callout) + .interfaceFont(.callout) } } } diff --git a/supacode/Features/Settings/Views/NotificationsSettingsView.swift b/supacode/Features/Settings/Views/NotificationsSettingsView.swift index 654a66de8..ddb1cd2fe 100644 --- a/supacode/Features/Settings/Views/NotificationsSettingsView.swift +++ b/supacode/Features/Settings/Views/NotificationsSettingsView.swift @@ -70,7 +70,7 @@ struct NotificationsSettingsView: View { .disabled(!dockBadgeAvailable) if let dockBadgeCaption { Text(dockBadgeCaption) - .font(.callout) + .interfaceFont(.callout) .foregroundStyle(.secondary) } } diff --git a/supacode/Features/Settings/Views/RepositorySettingsView.swift b/supacode/Features/Settings/Views/RepositorySettingsView.swift index 5c6fd1b01..cc0d0820f 100644 --- a/supacode/Features/Settings/Views/RepositorySettingsView.swift +++ b/supacode/Features/Settings/Views/RepositorySettingsView.swift @@ -63,7 +63,7 @@ struct RepositorySettingsView: View { VStack(alignment: .leading, spacing: 4) { ForEach(workspace.taskLinks, id: \.self) { link in Text(link) - .font(.subheadline.monospaced()) + .interfaceFont(.subheadline, design: .monospaced) .foregroundStyle(.secondary) .textSelection(.enabled) } @@ -80,7 +80,7 @@ struct RepositorySettingsView: View { + "\(ProjectWorkspace.metadataURL(for: store.rootURL).path(percentEncoded: false)) " + "— edit that file to change it." ) - .font(.footnote) + .interfaceFont(.footnote) .foregroundStyle(.secondary) .textSelection(.enabled) } @@ -101,7 +101,7 @@ struct RepositorySettingsView: View { Spacer() Image(systemName: "chevron.up.chevron.down") .foregroundStyle(.secondary) - .font(.caption) + .interfaceFont(.caption) .accessibilityHidden(true) } .contentShape(Rectangle()) diff --git a/supacode/Features/Settings/Views/ShortcutsSettingsView.swift b/supacode/Features/Settings/Views/ShortcutsSettingsView.swift index 8c25c4dd0..c8e60b939 100644 --- a/supacode/Features/Settings/Views/ShortcutsSettingsView.swift +++ b/supacode/Features/Settings/Views/ShortcutsSettingsView.swift @@ -74,7 +74,7 @@ struct ShortcutsSettingsView: View { Color.clear .frame(width: ShortcutTableLayout.actionColumnWidth, height: 1) } - .font(.caption.weight(.semibold)) + .interfaceFont(.caption, weight: .semibold) .foregroundStyle(.secondary) .padding(.horizontal, 16) @@ -89,7 +89,7 @@ struct ShortcutsSettingsView: View { } header: { HStack(alignment: .center, spacing: 8) { Text(group.title) - .font(.caption.weight(.semibold)) + .interfaceFont(.caption, weight: .semibold) .foregroundStyle(.secondary) Spacer(minLength: 0) if hasOverrides(in: group) { @@ -97,7 +97,7 @@ struct ShortcutsSettingsView: View { resetOverrides(in: group) } .buttonStyle(.link) - .font(.caption) + .interfaceFont(.caption) } } } @@ -196,7 +196,7 @@ struct ShortcutsSettingsView: View { requestResetOverride(for: command.id) } label: { Image(systemName: "arrow.counterclockwise") - .font(.caption.weight(.semibold)) + .interfaceFont(.caption, weight: .semibold) .foregroundStyle(.secondary) .accessibilityHidden(true) } @@ -225,13 +225,13 @@ struct ShortcutsSettingsView: View { .buttonStyle(.link) .help("Cancel recording") } - .font(.caption) + .interfaceFont(.caption) .foregroundStyle(.secondary) } if let invalid = invalidMessageByCommandID[command.id] { Text(invalid) - .font(.caption) + .interfaceFont(.caption) .foregroundStyle(.red) } } @@ -257,13 +257,13 @@ struct ShortcutsSettingsView: View { HStack(spacing: 6) { if isRecording { Image(systemName: "record.circle.fill") - .font(.caption) + .interfaceFont(.caption) .foregroundStyle(Color.accentColor) .accessibilityHidden(true) } Text(shortcutRecorderTitle(resolvedBinding: resolvedBinding, isRecording: isRecording)) - .font(.body.monospaced()) + .interfaceFont(.body, design: .monospaced) .lineLimit(1) .truncationMode(.tail) .frame(maxWidth: .infinity, alignment: .leading) @@ -330,7 +330,7 @@ struct ShortcutsSettingsView: View { let title = resolvedBinding == nil ? "Disabled" : "Defined" return AnyView( Text(title) - .font(.caption2.monospaced()) + .interfaceFont(.caption2, design: .monospaced) .lineLimit(1) .minimumScaleFactor(0.8) .frame(width: ShortcutTableLayout.statusChipWidth, height: ShortcutTableLayout.statusChipHeight) diff --git a/supacode/Features/Settings/Views/UpdatesSettingsView.swift b/supacode/Features/Settings/Views/UpdatesSettingsView.swift index e5e29d73c..14c6f5c6c 100644 --- a/supacode/Features/Settings/Views/UpdatesSettingsView.swift +++ b/supacode/Features/Settings/Views/UpdatesSettingsView.swift @@ -19,7 +19,7 @@ struct UpdatesSettingsView: View { "When a new version is available, a small badge appears next to the notifications bell. " + "Click it to review, install, and choose future background downloads." ) - .font(.callout) + .interfaceFont(.callout) .foregroundStyle(.secondary) } diff --git a/supacode/Features/Shelf/Views/ShelfSidebarButton.swift b/supacode/Features/Shelf/Views/ShelfSidebarButton.swift index aebb2b876..c36bf96de 100644 --- a/supacode/Features/Shelf/Views/ShelfSidebarButton.swift +++ b/supacode/Features/Shelf/Views/ShelfSidebarButton.swift @@ -13,7 +13,7 @@ struct ShelfSidebarButton: View { } label: { HStack(spacing: 6) { Label("Shelf", systemImage: "books.vertical") - .font(.callout) + .interfaceFont(.callout) .frame(maxWidth: .infinity, alignment: .leading) if commandKeyObserver.isPressed, let shortcut = AppShortcuts.display(for: AppShortcuts.CommandID.toggleShelf, in: resolvedKeybindings) diff --git a/supacode/Features/Shelf/Views/ShelfSpineView.swift b/supacode/Features/Shelf/Views/ShelfSpineView.swift index bebb6840c..d597bdd99 100644 --- a/supacode/Features/Shelf/Views/ShelfSpineView.swift +++ b/supacode/Features/Shelf/Views/ShelfSpineView.swift @@ -349,6 +349,7 @@ private struct ShelfSpineHeader: View { let icon: RepositoryIconSource? let iconTint: Color let repositoryRootURL: URL + @Environment(\.interfaceText) private var interfaceText /// Reserved slot for the top decoration (icon and/or notification), /// sized at the maximum expected configuration (14pt icon plus a @@ -430,8 +431,8 @@ private struct ShelfSpineHeader: View { /// repositories with many worktrees. @ViewBuilder private var rotatedTitle: some View { - combinedTitle - .font(.callout) + combinedTitle(interfaceText) + .interfaceFont(.callout) .lineLimit(1) .truncationMode(.middle) .frame(width: ShelfMetrics.headerMaxLength, alignment: .leading) @@ -443,9 +444,13 @@ private struct ShelfSpineHeader: View { /// truncation can operate across project + branch as one string. /// `foregroundStyle` on each interpolated piece survives composition /// and drives the primary/secondary split. - private var combinedTitle: Text { + /// Takes the resolution rather than reading the environment: the composed + /// value must stay a `Text` for interpolation to shorten across both pieces + /// as one string, and the view modifiers that read the environment return + /// `some View`. + private func combinedTitle(_ resolution: InterfaceTextResolution) -> Text { let project = Text(book.projectName) - .font(.callout.weight(.semibold)) + .interfaceFont(.callout, weight: .semibold, resolution: resolution) .foregroundStyle(.primary) guard let branch = book.branchName, !branch.isEmpty else { return project @@ -543,10 +548,11 @@ private struct ShelfSpineTabSlot: View { if let hotkeyIndex, showsHotkey { HStack(spacing: 1) { Image(systemName: "command") - .font(.system(size: 8, weight: .semibold)) + .interfaceFont(size: 8, weight: .semibold) .foregroundStyle(foregroundTint) Text("\(hotkeyIndex)") - .font(.callout.weight(.semibold).monospacedDigit()) + .interfaceFont(.callout, weight: .semibold) + .monospacedDigit() .foregroundStyle(foregroundTint) } .accessibilityHidden(true) @@ -605,7 +611,7 @@ private struct ShelfSpineTabSlot: View { private func statusMarker(for entry: ActiveAgentEntry) -> some View { if let symbol = entry.displayState.shelfSpineStatusSymbol { Image(systemName: symbol) - .font(.caption2.weight(.bold)) + .interfaceFont(.caption2, weight: .bold) .foregroundStyle(entry.displayState.foregroundStyle) // Thin halo so the bare glyph stays legible over the tab icon's strokes. .shadow(color: .black.opacity(0.4), radius: 1) diff --git a/supacode/Features/Terminal/TabBar/Views/TabIconPickerView.swift b/supacode/Features/Terminal/TabBar/Views/TabIconPickerView.swift index 775efcaea..cb8691f8e 100644 --- a/supacode/Features/Terminal/TabBar/Views/TabIconPickerView.swift +++ b/supacode/Features/Terminal/TabBar/Views/TabIconPickerView.swift @@ -48,9 +48,9 @@ struct TabIconPickerView: View { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 4) { Text(title) - .font(.headline) + .interfaceFont(.headline) Text(subtitle) - .font(.caption) + .interfaceFont(.caption) .foregroundStyle(.secondary) } diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabBarTrailingAccessories.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabBarTrailingAccessories.swift index ef128fa5e..2d8f8c8b3 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabBarTrailingAccessories.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabBarTrailingAccessories.swift @@ -146,6 +146,11 @@ struct TerminalTabBarTrailingAccessories: View { } .padding(10) .frame(minWidth: 220) + // The action titles carry no font of their own, so without a resolved + // default here they stay at the system size while their shortcut hints + // scale. Setting it on the container covers every row, including rows + // added later that would otherwise inherit the unscaled default. + .interfaceFont(.body) } private func helpText(_ title: String, shortcut: String?) -> String { diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabBarView.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabBarView.swift index c1b28e6e7..13297d2a7 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabBarView.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabBarView.swift @@ -96,7 +96,7 @@ struct TerminalTabBarView: View { var body: some View { VStack(alignment: .leading, spacing: 4) { Text(title) - .font(.caption) + .interfaceFont(.caption) .foregroundStyle(.secondary) TerminalTabBarView( manager: manager, diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabCloseButton.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabCloseButton.swift index 4e69a72ff..df480797c 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabCloseButton.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabCloseButton.swift @@ -20,8 +20,7 @@ struct TerminalTabCloseButton: View { } .labelStyle(.iconOnly) .buttonStyle(TerminalPressTrackingButtonStyle(isPressed: $isPressing)) - .font(.caption2) - .bold() + .interfaceFont(.caption2, weight: .bold) .foregroundStyle( isHoveringClose ? TerminalTabBarColors.activeText : TerminalTabBarColors.inactiveText ) diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabLabelView.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabLabelView.swift index 683cbc207..631e7bab7 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabLabelView.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabLabelView.swift @@ -19,7 +19,7 @@ struct TerminalTabLabelView: View { TerminalTabIconBadge(tab: tab, isActive: isActive) } Text(tab.displayTitle) - .font(.caption) + .interfaceFont(.caption) .lineLimit(1) .foregroundStyle(isActive ? TerminalTabBarColors.activeText : TerminalTabBarColors.inactiveText) } diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift index 501b7883d..13254c935 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabView.swift @@ -26,6 +26,7 @@ struct TerminalTabView: View { @State private var tabWidth: CGFloat = 0 @Environment(CommandKeyObserver.self) private var commandKeyObserver @Environment(\.resolvedKeybindings) private var resolvedKeybindings + @Environment(\.interfaceText) private var interfaceText var body: some View { ZStack(alignment: .leading) { @@ -79,6 +80,7 @@ struct TerminalTabView: View { TerminalTabIconBadge(tab: tab, isActive: isActive) } RenameTextField( + fontSize: InterfaceTextMetrics.pointSize(NSFont.smallSystemFontSize, resolution: interfaceText), text: $editingTitle, onCommit: { onEndRename() }, onCancel: { @@ -240,6 +242,7 @@ private final class MiddleClickNSView: NSView { /// directly lets us drive `selectAll` on its own field editor without /// fighting SwiftUI's focus timing. private struct RenameTextField: NSViewRepresentable { + let fontSize: CGFloat @Binding var text: String let onCommit: () -> Void let onCancel: () -> Void @@ -248,7 +251,7 @@ private struct RenameTextField: NSViewRepresentable { func makeNSView(context: Context) -> RenameNSTextField { let field = RenameNSTextField() - field.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize) + field.font = NSFont.systemFont(ofSize: fontSize) field.textColor = .labelColor field.isBordered = false field.drawsBackground = false @@ -266,6 +269,9 @@ private struct RenameTextField: NSViewRepresentable { func updateNSView(_ nsView: RenameNSTextField, context: Context) { context.coordinator.parent = self + if nsView.font?.pointSize != fontSize { + nsView.font = NSFont.systemFont(ofSize: fontSize) + } if nsView.stringValue != text { nsView.stringValue = text } diff --git a/supacode/Features/Terminal/Views/EmptyTerminalPaneView.swift b/supacode/Features/Terminal/Views/EmptyTerminalPaneView.swift index 641b11b79..99d805635 100644 --- a/supacode/Features/Terminal/Views/EmptyTerminalPaneView.swift +++ b/supacode/Features/Terminal/Views/EmptyTerminalPaneView.swift @@ -6,9 +6,9 @@ struct EmptyTerminalPaneView: View { var body: some View { VStack { Text(message) - .font(.headline) + .interfaceFont(.headline) Text("Use the plus button to open a terminal.") - .font(.subheadline) + .interfaceFont(.subheadline) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/supacode/Features/Terminal/Views/GhosttySurfaceSearchOverlay.swift b/supacode/Features/Terminal/Views/GhosttySurfaceSearchOverlay.swift index 95668f8a3..c3f04ca05 100644 --- a/supacode/Features/Terminal/Views/GhosttySurfaceSearchOverlay.swift +++ b/supacode/Features/Terminal/Views/GhosttySurfaceSearchOverlay.swift @@ -6,6 +6,7 @@ struct GhosttySurfaceSearchOverlay: View { @Bindable var state: GhosttySurfaceState @Environment(GhosttyShortcutManager.self) private var ghosttyShortcuts @Environment(\.resolvedKeybindings) private var resolvedKeybindings + @Environment(\.interfaceText) private var interfaceText @State private var searchText: String @State private var corner: GhosttySearchCorner = .topRight @@ -27,6 +28,7 @@ struct GhosttySurfaceSearchOverlay: View { ZStack(alignment: corner.alignment) { HStack(spacing: 4) { GhosttySearchField( + fontSize: InterfaceTextMetrics.pointSize(NSFont.systemFontSize, resolution: interfaceText), text: $searchText, isFocused: isSearchFieldFocused, onSubmit: { isShifted in @@ -151,12 +153,12 @@ struct GhosttySurfaceSearchOverlay: View { private var matchLabel: some View { if let selected = state.searchSelected, let total = state.searchTotal { Text("\(total - selected)/\(total)") - .font(.caption) + .interfaceFont(.caption) .foregroundStyle(.secondary) .padding(.trailing, 8) } else if let total = state.searchTotal { Text("-/\(total)") - .font(.caption) + .interfaceFont(.caption) .foregroundStyle(.secondary) .padding(.trailing, 8) } @@ -281,6 +283,7 @@ private struct SearchButtonLabel: View { } private struct GhosttySearchField: NSViewRepresentable { + let fontSize: CGFloat @Binding var text: String var isFocused: Bool var onSubmit: (Bool) -> Void @@ -301,11 +304,14 @@ private struct GhosttySearchField: NSViewRepresentable { field.placeholderString = "Search" field.usesSingleLineMode = true field.lineBreakMode = .byTruncatingTail - field.font = .systemFont(ofSize: NSFont.systemFontSize, weight: .regular) + field.font = .systemFont(ofSize: fontSize, weight: .regular) return field } func updateNSView(_ nsView: SearchField, context: Context) { + if nsView.font?.pointSize != fontSize { + nsView.font = .systemFont(ofSize: fontSize, weight: .regular) + } if nsView.stringValue != text { nsView.stringValue = text } diff --git a/supacode/Features/Terminal/Views/TabIconImage.swift b/supacode/Features/Terminal/Views/TabIconImage.swift index 5e71133af..ad9e282e7 100644 --- a/supacode/Features/Terminal/Views/TabIconImage.swift +++ b/supacode/Features/Terminal/Views/TabIconImage.swift @@ -22,7 +22,7 @@ struct TabIconImage: View { switch ResolvedTabIcon.parse(rawName) { case .systemSymbol(let name): Image(systemName: name) - .font(.system(size: pointSize)) + .fixedInterfaceFont(.system(size: pointSize)) case .asset(let name): Image(name) .resizable() diff --git a/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift b/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift index 7a0b0be7c..3671ca888 100644 --- a/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift +++ b/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift @@ -240,7 +240,7 @@ struct TerminalSplitTreeView: View { ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right" ) - .font(.callout.weight(.semibold)) + .interfaceFont(.callout, weight: .semibold) .foregroundStyle(.primary) .padding(5) .background(.regularMaterial, in: .rect(cornerRadius: 6)) @@ -270,7 +270,7 @@ struct TerminalSplitTreeView: View { .overlay { if isHovering { Image(systemName: "ellipsis") - .font(.system(.callout, weight: .semibold)) + .interfaceFont(.callout, weight: .semibold) .foregroundStyle(.primary.opacity(0.5)) .accessibilityHidden(true) } diff --git a/supacode/Support/InterfaceTextMetrics.swift b/supacode/Support/InterfaceTextMetrics.swift new file mode 100644 index 000000000..645fbb1f2 --- /dev/null +++ b/supacode/Support/InterfaceTextMetrics.swift @@ -0,0 +1,205 @@ +import AppKit +import SwiftUI + +/// How text in the app resolves: a proportional scale applied to every style, +/// then a hard floor under the result. +/// +/// The two are separate because neither can do the other's job. A floor lifts +/// everything below it to exactly itself, so it compresses: at 16pt, caption +/// through headline all resolve to 16 and stop being distinguishable. That is +/// correct for an accessibility floor and wrong for "make the interface +/// bigger", which has to keep the ramp intact. Scaling keeps it intact and +/// guarantees no lower bound. A reader who wants both sets both. +/// +/// Terminal content is excluded: its size comes from the Ghostty font settings. +struct InterfaceTextResolution: Equatable, Sendable { + /// Multiplier on every style's system size. 1 leaves sizes alone. + var scale: Double = 1 + /// Hard lower bound in points, applied after scaling. 0 means no floor. + var minimumSize: Double = 0 + + static let system = InterfaceTextResolution() +} + +/// Injected at the window root from `GlobalSettings`. The default leaves every +/// size at the system value, so views hosted outside the main window and +/// previews render exactly as they would without the feature. +private struct InterfaceTextResolutionKey: EnvironmentKey { + static let defaultValue = InterfaceTextResolution.system +} + +extension EnvironmentValues { + var interfaceText: InterfaceTextResolution { + get { self[InterfaceTextResolutionKey.self] } + set { self[InterfaceTextResolutionKey.self] = newValue } + } +} + +extension View { + /// Semantic text style that honours the interface text settings. When both + /// settings are at their defaults this resolves to the plain semantic font, + /// so default rendering is identical to `.font(style)`. + func interfaceFont( + _ style: Font.TextStyle, + weight: Font.Weight? = nil, + design: Font.Design? = nil + ) -> some View { + modifier(InterfaceFontModifier(style: style, weight: weight, design: design)) + } + + /// Reading text whose size was written as a point value rather than a + /// semantic style. It scales with the settings like any other text; only the + /// starting size is spelled out. Prefer a semantic style where one fits, so + /// the ramp stays the single source of relative sizing. + func interfaceFont( + size: Double, + weight: Font.Weight? = nil, + design: Font.Design? = nil + ) -> some View { + modifier(InterfaceSizedFontModifier(size: size, weight: weight, design: design)) + } + + /// Text or a symbol whose size is deliberately fixed, exempt from the + /// interface text settings. Use it where a point size is a layout constant + /// rather than a reading size — an SF Symbol sized to a fixed slot, a glyph + /// aligned to a drawn shape. Naming the exemption keeps it greppable and + /// reviewable, which a bare `.font(...)` does not. + func fixedInterfaceFont(_ font: Font) -> some View { + self.font(font) + } +} + +private struct InterfaceFontModifier: ViewModifier { + @Environment(\.interfaceText) private var resolution + let style: Font.TextStyle + let weight: Font.Weight? + let design: Font.Design? + + func body(content: Content) -> some View { + content.font( + InterfaceTextMetrics.font(style, weight: weight, design: design, resolution: resolution) + ) + } +} + +extension Text { + /// Resolved style for a value that has to stay a `Text`. String + /// interpolation composes `Text`, so a title assembled from several styled + /// pieces cannot go through the `View` modifiers, which return `some View`. + /// + /// The resolution is passed in because a `Text`-typed property cannot read + /// the environment. Read it once in the enclosing view and hand it down. + func interfaceFont( + _ style: Font.TextStyle, + weight: Font.Weight? = nil, + design: Font.Design? = nil, + resolution: InterfaceTextResolution + ) -> Text { + font(InterfaceTextMetrics.font(style, weight: weight, design: design, resolution: resolution)) + } +} + +private struct InterfaceSizedFontModifier: ViewModifier { + @Environment(\.interfaceText) private var resolution + let size: Double + let weight: Font.Weight? + let design: Font.Design? + + func body(content: Content) -> some View { + var font = Font.system( + size: InterfaceTextMetrics.pointSize(size, resolution: resolution), + design: design ?? .default + ) + if let weight { + font = font.weight(weight) + } + return content.font(font) + } +} + +enum InterfaceTextMetrics { + static func font( + _ style: Font.TextStyle, + weight: Font.Weight? = nil, + design: Font.Design? = nil, + resolution: InterfaceTextResolution + ) -> Font { + let base = basePointSize(style) + let resolved = pointSize(style, resolution: resolution) + var font: Font = + if resolved == base { + .system(style, design: design ?? .default) + } else { + .system(size: resolved, design: design ?? .default) + } + if let weight { + font = font.weight(weight) + } + return font + } + + /// Scale first, then floor. The order matters: flooring first would let the + /// scale lift text back above a floor the reader set as a lower bound, which + /// is harmless, and would also let it drag text below one, which is not. + static func pointSize(_ style: Font.TextStyle, resolution: InterfaceTextResolution) -> Double { + max(basePointSize(style) * resolution.scale, resolution.minimumSize) + } + + /// Same rule for a raw point size that carries no semantic style, for the + /// places that hand a size to AppKit rather than resolving a `Font`. + static func pointSize(_ base: Double, resolution: InterfaceTextResolution) -> Double { + max(base * resolution.scale, resolution.minimumSize) + } + + /// Points the settings add to a style (0 when nothing applies). + /// Row heights sized for one line of that style grow by this amount. + static func extraHeight(_ style: Font.TextStyle, resolution: InterfaceTextResolution) -> CGFloat { + CGFloat(pointSize(style, resolution: resolution) - basePointSize(style)) + } + + /// Resolved size relative to the system size, for lengths that must track + /// a style proportionally (for example the checks ring beside caption text). + static func scaleFactor(_ style: Font.TextStyle, resolution: InterfaceTextResolution) -> Double { + pointSize(style, resolution: resolution) / basePointSize(style) + } + + /// Ascender of the system font rows use for their primary text, at the + /// resolved size. Keeps AppKit-derived baseline alignment guides in step + /// with the resolved SwiftUI fonts. + static func bodyAscender(resolution: InterfaceTextResolution) -> CGFloat { + NSFont.systemFont(ofSize: pointSize(.body, resolution: resolution)).ascender + } + + private static func basePointSize(_ style: Font.TextStyle) -> Double { + NSFont.preferredFont(forTextStyle: nsTextStyle(style)).pointSize + } + + private static func nsTextStyle(_ style: Font.TextStyle) -> NSFont.TextStyle { + switch style { + case .largeTitle: + return .largeTitle + case .title: + return .title1 + case .title2: + return .title2 + case .title3: + return .title3 + case .headline: + return .headline + case .subheadline: + return .subheadline + case .body: + return .body + case .callout: + return .callout + case .footnote: + return .footnote + case .caption: + return .caption1 + case .caption2: + return .caption2 + @unknown default: + return .body + } + } +} diff --git a/supacode/Support/ShortcutHintView.swift b/supacode/Support/ShortcutHintView.swift index 0c9f42cec..1615840ae 100644 --- a/supacode/Support/ShortcutHintView.swift +++ b/supacode/Support/ShortcutHintView.swift @@ -6,7 +6,7 @@ struct ShortcutHintView: View { var body: some View { Text(text) - .font(.caption2) + .interfaceFont(.caption2) .lineLimit(1) .fixedSize(horizontal: true, vertical: false) .foregroundStyle(color) diff --git a/supacodeTests/InterfaceTextMetricsTests.swift b/supacodeTests/InterfaceTextMetricsTests.swift new file mode 100644 index 000000000..ae8cc290a --- /dev/null +++ b/supacodeTests/InterfaceTextMetricsTests.swift @@ -0,0 +1,91 @@ +import AppKit +import SwiftUI +import Testing + +@testable import supacode + +/// The two interface text settings resolve differently on purpose, and the +/// difference is the reason there are two of them. These pin that difference. +struct InterfaceTextMetricsTests { + /// The styles the app chrome actually uses, smallest first. + private static let ramp: [Font.TextStyle] = [ + .caption2, .caption, .footnote, .subheadline, .callout, .body, .headline, .title3, .title2, + ] + + private func base(_ style: Font.TextStyle) -> Double { + InterfaceTextMetrics.pointSize(style, resolution: .system) + } + + @Test func defaultsLeaveEverySizeAtTheSystemValue() { + for style in Self.ramp { + let resolved = InterfaceTextMetrics.pointSize(style, resolution: .system) + #expect(resolved == base(style), "\(style) moved with no setting applied") + #expect(InterfaceTextMetrics.extraHeight(style, resolution: .system) == 0) + #expect(InterfaceTextMetrics.scaleFactor(style, resolution: .system) == 1) + } + } + + @Test func floorLiftsOnlyStylesBelowIt() { + let resolution = InterfaceTextResolution(scale: 1, minimumSize: 12) + for style in Self.ramp { + let resolved = InterfaceTextMetrics.pointSize(style, resolution: resolution) + if base(style) >= 12 { + #expect(resolved == base(style), "\(style) was already above the floor and moved") + } else { + #expect(resolved == 12, "\(style) was below the floor and did not reach it") + } + } + } + + /// A floor collapses everything under it onto one size. This is inherent to a + /// floor rather than a defect, and it is why the scale setting exists: a + /// reader who wants larger text with the hierarchy intact must not be told to + /// reach for this one. + @Test func floorCollapsesTheStylesBeneathIt() { + let resolution = InterfaceTextResolution(scale: 1, minimumSize: 16) + let collapsed = Self.ramp.filter { base($0) < 16 } + let resolved = Set(collapsed.map { InterfaceTextMetrics.pointSize($0, resolution: resolution) }) + + #expect(collapsed.count > 1, "expected several styles below a 16pt floor") + #expect(resolved == [16], "styles below the floor must all resolve to it") + } + + @Test func scalePreservesTheOrderingAndTheGaps() { + let resolution = InterfaceTextResolution(scale: 1.5, minimumSize: 0) + let resolved = Self.ramp.map { InterfaceTextMetrics.pointSize($0, resolution: resolution) } + + #expect(resolved == Self.ramp.map { base($0) * 1.5 }) + #expect(resolved == resolved.sorted(), "scaling must not reorder the ramp") + #expect(Set(resolved).count == Set(Self.ramp.map { base($0) }).count, "scaling must not merge sizes") + } + + @Test func scaleAppliesBeforeTheFloor() { + // caption scaled past the floor keeps its scaled size rather than being + // pinned to the floor; a floor applied first would have clamped it low. + let resolution = InterfaceTextResolution(scale: 2, minimumSize: 12) + let resolved = InterfaceTextMetrics.pointSize(.caption, resolution: resolution) + + #expect(resolved == base(.caption) * 2) + #expect(resolved > 12) + } + + @Test func floorStillAppliesUnderneathASmallScale() { + let resolution = InterfaceTextResolution(scale: 0.5, minimumSize: 13) + for style in Self.ramp { + #expect(InterfaceTextMetrics.pointSize(style, resolution: resolution) >= 13) + } + } + + @Test func rawPointSizesFollowTheSameRule() { + let resolution = InterfaceTextResolution(scale: 1.25, minimumSize: 14) + #expect(InterfaceTextMetrics.pointSize(8, resolution: resolution) == 14) + #expect(InterfaceTextMetrics.pointSize(20, resolution: resolution) == 25) + } + + @Test func settingsMapToTheResolutionTheyPromise() { + #expect(MinimumTextSize.system.points == nil) + #expect(MinimumTextSize.points14.points == 14) + #expect(InterfaceTextScale.system.factor == 1) + #expect(InterfaceTextScale.percent125.factor == 1.25) + } +} diff --git a/supacodeTests/SettingsFeatureTests.swift b/supacodeTests/SettingsFeatureTests.swift index 0fa83ae9c..f9bdb128c 100644 --- a/supacodeTests/SettingsFeatureTests.swift +++ b/supacodeTests/SettingsFeatureTests.swift @@ -332,6 +332,20 @@ struct SettingsFeatureTests { #expect(settingsFile.global.canvasDefaultLayout == .uniform) } + @Test(.dependencies) func changingMinimumTextSizePersists() async { + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global = .default } + let store = TestStore(initialState: SettingsFeature.State()) { + SettingsFeature() + } + #expect(store.state.minimumTextSize == .system) + await store.send(.binding(.set(\.minimumTextSize, .points13))) { + $0.minimumTextSize = .points13 + } + await store.receive(\.delegate.settingsChanged) + #expect(settingsFile.global.minimumTextSize == .points13) + } + @Test(.dependencies) func changingGlobalOverrideDefaultsUpdatesRepositorySettingsState() async { let rootURL = URL(fileURLWithPath: "/tmp/repo") @Shared(.settingsFile) var settingsFile diff --git a/supacodeTests/SettingsFilePersistenceTests.swift b/supacodeTests/SettingsFilePersistenceTests.swift index 9a1bc8455..d4b2a9325 100644 --- a/supacodeTests/SettingsFilePersistenceTests.swift +++ b/supacodeTests/SettingsFilePersistenceTests.swift @@ -375,6 +375,51 @@ struct SettingsFilePersistenceTests { #expect(settings.global.systemNotificationsEnabled == true) #expect(settings.global.updatesAutomaticallyDownloadUpdates == true) } + + @Test(.dependencies) func saveAndReloadMinimumTextSize() throws { + let storage = SettingsTestStorage() + + withDependencies { + $0.settingsFileStorage = storage.storage + } operation: { + @Shared(.settingsFile) var settings: SettingsFile + $settings.withLock { + $0.global.minimumTextSize = .points12 + } + } + + let reloaded: SettingsFile = withDependencies { + $0.settingsFileStorage = storage.storage + } operation: { + @Shared(.settingsFile) var settings: SettingsFile + return settings + } + + #expect(reloaded.global.minimumTextSize == .points12) + } + + @Test(.dependencies) func decodesUnrecognizedMinimumTextSizeAsDefaultWithoutResettingSiblings() throws { + var global = GlobalSettings.default + global.appearanceMode = .dark + global.systemNotificationsEnabled = true + + let encoded = try JSONEncoder().encode(global) + var globalDict = try #require(try JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + globalDict["minimumTextSize"] = "futureSizeFromNewerBuild" + let data = try JSONSerialization.data(withJSONObject: ["global": globalDict, "repositories": [:]]) + let storage = MutableTestStorage(initialData: data) + + let settings: SettingsFile = withDependencies { + $0.settingsFileStorage = storage.storage + } operation: { + @Shared(.settingsFile) var settings: SettingsFile + return settings + } + + #expect(settings.global.minimumTextSize == .system) + #expect(settings.global.appearanceMode == .dark) + #expect(settings.global.systemNotificationsEnabled == true) + } } nonisolated private final class MutableTestStorage: @unchecked Sendable {