From 3af02c74fb28b023edf7f596225936fd59c1fafd Mon Sep 17 00:00:00 2001 From: Nagarajan Natarajan <128729140+dctmfoo@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:12:47 +0530 Subject: [PATCH 1/2] fix(player): center portrait stage composition --- DESIGN.md | 4 +- StepBack.xcodeproj/project.pbxproj | 4 + .../Features/Player/PlayerSegmentView.swift | 56 +++++++----- .../Features/Player/PlayerStageView.swift | 10 ++- .../PlayerPortraitStageUITests.swift | 87 +++++++++++++++++++ design/ui-spec.html | 30 +++++++ docs/specs/player-portrait-stacked-stage.md | 2 +- 7 files changed, 169 insertions(+), 24 deletions(-) create mode 100644 StepBackUITests/PlayerPortraitStageUITests.swift diff --git a/DESIGN.md b/DESIGN.md index 9ee95ce..eddaaa7 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -105,7 +105,7 @@ across_the_room: # The signature ruleset. Applies to the pla media_readiness: # PRD §4.7 — v1 is name-only, layouts reserve media slots. component: "WorkoutVisual — the ONLY way any screen renders a workout's visual identity." placeholder: "Monogram tile: category-hue soft fill, category SF Symbol, concentric radius. Same tile family at every size." - slots: { gallery_card: "1:1", workout_detail: "4:3", builder_row: "1:1 small", stage: "4:3 region beside/above the countdown (landscape/portrait)" } + slots: { gallery_card: "1:1", workout_detail: "4:3", builder_row: "1:1 small", stage: "4:3 region beside/below the countdown (landscape/portrait)" } later: "Photos/video loops drop into the same slots via mediaKey; screens must not reflow when media arrives." haptics: @@ -283,7 +283,7 @@ size stays disciplined. All numerals tabular so durations align down every list. smart defaults (PRD §5.4). - **Stage — work segment:** countdown as the single hero, workout name below it, set indicator ("Set 2 of 3") and optional rep guidance in stage-dim text, the - `WorkoutVisual` region beside (landscape) or above (portrait) the countdown, and a + `WorkoutVisual` region beside (landscape) or below (portrait) the countdown, and a full-width thin progress bar with elapsed/remaining at the stage foot. Segment hue: Stage Work (progress ring/bar, accents — never a full-canvas flood). - **Stage — rest segment:** layout inverts — "Next: Squats" leads at title scale with diff --git a/StepBack.xcodeproj/project.pbxproj b/StepBack.xcodeproj/project.pbxproj index 39d5217..b893599 100644 --- a/StepBack.xcodeproj/project.pbxproj +++ b/StepBack.xcodeproj/project.pbxproj @@ -50,6 +50,7 @@ 3AB3FF32CFAFE0F9A6386FC3 /* WeeklyPlanDayRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A41E7831775D6BDA838142 /* WeeklyPlanDayRow.swift */; }; 3D064CDAB1BEEBF526690467 /* PlayerCompletionStatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B56F98A2C9DB4CE0DD9481BF /* PlayerCompletionStatsView.swift */; }; 3DA4AAF68931A61BC153D06D /* SystemSessionDateProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B92329DBD4E12D14139C54D4 /* SystemSessionDateProvider.swift */; }; + 3F2866F29DBF9143116730E4 /* PlayerPortraitStageUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 560DB6187B875E1110B0D515 /* PlayerPortraitStageUITests.swift */; }; 4135EAB3D4B7983DD39CDDB2 /* RoutineCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7A580CD4774BF44D2990F63 /* RoutineCard.swift */; }; 41A6B8C26A725FF6089E3CAF /* PlayerControlBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1E8D478F209612C88CD5FB1 /* PlayerControlBar.swift */; }; 427BE546700D208EF0F6580B /* InFlightSessionMarker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4637373527B6278F7CF4D210 /* InFlightSessionMarker.swift */; }; @@ -327,6 +328,7 @@ 558E2480503D9BEB9C013327 /* AgentBridgePluginContractTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentBridgePluginContractTests.swift; sourceTree = ""; }; 55C5668721400D86CCAAB5C3 /* ColorTokenContrastTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorTokenContrastTests.swift; sourceTree = ""; }; 55D9400C781F7121ACBD717E /* PlayerStageRoot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerStageRoot.swift; sourceTree = ""; }; + 560DB6187B875E1110B0D515 /* PlayerPortraitStageUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerPortraitStageUITests.swift; sourceTree = ""; }; 57B9BE25B096864518B7F539 /* PlayerAudioSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerAudioSession.swift; sourceTree = ""; }; 58E4C63295736CEA9B596B5E /* RoutineRestRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoutineRestRow.swift; sourceTree = ""; }; 5C4EFEB501E194C261920672 /* RoutineStepFormatting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoutineStepFormatting.swift; sourceTree = ""; }; @@ -506,6 +508,7 @@ 43811FFACD2386CBA0DBF601 /* StepBackUITests */ = { isa = PBXGroup; children = ( + 560DB6187B875E1110B0D515 /* PlayerPortraitStageUITests.swift */, EB8375CD5C14F6444B3E5A8C /* StepBackAccessibilityAuditTests.swift */, D32A7C81517DC2DA3448327C /* StepBackPerformanceTests.swift */, 44E1A1F153DD793B55B2AADA /* StepBackUITests.swift */, @@ -1168,6 +1171,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 3F2866F29DBF9143116730E4 /* PlayerPortraitStageUITests.swift in Sources */, 9C404245FE092AB302498912 /* StepBackAccessibilityAuditTests.swift in Sources */, 986C702369429BE81DB97403 /* StepBackPerformanceTests.swift in Sources */, 4A52183641FFDED9C276FD50 /* StepBackUITests.swift in Sources */, diff --git a/StepBack/Features/Player/PlayerSegmentView.swift b/StepBack/Features/Player/PlayerSegmentView.swift index 0442e4d..c6adc5e 100644 --- a/StepBack/Features/Player/PlayerSegmentView.swift +++ b/StepBack/Features/Player/PlayerSegmentView.swift @@ -7,6 +7,7 @@ struct PlayerSegmentView: View { let categoryName: String? let isWide: Bool let countdownSize: CGFloat + let stackedVisualHeight: CGFloat var body: some View { Group { @@ -14,13 +15,19 @@ struct PlayerSegmentView: View { HStack(spacing: 40) { segmentText visual + .frame(maxWidth: 360) } } else { - VStack(spacing: 16) { + VStack(spacing: 24) { segmentText visual - .frame(maxHeight: 180) + .frame( + maxWidth: stackedVisualHeight * 4 / 3, + idealHeight: stackedVisualHeight, + maxHeight: stackedVisualHeight + ) } + .frame(maxHeight: .infinity, alignment: .center) } } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -29,7 +36,7 @@ struct PlayerSegmentView: View { } private var segmentText: some View { - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: isWide ? .leading : .center, spacing: 8) { Text(kicker) .font(.headline.bold()) .textCase(.uppercase) @@ -42,29 +49,34 @@ struct PlayerSegmentView: View { .font(.largeTitle.bold()) .accessibilityIdentifier("player.next") countdown + supportingText } else { countdown Text(headline) .font(.largeTitle.bold()) .accessibilityIdentifier("player.name") + supportingText } + } + .multilineTextAlignment(isWide ? .leading : .center) + .frame(maxWidth: .infinity, alignment: isWide ? .leading : .center) + } - if let detail { - Text(detail) - .font(.title3) - .foregroundStyle(Color("StageTextDim")) - .accessibilityIdentifier("player.setIndicator") - } - - if model.showsNextDuringWork, - let next = model.currentSegment?.nextWorkoutNameSnapshot { - Text(L10n.playerNext(next)) - .font(.headline) - .foregroundStyle(Color("StageTextDim")) - .accessibilityIdentifier("player.next") - } + @ViewBuilder + private var supportingText: some View { + if let detail, isWide || !showsNextLine { + Text(detail) + .font(.title3) + .foregroundStyle(Color("StageTextDim")) + .accessibilityIdentifier("player.setIndicator") + } + if showsNextLine, + let next = model.currentSegment?.nextWorkoutNameSnapshot { + Text(L10n.playerNext(next)) + .font(.headline) + .foregroundStyle(Color("StageTextDim")) + .accessibilityIdentifier("player.next") } - .frame(maxWidth: .infinity, alignment: .leading) } private var countdown: some View { @@ -83,7 +95,6 @@ struct PlayerSegmentView: View { private var visual: some View { if let workout { WorkoutVisual(workout: workout, categoryName: categoryName, variant: .stage) - .frame(maxWidth: 360) } else if let step = model.currentSegment?.step { WorkoutVisual( workoutID: step.workoutID, @@ -91,7 +102,6 @@ struct PlayerSegmentView: View { categoryName: nil, variant: .stage ) - .frame(maxWidth: 360) } } @@ -104,6 +114,12 @@ struct PlayerSegmentView: View { return kind == .getReady || kind == .rest || kind == .setRest } + private var showsNextLine: Bool { + !leadsWithNext + && model.showsNextDuringWork + && model.currentSegment?.nextWorkoutNameSnapshot != nil + } + private var kicker: String { guard let segment = model.currentSegment else { return L10n.playerKickerWork } switch segment.kind { diff --git a/StepBack/Features/Player/PlayerStageView.swift b/StepBack/Features/Player/PlayerStageView.swift index 6ceb92e..891ccf9 100644 --- a/StepBack/Features/Player/PlayerStageView.swift +++ b/StepBack/Features/Player/PlayerStageView.swift @@ -18,7 +18,8 @@ struct PlayerStageView: View { workout: workout, categoryName: categoryName, isWide: isWide, - countdownSize: countdownSize(for: proxy.size, isWide: isWide) + countdownSize: countdownSize(for: proxy.size, isWide: isWide), + stackedVisualHeight: stackedVisualHeight(for: proxy.size) ) PlayerProgressFoot(model: model) PlayerControlBar(model: model, end: end) @@ -44,4 +45,11 @@ struct PlayerStageView: View { let proportion = isWide ? size.height * 0.27 : size.height * 0.17 return min(220, max(72, proportion)) } + + private func stackedVisualHeight(for size: CGSize) -> CGFloat { + let widthBound = max(0, size.width - 32) * 3 / 4 + let canvasTarget = max(180, size.height * 0.30) + let shortCanvasBound = size.height * 0.34 + return min(widthBound, canvasTarget, shortCanvasBound) + } } diff --git a/StepBackUITests/PlayerPortraitStageUITests.swift b/StepBackUITests/PlayerPortraitStageUITests.swift new file mode 100644 index 0000000..229207c --- /dev/null +++ b/StepBackUITests/PlayerPortraitStageUITests.swift @@ -0,0 +1,87 @@ +import XCTest + +@MainActor +final class PlayerPortraitStageUITests: XCTestCase { + func testPortraitPlayerKeepsWorkAndRestOnOneCenterAxis() { + XCUIDevice.shared.orientation = .portrait + addTeardownBlock { + XCUIDevice.shared.orientation = .portrait + } + + let app = XCUIApplication() + app.launchArguments.append("-StepBackUITesting") + app.launch() + + let play = app.buttons["Play Quick Start"] + XCTAssertTrue(play.waitForExistence(timeout: 3)) + play.tap() + + let skip = app.descendants(matching: .any)["player.skip"] + XCTAssertTrue(skip.waitForExistence(timeout: 2)) + advance(toKickerContaining: "work", using: skip, in: app) + + let countdown = app.descendants(matching: .any)["player.countdown"] + let name = app.descendants(matching: .any)["player.name"] + let visual = app.descendants(matching: .any)["player.visual.category"] + XCTAssertTrue(countdown.waitForExistence(timeout: 2)) + XCTAssertTrue(name.waitForExistence(timeout: 2)) + XCTAssertTrue(visual.waitForExistence(timeout: 2)) + XCTAssertTrue(app.descendants(matching: .any)["player.progress"].exists) + XCTAssertTrue(app.descendants(matching: .any)["player.playPause"].exists) + assertCentered([countdown, name, visual], in: app) + waitForLayoutToSettle() + attachScreenshot(named: "iPad portrait work", from: app) + + advance(toKickerContaining: "rest", using: skip, in: app) + let next = app.descendants(matching: .any)["player.next"] + XCTAssertTrue(next.waitForExistence(timeout: 2)) + assertCentered([next, countdown], in: app) + waitForLayoutToSettle() + attachScreenshot(named: "iPad portrait rest", from: app) + + XCUIDevice.shared.orientation = .landscapeLeft + XCTAssertTrue(app.descendants(matching: .any)["player.progress"].waitForExistence(timeout: 2)) + waitForLayoutToSettle() + attachScreenshot(named: "iPad landscape regression", from: app) + } + + private func advance( + toKickerContaining expected: String, + using skip: XCUIElement, + in app: XCUIApplication + ) { + let kicker = app.descendants(matching: .any)["player.kicker"] + for _ in 0..<6 { + if kicker.exists, + kicker.label.localizedCaseInsensitiveContains(expected) { + return + } + skip.tap() + RunLoop.current.run(until: Date().addingTimeInterval(0.3)) + } + XCTFail("Player did not reach the expected \(expected) segment") + } + + private func assertCentered(_ elements: [XCUIElement], in app: XCUIApplication) { + let stageMidX = app.windows.firstMatch.frame.midX + for element in elements { + XCTAssertEqual( + element.frame.midX, + stageMidX, + accuracy: 12, + "\(element.identifier) must share the portrait stage center axis" + ) + } + } + + private func attachScreenshot(named name: String, from app: XCUIApplication) { + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + } + + private func waitForLayoutToSettle() { + RunLoop.current.run(until: Date().addingTimeInterval(1.5)) + } +} diff --git a/design/ui-spec.html b/design/ui-spec.html index 61af4a4..d22d65e 100644 --- a/design/ui-spec.html +++ b/design/ui-spec.html @@ -100,6 +100,7 @@ box-shadow:0 26px 52px rgba(0,0,0,.5),0 0 0 1px rgba(255,255,255,.07)} .ipadframe.land{width:720px;height:548px} .ipadframe.stagefr{width:860px;height:632px} + .ipadframe.stageportrait{width:590px;height:780px} .ipadscr{width:100%;height:100%;border-radius:18px;overflow:hidden;position:relative; background:var(--ios-grouped);display:flex;flex-direction:column;font-size:13px;color:var(--ios-label)} .isbar{height:26px;flex:none;display:flex;justify-content:space-between;align-items:center;padding:8px 20px 0;font-size:11.5px;font-weight:600} @@ -1228,6 +1229,35 @@

