Skip to content

Redesign the monitor screen around the viewfinder - #191

Merged
darioalessandro merged 28 commits into
masterfrom
redesign-remote
Aug 2, 2026
Merged

Redesign the monitor screen around the viewfinder#191
darioalessandro merged 28 commits into
masterfrom
redesign-remote

Conversation

@darioalessandro

@darioalessandro darioalessandro commented Aug 1, 2026

Copy link
Copy Markdown
Member

Why

A review, translated:

The app is actually practical and it works, BUT on the remote-control device the image preview is tiny; instead a lot of screen space is wasted on a rather clumsy arrangement of interface elements. On the controlled device, by contrast, the preview is full-screen — and there, in my opinion, completely unnecessary. Very illogical!

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 a ZStack — 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 reads 16: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 single transition(to:) choke point (MonitorActivity.forState) and rendered as a ring 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 — 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 (.on default — the preview is genuinely useful when you're near the camera, so nothing changes unless you ask):

  • Persisted on the camera device (UserDefaults, CameraPreviewModeStore)
  • Settable locally on the camera, or remotely via RemoteCmd.SetCameraPreviewMode (action 24, verified free)
  • Capability-gated on supports_preview_mode, matching the FocusAtPoint gate — 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 nothing
  • The camera reports its mode back, so the monitor's moon glyph reflects the camera's confirmed state, not local intent

Standby stops local display only. The capture session keeps running and frames keep streaming to the monitor — pinned by testStandbyDoesNotStopFrameStreamingToMonitor, which drives a real FrameSender across 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 — hard left with a void before . 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, because MonitorView owns isTrayOpen privately and no test could reach those tiles.

Deferred

  • Portrait/landscape mismatch. The 26% case is specifically portrait-monitor + landscape-camera; two phones held the same way fill fine. Deliberately not adding a "rotate your phone" nudge — portrait is the natural way to hold a remote.
  • CI does not run Thread Sanitizer, so this is unaffected, but note the TSan rule in CLAUDE.md is currently broken on master by a SEGV in Stormo's cert generation (swift-certificates @inlinable generic 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

darioalessandro and others added 4 commits August 1, 2026 00:36
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>
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

📊 Test Coverage Report

626/638 tests passed · ⏭️ 12 skipped

Metric Coverage
App code (excl. generated + vendored Theater) █████████░░░░░░░░░░░ 44.6% 🔴
Whole target █████████░░░░░░░░░░░ 45.8% 🔴

🎥 Capture & session core

File Coverage Lines
CaptureEngine.swift ██░░░░░░░░░░░░░░░░░░ 10.7% 164/1529 🔴
RecordingPipeline.swift ████████████░░░░░░░░ 59.7% 284/476 🟡
FrameStreamingCoordinator.swift ░░░░░░░░░░░░░░░░░░░░ 0.0% 0/124 🔴
FrameStreamer.swift ██████████████░░░░░░ 69.2% 72/104 🟡
RemoteCmdFlatBuffers.swift ███████████████████░ 96.2% 1200/1247 🟢
📁 Full report — 87 files
File Coverage Lines
CountdownTimer.swift ████████████████████ 100.0% 48/48 🟢
JPEGFrameEncoder.swift ████████████████████ 100.0% 12/12 🟢
CameraPreviewMode.swift ████████████████████ 100.0% 13/13 🟢
AppActivityMonitor.swift ████████████████████ 100.0% 31/31 🟢
AppTheme.swift ████████████████████ 100.0% 33/33 🟢
Messages.swift ████████████████████ 100.0% 7/7 🟢
ZoomScale.swift ████████████████████ 100.0% 76/76 🟢
DebugLog.swift ████████████████████ 100.0% 2/2 🟢
MonitorChrome.swift ████████████████████ 100.0% 56/56 🟢
StreamingConfig.swift ████████████████████ 100.0% 34/34 🟢
MultipeerMessages.swift ████████████████████ 100.0% 12/12 🟢
Locked.swift ████████████████████ 100.0% 19/19 🟢
ScannerLobby.swift ████████████████████ 100.0% 5/5 🟢
FrameCreditWindow.swift ████████████████████ 100.0% 9/9 🟢
AppDelegate.swift ████████████████████ 100.0% 17/17 🟢
HEICFrameEncoder.swift ████████████████████ 100.0% 17/17 🟢
PeerCompatibility.swift ████████████████████ 100.0% 42/42 🟢
DeviceScannerViewModel.swift ████████████████████ 99.1% 112/113 🟢
FrameSender.swift ████████████████████ 98.4% 187/190 🟢
FocusPointMapping.swift ████████████████████ 97.6% 40/41 🟢
RemoteCmdFlatBuffers.swift ███████████████████░ 96.2% 1200/1247 🟢
RemoteCmds.swift ███████████████████░ 95.9% 301/314 🟢
WatchSharedTypes.swift ███████████████████░ 94.0% 236/251 🟢
WatchPreviewStreamer.swift ███████████████████░ 93.6% 44/47 🟢
VP9FrameEncoder.swift ███████████████████░ 92.7% 153/165 🟢
WelcomeView.swift ██████████████████░░ 89.3% 492/551 🟢
VideoTransferProgressView.swift █████████████████░░░ 84.0% 295/351 🟢
UIImage+gif.swift █████████████████░░░ 83.2% 129/155 🟢
UIAlertController.swift █████████████████░░░ 83.0% 44/53 🟢
CameraDeviceDescriptor.swift ████████████████░░░░ 81.0% 34/42 🟢
CameraProgressOverlayView.swift ████████████████░░░░ 78.5% 186/237 🟡
MonitorView.swift ███████████████░░░░░ 76.8% 1178/1534 🟡
FlatBufferSchemas_generated.swift (excluded from app-code metric) ███████████████░░░░░ 75.6% 742/982 🟡
RolePickerView.swift ███████████████░░░░░ 75.4% 306/406 🟡
RecordingTimer.swift ███████████████░░░░░ 73.5% 61/83 🟡
CameraRecordingTimerView.swift ███████████████░░░░░ 73.4% 127/173 🟡
WelcomeViewModel.swift ███████████████░░░░░ 73.3% 96/131 🟡
SettingsViewModel.swift ██████████████░░░░░░ 70.9% 90/127 🟡
DeviceScannerView.swift ██████████████░░░░░░ 70.3% 1132/1611 🟡
SessionCoordinator.swift ██████████████░░░░░░ 69.8% 1732/2480 🟡
FrameStreamer.swift ██████████████░░░░░░ 69.2% 72/104 🟡
MonitorPresenter.swift █████████████░░░░░░░ 65.3% 154/236 🟡
WelcomeViewController.swift █████████████░░░░░░░ 64.1% 41/64 🟡
UIAlertPresenter.swift ████████████░░░░░░░░ 60.5% 26/43 🟡
RecordingPipeline.swift ████████████░░░░░░░░ 59.7% 284/476 🟡
MediaProcessors.swift ████████████░░░░░░░░ 58.3% 7/12 🟡
StoreManager.swift ███████████░░░░░░░░░ 53.2% 75/141 🟡
UICmds.swift ██████████░░░░░░░░░░ 50.0% 96/192 🟡
CameraViewModel.swift ██████████░░░░░░░░░░ 49.6% 126/254 🔴
ZoomPill.swift ██████████░░░░░░░░░░ 49.4% 168/340 🔴
UIViewController+SwiftUIHosting.swift ██████████░░░░░░░░░░ 48.4% 15/31 🔴
FrameCodecs.swift ██████████░░░░░░░░░░ 48.1% 25/52 🔴
FrameStreamReceiver.swift █████████░░░░░░░░░░░ 46.3% 238/514 🔴
SceneDelegate.swift █████████░░░░░░░░░░░ 45.8% 11/24 🔴
MonitorViewModel.swift █████████░░░░░░░░░░░ 44.1% 186/422 🔴
PurchaseManaging.swift ████████░░░░░░░░░░░░ 42.0% 21/50 🔴
HEVCFrameEncoder.swift ████████░░░░░░░░░░░░ 40.1% 93/232 🔴
MonitorViewController.swift ███████░░░░░░░░░░░░░ 37.3% 84/225 🔴
EnumExtensions.swift ███████░░░░░░░░░░░░░ 33.9% 40/118 🔴
CameraScreenView.swift ██████░░░░░░░░░░░░░░ 28.5% 148/520 🔴
DeviceScannerViewController.swift ██████░░░░░░░░░░░░░░ 28.1% 107/381 🔴
WatchSessionManager.swift ████░░░░░░░░░░░░░░░░ 20.8% 37/178 🔴
SoundManager.swift ████░░░░░░░░░░░░░░░░ 20.0% 4/20 🔴
MonitorViewController+SwiftUI.swift ███░░░░░░░░░░░░░░░░░ 14.4% 67/465 🔴
CaptureEngine.swift ██░░░░░░░░░░░░░░░░░░ 10.7% 164/1529 🔴
SwiftConstants.swift ██░░░░░░░░░░░░░░░░░░ 10.3% 6/58 🔴
RolePickerController.swift ██░░░░░░░░░░░░░░░░░░ 10.2% 23/226 🔴
PeerLinkStatus.swift █░░░░░░░░░░░░░░░░░░░ 7.1% 10/141 🔴
WatchRemoteCameraController.swift █░░░░░░░░░░░░░░░░░░░ 3.2% 9/279 🔴
CameraPermissionsView.swift ░░░░░░░░░░░░░░░░░░░░ 0.0% 0/640 🔴
CameraHostController.swift ░░░░░░░░░░░░░░░░░░░░ 0.0% 0/197 🔴
RemoteShutterHelpView.swift ░░░░░░░░░░░░░░░░░░░░ 0.0% 0/1373 🔴
CameraPermissionErrorView.swift ░░░░░░░░░░░░░░░░░░░░ 0.0% 0/416 🔴
PhotosAccessDeniedView.swift ░░░░░░░░░░░░░░░░░░░░ 0.0% 0/606 🔴
FrameStreamingCoordinator.swift ░░░░░░░░░░░░░░░░░░░░ 0.0% 0/124 🔴
MicrophonePermissionPromptView.swift ░░░░░░░░░░░░░░░░░░░░ 0.0% 0/347 🔴
SettingsView.swift ░░░░░░░░░░░░░░░░░░░░ 0.0% 0/760 🔴
Photos.swift ░░░░░░░░░░░░░░░░░░░░ 0.0% 0/12 🔴
LocalNetworkPermissionView.swift ░░░░░░░░░░░░░░░░░░░░ 0.0% 0/747 🔴
PermissionManager.swift ░░░░░░░░░░░░░░░░░░░░ 0.0% 0/159 🔴
OrientationUtils.swift ░░░░░░░░░░░░░░░░░░░░ 0.0% 0/18 🔴
PhotosAccessHelper.swift ░░░░░░░░░░░░░░░░░░░░ 0.0% 0/324 🔴
CameraRig.swift ░░░░░░░░░░░░░░░░░░░░ 0.0% 0/574 🔴
StreamLog.swift ░░░░░░░░░░░░░░░░░░░░ 0.0% 0/1 🔴
MultipeerService.swift ░░░░░░░░░░░░░░░░░░░░ 0.0% 0/116 🔴
CameraCountdownTorch.swift ░░░░░░░░░░░░░░░░░░░░ 0.0% 0/61 🔴
Data+MD5.swift ░░░░░░░░░░░░░░░░░░░░ 0.0% 0/4 🔴

Generated from 281bc05 · workflow run

darioalessandro and others added 24 commits August 1, 2026 20:35
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
darioalessandro merged commit e282d53 into master Aug 2, 2026
2 checks passed
@darioalessandro
darioalessandro deleted the redesign-remote branch August 2, 2026 05:51
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant