Skip to content

Add Live Activity and watchOS support for mapping sessions - #29

Open
agessaman wants to merge 63 commits into
MeshMapper:devfrom
agessaman:feat/watch-app
Open

Add Live Activity and watchOS support for mapping sessions#29
agessaman wants to merge 63 commits into
MeshMapper:devfrom
agessaman:feat/watch-app

Conversation

@agessaman

Copy link
Copy Markdown

Summary

This PR adds two Apple companion surfaces to MeshMapper:

  • a native watchOS companion app for monitoring and controlling a MeshMapper session from the wrist
  • iOS Live Activities for at-a-glance session and ping-cycle status

Both surfaces remain projections of the iPhone-owned session state. The phone continues to own the MeshCore connection, GPS fix, session lifecycle, transmit policy, and command admission; the Watch and Live Activity surfaces render that state and send user intent back to the phone.

These features are included together because they share the same underlying session-state projection and lifecycle work rather than maintaining independent copies of application state.

Apple Watch companion

The watch app includes:

  • map view with current position, recent pings, repeaters, and response links
  • session controls for Passive/Hybrid operation, stop, and manual ping
  • heard-node list
  • wrist-local map and layout settings
  • transport/debug diagnostics in debug builds
  • stale-state presentation when the phone has not supplied current state

The watch does not independently drive a MeshCore session. Commands from the wrist are treated as intent and are revalidated by the phone before anything can transmit.

WatchConnectivity transport

The Watch bridge was designed to keep radio and processing overhead low during wardriving:

  • snapshot fingerprinting suppresses unchanged state
  • updatedAt metadata does not itself defeat deduplication
  • non-urgent updates are throttled
  • small GPS changes are suppressed with a movement gate
  • map-only geography can be suppressed while the map is not visible
  • map suppression uses a renewable lease so a lost command fails safely back to full geography
  • queued wrist commands carry IDs for redelivery deduplication
  • transmitting commands carry timestamps and are rejected if they arrive too late or with unreasonable clock skew

Explicit refreshes are distinguished from map-demand updates. A genuine request for current state can defeat payload deduplication, while map-geo lease renewals remain deduplicatable.

Watch lifecycle / stale-state handling

WatchConnectivity may retain application context across watch app launches, so retained state is aged from the phone's original updatedAt timestamp rather than from the moment the watch process reads it.

The watch also reconciles state when its scene becomes active again. watchOS suspends the app while the wrist is down, so onAppear alone is insufficient for normal wrist-raise behavior.

On resume the watch:

  1. consumes any application context already retained locally
  2. keeps using it without radio traffic when it is still fresh
  3. requests a current snapshot only when the local state is missing or stale

This avoids both presenting old retained state as current and placing a WatchConnectivity round trip behind every wrist raise.

Live Activities

The iOS app now exposes session progress through ActivityKit, including current mode/phase, countdown state, recent repeater information, ping status, and session counters.

The implementation is local to the app and does not require a push server or separate App Group state.

Compatibility

  • iPhone application deployment target remains unchanged
  • Live Activity extension: iOS 16.2+
  • Apple Watch companion: watchOS 11+
  • Watch wire additions are optional/additive so missing fields from older peers retain safe defaults

Testing

The branch adds coverage for:

  • Watch/Dart wire contracts
  • command redelivery and admission
  • queued-command age and clock-skew handling
  • explicit refresh vs. map-geo lease intent
  • forced refresh through payload deduplication
  • forced-refresh persistence after a native delivery refusal
  • map geography suppression/restoration
  • movement gating
  • Watch diagnostics
  • Live Activity models and service behavior

CI now runs:

dart format --output=none --set-exit-if-changed .
flutter analyze
flutter test

Bjorkan and others added 30 commits July 15, 2026 01:29
Add an ActivityKit and WidgetKit integration for iOS mapping sessions.

- show the current wardriving phase, including sending, listening, cooldown, and waiting states
- render system-driven countdowns from shared phase deadlines
- display up to three heard repeaters with SNR and hop information
- include session counters, queue state, zone, GPS, and connection status
- provide layouts for the Lock Screen, Dynamic Island, and compact CarPlay presentation
- throttle native updates and avoid duplicate Live Activities
- mark stale session data and show a final summary when a session ends
- keep the integration dependency-free and isolated from existing map presentation logic
The merged Live Activity work introduced the repo's only analyzer issue
(unnecessary_brace_in_string_interps). `dev` analyzes clean, so this
restores that baseline on the watch-app branch.
Phase 1 of the Apple Watch companion: the target skeleton only. The watch is
a mirror-and-remote for a session the iPhone owns, so this ships no session
logic — WatchConnectivity, map, node list, and controls follow in later phases.

Single-target watchOS app (WKApplication), watchOS 26.0, embedded into Runner
via an Embed Watch Content phase so `flutter build ipa` carries it along.

Also routes bundle IDs and signing team through MESHMAPPER_BUNDLE_PREFIX and
MESHMAPPER_DEVELOPMENT_TEAM, defined once at project level. Both resolve to
the previous literals, so nothing changes by default. This exists because a
watch app's bundle ID must be prefixed by its companion's: testing on a
Personal Team means moving every ID together, which is now one field instead
of four targets.

Verified: builds for simulator, embeds at Runner.app/Watch/MeshMapperWatch.app
with all variables resolved, installs and launches on a paired
iPhone 17 Pro / Apple Watch Series 11 simulator pair.
Phase 2 of the Apple Watch companion. The wire is now real in both
directions; the wrist UI is still a raw debug dump, replaced by the map in
Phase 3.

Shared contract in ios/Shared/MeshMapperWatchPayload.swift is compiled into
both Runner and the watch target so it cannot drift, with the Dart mirror in
lib/services/watch/. WatchSnapshot composes LiveActivitySnapshot rather than
re-deriving phase and counter semantics, so both surfaces always agree.

Three decisions worth keeping:

- Countdowns ship as absolute deadlines, never ticks. The watch renders them
  with Text(timerInterval:), so a session sends about one update per phase
  transition instead of one per second.
- Colours resolve on the phone. Dart owns the colour-vision palettes, so
  accessibility palettes work on the wrist with no duplicated code.
- The watch sends intents, never state. Every guard is re-evaluated in
  _handleWatchCommand, so a stale payload cannot cause a transmit.

Unlike the Live Activity, the watch receives snapshots even with no session
running — otherwise the start button could never be reached from the wrist.

Two bugs found by testing rather than review:

- WatchSessionManager held its FlutterMethodChannel weakly. setMethodCallHandler
  makes the messenger retain the handler block, not the channel, so a channel
  left in an AppDelegate local deallocates and every inbound command was
  silently dropped. The other channels survive as locals because they only
  receive; this is the first that invokes Dart from native.
- The watch pre-checked isReachable before sending. That flag lags reality —
  the simulator reported unreachable while still delivering messages seconds
  later — which turned a stale flag into a refused tap. errorHandler is now
  the source of truth.

Verified on paired iPhone 17 Pro / Apple Watch Series 11 simulators: phone
state renders on the wrist (phase, counters, GPS fix, disabled controls with
reason), and requestSnapshot round-trips watch → Swift → Dart → ack.
138 tests pass, 27 of them new.
Phase 3 of the Apple Watch companion. The map is Apple's basemap with
MeshMapper's data drawn on top: ping squares in the phone-resolved ping
colours, repeater pins with a highlight ring for those heard this cycle,
optional lines from the fix to each responding repeater, and a fix puck with
heading. A countdown pill renders the phase deadline via Text(timerInterval:).

MeshMapper's own basemap cannot come along — MKTileOverlay is
API_UNAVAILABLE(watchos), so the OpenFreeMap styles, ArcGIS satellite raster,
and coverage vector tiles have no route onto the wrist. Only the data layer is
ours; a satellite toggle uses Apple imagery instead.

The fix is drawn as a custom annotation rather than UserAnnotation, so the
watch renders the *phone's* position and needs no location permission of its
own.

Follow mode recentres on the fix, yields when the wearer pans, and drifts back
after 8s. Two bugs fixed while verifying it: the initial `.automatic` camera
settle was misread as a pan (suspending follow before the first fix arrived),
and the resume deadline was only ever read during a render, so a stationary
phone — which sends no updates — would stay unfollowed indefinitely.

Initial span is ~3 km rather than ~1 km: wardriving is about what is around
you, and the tighter default opened with every nearby repeater off-screen.

SampleSnapshot is DEBUG-only behind a launch argument
(-MeshMapperSampleData YES). The simulator has no Bluetooth and so can never
produce pings or repeaters, which would leave the map permanently empty there.
Verified excluded from Release by building the watch target in Release.

Known: Apple Maps basemap tiles do not load in the watch simulator — watchOS
proxies tile requests through the paired phone and the simulator's companion
proxy returns GEOErrorDomain -11. Annotations, geometry, and camera are all
verified; the basemap itself needs real hardware.
Phase 4 of the Apple Watch companion. NodeListView shows the repeaters that
answered the most recent ping — SNR-coloured dot, name, SNR, and a context
line of hop count and distance — with a detail view carrying RSSI, seen count
and last-heard time.

Both placements read the same view, so this is a presentation toggle rather
than two implementations:

- sheet: the map keeps a tappable summary bar showing the strongest node
  inline, opening the full list over the map.
- page:  the map stays clean full-bleed and the list gets its own tab.

The bar is a tap, not the swipe-up originally planned: on watchOS a swipe from
the bottom edge is the Control Center gesture, so it would fight the system.
Surfacing the strongest node inline also answers the common question — "what
just answered?" — with no interaction at all.

Rows use standard text styles and never shrink type to hit a row count. The
payload carries up to 7; the list renders what fits at the wearer's text size
and scrolls for the rest.

The map chrome gains a stale badge, so data that has stopped updating can
never read as live.

Two DEBUG-only launch arguments (-MeshMapperShowNodeSheet,
-MeshMapperInitialPage) make specific screens capturable headlessly; the
simulator offers no way to tap or swipe. Verified excluded from Release.
Reworks the heard-node model to match what the phone's map actually shows.
Wire version 2.

The watch was inventing its own idea of "recently responded" from
TxPing.heardRepeaters — names, hop counts, RSSI, seen counts. The app's map
overlay (_buildTopRepeatersOverlay) shows something different and simpler:
up to three rows from the latest ping plus the RX slot, each a dot coloured
by which ping type was answered, the hex path hash, and the SNR.

Three corrections:

- Hex ID is the identity, not the name. Path hashes are 1-3 bytes, so a short
  ID often maps to several repeaters. Names are resolved only through
  indexByHexPrefix, which drops any prefix owned by more than one repeater --
  a confidently wrong name is worse than none. The watch always shows the hex
  and treats a name as a secondary hint.