Pre-roll — time to step back

Compact stage & completion

+
+
+
Morning Core9:41
+
+
+
Work · Set 2 of 2
+
0:19
+
Squats
+
Workout 2 of 5
+
+
+ + Legs & Glutes +
+
+
+
+
2:46−2:44
+
+
+
+
+
End
+
+
+
+
Work segment, iPad portrait. Every stacked stage shares one center axis: kicker → countdown → workout name → detail, then the 4:3 WorkoutVisual below. The visual consumes roughly 30% of the stage height, while the remaining height is deliberately distributed around the hero and visual bands instead of pooling into dead space. The countdown remains the single hero and landscape remains the two-column composition above.
+
+
diff --git a/docs/specs/player-portrait-stacked-stage.md b/docs/specs/player-portrait-stacked-stage.md index e8e3674..3e525d2 100644 --- a/docs/specs/player-portrait-stacked-stage.md +++ b/docs/specs/player-portrait-stacked-stage.md @@ -1,6 +1,6 @@ # Spec: Portrait (stacked) stage composition — centered hero stack -**Status:** Ready +**Status:** Implemented **Owner screens:** - `StepBack/Features/Player/PlayerSegmentView.swift` (stacked-branch layout, visual sizing) - `StepBack/Features/Player/PlayerStageView.swift` (stage vertical budget, stacked padding/spacers) From 28506f16a99afedbd13a947bfc55659e95f06a1a Mon Sep 17 00:00:00 2001 From: Nagarajan Natarajan <128729140+dctmfoo@users.noreply.github.com> Date: Mon, 13 Jul 2026 21:39:27 +0530 Subject: [PATCH 2/2] fix(player): finish portrait stage contract Keep the stacked hero and visual in separately budgeted bands while preserving final-five stability, accessibility sizing, and the wide player composition. Add focused geometry and long-name coverage for the verified contract. --- StepBack.xcodeproj/project.pbxproj | 6 + .../Features/Player/PlayerSegmentView.swift | 76 +++-- .../Player/PlayerStackedSegmentLayout.swift | 93 +++++++ .../Features/Player/PlayerStageView.swift | 3 +- StepBack/Features/Shared/WorkoutVisual.swift | 41 ++- StepBack/Persistence/StepBackBootstrap.swift | 9 + .../PlayerPortraitStageUITests.swift | 262 +++++++++++++++++- docs/specs/player-portrait-stacked-stage.md | 1 + 8 files changed, 452 insertions(+), 39 deletions(-) create mode 100644 StepBack/Features/Player/PlayerStackedSegmentLayout.swift diff --git a/StepBack.xcodeproj/project.pbxproj b/StepBack.xcodeproj/project.pbxproj index 96f68b4..3dbc0a1 100644 --- a/StepBack.xcodeproj/project.pbxproj +++ b/StepBack.xcodeproj/project.pbxproj @@ -84,6 +84,7 @@ 5F7F2EAAFFC2BA46D17FFCC2 /* RoutineBuilderFloatingBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 175EB9F56FCBC93BF2302C37 /* RoutineBuilderFloatingBar.swift */; }; 5FBBEA43681D7211F77A3AF9 /* ColorTokenContrastTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55C5668721400D86CCAAB5C3 /* ColorTokenContrastTests.swift */; }; 5FEAE92B1C411CF30D2A8DF7 /* PlayerCompletionStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40EBEB35653C1EDB4E93348D /* PlayerCompletionStats.swift */; }; + 60B5FB971CC6B4AD920D1868 /* PlayerStackedSegmentLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9DF4B0DB49662E2C43A9897 /* PlayerStackedSegmentLayout.swift */; }; 622439D35549A8FC40785285 /* AppSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B8A3978327F3430A2D8FC6E /* AppSection.swift */; }; 63017C363437110DD559D449 /* PlayerPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14F59D68FFF5527BFBFB69F5 /* PlayerPreferences.swift */; }; 6514B31863DE0FB5BEF39A16 /* TabAppShellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11A9CDAF4C9E27EC92D58B4 /* TabAppShellView.swift */; }; @@ -229,6 +230,7 @@ EE819960E7839288609E78AC /* PlayerCompletionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C186CD3F3272BED3DCAFBAA /* PlayerCompletionView.swift */; }; EF2DDE5B930ACBAD0D34AEDD /* AgentBridgeProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3578C8D6CD1D732AF4920D5B /* AgentBridgeProtocol.swift */; }; F0A5AC9FAD61399C0650D3EF /* PlayerPartialCompletionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EF1AA0876E78612C1103F65 /* PlayerPartialCompletionView.swift */; }; + F0C0ACEA9BC0A4124BA79A6F /* PlayerStackedSegmentLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9DF4B0DB49662E2C43A9897 /* PlayerStackedSegmentLayout.swift */; }; F12BC9CC65987D2581FB581D /* RoutineDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 536C0807F3424D56760C38F9 /* RoutineDetailView.swift */; }; F197D90DEC3D7AFF8A3EC668 /* AppShellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62F12B3CC4D8E8CE7CA0D292 /* AppShellView.swift */; }; F247684BCD2C70CCFB38F1AB /* CloudAccountStatusModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFC1E7F1C2BA1E4253991224 /* CloudAccountStatusModel.swift */; }; @@ -399,6 +401,7 @@ D32A7C81517DC2DA3448327C /* StepBackPerformanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepBackPerformanceTests.swift; sourceTree = ""; }; D3E5DF2B9320E1A4302BDF44 /* CustomWorkoutEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomWorkoutEditor.swift; sourceTree = ""; }; D421CAA01BED6DBD5BDDB5BA /* ShapeRadius.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShapeRadius.swift; sourceTree = ""; }; + D9DF4B0DB49662E2C43A9897 /* PlayerStackedSegmentLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerStackedSegmentLayout.swift; sourceTree = ""; }; DE816E5057C6DF4B2DBBD9AA /* RoutinesHomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoutinesHomeView.swift; sourceTree = ""; }; DEC57F161E6C05CC443CEF78 /* ProductionCatalogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductionCatalogTests.swift; sourceTree = ""; }; DF883D65838AAB64A21649A2 /* WorkoutVisual.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutVisual.swift; sourceTree = ""; }; @@ -668,6 +671,7 @@ 677AEEA10EF3C0DCB0D63682 /* PlayerSegmentView.swift */, EE42F7940F0735DFFB0AB338 /* PlayerSessionModel.swift */, F840B9BDB99F4E49A3516A4B /* PlayerSignposting.swift */, + D9DF4B0DB49662E2C43A9897 /* PlayerStackedSegmentLayout.swift */, E175A54019E51246C0BFDB73 /* PlayerStageColors.swift */, 55D9400C781F7121ACBD717E /* PlayerStageRoot.swift */, 8E923FAEAC372A7927806372 /* PlayerStageView.swift */, @@ -977,6 +981,7 @@ 744777CBE10EF7161E12C7FE /* PlayerSessionModel.swift in Sources */, 990534B86F1BA0E974197C6A /* PlayerSessionRecorder.swift in Sources */, F2B2BB545DF6F0182557B6E4 /* PlayerSignposting.swift in Sources */, + 60B5FB971CC6B4AD920D1868 /* PlayerStackedSegmentLayout.swift in Sources */, 86554C2F5C8C3E9AF225B403 /* PlayerStageColors.swift in Sources */, 9F710B6EF41986B53FDAD052 /* PlayerStageRoot.swift in Sources */, 88A64D7A0A6EC3A2DFFA803D /* PlayerStageView.swift in Sources */, @@ -1089,6 +1094,7 @@ D82422433D3E5D1D8ADA5F1B /* PlayerSessionModel.swift in Sources */, 44C32D69C00959EC75C75DFC /* PlayerSessionRecorder.swift in Sources */, 1BA4EE8DFFFD59A2C57E3B93 /* PlayerSignposting.swift in Sources */, + F0C0ACEA9BC0A4124BA79A6F /* PlayerStackedSegmentLayout.swift in Sources */, E2F68EADEBFEE76839233AF5 /* PlayerStageColors.swift in Sources */, E4F1E65F04FD5DAA818EBF62 /* PlayerStageRoot.swift in Sources */, 2B4B238EC57B95998E1CC80D /* PlayerStageView.swift in Sources */, diff --git a/StepBack/Features/Player/PlayerSegmentView.swift b/StepBack/Features/Player/PlayerSegmentView.swift index c6adc5e..1f2284b 100644 --- a/StepBack/Features/Player/PlayerSegmentView.swift +++ b/StepBack/Features/Player/PlayerSegmentView.swift @@ -2,6 +2,7 @@ import StepBackCore import SwiftUI struct PlayerSegmentView: View { + @Environment(\.dynamicTypeSize) private var dynamicTypeSize let model: PlayerSessionModel let workout: WorkoutItem? let categoryName: String? @@ -18,7 +19,7 @@ struct PlayerSegmentView: View { .frame(maxWidth: 360) } } else { - VStack(spacing: 24) { + PlayerStackedSegmentLayout(visualHeight: stackedVisualHeight) { segmentText visual .frame( @@ -27,7 +28,7 @@ struct PlayerSegmentView: View { maxHeight: stackedVisualHeight ) } - .frame(maxHeight: .infinity, alignment: .center) + .frame(maxWidth: .infinity, maxHeight: .infinity) } } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -35,7 +36,18 @@ struct PlayerSegmentView: View { .transition(.opacity.combined(with: .scale(scale: 0.98))) } + @ViewBuilder private var segmentText: some View { + if isWide { + segmentText(countdownSize: countdownSize) + } else { + segmentText( + countdownSize: dynamicTypeSize.isAccessibilitySize ? 44 : countdownSize + ) + } + } + + private func segmentText(countdownSize: CGFloat) -> some View { VStack(alignment: isWide ? .leading : .center, spacing: 8) { Text(kicker) .font(.headline.bold()) @@ -47,13 +59,17 @@ struct PlayerSegmentView: View { if leadsWithNext { Text(headline) .font(.largeTitle.bold()) + .lineLimit(isWide ? nil : 2) + .fixedSize(horizontal: false, vertical: true) .accessibilityIdentifier("player.next") - countdown + countdown(size: countdownSize) supportingText } else { - countdown + countdown(size: countdownSize) Text(headline) .font(.largeTitle.bold()) + .lineLimit(isWide ? nil : 2) + .fixedSize(horizontal: false, vertical: true) .accessibilityIdentifier("player.name") supportingText } @@ -64,24 +80,49 @@ struct PlayerSegmentView: View { @ViewBuilder private var supportingText: some View { - if let detail, isWide || !showsNextLine { + if let detail { Text(detail) .font(.title3) .foregroundStyle(Color("StageTextDim")) .accessibilityIdentifier("player.setIndicator") } - if showsNextLine, - let next = model.currentSegment?.nextWorkoutNameSnapshot { - Text(L10n.playerNext(next)) - .font(.headline) - .foregroundStyle(Color("StageTextDim")) - .accessibilityIdentifier("player.next") + if let workNextName { + workNextUp(workNextName) + } + } + + @ViewBuilder + private func workNextUp(_ next: String) -> some View { + if isWide { + if showsNextLine { + nextUpText(next) + } + } else { + nextUpText( + next, + accessibilityIdentifier: showsNextLine ? "player.next" : "" + ) + .opacity(showsNextLine ? 1 : 0) + .accessibilityHidden(!showsNextLine) + .animation(.easeInOut(duration: 0.2), value: showsNextLine) } } - private var countdown: some View { + private func nextUpText( + _ next: String, + accessibilityIdentifier: String = "player.next" + ) -> some View { + Text(L10n.playerNext(next)) + .font(.headline) + .lineLimit(isWide ? nil : 2) + .fixedSize(horizontal: false, vertical: true) + .foregroundStyle(Color("StageTextDim")) + .accessibilityIdentifier(accessibilityIdentifier) + } + + private func countdown(size: CGFloat) -> some View { Text(DisplayFormatters.stageDuration(displayedSeconds)) - .font(.system(size: countdownSize, weight: .heavy, design: .rounded)) + .font(.system(size: size, weight: .heavy, design: .rounded)) .monospacedDigit() .contentTransition(.numericText()) .lineLimit(1) @@ -115,9 +156,12 @@ struct PlayerSegmentView: View { } private var showsNextLine: Bool { - !leadsWithNext - && model.showsNextDuringWork - && model.currentSegment?.nextWorkoutNameSnapshot != nil + workNextName != nil && model.showsNextDuringWork + } + + private var workNextName: String? { + guard !leadsWithNext else { return nil } + return model.currentSegment?.nextWorkoutNameSnapshot } private var kicker: String { diff --git a/StepBack/Features/Player/PlayerStackedSegmentLayout.swift b/StepBack/Features/Player/PlayerStackedSegmentLayout.swift new file mode 100644 index 0000000..de8f046 --- /dev/null +++ b/StepBack/Features/Player/PlayerStackedSegmentLayout.swift @@ -0,0 +1,93 @@ +import SwiftUI + +struct PlayerStackedSegmentLayout: Layout { + let visualHeight: CGFloat + private let bandSpacing: CGFloat = 24 + private let footClearance: CGFloat = 20 + + static var layoutProperties: LayoutProperties { + var properties = LayoutProperties() + properties.stackOrientation = .vertical + return properties + } + + func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) -> CGSize { + guard subviews.count == 2 else { + return proposal.replacingUnspecifiedDimensions() + } + + let heroSize = subviews[0].sizeThatFits( + ProposedViewSize(width: proposal.width, height: nil) + ) + let visualSize = subviews[1].sizeThatFits( + visualProposal(maxWidth: proposal.width, height: visualHeight) + ) + return CGSize( + width: proposal.width ?? max(heroSize.width, visualSize.width), + height: proposal.height ?? heroSize.height + visualSize.height + bandSpacing + ) + } + + func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) { + guard subviews.count == 2 else { return } + + let usableHeight = max(0, bounds.height - footClearance) + let idealHeroSize = subviews[0].sizeThatFits( + ProposedViewSize(width: bounds.width, height: nil) + ) + let availableVisualHeight = max( + 0, + usableHeight - idealHeroSize.height - bandSpacing + ) + let placedVisualHeight = min(visualHeight, availableVisualHeight) + let heroHeightLimit = max( + 0, + usableHeight - placedVisualHeight - bandSpacing + ) + let heroSize = subviews[0].sizeThatFits( + ProposedViewSize(width: bounds.width, height: heroHeightLimit) + ) + let visualProposal = visualProposal( + maxWidth: bounds.width, + height: placedVisualHeight + ) + let visualSize = subviews[1].sizeThatFits(visualProposal) + + let remainingHeight = max( + 0, + usableHeight - heroSize.height - visualSize.height - bandSpacing + ) + let outerBreathingSpace = remainingHeight / 4 + let bandGap = bandSpacing + remainingHeight / 2 + let centerX = bounds.midX + let heroY = bounds.minY + outerBreathingSpace + let visualY = heroY + heroSize.height + bandGap + + subviews[0].place( + at: CGPoint(x: centerX, y: heroY), + anchor: .top, + proposal: ProposedViewSize(width: bounds.width, height: heroSize.height) + ) + subviews[1].place( + at: CGPoint(x: centerX, y: visualY), + anchor: .top, + proposal: visualProposal + ) + } + + private func visualProposal(maxWidth: CGFloat?, height: CGFloat) -> ProposedViewSize { + ProposedViewSize( + width: min(maxWidth ?? height * 4 / 3, height * 4 / 3), + height: height + ) + } +} diff --git a/StepBack/Features/Player/PlayerStageView.swift b/StepBack/Features/Player/PlayerStageView.swift index 891ccf9..5020a36 100644 --- a/StepBack/Features/Player/PlayerStageView.swift +++ b/StepBack/Features/Player/PlayerStageView.swift @@ -47,9 +47,8 @@ struct PlayerStageView: View { } private func stackedVisualHeight(for size: CGSize) -> CGFloat { - let widthBound = max(0, size.width - 32) * 3 / 4 let canvasTarget = max(180, size.height * 0.30) let shortCanvasBound = size.height * 0.34 - return min(widthBound, canvasTarget, shortCanvasBound) + return min(canvasTarget, shortCanvasBound) } } diff --git a/StepBack/Features/Shared/WorkoutVisual.swift b/StepBack/Features/Shared/WorkoutVisual.swift index f0f2ce1..b8c7e9c 100644 --- a/StepBack/Features/Shared/WorkoutVisual.swift +++ b/StepBack/Features/Shared/WorkoutVisual.swift @@ -1,6 +1,8 @@ import SwiftUI struct WorkoutVisual: View { + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + enum Variant { case smallRow case galleryCard @@ -43,26 +45,47 @@ struct WorkoutVisual: View { var body: some View { let style = CategoryStyle.resolve(categoryID) + Color.clear + .frame(maxWidth: .infinity, maxHeight: .infinity) + .aspectRatio(aspectRatio, contentMode: .fit) + .background { + background(style: style) + } + .accessibilityElement(children: .ignore) + .accessibilityIdentifier(variant == .stage ? "player.visual.category" : "") + .accessibilityHidden(true) + .overlay { + visualContent(style: style) + } + .clipped() + } + + @ViewBuilder + private func visualContent(style: CategoryStyle) -> some View { + if variant == .stage { + visualLabel(style: style) + .accessibilityHidden(true) + } else { + visualLabel(style: style) + } + } + + private func visualLabel(style: CategoryStyle) -> some View { VStack(spacing: 8) { Image(systemName: style.symbolName) .font(symbolFont) .foregroundStyle(style.color) - .accessibilityHidden(true) - if showsCategoryName, let categoryName { + if showsCategoryName, + !(variant == .stage && dynamicTypeSize.isAccessibilitySize), + let categoryName { Text(categoryName.uppercased()) .font(.caption.bold()) .tracking(1) .foregroundStyle(.primary) .multilineTextAlignment(.center) - .accessibilityIdentifier(variant == .stage ? "player.visual.category" : "") } } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .aspectRatio(aspectRatio, contentMode: .fit) - .background { - background(style: style) - } - .accessibilityHidden(true) + .padding(8) } private var symbolFont: Font { diff --git a/StepBack/Persistence/StepBackBootstrap.swift b/StepBack/Persistence/StepBackBootstrap.swift index 8b58720..da222b6 100644 --- a/StepBack/Persistence/StepBackBootstrap.swift +++ b/StepBack/Persistence/StepBackBootstrap.swift @@ -47,6 +47,15 @@ final class StepBackBootstrap { catalogService: catalog, flagStore: resolvedFlagStore ) + if isRunningTests, + ProcessInfo.processInfo.environment["StepBackUILongWorkoutNameFixture"] == "1", + let routine = try container.mainContext.fetch(FetchDescriptor()) + .first(where: { $0.seedIdentifier == "starter.quick-start" }), + let firstStep = routine.steps?.min(by: { $0.sortIndex < $1.sortIndex }) { + firstStep.workoutNameSnapshot = + "Alternating Single Leg Squat With Overhead Reach And Controlled Rotation" + try container.mainContext.saveOrRollback() + } if isRunningTests, ProcessInfo.processInfo.environment["StepBackUIRemovedPlanFixture"] == "1" { let weekday = Calendar.autoupdatingCurrent.component(.weekday, from: .now) diff --git a/StepBackUITests/PlayerPortraitStageUITests.swift b/StepBackUITests/PlayerPortraitStageUITests.swift index 229207c..0f6d625 100644 --- a/StepBackUITests/PlayerPortraitStageUITests.swift +++ b/StepBackUITests/PlayerPortraitStageUITests.swift @@ -13,6 +13,9 @@ final class PlayerPortraitStageUITests: XCTestCase { app.launch() let play = app.buttons["Play Quick Start"] + if !play.exists { + app.swipeDown() + } XCTAssertTrue(play.waitForExistence(timeout: 3)) play.tap() @@ -23,26 +26,159 @@ final class PlayerPortraitStageUITests: XCTestCase { let countdown = app.descendants(matching: .any)["player.countdown"] let name = app.descendants(matching: .any)["player.name"] let visual = app.descendants(matching: .any)["player.visual.category"] + let kicker = app.descendants(matching: .any)["player.kicker"] + let detail = app.descendants(matching: .any)["player.setIndicator"] + let controls = controlElements(in: app) XCTAssertTrue(countdown.waitForExistence(timeout: 2)) XCTAssertTrue(name.waitForExistence(timeout: 2)) XCTAssertTrue(visual.waitForExistence(timeout: 2)) XCTAssertTrue(app.descendants(matching: .any)["player.progress"].exists) - XCTAssertTrue(app.descendants(matching: .any)["player.playPause"].exists) - assertCentered([countdown, name, visual], in: app) - waitForLayoutToSettle() + XCTAssertTrue(controls.allSatisfy(\.exists)) + XCTAssertTrue(detail.exists) + waitForStableLayout([kicker, countdown, name, detail, visual] + controls) + assertCentered([kicker, countdown, name, detail, visual], in: app) + assertPortraitVisualBudget(visual, in: app) + + let countdownFrameBeforeNext = countdown.frame + let visualFrameBeforeNext = visual.frame + let nextDuringWork = app.descendants(matching: .any)["player.next"] + XCTAssertFalse(nextDuringWork.exists) + XCTAssertTrue( + nextDuringWork.waitForExistence(timeout: 60), + "Next-up must appear during the final five seconds of work" + ) + let playPause = app.descendants(matching: .any)["player.playPause"] + playPause.tap() + let paused = NSPredicate { object, _ in + guard let element = object as? XCUIElement else { return false } + return element.exists && element.label.localizedCaseInsensitiveContains("resume") + } + XCTAssertEqual( + XCTWaiter.wait( + for: [XCTNSPredicateExpectation(predicate: paused, object: playPause)], + timeout: 2 + ), + .completed, + "The final-five work state must be frozen before rotation checks" + ) + waitForStableLayout([countdown, visual, nextDuringWork] + controls) + XCTAssertTrue(detail.exists, "Work detail must remain visible beside final-five next-up") + assertStablePosition( + countdown, + from: countdownFrameBeforeNext, + accuracy: 2, + message: "Countdown must not move when final-five next-up appears" + ) + assertStablePosition( + visual, + from: visualFrameBeforeNext, + accuracy: 2, + message: "Workout visual must not move when final-five next-up appears" + ) + assertWithinStage( + [kicker, countdown, name, detail, nextDuringWork, visual] + controls, + in: app + ) + XCTAssertGreaterThanOrEqual( + visual.frame.minY - nextDuringWork.frame.maxY, + 20, + "The visual must remain a separately budgeted band below the hero" + ) attachScreenshot(named: "iPad portrait work", from: app) + let portraitFrame = app.windows.firstMatch.frame + let runsIPadLandscapeRegression = portraitFrame.width >= 700 + if runsIPadLandscapeRegression { + XCUIDevice.shared.orientation = .landscapeLeft + waitForWindow(in: app, toBeLandscape: true, differingFrom: portraitFrame) + waitForStableLayout([countdown, name, detail, nextDuringWork, visual] + controls) + assertWideComposition( + hero: [countdown, name, detail, nextDuringWork], + visual: visual + ) + assertWithinStage( + [countdown, name, detail, nextDuringWork, visual] + controls, + in: app + ) + attachScreenshot(named: "iPad landscape work regression", from: app) + + let landscapeFrame = app.windows.firstMatch.frame + XCUIDevice.shared.orientation = .portrait + waitForWindow(in: app, toBeLandscape: false, differingFrom: landscapeFrame) + waitForStableLayout([countdown, name, detail, nextDuringWork, visual] + controls) + } + advance(toKickerContaining: "rest", using: skip, in: app) let next = app.descendants(matching: .any)["player.next"] XCTAssertTrue(next.waitForExistence(timeout: 2)) - assertCentered([next, countdown], in: app) - waitForLayoutToSettle() + waitForStableLayout([kicker, next, countdown, detail, visual] + controls) + assertCentered([kicker, next, countdown, detail, visual], in: app) + assertPortraitVisualBudget(visual, in: app) + assertWithinStage([kicker, next, countdown, detail, visual] + controls, in: app) attachScreenshot(named: "iPad portrait rest", from: app) - XCUIDevice.shared.orientation = .landscapeLeft - XCTAssertTrue(app.descendants(matching: .any)["player.progress"].waitForExistence(timeout: 2)) - waitForLayoutToSettle() - attachScreenshot(named: "iPad landscape regression", from: app) + if runsIPadLandscapeRegression { + let restPortraitFrame = app.windows.firstMatch.frame + XCUIDevice.shared.orientation = .landscapeLeft + waitForWindow(in: app, toBeLandscape: true, differingFrom: restPortraitFrame) + waitForStableLayout([next, countdown, detail, visual] + controls) + assertWideComposition(hero: [next, countdown, detail], visual: visual) + assertWithinStage( + [next, countdown, detail, visual] + controls, + in: app + ) + attachScreenshot(named: "iPad landscape regression", from: app) + } + } + + func testStackedPlayerYieldsForAccessibilityText() { + XCUIDevice.shared.orientation = .portrait + addTeardownBlock { + XCUIDevice.shared.orientation = .portrait + } + + let app = XCUIApplication() + app.launchArguments += [ + "-StepBackUITesting", + "-UIPreferredContentSizeCategoryName", + "UICTContentSizeCategoryAccessibilityExtraExtraExtraLarge", + ] + app.launchEnvironment["StepBackUIAccessibilityXXXL"] = "1" + app.launchEnvironment["StepBackUILongWorkoutNameFixture"] = "1" + app.launch() + + let play = app.buttons["Play Quick Start"] + if !play.exists { + app.swipeDown() + } + XCTAssertTrue(play.waitForExistence(timeout: 3)) + play.tap() + + let skip = app.descendants(matching: .any)["player.skip"] + XCTAssertTrue(skip.waitForExistence(timeout: 2)) + advance(toKickerContaining: "work", using: skip, in: app) + + let kicker = app.descendants(matching: .any)["player.kicker"] + let countdown = app.descendants(matching: .any)["player.countdown"] + let name = app.descendants(matching: .any)["player.name"] + let detail = app.descendants(matching: .any)["player.setIndicator"] + let visual = app.descendants(matching: .any)["player.visual.category"] + let progress = app.descendants(matching: .any)["player.progress"] + let controls = controlElements(in: app) + XCTAssertTrue(name.waitForExistence(timeout: 2)) + XCTAssertTrue(name.label.contains("Alternating Single Leg Squat")) + waitForStableLayout([kicker, countdown, name, detail, visual, progress] + controls) + assertCentered([kicker, countdown, name, detail, visual], in: app) + assertWithinStage( + [kicker, countdown, name, detail, visual, progress] + controls, + in: app + ) + XCTAssertLessThanOrEqual( + visual.frame.maxY + 12, + progress.frame.minY, + "The compressed visual must remain above progress; visual=\(visual.frame), progress=\(progress.frame)" + ) + attachScreenshot(named: "Stacked player AX-XXXL long name", from: app) } private func advance( @@ -56,8 +192,14 @@ final class PlayerPortraitStageUITests: XCTestCase { kicker.label.localizedCaseInsensitiveContains(expected) { return } + let previousLabel = kicker.exists ? kicker.label : "" skip.tap() - RunLoop.current.run(until: Date().addingTimeInterval(0.3)) + let changed = NSPredicate { object, _ in + guard let element = object as? XCUIElement else { return false } + return element.exists && element.label != previousLabel + } + let expectation = XCTNSPredicateExpectation(predicate: changed, object: kicker) + _ = XCTWaiter.wait(for: [expectation], timeout: 2) } XCTFail("Player did not reach the expected \(expected) segment") } @@ -74,6 +216,83 @@ final class PlayerPortraitStageUITests: XCTestCase { } } + private func assertStablePosition( + _ element: XCUIElement, + from originalFrame: CGRect, + accuracy: CGFloat, + message: String + ) { + XCTAssertEqual(element.frame.midX, originalFrame.midX, accuracy: accuracy, message) + XCTAssertEqual(element.frame.minY, originalFrame.minY, accuracy: accuracy, message) + } + + private func assertPortraitVisualBudget(_ visual: XCUIElement, in app: XCUIApplication) { + XCTAssertEqual( + visual.frame.width / visual.frame.height, + 4 / 3, + accuracy: 0.05, + "The stacked WorkoutVisual must preserve its 4:3 region" + ) + let heightFraction = visual.frame.height / app.windows.firstMatch.frame.height + XCTAssertTrue( + (0.24...0.36).contains(heightFraction), + "The stacked WorkoutVisual must consume roughly 30% of the stage height" + ) + } + + private func assertWideComposition(hero: [XCUIElement], visual: XCUIElement) { + for element in hero { + XCTAssertLessThan( + element.frame.midX, + visual.frame.midX, + "\(element.identifier) must remain in the leading landscape column" + ) + } + XCTAssertGreaterThan( + visual.frame.midX - hero.map(\.frame.midX).max()!, + 120, + "Landscape must retain visibly separate hero and visual columns" + ) + } + + private func controlElements(in app: XCUIApplication) -> [XCUIElement] { + ["player.back", "player.playPause", "player.skip", "player.end"].map { + app.descendants(matching: .any)[$0] + } + } + + private func waitForWindow( + in app: XCUIApplication, + toBeLandscape: Bool, + differingFrom originalFrame: CGRect, + timeout: TimeInterval = 3 + ) { + let window = app.windows.firstMatch + let deadline = Date().addingTimeInterval(timeout) + repeat { + let frame = window.frame + let matchesOrientation = toBeLandscape + ? frame.width > frame.height + : frame.height > frame.width + if matchesOrientation && frame != originalFrame { return } + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } while Date() < deadline + + XCTFail("Player window did not settle into the expected orientation") + } + + private func assertWithinStage(_ elements: [XCUIElement], in app: XCUIApplication) { + let stageFrame = app.windows.firstMatch.frame + for element in elements { + XCTAssertTrue(element.exists, "\(element.identifier) must exist") + XCTAssertFalse(element.frame.isEmpty, "\(element.identifier) must have a visible frame") + XCTAssertTrue( + stageFrame.insetBy(dx: -2, dy: -2).contains(element.frame), + "\(element.identifier) must remain inside the stage" + ) + } + } + private func attachScreenshot(named name: String, from app: XCUIApplication) { let attachment = XCTAttachment(screenshot: app.screenshot()) attachment.name = name @@ -81,7 +300,26 @@ final class PlayerPortraitStageUITests: XCTestCase { add(attachment) } - private func waitForLayoutToSettle() { - RunLoop.current.run(until: Date().addingTimeInterval(1.5)) + private func waitForStableLayout( + _ elements: [XCUIElement], + timeout: TimeInterval = 2 + ) { + let deadline = Date().addingTimeInterval(timeout) + var previousFrames: [CGRect]? + var stableSamples = 0 + + repeat { + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + let frames = elements.map(\.frame) + if frames == previousFrames { + stableSamples += 1 + if stableSamples == 2 { return } + } else { + stableSamples = 0 + previousFrames = frames + } + } while Date() < deadline + + XCTFail("Player layout did not settle within \(timeout) seconds") } } diff --git a/docs/specs/player-portrait-stacked-stage.md b/docs/specs/player-portrait-stacked-stage.md index 3e525d2..94ace3a 100644 --- a/docs/specs/player-portrait-stacked-stage.md +++ b/docs/specs/player-portrait-stacked-stage.md @@ -3,6 +3,7 @@ **Status:** Implemented **Owner screens:** - `StepBack/Features/Player/PlayerSegmentView.swift` (stacked-branch layout, visual sizing) +- `StepBack/Features/Player/PlayerStackedSegmentLayout.swift` (proportional hero/visual band allocation) - `StepBack/Features/Player/PlayerStageView.swift` (stage vertical budget, stacked padding/spacers) - `design/ui-spec.html` §player (new iPad-portrait work-segment frame)