transit PR #242 head → base fix/restore-width-adaptive-dashboard → main commits 2 files 19 touched lines +148 / -90

PR #242 — Restore width-adaptive dashboard layout

Independent review of exact head fe6c08cac0dd3e12b8329c8c9e3c8b8b0d95dd9d against exact base a467af4f55a18962b1147e07ad492ce74e508dca. PR #242 by @ArjenSchwarz · view on GitHub.

At a glance

  • Width-adaptive behavior restored: portrait phones render the Kanban board whenever two 200-point columns fit; only narrower widths use the segmented fallback.

  • UI-test stabilization is targeted: shared helpers handle search dismissal and toolbar overflow, while selectors account for SwiftUI accessibility-tree shapes.

  • Independent evidence is green: make test-quick, make lint, make build, direct make test-ui, direct make test, and git diff --check all passed at the reviewed head.

Verdict

Ready to push/merge

LGTM. The 200-point width rule is implemented in one pure selector and exercised at both sides of the 400-point portrait boundary, while preserving the landscape cap and fallback default. Requirements, design, decision record, user-facing docs, and project guidance agree. Direct full-suite and UI-suite runs completed successfully; the sole GitHub Action is successful, the PR is CLEAN, and there are zero review threads.

Author's PR description

Shown verbatim — the markdown the author wrote, unmodified.

@ArjenSchwarz · 2026-08-06
## Summary
- restore the multi-column Kanban board on portrait phones when two or more 200-point columns fit
- retain the segmented single-column fallback below 400 points
- correct requirement 13.1 and align design, implementation, and agent guidance
- supersede the T-1802 product decision and update focused unit/UI coverage

## Validation
- focused `DashboardLayoutTests`
- focused iPhone 17 portrait UI test
- `make test-quick`
- `make lint`
- `make build`
- `git diff --check`

Commits

Three-level explanation

What changed

Transit now uses the screen width—not simply whether a phone is upright—to choose its dashboard. A portrait phone wide enough for two readable columns shows the Kanban board. A narrower screen still gets the simple status selector.

Why it matters

This makes better use of wide iPhone portrait screens while keeping the compact fallback when the board would be too cramped.

Key concepts

The 200-point minimum is the ruler: below two columns the app shows one selected column; at two or more it shows a horizontally scrollable board.

Changes overview

DashboardLayoutLogic.layout removes the portrait size-class override and derives the visible column count from width / 200. The phone-landscape branch still caps at three columns and begins at Planning. A shared defaultSingleColumn keeps the fallback default explicit.

Test and documentation approach

Boundary tests prove 399 points selects one column and 400 points selects two. The iPhone 17 UI test confirms portrait orientation, a width of at least 400 points, no segmented control, and visible Idea/Planning headers. Documentation updates cover requirements, design, Decision 12, README, CHANGELOG, and dashboard guidance.

Trade-offs

Width is a stronger signal than horizontal size class for usable column count. The retained landscape cap preserves the established phone-landscape navigation affordance.

Technical deep dive

The layout helper is deterministic over width, verticalSizeClass, and isPhone. It computes max(1, Int(width / 200)), conditionally caps only compact-height phones at three, returns the segmented path only for one column, then caps all other Kanban results at the five visual columns. This avoids a portrait-only rule that conflicts with window geometry and keeps the SwiftUI view body a simple switch.

Architecture impact

No data, service, routing, status, or drag/drop semantics changed. The selector remains pure and independently testable. UI helper extraction consolidates platform-sensitive interactions without leaking them into production code.

Edge cases

The tests pin 399/400, wider portrait, narrow iPad split view, iPad/Mac adaptation, a nil vertical size class, and the three-column landscape cap. Full iPhone 17 UI coverage also validates that the new adaptive layout does not destabilize filters, detail presentation, settings overflow, search, or destructive alerts.

Important changes — detailed

Dashboard layout: restore geometry-driven portrait Kanban

Transit/Transit/Views/Dashboard/DashboardLayout.swift

Why it matters. This is the user-visible behavioral correction. It restores multi-column portrait Kanban at the 400-point threshold without changing the narrow fallback or established landscape behavior.

What to look at. DashboardLayoutLogic.layout, lines 8–29

Takeaway. Keep adaptive-layout selection in a pure value function so thresholds and platform-specific caps are inexpensive to verify.
Rationale. Decision 12 explicitly amends the product rule: available width varies on every platform and should drive readable column count.

Requirements and design: align the cross-platform width contract

specs/transit-v1/requirements.md

Why it matters. The implementation only remains durable if the requirement, design, decision log, README, CHANGELOG, and agent guidance specify the same portrait and fallback behavior.

What to look at. requirements 13.1–13.7; Decision 12; Platform Layout documentation

Takeaway. When a product decision reverses a recent bug fix, mark the old report superseded and update every normative description in the same change.
Rationale. The branch documents that PR #205 / T-1802’s portrait-only segmentation is superseded by the restored width-based contract.

Layout tests: pin the 399/400-point boundary and retained platform rules

Transit/TransitTests/DashboardLayoutTests.swift

Why it matters. The tests cover the exact regression boundary plus portrait, landscape, iPad split view, Mac, nil size-class, and fallback-default behavior.

What to look at. DashboardLayoutTests, lines 8–115

Takeaway. Boundary values and retained exception paths are more valuable than broad device-category assertions for a geometry selector.
Rationale. The 200-point minimum-column invariant is the documented rule, so 399/400 provides the decisive regression pair.

UI tests: centralize adaptive-toolbar interaction stabilization

Transit/TransitUITests/TransitUITestHelpers.swift

Why it matters. The restored wide board can cause active toolbar controls to overflow and changes iOS accessibility presentation; shared helpers make affected tests explicit and deterministic.

What to look at. XCUIApplication.dismissTransitSearch and tapTransitToolbarButton

