From 21b951ee01ba31af89083f16916ba84ba14da839 Mon Sep 17 00:00:00 2001 From: Simon Heimlicher Date: Sun, 30 Aug 2026 20:24:34 +0200 Subject: [PATCH] Add a Safari-style minimum text size for app chrome Prowl's chrome is built from SwiftUI text styles, which follow the system text size and nothing else. On a large display at a comfortable terminal font, the sidebar, Active Agents panel, toolbar and tab bar render text several points smaller than the terminal beside them, and macOS offers no control that lifts them without scaling the whole interface. Add a floor rather than a scale, in the shape Safari uses for "Never use font sizes smaller than": chrome text below the chosen size is lifted to it, and text already above it is left alone. The picker sits in the Appearance section and offers System plus 11-16 pt. `InterfaceTextMetrics` resolves a style against the floor, `interfaceFont` applies it from the environment, and `fixedInterfaceFont` marks the sizes that are layout constants rather than reading matter. A custom SwiftLint rule keeps new chrome views on the modifier. Terminal content is untouched: it has its own font setting and is not chrome. --- .swiftlint.yml | 15 ++ docs/components/settings.md | 2 +- docs/reference/settings-fields.md | 134 ++++++------ supacode/App/AppLoadingView.swift | 2 +- supacode/App/ContentView.swift | 8 +- supacode/App/supacodeApp.swift | 7 + .../ActiveAgents/Views/ActiveAgentRow.swift | 12 +- .../Views/ActiveAgentsPanel.swift | 4 +- .../Canvas/Views/CanvasCardView.swift | 8 +- .../Canvas/Views/CanvasHelpButton.swift | 8 +- .../Canvas/Views/CanvasSidebarButton.swift | 2 +- .../Features/Canvas/Views/CanvasView.swift | 10 +- .../Views/CommandPaletteOverlayView.swift | 8 +- .../Debug/Views/IconCatalogView.swift | 4 +- .../DiffView/DiffWindowContentView.swift | 8 +- .../Views/HandoffHudOverlayView.swift | 20 +- supacode/Features/Help/AskAgentHelpView.swift | 6 +- .../Repositories/Views/AddToProwlView.swift | 20 +- .../Views/AgentsToolbarButton.swift | 24 +- .../Views/ArchivedWorktreeRowView.swift | 16 +- .../Views/ArchivedWorktreesDetailView.swift | 6 +- .../Views/CloneRepositoryView.swift | 8 +- .../DeleteWorktreeConfirmationView.swift | 4 +- .../Views/FailedRepositoryRow.swift | 3 +- .../Views/NotificationPopoverView.swift | 6 +- .../OpenWorktreeActionMenuLabelView.swift | 6 +- .../Views/PullRequestBadgeView.swift | 2 +- .../Views/PullRequestChecksPopoverView.swift | 16 +- .../Views/PullRequestChecksRingView.swift | 11 +- .../Views/PullRequestStatusButton.swift | 2 +- .../RemoveWorkspaceConfirmationView.swift | 8 +- .../Views/RenameBranchPromptView.swift | 6 +- .../Repositories/Views/RepoHeaderRow.swift | 5 +- .../Views/RepositoryDetailView.swift | 8 +- .../Views/SidebarFooterView.swift | 2 +- .../Repositories/Views/SidebarListView.swift | 4 +- .../ToolbarNotificationsPopoverButton.swift | 4 +- .../ToolbarNotificationsPopoverView.swift | 10 +- .../Views/ToolbarStatusView.swift | 11 +- .../Views/WorkspaceCreationPromptView.swift | 26 +-- .../Views/WorkspaceDetailView.swift | 16 +- .../Views/WorkspaceRepositoriesGridView.swift | 16 +- .../Views/WorktreeCreationPromptView.swift | 18 +- .../Views/WorktreeDetailToolbarViews.swift | 27 ++- .../Views/WorktreeDetailView.swift | 2 +- .../Views/WorktreeLoadingView.swift | 6 +- .../Repositories/Views/WorktreeRow.swift | 27 ++- .../RepositoryAppearancePickerView.swift | 16 +- .../RepositorySymbolSuggestionsSection.swift | 12 +- .../Settings/Models/GlobalSettings.swift | 24 ++ .../Settings/Models/InterfaceTextScale.swift | 44 ++++ .../Settings/Models/MinimumTextSize.swift | 36 +++ .../Settings/Reducer/SettingsFeature.swift | 8 + .../Settings/Views/AdvancedSettingsView.swift | 10 +- .../Views/AgentProfileEditorView.swift | 18 +- .../Views/AgentSkillsSectionView.swift | 16 +- .../Views/AppearanceOptionCardView.swift | 2 +- .../Views/AppearanceSettingsView.swift | 35 ++- .../Views/CommandLineToolSettingsView.swift | 8 +- .../Settings/Views/CustomCommandsEditor.swift | 34 +-- .../Settings/Views/GithubSettingsView.swift | 12 +- .../Views/NotificationsSettingsView.swift | 2 +- .../Views/RepositorySettingsView.swift | 6 +- .../Views/ShortcutsSettingsView.swift | 18 +- .../Settings/Views/UpdatesSettingsView.swift | 2 +- .../Shelf/Views/ShelfSidebarButton.swift | 2 +- .../Features/Shelf/Views/ShelfSpineView.swift | 20 +- .../TabBar/Views/TabIconPickerView.swift | 4 +- .../TerminalTabBarTrailingAccessories.swift | 5 + .../TabBar/Views/TerminalTabBarView.swift | 2 +- .../TabBar/Views/TerminalTabCloseButton.swift | 3 +- .../TabBar/Views/TerminalTabLabelView.swift | 2 +- .../TabBar/Views/TerminalTabView.swift | 8 +- .../Views/EmptyTerminalPaneView.swift | 4 +- .../Views/GhosttySurfaceSearchOverlay.swift | 12 +- .../Terminal/Views/TabIconImage.swift | 2 +- .../Views/TerminalSplitTreeView.swift | 4 +- supacode/Support/InterfaceTextMetrics.swift | 205 ++++++++++++++++++ supacode/Support/ShortcutHintView.swift | 2 +- supacodeTests/InterfaceTextMetricsTests.swift | 91 ++++++++ supacodeTests/SettingsFeatureTests.swift | 14 ++ .../SettingsFilePersistenceTests.swift | 45 ++++ 82 files changed, 938 insertions(+), 368 deletions(-) create mode 100644 supacode/Features/Settings/Models/InterfaceTextScale.swift create mode 100644 supacode/Features/Settings/Models/MinimumTextSize.swift create mode 100644 supacode/Support/InterfaceTextMetrics.swift create mode 100644 supacodeTests/InterfaceTextMetricsTests.swift 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 {