- No hop counts anywhere. _updateTopRepeaters is fed directRepeaters and
  multiHopEvents are explicitly excluded, so multi-hop was never part of this
  surface.
- The RX slot trails the top three rather than competing on SNR, matching the
  overlay's distinct trailing row.

Layout follows the phone: Top Heard hard against the upper-left corner
(drawing into the top safe area, which is free because the system clock is
right-aligned), countdown pill bottom-right. The bottom summary bar is gone,
superseded by the box.

Colours come from the same OverlayPingType mapping the map uses, resolved on
the phone, so colour-vision palettes carry across unchanged.
…orner

Two placement and legibility fixes on the watch map.

The countdown sat well clear of the bottom edge because the overlay still
honoured the bottom safe area while the map beneath it ignored it. The overlay
now ignores both edges, so the two corners are actually corners.

Top Heard is sized for the worst case rather than the sample case. A 3-byte
zone yields six-character path hashes, and four of those at full size ran the
box across a 40 mm screen. Row type now shrinks with ID length exactly as
RepeaterIdChip does on the phone (11/10/9 pt for 2/4/6 characters), rows are
pinned to a single line, and the box is held to a fixed type size: it is a HUD,
and at large accessibility sizes it would otherwise swallow the map. The
scrollable detail list is where the wearer's text-size setting is honoured.

Both overlays move from flat 70% black to a blurred material. The flat panel
let bright basemap labels bleed through and fight the SNR digits — visible as
a road label crossing the fourth row. Blurring removes the competing detail
and matches the platform's own overlay treatment.

Verified at the worst case — four six-character IDs on a 40 mm SE — via a new
DEBUG launch argument, -MeshMapperLongIds YES.
Both overlays were clipped by the display curvature on a real watch. The
simulator renders a flat rectangle and never showed it — .ignoresSafeArea was
reaching for corners that physically do not exist.

Rather than nudging insets, the two overlays become one panel across the
bottom of the map, inside the safe area, with no corner to lose:

- A depleting bar across the top, draining right to left, with the remaining
  time beside it. Fed by phaseEndsAt and a new phaseDurationMs, both absolute,
  so the bar is correct between updates and correct when the app opens midway
  through a phase. CountdownTimerService gains a durationMs getter and the
  provider identifies the owning timer by matching end times.
- Heard rows in two columns when they fit, one when they do not. ViewThatFits
  decides by measurement: a 3-byte zone's six-character hashes plus SNR cannot
  fit two columns on 40 mm, and the hex ID must never truncate — it is the
  repeater's identity.

Two layout traps worth recording. A Rectangle rule is a greedy child and
stretched the panel to its cap; spacing replaces it. And .frame(maxWidth:) is
expansive rather than merely limiting, so capping the panel made it that wide
always — only the phase title, which can run long, carries a ceiling now.

The translucent material is kept, and the whole panel remains the tap target
for the detail list.
…panel

The label rides on the timer bar instead of sitting beside it, with a shadow
so it stays readable over both the filled and empty parts of the track. A
separate column cost width permanently and left the phase title cramped.

The camera now places the fix in the middle of the band between the top of the
display and the top of the panel, rather than the middle of the display, so
the puck is no longer pushed down behind the panel. The panel measures itself
through a preference key, so this holds however tall the panel gets.

Also tracks the rendered region from onMapCameraChange. Without it a Digital
Crown zoom was discarded on the next follow update, since every recenter
reused the originally requested span.

Known incomplete: the offset under-shifts. Instrumentation on a 46 mm
simulator showed panel=54pt, view=159pt, frac=0.34, latSpan=0.03 — the
fraction is right, but two denominators are wrong. viewHeight measures the
ZStack (159pt) while the map draws into the safe area it excludes (~242pt),
and context.region.span reports the requested span rather than the visible
one. Deriving both from context.rect (MKMapRect) is the fix.
Two changes to the map page, both driven by what the panel actually
measures rather than by a guess.

Bar placement now differs by screen. A 46 mm watch has 184 pt of panel
width and a 40 mm one has 138, so the large screens set the phase title
and the countdown either side of the track and leave the bar as a pure
gauge, while the small ones keep the single label riding on the bar. The
column gutter widens on the roomy sizes too, which ViewThatFits will
still veto down to one column if six-character hashes need the space.

Placing the fix is now a question for the map, not a calculation. The
previous offset under-shot because it compared the panel's height to the
enclosing stack's, and that stack is not the map: SwiftUI reports the
map's frame as 159 pt tall on a 248 pt display, yet it draws to every
edge. No measurement of the view hierarchy predicts where a coordinate
lands. MapReader's proxy answers directly, so the camera translates by
the difference between the fix and whatever sits at the target point,
then a deadbanded correction on each camera change absorbs the first
render and any Digital Crown zoom. Verified: the puck settles at 78.75 pt
against a panel top of 158 on 46 mm, and 47.75 against 96 on 40 mm —
centred in the band, as asked, and stable across frames.

Worth remembering: the CGRect preference keys must ignore empty values.
Every sibling subtree contributes the default, so taking nextValue()
unconditionally let a later .zero overwrite the real frame — the
measurements read as zero until reduce learned to skip them.
…y curve

Three changes, implemented by Codex against a spec and verified here on
both simulator sizes.

The phase title and the countdown both ride the bar again, at opposite
ends of one overlay. Splitting them by screen size was wrong: it moved
"Listening" permanently to the left of the track on large watches and
left the right end of the bar empty, which read as the clock having
disappeared. One treatment now serves every size.

The panel is narrower and sits 4 pt from the bottom edge. It may descend
into the bottom safe area only in exchange for horizontal clearance,
because that safe area is what the display's curvature costs: modelling
the corner as a circle of radius R, a panel whose bottom edge is g from
the edge needs R - sqrt(2Rg - g^2) of inset, plus 4 pt because the
reported inset is a lower bound on the real glass. watchOS exposes no
corner radius, but its bottom safe-area inset is the clearance a
full-width element demands, which is that radius. The 46 mm reports 36
and the 40 mm 19, so the large watch narrows nearly twice as much — the
asymmetry that was asked for, arrived at rather than assumed.

Top Heard is two columns everywhere now, including 40 mm, with the type
size solved from the width actually available rather than picked from a
hash-length ladder. Only a six-character zone on the smallest screen
falls back to one column, and it now does so at the 9 pt cap instead of
inheriting the two-column floor.

Measured, in points, with the panel top and the fix both verified stable
across frames:

  46 mm  safeBottom 36  inset 23.5  panel 157.5 wide at y=190  font 9.8
  40 mm  safeBottom 19  inset 11.3  panel 135.5 wide at y=144  font 7.8
  40 mm  six-char       inset 11.3  panel 135.3 wide at y=115  font 9.0

Three measurement traps cost a build each and are worth remembering.
Reading the container's width to compute padding applied to that same
container is a feedback loop — the 40 mm reported itself 169 then 173 pt
wide on a 162 pt display, and the panel landed 2.3 pt from the edge
instead of the 9.3 it had just computed; WKInterfaceDevice.screenBounds
is static and cannot feed back. A GeometryReader in the map's background
reports no safe area, because the map ignores it. And `.ignoresSafeArea()`
on the reader itself reports none either, since it measures its own
expanded region — plain is correct here, and the first value is latched
so the panel can never perturb the number that positioned it.

The corner model still needs confirming on hardware: the simulator
renders a flat rectangle and cannot show a clip.
Placement and clearance gaps are now separate constants. The panel sits
8 pt from the bottom edge, but its horizontal inset is still evaluated
as if it sat at 4 — otherwise the geometry would hand back roughly 12 pt
of width on the 46 mm, undoing the narrowing that was the point. Keeping
the lower position as a clearance floor is also the conservative
direction for curvature the simulator cannot show.

Measured: both insets unchanged at 23.51 and 11.34, both panels 4 pt
higher, and the fix recentred with them — 92.75 pt against a 93.0 target
on the 46 mm, 69.75 against 70.0 on the 40 mm.

Also verified the six-character worst case on the 46 mm, which had only
been checked on the 40 mm: two columns at ~8 pt, no overflow. The 40 mm
remains the sole size that falls back to one column, at the 9 pt cap.
…d phases

Both gaps now scale with the measured corner radius rather than being
fixed. A panel sitting higher needs less horizontal clearance, so height
buys width — the trade this deliberately refused last commit, when the
ask was narrower-and-higher and only the height part had arrived. The
ask now includes the width, because six-character hashes on a 46 mm were
down at 8 pt.

The 4/19 and 8/19 ratios are calibrated from the 40 mm, the one size
confirmed good on hardware, so it reproduces its numbers exactly by
construction — inset 11.338, content 123.324, font 7.825, verified
unchanged. The 46 mm gains 11 pt of width and sits 15 pt off the bottom,
taking six-character IDs from 8.0 to 8.81 pt. Width still binds there
rather than the hash-length ladder's 9 pt ceiling.

A lapsed deadline no longer claims its phase. The title was never wrong
— the phone sends "Listening…" while the RX window runs and "Next ping"
while the auto-ping timer does, matching ping_controls — but the watch
rendered whatever it last heard forever, so a passed deadline with no
newer snapshot left "Listening" asserted over an empty track. That is
the state Adam photographed. Titles with a future deadline are unchanged;
titles with no deadline at all stay full strength, since "Device
disconnected" and "Waiting for GPS" are states rather than countdowns
and remain true until replaced; a title whose deadline has passed now
dims to 45%, reading as last-known rather than current.

The reason this took until now to surface is that SampleSnapshot
hardcoded "Listening", so no screenshot ever showed a wait phase.
-MeshMapperSamplePhase listen|wait|lapsed fixes that gap. Verified all
three render correctly, and that it and the three existing launch
arguments are absent from the Release binary.
Phase 5. The transport and all guards already existed — `_handleWatchCommand`
revalidates on arrival and the wire carries `canStartStop`, `canManualPing`,
`isSessionActive`, `blockedReason` and the cooldown deadline. What was
missing was the surface.

Start/stop, and manual ping behind a two-stage confirm: the first tap arms
the button for three seconds, the second sends. A wrist bump must not be
able to transmit, and disarming needs no round trip.

`WatchSessionClient` now tracks the in-flight command so a tap shows work
happening, and drops a reply that a newer tap has superseded — otherwise an
old answer lands under a fresh action. Silent refreshes stay out of that
state; the wearer never asked for them.

Three defects found by putting it on both simulator sizes:

A cooldown is the only unavailability the phone reports with **no**
`blockedReason` — `_buildWatchControls` sets one for "Not connected" and
"No GPS fix" only — so the ping button sat dead and unexplained for fifteen
seconds, which is precisely what this phase exists to prevent. It now
counts down on the button face from the absolute deadline already on the
wire, so it stays right without further snapshots.

