transit PR #205 commits 4 files 7 touched lines +283 / -26

Pre-push review: T-1802 iPhone portrait dashboard layout

Strict review of PR #205 at exact head 4ea6bd561bd827e985d47f9f0fc7675e75d159d4 against exact base ad14b788063e0dec97df0a0060f762b00d6ba822.

At a glance

  • Identity: HEAD and PR head are 4ea6bd561bd827e985d47f9f0fc7675e75d159d4; PR base and requested base are ad14b788063e0dec97df0a0060f762b00d6ba822.
  • CI/review: current-head claude-review passed; latest current-head review comment reports no blocking issues; review decision is unset and review threads total zero.
  • Local validation: make lint, make test-quick, focused DashboardLayoutTests (9 dynamic test runs), and focused iPhone portrait UI test (1 test) passed.
  • Patch stability: non-changelog diff against the rebased base exactly matches the original diff from 275ac5c to the pre-rebase PR head; intervening PRs touch no dashboard files or known failing selectors.

Verdict

Ready to push

No candidate-specific blocker was found. The candidate is clean at the requested identity; the non-changelog patch is byte-for-byte identical to the original PR patch; current-head CI is green; the current-head review found no blockers; there are zero review threads; and all required post-rebase local checks passed. The three full-iOS failures were already established as identical baseline failures against 275ac5c.

Review findings

2 raised · 1 fixed · 1 skipped

Jump to findings →

Commits

Three-level explanation

What changed

The dashboard now chooses its layout using an explicit helper instead of relying only on available width. A real iPhone in compact-width portrait always gets the segmented single-column view, even at widths where two 200-point columns would fit. Landscape phones keep their three-column cap, while iPad and Mac keep geometry-based adaptation.

Why it matters

This restores the required iPhone portrait interaction model and prevents the segmented control from disappearing on wider compact portrait windows.

Architecture

DashboardLayoutLogic.layout returns an explicit DashboardLayoutMode. DashboardView supplies width, both size classes, and the platform device idiom, then switches between SingleColumnView and KanbanBoardView. The pure helper is covered independently across phone, iPad split-view, iPad, Mac, landscape, and missing-size-class cases.

Trade-off

Platform detection remains a small iOS-only boundary around otherwise platform-neutral layout logic, making the decision testable without changing either child view.

Decision boundary

The compact-portrait predicate is intentionally evaluated before geometry-derived column counting: isPhone && horizontalSizeClass == .compact && verticalSizeClass == .regular returns .singleColumn. Phone landscape is guarded by both phone idiom and compact vertical size class, preventing iPad panes with compact height from inheriting phone behavior. All other paths retain the 200-point width calculation and cap visible columns with DashboardColumn.allCases.count.

Validation implications

The unit suite pins the 400-point regression boundary and the size-class matrix; the UI test explicitly forces portrait and asserts both segmented-control existence and Active selection. The exact non-changelog patch comparison rules out rebase-induced semantic drift.

Important changes — detailed

Dashboard: make layout selection explicit

Transit/Transit/Views/Dashboard/DashboardLayout.swift

Why it matters. Fixes the compact-width portrait regression without changing the child dashboard views.

What to look at. DashboardLayout.swift:1-37

Takeaway. Pure layout-selection helpers make size-class and width boundary behavior directly unit-testable.
Rationale. The compact portrait rule must take precedence over width-based geometry; other platforms and phone landscape retain their existing adaptation. (inferred — not stated by the author)

DashboardView: route through the selector

Transit/Transit/Views/Dashboard/DashboardView.swift

Why it matters. Integrates the fix at the root layout boundary while preserving task taps, drops, and landscape scroll targeting.

What to look at. DashboardView.swift:5-104

Takeaway. Keep platform-specific device detection at the integration edge and pass primitive inputs into pure logic.
Rationale. The view needs the actual iOS device idiom, while the helper stays easy to exercise on macOS tests. (inferred — not stated by the author)

Tests: cover the size-class matrix and regression boundary

Transit/TransitTests/DashboardLayoutTests.swift

Why it matters. Prevents recurrence at 400 points and protects iPad, Mac, landscape, and preview fallbacks.

What to look at. DashboardLayoutTests.swift:1-106

Takeaway. Boundary-oriented parameterized tests are stronger than testing only one representative device width.
Rationale. The original defect was caused by an untested width boundary, so coverage spans both sides and the relevant platform/orientation combinations. (inferred — not stated by the author)

