From 6f8ca4053266e510146308ac1ef35f228a9a851c Mon Sep 17 00:00:00 2001 From: r3dbars Date: Tue, 7 Jul 2026 15:16:37 -0500 Subject: [PATCH] WS4 premium minimalism: error states that act, teaching empty state, design tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Visual/UX polish from docs/TRANSCRIPTED_PREMIUM_PRD.md WS4. Additive and backward-compatible; no perf/latency budget touched (separate thread owns that). Error states that act (WS4.3) - New Foundation-pure AgentSetupFailureCopy + SettingsActionFailureCopy hold plain-words failure copy (no jargon, no exclamation marks). Every fixed failure now reads "what happened" in plain words, keeps its existing control as the retry, and tucks the raw error behind a "Copy Details" reveal instead of dumping error.localizedDescription into a user-facing label. - Fixed the 9 highest-visibility raw-error offenders from the audit: the Agent page (connect row, Codex inbox, all four live-meeting setup paths), the first-run onboarding Claude Desktop connect card, and five Settings status lines (beta sidecar, local-summary prep, model-cache cleanup ×3, launch-at-login, capture-library migration). Empty state that teaches (WS4.5) - The Speakers "All speakers" empty state was a bare gray caption; it now teaches what fills the screen and offers a real next action (Start a meeting), reusing the Home teaching-empty-state pattern. Search-no-match keeps its plain caption. Design tokens (WS4.4) - New docs/DESIGN_TOKENS.md is the single source for the type scale (6-step SF Pro), spacing grid, and corner-radius set, grounded in the app's most-common existing values. MenuTokens now owns the menubar's type roles (MenuTokens.Font) and the menubar rows/header read fonts from there instead of raw NSFont literals — the reference adoption that sets the pattern for the SwiftUI surfaces to follow. Verification - New UIAutomationSurfaceContract guards pin all of the above (teaching empty state present, zero raw error.localizedDescription on the hardened surfaces, tokens are the single source). Full app build, full fast suite (12755 tests), and the duplicate-declaration hygiene check all pass. Co-Authored-By: Claude Opus 4.8 --- Sources/UI/MenuBar/MenuBarActionRowView.swift | 12 +- Sources/UI/MenuBar/MenuBarHeaderView.swift | 8 +- Sources/UI/MenuBar/MenuTokens.swift | 16 +++ .../AgentConnectionSettingsPage.swift | 83 ++++++++--- .../UI/Settings/AgentSetupFailureCopy.swift | 33 +++++ .../Settings/PermissionsOnboardingView.swift | 5 +- .../Settings/SettingsActionFailureCopy.swift | 32 +++++ .../SpeakerPeopleSettingsSection.swift | 73 +++++++++- .../Settings/TranscriptedSettingsView.swift | 65 +++++++-- Tests/UIAutomationSurfaceContractTests.swift | 130 ++++++++++++++++++ docs/DESIGN_TOKENS.md | 87 ++++++++++++ 11 files changed, 498 insertions(+), 46 deletions(-) create mode 100644 Sources/UI/Settings/AgentSetupFailureCopy.swift create mode 100644 Sources/UI/Settings/SettingsActionFailureCopy.swift create mode 100644 docs/DESIGN_TOKENS.md diff --git a/Sources/UI/MenuBar/MenuBarActionRowView.swift b/Sources/UI/MenuBar/MenuBarActionRowView.swift index d4803a90..5435ee4b 100644 --- a/Sources/UI/MenuBar/MenuBarActionRowView.swift +++ b/Sources/UI/MenuBar/MenuBarActionRowView.swift @@ -223,13 +223,13 @@ final class MenuBarActionRowView: NSControl { private func updateTypography() { switch rowSize { case .primary: - titleLabel.font = NSFont.systemFont(ofSize: 12.5, weight: .medium) - detailLabel.font = NSFont.systemFont(ofSize: 10) - trailingLabel.font = NSFont.systemFont(ofSize: 10.5, weight: .medium) + titleLabel.font = MenuTokens.Font.rowTitlePrimary + detailLabel.font = MenuTokens.Font.rowDetail + trailingLabel.font = MenuTokens.Font.rowTrailingPrimary case .utility: - titleLabel.font = NSFont.systemFont(ofSize: 12.5, weight: .regular) - detailLabel.font = NSFont.systemFont(ofSize: 10) - trailingLabel.font = NSFont.systemFont(ofSize: 10, weight: .medium) + titleLabel.font = MenuTokens.Font.rowTitleUtility + detailLabel.font = MenuTokens.Font.rowDetail + trailingLabel.font = MenuTokens.Font.rowTrailingUtility } } diff --git a/Sources/UI/MenuBar/MenuBarHeaderView.swift b/Sources/UI/MenuBar/MenuBarHeaderView.swift index 58891d0f..7cf0d0ae 100644 --- a/Sources/UI/MenuBar/MenuBarHeaderView.swift +++ b/Sources/UI/MenuBar/MenuBarHeaderView.swift @@ -35,7 +35,7 @@ final class MenuBarHeaderView: NSView { override var isFlipped: Bool { true } private func setupViews() { - titleLabel.font = NSFont.systemFont(ofSize: 15.5, weight: .semibold) + titleLabel.font = MenuTokens.Font.headerTitle titleLabel.textColor = MenuTokens.textPrimaryNS addSubview(titleLabel) @@ -43,7 +43,7 @@ final class MenuBarHeaderView: NSView { statusDot.layer?.cornerRadius = MenuTokens.statusDotSize / 2 addSubview(statusDot) - statusLabel.font = NSFont.systemFont(ofSize: 11.5, weight: .medium) + statusLabel.font = MenuTokens.Font.headerStatus statusLabel.textColor = MenuTokens.textSecondaryNS addSubview(statusLabel) @@ -53,7 +53,7 @@ final class MenuBarHeaderView: NSView { progressBar.maxValue = 1 addSubview(progressBar) - detailLabel.font = NSFont.systemFont(ofSize: 10) + detailLabel.font = MenuTokens.Font.headerDetail detailLabel.textColor = MenuTokens.textSecondaryNS detailLabel.maximumNumberOfLines = 2 addSubview(detailLabel) @@ -64,7 +64,7 @@ final class MenuBarHeaderView: NSView { } addSubview(warningIconView) - warningLabel.font = NSFont.systemFont(ofSize: 10) + warningLabel.font = MenuTokens.Font.headerDetail warningLabel.textColor = MenuTokens.textSecondaryNS warningLabel.maximumNumberOfLines = 2 addSubview(warningLabel) diff --git a/Sources/UI/MenuBar/MenuTokens.swift b/Sources/UI/MenuBar/MenuTokens.swift index 029ef52f..b290dce8 100644 --- a/Sources/UI/MenuBar/MenuTokens.swift +++ b/Sources/UI/MenuBar/MenuTokens.swift @@ -70,6 +70,22 @@ enum MenuTokens { static let compactActionRowHeight: CGFloat = 42 static let utilityActionRowHeight: CGFloat = 40 static let statusDotSize: CGFloat = 6 + + // Type — the popover's SF Pro roles. See docs/DESIGN_TOKENS.md. The menubar + // is the app's compact density tier, so its sizes sit a half-point below the + // main-window scale; they live here as named roles so views never reach for + // a raw `NSFont.systemFont(ofSize:)` literal. Changing a menubar font means + // changing it once, here. + enum Font { + static let rowTitlePrimary = NSFont.systemFont(ofSize: 12.5, weight: .medium) + static let rowTitleUtility = NSFont.systemFont(ofSize: 12.5, weight: .regular) + static let rowDetail = NSFont.systemFont(ofSize: 10, weight: .regular) + static let rowTrailingPrimary = NSFont.systemFont(ofSize: 10.5, weight: .medium) + static let rowTrailingUtility = NSFont.systemFont(ofSize: 10, weight: .medium) + static let headerTitle = NSFont.systemFont(ofSize: 15.5, weight: .semibold) + static let headerStatus = NSFont.systemFont(ofSize: 11.5, weight: .medium) + static let headerDetail = NSFont.systemFont(ofSize: 10, weight: .regular) + } } extension NSView { diff --git a/Sources/UI/Settings/AgentConnectionSettingsPage.swift b/Sources/UI/Settings/AgentConnectionSettingsPage.swift index 983f02b3..1a9c82ca 100644 --- a/Sources/UI/Settings/AgentConnectionSettingsPage.swift +++ b/Sources/UI/Settings/AgentConnectionSettingsPage.swift @@ -21,6 +21,9 @@ struct AgentConnectionSettingsPage: View { @State private var detectedAgents: Set = [] @State private var connectedAgents: Set = [] @State private var rowPhases: [AgentMCPAgent: RowPhase] = [:] + // Raw error text for the connect-row failure, kept out of the user-visible + // message and offered behind "Copy Details". + @State private var rowFailureDetails: [AgentMCPAgent: String] = [:] @State private var configRepairNotices: [AgentMCPAgent: String] = [:] @State private var claudeDesktopSelfTest: TranscriptedMCPSelfTest? @State private var copiedLocalAgentPrompt = false @@ -28,10 +31,12 @@ struct AgentConnectionSettingsPage: View { @State private var copiedFolderPaths = false @State private var openedCodexInboxSetup = false @State private var codexInboxSetupError: String? + @State private var codexInboxSetupErrorDetails: String? @State private var openedLiveMeetingCodexSetup = false @State private var openedLiveMeetingPreview = false @State private var copiedLiveMeetingCoworkSetup = false @State private var liveMeetingCodexSetupError: String? + @State private var liveMeetingCodexSetupErrorDetails: String? @State private var showAdvancedAgentSetup = false @AppStorage(LiveMeetingCodexPreferences.enabledKey) private var liveMeetingCodexEnabled = LiveMeetingCodexPreferences.defaultEnabled @@ -121,10 +126,11 @@ struct AgentConnectionSettingsPage: View { } if case .failed(let message) = phase { - Label(message, systemImage: "exclamationmark.triangle.fill") - .font(.caption) - .foregroundStyle(.orange) - .fixedSize(horizontal: false, vertical: true) + failureNotice( + message: message, + details: rowFailureDetails[agent], + detailsAutomationIdentifier: "transcripted.settings.agent.connect-error-details.\(agent.rawValue)" + ) } if let configRepairNotice = configRepairNotices[agent] { @@ -247,10 +253,11 @@ struct AgentConnectionSettingsPage: View { } if let liveMeetingCodexSetupError { - Label(liveMeetingCodexSetupError, systemImage: "exclamationmark.triangle.fill") - .font(.caption) - .foregroundStyle(.orange) - .fixedSize(horizontal: false, vertical: true) + failureNotice( + message: liveMeetingCodexSetupError, + details: liveMeetingCodexSetupErrorDetails, + detailsAutomationIdentifier: "transcripted.settings.agent.live-meetings.error-details" + ) } } } @@ -335,10 +342,11 @@ struct AgentConnectionSettingsPage: View { } if let codexInboxSetupError { - Label(codexInboxSetupError, systemImage: "exclamationmark.triangle.fill") - .font(.caption) - .foregroundStyle(.orange) - .fixedSize(horizontal: false, vertical: true) + failureNotice( + message: codexInboxSetupError, + details: codexInboxSetupErrorDetails, + detailsAutomationIdentifier: "transcripted.settings.agent.codex-inbox.error-details" + ) } } } @@ -433,11 +441,39 @@ struct AgentConnectionSettingsPage: View { } } + // MARK: - Failure notice + + /// Plain-words failure line plus a subtle "Copy Details" reveal for the raw + /// error. The triggering control (Connect / Set up / Open) is the retry. + @ViewBuilder + private func failureNotice( + message: String, + details: String?, + detailsAutomationIdentifier: String + ) -> some View { + VStack(alignment: .leading, spacing: 4) { + Label(message, systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(.orange) + .fixedSize(horizontal: false, vertical: true) + + if let details { + Button(AgentSetupFailureCopy.detailsTitle) { + copyText(details) + } + .buttonStyle(.link) + .font(.caption) + .accessibilityIdentifier(detailsAutomationIdentifier) + } + } + } + // MARK: - Connect private func connect(_ agent: AgentMCPAgent) { guard rowPhases[agent] != .connecting else { return } rowPhases[agent] = .connecting + rowFailureDetails[agent] = nil configRepairNotices[agent] = nil if agent == .claudeDesktop { claudeDesktopSelfTest = nil @@ -470,7 +506,8 @@ struct AgentConnectionSettingsPage: View { } trackConnect(agent, priorStatus: priorStatus, result: .success) } catch { - rowPhases[agent] = .failed(error.localizedDescription) + rowPhases[agent] = .failed(AgentSetupFailureCopy.connect(agentName: agent.displayName)) + rowFailureDetails[agent] = error.localizedDescription trackConnect(agent, priorStatus: priorStatus, result: .failed) } } @@ -511,6 +548,7 @@ struct AgentConnectionSettingsPage: View { private func setupCodexInbox() { codexInboxSetupError = nil + codexInboxSetupErrorDetails = nil do { let inboxURL = try AgentConnectionGuide.ensureCodexInboxFolder() @@ -558,7 +596,8 @@ struct AgentConnectionSettingsPage: View { ) } } catch { - codexInboxSetupError = "Could not set up Codex Inbox: \(error.localizedDescription)" + codexInboxSetupError = AgentSetupFailureCopy.codexInbox + codexInboxSetupErrorDetails = error.localizedDescription ActivationTelemetry.trackAgentSetupCTA( setupKind: .codexInbox, agentTarget: .codex, @@ -572,6 +611,7 @@ struct AgentConnectionSettingsPage: View { private func setupLiveMeetingCodex() { liveMeetingCodexSetupError = nil + liveMeetingCodexSetupErrorDetails = nil do { liveMeetingCodexEnabled = true @@ -622,7 +662,8 @@ struct AgentConnectionSettingsPage: View { } } catch { disableLiveMeetingSidecarAfterFailure() - liveMeetingCodexSetupError = "Could not set up Live Meetings: \(error.localizedDescription)" + liveMeetingCodexSetupError = AgentSetupFailureCopy.liveMeetings + liveMeetingCodexSetupErrorDetails = error.localizedDescription ActivationTelemetry.trackAgentSetupCTA( setupKind: .liveSidecar, agentTarget: .codex, @@ -634,17 +675,20 @@ struct AgentConnectionSettingsPage: View { private func prepareLiveMeetingSidecarWorkspace() { liveMeetingCodexSetupError = nil + liveMeetingCodexSetupErrorDetails = nil do { _ = try prepareLiveMeetingSidecarWorkspaceForUse() } catch { disableLiveMeetingSidecarAfterFailure() - liveMeetingCodexSetupError = "Could not prepare Live Meetings: \(error.localizedDescription)" + liveMeetingCodexSetupError = AgentSetupFailureCopy.liveMeetingsPrepare + liveMeetingCodexSetupErrorDetails = error.localizedDescription } } private func copyLiveMeetingCoworkSetup() { liveMeetingCodexSetupError = nil + liveMeetingCodexSetupErrorDetails = nil do { liveMeetingCodexEnabled = true @@ -666,7 +710,8 @@ struct AgentConnectionSettingsPage: View { showCopiedFeedback($copiedLiveMeetingCoworkSetup) } catch { disableLiveMeetingSidecarAfterFailure() - liveMeetingCodexSetupError = "Could not set up Live Meetings: \(error.localizedDescription)" + liveMeetingCodexSetupError = AgentSetupFailureCopy.liveMeetings + liveMeetingCodexSetupErrorDetails = error.localizedDescription ActivationTelemetry.trackAgentSetupCTA( setupKind: .liveSidecar, agentTarget: .cowork, @@ -678,6 +723,7 @@ struct AgentConnectionSettingsPage: View { private func openLiveMeetingPreview() { liveMeetingCodexSetupError = nil + liveMeetingCodexSetupErrorDetails = nil do { liveMeetingCodexEnabled = true @@ -718,7 +764,8 @@ struct AgentConnectionSettingsPage: View { } } catch { disableLiveMeetingSidecarAfterFailure() - liveMeetingCodexSetupError = "Could not open Live View: \(error.localizedDescription)" + liveMeetingCodexSetupError = AgentSetupFailureCopy.liveView + liveMeetingCodexSetupErrorDetails = error.localizedDescription ActivationTelemetry.trackAgentSetupCTA( setupKind: .livePreview, agentTarget: .localAgent, diff --git a/Sources/UI/Settings/AgentSetupFailureCopy.swift b/Sources/UI/Settings/AgentSetupFailureCopy.swift new file mode 100644 index 00000000..8e8f9ba5 --- /dev/null +++ b/Sources/UI/Settings/AgentSetupFailureCopy.swift @@ -0,0 +1,33 @@ +import Foundation + +/// Plain-words copy for Agent-page setup failures. +/// +/// Foundation-pure so the strings can be pinned by fast tests and can never +/// regress to a raw `error.localizedDescription` dump the way these sites did +/// before. Each message says what happened and the one thing to try; the raw +/// error is offered separately behind "Copy Details", never shown inline. Plain +/// words, no jargon, no exclamation marks — the repo voice convention. +/// +/// This mirrors `HomeActionFailureCopy` (the meeting/Home path's template): +/// classify the failure into a context, return `{plain message}`, and keep the +/// raw string for a reveal affordance while the triggering control provides retry. +enum AgentSetupFailureCopy { + /// Title for the reveal affordance that copies the raw error to the clipboard. + static let detailsTitle = "Copy Details" + + static func connect(agentName: String) -> String { + "Transcripted couldn't connect \(agentName). Check that it's installed and not already running, then try Connect again." + } + + static let liveMeetings = + "Transcripted couldn't set up Live Meetings. Check that Codex is installed, then try again." + + static let liveMeetingsPrepare = + "Transcripted couldn't prepare Live Meetings. Try turning it on again." + + static let liveView = + "Transcripted couldn't open the live view. Try turning Live Meetings on again." + + static let codexInbox = + "Transcripted couldn't set up the Codex inbox. Check that Codex is installed and you have free disk space, then try again." +} diff --git a/Sources/UI/Settings/PermissionsOnboardingView.swift b/Sources/UI/Settings/PermissionsOnboardingView.swift index ad1209e6..4af67054 100644 --- a/Sources/UI/Settings/PermissionsOnboardingView.swift +++ b/Sources/UI/Settings/PermissionsOnboardingView.swift @@ -535,7 +535,10 @@ struct PermissionsOnboardingView: View { result: .success ) } catch { - claudeDesktopConnectPhase = .failed(error.localizedDescription) + // Plain words on the first-run card instead of a raw NSError dump. + // The button flips to "Try again" (the retry); the raw error still + // goes to telemetry above, not onto the user's onboarding screen. + claudeDesktopConnectPhase = .failed(AgentSetupFailureCopy.connect(agentName: "Claude Desktop")) ActivationTelemetry.trackAgentSetupCTA( setupKind: .claudeDesktop, agentTarget: .claudeDesktop, diff --git a/Sources/UI/Settings/SettingsActionFailureCopy.swift b/Sources/UI/Settings/SettingsActionFailureCopy.swift new file mode 100644 index 00000000..09a65f5d --- /dev/null +++ b/Sources/UI/Settings/SettingsActionFailureCopy.swift @@ -0,0 +1,32 @@ +import Foundation + +/// Plain-words copy for Settings-page action failures (beta setup, model-cache +/// cleanup, launch-at-login, capture-library migration). +/// +/// Foundation-pure so the strings can be pinned by fast tests and can never +/// regress to a raw `error.localizedDescription` dump. Each message says what +/// happened and the one thing to try; the raw error is offered separately behind +/// "Copy Details", never shown inline. Plain words, no jargon, no exclamation +/// marks — the repo voice convention. Mirrors `HomeActionFailureCopy` and +/// `AgentSetupFailureCopy`. +enum SettingsActionFailureCopy { + /// Title for the reveal affordance that copies the raw error to the clipboard. + static let detailsTitle = "Copy Details" + + static let betaLiveSidecar = + "Transcripted couldn't prepare the live meeting sidecar. Try turning it on again." + + static func localSummary(providerTitle: String) -> String { + "\(providerTitle) setup didn't finish. Check your connection and free disk space, then try again." + } + + static let modelCacheRemoval = + "Transcripted couldn't remove those model files. Check that no capture is running, then try again." + + static let launchAtLogin = + "Transcripted couldn't change the launch-at-login setting. Try again." + + static func captureLibraryMigration(currentLibraryPath: String) -> String { + "The copy stopped before it finished. Your captures are still in \(currentLibraryPath) and the library was not switched. Try again." + } +} diff --git a/Sources/UI/Settings/SpeakerPeopleSettingsSection.swift b/Sources/UI/Settings/SpeakerPeopleSettingsSection.swift index 11845ed0..fd8893c2 100644 --- a/Sources/UI/Settings/SpeakerPeopleSettingsSection.swift +++ b/Sources/UI/Settings/SpeakerPeopleSettingsSection.swift @@ -727,6 +727,9 @@ struct SpeakerPeopleSettingsSection: View { } @ObservedObject var model: SpeakerPeopleSettingsViewModel + /// Optional hook so the first-run empty state can offer a real next step. + /// Defaults to nil to keep the initializer additive for existing call sites. + var onStartMeeting: (() -> Void)? = nil @State private var playbackStateVersion = 0 var body: some View { @@ -780,9 +783,13 @@ struct SpeakerPeopleSettingsSection: View { } if model.filteredProfiles.isEmpty { - Text(emptyPeopleMessage) - .font(.caption) - .foregroundStyle(.secondary) + if model.profiles.isEmpty { + SpeakersEmptyStateView(onStartMeeting: onStartMeeting) + } else { + Text(emptyPeopleMessage) + .font(.caption) + .foregroundStyle(.secondary) + } } else { let profiles = model.filteredProfiles LazyVStack(alignment: .leading, spacing: 0) { @@ -823,11 +830,65 @@ struct SpeakerPeopleSettingsSection: View { return count == 1 ? "1 saved speaker." : "\(count) saved speakers." } + // The truly-empty case now renders `SpeakersEmptyStateView`; this only covers + // the "search filtered everyone out" case, where a plain caption is right. private var emptyPeopleMessage: String { - if model.profiles.isEmpty { - return "No speakers yet. After your next meeting, the people in it will appear here." + SpeakerPeopleEmptyState.noSearchMatches + } +} + +// MARK: - Empty state copy + teaching view + +/// Foundation-pure copy for the Speakers surface's empty states. Kept as +/// constants so the teaching first-run copy can be pinned by fast tests and can +/// never regress to bare gray placeholder text. Plain words, no exclamation +/// marks, per the repo voice convention. +enum SpeakerPeopleEmptyState { + static let symbolName = "person.2" + static let title = "No speakers yet" + static let message = "Transcripted learns each voice as you record. After your first meeting, the people in it show up here, so you can name someone once and have them recognized in every meeting after." + static let actionTitle = "Start a meeting" + static let actionAutomationIdentifier = "transcripted.speakers.empty.start-meeting" + static let noSearchMatches = "No speakers match your search." +} + +/// First-run teaching empty state for the all-speakers list: it explains what +/// the screen will fill with and offers the one action that fills it. +private struct SpeakersEmptyStateView: View { + var onStartMeeting: (() -> Void)? + + var body: some View { + VStack(spacing: 12) { + Image(systemName: SpeakerPeopleEmptyState.symbolName) + .font(.system(size: 30, weight: .light)) + .foregroundStyle(.tertiary) + + VStack(spacing: 5) { + Text(SpeakerPeopleEmptyState.title) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(Color.primary) + + Text(SpeakerPeopleEmptyState.message) + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: 360) + } + + if let onStartMeeting { + Button(action: onStartMeeting) { + Text(SpeakerPeopleEmptyState.actionTitle) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .accessibilityIdentifier(SpeakerPeopleEmptyState.actionAutomationIdentifier) + .padding(.top, 2) + } } - return "No speakers match your search." + .frame(maxWidth: .infinity) + .padding(.vertical, 40) + .padding(.horizontal, 16) } } diff --git a/Sources/UI/Settings/TranscriptedSettingsView.swift b/Sources/UI/Settings/TranscriptedSettingsView.swift index 295302b8..c7e3eaec 100644 --- a/Sources/UI/Settings/TranscriptedSettingsView.swift +++ b/Sources/UI/Settings/TranscriptedSettingsView.swift @@ -97,6 +97,12 @@ struct TranscriptedSettingsView: View { @State private var appleSummarySetupStatus = AppleFoundationSummarySetupStatus.current() @State private var showLocalSummarySetupDetails = false @State private var localSummaryModelPreparationStatus: String? + // Raw error text for settings-action failures, kept out of the user-visible + // status line and offered behind "Copy Details". + @State private var betaFeatureStatusDetails: String? + @State private var localSummaryModelPreparationStatusDetails: String? + @State private var modelCacheCleanupStatusDetails: String? + @State private var captureLibraryMigrationStatusDetails: String? @State private var localSummaryModelPreparationTask: Task? @State private var localSummaryModelPreparationToken: UUID? @State private var isLocalSummaryModelPreparing = false @@ -2094,6 +2100,20 @@ struct TranscriptedSettingsView: View { pasteboard.setString(details, forType: .string) } + /// Subtle "Copy Details" reveal shown under a failed settings-action status + /// line. The raw error lives here, never inline in the status message; the + /// action's own button (Set up / Remove / toggle) is the retry. + @ViewBuilder + private func settingsFailureDetailsButton(_ details: String?) -> some View { + if let details { + Button(SettingsActionFailureCopy.detailsTitle) { + copyHomeFailureDetails(details) + } + .buttonStyle(.link) + .font(.caption) + } + } + private func retryFailedMeeting(_ item: MeetingSessionController.FailedMeetingItem) { let didStart = meetingSession.retryFailedMeeting(id: item.id) if !didStart { @@ -3353,7 +3373,10 @@ struct TranscriptedSettingsView: View { summary: "Name new voices and manage the people in your meetings." ) - SpeakerPeopleSettingsSection(model: speakerPeopleModel) + SpeakerPeopleSettingsSection( + model: speakerPeopleModel, + onStartMeeting: { actions.startMeeting() } + ) } .accessibilityIdentifier("transcripted.settings.page.people") } @@ -3415,6 +3438,7 @@ struct TranscriptedSettingsView: View { .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) + settingsFailureDetailsButton(captureLibraryMigrationStatusDetails) } Text("Pick an Obsidian vault or any folder you want agents to read.") @@ -3567,6 +3591,7 @@ struct TranscriptedSettingsView: View { .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) + settingsFailureDetailsButton(modelCacheCleanupStatusDetails) } } else if !modelCacheLoading { Text("Model storage has not been scanned yet.") @@ -3823,6 +3848,7 @@ struct TranscriptedSettingsView: View { .font(.caption) .foregroundStyle(isLocalSummaryModelPreparing ? Color.accentColor : .secondary) .fixedSize(horizontal: false, vertical: true) + settingsFailureDetailsButton(localSummaryModelPreparationStatusDetails) } DisclosureGroup("Setup details", isExpanded: $showLocalSummarySetupDetails) { @@ -3875,13 +3901,15 @@ struct TranscriptedSettingsView: View { } if let betaFeatureStatus { + let isFailure = betaFeatureStatusDetails != nil Label( betaFeatureStatus, - systemImage: betaFeatureStatus.hasPrefix("Could not") ? "exclamationmark.triangle.fill" : "checkmark.circle.fill" + systemImage: isFailure ? "exclamationmark.triangle.fill" : "checkmark.circle.fill" ) .font(.caption) - .foregroundStyle(betaFeatureStatus.hasPrefix("Could not") ? Color.orange : Color.secondary) + .foregroundStyle(isFailure ? Color.orange : Color.secondary) .fixedSize(horizontal: false, vertical: true) + settingsFailureDetailsButton(betaFeatureStatusDetails) } } } @@ -3903,6 +3931,7 @@ struct TranscriptedSettingsView: View { private func handleBetaLiveMeetingSidecarToggle(_ enabled: Bool) { betaFeatureStatus = nil + betaFeatureStatusDetails = nil if enabled { do { @@ -3913,7 +3942,8 @@ struct TranscriptedSettingsView: View { LiveMeetingCodexPreferences.setEnabled(false) meetingSession.stopLiveCodexSessionFromSettings() stopBetaLiveMeetingSidecarPreview() - betaFeatureStatus = "Could not prepare live meeting sidecar: \(error.localizedDescription)" + betaFeatureStatus = SettingsActionFailureCopy.betaLiveSidecar + betaFeatureStatusDetails = error.localizedDescription } } else { meetingSession.stopLiveCodexSessionFromSettings() @@ -3930,6 +3960,7 @@ struct TranscriptedSettingsView: View { clearHomeLocalSummaryNotice() cancelLocalSummaryModelPreparation() localSummaryModelPreparationStatus = nil + localSummaryModelPreparationStatusDetails = nil } } @@ -3962,6 +3993,7 @@ struct TranscriptedSettingsView: View { isLocalSummaryModelPreparing = true localSummaryModelPreparationStatus = localSummaryPreparationStartedStatus + localSummaryModelPreparationStatusDetails = nil recordLocalSummaryEvent( event: "local_meeting_summary_model_prepare_started", message: "\(provider.title) summary model preparation started", @@ -4020,7 +4052,8 @@ struct TranscriptedSettingsView: View { localSummaryModelPreparationTask = nil localSummaryModelPreparationToken = nil refreshLocalSummarySetupStatus() - localSummaryModelPreparationStatus = "\(provider.title) setup failed: \(error.localizedDescription)" + localSummaryModelPreparationStatus = SettingsActionFailureCopy.localSummary(providerTitle: provider.title) + localSummaryModelPreparationStatusDetails = error.localizedDescription trackBetaModelPrepAbandoned(reason: .failed, stage: "prepare_model") recordLocalSummaryEvent( level: .error, @@ -5003,6 +5036,7 @@ struct TranscriptedSettingsView: View { guard !modelCacheCleanupInProgress else { return } modelCacheCleanupInProgress = true modelCacheCleanupStatus = nil + modelCacheCleanupStatusDetails = nil Task.detached(priority: .utility) { do { @@ -5021,7 +5055,8 @@ struct TranscriptedSettingsView: View { } catch { await MainActor.run { modelCacheCleanupInProgress = false - modelCacheCleanupStatus = "Could not remove stale models: \(error.localizedDescription)" + modelCacheCleanupStatus = SettingsActionFailureCopy.modelCacheRemoval + modelCacheCleanupStatusDetails = error.localizedDescription } } } @@ -5032,6 +5067,7 @@ struct TranscriptedSettingsView: View { let includeWhisper = !effectiveTranscriptionModel.isWhisper modelCacheCleanupInProgress = true modelCacheCleanupStatus = nil + modelCacheCleanupStatusDetails = nil Task.detached(priority: .utility) { do { @@ -5050,7 +5086,8 @@ struct TranscriptedSettingsView: View { } catch { await MainActor.run { modelCacheCleanupInProgress = false - modelCacheCleanupStatus = "Could not remove reclaimable cache: \(error.localizedDescription)" + modelCacheCleanupStatus = SettingsActionFailureCopy.modelCacheRemoval + modelCacheCleanupStatusDetails = error.localizedDescription } } } @@ -5060,6 +5097,7 @@ struct TranscriptedSettingsView: View { guard !modelCacheCleanupInProgress, !effectiveTranscriptionModel.isWhisper else { return } modelCacheCleanupInProgress = true modelCacheCleanupStatus = nil + modelCacheCleanupStatusDetails = nil Task.detached(priority: .utility) { do { @@ -5078,7 +5116,8 @@ struct TranscriptedSettingsView: View { } catch { await MainActor.run { modelCacheCleanupInProgress = false - modelCacheCleanupStatus = "Could not remove Whisper cache: \(error.localizedDescription)" + modelCacheCleanupStatus = SettingsActionFailureCopy.modelCacheRemoval + modelCacheCleanupStatusDetails = error.localizedDescription } } } @@ -5175,7 +5214,8 @@ struct TranscriptedSettingsView: View { refreshLaunchAtLoginState() } catch { launchAtLoginEnabled = previousValue - launchAtLoginStatus = "Could not update launch at login: \(error.localizedDescription)" + // Tooltip copy only; the raw error is captured to telemetry below. + launchAtLoginStatus = SettingsActionFailureCopy.launchAtLogin EventReporter.shared.capture( level: .warning, engine: "app", @@ -5392,6 +5432,7 @@ struct TranscriptedSettingsView: View { guard !captureLibraryMigrationInProgress else { return } captureLibraryMigrationInProgress = true captureLibraryMigrationStatus = "Copying captures..." + captureLibraryMigrationStatusDetails = nil Task.detached(priority: .utility) { let planner = CaptureLibraryMigrationPlanner() @@ -5410,8 +5451,10 @@ struct TranscriptedSettingsView: View { } catch { await MainActor.run { captureLibraryMigrationInProgress = false - captureLibraryMigrationStatus = "Copy stopped: \(error.localizedDescription) " - + "Your captures are still in \(choice.currentLibrary.path) and the library was not switched." + captureLibraryMigrationStatus = SettingsActionFailureCopy.captureLibraryMigration( + currentLibraryPath: choice.currentLibrary.path + ) + captureLibraryMigrationStatusDetails = error.localizedDescription } } } diff --git a/Tests/UIAutomationSurfaceContractTests.swift b/Tests/UIAutomationSurfaceContractTests.swift index a7174279..e219ba79 100644 --- a/Tests/UIAutomationSurfaceContractTests.swift +++ b/Tests/UIAutomationSurfaceContractTests.swift @@ -840,6 +840,136 @@ func testUIAutomationSurfaceContract() { "QA bench should keep a callable ui mode with local JSON evidence" ) } + + // WS4 "Premium minimalism" polish guards. These pin the visual/UX polish so + // it can't silently regress: teaching empty states (not gray filler), + // plain-words error states routed through shared copy (no raw + // `error.localizedDescription` on a user-facing surface), and design tokens. + runSuite("UI automation surface contract - WS4 empty states teach") { + let speakers = contractSource("Sources/UI/Settings/SpeakerPeopleSettingsSection.swift") + let settings = contractSource("Sources/UI/Settings/TranscriptedSettingsView.swift") + + assertTrue( + speakers.contains("enum SpeakerPeopleEmptyState") + && speakers.contains("static let title = \"No speakers yet\"") + && speakers.contains("static let actionTitle = \"Start a meeting\"") + && speakers.contains("transcripted.speakers.empty.start-meeting") + && speakers.contains("struct SpeakersEmptyStateView") + && speakers.contains("Transcripted learns each voice as you record."), + "the Speakers surface should teach an empty first-run state, not render bare gray placeholder text" + ) + assertFalse( + speakers.contains("No speakers yet. After your next meeting, the people in it will appear here."), + "the old bare-caption Speakers empty message should be gone, replaced by the teaching empty state" + ) + assertTrue( + settings.contains("onStartMeeting: { actions.startMeeting() }"), + "the Speakers empty state should be wired to a real next action from the Settings host" + ) + } + + runSuite("UI automation surface contract - WS4 error states act, not dump") { + let agent = contractSource("Sources/UI/Settings/AgentConnectionSettingsPage.swift") + let onboarding = contractSource("Sources/UI/Settings/PermissionsOnboardingView.swift") + let settings = contractSource("Sources/UI/Settings/TranscriptedSettingsView.swift") + let agentCopy = contractSource("Sources/UI/Settings/AgentSetupFailureCopy.swift") + let settingsCopy = contractSource("Sources/UI/Settings/SettingsActionFailureCopy.swift") + + // Shared plain-words primitives exist and are the single source of copy. + assertTrue( + agentCopy.contains("enum AgentSetupFailureCopy") + && agentCopy.contains("static let detailsTitle = \"Copy Details\"") + && agentCopy.contains("Transcripted couldn't connect \\(agentName)"), + "AgentSetupFailureCopy should hold plain-words connect/setup failure copy behind a Copy Details reveal" + ) + assertTrue( + settingsCopy.contains("enum SettingsActionFailureCopy") + && settingsCopy.contains("static let modelCacheRemoval") + && settingsCopy.contains("static func captureLibraryMigration("), + "SettingsActionFailureCopy should hold plain-words settings-action failure copy" + ) + + // Agent page: routes through the shared copy + a Copy Details reveal, and + // no longer dumps a raw NSError into a user-visible label. + assertTrue( + agent.contains("AgentSetupFailureCopy.connect(agentName:") + && agent.contains("AgentSetupFailureCopy.codexInbox") + && agent.contains("AgentSetupFailureCopy.liveMeetings") + && agent.contains("AgentSetupFailureCopy.liveView") + && agent.contains("private func failureNotice(") + && agent.contains("transcripted.settings.agent.connect-error-details."), + "the Agent page should surface plain-words failures with a Copy Details reveal, not raw error text" + ) + assertFalse( + agent.contains(".failed(error.localizedDescription)") + || agent.contains("Could not set up Codex Inbox: \\(error") + || agent.contains("Could not set up Live Meetings: \\(error") + || agent.contains("Could not prepare Live Meetings: \\(error") + || agent.contains("Could not open Live View: \\(error"), + "the Agent page must not interpolate a raw error into a user-facing setup message" + ) + + // Onboarding: first-run connect failure is plain words, not a raw NSError. + assertTrue( + onboarding.contains("AgentSetupFailureCopy.connect(agentName: \"Claude Desktop\")"), + "onboarding's Claude Desktop connect failure should read as plain words" + ) + assertFalse( + onboarding.contains("claudeDesktopConnectPhase = .failed(error.localizedDescription)"), + "onboarding must not put a raw NSError on the first-run connect card" + ) + + // Settings statuses: plain words + Copy Details reveal, no raw dumps. + assertTrue( + settings.contains("SettingsActionFailureCopy.betaLiveSidecar") + && settings.contains("SettingsActionFailureCopy.localSummary(") + && settings.contains("SettingsActionFailureCopy.modelCacheRemoval") + && settings.contains("SettingsActionFailureCopy.launchAtLogin") + && settings.contains("SettingsActionFailureCopy.captureLibraryMigration(") + && settings.contains("private func settingsFailureDetailsButton("), + "Settings action failures should route through SettingsActionFailureCopy with a Copy Details reveal" + ) + assertFalse( + settings.contains("Could not prepare live meeting sidecar: \\(error") + || settings.contains("setup failed: \\(error.localizedDescription)") + || settings.contains("Could not remove stale models: \\(error") + || settings.contains("Could not update launch at login: \\(error") + || settings.contains("Copy stopped: \\(error"), + "Settings status lines must not interpolate a raw error into user-facing text" + ) + } + + runSuite("UI automation surface contract - WS4 design tokens are the single source") { + let tokens = contractSource("Sources/UI/MenuBar/MenuTokens.swift") + let actionRow = contractSource("Sources/UI/MenuBar/MenuBarActionRowView.swift") + let header = contractSource("Sources/UI/MenuBar/MenuBarHeaderView.swift") + let doc = contractSource("docs/DESIGN_TOKENS.md") + + assertTrue( + tokens.contains("enum Font") + && tokens.contains("static let rowTitlePrimary") + && tokens.contains("static let headerTitle"), + "MenuTokens should own the menubar's type roles so views never reach for a raw NSFont literal" + ) + assertTrue( + actionRow.contains("MenuTokens.Font.rowTitlePrimary") + && actionRow.contains("MenuTokens.Font.rowTitleUtility") + && header.contains("MenuTokens.Font.headerTitle"), + "menubar rows and header should read their fonts from MenuTokens.Font" + ) + assertFalse( + actionRow.contains("NSFont.systemFont(ofSize: 12.5") + || header.contains("NSFont.systemFont(ofSize: 15.5"), + "menubar labels should not re-inline raw NSFont sizes now that MenuTokens.Font owns them" + ) + assertTrue( + doc.contains("Type scale") + && doc.contains("Spacing grid") + && doc.contains("Corner radii") + && doc.contains("MenuTokens.Font"), + "docs/DESIGN_TOKENS.md should exist as the single source documenting the type, spacing, and radius scales" + ) + } } private func sourceBlock(named startMarker: String, endingBefore endMarker: String, in source: String) -> String { diff --git a/docs/DESIGN_TOKENS.md b/docs/DESIGN_TOKENS.md new file mode 100644 index 00000000..b234e463 --- /dev/null +++ b/docs/DESIGN_TOKENS.md @@ -0,0 +1,87 @@ +# Design tokens + +The single source of truth for Transcripted's type, spacing, and corner-radius +scales. Every new view should draw from these steps instead of picking a raw +size. The values here are not aspirational — they are grounded in the sizes the +app already uses most, so adopting them is a snap-to-grid, not a redesign. + +Scope note: this is the visual language (type, spacing, radii). Color tokens +already live per surface in `MenuTokens` (menubar popover, light + dark) and +`OverlayTokens` (floating overlay). Those stay where they are; this document +governs the cross-surface geometry that had drifted into ~21 ad-hoc font sizes, +12 corner radii, and ~26 padding values across the two main windows. + +## Type scale — SF Pro, 6 steps + +SF Pro is the system face, so `Font.system(...)` / `NSFont.systemFont(...)` is +"SF Pro throughout" with no bundled font. Six steps cover every real need. +Weights standardize to **regular / medium / semibold**; `.light` is reserved for +the one display use (the Home greeting). A monospaced variant is used only for +numeric readouts (timers, counts) that must not jitter. + +| Token | Size / weight | Use | Grounded in | +|-------|---------------|-----|-------------| +| `caption2` | 10, regular (semibold for labels) | dense metadata, status dots' text | `.caption2`, `size:10`, NSFont 10 | +| `caption` | 11, regular (semibold for labels) | secondary detail lines, row subtitles | `.caption` (72 sites), `size:11` | +| `body` | 13, regular (medium for emphasis) | primary row text, body copy | `size:13` (11 sites), `.subheadline` | +| `emphasis` | 14, semibold | selected/active row titles, small headers | `size:14,.semibold` — the single most common numeric spec (13 sites) | +| `title` | 16, semibold | section titles, card headers | `size:15/16,.semibold`, NSFont 15.5 | +| `display` | 22, semibold (28, light for the Home greeting) | page-level hero text | `size:22,.semibold`; greeting 28 light | +| `mono` | caption2 / caption size, monospaced | elapsed timers, counts | existing monospaced 10–11 readouts | + +Rounding rule when adopting: snap fractional and near-miss sizes to the nearest +step (10.5 → `caption2`, 11.5/12/12.5 → `body`, 15/15.5 → `title`). The only +intentional exception in the codebase is a single `Georgia` serif on Home; leave +deliberate one-offs that carry meaning, and comment them. + +## Spacing grid — 4pt based, 7 steps + +A strict 8pt grid would fight the two most-used values in the app (10 and 14), +so the grid is 4pt-based with 10 and 14 kept as first-class steps. + +| Token | Value | Grounded in (spacing + padding call counts) | +|-------|-------|---------------------------------------------| +| `xxs` | 2 | fine nudges | +| `xs` | 4 | 26 spacing / 11 padding | +| `sm` | 6 | 20 spacing / 27 padding | +| `md` | 8 | 63 spacing / 17 padding — the default gap | +| `ml` | 10 | 35 spacing / 34 padding — kept as a step, too common to round away | +| `lg` | 12 | 49 spacing / 17 padding | +| `xl` | 16 | 8 spacing / 13 padding | +| `xxl` | 24 | page-section separation | +| `gutter` | 14 | kept as a step (21 spacing / 18 padding); matches `SettingsContentLayoutPolicy.topPadding` | + +## Corner radii — 4 steps + +Twelve distinct radii collapse to four. The two dominant values (8, 12) are kept +exactly so the existing token files stay source-compatible. + +| Token | Value | Grounded in | Existing token | +|-------|-------|-------------|----------------| +| `sm` | 6 | small chips, inline controls | — | +| `md` | 8 | rows, cards, icon wells (42 sites) | `MenuTokens.cardCornerRadius` | +| `lg` | 12 | panels, sheets (25 sites) | `OverlayTokens.cornerRadius` | +| `xl` | 16 | large containers | — | + +Snap rule: 7 → `md`; 10 → `md` or `lg` by container size; 14/18 → `xl`. + +## Where the tokens live in code + +- **Menubar (AppKit):** `Sources/UI/MenuBar/MenuTokens.swift` owns the menubar's + colors and layout, and now its **type scale** (`MenuTokens.Font`). The menubar + action rows and header read their fonts from there instead of raw + `NSFont.systemFont(ofSize:)`. This is the reference adoption — the pattern to + follow when the SwiftUI surfaces (Home, Settings) migrate onto tokens. +- **Overlay (AppKit):** `Sources/UI/Overlay/OverlayTokens.swift` — colors + layout. +- **SwiftUI surfaces (Home, Settings):** still hold ad-hoc sizes. Migrate them + onto these steps incrementally, one view per PR, snapping to the nearest step. + Do not convert every view at once — correctness over a big-bang refactor. + +## Adoption checklist for a view + +1. Replace each `.font(.system(size:weight:))` with the nearest type step. +2. Replace numeric `spacing:` / `.padding(n)` with the nearest spacing step. +3. Replace each `cornerRadius:` with the nearest of the four radii. +4. Keep any deliberate exception, and add a one-line comment saying why. +5. If a layout-policy test pins a constant (e.g. + `SettingsContentLayoutPolicy.topPadding == 14`), keep the number equal.