The page title pushed `blockedReason` below the fold on a 40 mm, hiding the
one line that explains a disabled button. Dropped: the buttons name
themselves. The reason also finished flush against the bottom edge, which is
the class of bug that clipped on real hardware twice, so the content now
keeps clear of it.

Disabled buttons rendered inconsistently — a disabled green `borderedProminent`
desaturates to a pale grey that reads as tappable, while the accent-tinted one
went dark. Both grey out now.

Also fixes a debug affordance that never worked: `MeshMapperInitialPage` was
assigned in `onAppear`, and a `.verticalPage` TabView ignores a selection
change made that late, so every headless capture silently landed on the map.
It is the state's initial value now.

Node-list default is its own page, chosen for the room it gives the rows.
Sample controls gain `idle|blocked|cooldown` alongside `active`.
Three faults in one place, reported from the wrist as "the initial zoom
level was very high (multiple states)".

The opening view was never the default. `camera` starts `.automatic`,
which fits every annotation in the snapshot — continental with repeaters
spread wide — and `noteRenderedRegion` then adopted that span as though
the wearer had chosen it, so one auto-fit poisoned the zoom for the rest
of the session. Rendered spans are now ignored until `programmaticCenter`
exists, the same signal that already distinguishes our own camera updates
from the wearer's.

The default is 500 m rather than 3.3 km. The old comment argued the wide
span was deliberate for wardriving; that reasoning was mine and the wrist
disagreed, so it is gone rather than left contradicting the code.

Zoom now survives relaunch. It was `@State`, so every launch discarded it.
It lives in `WatchSettings` with the other preferences, clamped to
0.0005...0.5 on both read and write — persistence turns a stray Crown
flick into a permanent state, and the clamp is what stops a remembered
preference becoming a trap. Absence is distinguished from zero, because
`double(forKey:)` returns 0 for a missing key and would have opened every
fresh install at the 55 m minimum.

Persisting only on a material change (>1%), since every follow update
raises a camera change and writing an identical value would invalidate the
observable and re-render the map for nothing.

Verified on a fresh simulator container: opens at street level and stores
0.0045, which is proof the `.automatic` span was not adopted — that would
have stored a value orders of magnitude larger.
…, add the icon

Four wrist reports.

**The map flew across the north Pacific on launch.** Two faults stacked.
`recenterIfFollowing` always animated, so the first placement was a 0.25 s
flight from `.automatic`'s arbitrary opening position to the fix, dragging
tile loads the whole way. The first placement is now a cut; later ones
still animate, which is right for small follow nudges.

Underneath that, `.automatic` settles *after* our first request, centred on
the annotation cloud — measured 372 m from the fix — and `noteCameraChange`
read that disagreement as the wearer panning. It suspended following for
eight seconds and overwrote its own expectation, so our region landing then
looked like a *second* pan:

    [pan] SUSPEND dist=371.9 center=47.611891,-122.323025 expected=47.6122,-122.3181
    [pan] SUSPEND dist=371.9 center=47.6122,-122.3181     expected=47.611891,-122.323025

Nothing counts as a pan now until MapKit has confirmed a centre we asked
for. Zero suspensions at launch, down from two, and the fix lands 1.9 pt
from target on 46 mm and 0.25 pt on 40 mm. This also explains a transient
recenter button I dismissed as mid-animation two days ago, and it means the
placement regression in `a846153` was mine to catch and I did not — I said
the puck looked centred without measuring it. It was 57 pt low.

**Manual ping was offered where the phone would refuse it.** The watch gate
was `isConnected && hasGpsLock` plus cooldown; the app's own Send Ping
button requires twelve conditions. `_buildWatchControls` now mirrors that
set exactly, through the same `manualPingValidation` getter the widget uses,
rather than a second implementation of the policy. `blockedReason` gains
the app's own words for the two states it names, "Offline Mode" and
"Passive Only".

**Ping markers are circles.** They were squares under a comment claiming
they matched the iOS map, which is how it survived — `_CoverageMarkerPainter`
draws a filled circle with a white border. Mirrored at wrist scale, minus
the shadow, which would cost a blur each for up to sixty markers.

**The watch had no icon** because its `AppIcon.appiconset` held a
`Contents.json` expecting an image and no image. Copied the 1024 pt iOS
icon in; `AppIcon` now compiles into the watch's `Assets.car`, which
previously carried only `AccentColor`.
…orting

Three wrist reports, plus two defects found verifying them.

**The command handler's guards were weaker than the button's.** Its
manualPing case checked only connection, GPS and cooldown, then called
sendPing — while `_buildWatchControls` mirrored the app's twelve
conditions. So a wrist tap could reach the radio in a zone where TX is
not permitted, with nothing but sendPing's own checks between. That
contradicts the contract stated a few lines above it: every guard is
re-evaluated because a stale payload must never cause a transmit.

The condition set now lives once, in `_manualPingAvailability`, returning
availability and reason together. One caller decides what the wrist
offers; the other decides whether the radio transmits. Duplicating that
policy is exactly how the two drifted apart.

**"Stopping…" jumped to the right edge.** A SwiftUI ProgressView takes
the horizontal slack a stack offers it, so the spinner shoved the label
aside the moment a command went pending. Spinners are leading overlays
now, outside the layout that centres the label — feedback should not move
the thing under the wearer's thumb.

**A dead transport error sat under the button.** "Payload could not be
delivered." is WatchConnectivity's wording and it stayed on screen
indefinitely, reading as current state rather than one past action.
Refusals expire after six seconds, and the delivery-failure and
unreachable codes now say "iPhone didn't respond, try again". Refusals
from the phone stay verbatim — those are already written for people, and
rewording them would put the watch's guess above the phone's statement.
No retry: a send whose delivery is uncertain must not be repeated, or a
ping goes out twice.

Two defects caught in review, neither reported:

The cooldown label put two Text views in a `Group`, which applies each
modifier to every child — so `maxWidth: .infinity` went to the words and
the timer separately and threw them to opposite ends of the button.
Verified on screen before fixing. It is one HStack now.

The reason ladder fell through to "Another operation is in progress" even
when the ping *was* allowed, which would have printed that under two
working buttons. Reason is nil unless a refusal is actually happening.
Start and stop worked from the wrist but showed "iPhone didn't respond,
try again" every single time. The command was fine; the acknowledgement
was late. `relayCommand` replies only when Dart's future resolves, and
`_handleWatchCommand` awaited the entire action — `startSession` awaits
`toggleAutoPing`, which makes an API session check and drives BLE. That
outruns WatchConnectivity's reply window, so the watch's error handler
fired on every success.

Guards still run on arrival, unchanged: admission is decided
synchronously and refusals still travel in the reply. What changed is
that the action itself is no longer awaited before replying. Outcomes
were never the reply's job anyway — `isSessionActive`, the phase and the
ping colour all reach the wrist through snapshots.

That leaves failures that only appear later, and manual ping is the case
in point: it is refused inside `_checkSessionBeforeAction`, a *server*
call, so no local gate can predict it and the wrist got a bare "Ping
failed". `WatchHapticCue` already existed for events of this class, so it
gains an optional message; a failed action emits a unique-ID failure cue
and schedules a snapshot. The watch shows it through the same expiring
path as a refusal — one presentation for "the phone says something went
wrong", not two. Cue IDs are deduped against a bounded cache because
immediate messages and application context can deliver the same cue in
either order.

`_checkSessionBeforeAction` had `result.reason` and `result.message` and
discarded both. It now keeps them, so a refused ping says why, and
`zone_full` reuses the existing "Passive Only" wording rather than
inventing a third phrasing for one condition.

Wire version deliberately unchanged: the cue field is optional and both
sides ship in the same app.

Unverified end to end — reproducing it needs a phone doing real BLE work,
which the simulator cannot do. The reasoning and the gates are sound; the
wrist is the proof.
Start and stop kept reporting "iPhone didn't respond, try again" while
working. A device console capture ended the guessing:

    [WATCH] sendMessage(requestSnapshot) failed: ...device is not reachable.
    [WATCH] sendMessage(startSession)   failed: Payload could not be delivered.

`sendMessage` needs the counterpart app reachable, which for the phone
means roughly foreground — not the normal case when someone taps their
watch. WatchConnectivity delivered the payload and the phone acted on it,
but the reply could not return, so the watch reported `deliveryFailed`
after every success.

The previous fix, replying on admission rather than completion, was aimed
at reply latency. Latency was never the cause. Both of my diagnoses came
from the symptom; only the logged error settled it.

Commands and refresh requests now go by `transferUserInfo`: queued,
survives unreachability, wakes the counterpart, and has no reply to fail.
That is affordable only because outcomes and refusals already return as
snapshots and failure cues. `requestSnapshot` gains the most — it used to
fail outright with "not reachable", exactly when a refresh is most wanted.

Deliberately no opportunistic `sendMessage` and no retry on failure:
`deliveryFailed` is reported for payloads the phone *did* process, so a
fallback resend would transmit twice.

**Queued commands must expire.** A transfer can arrive whenever the phone
next becomes reachable, and a ping that fires minutes late is attributed
to where the vehicle now is rather than where it was. Commands carry
`issuedAtMs`; anything older than 30 s is refused before reaching
`_handleWatchCommand`. The ID is remembered first, so redelivery cannot
retry it later. `requestSnapshot` is exempt — a late refresh is harmless —
and a missing timestamp is still accepted, for watches running the older
build.

`pendingCommand` was cleared by the reply that no longer exists, so it now
clears on the next snapshot with a 10 s backstop; a spinner that never
stops is worse than none. `WatchCommandAck` is removed rather than left
describing a protocol we no longer speak.
Starting a session from the wrist did nothing, while stopping produced a
cooldown that passive mode never creates. `_handleWatchCommand` called
`toggleAutoPing(_autoMode)`, but `_autoMode` defaults to Active and is
only assigned inside `toggleAutoPing` when a mode actually starts. So
until a mode had been started *on the phone*, the wrist started Active —
which in Adam's passive-only region is the one mode forbidden there.

His Live Activity had been reporting this all along: the "circle with a
line through it" is the `txBlocked` phase, which fires on exactly
`(_autoMode == active|hybrid|targeted) && !txAllowed`. Pressing Passive
on the phone set `_autoMode`, which is why every wrist toggle worked
afterwards.

The phone never hit this because each of its buttons passes an explicit
mode. Only the wrist inherited an implicit one, and the default happened
to be the forbidden one.

`_resolvedWatchSessionMode` now decides: a running session keeps its own
mode, so the wrist stops what it started; otherwise a region that forbids
TX resolves to Passive; otherwise the wearer's last choice stands.