UI test: require the portrait control

Transit/TransitUITests/TransitUITests.swift

Why it matters. Turns a previously vacuous pass into an assertion that the required segmented control exists and defaults to Active.

What to look at. TransitUITests.swift:180-204

Takeaway. UI regression tests should fail when required UI is absent rather than conditionally skipping assertions.
Rationale. The test now directly verifies requirements 13.1 and 13.3 after explicitly setting portrait orientation. (inferred — not stated by the author)

Key decisions

Use device idiom plus both size classes

Checking only width or only one size class cannot distinguish compact portrait iPhones from iPhone landscape and iPad Split View. The helper therefore receives isPhone, horizontal size class, vertical size class, and width.

Retain the defensive unknown-orientation fallback

The UI test restores the captured orientation and uses landscapeLeft when XCTest reports .unknown. The current-head review treated this as non-blocking and the existing full UI run passed the relevant regression.

Keep the changelog as the only expected post-rebase difference

Intervening PRs #202, #203, and #204 modify unrelated query, color, and create-validation paths. Their changes do not overlap dashboard layout or the known failing selectors, so semantic equivalence was checked excluding CHANGELOG.md.

(inferred — not stated by the author.)

Review findings

SeverityAreaFindingResolution
infoReview stateEarlier review comments identified documentation, iPad split-view, coverage, and orientation concerns.The current candidate includes the documented iPad geometry behavior, updated agent note, added size-class coverage, and retained the reviewed defensive orientation fallback; current-head review reports no blocker.
infoKnown full-iOS baselineThe initial exact-head/full-iOS comparison against 275ac5c had three failures: testClearAll, DataMaintenanceUITests.testDataMaintenanceGoldenPath, and testEditViewPreservesTaskMilestone.These were identical in the comparison baseline and are unrelated to dashboard layout; the focused portrait UI test and all required local post-rebase checks pass.

Per-file diffs

Click to expand.

