Redesign the monitor screen around the viewfinder - #191
Merged
Conversation
A 1-star review put it plainly: on the remote the preview is tiny while screen space goes to a clumsy arrangement of controls. Measured on an iPhone 16 in portrait against a 16:9 camera, the image was 26% of the screen and the opaque control panel 44%. Invert the layout: the preview is full-bleed under floating chrome instead of stacked above an opaque panel. Portrait is width-limited so the image barely grows there, but the controls no longer sit on top of a fixed budget of pixels, and landscape now reaches 82% -- which is why rotation is unlocked and the nav bar hidden. The action cluster docks to a trailing rail when the view is wider than it is tall (MonitorChromeLayout.dock), a rule keyed off the view's shape rather than size class: an iPhone in landscape is compact width on every non-Max phone, so a size-class rule would bottom-dock the exact case this screen exists to serve. Two real bugs surfaced while measuring, both fixed at the root: Every remote command raised a modal UIAlertController spinner over the live preview. Activity is now derived from session state at the single transition(to:) choke point (MonitorActivity.forState) and rendered on the shutter, mirroring the idiom PeerLinkStatus already documents: an indicator that is a function of the state cannot outlive the thing it describes. The show/dismiss pairs a pushed indicator needs are exactly what stranded those spinners. Removes 6 alert sites and 24 dead dismissCameraAlert() calls. TakePicture gains a CapturePhase so "requesting" and "receiving" are distinguishable without an alert title; the phase swap keeps the same generation, so the armed watchdog carries over. A stalled stream was silent -- StreamStalled re-requested a frame with nothing on screen to say the picture was frozen. Adds a link chip and desaturates the stale frame. The self-timer and quality controls move into a tray behind one tap; each tile carries its own value, which is what lets them stop occupying a permanent row. Zoom pill: cluster the lens stops instead of spacing them by position on the zoom track (the gaps grew with the camera's range), and let the capsule shrink to fit when collapsed rather than holding 268pt for three buttons. The drag becomes relative to where it started -- absolute mapping assumed a fixed 240pt width and would read the first event in the wrong coordinate space now that the collapsed pill is narrower. Layout, tray composition, timer detents, link state and activity are pure functions in MonitorChrome.swift so they are testable without a window. 621 tests pass; adds snapshot coverage for landscape, capture in flight, countdown, and the tray (which had none -- MonitorView owns isTrayOpen privately, so no test could reach those tiles). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The camera device can now stop driving its local live preview to save battery/heat on long tripod shoots, while the capture session keeps running and preview frames keep streaming to the monitor unchanged. - CameraPreviewMode (.on default / .standby) persisted per camera phone in UserDefaults (CameraPreviewModeStore); survives relaunch. - Set three ways, one preference: local camera chrome (standby button + tap-to-restore status screen), a new capability-gated RemoteCmd (SetCameraPreviewMode, wire action 24), and reflected on the monitor. - Wire: CameraPreviewModeEnum + supports_preview_mode capability + preview_mode in CameraState; gate mirrors FocusAtPoint (loopback pinned). Camera reports its mode back via CameraPreviewModeResp and in capabilities. - Standby is local-display only: SessionCoordinator/CameraRig touch only the view model, never the FrameSender/streaming path. Pinned by a loopback test that streams a frame to the monitor while in standby. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts: # RemoteCam/MonitorView.swift # RemoteCam/SessionCoordinator.swift
The merge put MonitorTrayItem.cameraStandby in front of an exhaustive switch that predated it. Standby is glyph-only -- its state reads off the filled/unfilled moon, not a value label -- so it joins the nil group. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Contributor
📊 Test Coverage Report✅ 626/638 tests passed · ⏭️ 12 skipped
🎥 Capture & session core
📁 Full report — 87 files
Generated from |
Standby swapped `liveContent` out of the view tree for the status screen. `liveContent` owns `CameraPreviewView`, whose backing layer IS the AVCaptureVideoPreviewLayer holding a reference to the running capture session -- so the swap dismantled the UIView and mutated a live capture graph as a side effect of a view change. Frame delivery to the monitor stopped outright. It came back only when CameraRig's first-frame watchdog fired 5s later, fell the camera over to another device, and bounced the session -- so recovery could also leave you on a different camera than the one you framed with. Keep `liveContent` mounted in every mode and draw the standby screen over it. The capture graph is never touched by a view change now. Reproduced on two real devices; the simulator suite stayed green throughout, including the loopback test that claimed to pin this. That test calls FrameSender.send directly, so it only ever covered the transport -- the producer chain (AVCaptureVideoDataOutput -> CaptureEngine -> FrameStreamingCoordinator) was bypassed entirely. Renamed it to say what it actually checks and documented the gap; producing frames needs real capture hardware, so that belongs in CaptureIntegrationTests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The monitor showed two Back buttons: the nav bar's "Disconnect" and the viewfinder's floating chevron. `viewWillAppear` hid the bar, then the coordinator dropped the monitor into photo mode and `swiftUIConfigurePhotoMode` showed it again. Nav-bar visibility was set in four places keyed to capture mode -- photo, video and shorts showed it, recording hid it -- left from the design where the bar was part of the screen and only got out of the way during a take. The viewfinder is full-bleed in every mode now, so visibility is a property of the screen, not of the mode. Flipping those four booleans to `true` would have fixed the screenshot and kept the defect: four handlers with an opinion about one screen-level constant. They no longer touch it. `viewWillAppear` hides the bar once and `viewWillDisappear` hands it back, which is the whole policy. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
In landscape the action cluster docked to the trailing rail regardless of which way the device turned. Rotate clockwise and the home-indicator edge swings left while the cluster goes right, so the shutter crossed the device -- the one control reached for by muscle memory was the one that moved. Apple's Camera avoids this by locking to portrait and only rotating its glyphs. This screen can't: its frames come from another device, and a landscape frame in a portrait-locked window is the letterbox the redesign exists to remove. So keep the rotation and pin the cluster to the home-indicator edge instead -- dock() now takes the interface orientation and returns .leading or .trailing, since both landscapes are the same shape and size alone can't tell them apart. Zoom, mode and the control capsule still move; they're glance-and-tap, not muscle memory. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two problems in the same screenshot. The action cluster floated mid-window with a few hundred points of empty space beside it, and the shutter straddled the letterbox boundary. sideCluster is an HStack whose contents size to fit, so the whole row centered instead of the column hugging the edge. The zoom/mode side now takes the slack. And a rail is wrong on a Mac. It exists so a rotating device doesn't move the shutter out from under a thumb; a Mac window neither rotates nor is held. Catalyst also reports .landscapeRight permanently, so the rule railed it forever. dock() now takes the input kind and pointer-driven windows keep the conventional bottom bar. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Landscape had three groups in three places: the action rail hard against one edge, zoom and mode adrift near the bottom centre, and the active camera name floating over the middle of the picture. Zoom and mode now sit inboard of the rail, so the docked edge carries one control zone instead of scattering across the frame. The camera name moves up beside the link chip -- it is status, and it was sitting on the subject the user is trying to frame. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Mac had no Back at all. The floating chevron was suppressed on Catalyst on the assumption the window toolbar owned Back -- it does not, the window carries only the traffic lights and a title -- and the viewfinder hides the nav bar that used to carry Disconnect. Between the two, the screen was a dead end. The chevron now shows everywhere. Rectangles were drawn around the shutter, gallery and flip: every control here draws its own shape, but Catalyst's default button style paints a bordered box behind it. Set .buttonStyle(.plain) once at the chrome root rather than on each control. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Removing Catalyst's bordered button chrome also removed the hit region that came with it: under .plain a button is only clickable where its label actually draws. These labels are a glyph over an .ultraThinMaterial circle, and material fills do not hit-test, so the targets collapsed to roughly the glyph strokes. Declare the region explicitly on every control the chrome owns -- shutter, gallery, flip, capsule glyphs, mode buttons and tray tiles -- so the whole 44pt (78pt for the shutter) is clickable again. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both wrap their icon in another control, so shaping the label was not enough -- the Button and the Menu still hit-test their own frames. Both now declare the region on the outside as well, and the device menu takes .borderlessButton so Catalyst stops applying its own menu chrome and hit region. Back also drew at 36pt, under the 44pt minimum, so it was a small target on top of the shaping problem. GlassCircleButton now never takes a hit area below 44pt regardless of what it draws. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Gallery and camera-switch were the two controls whose entire visible surface is .ultraThinMaterial. A material fill does not hit-test, so under .buttonStyle(.plain) -- which drops the bordered style's own hit region -- the target collapsed to where the glyph draws. Everything that kept working fills with a colour instead: the shutter opaque white, mode buttons and tray tiles white at low opacity. .contentShape did not override it. Back appeared fixed in the previous commit only because max(size, 44) grew its frame from 36; the shaping did nothing there either, and it shares the same underlying problem. Put a colour fill under the material, at an opacity that cannot be seen but does take clicks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
.plain removes two things: the bordered background, which is what was wanted, and the style's hit region, which is what broke the gallery and camera-switch buttons on macOS. .borderless removes the box and keeps a normal hit region. The colour fills under the material stay -- they are harmless and make the targets robust regardless of style. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UIKit's hitTest skips any view that is hidden, interaction-disabled, or at alpha <= 0.01. The invisible fills added to make the material-backed buttons clickable were 0.001 -- under it -- so they were exactly as dead as the material they were meant to cover. That is the difference between the shutter and the gallery/camera-switch buttons: the shutter's label is an opaque Circle().fill(Color.white) across its whole diameter. Mode buttons (0.16) and tray tiles (0.12) are also above the threshold and also work. Material and 0.001 were the only fills that failed. 0.02 is still imperceptible over the material and is hit-testable. Same bug in the tray's dismiss scrim, which was also 0.001: tapping outside an open tray could not close it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Diagnostic: replace the translucent material behind both controls with a solid colour, to settle whether alpha is what makes them unclickable on macOS while the shutter (opaque white) works. If this fixes them the cause is the fill and the opacity can be dialled back to the lightest value that still hit-tests. If it does not, the fill was never the cause. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Back and the top-right capsule glyphs were never broken, and changing GlassCircleButton's fill outright changed Back too. Revert that: the material fill is the default again, and only the gallery button opts into the solid one via usesSolidFill. Also reverts the frame/contentShape/menuStyle I had added to the camera switch, so the fill is the single variable that differs between the two controls that do not work and the ones that do. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Back button was never a hit-testing fix. 7f80f0e turned it on for Catalyst -- it did not exist on the Mac before that -- and it worked immediately with .ultraThinMaterial and no shaping at all. So GlassCircleButton with a material fill works on macOS. Gallery is the same component with the same fill, which means the fill cannot be why it does not. contentShape, 44pt frames, alpha thresholds and opaque fills were all fixing a problem that was not there. Back to material everywhere. What remains is position: Back is in the top bar and works; gallery and the camera switch are the outer two items of actionCluster and do not, while the shutter between them does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Component or position -- one build tells us which. position -> shutter breaks on the edge, gallery works in the centre component -> gallery stays broken in the centre, shutter still works Revert this commit either way once the answer is in. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Revert the diagnostic swap; it answered the question. Gallery worked as soon as it moved to the centre, and the shutter kept working when it moved out to the edge -- so the cause was never the button. The row is 242pt wide and centred (x from -121 to +121). The shutter was alive at -121..-47 while the gallery had been dead at -121..-77: same left edge, but the shutter reaches further inward. There is a centred live band of roughly +/-80pt, and a 44pt button outside it sits entirely in dead space while a 74pt one still pokes in. That is a hit-test bounds problem. A stack sizes to its widest child and SwiftUI happily draws children outside those bounds, but UIKit hit testing stops at them -- so the row rendered correctly and only partly responded. The camera switch was dead in both arrangements because it never left the far edge. Give the cluster and the bottom stack the full width so nothing sits outside its parent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
36pt with a 16pt glyph read as a small auxiliary control next to the system back button it replaces. 44pt with a 22pt chevron matches the nav bar's target size and glyph weight, and lines up with the gallery and camera-switch buttons, which are also 44. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Hiding the bar cost the interactive pop gesture: UIKit disables it along with the bar, since it normally drives the bar's own back button. Rather than take over the recogniser's delegate to force it back on -- the usual workaround, and one that can wedge the navigation stack -- keep the bar present and configure it with a transparent background. The preview still runs edge to edge behind it, Back is the system button again (with its long-press history menu, and the "Disconnect" title the scanner sets), and the swipe is the real one because nothing was disabled. The floating chevron would now duplicate it, so it goes. The bar's previous appearance is saved on the way in and restored on the way out, so this screen's transparency does not leak to other screens. Step 1 of 2: the link chip, timecode and control capsule still live in the SwiftUI chrome and now sit below the bar. Moving them into left, title and right bar items is the follow-up. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Catalyst does not render the navigation bar, so making it transparent and dropping the floating chevron left macOS with no way off the screen -- the same dead end as before, reintroduced by assuming the bar exists everywhere. Split it, because the platforms genuinely differ. iOS keeps the transparent bar: it carries Back and, more importantly, the swipe. A Mac has no bar and no swipe gesture to protect, so the bar stays hidden there and MonitorView draws its own chevron. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The bar already claims the top strip on iOS, and the flash/torch/tray capsule was floating just below it -- two rows of chrome doing one row's work. The capsule is now a right bar button item there, hosted from a retained UIHostingController; MonitorNavControls observes the view model directly, because a UIBarButtonItem cannot be re-rendered from outside. Catalyst renders no navigation bar, so it keeps the capsule inline in the chrome exactly as before. showsInlineControlCapsule is the single switch. isTrayOpen moves from MonitorView's private @State onto the view model: on iOS the button that opens the tray now lives outside that view. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UIViewControllerHierarchyInconsistency on iOS at launch. UIBarButtonItem(customView:) puts the hosting controller's *view* inside the navigation bar, which is owned by the UINavigationController, while addChild made MonitorViewController its parent. UIKit checks that a child controller's view lives under its parent's and throws when it does not. Keep the strong reference -- that is what keeps the SwiftUI view alive and updating -- but do not add it as a child. Sizing goes through Auto Layout and SwiftUI's intrinsic size rather than a one-shot sizeToFit, since the capsule is narrower in video mode where the flash glyph drops out. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The bar went opaque because configureWithTransparentBackground was only applied to standard, scrollEdge and compact. compactScrollEdgeAppearance -- the one iPhone uses in landscape -- was left to fall back to the default opaque background. Set it too, clear backgroundEffect, and set isTranslucent plus the legacy background/shadow images so nothing paints a backdrop. The hosting controller's view is opaque by default, which put a solid rectangle behind the capsule regardless of the bar; it is now clear and non-opaque. Back shows the chevron alone via backButtonDisplayMode = .minimal, set on this screen's navigation item rather than by blanking the scanner's back item, which the camera screen also uses. The restore on the way out now undoes all of it, including the legacy overrides, so the next screen does not inherit a transparent bar. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Back to the hidden nav bar with custom chrome on every platform, which is exactly the state at 461893c. The transparent-bar route was meant to buy the swipe-back gesture for free. It cost more than it bought: the bar would not stay translucent across appearance slots, Catalyst renders no bar at all so the platforms diverged, and putting the control capsule in a bar button item has no good answer -- parenting the hosting controller raises UIViewControllerHierarchyInconsistency, and leaving it unparented gives a view that draws opaque and does not take taps. Swipe-to-go-back is not currently available on this screen. If it is wanted later, the honest options are plain UIBarButtonItems (no SwiftUI hosting) or taking over interactivePopGestureRecognizer's delegate -- both understood, neither started here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comments now describe the code as it is: removed six that narrated what it used to do (the timer's old slider, the modal spinners, the earlier standby behaviour), and cut the MonitorChrome doc blocks to the rule they state rather than the argument for it. Deletes MonitorViewModel.areControlsExpanded. Its only readers were the collapsible controls panel and its chevron, which the redesign replaced; it was persisting a preference nothing consults. The UserDefaults key stays behind on existing installs as an orphan. MonitorTray.items no longer defaults supportsCameraStandby. A default in the middle of the parameter list lets a caller omit it and silently lose the tile; all three call sites now say what they mean. Also restores a mangled import in WatchSessionManager that had crept in. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
darioalessandro
added a commit
that referenced
this pull request
Aug 2, 2026
* Regenerate App Store screenshots for the redesigned remote PR #191 inverted the remote screen: the preview is full-bleed under floating glass chrome instead of a small image stacked above an opaque control panel. Every shipped screenshot still showed the old one -- a blue Photo/Video segmented control, a "Timer: 0s" slider row, 1/2x/Dual lens buttons, a row of flash/torch/settings glyphs. None of that exists, so the screenshots misrepresented the app and undersold the one change users asked for. The redesign also broke how a device screen was composited. The template pasted a preview image into a FIXED sub-rectangle of a flat UI capture, which only worked because the old chrome never overlapped the preview. Now it floats over the picture, so a pasted rect would cover the shutter, zoom pill and mode selector. A remote screen is now three layers: black, the frame aspect-fit (the app's own letterboxing), then a straight-alpha chrome overlay. A capture taken over a black viewfinder IS the chrome premultiplied over black, so the value channel recovers it -- alpha = max(R,G,B), color = pixel/alpha. That is exact over black, and over a bright frame it produces the same washed-out light glass the real app shows, because dark-mode .ultraThinMaterial is additive light over its backdrop. The value channel rather than luma keeps the gold accent and red record disc in hue. tools.py chrome does the keying; --disc forces the shutter opaque (a white disc with a black ring inside it would otherwise key to a hole) and --blank zeroes the device status bar, which the template redraws clean. Consequences worth noting: - The frame reaches 83% of an iPhone screen where the old layout gave it 26%, which is the claim the redesign was making. - One Mac overlay serves the in-scene MacBooks and a second, near-square one the callout slot; the preview is composited rather than baked in, so ui-monitor-mac-cook/-remote are gone. - chrome: "camera" was drawing a back chevron, a red dot and a "?" button that CameraScreenView no longer has. It is now what the screen actually is: a full-bleed preview and a standby button, sized off the screen's short edge so it stays 44pt whether the device is held portrait or landscape. - Callout anchors were re-measured off the overlays, not estimated. The feature-callout slots take a preview with contrast at the edges: glass over a bright flat frame is legitimately near-invisible, which is accurate and useless for a slot whose job is labelling controls. - The Mac callout labels route left and split vertically. Its window sits ~130px from the canvas edge, so an outward label clipped the moment a locale spelled it out (ru TAIMER, vi DEN FLASH & DEN PIN). Verified across all 15 locales by checking every callout screenshot's border for pill or ink pixels. - LIVE PREVIEW was missing from ru/hi/vi/ms/tr, which predates this change -- no slot used the label until now. Scenes are untouched: no Nano Banana generation, no new photography. The iPhone capture's live-frame band is blanked in assets/raw/ before anything enters the repo, and its camera-name chip carried a USB webcam's brand name, which is blanked too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Lead the release notes with the redesigned remote The notes still opened on the Stormo engine, which shipped in 9.0.x. The viewfinder redesign is what this build is, so it takes the lead and Stormo moves down into "Recently added" alongside Tap to Focus. Written from what the release actually changed for someone holding the remote -- the preview is full screen, it rotates, the set-once controls are behind one tap, the link says when the picture has gone stale, and capture feedback stopped covering the frame you are composing. Camera standby is in there too. All 17 locale directories. en-GB stays byte-for-byte with en-US and es-ES with es-MX, per the storefront cross-indexing rule. Kept the existing phrasing for the connection ("no router or network needed"), which does not contradict the app's requirement that the Wi-Fi radio be on (2.3.1). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Fix literal-translation artifacts in the new release notes Reviewing the lead section I wrote in the previous commit, three calques ran through most languages: "Tray" came out as a physical serving tray or drawer in every language that has a word for one -- da bakke, de Fach, fr tiroir, es/pt bandeja, ms dulang, vi khay, tr cekmece. The thing on screen is a panel, so it now says panel everywhere, English included. "Tile" was worse: da flise, fr tuile, ms jubin, tr karo and hi are all building materials, and zh had Windows Metro's magnetic tile. Dropped the noun -- the sentence reads "and each one shows its current setting", which is what it means. "The controls out of its way" was calqued word for word into the romance languages and Korean. Each now uses its own idiom for stepping back. Language-specific: - da: "stroemmen gaar i staa" reads as the electricity failing, not the video stalling. Now says the picture freezes. "pop op" -> "et vindue". - it: "fuori dai piedi" is too colloquial for a store listing -> "in secondo piano". "live" -> "in diretta", matching the rest of the entry. - ja: two "ga" particles in one clause. Split into two sentences. - ko: "hwamyeon" (screen) where the picture was meant, and a small preview described as "pressed" rather than squeezed. - ru: "Ozhidanie kamery" as a heading -> "Rezhim ozhidaniya kamery". - zh: "tuiliu" is streamer jargon -> plain "send the picture". - fr: "l'appareil qui filme" implied video only -> "qui sert de camera". Same fix for the clunky de "Kameraegeraet" and the vague hi/tr/ms equivalents. Only the lead section changed. The "Recently added" tail is inherited text from the previous release and is byte-for-byte identical, verified per locale; en-GB still mirrors en-US and es-ES mirrors es-MX. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
A review, translated:
Measured on an iPhone 16 in portrait against a 16:9 camera: image 26% of the screen, black letterbox 30%, opaque control panel 44%. The reviewer was right on both counts, so this addresses both.
Monitor: the preview is the screen
VStack{preview; opaquePanel}becomes aZStack— full-bleed preview under floating chrome.Being honest about what that buys: portrait is width-limited, so the image barely grows there (26% → ~30%). Rotation is where the pixels actually are — landscape reaches 82% — so rotation is unlocked and the nav bar hidden. The action cluster docks to a trailing rail when the view is wider than tall.
That docking rule keys off the view's shape, not the size class. An iPhone in landscape is compact width on every non-Max phone, so a size-class rule would bottom-dock the exact case this screen exists to serve. Shape covers iPhone rotation, iPad Split View, and a resized Mac window with one rule and no
#if.The self-timer and quality controls move into a tray behind one tap. Each tile carries its own value — the timer tile reads
10, aspect reads16:9— which is what lets them stop occupying a permanent labelled row.Two bugs found while measuring
Modal spinners over the live preview. Every remote command raised a
UIAlertController. Activity is now derived from session state at the singletransition(to:)choke point (MonitorActivity.forState) and rendered as a ring on the shutter — mirroring the idiomPeerLinkStatusalready documents: an indicator that is a function of the state cannot outlive the thing it describes. The show/dismiss pairs a pushed indicator needs are exactly what stranded those spinners. Removes 6 alert sites and 24 deaddismissCameraAlert()calls.TakePicturegains aCapturePhaseso "requesting" and "receiving" are distinguishable without an alert title. The phase swap keeps the same generation, so the armed watchdog carries over.A stalled stream was silent.
StreamStalledre-requested a frame with nothing on screen to say the picture was frozen — you could frame a shot against a stale image. Adds a link chip and desaturates the stale frame.Camera standby (the review's second complaint)
The controlled device rendered a full-screen preview nobody is looking at while it sits on a tripod. Adds an opt-in
CameraPreviewMode(.ondefault — the preview is genuinely useful when you're near the camera, so nothing changes unless you ask):UserDefaults,CameraPreviewModeStore)RemoteCmd.SetCameraPreviewMode(action24, verified free)supports_preview_mode, matching theFocusAtPointgate — a camera that predates the feature is never sent the command, and the monitor hides the tile entirely rather than offering a control that does nothingStandby stops local display only. The capture session keeps running and frames keep streaming to the monitor — pinned by
testStandbyDoesNotStopFrameStreamingToMonitor, which drives a realFrameSenderacross the loopback wire with the camera in standby.Zoom pill
Lens stops were spaced by their position on the zoom track, so the gaps grew with the camera's range —
1×hard left with a void before2×. Now a tight cluster, and the capsule shrinks to fit when collapsed instead of holding 268pt for three buttons (~144pt now).The drag also became relative to where it started. That one wasn't cosmetic: absolute mapping assumed a fixed 240pt width and would have read the first event in the wrong coordinate space once the collapsed pill got narrower.
Testing
635 tests, 0 failures. Mac Catalyst builds.
Layout, tray composition, timer detents, link state and activity are pure functions in
MonitorChrome.swift, testable without a window. New loopback coverage for the standby command and its capability gate. New snapshot coverage for landscape, capture-in-flight, countdown, and the tray — which had none, becauseMonitorViewownsisTrayOpenprivately and no test could reach those tiles.Deferred
@inlinablegeneric miscompiled under TSan instrumentation). Fix is up as Fix Thread Sanitizer SEGV in certificate generation Stormo#17; needs a merge + tag + pin bump.🤖 Generated with Claude Code