The button says which mode it will start — "Start Passive" — because a
wrist control that silently picks a mode is only safe while the guess is
right. That label reads from the same resolver as the action; sourcing it
from the ambient `_autoMode` would have traded a silent wrong action for
a visible lie. Only the watch payload's `mode` changed: the Live
Activity keeps `_liveActivityModeTitle`, since it reports the session
that is running rather than the one a button would start.
"The design of the live event panel on the watch and iphone leave a lot
to be desired. We have better design elements in the app we should
repurpose." The elements worth repurposing are the ones now signed off on
the wrist: a depleting countdown bar, and rows of hex identity with a
ping-type dot and a quality-coloured SNR.

Most of the gap was data, not styling. `ContentState` carried
`phaseEndsAt` but no duration, so a progress bar could only be full or
empty. `HeardRepeater` was `{id, name, snr}` with no ping type and no
colour, so every dot was painted the same grey-teal and the distinction
between a discovery answer, a flood answer and an RX packet — which the
map overlay is built around — could not be drawn at all. Nothing carried
the last ping's outcome, so an unanswered ping, a real negative result
when mapping coverage, looked identical to a cycle that had not reported
yet. And the extension hardcoded three colours, quietly ignoring the
colour-vision palettes that the watch honours for free.

So the wire gains `phaseDurationMs`, `pingColor`, and per-repeater
`typeColor`/`snrColor`, all resolved on the phone through the same
helpers the watch already uses rather than a second set. Colour policy
stays in Dart, where the palettes live.

Layouts, per surface's real constraints: the lock screen shows hex *and*
resolved name, because that is what the larger display is for; the watch
small family shows hex only, since the hash is the identity and there is
no room for more; the island's minimal presentation carries the outcome
colour, since one mark should be the most valuable one.

A lapsed deadline dims its title to 45% and drops the countdown, matching
the rule the watch already follows — these surfaces can sit on a stale
state for a long time, and neither should keep asserting a phase it can
no longer vouch for.

Reviewed as rendered pixels, not as code. The content views take a plain
`ContentState`, so `ImageRenderer` can draw them headlessly — nine images
across three states at each surface's real width. That caught what
reading could not: the metrics sat on the third repeater row's baseline,
so `91CE  -8.7 dB  TX 42  RX 318` read as one line and the session totals
looked like properties of a repeater. They now share the badge row,
taking the space back from `phaseDetail`, which on the lock screen only
restated the bar above it. The detail stays in the payload — several
phases carry information the bar does not, and the island's centre region
is the place for it.

Session-end summary is deliberately not here; it is the next round.
Adam, on the render: "Don't tint the bar. That's too much of an error
telegraph for a common case."

He is right, and the mistake was mine. An unanswered ping is the normal
outcome in thin coverage — it is the thing being mapped, not a fault.
Filling the whole progress bar red made an ordinary result look like a
system failure, and in doing so left nothing louder for actual errors.

The bar and the Dynamic Island keyline now take a neutral accent, and the
compact-leading glyph follows the phase rather than the last ping. The
outcome stays legible where it belongs: the outcome dot on the small and
minimal presentations, the coloured dot beside "Nothing heard", and the
per-repeater type dots. None of those were made louder to compensate —
the point is that a routine negative result should be available, not
announced.

The watch's own bar has the same tint and arguably the same problem, but
that surface was signed off on hardware, so it is asked about rather than
changed here.

Verified by re-rendering all nine states through the harness.
"The teal passive pings aren't being displayed as dots on the map or they
are being rendered as purple rx dots."

They were never displayable. `buildPings` took only `txPings` and
`rxPings`, and `_buildWatchGeo` passed exactly those — so the builder's
`pingColor('disc', …)` teal branch and its `trace` branch were
unreachable code, and RX purple was the only non-TX colour the watch
could draw. Green was fine: it is on the path that runs.

The phone draws these from sources the watch was never handed:
`discLogEntries`, where success is `discoveredNodes.isNotEmpty`, and
`traceLogEntries`, which carries `success` outright. Both now reach the
wire, with the phone's own success rules rather than new ones.

The cap needed rethinking with four sources. It applied to a list built
as "all TX, then all RX", which with discovery added could have kept
sixty TX markers and dropped every teal one — the same bug wearing a
different hat. Candidates are now sorted newest-first across all types
before the cap, so history thins evenly instead of a category vanishing.

Also mirrors the phone's multi-hop rule, which is the other half of what
he saw: a TX answered only through multi-hop draws as an RX marker,
because that is what it evidences — the packet returned, but not
directly. `pathHops == null` marks a direct echo.

Four types, four colour rules, and until now nothing asserted that a
discovery ping ever reached the wire at all — which is exactly how a
whole category went missing unnoticed. The tests do that now, including
that the cap starves no single type.

Not fixed here, and reported separately: RX-only repeaters never get the
current-cycle ring, because `heardIds` is built from `_topRepeatersOverlay`
alone and omits `_rxOverlaySlot`. That is the repeater pins, not the ping
markers, so it does not belong in this change.
A 71-minute walk cost ~40% of Adam's watch battery — about 34%/hour,
which makes the app useless for the long drive it exists for.

Two patterns account for the obvious waste, and neither was measured
here: watch power cannot be instrumented from this machine, so these are
the known-expensive things removed, not a proven culprit. A real walk is
the only test.

**Always-On was unhandled.** Phase 7 planned it and it was never built,
so for most of that walk — wrist down, app frontmost — watchOS was
rendering a live MapKit view with annotations. MapKit is the most
power-hungry thing on the device, and none of it is legible at reduced
luminance. The dimmed state now removes that subtree entirely rather than
covering it, and stops driving the camera: no recentring, no corrections,
no animations until full luminance returns.

**A 1 Hz TimelineView redrew the panel over the live map** for the whole
session, compositing translucent material every second. Most of it bought
nothing: `Text(timerInterval:)` already updates itself natively, and a
depleting bar can be one linear animation over the remaining phase rather
than thousands of view updates. The Live Activity's bar had the same
timeline and the same fix. No 1 Hz timeline remains anywhere.

Always-On also needed its own layout, which only became apparent once it
could be seen. Reusing the map's overlay panel left a small card pinned to
the bottom of a black screen at map-overlay type size — and then the phase
title truncated to "List…" on a 40 mm, hiding the one thing a dimmed
glance is for. The dimmed view now spends the space it actually has: title
at 18 pt wrapping rather than truncating, countdown at 24 pt beneath it,
Top Heard full width below. The progress bar is gone from that state —
beside an explicit countdown it was duplicated information and another
compositing pass.

The countdown reads "<1 min" / "3 min" there, because Always-On updates
about once a minute and a seconds figure would be silently up to a minute
wrong.

`MeshMapperForceDimmed` is kept, not scaffolding: Always-On cannot be
entered in the simulator and otherwise needs a wrist-down device, so
without it this surface goes back to being unreviewable — which is how it
shipped bottom-pinned and truncated in the first place.
… glanceable

Three things from real-device use.

**The bar stuttered in "ping skipped" mode.** `phaseKey` included the
phase and its title, and `.task(id:)` restarted the drain whenever either
changed. In skip mode the phase flips between waiting/"Next ping" and
skipped/"Ping skipped" while the *same* auto-ping timer runs to an
unchanged deadline — so each flip cancelled the animation, snapped the
fill back to its true fraction and started again. The bar was reporting a
change of wording as a change in time. It keys on the deadline and
duration alone now; the title is a caption over the drain, not part of it.

**A bar that cannot be refreshed should not pretend to move.** On the
iPhone's always-on lock screen, refreshing about once a minute, the
animated fill rendered frozen mid-drain — which reads as a stalled
session, worse than showing nothing. At reduced luminance the Live
Activity now draws the track alone and lets the countdown carry the
state, a coarse number being honest where a stopped bar is not. Adam's
rule, worth keeping: an element implying continuous motion must not be
drawn where the refresh rate cannot deliver it.

**The panel's text was too small to glance at.** Raising it buys width,
because the curvature clearance needed at a given height falls as the
panel moves up the curve, and width buys type size through the existing
solver. This re-couples placement to clearance, which `bf6f7ef`
deliberately decoupled — that was right when he wanted the panel
narrower, and this is right now that he wants it legible. His call both
times.

  46 mm  gap 18.9  inset 8.3  panel 191  font 11.0
  45 mm  gap 18.4  inset 8.2  panel 182  font 11.0
  40 mm  gap 10.0  inset 6.3  panel 149  font 8.7

The ladder cap rose a point too, since width alone was no longer the
binding constraint. Mirroring the phone's `RepeaterIdChip` sizes was the
original reason for it, but a watch is read at arm's length in motion.

Worth flagging: the panel is now nearly as wide as it was before he asked
for it narrowed, though sitting much higher. The clearance arithmetic says
that is safe — at 19 pt up the curve the corner needs only ~4 pt of inset
— but the hardware confirmation was taken at the previous geometry, so
this specific combination is unverified on glass.
"On the map our location doesn't [stay] fixed in the center with the map
moving around, instead we move on the map and then recenter."

Two decisions collided. The phone withholds geo updates until the fix
moves 15 m, so nothing moves between snapshots and each one lands as a
single large step. And the camera animated over 0.25 s while the fix
annotation — anchored to a coordinate — moved the instant the snapshot
arrived. So the puck jumped ahead and the map slid after it, which is
precisely the sensation of moving across the map and being chased.

Worst where he noticed it: at walking pace that is one lurch every ~11 s.
At 30 mph the same threshold fires every ~1.2 s and reads as continuous.

Automatic follow updates and placement corrections now cut, so the camera
moves in the same frame as the fix and the puck stays where it is while
the world steps beneath it. An explicit recentre tap still animates: it is
rare, the wearer asked for it, and the motion shows what their tap did.
Nobody asked for a follow update, so nothing should appear to move except
the world.

Two alternatives were considered and deliberately not taken, both
recorded: lowering the 15 m threshold buys smaller steps with more radio
wakeups, and interpolating between fixes would pan continuously but
invents position data and reinstates the continuous animation `f15c93e`
removed. Either is available if stepping still distracts on a drive —
after tomorrow's battery numbers, not before.
"The countdown timer in the live [activity] is not positioned at the end
of the bar. It would be nice to move it a bit closer to the end of the
bar as it is in the watch app."

Measured from a render rather than guessed: the number's right edge sat
8.7 pt inside the track on the lock screen and 8.3 in the island. Because
the cap is rounded, the eye measures to the curve, which makes that gap
read as larger than it is — the number looked adrift in the dark part of
the track rather than anchored to its end.

The two ends were being inset equally, but they are not symmetric in
effect: the title begins against a straight fill edge while the countdown
ends against a curve. The inset is asymmetric now, and the gap measures
4.0 pt on the lock screen, 3.7 in the island and 4.0 on the small family.

The spacer between title and countdown is untouched — it is what
guarantees the title truncates before the two can collide, so buying room
from it would trade one defect for a worse one.
…entity