CHANGELOG.md Modified +1 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 2fe949a..0365791 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -10,6 +10,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. - 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. 
Transit/Transit/Views/Dashboard/DashboardLayout.swift Added +37 / -0
diff --git a/Transit/Transit/Views/Dashboard/DashboardLayout.swift b/Transit/Transit/Views/Dashboard/DashboardLayout.swiftnew file mode 100644index 0000000..c5a9cdc--- /dev/null+++ b/Transit/Transit/Views/Dashboard/DashboardLayout.swift@@ -0,0 +1,37 @@+import SwiftUI++enum DashboardLayoutMode: Equatable {+    case singleColumn+    case kanban(visibleCount: Int, initialScrollTarget: DashboardColumn?)+}++enum DashboardLayoutLogic {+    static let columnMinWidth: CGFloat = 200++    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++        if columnCount == 1 {+            return .singleColumn+        }++        return .kanban(+            visibleCount: min(columnCount, DashboardColumn.allCases.count),+            initialScrollTarget: isPhoneLandscape ? .planning : nil+        )+    }+}
Transit/Transit/Views/Dashboard/DashboardView.swift Modified +23 / -13
diff --git a/Transit/Transit/Views/Dashboard/DashboardView.swift b/Transit/Transit/Views/Dashboard/DashboardView.swiftindex a1605a3..2458d9c 100644--- a/Transit/Transit/Views/Dashboard/DashboardView.swift+++ b/Transit/Transit/Views/Dashboard/DashboardView.swift@@ -2,6 +2,18 @@ import SwiftData import SwiftUI +#if os(iOS)+import UIKit+#endif++#if os(iOS)+private func dashboardIsPhoneDevice() -> Bool {+    UIDevice.current.userInterfaceIdiom == .phone+}+#else+private func dashboardIsPhoneDevice() -> Bool { false }+#endif+ struct DashboardView: View {     @Query(sort: \TransitTask.lastStatusChangeDate, order: .reverse) private var allTasks: [TransitTask]     @Query(sort: \Project.name) private var projects: [Project]@@ -26,13 +38,6 @@ struct DashboardView: View {     @Environment(\.sceneSessionID) private var sceneSessionID     #endif -    /// Minimum width (in points) for a single kanban column.-    private static let columnMinWidth: CGFloat = 200--    private var isPhoneLandscape: Bool {-        verticalSizeClass == .compact-    }-     private var effectiveSearchText: String {         searchText.trimmingCharacters(in: .whitespacesAndNewlines)     }@@ -68,21 +73,26 @@ struct DashboardView: View {         // render pass instead of once for each consumer. [T-198]         let columns = filteredColumns         GeometryReader { geometry in-            let rawColumnCount = max(1, Int(geometry.size.width / Self.columnMinWidth))-            let columnCount = isPhoneLandscape ? min(rawColumnCount, 3) : rawColumnCount+            let layout = DashboardLayoutLogic.layout(+                width: geometry.size.width,+                horizontalSizeClass: sizeClass,+                verticalSizeClass: verticalSizeClass,+                isPhone: dashboardIsPhoneDevice()+            ) -            if columnCount == 1 {+            switch layout {+            case .singleColumn:                 SingleColumnView(                     columns: columns,                     selectedColumn: $selectedColumn,                     onTaskTap: { handleTaskTap($0) },                     onDrop: handleDrop                 )-            } else {+            case let .kanban(visibleCount, initialScrollTarget):                 KanbanBoardView(                     columns: columns,-                    visibleCount: min(columnCount, 5),-                    initialScrollTarget: isPhoneLandscape ? .planning : nil,+                    visibleCount: visibleCount,+                    initialScrollTarget: initialScrollTarget,                     onTaskTap: { handleTaskTap($0) },                     onDrop: handleDrop                 )
Transit/TransitTests/DashboardLayoutTests.swift Added +106 / -0
diff --git a/Transit/TransitTests/DashboardLayoutTests.swift b/Transit/TransitTests/DashboardLayoutTests.swiftnew file mode 100644index 0000000..40e8265--- /dev/null+++ b/Transit/TransitTests/DashboardLayoutTests.swift@@ -0,0 +1,106 @@+import SwiftUI+import Testing+@testable import Transit++@MainActor+struct DashboardLayoutTests {+    @Test(arguments: [320, 375, 390, 400, 428, 599])+    func compactPortraitUsesSingleColumnAtEveryWidth(width: Int) {+        let layout = DashboardLayoutLogic.layout(+            width: CGFloat(width),+            horizontalSizeClass: .compact,+            verticalSizeClass: .regular,+            isPhone: true+        )++        #expect(layout == .singleColumn)+    }++    @Test func compactWidthRegularHeightUsesGeometryAdaptationForWideIPadSplitView() {+        let layout = DashboardLayoutLogic.layout(+            width: 500,+            horizontalSizeClass: .compact,+            verticalSizeClass: .regular,+            isPhone: false+        )++        #expect(layout == .kanban(visibleCount: 2, initialScrollTarget: nil))+    }++    @Test func narrowIPadSplitViewFallsBackToSingleColumn() {+        let layout = DashboardLayoutLogic.layout(+            width: 199,+            horizontalSizeClass: .compact,+            verticalSizeClass: .regular,+            isPhone: false+        )++        #expect(layout == .singleColumn)+    }++    @Test func compactHeightRetainsLandscapeGeometryAdaptation() {+        let layout = DashboardLayoutLogic.layout(+            width: 400,+            horizontalSizeClass: .compact,+            verticalSizeClass: .compact,+            isPhone: true+        )++        #expect(layout == .kanban(visibleCount: 2, initialScrollTarget: .planning))+    }++    @Test func regularWidthCompactHeightRetainsPhoneLandscapeAdaptation() {+        let layout = DashboardLayoutLogic.layout(+            width: 800,+            horizontalSizeClass: .regular,+            verticalSizeClass: .compact,+            isPhone: true+        )++        #expect(layout == .kanban(visibleCount: 3, initialScrollTarget: .planning))+    }++    @Test func regularIPadWidthRetainsGeometryAdaptation() {+        let layout = DashboardLayoutLogic.layout(+            width: 600,+            horizontalSizeClass: .regular,+            verticalSizeClass: .regular,+            isPhone: false+        )++        #expect(layout == .kanban(visibleCount: 3, initialScrollTarget: nil))+    }++    @Test func regularMacWidthRetainsGeometryAdaptationAndCapsAtFiveColumns() {+        let layout = DashboardLayoutLogic.layout(+            width: 1_200,+            horizontalSizeClass: nil,+            verticalSizeClass: nil,+            isPhone: false+        )++        #expect(layout == .kanban(visibleCount: 5, initialScrollTarget: nil))+    }++    @Test func missingSizeClassesRetainWidthBasedPreviewFallback() {+        let layout = DashboardLayoutLogic.layout(+            width: 400,+            horizontalSizeClass: nil,+            verticalSizeClass: nil,+            isPhone: false+        )++        #expect(layout == .kanban(visibleCount: 2, initialScrollTarget: nil))+    }++    @Test func missingVerticalSizeClassDoesNotAssumePortrait() {+        let layout = DashboardLayoutLogic.layout(+            width: 400,+            horizontalSizeClass: .compact,+            verticalSizeClass: nil,+            isPhone: true+        )++        #expect(layout == .kanban(visibleCount: 2, initialScrollTarget: nil))+    }+}
Transit/TransitUITests/TransitUITests.swift Modified +17 / -9
diff --git a/Transit/TransitUITests/TransitUITests.swift b/Transit/TransitUITests/TransitUITests.swiftindex 563650f..3ba25ee 100644--- a/Transit/TransitUITests/TransitUITests.swift+++ b/Transit/TransitUITests/TransitUITests.swift@@ -178,18 +178,26 @@ final class TransitUITests: XCTestCase {      @MainActor     func testIPhonePortraitDefaultsToActiveSegment() throws {+        #if os(iOS)+        let originalOrientation = XCUIDevice.shared.orientation+        XCUIDevice.shared.orientation = .portrait+        defer {+            XCUIDevice.shared.orientation = originalOrientation == .unknown+                ? .landscapeLeft+                : originalOrientation+        }+        #endif         let app = launchApp() -        // [req 13.3] iPhone portrait default segment is "Active" (In Progress)+        // [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-        if segmentedControl.waitForExistence(timeout: 5) {-            let activeSegment = segmentedControl.buttons.matching(-                NSPredicate(format: "label CONTAINS 'Active'")-            ).firstMatch-            XCTAssertTrue(activeSegment.exists)-            XCTAssertTrue(activeSegment.isSelected)-        }-        // On wider devices (iPad), segmented control may not appear — test passes implicitly+        XCTAssertTrue(segmentedControl.waitForExistence(timeout: 5))+        let activeSegment = segmentedControl.buttons.matching(+            NSPredicate(format: "label CONTAINS 'Active'")+        ).firstMatch+        XCTAssertTrue(activeSegment.exists)+        XCTAssertTrue(activeSegment.isSelected)     }      // MARK: - Filter Menus
docs/agent-notes/dashboard-views.md Modified +3 / -4
diff --git a/docs/agent-notes/dashboard-views.md b/docs/agent-notes/dashboard-views.mdindex 946b26b..fe3ad4d 100644--- a/docs/agent-notes/dashboard-views.md+++ b/docs/agent-notes/dashboard-views.md@@ -7,10 +7,9 @@  ### DashboardView - Root view, displayed on every launch-- Uses `GeometryReader` with `columnMinWidth: 200` to determine layout-- `columnCount == 1` → `SingleColumnView` (iPhone portrait, narrow iPad Split View)-- `columnCount > 1` → `KanbanBoardView` (iPad, Mac, iPhone landscape)-- iPhone landscape: caps at 3 columns, defaults scroll to Planning column+- 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 - `@Query` for allTasks and projects — reactive data from SwiftData - `selectedProjectIDs: Set<UUID>` is ephemeral (resets on launch) - `buildFilteredColumns()` is a static method for testability
specs/bugfixes/iphone-portrait-can-render-a-multi-column-dashboard/report.md Added +96 / -0
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.mdnew file mode 100644index 0000000..9812860--- /dev/null+++ b/specs/bugfixes/iphone-portrait-can-render-a-multi-column-dashboard/report.md@@ -0,0 +1,96 @@+# Bugfix Report: iPhone Portrait Can Render a Multi-Column Dashboard++**Date:** 2026-08-02+**Status:** Fixed++## Description of the Issue++An iPhone portrait dashboard can render the multi-column kanban view when the compact-width window is at least 400 points wide. Requirement 13.1 requires every iPhone compact-width portrait layout to use the segmented single-column view, independent of that width.++**Reproduction steps:**+1. Launch Transit on an iPhone in portrait orientation with a compact-width window around 400 points or wider.+2. Open the dashboard.+3. Observe that the segmented status control is absent and multiple kanban columns are rendered.++**Impact:** The portrait iPhone layout violates the platform-adaptive dashboard contract and presents the wrong interaction model on affected device/window widths.++## Investigation Summary++The dashboard layout path was inspected from its root caller through both layout implementations and existing UI/unit coverage.++- **Symptoms examined:** Compact portrait widths below and above the 400-point geometry boundary; iPhone landscape; narrow iPad split view; regular iPad; Mac.+- **Code inspected:** `DashboardView`, `KanbanBoardView`, `SingleColumnView`, `ColumnView`, `TransitApp`, existing dashboard tests, and the iOS UI test launch helper.+- **Hypotheses tested:** The issue is not in the segmented control or column views; the root view selects `KanbanBoardView` before either child can affect layout. The existing drag/drop and filter callers remain downstream of this selection and do not alter it.++## Discovered Root Cause++`DashboardView` derives `rawColumnCount` only from `geometry.size.width / 200`. It uses `verticalSizeClass` only to cap landscape columns, and does not use compact `horizontalSizeClass` to force portrait into the segmented path. Therefore a compact-width portrait geometry of 400 points produces two columns.++**Defect type:** Layout-selection logic error and missing boundary condition.++**Why it occurred:** The original geometry rule treated width as sufficient to choose between one and multiple columns. That is valid for iPad and Mac, and the landscape cap is valid for iPhone landscape, but it does not encode the stronger requirement that compact-width portrait always uses one column.++**Five whys:**+1. Why can portrait show multiple columns? Because `rawColumnCount` becomes two or more at widths of 400 points and above.+2. Why is that raw count used in portrait? Because the selection code only branches on `isPhoneLandscape` and `columnCount == 1`.+3. Why does it not branch on portrait compact width? Because `horizontalSizeClass` is read but ignored by the selection calculation.+4. Why was the ignored size class not caught? Existing UI coverage allowed the segmented control to be absent, and unit coverage did not exercise the layout selector across the compact-width boundary.+5. Root cause: Device class and orientation were not represented as an explicit, testable layout-selection rule; width-only geometry leaked into the compact portrait path.++## Resolution for the Issue++**Changes made:**+- `Transit/Transit/Views/Dashboard/DashboardLayout.swift` — Extracted the layout decision from `DashboardView` into a value-returning helper. A phone in compact width with regular height now returns `.singleColumn` before width-based column counting; iPad and Mac retain geometry-based adaptation and only fall back to `.singleColumn` when one 200pt column fits. Phone landscape, including regular-width compact-height phones, still caps geometry-derived columns at three.+- `Transit/Transit/Views/Dashboard/DashboardView.swift` — Supplies the iOS device idiom to the selector and uses the resulting layout mode to select `SingleColumnView` or `KanbanBoardView`, preserving the existing task tap, drop callbacks, and landscape initial scroll target.+- `Transit/TransitTests/DashboardLayoutTests.swift` — Added width-range and cross-platform regression tests for phone portrait, wide and narrow iPad split view, both phone landscape size-class shapes, iPad, Mac, and missing size classes.+- `Transit/TransitUITests/TransitUITests.swift` — Makes the portrait UI regression deterministic by setting portrait orientation and requiring the segmented control plus Active default instead of allowing the control to be absent.++**Approach rationale:** The selection rule is now explicit and unit-testable. The phone portrait rule combines device idiom with size classes, so it covers 400-point and wider compact phone windows without forcing wider iPad Split View panes into the segmented path. iPad and Mac continue to use geometry adaptation, with a single-column fallback only below the 200pt column-fit boundary. Keeping the existing child views and callbacks avoids changing dashboard behavior outside the selection boundary.++**Alternatives considered:**+- Lowering `columnMinWidth` or increasing it — rejected because any width threshold would remain device-specific and could regress iPad/Mac geometry adaptation.+- Checking only `horizontalSizeClass == .compact` — rejected because iPhone landscape and iPad Split View can also report compact width but must retain geometry-based adaptation.+- Using only `verticalSizeClass` for phone detection — rejected because iPad and other regular-height panes can share the same vertical class; the iOS device idiom is needed to distinguish phone portrait from iPad Split View.++## Regression Test++**Test file:** `Transit/TransitTests/DashboardLayoutTests.swift`+**Test names:** `compactPortraitUsesSingleColumnAtEveryWidth(width:)`, `compactWidthRegularHeightUsesGeometryAdaptationForWideIPadSplitView()`, `narrowIPadSplitViewFallsBackToSingleColumn()`, `compactHeightRetainsLandscapeGeometryAdaptation()`, `regularWidthCompactHeightRetainsPhoneLandscapeAdaptation()`, `regularIPadWidthRetainsGeometryAdaptation()`, `regularMacWidthRetainsGeometryAdaptationAndCapsAtFiveColumns()`, `missingSizeClassesRetainWidthBasedPreviewFallback()`, `missingVerticalSizeClassDoesNotAssumePortrait()`++**What it verifies:** Phone compact-width portrait uses one column across representative widths (320, 375, 390, 400, 428, and 599 points). Wide iPad Split View retains geometry-based multi-column adaptation, while a pane narrower than one 200pt column falls back to the segmented view. Phone landscape, including regular-width compact-height phones, retains its three-column cap and Planning initial target; regular iPad and Mac retain geometry adaptation. Missing size classes retain the width-based fallback used by previews and macOS, and the iOS UI test requires the segmented control and Active default rather than silently passing when the control is missing.++**Run command:** `make test-quick` and `make test-ui`++## Affected Files++| File | Change |+|------|--------|+| `Transit/Transit/Views/Dashboard/DashboardLayout.swift` | Extract current layout-selection result for focused tests. |+| `Transit/Transit/Views/Dashboard/DashboardView.swift` | Route the root layout choice through the extracted helper. |+| `Transit/TransitTests/DashboardLayoutTests.swift` | Add boundary and cross-platform regression coverage. |+| `Transit/TransitUITests/TransitUITests.swift` | Require the portrait segmented control and Active default. |+| `specs/bugfixes/iphone-portrait-can-render-a-multi-column-dashboard/report.md` | Record investigation and red-test checkpoint. |++## Verification++**Automated:**+- [x] Regression tests pass — `DashboardLayoutTests` passes on macOS and iOS, including compact portrait widths 320, 375, 390, 400, 428, and 599 points plus iPad split, landscape, iPad, Mac, and missing-size-class cases.+- [x] Portrait UI behavior passes in the full iOS run — `testIPhonePortraitDefaultsToActiveSegment` passed on the iPhone 17 iOS 26.5 Simulator, including the segmented-control and selected Active accessibility assertions.+- [~] Full iOS suite — `make test` result bundle `DerivedData/Logs/Test/Test-Transit-2026.08.02_12-09-36-+1000.xcresult` reports 1,161/1,164 tests passed. The only three failures exactly match the known iOS 26.5 baseline: `testClearAll` (`XCTAssertTrue failed`), `testEditViewPreservesTaskMilestone` (`XCTAssertTrue failed`), and `testDataMaintenanceGoldenPath` (two matching `dataMaintenance.confirmButton` elements). No layout regression or new failure was observed.+- [x] `make test-quick` passes.+- [x] `make lint` passes with zero violations.++**Manual verification:**+- Not performed on physical hardware; iPhone 17 iOS 26.5 Simulator UI coverage exercises portrait selection and the existing landscape/iPad/Mac logic is covered by the pure helper tests.++## Prevention++- Keep device-class and orientation layout decisions in a pure, testable helper rather than deriving the view directly from width.+- Test representative ranges around geometry boundaries instead of one device width.+- Do not make UI tests pass implicitly when a required layout control is absent.++## Related++- Transit T-1802+- `specs/transit-v1/requirements.md` requirement 13.1 and 13.7+- `docs/agent-notes/dashboard-views.md`

Things to double-check

Platform and orientation correctness

The implementation uses #if os(iOS) for UIDevice access and returns false on macOS. Focused macOS layout tests passed, and the iPhone 17 iOS 26.5 simulator portrait UI test passed. Unit coverage verifies compact portrait, both phone landscape size-class shapes, wide/narrow iPad split view, regular iPad, Mac, and missing-size-class fallbacks.

Clean candidate before and after

The candidate was clean before review and remained clean after lint/tests: git status --porcelain=v1 --untracked-files=all was empty and both worktree/index diff checks reported clean.

Current-head GitHub state

PR #205 is open and unmerged, exact head/base match the requested objects, current-head claude-review completed successfully, formal review decision is unset, reviews total zero, and review thread total zero.

Rebase equivalence

The complete non-changelog unified diff from ad14b788… to 4ea6bd5… exactly matches the original non-changelog diff from 275ac5c to origin/pr-205-head, with matching path-set and per-file checksums.