diff --git a/native/ios/PsycheApp/Sources/PsycheApp/CockpitView.swift b/native/ios/PsycheApp/Sources/PsycheApp/CockpitView.swift index 554b38837..d07eb2b14 100644 --- a/native/ios/PsycheApp/Sources/PsycheApp/CockpitView.swift +++ b/native/ios/PsycheApp/Sources/PsycheApp/CockpitView.swift @@ -31,6 +31,7 @@ enum RootTab: String, Hashable, CaseIterable { /// throw away where you were. struct CockpitView: View { @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @Environment(\.accessibilityReduceMotion) private var reduceMotion @EnvironmentObject private var remoteActionStore: RemoteActionStore @EnvironmentObject private var store: WorkspaceStore @@ -52,6 +53,12 @@ struct CockpitView: View { .sheet(isPresented: remoteActionSheetBinding) { ActionSheetView(store: remoteActionStore) } + .transaction { transaction in + if reduceMotion || ProcessInfo.processInfo.arguments.contains("-uiReduceMotion") { + transaction.disablesAnimations = true + transaction.animation = nil + } + } .accessibilityIdentifier("main-cockpit") } diff --git a/native/ios/PsycheApp/Sources/PsycheApp/Design/PsycheTheme.swift b/native/ios/PsycheApp/Sources/PsycheApp/Design/PsycheTheme.swift index f9be97ece..e61996d23 100644 --- a/native/ios/PsycheApp/Sources/PsycheApp/Design/PsycheTheme.swift +++ b/native/ios/PsycheApp/Sources/PsycheApp/Design/PsycheTheme.swift @@ -50,10 +50,18 @@ enum PaneIndicator: Equatable { case .quiet: .gray } } + + var text: String { + switch self { + case .needsAttention: "Needs you" + case .running: "Running" + case .quiet: "Idle" + } + } } -/// The dot beside a pane. Hidden from VoiceOver on purpose: the row's combined -/// label already states the status, so exposing it again would say it twice. +/// The dot beside a pane. It carries its own status text so the indicator is +/// never only colour, even when it is reused outside a combined row. struct WorkspaceStatusDot: View { let status: String let needsAttention: Bool @@ -66,6 +74,7 @@ struct WorkspaceStatusDot: View { Circle() .fill(indicator.color) .frame(width: 9, height: 9) - .accessibilityHidden(true) + .accessibilityLabel("Status") + .accessibilityValue(indicator.text) } } diff --git a/native/ios/PsycheApp/Sources/PsycheApp/PsycheApp.swift b/native/ios/PsycheApp/Sources/PsycheApp/PsycheApp.swift index e30c3083a..561ed441d 100644 --- a/native/ios/PsycheApp/Sources/PsycheApp/PsycheApp.swift +++ b/native/ios/PsycheApp/Sources/PsycheApp/PsycheApp.swift @@ -17,15 +17,42 @@ struct PsycheApp: App { var body: some Scene { WindowGroup { - CockpitView() - .environmentObject(model) - .environmentObject(model.workspaceStore) - .environmentObject(model.remoteActionStore) - .environmentObject(model.terminalRegistry) - .preferredColorScheme(.dark) - .task { - await model.start() - } + configuredCockpit + } + } + + @ViewBuilder + private var configuredCockpit: some View { + let content = CockpitView() + .environmentObject(model) + .environmentObject(model.workspaceStore) + .environmentObject(model.remoteActionStore) + .environmentObject(model.terminalRegistry) + .preferredColorScheme(.dark) + .task { + await model.start() + } + + if let dynamicTypeSize = Self.dynamicTypeSize(in: ProcessInfo.processInfo.arguments) { + content.environment(\.dynamicTypeSize, dynamicTypeSize) + } else { + content + } + } + + private static func dynamicTypeSize(in arguments: [String]) -> DynamicTypeSize? { + guard let index = arguments.firstIndex(of: "-uiDynamicTypeSize"), + arguments.indices.contains(index + 1) + else { + return nil + } + switch arguments[index + 1] { + case "accessibility1": return .accessibility1 + case "accessibility2": return .accessibility2 + case "accessibility3": return .accessibility3 + case "accessibility4": return .accessibility4 + case "accessibility5": return .accessibility5 + default: return nil } } } diff --git a/native/ios/PsycheApp/Sources/PsycheApp/Views/NowView.swift b/native/ios/PsycheApp/Sources/PsycheApp/Views/NowView.swift index 69f698440..196b56d5c 100644 --- a/native/ios/PsycheApp/Sources/PsycheApp/Views/NowView.swift +++ b/native/ios/PsycheApp/Sources/PsycheApp/Views/NowView.swift @@ -67,10 +67,23 @@ struct NowPaneRow: View { Text(item.title) .font(.body.weight(.semibold)) .lineLimit(2) - Text(contextLine) - .font(.subheadline) - .foregroundStyle(.secondary) - .lineLimit(1) + ViewThatFits(in: .horizontal) { + Text(contextLine) + VStack(alignment: .leading, spacing: 2) { + Text(item.projectTitle) + if let agent = item.agent { + Text(agent) + } else { + Text(item.status) + } + if let hostName, !hostName.isEmpty { + Text("Host \(hostName)") + } + } + } + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(2) if let lastActivity = item.lastActivity { Text(lastActivity, style: .relative) .font(.caption) @@ -90,7 +103,8 @@ struct NowPaneRow: View { PaneAccessibility.contextLine( projectTitle: item.projectTitle, agent: item.agent, - status: item.status + status: item.status, + hostName: hostName ) } diff --git a/native/ios/PsycheApp/Sources/PsycheApp/Views/PaneAccessibility.swift b/native/ios/PsycheApp/Sources/PsycheApp/Views/PaneAccessibility.swift index e8d61ca49..c6fbaeac3 100644 --- a/native/ios/PsycheApp/Sources/PsycheApp/Views/PaneAccessibility.swift +++ b/native/ios/PsycheApp/Sources/PsycheApp/Views/PaneAccessibility.swift @@ -14,6 +14,18 @@ enum PaneAccessibility { .joined(separator: " · ") } + static func contextLine( + projectTitle: String, + agent: String?, + status: String, + hostName: String? + ) -> String { + [projectTitle, agent ?? status, hostName.map { "host \($0)" }] + .compactMap { $0 } + .filter { !$0.isEmpty } + .joined(separator: " · ") + } + static func label( title: String, projectTitle: String, @@ -63,11 +75,13 @@ enum PaneAccessibility { static func projectSubtitle( branch: String?, + hostName: String?, runningCount: Int, attentionCount: Int ) -> String { var parts: [String] = [] if let branch, !branch.isEmpty { parts.append(branch) } + if let hostName, !hostName.isEmpty { parts.append("host \(hostName)") } parts.append("\(runningCount) running") if attentionCount > 0 { parts.append("\(attentionCount) needs you") @@ -78,11 +92,13 @@ enum PaneAccessibility { static func projectLabel( title: String, branch: String?, + hostName: String?, runningCount: Int, attentionCount: Int ) -> String { var parts = [title] if let branch, !branch.isEmpty { parts.append("branch \(branch)") } + if let hostName, !hostName.isEmpty { parts.append("host \(hostName)") } parts.append("\(runningCount) running") parts.append("\(attentionCount) needing attention") return parts.joined(separator: ", ") diff --git a/native/ios/PsycheApp/Sources/PsycheApp/Views/PaneControls.swift b/native/ios/PsycheApp/Sources/PsycheApp/Views/PaneControls.swift index 8857e1350..0071eb9a0 100644 --- a/native/ios/PsycheApp/Sources/PsycheApp/Views/PaneControls.swift +++ b/native/ios/PsycheApp/Sources/PsycheApp/Views/PaneControls.swift @@ -40,6 +40,7 @@ struct PaneControlsMenu: View { } label: { Label(PaneControlsMenuAction.files.label, systemImage: PaneControlsMenuAction.files.systemImage) } + .accessibilityIdentifier("pane-action-files") .disabled(PaneControlsPresentation.filesDisabled( hasInspectableWorktree: hasInspectableWorktree, isStale: store.isStale, @@ -53,8 +54,9 @@ struct PaneControlsMenu: View { Button(role: .destructive) { isConfirmingStop = true } label: { - Label(PaneControlsMenuAction.stop.label, systemImage: PaneControlsMenuAction.stop.systemImage) + Label(PaneControlsMenuAction.stop.consequenceLabel, systemImage: PaneControlsMenuAction.stop.systemImage) } + .accessibilityIdentifier("pane-action-stop") .disabled(PaneControlsPresentation.hostActionDisabled( isStale: store.isStale, isBusy: localActionBusy @@ -63,8 +65,9 @@ struct PaneControlsMenu: View { Button(role: .destructive) { isConfirmingCleanup = true } label: { - Label(PaneControlsMenuAction.cleanup.label, systemImage: PaneControlsMenuAction.cleanup.systemImage) + Label(PaneControlsMenuAction.cleanup.consequenceLabel, systemImage: PaneControlsMenuAction.cleanup.systemImage) } + .accessibilityIdentifier("pane-action-cleanup") .disabled(PaneControlsPresentation.hostActionDisabled( isStale: store.isStale, isBusy: remoteActionBusy @@ -190,6 +193,7 @@ struct PaneControlsMenu: View { } label: { Label(ActionSheetPresentation.actionLabel(for: action), systemImage: systemImage) } + .accessibilityIdentifier("pane-action-\(action.rawValue)") .disabled(PaneControlsPresentation.hostActionDisabled( isStale: store.isStale, isBusy: remoteActionBusy @@ -260,6 +264,17 @@ enum PaneControlsMenuAction: CaseIterable, Equatable { } } + var consequenceLabel: String { + switch self { + case .stop: + "Stop pane and keep work" + case .cleanup: + "Close pane and choose cleanup" + default: + label + } + } + var systemImage: String { switch self { case .merge: diff --git a/native/ios/PsycheApp/Sources/PsycheApp/Views/PaneSwitcher.swift b/native/ios/PsycheApp/Sources/PsycheApp/Views/PaneSwitcher.swift index b6eda44ca..861e72e87 100644 --- a/native/ios/PsycheApp/Sources/PsycheApp/Views/PaneSwitcher.swift +++ b/native/ios/PsycheApp/Sources/PsycheApp/Views/PaneSwitcher.swift @@ -8,6 +8,7 @@ import SwiftUI /// would attach a stream for something nobody is looking at, which is exactly /// what the two-session cap exists to prevent. struct PaneSwitcher: View { + @Environment(\.dynamicTypeSize) private var dynamicTypeSize let panes: [PaneChoice] let primaryPaneID: String? let secondaryPaneID: String? @@ -76,9 +77,13 @@ struct PaneSwitcher: View { } label: { HStack(spacing: 6) { WorkspaceStatusDot(status: pane.status, needsAttention: pane.needsAttention) - Text(pane.title) - .font(.caption.weight(.semibold)) - .lineLimit(1) + VStack(alignment: .leading, spacing: 1) { + Text(pane.title) + Text(pane.status) + .foregroundStyle(.secondary) + } + .font(.caption.weight(.semibold)) + .lineLimit(dynamicTypeSize.isAccessibilitySize ? 2 : 1) } .padding(.horizontal, 12) .frame(minHeight: PsycheTheme.minimumTapTarget) diff --git a/native/ios/PsycheApp/Sources/PsycheApp/Views/ProjectsView.swift b/native/ios/PsycheApp/Sources/PsycheApp/Views/ProjectsView.swift index 41963c88b..867c38e53 100644 --- a/native/ios/PsycheApp/Sources/PsycheApp/Views/ProjectsView.swift +++ b/native/ios/PsycheApp/Sources/PsycheApp/Views/ProjectsView.swift @@ -5,6 +5,7 @@ import SwiftUI /// you need to look: how much is running, and how much is waiting on you. struct ProjectsView: View { @EnvironmentObject private var store: WorkspaceStore + @EnvironmentObject private var model: AppModel private var projects: [WorkspaceProjectSnapshot] { store.workspace?.projects ?? [] @@ -17,6 +18,7 @@ struct ProjectsView: View { ProjectDetailView(projectID: project.id) } label: { ProjectRow(project: project) + .environmentObject(model) } .accessibilityIdentifier("project-\(project.id)") } @@ -38,6 +40,7 @@ struct ProjectsView: View { } struct ProjectRow: View { + @EnvironmentObject private var model: AppModel let project: WorkspaceProjectSnapshot private var branch: String? { @@ -48,10 +51,24 @@ struct ProjectRow: View { VStack(alignment: .leading, spacing: 4) { Text(project.title) .font(.body.weight(.semibold)) - Text(subtitle) - .font(.subheadline) - .foregroundStyle(.secondary) - .lineLimit(1) + ViewThatFits(in: .horizontal) { + Text(subtitle) + VStack(alignment: .leading, spacing: 2) { + if let branch, !branch.isEmpty { + Text(branch) + } + if let hostName = model.hostName, !hostName.isEmpty { + Text("Host \(hostName)") + } + Text("\(project.runningCount) running") + if project.attentionCount > 0 { + Text("\(project.attentionCount) needs you") + } + } + } + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(2) } .padding(.vertical, 4) .frame(minHeight: PsycheTheme.minimumTapTarget, alignment: .leading) @@ -62,6 +79,7 @@ struct ProjectRow: View { private var subtitle: String { PaneAccessibility.projectSubtitle( branch: branch, + hostName: model.hostName, runningCount: project.runningCount, attentionCount: project.attentionCount ) @@ -71,6 +89,7 @@ struct ProjectRow: View { PaneAccessibility.projectLabel( title: project.title, branch: branch, + hostName: model.hostName, runningCount: project.runningCount, attentionCount: project.attentionCount ) @@ -192,10 +211,22 @@ struct WorkspacePaneRow: View { Text(pane.title ?? pane.id) .font(.body.weight(.semibold)) .lineLimit(2) - Text([pane.agent, pane.status].compactMap { $0 }.joined(separator: " · ")) - .font(.subheadline) - .foregroundStyle(.secondary) - .lineLimit(1) + ViewThatFits(in: .horizontal) { + Text(contextLine) + VStack(alignment: .leading, spacing: 2) { + Text(projectTitle) + if let agent = pane.agent { + Text(agent) + } + Text(pane.status) + if let hostName, !hostName.isEmpty { + Text("Host \(hostName)") + } + } + } + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(2) } } .padding(.vertical, 6) @@ -208,4 +239,13 @@ struct WorkspacePaneRow: View { private var accessibilityLabel: String { PaneAccessibility.label(for: pane, projectTitle: projectTitle, hostName: hostName) } + + private var contextLine: String { + PaneAccessibility.contextLine( + projectTitle: projectTitle, + agent: pane.agent, + status: pane.status, + hostName: hostName + ) + } } diff --git a/native/ios/PsycheApp/Tests/PsycheAppUITests/PsycheAppUITests.swift b/native/ios/PsycheApp/Tests/PsycheAppUITests/PsycheAppUITests.swift index dec65e466..a14de79b8 100644 --- a/native/ios/PsycheApp/Tests/PsycheAppUITests/PsycheAppUITests.swift +++ b/native/ios/PsycheApp/Tests/PsycheAppUITests/PsycheAppUITests.swift @@ -115,6 +115,36 @@ final class PsycheAppUITests: XCTestCase { XCTAssertTrue(label.contains("needs you"), label) } + func testAccessibleTextSizeAndReduceMotionCompleteNowPaneActionSheetPath() throws { + let app = launchApp(arguments: [ + "-uiFixture", "multiproject", + "-uiDynamicTypeSize", "accessibility3", + "-uiReduceMotion", + ]) + + let paneRow = element("now-pane-web-home", in: app) + XCTAssertTrue(paneRow.waitForExistence(timeout: 30)) + XCTAssertTrue(paneRow.label.contains("open-coven.dev"), paneRow.label) + XCTAssertTrue(paneRow.label.contains("psyche-demo.local"), paneRow.label) + paneRow.tap() + + XCTAssertTrue(element("pane-workspace-web-home", in: app).waitForExistence(timeout: 10)) + let focusedPane = element("terminal-pane-web-home", in: app) + XCTAssertTrue(focusedPane.waitForExistence(timeout: 10)) + XCTAssertTrue(focusedPane.isSelected) + + openPaneActions(in: app) + let rename = element("pane-action-rename", in: app) + XCTAssertTrue(rename.waitForExistence(timeout: 10)) + rename.tap() + + let sheet = element("remote-action-sheet", in: app) + XCTAssertTrue(sheet.waitForExistence(timeout: 10)) + let input = element("remote-action-input", in: app) + reveal(input, in: sheet) + XCTAssertTrue(input.waitForExistence(timeout: 10)) + } + // MARK: - Terminal workspace /// Opening a pane has to show its terminal, not an empty frame. The @@ -441,8 +471,8 @@ final class PsycheAppUITests: XCTestCase { XCTAssertTrue(app.buttons["Rename"].waitForExistence(timeout: 10)) XCTAssertTrue(app.buttons["Browse Files"].waitForExistence(timeout: 10)) XCTAssertTrue(app.buttons["Rituals"].waitForExistence(timeout: 10)) - XCTAssertTrue(app.buttons["Stop"].waitForExistence(timeout: 10)) - XCTAssertTrue(app.buttons["Close and Cleanup"].waitForExistence(timeout: 10)) + XCTAssertTrue(app.buttons["Stop pane and keep work"].waitForExistence(timeout: 10)) + XCTAssertTrue(app.buttons["Close pane and choose cleanup"].waitForExistence(timeout: 10)) app.buttons["Rituals"].tap() let unavailable = app.buttons["Ritual execution is not available on mobile yet"] @@ -476,7 +506,7 @@ final class PsycheAppUITests: XCTestCase { openWebHomePane(in: app) openPaneActions(in: app) - app.buttons["Stop"].tap() + element("pane-action-stop", in: app).tap() XCTAssertTrue(app.staticTexts["Stop homepage polish?"].waitForExistence(timeout: 10)) let message = app.staticTexts.containing( @@ -499,7 +529,7 @@ final class PsycheAppUITests: XCTestCase { openWebHomePane(in: app) openPaneActions(in: app) - app.buttons["Stop"].tap() + element("pane-action-stop", in: app).tap() XCTAssertTrue(app.staticTexts["Stop homepage polish?"].waitForExistence(timeout: 10)) let dialog = app.sheets.firstMatch.exists ? app.sheets.firstMatch : app.alerts.firstMatch @@ -516,7 +546,7 @@ final class PsycheAppUITests: XCTestCase { openWebHomePane(in: app) openPaneActions(in: app) - app.buttons["Close and Cleanup"].tap() + element("pane-action-cleanup", in: app).tap() XCTAssertTrue( app.staticTexts["Close and clean up homepage polish?"].waitForExistence(timeout: 10) @@ -543,7 +573,7 @@ final class PsycheAppUITests: XCTestCase { openWebHomePane(in: app) openPaneActions(in: app) - app.buttons["Close and Cleanup"].tap() + element("pane-action-cleanup", in: app).tap() XCTAssertTrue( app.staticTexts["Close and clean up homepage polish?"].waitForExistence(timeout: 10) diff --git a/native/ios/PsycheApp/UnitTests/CreatePaneFormTests.swift b/native/ios/PsycheApp/UnitTests/CreatePaneFormTests.swift index 2517555f4..c39084571 100644 --- a/native/ios/PsycheApp/UnitTests/CreatePaneFormTests.swift +++ b/native/ios/PsycheApp/UnitTests/CreatePaneFormTests.swift @@ -218,6 +218,8 @@ final class CreatePaneFormTests: XCTestCase { func testStopAndCleanupMenuActionsStayDistinct() { XCTAssertNotEqual(PaneControlsMenuAction.stop.label, PaneControlsMenuAction.cleanup.label) + XCTAssertEqual(PaneControlsMenuAction.stop.consequenceLabel, "Stop pane and keep work") + XCTAssertEqual(PaneControlsMenuAction.cleanup.consequenceLabel, "Close pane and choose cleanup") XCTAssertNotEqual( PaneControlsMenuAction.stop.systemImage, PaneControlsMenuAction.cleanup.systemImage diff --git a/native/ios/PsycheApp/UnitTests/PaneAccessibilityTests.swift b/native/ios/PsycheApp/UnitTests/PaneAccessibilityTests.swift index 1b6f07978..24b4a242a 100644 --- a/native/ios/PsycheApp/UnitTests/PaneAccessibilityTests.swift +++ b/native/ios/PsycheApp/UnitTests/PaneAccessibilityTests.swift @@ -106,19 +106,53 @@ final class PaneAccessibilityTests: XCTestCase { ) } + func testContextLineCanIncludeHostForCombinedVisibleIdentity() { + XCTAssertEqual( + PaneAccessibility.contextLine( + projectTitle: "psyche-build", + agent: "Copilot", + status: "working", + hostName: "studio.local" + ), + "psyche-build · Copilot · host studio.local" + ) + } + // MARK: - Project rows func testProjectSubtitleHidesAZeroAttentionCount() { XCTAssertEqual( - PaneAccessibility.projectSubtitle(branch: "main", runningCount: 2, attentionCount: 0), + PaneAccessibility.projectSubtitle( + branch: "main", + hostName: nil, + runningCount: 2, + attentionCount: 0 + ), "main · 2 running" ) XCTAssertEqual( - PaneAccessibility.projectSubtitle(branch: "main", runningCount: 2, attentionCount: 1), + PaneAccessibility.projectSubtitle( + branch: "main", + hostName: nil, + runningCount: 2, + attentionCount: 1 + ), "main · 2 running · 1 needs you" ) } + func testProjectSubtitleCanIncludeHostForCombinedVisibleIdentity() { + XCTAssertEqual( + PaneAccessibility.projectSubtitle( + branch: "main", + hostName: "studio.local", + runningCount: 2, + attentionCount: 1 + ), + "main · host studio.local · 2 running · 1 needs you" + ) + } + /// The subtitle hides a zero, but the spoken label must not — "0 needing /// attention" is the answer to the question a VoiceOver user just asked. func testProjectLabelAlwaysStatesBothCounts() { @@ -126,6 +160,7 @@ final class PaneAccessibilityTests: XCTestCase { PaneAccessibility.projectLabel( title: "psyche-build", branch: "main", + hostName: nil, runningCount: 2, attentionCount: 0 ), @@ -138,6 +173,7 @@ final class PaneAccessibilityTests: XCTestCase { PaneAccessibility.projectLabel( title: "psyche-build", branch: nil, + hostName: nil, runningCount: 0, attentionCount: 0 ), @@ -145,6 +181,19 @@ final class PaneAccessibilityTests: XCTestCase { ) } + func testProjectLabelCanIncludeHost() { + XCTAssertEqual( + PaneAccessibility.projectLabel( + title: "psyche-build", + branch: "main", + hostName: "studio.local", + runningCount: 2, + attentionCount: 0 + ), + "psyche-build, branch main, host studio.local, 2 running, 0 needing attention" + ) + } + // MARK: - Worktree state func testWorktreeStatesNameEveryConditionThatChangesWhatYouCanDo() throws { diff --git a/native/ios/PsycheApp/UnitTests/PaneIndicatorTests.swift b/native/ios/PsycheApp/UnitTests/PaneIndicatorTests.swift index f7d1b16f7..b489fe349 100644 --- a/native/ios/PsycheApp/UnitTests/PaneIndicatorTests.swift +++ b/native/ios/PsycheApp/UnitTests/PaneIndicatorTests.swift @@ -54,6 +54,12 @@ final class PaneIndicatorTests: XCTestCase { XCTAssertEqual(PaneIndicator.forStatus("Waiting", needsAttention: false), .needsAttention) } + func testEveryIndicatorHasTextSemantics() { + XCTAssertEqual(PaneIndicator.needsAttention.text, "Needs you") + XCTAssertEqual(PaneIndicator.running.text, "Running") + XCTAssertEqual(PaneIndicator.quiet.text, "Idle") + } + /// The dot and the Now section have to agree: anything grouped under Needs /// You shows the attention colour, anything under Running shows the live /// colour, and everything else stays quiet.