Three findings from the branch review, all traced before being believed.

**A synthetic timestamp defeated the dedupe and lied on screen.** Every
heard row carried `at: now` — the moment the payload was built — so once
Top Heard held anything, each rebuild produced a different fingerprint and
the 2 s throttle became the only brake: roughly 2,100 context updates
across a 71-minute session where near-zero were intended. Excluding
`updatedAtMs` from the fingerprint had achieved nothing. It was also
displayed: Node Detail's "Heard" time read as now, always. Rows now carry
when their set last changed, tracked separately for Top Heard and the RX
slot because multi-hop updates can move one without moving the other. The
manual-cooldown deadline serialises the timer's own `endTime` rather than
being reconstructed from two `DateTime.now()` reads whose jitter alone
changed the fingerprint.

**The cheap check ran after the expensive one.** `_flush` built the whole
geo payload — merging and sorting up to 2,000 ping candidates, resolving
colours and distances, evaluating the twelve-condition ping gate — then
serialised it, and only then compared the fingerprint and usually threw it
away. Five countdown timers tick at 500 ms into the scheduler, so a quiet
session did that twice a second. Urgency is now decided from a small
scalar projection first, and a flush inside the throttle window
reschedules without building anything.

Worse, none of it was gated on owning a watch. `isSupportedPlatform` only
asked whether this was iOS; native refused the payload at `isPaired` /
`isWatchAppInstalled`, but after Dart had built, encoded and crossed the
method channel. Someone with no Apple Watch paid all of that for a payload
that was discarded. Native now publishes its availability and the
scheduler does nothing without it — and because `sessionWatchStateDidChange`
republishes, a watch paired after launch starts working without a restart.

**Links compared incompatible identities, so none had ever drawn.**
`linkedRepeaterIds` and the heard IDs carry path hashes; `WatchRepeater.id`
carried the API database ID and `hexId` never reached the wire. The watch
compared the two exactly, so no link line has ever appeared and the
current-cycle ring almost never fired. Both identities now travel, and
matching resolves a path hash as a unique hex prefix — ambiguous prefixes
draw nothing, because a line to the wrong repeater asserts a relationship
that does not exist. The RX slot joins the highlight set, which was the
item deferred from the ping fix.

Nine tests cover the parts that were blind: timestamp stability across
rebuilds, throttling before the build, pairing transitions, prefix links,
ambiguity, and RX-only highlighting.
Five remaining review findings.

**Stop was silently dropped while a session was starting.** Admission
checked `_autoPingEnabled` but not `_autoPingStarting`, and during Start's
awaited session check the first is false while the second is true — so a
Stop arriving in that window was treated as "already stopped" and
discarded, after which the session came up anyway. Start in that window is
genuinely idempotent and is still accepted as a no-op; Stop is not, so it
now refuses with "Still starting — try Stop again." No deferred queue: a
refusal the wearer can act on beats hidden ordering they cannot see.

**An idle watch claimed to be preparing a session.** The shared phase
resolver maps "no session" onto Starting, which is right for the Live
Activity — whose builder only runs during a session — and wrong for the
watch, which is always present. A connected, GPS-locked, idle watch said
"Preparing session…" indefinitely, including right after Stop. The watch
projects that fallback to a new idle phase, "Ready / No session running";
the Live Activity still calls the shared resolver directly and is
unchanged.

**The outcome colour only ever read TX history**, so a Passive session
showed whatever TX last did, and a multi-hop-only TX reported success
while the map marker beside it drew RX purple — the same event described
two ways. It now follows the newest event across all four histories and
applies the map's multi-hop rule.

**Staleness never invalidated the view.** `isStale` compared against
`receivedAt` with nothing changing at the 90-second boundary, so on a
durable phase a dead link could look current indefinitely. The boundary is
an event now: one cancellable task per snapshot, not a poll — this is the
app whose battery we spent the day cutting.

**A failure cue replayed after a watch restart**, because the phone never
cleared it and the watch deduped IDs in process memory only. The phone
drops a cue once native accepts it, and the watch ignores anything undated
or older than 30 seconds. Either half alone leaves the hole open.
At full luminance the readout sits below the navigation bar that hosts
the map/readout toggle. Hardware hides that bar in Always-On, so the
same surface rendered several points higher — the shift Adam saw when
comparing the two.

Rather than encode which way it moves, the readout now measures the
full-luminance bar and restores only what disappears:
`max(0, latched - current)`. Equal insets give zero and nothing moves;
a missing reference is deliberately a no-op, the same stance the panel
takes when its own measurement is absent.

Two details that are easy to get wrong and were both measured, not
reasoned. The inset settles *upward* — 28.0 then 35.5 on 40 mm, 44.5
then 59.75 on 46 mm — as the bar grows to fit its toolbar item, so the
reference is the largest value seen, not the first. First-nonzero would
have under-corrected by 7.5 pt and 15.25 pt respectively, which reads as
"nearly fixed" rather than as a bug. And the map reports a different
inset again (47.5 and 62.0) because it extends beneath the top chrome,
so only the readout may set the reference; borrowing the map's would
push the readout down past where it belongs.

The top rule therefore differs from the bottom's first-nonzero latch on
purpose. The bottom value drives padding and can feed back into the
measurement that produced it; this one drives a visual offset that
cannot, so tracking a maximum is safe here and not there.

Full-luminance readout measured unmoved to 0.00 pt on both sizes. The
Always-On half is verifiable only on hardware.
The previous correction moved the readout with `.offset(y:)`, which
repositions without consuming space. Always-On hands back the hidden
navigation bar's height, so the container grew by that much and the
`Spacer(minLength: 6)` between the phase block and Top Heard absorbed
the slack. The top-anchored phase block landed correctly and Top Heard
was pushed down — which is exactly what Adam saw: headers matching,
Top Heard low.

Applying the same value as `.padding(.top,)` consumes the returned
height as well as setting the origin, so the content box matches the
full-luminance reference in size and position. The spacer then has the
same slack it has at full luminance, which is none, and Top Heard packs
where it does there.

Padding a descendant cannot disturb the measurement: the reader takes
its safe-area inset from the navigation host, which our padding does
not feed. That is also what still permits the top reference to track a
maximum while the bottom latches its first nonzero value.

Full-luminance readout measured unmoved to 0.00 pt on both sizes again.
Adam asked for a much tighter starting zoom. The default drops from
0.0045 to 0.00225 degrees latitude — about 250 m north-south — with a
one-time `map.zoomDefaultsVersion` migration, because the wearer's zoom
is remembered and every watch that has shown the map already has a
stored value that would otherwise win.

The constant alone would not have worked. `noteRenderedRegion` guarded
only on `programmaticCenter != nil`, which proves a region was
requested, not that MapKit rendered it. MapKit reports its `.automatic`
annotation fit for several callbacks after our first assignment, and
those spans were persisted as though the wearer had chosen them. A
fresh install stored 0.136 degrees on 40 mm and 0.0875 on 46 mm — 15 km
and 9.7 km — and read that back on the next launch, so the map started
wide and stayed there. That is almost certainly the behaviour being
reported, and it would have eaten the new default within seconds.

The centre had already been given this treatment: nothing counts as a
pan until MapKit confirms a centre we asked for. The span now gets the
same handshake. Traced on a fresh install: three automatic callbacks at
0.136 rejected at 59x the request, then a rendered 0.00225 confirms,
after which callbacks reach the normal write-back and decline only
because the value is unchanged.

The confirmation tolerance is deliberately loose at 25%. It separates
our request from a fit dozens of times wider, and an exact test could
wait forever if MapKit adjusted a span for display geometry, silently
disabling zoom memory — a quieter failure than the one being fixed.

Fresh install and warm relaunch both hold 0.00225 on 40 mm and 46 mm.
The top bar holds exactly one item per side — a second leading item is
silently dropped, which I confirmed by rendering it — so the trailing
slot is the only one left, and the system moves the clock to centre to
make room. It now carries session control.

Which control owns the slot follows stable facts, never the live ping
gate:

  no session                          -> Start
  running, ping applicable + opted in -> Ping
  running, otherwise                  -> Stop

That distinction is the point. `canManualPing` flickers with cooldowns,
RX windows and discovery windows, so keying the slot on it would change
the button's identity under a moving thumb and turn a ping into a stop.
The phone now publishes `manualPingApplicable` — connected, TX permitted
by the region, and no Active, Hybrid or Trace session running — which
holds still for the duration of a session. Live availability only
enables or disables, so a cooldown greys the ping button rather than
replacing it.

Ping cannot fire during Active or Hybrid at all; `isTxModeRunning` gates
it in the shared availability rule and again in the phone's own button.
So the slot correctly shows Stop there rather than a permanently dead
ping.

Stop and Ping take two taps within three seconds, reusing the arming
pattern manual ping already had, because this button sits under the
thumb on a surface that is also panned. Start does not confirm; starting
is recoverable and stopping loses the session.

Start now sends an explicit mode. The snapshot advertises which the zone
permits, the wrist picks its default from those, and the phone
revalidates and refuses rather than downgrading. Settings gains Default
start mode, defaulting to Passive with Hybrid offered only where it is
permitted, and states plainly when a stored Hybrid cannot be honoured.
Main page and Node list move above Map, so the choice that decides
whether the map toggles matter is no longer below them.

All six slot states driven through sample data on both watch sizes.
`effectiveStartMode` is currently computed in two places; consolidating
it is the first item of the next change.
It used raw system tints and a bare stack of two buttons, which is most
of why it read as a page from a different app. It now uses the phone's
own vocabulary: slate surfaces, green #22C55E, the app's red #BD2130,
indigo #6366F1, a 12 pt material card with a hairline border matching
the map's status panel, and the readout's approved type scale.

Three substantive changes beyond paint. A compact mode and phase header,
because the page previously gave no way to see what was running while
looking at the controls for it. `blockedReason` and refusals now sit
directly beneath the control they explain rather than floating at the
bottom as two grey strings. And the phase shrinks to fit rather than
truncating — on 40 mm it rendered "Listenin…", and since the titles the
phone sends already end in an ellipsis, that read as a rendering fault.

The controls were 69.5 pt tall against a 44 pt ergonomic floor, which
pushed the last one to within 5 pt of the 46 mm display bottom. At the
card's 14 pt margin and that watch's ~37 pt corner radius the curve
needs about 8 pt, so its corners were clipped — the failure mode this
project has hit repeatedly, and again invisible in a flat simulator
rectangle until measured. Moving the 44 pt guarantee to the styled
button rather than its label brings them to 52 pt on 46 mm and 44.5 pt
on 40 mm. Every sample state now fits both displays without scrolling,
with 40 pt and 16 pt of clearance in the tightest one.