Takeaway. Put platform interaction workarounds in narrowly scoped test helpers, while keeping behavior assertions in the test that owns the user flow.
Rationale. The commit message records search dismissal, toolbar overflow, combined labels, and nested alert actions as the sources of iPhone 17 UI-test instability.

Key decisions

Width over portrait size class for dashboard selection

Decision 12 now applies the 200-point minimum-column rule to iPhone portrait as well as iPad and Mac. The segmented picker is a one-column fallback, not the default portrait dashboard.

Preserve the phone-landscape three-column cap

Compact-height phones continue to cap the initial visible count at three and target Planning. This keeps the established landscape interaction model while portrait becomes geometry-driven.

Per-file diffs

Click to expand.

CHANGELOG.md Modified +4 / -1
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 1fc6ea7..d74cfde 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -6,6 +6,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).  ## [Unreleased] +- Restored the original width-adaptive dashboard layout: iPhone portrait keeps the multi-column Kanban board whenever at least two 200-point columns fit, and uses segmented single-column navigation only below that boundary. This supersedes T-1802 / PR #205 and corrects requirement 13.1.+- Dashboard UI tests now dismiss the iOS search presentation, navigate toolbar overflow actions, match combined detail-row accessibility labels, and disambiguate nested alert buttons so the full iPhone 17 suite covers the restored multi-column portrait layout reliably.+ - T-1862: Single-record `displayId` queries now reserve successful `[]` results for explicit task/milestone not-found outcomes. `QueryTasksIntent` and `QueryMilestonesIntent` surface other direct-lookup failures as source-specific `INTERNAL_ERROR` payloads, while MCP `query_milestones` returns the matching tool error; duplicate-ID error contracts remain unchanged. Deterministic injected-fetch regressions assert exact not-found, duplicate, and storage-failure behavior across the affected JSON and MCP paths. - T-1937: iCloud Sync preference changes now remain restart-scoped for the heartbeat as well as the live SwiftData container. An active launch keeps its MCP freshness heartbeat running while the preference is toggled off and back on; a CloudKit-free fallback launch cannot start one from a preference-on. Explicit MCP lifecycle shutdown remains the only runtime stop path, and MCP-start failure handling from T-1767 is unchanged. @@ -52,7 +55,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - MCP `create_task`, `create_milestone`, `CreateTaskIntent`, and `CreateMilestoneIntent` now distinguish missing required fields from present non-string `name`/`type` values (T-1598). Numeric, boolean, array, object, and null inputs return field-specific `name must be a string` / `type must be a string` validation errors before mutation, while missing-field and invalid-string-type behavior remains unchanged. Symmetric regression coverage verifies all four create paths and confirms malformed requests create no records. - `QueryTasksIntent` now rejects explicit JSON `null` in nested `completionDate` and `lastStatusChangeDate` fields (`relative`, `from`, and `to`) before Codable decoding can erase presence. Malformed nested date-filter shapes retain the established `INVALID_INPUT` contract, omitted fields remain unchanged, and regressions cover both filters and no-broadening behavior (T-1644). - `Color.hexString` now rounds resolved RGB components to the nearest byte after finite-checking and clamping instead of truncating values just below byte boundaries (T-1807). Project colors no longer darken when an editor saves an unchanged color. Regression coverage exercises every `0...255` RGB value, deterministic sRGB resolved components immediately below each boundary, and the existing uppercase six-digit RGB/no-alpha format.-- T-1802: Compact-width portrait dashboard layouts now always use the segmented single-column view, including widths at and above 400 points. Landscape, iPad, and Mac continue to use geometry-based column adaptation through a focused layout-selection helper and regression coverage.+- T-1802: Compact-width portrait dashboard layouts were changed to always use the segmented single-column view, including widths at and above 400 points. This behavior is superseded by the current Unreleased width-adaptive restoration. - MCP `update_task_status` now returns the exact comment created by its atomic status-plus-comment mutation (T-1823). `TaskService.updateStatus` propagates the created `Comment` to the handler for direct serialization instead of refetching every task comment and taking the last `creationDate`; a future-dated existing or concurrently imported comment can no longer replace the mutation result. If the atomic save fails, the newly inserted comment is deleted before the task state is rolled back so a later save cannot resurrect it. Regressions verify exact UUID propagation, future-clock-skew behavior, and service/MCP failure recovery. - Cross-device task and milestone promotion now re-probes committed SwiftData state after display-ID allocation and skips assignment when a peer has already supplied a permanent ID (T-2020). Missing or unreadable records fail closed, save-failure recovery preserves peer-merged values, and deterministic two-context tests pin both record types. Because SwiftData has no field-level compare-and-set, this closes the peer-merge-during-await window but cannot prevent two devices whose final local probes both precede either CloudKit merge from racing; the limitation and owner-reservation alternative are documented. 
CLAUDE.md Modified +3 / -3
diff --git a/CLAUDE.md b/CLAUDE.mdindex 8345e62..0cc44c8 100644--- a/CLAUDE.md+++ b/CLAUDE.md@@ -84,9 +84,9 @@ Idea | Planning | Spec | In Progress | Done/Abandoned  ### Platform Layout -- **iPhone portrait**: segmented control, one column at a time (default: In Progress / "Active")-- **iPhone landscape**: three columns visible, swipeable-- **iPad/Mac**: all five columns visible+- **iPhone portrait**: width-adaptive Kanban (two or more columns when they fit); segmented single-column fallback below 400 points (default: In Progress / "Active")+- **iPhone landscape**: up to three columns visible, swipeable+- **iPad/Mac**: width-adaptive Kanban, up to all five columns  ### Service Layer 
README.md Modified +1 / -1
diff --git a/README.md b/README.mdindex e209ee0..c3c299c 100644--- a/README.md+++ b/README.md@@ -18,7 +18,7 @@ Transit sits alongside the existing tool ecosystem: [Orbit](https://github.com/A - **CLI automation** through App Intents — create tasks, update statuses, manage milestones, add comments, generate reports - **MCP server** (macOS) — HTTP JSON-RPC server for AI agent integration with 10 tools - **Agent handoff statuses** (Ready for Implementation, Ready for Review) for AI/human workflow integration-- **Adaptive layout**: single column on iPhone portrait, multi-column on landscape/iPad/Mac+- **Adaptive layout**: width-based multi-column Kanban on iPhone/iPad/Mac, with a segmented single-column fallback only when one column fits - **Drag and drop** between columns to change task status - **Search and filter** — by project, type, milestone, status, and text (including display ID matching) - **Liquid Glass** design language throughout
Transit/Transit/Models/TaskStatus.swift Modified +1 / -1
diff --git a/Transit/Transit/Models/TaskStatus.swift b/Transit/Transit/Models/TaskStatus.swiftindex 49d0dd8..182cfdf 100644--- a/Transit/Transit/Models/TaskStatus.swift+++ b/Transit/Transit/Models/TaskStatus.swift@@ -43,7 +43,7 @@ enum TaskStatus: String, Codable, CaseIterable {         }     } -    /// Short labels for iPhone segmented control [req 13.2]+    /// Short labels for the narrow segmented-control fallback [req 13.2]     var shortLabel: String {         switch column {         case .idea: "Idea"
Transit/Transit/Views/Dashboard/DashboardLayout.swift Modified +1 / -8
diff --git a/Transit/Transit/Views/Dashboard/DashboardLayout.swift b/Transit/Transit/Views/Dashboard/DashboardLayout.swiftindex c5a9cdc..00aac2c 100644--- a/Transit/Transit/Views/Dashboard/DashboardLayout.swift+++ b/Transit/Transit/Views/Dashboard/DashboardLayout.swift@@ -7,21 +7,14 @@ enum DashboardLayoutMode: Equatable {  enum DashboardLayoutLogic {     static let columnMinWidth: CGFloat = 200+    static let defaultSingleColumn: DashboardColumn = .inProgress      static func layout(         width: CGFloat,-        horizontalSizeClass: UserInterfaceSizeClass?,         verticalSizeClass: UserInterfaceSizeClass?,         isPhone: Bool     ) -> DashboardLayoutMode {         let rawColumnCount = max(1, Int(width / columnMinWidth))-        let isPhonePortrait = isPhone-            && horizontalSizeClass == .compact-            && verticalSizeClass == .regular-        if isPhonePortrait {-            return .singleColumn-        }-         let isPhoneLandscape = isPhone && verticalSizeClass == .compact         let columnCount = isPhoneLandscape ? min(rawColumnCount, 3) : rawColumnCount 
Transit/Transit/Views/Dashboard/DashboardView.swift Modified +1 / -3
diff --git a/Transit/Transit/Views/Dashboard/DashboardView.swift b/Transit/Transit/Views/Dashboard/DashboardView.swiftindex 2458d9c..fa7cc90 100644--- a/Transit/Transit/Views/Dashboard/DashboardView.swift+++ b/Transit/Transit/Views/Dashboard/DashboardView.swift@@ -22,12 +22,11 @@ struct DashboardView: View {     @State private var selectedMilestones: Set<UUID> = []     @State private var selectedPriorities: Set<TaskPriority> = []     @State private var sortOrder: DashboardLogic.ColumnSortOrder = .recent-    @State private var selectedColumn: DashboardColumn = .inProgress // [req 13.3]+    @State private var selectedColumn: DashboardColumn = DashboardLayoutLogic.defaultSingleColumn // [req 13.3]     @State private var selectedTask: TransitTask?     @State private var showAddTask = false     @State private var searchText = ""     @Environment(TaskService.self) private var taskService-    @Environment(\.horizontalSizeClass) private var sizeClass     @Environment(\.verticalSizeClass) private var verticalSizeClass     @Environment(\.resolvedTheme) private var resolvedTheme     #if os(macOS)@@ -75,7 +74,6 @@ struct DashboardView: View {         GeometryReader { geometry in             let layout = DashboardLayoutLogic.layout(                 width: geometry.size.width,-                horizontalSizeClass: sizeClass,                 verticalSizeClass: verticalSizeClass,                 isPhone: dashboardIsPhoneDevice()             )
Transit/Transit/Views/Dashboard/SingleColumnView.swift Modified +1 / -1
diff --git a/Transit/Transit/Views/Dashboard/SingleColumnView.swift b/Transit/Transit/Views/Dashboard/SingleColumnView.swiftindex b804237..66cdbac 100644--- a/Transit/Transit/Views/Dashboard/SingleColumnView.swift+++ b/Transit/Transit/Views/Dashboard/SingleColumnView.swift@@ -8,7 +8,7 @@ struct SingleColumnView: View {      var body: some View {         VStack(spacing: 0) {-            // Segmented control with drop targets [req 13.1, 13.2]+            // Segmented control for the narrow single-column fallback [req 13.1, 13.2]             // ZStack overlays invisible drop zones on the native picker so             // users can drag a task onto a different tab to change its status.             ZStack {
Transit/TransitTests/DashboardLayoutTests.swift Modified +30 / -21
diff --git a/Transit/TransitTests/DashboardLayoutTests.swift b/Transit/TransitTests/DashboardLayoutTests.swiftindex 40e8265..1ad627a 100644--- a/Transit/TransitTests/DashboardLayoutTests.swift+++ b/Transit/TransitTests/DashboardLayoutTests.swift@@ -4,11 +4,10 @@ import Testing  @MainActor struct DashboardLayoutTests {-    @Test(arguments: [320, 375, 390, 400, 428, 599])-    func compactPortraitUsesSingleColumnAtEveryWidth(width: Int) {+    @Test(arguments: [320, 375, 390, 399])+    func narrowPhonePortraitUsesSingleColumn(width: Int) {         let layout = DashboardLayoutLogic.layout(             width: CGFloat(width),-            horizontalSizeClass: .compact,             verticalSizeClass: .regular,             isPhone: true         )@@ -16,10 +15,30 @@ struct DashboardLayoutTests {         #expect(layout == .singleColumn)     } +    @Test(arguments: [400, 428, 599])+    func widePhonePortraitUsesTwoColumnKanban(width: Int) {+        let layout = DashboardLayoutLogic.layout(+            width: CGFloat(width),+            verticalSizeClass: .regular,+            isPhone: true+        )++        #expect(layout == .kanban(visibleCount: 2, initialScrollTarget: nil))+    }++    @Test func widerPhonePortraitUsesThreeColumnKanban() {+        let layout = DashboardLayoutLogic.layout(+            width: 600,+            verticalSizeClass: .regular,+            isPhone: true+        )++        #expect(layout == .kanban(visibleCount: 3, initialScrollTarget: nil))+    }+     @Test func compactWidthRegularHeightUsesGeometryAdaptationForWideIPadSplitView() {         let layout = DashboardLayoutLogic.layout(             width: 500,-            horizontalSizeClass: .compact,             verticalSizeClass: .regular,             isPhone: false         )@@ -30,7 +49,6 @@ struct DashboardLayoutTests {     @Test func narrowIPadSplitViewFallsBackToSingleColumn() {         let layout = DashboardLayoutLogic.layout(             width: 199,-            horizontalSizeClass: .compact,             verticalSizeClass: .regular,             isPhone: false         )@@ -41,7 +59,6 @@ struct DashboardLayoutTests {     @Test func compactHeightRetainsLandscapeGeometryAdaptation() {         let layout = DashboardLayoutLogic.layout(             width: 400,-            horizontalSizeClass: .compact,             verticalSizeClass: .compact,             isPhone: true         )@@ -49,10 +66,9 @@ struct DashboardLayoutTests {         #expect(layout == .kanban(visibleCount: 2, initialScrollTarget: .planning))     } -    @Test func regularWidthCompactHeightRetainsPhoneLandscapeAdaptation() {+    @Test func widePhoneLandscapeRetainsThreeColumnCap() {         let layout = DashboardLayoutLogic.layout(             width: 800,-            horizontalSizeClass: .regular,             verticalSizeClass: .compact,             isPhone: true         )@@ -63,7 +79,6 @@ struct DashboardLayoutTests {     @Test func regularIPadWidthRetainsGeometryAdaptation() {         let layout = DashboardLayoutLogic.layout(             width: 600,-            horizontalSizeClass: .regular,             verticalSizeClass: .regular,             isPhone: false         )@@ -74,7 +89,6 @@ struct DashboardLayoutTests {     @Test func regularMacWidthRetainsGeometryAdaptationAndCapsAtFiveColumns() {         let layout = DashboardLayoutLogic.layout(             width: 1_200,-            horizontalSizeClass: nil,             verticalSizeClass: nil,             isPhone: false         )@@ -82,25 +96,20 @@ struct DashboardLayoutTests {         #expect(layout == .kanban(visibleCount: 5, initialScrollTarget: nil))     } -    @Test func missingSizeClassesRetainWidthBasedPreviewFallback() {+    @Test func missingVerticalSizeClassRetainsWidthBasedFallback() {         let layout = DashboardLayoutLogic.layout(             width: 400,-            horizontalSizeClass: nil,             verticalSizeClass: nil,-            isPhone: false+            isPhone: true         )          #expect(layout == .kanban(visibleCount: 2, initialScrollTarget: nil))     } -    @Test func missingVerticalSizeClassDoesNotAssumePortrait() {-        let layout = DashboardLayoutLogic.layout(-            width: 400,-            horizontalSizeClass: .compact,-            verticalSizeClass: nil,-            isPhone: true-        )+    @Test func narrowFallbackUsesShortLabelsAndDefaultsToActive() {+        let labels = DashboardColumn.allCases.map { $0.primaryStatus.shortLabel } -        #expect(layout == .kanban(visibleCount: 2, initialScrollTarget: nil))+        #expect(labels == ["Idea", "Plan", "Spec", "Active", "Done"])+        #expect(DashboardLayoutLogic.defaultSingleColumn == .inProgress)     } }
Transit/TransitUITests/DataMaintenanceUITests.swift Modified +9 / -6
diff --git a/Transit/TransitUITests/DataMaintenanceUITests.swift b/Transit/TransitUITests/DataMaintenanceUITests.swiftindex d57d07c..5584a12 100644--- a/Transit/TransitUITests/DataMaintenanceUITests.swift+++ b/Transit/TransitUITests/DataMaintenanceUITests.swift@@ -29,10 +29,12 @@ final class DataMaintenanceUITests: XCTestCase {     func testDataMaintenanceGoldenPath() throws {         let app = launchApp(scenario: "duplicateDisplayIds") -        // Open Settings from the dashboard toolbar.-        let settingsButton = app.buttons["dashboard.settingsButton"]-        XCTAssertTrue(settingsButton.waitForExistence(timeout: 5))-        settingsButton.tap()+        // Open Settings from the dashboard toolbar. The populated maintenance+        // scenario shows every filter, so Settings can move into the overflow.+        app.tapTransitToolbarButton(+            identifier: "dashboard.settingsButton",+            overflowLabel: "Settings"+        )          // Navigate to Data Maintenance via the iOS Settings list using the         // dataMaintenance.row accessibility identifier on the NavigationLink.@@ -54,8 +56,9 @@ final class DataMaintenanceUITests: XCTestCase {         )         reassignButton.tap() -        // Confirmation alert with destructive primary action.-        let confirmButton = app.buttons["dataMaintenance.confirmButton"]+        // SwiftUI exposes the destructive alert action as nested matching+        // buttons, so select the first concrete match before tapping.+        let confirmButton = app.buttons["dataMaintenance.confirmButton"].firstMatch         XCTAssertTrue(             confirmButton.waitForExistence(timeout: 5),             "Confirmation alert should appear with destructive primary button"
Transit/TransitUITests/TransitUITestHelpers.swift Added +31 / -0
diff --git a/Transit/TransitUITests/TransitUITestHelpers.swift b/Transit/TransitUITests/TransitUITestHelpers.swiftnew file mode 100644index 0000000..f43adb6--- /dev/null+++ b/Transit/TransitUITests/TransitUITestHelpers.swift@@ -0,0 +1,31 @@+import XCTest++extension XCUIApplication {+    @MainActor+    func dismissTransitSearch() {+        let searchKey = keyboards.buttons["search"]+        XCTAssertTrue(searchKey.waitForExistence(timeout: 5))+        searchKey.tap()++        let closeSearch = buttons["close"]+        XCTAssertTrue(closeSearch.waitForExistence(timeout: 5))+        closeSearch.tap()+    }++    @MainActor+    func tapTransitToolbarButton(identifier: String, overflowLabel: String) {+        let directButton = buttons[identifier].firstMatch+        if directButton.waitForExistence(timeout: 1) {+            directButton.tap()+            return+        }++        let moreButton = buttons["More"]+        XCTAssertTrue(moreButton.waitForExistence(timeout: 5))+        moreButton.tap()++        let overflowButton = buttons[overflowLabel]+        XCTAssertTrue(overflowButton.waitForExistence(timeout: 5))+        overflowButton.tap()+    }+}
Transit/TransitUITests/TransitUITests.swift Modified +31 / -14
diff --git a/Transit/TransitUITests/TransitUITests.swift b/Transit/TransitUITests/TransitUITests.swiftindex 3ba25ee..89be586 100644--- a/Transit/TransitUITests/TransitUITests.swift+++ b/Transit/TransitUITests/TransitUITests.swift@@ -177,7 +177,7 @@ final class TransitUITests: XCTestCase {     // MARK: - Default Segment      @MainActor-    func testIPhonePortraitDefaultsToActiveSegment() throws {+    func testIPhonePortraitUsesWidthBasedDashboardLayout() throws {         #if os(iOS)         let originalOrientation = XCUIDevice.shared.orientation         XCUIDevice.shared.orientation = .portrait@@ -188,16 +188,26 @@ final class TransitUITests: XCTestCase {         }         #endif         let app = launchApp()+        let window = app.windows.firstMatch+        XCTAssertTrue(window.waitForExistence(timeout: 5))+        let portraitExpectation = XCTNSPredicateExpectation(+            predicate: NSPredicate { object, _ in+                guard let element = object as? XCUIElement else { return false }+                return element.frame.height > element.frame.width+            },+            object: window+        )+        XCTAssertEqual(XCTWaiter.wait(for: [portraitExpectation], timeout: 5), .completed) -        // [req 13.1, 13.3] Every compact-width portrait layout uses the-        // segmented single-column dashboard and defaults to "Active".         let segmentedControl = app.segmentedControls.firstMatch-        XCTAssertTrue(segmentedControl.waitForExistence(timeout: 5))-        let activeSegment = segmentedControl.buttons.matching(-            NSPredicate(format: "label CONTAINS 'Active'")-        ).firstMatch-        XCTAssertTrue(activeSegment.exists)-        XCTAssertTrue(activeSegment.isSelected)+        XCTAssertGreaterThanOrEqual(+            window.frame.width,+            400,+            "The standard iPhone 17 UI destination must exercise the wide portrait Kanban path"+        )+        XCTAssertFalse(segmentedControl.waitForExistence(timeout: 2))+        XCTAssertTrue(app.staticTexts["Idea"].waitForExistence(timeout: 5))+        XCTAssertTrue(app.staticTexts["Planning"].exists)     }      // MARK: - Filter Menus@@ -217,9 +227,13 @@ final class TransitUITests: XCTestCase {         searchField.tap()         searchField.typeText("Ship") -        let clearAll = app.buttons["dashboard.clearAllFilters"]-        XCTAssertTrue(clearAll.waitForExistence(timeout: 5))-        clearAll.tap()+        // Dismiss search so the navigation toolbar returns, then use the+        // active Clear All action whether it is direct or in the overflow.+        app.dismissTransitSearch()+        app.tapTransitToolbarButton(+            identifier: "dashboard.clearAllFilters",+            overflowLabel: "Clear All"+        )          XCTAssertTrue(app.staticTexts["Ship Active"].waitForExistence(timeout: 5))         XCTAssertTrue(app.staticTexts["Backlog Idea"].waitForExistence(timeout: 5))@@ -292,8 +306,11 @@ final class TransitUITests: XCTestCase {         XCTAssertTrue(taskCard.waitForExistence(timeout: 5))         taskCard.tap() -        // Detail view opens — verify milestone is shown-        let detailMilestone = app.staticTexts["v1.0 (M-1)"]+        // Detail rows expose a combined accessibility label in the half-height+        // sheet, so match the milestone value within that label.+        let detailMilestone = app.staticTexts.matching(+            NSPredicate(format: "label CONTAINS %@", "v1.0 (M-1)")+        ).firstMatch         XCTAssertTrue(detailMilestone.waitForExistence(timeout: 5))          // Tap edit button (pencil icon in toolbar)
docs/agent-notes/dashboard-views.md Modified +4 / -4
diff --git a/docs/agent-notes/dashboard-views.md b/docs/agent-notes/dashboard-views.mdindex fe3ad4d..ba8a235 100644--- a/docs/agent-notes/dashboard-views.md+++ b/docs/agent-notes/dashboard-views.md@@ -7,9 +7,9 @@  ### DashboardView - Root view, displayed on every launch-- Uses `DashboardLayoutLogic` with a 200pt minimum column width and horizontal/vertical size classes-- Phone portrait (`.compact` width + `.regular` height) always uses `SingleColumnView`; iPad/macOS use width-based geometry, falling back to `SingleColumnView` only when one 200pt column fits-- Phone landscape (including regular-width phone landscape) caps at 3 columns and defaults scroll to Planning+- Uses `DashboardLayoutLogic` with a 200pt minimum column width and vertical size class+- All platforms, including iPhone portrait, use width-based geometry; widths at or above 400pt retain the multi-column Kanban board, while narrower layouts use `SingleColumnView`+- Phone landscape caps at 3 columns and defaults scroll to Planning - `@Query` for allTasks and projects — reactive data from SwiftData - `selectedProjectIDs: Set<UUID>` is ephemeral (resets on launch) - `buildFilteredColumns()` is a static method for testability@@ -107,6 +107,6 @@ Implemented in `DashboardView.buildFilteredColumns()`: ### Gotchas - Always use `.contentShape(.rect)` on views with `.dropDestination` that contain Spacers or ScrollViews - Avoid `.scrollTargetBehavior(.paging)` with drag-and-drop — use `.viewAligned` instead-- On iPhone portrait, cross-column drag requires the segmented control overlay (no other visible drop targets)+- In the narrow segmented fallback, cross-column drag requires the segmented control overlay because no other columns are visible; wide portrait Kanban layouts use normal column drop targets - macOS window toolbar defaults to opaque background; use `.toolbarBackgroundVisibility(.hidden, for: .windowToolbar)` to make it transparent. iOS inline navigation bars are automatically translucent with Liquid Glass. - When adding state to services shared across scenes (like `QuickActionService`), always scope by scene session ID for iPadOS multi-window. Avoid global booleans that any scene can race to consume.
docs/transit-design-doc.md Modified +8 / -10
diff --git a/docs/transit-design-doc.md b/docs/transit-design-doc.mdindex 970c46f..9df22a3 100644--- a/docs/transit-design-doc.md+++ b/docs/transit-design-doc.md@@ -176,7 +176,7 @@ A filter button in the dashboard navigation bar opens a popover listing all proj  A "Clear" action resets the filter to show all projects. -The filter applies to all columns including the counts shown in column headers and the iPhone segmented control.+The filter applies to all columns including the counts shown in column headers and, when active, the segmented single-column fallback.  Filter state is ephemeral — it resets on app launch or tab switch. No need to persist it. @@ -429,15 +429,13 @@ The MCP Apps spec requires servers to provide text-only fallback for all UI-enab ## Platform Layout  ### iPhone — Portrait-Single column visible at a time. A segmented control below the navigation bar shows all five status categories with task counts. Tapping a segment switches the visible column. Default segment on launch: **In Progress** ("Active" in the compact label).--Short labels used in the segmented control: Idea, Plan, Spec, Active, Done.+The dashboard adapts to available width using a 200-point minimum column width. Wide portrait displays show the multi-column Kanban board; only widths where one column fits use the segmented control. The segmented fallback shows Idea, Plan, Spec, Active, and Done with task counts and defaults to **In Progress** ("Active").  ### iPhone — Landscape-Three columns visible at a time. Swipe left/right to reveal additional columns. Default columns on launch: **Planning / Spec / In Progress**.+Up to three columns are visible at a time. Swipe left/right to reveal additional columns. Default columns on launch: **Planning / Spec / In Progress**.  ### iPad and Mac-Full five-column view. All statuses visible simultaneously.+The column count adapts to the window width, up to all five columns. Windows narrow enough for one column use the same segmented fallback.  --- @@ -562,10 +560,10 @@ Decisions made during the design phase, including alternatives that were conside - Scrollable single row with search — keeps the visual feel but adds complexity. **Rationale:** A dropdown is the most iOS-native pattern, takes up a single row regardless of project count, and the color dot preserves visual identification. For the Add Task sheet where you're picking one project, you don't need to see them all at once. -### iPhone column navigation-**Decision:** Segmented control with status names and task counts, defaulting to "Active" (In Progress).-**Alternative considered:** Horizontal swipe between columns.-**Rationale:** A segmented control gives immediate visibility into task distribution across all statuses. Swipe-based navigation hides the other columns entirely. The counts in each segment provide useful ambient information about where work is accumulating.+### Narrow-layout column navigation+**Decision:** When available width fits only one 200-point column, use a segmented control with status names and task counts, defaulting to "Active" (In Progress). Wider layouts retain the Kanban board.+**Alternative considered:** Horizontal swipe between single columns without a visible selector.+**Rationale:** The segmented fallback keeps every status discoverable when multiple readable columns cannot fit. It must not replace the Kanban board when two or more columns fit.  ### Visual design direction **Decision:** Liquid Glass design targeting iOS 26+. Light mode uses system grouped background with translucent glass cards. Dark mode uses true black with subtle glass surfaces. Project-colored borders on task cards (1.5pt) as the primary visual identification.
specs/bugfixes/iphone-portrait-can-render-a-multi-column-dashboard/report.md Modified +3 / -1
diff --git a/specs/bugfixes/iphone-portrait-can-render-a-multi-column-dashboard/report.md b/specs/bugfixes/iphone-portrait-can-render-a-multi-column-dashboard/report.mdindex 9812860..9c0dd90 100644--- a/specs/bugfixes/iphone-portrait-can-render-a-multi-column-dashboard/report.md+++ b/specs/bugfixes/iphone-portrait-can-render-a-multi-column-dashboard/report.md@@ -1,7 +1,9 @@ # Bugfix Report: iPhone Portrait Can Render a Multi-Column Dashboard  **Date:** 2026-08-02-**Status:** Fixed+**Status:** Superseded++> **Superseded 2026-08-06:** The product decision and requirement 13.1 were corrected to restore the original width-based behavior. Wide iPhone portrait layouts use the multi-column Kanban board when at least two 200-point columns fit; only narrower layouts use the segmented single-column fallback. The investigation below remains as historical context for PR #205.  ## Description of the Issue 
specs/search-empty-state/smolspec.md Modified +1 / -1
diff --git a/specs/search-empty-state/smolspec.md b/specs/search-empty-state/smolspec.mdindex edadd95..5898f3e 100644--- a/specs/search-empty-state/smolspec.md+++ b/specs/search-empty-state/smolspec.md@@ -70,7 +70,7 @@ Follow the existing pattern (`ReportView.swift:79` uses `.accessibilityIdentifie ## Risks and Assumptions  - **Assumption:** The `.overlay` covers both the `SingleColumnView` and `KanbanBoardView` branches because it is applied to the `GeometryReader` content in `body` (line 82), not to either child. Verified against current code.-- **Accepted behavior:** On iPhone portrait, the full-bleed overlay covers the segmented column switcher in `SingleColumnView` (the `Picker` at `SingleColumnView.swift:14-42`). This is pre-existing behavior (the generic empty state already does this) and is acceptable — when there are no results there is nothing to switch between. No per-layout work is added.+- **Accepted behavior:** In the narrow single-column fallback, the full-bleed overlay covers the segmented column switcher in `SingleColumnView` (the `Picker` at `SingleColumnView.swift:14-42`). This is pre-existing behavior (the generic empty state already does this) and is acceptable — when there are no results there is nothing to switch between. No per-layout work is added. - **Assumption:** `ContentUnavailableView.search(text:)` renders acceptably over the `BoardBackground` glass/gradient on all three platforms. | **Mitigation:** It is the same `ContentUnavailableView` family already used elsewhere in the app; poor contrast would be a cosmetic follow-up, not a blocker. - **Risk:** A future contributor moves `.searchable` inside the overlay branching, making the bar vanish when empty. | **Mitigation:** Documented above and asserted by the UI test (search field remains present while the empty state is shown). - **Decision:** When search text and another filter are both active, the generic "clear filters" message wins (not the search state). Recorded in decision_log.md Decision 1.
specs/transit-v1/decision_log.md Modified +3 / -2
diff --git a/specs/transit-v1/decision_log.md b/specs/transit-v1/decision_log.mdindex bb5b62a..b258fe6 100644--- a/specs/transit-v1/decision_log.md+++ b/specs/transit-v1/decision_log.md@@ -363,14 +363,15 @@ Groups buttons by function. Filter and add are both task-related dashboard actio  **Date**: 2026-02-09 **Status**: accepted+**Amended**: 2026-08-06 — clarified that width-based adaptation also applies to iPhone portrait  ### Context -The original requirement stated iPad and Mac always show all five columns. However, Mac windows can be resized and iPad supports Split View, meaning the available width may be too narrow for five columns.+Available width varies on every platform: Mac windows resize, iPad supports Split View, and wider iPhone portrait displays can fit multiple readable Kanban columns. A device-orientation rule that forces every portrait phone into one stretched column wastes available space and hides the board behind segmented navigation.  ### Decision -The number of visible columns adapts to the available window width. All five columns show when space permits; fewer columns with horizontal scrolling when narrowed. At minimum width (single-column), fall back to the iPhone portrait layout with a segmented control.+The number of visible columns adapts to the available window width on iPhone, iPad, and Mac, using a 200-point minimum column width. All five columns show when space permits; fewer columns show with horizontal scrolling when narrowed. At minimum width, when only one column fits, the dashboard uses the segmented single-column fallback. iPhone landscape additionally caps the visible count at three.  ### Rationale 
specs/transit-v1/design.md Modified +9 / -6
diff --git a/specs/transit-v1/design.md b/specs/transit-v1/design.mdindex 8a08062..8aebcc7 100644--- a/specs/transit-v1/design.md+++ b/specs/transit-v1/design.md@@ -92,7 +92,7 @@ Transit/ │   ├── Dashboard/ │   │   ├── DashboardView.swift         # Root view, layout switching │   │   ├── KanbanBoardView.swift       # Multi-column board (iPad/Mac/landscape)-│   │   ├── SingleColumnView.swift      # Segmented control layout (iPhone portrait/narrow)+│   │   ├── SingleColumnView.swift      # Segmented control layout (width-based narrow fallback) │   │   ├── ColumnView.swift            # Single kanban column with header │   │   ├── TaskCardView.swift          # Glass card with project border │   │   └── FilterPopoverView.swift     # Project filter popover@@ -196,7 +196,7 @@ enum TaskStatus: String, Codable, CaseIterable {         self == .done || self == .abandoned     } -    /// Short labels for iPhone segmented control [req 13.2]+    /// Short labels for the narrow segmented-control fallback [req 13.2]     var shortLabel: String {         switch column {         case .idea: return "Idea"@@ -452,15 +452,18 @@ struct DashboardView: View {     @State private var selectedTask: TransitTask?     @State private var showAddTask = false     @State private var showFilter = false-    @Environment(\.horizontalSizeClass) private var sizeClass     @Environment(\.verticalSizeClass) private var verticalSizeClass      /// Minimum width (in points) for a single kanban column.     private static let columnMinWidth: CGFloat = 200 -    /// Whether the device is in iPhone landscape (compact vertical, any horizontal).+    /// Whether this is a phone in landscape; iPad compact height remains width-adaptive.     private var isPhoneLandscape: Bool {-        verticalSizeClass == .compact+        #if os(iOS)+        UIDevice.current.userInterfaceIdiom == .phone && verticalSizeClass == .compact+        #else+        false+        #endif     }      var body: some View {@@ -1024,7 +1027,7 @@ UI tests focus on navigation flows and presentation, not business logic (which i | Empty column shows empty state message | [20.2] | | Dashboard with zero tasks shows global empty state | [20.1] | | Settings with zero projects shows create prompt | [20.4] |-| iPhone portrait defaults to Active segment | [13.3] |+| iPhone portrait uses Kanban when two columns fit and Active-segment fallback when only one fits | [13.1], [13.3] | | Abandoned task card shows reduced opacity | [5.7] |  ### Integration Tests
specs/transit-v1/implementation.md Modified +1 / -1
diff --git a/specs/transit-v1/implementation.md b/specs/transit-v1/implementation.mdindex 7ade957..26f5fdf 100644--- a/specs/transit-v1/implementation.md+++ b/specs/transit-v1/implementation.md@@ -62,7 +62,7 @@ Transit integrates into a developer workflow where AI agents and CI pipelines ca  **Status Engine Pattern**: All status transitions go through `StatusEngine.applyTransition()`, which handles side effects (setting/clearing `completionDate`, updating `lastStatusChangeDate`). This is the single source of truth — UI drag-and-drop, detail view edits, and App Intents all use the same path via `TaskService`. -**Adaptive Dashboard Layout**: `DashboardView` uses `GeometryReader` to calculate how many columns fit. iPhone portrait gets a segmented control (1 column), landscape gets 3 columns with paging, iPad/Mac gets up to 5. The static `buildFilteredColumns()` method handles column grouping, 48-hour terminal cutoff, and sorting (handoff tasks first, then by date).+**Adaptive Dashboard Layout**: `DashboardView` uses `GeometryReader` to calculate how many 200-point columns fit on every platform. Wide iPhone portrait layouts retain the multi-column Kanban board; layouts narrow enough for only one column use the segmented control. iPhone landscape caps the visible count at 3, while iPad/Mac show up to 5. The static `buildFilteredColumns()` method handles column grouping, 48-hour terminal cutoff, and sorting (handoff tasks first, then by date).  **Display ID Allocation**: Uses a CloudKit counter record with `CKModifyRecordsOperation` and `.ifServerRecordUnchanged` save policy for optimistic locking. Falls back to provisional IDs offline. Promotion triggers on app launch, foreground, and network restore via `NWPathMonitor`. 
specs/transit-v1/requirements.md Modified +6 / -6
diff --git a/specs/transit-v1/requirements.md b/specs/transit-v1/requirements.mdindex a4883fd..050a4e5 100644--- a/specs/transit-v1/requirements.md+++ b/specs/transit-v1/requirements.md@@ -139,7 +139,7 @@ This document defines the requirements for the complete V1 implementation. The d 1. <a name="9.1"></a>WHEN the user taps the filter button, the system SHALL present a popover listing all projects with checkboxes 2. <a name="9.2"></a>The system SHALL support selecting multiple projects simultaneously (OR logic — show tasks from any selected project) 3. <a name="9.3"></a>WHEN a filter is active, the filter button SHALL display the count of selected projects-4. <a name="9.4"></a>The filter SHALL apply to all columns including task counts in column headers (per [5.9](#5.9)) and the iPhone segmented control+4. <a name="9.4"></a>The filter SHALL apply to all columns including task counts in column headers (per [5.9](#5.9)) and the segmented single-column fallback 5. <a name="9.5"></a>The popover SHALL include a "Clear" action that resets the filter to show all projects 6. <a name="9.6"></a>Filter state SHALL be ephemeral — it SHALL reset on app launch @@ -203,13 +203,13 @@ This document defines the requirements for the complete V1 implementation. The d  **Acceptance Criteria:** -1. <a name="13.1"></a>On iPhone in portrait orientation, the system SHALL display a segmented control below the navigation bar showing five status categories with task counts, with one column visible at a time-2. <a name="13.2"></a>The iPhone segmented control SHALL use short labels: Idea, Plan, Spec, Active, Done-3. <a name="13.3"></a>The iPhone default segment on launch SHALL be "Active" (In Progress)-4. <a name="13.4"></a>On iPhone in landscape orientation, the system SHALL display three columns visible at a time with horizontal swipe to reveal additional columns+1. <a name="13.1"></a>On iPhone in portrait orientation, the number of visible dashboard columns SHALL adapt to the available width using a 200-point minimum column width; when two or more columns fit, the system SHALL display the multi-column Kanban board, and when only one column fits, it SHALL display the segmented single-column layout+2. <a name="13.2"></a>WHEN the segmented single-column layout is active, the control SHALL use short labels: Idea, Plan, Spec, Active, Done+3. <a name="13.3"></a>WHEN the segmented single-column layout is active, its default segment on launch SHALL be "Active" (In Progress)+4. <a name="13.4"></a>On iPhone in landscape orientation, the system SHALL display up to three columns visible at a time with horizontal swipe to reveal additional columns 5. <a name="13.5"></a>The iPhone landscape default columns on launch SHALL be Planning, Spec, In Progress 6. <a name="13.6"></a>On iPad and Mac, the number of visible columns SHALL adapt to the available window width — all five columns when space permits, fewer columns with horizontal scrolling when the window is narrowed-7. <a name="13.7"></a>WHEN the available width is narrow enough to fit only a single column (e.g., iPad Split View at minimum width), the system SHALL fall back to the iPhone portrait layout with a segmented control+7. <a name="13.7"></a>WHEN the available width on any platform is narrow enough to fit only a single 200-point column, the system SHALL use the segmented single-column layout  --- 

Things to double-check

CI status interpretation

The GitHub combined legacy commit status is pending with total_count: 0 and no status contexts. It is not an outstanding check. The only current workflow, Claude Code Review, completed successfully; GitHub reports no required checks and the PR merge state is CLEAN.

Validation wrapper timeout

An initial run_silent make test attempt reached that wrapper’s ten-minute timeout after listing passing tests, so it was not accepted as evidence. The subsequent direct make test and make test-ui executions both recorded exit status 0.