`effectiveStartMode` is now single-sourced on `WatchSettings`; the map
and settings copies had already begun to diverge in form. The toolbar's
trailing control drops its capsule fill for a coloured glyph on the
default glass, matching the leading toggle it sits opposite. Only the
armed state stays filled: the confirm window is the one state with a
consequence attached, so it is the one that should be loud.

One defect fixed along the way. Associating refusals with their
originating control had routed phone-originated cues through the same
path, so an unrelated cue inherited whatever the wrist last tapped and
could report a failure under Manual ping for a ping that succeeded.
`WatchHapticCue` carries no correlation to a command, so attribution is
now explicit per call site and cues are deliberately unattributed.
Three fixes from Adam's walk.

The trailing control looked empty with nothing connected. It was drawn
correctly — a disabled play glyph in slate on glass — but slate over
Apple's darkest basemap is invisible, so it read as an empty circle.
Disconnection now has its own glyph rather than a dimmed Start, and the
disabled colour moves to the brighter slate so that no disabled control
reads as empty. The accessibility label still distinguishes a missing
phone from a missing device even though the glyph cannot.

Satellite did nothing. `.mapStyle(.imagery)` renders pixel-identically
to `.standard` on watchOS: a diff of the two states across the whole
basemap band found zero differing pixels, and it behaved the same on
Adam's watch. The toggle is gone rather than left as a control that
silently does nothing, and the header comment records it alongside the
other watchOS MapKit limits.

The map claimed both gestures. `interactionModes` included `.pan`, so
vertical swipes dragged the basemap instead of paging and the status
panel was the only place a swipe could change pages. Zoom-only gives the
page back its swipe while leaving the crown as the deliberate zoom
control.

That removes the pan-suspension machinery entirely, and deliberately
rather than incidentally. Its purpose was to stop fighting a wearer who
had dragged the map. With drag gone, the only remaining source of centre
drift is MapKit settling and crown zoom — neither of which is a wearer
moving the map — so a distance heuristic could no longer identify a pan
truthfully, only misfire and suspend follow for eight seconds. Keeping
it would have introduced the bug this change was meant to avoid.

`programmaticCenter` stays: it is now solely the first half of the span
handshake that keeps `.automatic` from becoming the remembered zoom.
Fresh installs still settle at 250 m on both watch sizes.
Pre-beta review round one: four correctness defects, none cosmetic.

The wrist could start a session the phone's own button would have
refused. `canStartStop` checked connection and nothing else, while the
phone gates Start on nine conditions. Starting Active from the wrist
while a manual ping was in flight made `sendTxPing` return early on
`_pingInProgress`, so the first automatic ping neither transmitted nor
scheduled the next cycle: a session reporting itself active that never
advanced. A tester could have walked an hour and come back with nothing.

Start admission is now one rule, mirroring how `_manualPingAvailability`
is already the single copy of the ping gate, and consumed by both the
offered button and the radio admission. It is mode-aware on purpose:
Passive does not transmit, so TX cooldowns, receive windows and zone TX
policy must not block it — refusing a Passive start because a manual
ping was in flight would be the same bug pointing the other way.
`ping_controls.dart` keeps its own copy for now; its enablement is
entangled with running-mode toggles and labels, and restructuring the
phone UI days before a beta buys a worse risk than it removes.

`DebugPage` shipped in Release with a single-tap manual ping, bypassing
the confirmation the real surfaces have precisely because that action
transmits. It is now DEBUG-only, and while it remains there its ping
confirms and its Start sends the configured mode.

Command failures had no presentation on the map. The toolbar is now the
primary way to start and stop a session, but refusals only reached
`ControlsPage`, so a wrist Start could fail, clear its spinner, and
explain nothing on the surface being looked at. Both surfaces now show a
transient toast on the existing six-second lifetime, layout-neutral and
absent at reduced luminance. The readout anchors it to the safe-area
child rather than to the black backing: that sibling ignores the safe
area and would have placed a bottom-aligned overlay off-screen, the
third time this file has been bitten by geometry read from an expanded
region. Sized to clear the corner radius so it drops to 2.5 pt off the
physical edge, it sits below Top Heard entirely on 46 mm.

Controls sent no mode while the toolbar sent an explicit one, so the two
made different promises — and on a fresh phone session in a TX region,
ambient `_autoMode` can be Active while the wrist's stored default is
Passive. Both now send the same single-sourced effective mode.

One known limit, not worth wire churn before the beta: enablement is
computed for Passive because the chosen mode is watch-local, so a
blocked Hybrid start shows an enabled button and an explained refusal
rather than a disabled one. Publishing availability per mode would fix
it properly.

`-MeshMapperForceRefusal` joins the DEBUG launch arguments, since the
banner is otherwise unreachable in a simulator with no way to tap.
Measured on a maximal payload — 60 pings, 20 repeaters, four heard rows,
encoded exactly as the wire does:

  full        11,609 bytes
  suppressed   1,443 bytes   (87.6% smaller)

Geography was built unconditionally while the phone had no idea what the
watch was displaying; it knew only reachability and activation. So every
ping coordinate, repeater name and colour was sorted, encoded,
transmitted, decoded and discarded whenever the wearer sat on the
readout, on Controls or Settings, or — most of a walk — had the wrist
down in Always-On, where the readout draws none of it.

The wrist now reports whether its current surface needs geography, over
the queued command path that already existed. The phone skips building
the markers as well as encoding them, so the wasted sort of up to two
thousand ping candidates down to sixty goes with them.

Every uncertainty resolves toward sending geography, because the failure
modes are not symmetric: extra bytes cost battery, an empty map costs
the feature. Suppression is a lease the wrist renews rather than a latch
it sets, so a watch that stops reporting returns to full payloads by
itself. Launch assumes geography is needed until the surface says
otherwise. Returning to the map requests a full snapshot immediately
instead of waiting for the next scheduled push, and a suppressed payload
arriving after the map became visible triggers a throttled replacement
rather than leaving half-stale markers on screen.

Suppression waits fifteen seconds so an ordinary glance does not enqueue
a false-then-true pair, which would trade one kind of waste for another.

Wire stays at version 2: both fields are additive, an old phone ignores
the claim and keeps sending everything, an absent flag decodes as
included.
The Crown zooms about the map's region centre. We had been lifting that
centre by hand so the fix rendered above the status panel, so a zoom
slid the puck across the screen and the correction pass snapped it back
when the gesture ended. Both halves worked as designed; the design was
the problem.

Telling MapKit what is covering it — a safe-area inset for the panel and
for the toolbar strip — makes its own centre the centre of the band the
wearer can actually see. The fix then lands there because it is centred
on, not translated toward, and a zoom anchors on it. Adam drove the
Crown on a build of this and reported it stays centred throughout, which
is the half neither the simulator nor a static capture can show.

That removes the machinery the old approach needed: the target point,
the coordinate translation, the post-render correction loop and its
deadband, and the remembered rendered centre. `programmaticCenter`
stays, now solely as the sentinel for the span handshake, which is
unaffected — it is only ever tested for non-nil.

Both edges are inset on purpose. Insetting only the bottom centres the
fix in the band from the top of the display to the panel, which still
includes the toolbar, and the puck reads high — plainly so on 46 mm
where the chrome is a smaller fraction of the screen.

Measured: the fix sits at 91.2 pt on 40 mm against a predicted 92, and
116.8 pt on 46 mm against 117. The status panel is pixel-identical
across every variant, a fresh install still settles at 250 m on both
sizes, and a panel height change — forced with long hex IDs — leaves the
persisted span untouched, so a growing panel cannot drift the zoom.

`MapReader` is now vestigial: nothing calls `proxy.convert`, so the
proxy is threaded through five functions unused and its comment is no
longer true. Removing it is a hierarchy change around a map whose launch
and framing behaviour was just verified, so it belongs in its own change
with its own A/B rather than riding along here.
Camera placement moved to safe-area insets, so nothing calls
proxy.convert any more and the reader's comment claimed a purpose it no
longer has. Record what it is and why removing it wants its own change,
rather than leaving a false rationale for the next reader to trust.
Marker ids embedded the entry's index in its source list. Those
histories insert at the front and trim from the back, so one new
discovery renumbered every surviving marker and SwiftUI saw sixty
replacements rather than a single insertion — tearing down and
rebuilding the whole annotation set to show one new dot. TX and RX had
the same fault once their five-hundred-entry lists began trimming.

Identity now comes from the event's own timestamp, which is intrinsic to
it and survives both insertion and trimming. Two events of one kind
inside the same millisecond are the only case needing a discriminator,
and it counts within the colliding group rather than across the list, so
it does not reintroduce positional churn. The ids are also marginally
shorter than the ones they replace.

The regression test was checked against the old implementation and fails
on it, so it covers the property rather than merely describing it.

Not measured: how much energy the churn actually cost. That needs Watch
Instruments on hardware. The churn itself was certain from the code, and
the fix is cheap enough not to need the number first.
The bar's fill is an animated layout width, so SwiftUI re-runs layout on
the main thread every frame for the whole phase instead of handing a
transform to the render server. That is the expensive class of
animation, and a session is almost entirely consecutive countdown
phases — but how much energy it actually costs is not knowable from the
source, and the cheaper alternatives change how the bar looks. Adam
chose this bar, so the measurement has to come before the redesign.

A DEBUG-only Settings toggle freezes the drain and leaves everything
else running: same snapshots, same markers, same live timer text, same
layout. An Instruments trace of the two states therefore isolates this
one animation rather than a whole different build.

It lives on the watch rather than behind a launch argument so the two
states can be compared on a walk without a Mac, and it is read
statically so flipping it does not invalidate the view mid-drain and
perturb the trace it exists to produce. Compiled out of Release.
Swiping up from the map left the controls page blurred and deaf to every
swipe and Crown turn. It reads as a crash and is not one: the process
lives, holding a modal it should never have raised.

Two faults compound. The status panel is a button that opens the heard
sheet, and it lies across the bottom of the map — exactly where an
upward page swipe begins — so the pager and the button both claimed the
same touch. Then the page changed underneath the sheet, leaving a modal
belonging to the map presented over the controls page, blurring it and
swallowing input.

That became reachable when the map gave up `.pan`. Before, an upward
drag moved the basemap; now it pages, so wearers swipe from wherever
their thumb rests, and the panel is the largest target on the screen.

The panel now opens the sheet only when the heard list is actually
placed in a sheet. With the list on its own page — the default — it was
presenting a duplicate of a page that already existed, so the tap had
nothing to offer and every cost of firing by accident. And a sheet is
dismissed when its page stops being selected, because a sheet belongs to
the page that raised it; that guard holds however the sheet was raised.

Reproduced first: presenting the sheet and then changing pages leaves it
stranded with the controls page's buttons bleeding through behind it.
Both are fixed, and the sheet still presents correctly over the map when
that is the chosen placement.

The page-switch launch argument used to reproduce it stays, documented
with the others. The simulator cannot swipe, and this class of defect is
invisible without it.
Swiping off the map wedged the watch: the destination page rendered
blurred and stopped accepting swipes and Crown input. No crash report,
process alive — the main thread simply never came free.

A log capture during the gesture showed why. In forty-five seconds:
444,859 lines, 21,611 MapKit reconfigurations, and 194,490 evaluations
of one debug flag read from the view body. Roughly 480 map rebuilds and
4,300 body passes per second.

The cycle ran through the camera inset added in c2b9735:

  panel measured in .global -> panelFrame -> panelCameraInset
    -> Map safeAreaPadding -> layout invalidation -> new global frame

An interactive swipe translates the page, so the panel's global position
changed every frame, cleared the half-point guard, and re-framed the map
again. Assigning `selection` in code swaps pages without ever producing
those intermediate positions, which is exactly why every test passed
while the real gesture was broken. Three rounds of green simulator runs
described a build that locked up in ordinary use.

The inset only ever needed to know how much of the display the panel
covers, and that follows from its height plus the gap beneath it — both
invariant under translation. So the cycle cannot close: moving the page
no longer changes anything the map is told.

The inset is also clamped to three quarters of the display. A status
panel has no business claiming more, and a future measurement fault
should degrade the framing rather than starve MapKit of anywhere to put
the camera.

Placement is unchanged: the fix renders at 91.25 pt against 91.2 pt
before. Adam confirmed by hand that the gesture no longer wedges, and
the same capture now yields 10,146 lines with 704 map reconfigurations.
`camera` started `.automatic` and `recenterIfFollowing` was the only thing
that ever assigned a region, so its three early returns each left the camera
untouched. `.automatic` fits every annotation, which on hardware meant 34.4
degrees of latitude — about 3,800 km — and nothing recovered from it. Twenty
consecutive continental callbacks across five wrist raises; only a manual
Crown zoom escaped.

Both triggers are now confirmed on a Series 9 and both are handled:

  Follow off    reproduced in the simulator, which had never shown this
                because nobody had turned Follow off there
  fix == nil    caught on device, `.automatic` reporting a 58.4-degree fit

`anchorCameraIfNeeded` asserts a region once per native-map lifetime from a
fresh stored centre, else the live fix, else a stale stored centre, so
`isFollowing` governs tracking rather than whether any region is asserted at
all. The centre persists in `WatchSettings` as one array — two keys can tear
if the app is killed between writes — with a timestamp, because this map
cannot pan and opening on a centre the wearer has travelled away from strands
them somewhere they can only zoom.

The span handshake is unchanged and still load-bearing: the `.automatic` fit
arrives first on every rebuild and is rejected by the 25% gate, so a broken
camera never corrupts the saved zoom. Verified in the logs as
`confirmed N drift 3789.0%` followed by our own region at `drift 0.0%`.

Also here, all measured rather than reasoned:

- Past roughly 22 degrees of span MapKit shifts the camera centre north and
  leaves it there — 8.2 km at 24.5 degrees, 14.0 km at 25.0 — and the shift
  survived every later zoom including one back in to street level. It is now
  repaired after each Crown zoom, preserving the rendered span so correcting
  the centre does not undo the zoom.

- The zoom floor drops to 0.0002. The Crown reaches 0.000230 degrees (~26 m)
  and the old 0.0005 floor clamped it, so zooming in snapped back out. The
  ceiling stays 0.5: zooming out past it still works, only persistence caps.

- `MapReader` is gone. Camera placement moved to `.safeAreaPadding`, nothing
  called `proxy.convert`, and the proxy was threaded through six functions
  unread.

- `WakeLog` and a persisted Instruments toggle, because launch arguments live
  in `NSArgumentDomain` and evaporate when watchOS relaunches the app, which
  is exactly what a wrist-down test provokes.

- No main-actor hitch detector. One was written and removed: it fired on every
  wake at almost exactly the wrist-down duration, because it was timing the
  app being suspended rather than the main actor being blocked.
The phone stopped sending snapshots for two hours and said nothing. The
cause was `WCSession.isWatchAppInstalled` reporting false, so
`WatchSessionManager.send` returned false and Dart's `schedule()` bailed
before attempting anything. Neither side logs that: the bridge had no
logging at all, and native only logs when a real send attempt fails, which
never happened. `statusDictionary()` held the exact answer, handed it to
Dart, and Dart showed it nowhere.

Diagnosis took an hour and three wrong theories — a WCSession pairing
fault, a second app install competing for the radio, two sources of truth
for `isConnected` — none of which survived comparing file timestamps in
the app containers. This is the surface that makes that a one-minute
lookup instead.

Settings gains an Apple Watch section listing the five native WCSession
values, Dart's derived `canSync`, and — the point of the whole thing —
which of `activated`, `paired` and `installed` is failing when it is
false. Plus time since the last successful send, time since the last
availability change, and whether that send was delivered or refused.

Reachability is shown but deliberately excluded from the gate, because it
is not one of the three conditions and mistaking it for one is exactly the
wrong turn taken during the incident.

The entry appears only when a watch is paired now or has ever been paired.
The history flag is one-way on purpose: an unpaired watch is precisely when
this needs to stay reachable, so no later native false may erase it.

Also here: `statusDictionary()` now returns `activated` from its
no-session branch too, so Dart never reads a missing key; and
`_applyAvailability` logs the full status map on change only, never per
send — the payload design exists to avoid chatty updates and this must not
undo it.

Tests cover both directions: the healthy gate, and the incident-shaped
state where `installed` is false and the screen must name it.
Pings carry their own transmit-time GPS while the puck was held still until
the fix moved fifteen metres, so on a walk the pings led the wearer in the
direction of travel. Tightening the watch's zoom floor to about twenty-two
metres of visible latitude turned a long-standing offset into two thirds of
the display.

The fifteen-metre gate exists to stop a parked phone's GPS jitter waking the
radio, and it did that by making the payload identical so the bridge deduped
it. But a new ping changes the payload by itself: that packet goes out
regardless, and sending a stale puck inside it buys nothing.

So ask the question in the transport instead of answering it in the payload.
WatchBridgeService now encodes the fingerprint a second time with geo.you
removed and skips the send only when everything else is byte-identical and
the fix has moved less than the threshold. The anchor is the fix the watch
actually received, so two eleven-metre steps add up and go, and a refused
send cannot silently consume the wearer's next fifteen metres.

Position-derived fields keep the old gate. distanceM is a full-precision
double and the repeater list is distance-sorted, so feeding those the live
fix would move the payload on every jitter in fields the transport cannot
recognise as position — a send every two seconds from a phone on a table.
_resolveRankingPosition holds them still; fifteen metres is invisible in a
kilometre-scale readout and was only ever visible in the puck.

movedEnough and distanceMeters move to WatchWire beside minMoveMeters, so
the transport can apply the gate without importing a builder that reaches
back into AppStateProvider.
Adam, on a walk: "the watch often falls back to the always on state very
rapidly despite still being in the lifted position. This can make the map
flicker on and disappear while a user is looking at it."

It is not the wrist coming down. watchOS drops to reduced luminance about
5.9 seconds into a glance — thirty-three lifts measured, fourteen inside a
half-second band — and the clock face does the same, so this is the
platform's full-brightness window and there is nothing upstream to fix. The
display stays legible. showsMap read the dim as nobody looking and tore the
whole MapKit subtree down mid-glance.

The map now survives the dim for twenty seconds. The geo claim does not:
needsMapGeo reads luminance directly instead of going through showsMap, so
the held map draws the pings the watch already has and the phone sends no
more than before. MapKit staying constructed is a real cost, and twenty
seconds a glance is the trade; a wrist-down's worth would not be.

Four things had to hold at once, and hardware taught three of them.

The hold cannot depend on a timer. watchOS runs the app at a much lower
cadence once dimmed, so a boolean owned by a sleeping task would stay set
for an entire wrist-down and leave a map as the Always-On surface. The
predicate reads the clock.

A clock is not enough either, because the last frame drawn stays on the
display until something asks for another. A repeating TimelineView requests
that render. Its entries are not the deadline, though: entering Always On
changes the timeline cadence, which re-queries the schedule and delivers the
next entry early. Treating that as the boundary expired the hold 0.82 to
1.05 seconds into every glance, thirteen for thirteen on a Series 9, while
the simulator — which never enters Always On — passed every time. The clock
decides; the entry only says when to look.

The first dimmed frame must already be holding, because the timestamp is
written by onChange, which runs after the body evaluation that first sees
the dim. So a missing timestamp means hold. That default is fenced by
hasBeenBright, which no view-lifecycle callback may clear: watchOS re-hosts
this page at the dim, and clearing it there put the wearer back on the
readout a second in. Fresh @State already covers what the reset was for.

And nothing may change shape at the dim. The toolbar items were wrapped in
an if, and removing them alters the toolbar's structure, which re-hosts the
page and rebuilds all of MapKit beneath it — a subtree appearing 0.13
seconds after every dim, with a fresh camera anchor and basemap repaint,
which the wearer saw as the map jumping as it faded. They now stay put and
are hidden by value. Disabled and accessibility-hidden as well as
transparent, because Start transmits on a single tap and an invisible
control is still reachable by an assistive technology.

-MeshMapperAutoDimAfter drives the transition in the simulator, which
MeshMapperForceDimmed could not: it only sets the state a page is born into,
so it exercised the cold-dimmed path and never the glance. Every fix above
was found by measurement rather than reasoning, three of them only after
hardware disagreed with a simulator that had signed the work off.

Also records the settled cause of the double map construction. It is not the
map: a bare probe above the branch doubles, and so does one in the readout
on a launch with no MapKit anywhere, while probes on the NavigationStack and
the TabView each fire once. The vertical pager builds its selected page
twice and discards one, and nothing here can prevent it.
They slept a fixed 2.5 seconds against the bridge's 2 second non-urgent
throttle and the retry it schedules, which left about half a second of
margin. That was enough until a loaded machine ate it, and a suite that
fails one run in ten teaches people to re-run rather than to look.

Only the assertions that nothing was sent actually need to wait, so they
keep a fixed window comfortably past the throttle: "no send" has to mean
suppressed rather than merely deferred. The ones expecting a send now poll
for it instead, which is both faster and indifferent to how busy the machine
is.
The three watch configurations carried a 26.0 deployment target, which
gates installation on every earlier OS. Nothing in the app needs it: the
compile floor is 10.0, and below that only `@Observable` and one
`NodeListView` initialiser fail. watchOS 11 and 26 share a hardware floor
of Series 6, so this costs no device support at all and reaches everyone
who has not updated.

Built, installed, and launched on watchOS 10.5, 11.5, and 26.5. Map,
controls, and settings all render, including on the smallest watchOS 10
screen, because layout is measured from safe-area insets rather than
tuned to one version's chrome.

Also drop the Foundation.framework reference, which pointed into a
WatchOS11.0.sdk path that no longer exists in Xcode 26. Swift links
Foundation implicitly, so the entry was inert generator residue.
A queued command carries the time the wrist tapped it, and the phone
refuses one older than 30 seconds so a transmit cannot be attributed to
a place the vehicle has already left. Only the upper bound was checked,
so a timestamp in the future made the age negative and sailed through:
a watch clock running ten minutes fast bought its commands ten extra
minutes of life. The map-geography check directly above already had this
right, and now both use the same tolerance.

The window had no coverage at all, in either direction. Add it for every
transmitting command: too old refuses and never reaches admission, a
small forward skew still runs, and a far-future stamp refuses. Also pin
the two exemptions, since both are deliberate — an untimestamped command
from an older watch build is still accepted, and requestSnapshot is
exempt because it transmits nothing.
Staleness was measured from when a snapshot reached the wrist. On launch
the app ingests whatever application context WatchConnectivity retained,
which can be hours old, and stamping arrival there presented long-dead
state as current for a full 90 seconds — dimming and the age badge both
said the phone was in touch when it was not.

Age from the phone's own updatedAt instead, which the payload already
carried and nothing read. Clamped to arrival so a phone clock running
fast cannot date a snapshot into the future and extend its life, with a
few seconds of slack so ordinary skew does not age a live payload early.
A context already past the boundary is now stale immediately.

The cue freshness check wanted the same tolerance for the same reason,
so the two constants become one.
The app header still described Phase 2 as shipping a transport and a raw
debug dump with the map, node list, and controls due later. All of them
are here, so a reader had no way to tell which files were finished.

The satellite preference was write-only: declared, persisted, loaded on
launch, and read by nothing. Settings offers no toggle for it, and the
map page documents why there is none — Apple's .imagery renders
indistinguishably from .standard at this size.
The suite is no longer empty: 196 tests cover the watch wire contract,
geo suppression, redelivery dedupe, movement gating, and command
admission, and they are what protects this work from a quiet regression.
The comment claiming there were no tests had outlived its truth.
Ageing retained state from the phone's updatedAt fixed state that lied
about being fresh, and exposed the reciprocal hole: the refresh the watch
sends to escape that state could be deduplicated into silence.

The phone forgets its delivered-payload fingerprint only when
WatchConnectivity reports a state change. A watch app relaunch is not
one — `sessionReachabilityDidChange` is not implemented, and pairing and
installation are unchanged, so nothing calls `publishStatus()`. The
fingerprint survives, the rebuilt payload matches it once updatedAtMs is
stripped, and `_flush` returns without sending. The watch then sits on a
context it has correctly marked stale, with no way to prove otherwise,
until something unrelated moves.

It needs the map to have been visible when the watch died: a suppressed
map means requestSnapshot restores geography, which changes the payload
and defeats dedupe by itself. Leaving the watch on the map page is both
the common case and the one where the dimming is most visible.

So requestSnapshot now forces delivery. Only that command does — most
immediate updates are immediate precisely because something changed, and
should stay deduplicatable. The radio throttle still applies, and the
obligation outlives its own deferral rather than bypassing it, so a watch
asking repeatedly cannot turn this into an unmetered path to the radio.
It is cleared on delivery, not on the decision to send, so a payload
native refuses does not strand the wearer exactly as before.
requestSnapshot carries two intents down one wire. One is a genuine plea
for state, after a relaunch or a resume onto a retained context of
unknown age. The other only changes what future snapshots contain: the
map-geo lease, which renews as the same command every five minutes for
as long as the map stays hidden.

Forcing delivery for the command kind therefore forced an otherwise
identical snapshot on every renewal — spending the radio exactly where
the lease exists to save it, and against the comment saying renewal
"keeps a long Always-On session cheap".

Carry the intent explicitly instead. It is not inferred from
mapGeoNeeded, which the bridge may legitimately resolve to nil when a
suppression claim arrives stale or out of order, and which is also true
for an ordinary return to the map — a transition that already defeats
dedupe by restoring geography and needs no help. Absent on the wire
means false, so a phone paired with an older watch build behaves as it
did before.

Also pin the behaviour the force path leans on hardest: a send native
refuses leaves the fingerprint untouched, so the obligation must survive
it or the next flush dedupes against that same payload and strands the
wearer exactly as before the fix.
`onAppear` fires once and cannot speak for a resume, and watchOS suspends
this app for essentially the whole wrist-down interval — measured here at
7.42 s of suspension against 8.20 s of wrist-down, 12.89 against 13.63.
Nothing else asked the phone for anything. So the dominant interaction on
this device, raising a wrist, could land on state the UI itself declares
stale while the phone was available and willing the entire time.

Observe scenePhase and reconcile when the scene becomes active. This is
deliberately not `refresh()`, which always requests and would put a
WatchConnectivity round trip behind every glance — the opposite of what
this transport is for.

Ingesting the retained application context is free, so it happens on
every resume; the radio is spent only when what we hold is stale or
missing, which is exactly when a request can change what the wearer
sees. A glance onto fresh state costs nothing, and the worst case
becomes roughly one request per stale interval while someone is actually
looking at the watch, rather than one per raise.

The decision is logged to the existing wake-timing harness, because it
is otherwise invisible: a run of wrist raises should show `resume-local`
while the state is fresh and exactly one `resume-request` on the first
glance past the stale boundary. That is the claim, and on hardware it is
the only way to see whether it holds.
@agessaman
agessaman requested a review from MrAlders0n as a code owner August 15, 2026 04:35
The watch bar animated frame(width:) from its current fraction to zero for
the length of a whole phase, so SwiftUI re-ran layout for the panel on every
frame of a 60-second drain instead of handing one transform to the render
server. It is now a leading-anchored scale, and it stops entirely under
reduced luminance — Apple's Always-On guidance, and newly load-bearing since
the dim hold keeps this panel on screen for up to 20 s past a dim, which is
exactly when it was animating over a screen that updates once a minute.

The scale applies to a Rectangle mask rather than to the capsule. Scaling the
capsule squashed its end caps, so the bar started rounded and finished square.
A comment here previously excused that as "the shape it was already collapsing
to anyway"; Adam saw it within a minute of getting the build on his wrist.

The Live Activity bar was asking ActivityKit for something it does not do: a
@State fraction driven by a withAnimation lasting the entire phase. Widgets
cap a custom animation at two seconds and run none at all under reduced
luminance, so on an always-on lock screen the fill stayed where the last
update left it — "mostly full at 9 seconds remaining". Every @State, Task and
withAnimation is gone. The title and native countdown share a row and a
ProgressView(timerInterval:) draws beneath, so both are derived from absolute
dates and every render the system chooses to make lands correctly. The bar now
fills rather than drains, which is the trade for handing the work over: its
progress comes from a date range and cannot be inverted. Its range is the
whole phase, never now...deadline, which would reset it on every redraw.

Also here, and confirmed on hardware after one failed attempt: the drain no
longer outlives the phase. Assigning a value it already holds does not end the
animation driving it — withAnimation sets the model value immediately and
animates only the presentation, so mid-drain remainingFraction is already 0,
and the snap-to-truth on a stopped session assigned 0 to 0 and changed
nothing. The bar kept draining to the old deadline while the title read
"Ready". A dim never showed this because a dim mid-phase has a non-zero truth.
The nudge that forces a real change needs its own transaction, because SwiftUI
batches state changes made in one run-loop turn and the first attempt
coalesced into a single net-zero change.

Non-urgent Live Activity updates now wait 15 s rather than 2. Phase changes,
ping outcomes, connection loss and zone changes stay in urgencyKey and still
go immediately; counter and repeater churn waits. This saves on both devices,
since a locally generated ActivityKit update is mirrored to the paired watch
and counts against its Live Activity budget.

The bar probe logs phase title, deadline, duration, fraction, lapsed and
whether the fill drains, on every re-run of the phase task. It is what
separated "the task never re-ran" from "the animation outlived the state",
after this one line had already been wrong three different ways in a day.
Adam reported the map jumping at the end of the wait and listening timers. A
DEBUG probe, gated to a 2.5 s window after a phase change so a walk costs a
handful of lines rather than hundreds, named the cause in one session: the
panel's height. panelCameraInset feeds .safeAreaPadding(.bottom,), so every
height the panel passes through was a camera reassignment. Measured across one
session stop and restart, 54 -> 59 -> 40 -> 54 inside three seconds, four
applyRegion calls, none animated, while the fix itself moved 0.2 m.

Ordinary phase boundaries did not move the panel at all — it held at 54.0
through four of them — so this is a session-lifecycle event rather than a
per-cycle one. Zoom decides whether it is visible: at the 40 m span Adam
happened to be holding, a 19 pt inset change moves the framing centre 1.6 m
and cannot be seen, which is why an hour of stationary watching found nothing.
At the 250 m default zoom the same swing is roughly 9 m.

An inset change is also an apparent zoom, not merely a pan. applyRegion holds
the span constant while the safe-area padding changes the height of the band
MapKit fits that span into, and fewer points for the same span is a larger
scale. Nothing had said so before, and it is what made two rejected attempts
read badly: settling with growth adopted immediately gave a zoom in followed
by a zoom out 450 ms later, and settling symmetrically with an eased reframe
still showed one step each time the panel populated or emptied.

So the camera frames against the panel's high-water mark, which was Adam's
suggestion: treat the panel as though it were always at its maximum. A shrink
then moves the camera not at all, which is the common case and the one he saw.
Growth still waits 450 ms for the height to stop moving and then eases, so a
transition costs at most one gentle adjustment. Confirmed on the wrist: "no
movement on start or stop."

The trade is that while the panel is shorter than its maximum the map is
framed as if it were not, so the puck sits above the true centre of the
visible band by half the height difference, under about 10 pt at the sizes
measured. A puck slightly off centre that never moves beats a centred one that
jumps. The mark is per-launch @State: it survives the map subtree's teardown
on a wrist raise, so it holds across glances, while a fresh launch re-derives
it rather than inheriting a measurement from a content shape that has since
changed. The 0.75-of-display clamp still bounds it.

The settle is deliberately fail-safe rather than timer-dependent, since
watchOS suspends this app and the sleep fires whenever it is next resumed — the
trap the dim hold was bitten by twice. A late fire only reframes late, and the
map subtree's onAppear adopts the measured height outright, so no wrist raise
can find the camera framing against a height the panel has abandoned.

The reframe itself is untouched. It is the only thing keeping the camera on the
visible band, and this file has two scars from deleting an apparently
redundant camera assignment; only which height it follows has changed.
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.

2 participants