Repository navigation
Refine the iOS visual system and key screen layouts - #1068
Conversation
ryanbr
left a comment
There was a problem hiding this comment.
Really like the direction here — the redesign looks great, the ledger doc is a nice touch, and I checked the navigation carefully: all four tabs, their destinations, and the re-tap refresh/pop-to-root behavior are all faithfully preserved, and the quick-action "+" still reaches its sheet from the header. Nicely done.
A couple of things before merge though.
One hard blocker — i18n CI will fail. The new Trends week header adds two un-localized strings:
TrendsView.swift:378—"… · \(n)/7 days"TrendsView.swift:383— the matching.accessibilityLabel
Your own ledger says the xcstrings catalogs stay excluded "unless a later UI change genuinely introduces new localized copy" — which this does, so they just need de/es/fr entries in Strand/Resources/Localizable.xcstrings (or wrap in String(localized:) and add them). python3 Tools/i18n_audit.py --ci origin/main should exit 0 after that.
"Removes redundant, shown-twice" isn't quite right for four items — these look like real removals rather than same-screen dedup. Each may be a fine call, just want them to be deliberate:
- The Today sync chip is gone from both chromes (classic + liquid) and relocated to a new Devices card. That was the only ambient sync signal on Today (the whole point of #245) — fine to move, but it's a tab away now.
- The Live-HR subtitle ("Live · beat by beat" / "5-minute average · since midnight" / "Waiting for the strap") is deleted — that live-vs-banked-average state isn't shown anywhere else.
WeeklyDigestView:594now gates the scale caption to.chargeonly, so embedded Effort gauges lose their "of 21" denominator.- The "Full day ›" affordance is removed from the live-HR card (tap-through still works, just less discoverable).
The section-header trailings you dropped elsewhere (Sleep "Rest"/"vs typical", the Trends rangeSubtitle overline) genuinely are duplicates — those are good.
Last thing: I can't compile iOS here, so this needs an app-build pass before merge, especially DevicesView (it sits right at the type-checker's budget on iOS — the new card is its own struct so probably fine, but that file has bitten us before).
Happy to take you up on splitting the pure-styling layer from the layout/content changes if that's easier — would make this quicker to land. Thanks for the work on this!
I hear you, I will fixed blocker, but some of things you said are deliberate, I fixed number 3, for number 4 I made a red arrow so that's at least will keep it clean while showing that a function does exist, fixed number 2 and I think keeping number 1 is the smartest decision. 4 buttons on the top is already a lot, 5 is too much but let me know what you think, I will push in the next few minutes UPDATE: Pushed it, made a few layout changes on the workout screen I would like you to check out that I also think look much nicer now let me know of any issues or things you wanna change and I will get on it! |
Hey, in the trends tab, the charge, effort and rest card (not the one with the gauges) is it meant to be there? It also seems kinda like a duplicate because it doesn't have any functionality over the card above it with the gauges? |
|
Will this be iOS only or will we get this for Android as well? Saw the reddit Screencast and love it 👏. |
ryanbr
left a comment
There was a problem hiding this comment.
Latest two commits look good. Light-mode adaptation is done the right way — hardcoded colors moved to adaptive Color(light:dark:) tokens, and the onDark/overSky removals are justified now that the sky and card surfaces are light-adaptive (no dark-mode break either direction). The second LiveWorkout rework is layout-only: timer, BPM, effort (liveStrain → UnitFormatter.effortValue) and zones all read from the same sources. i18n gate passes.
Also — credit where due: I earlier called the sync status "gone from Today," but the pull-refresh vessel now holds on Today while live.backfilling is true, so an in-progress offload is visible ambiently. That effectively covers the concern; only the idle "synced Xm" state lives on Devices, which is fine.
One nit (not a blocker): the effort gauge dropped its of 21 / of 100 scale-max label. For the default 0–100 scale that's fine, but on the WHOOP 0–21 scale a bare "12.3" is ambiguous without the "of 21", and there's no accessibility fallback here (the a11y label is just the number). The MODERATE/HIGH state word + gauge fill partly cover it. Could you either mirror what the weekly-digest gauge does (tap popover / a11y carries the scale) or drop the scale into the accessibility label at least? Small.
The one real gate before merge: this is 46 files of app-target Swift that can't compile on Linux, and it leans on DevicesView (which has hit the iOS type-checker budget before) plus the new Light-mode paths. It needs a clean iOS build before merge — happy to run one on my side if that helps.
Really nice work overall.
Will fix the gauges, and I'm working on some more changes to the workout screen but I have a question. Would you consider moving the workout screen to the main tabs instead of trends? I feel like workouts might be much more important and main than the trends screen |
I addressed the remaining accessibility feedback for the effort gauge. I also ran a clean iOS build after the latest changes and verified it compiles successfully. I installed the app on my iPhone as well and confirmed it launches and runs correctly. Let me know if there's anything else you'd like me to adjust before merging. |
Restructure the in-exercise screen to the glanceable hierarchy from the iOS live-workout redesign (#1068): a sport-name header with a recording-status capsule, then centered TIME / HEART RATE / EFFORT heroes, with the HR-zone label as a capsule on the zone-rail header row. Same data and sources throughout - the elapsed clock, smoothed bpm + zone tint, ActiveWorkout .liveStrain effort, avg/peak/effort stats, the sensor readout, and the #845 scroll + End-confirm are all unchanged. Effort keeps its "of 21" / "of 100" scale caption (localized de/es/fr/pt-PT/ zh) - a bare number on the WHOOP 0-21 scale is ambiguous without it, so unlike the iOS change we keep it. Display-only: no analytics, decoder, stored-data, or BLE change. Android-only (iOS has this via #1068). compileFullDebugKotlin + i18n audit green; visual layout not device-verified (Compose does not render on this Linux box).
Co-lay the Start-workout and Add-workout controls as an equal-width action row on the Workouts screen, matching the iOS workoutActionRow (#1068). When a strap is bonded they sit side by side; unbonded shows Add alone (moved up out of the range bar so it stays reachable without a strap), which also frees the range pill from the old Add-vs-5-segment width fight (#234/#339). The active-workout banner is unchanged. WorkoutStartSection now provides Add in every idle state, so the empty-state card no longer renders its own Add (that would double it) and the dead `loaded` gate is dropped. Add is paused only during an in-progress live session (the banner owns that state) and returns the moment the workout ends. Display-only: Start is still bonded-gated (a live session needs the strap), no change to when it's available. Android-only. compileFullDebugKotlin + i18n audit green; layout not device-verified (Compose does not render on Linux).
Restructure the in-exercise screen to the glanceable hierarchy from the iOS live-workout redesign (#1068): a sport-name header with a recording-status capsule, then centered TIME / HEART RATE / EFFORT heroes, with the HR-zone label as a capsule on the zone-rail header row. Same data and sources throughout - the elapsed clock, smoothed bpm + zone tint, ActiveWorkout .liveStrain effort, avg/peak/effort stats, the sensor readout, and the #845 scroll + End-confirm are all unchanged. Effort keeps its "of 21" / "of 100" scale caption (localized de/es/fr/pt-PT/ zh) - a bare number on the WHOOP 0-21 scale is ambiguous without it, so unlike the iOS change we keep it. Display-only: no analytics, decoder, stored-data, or BLE change. Android-only (iOS has this via #1068). compileFullDebugKotlin + i18n audit green; visual layout not device-verified (Compose does not render on this Linux box).
Co-lay the Start-workout and Add-workout controls as an equal-width action row on the Workouts screen, matching the iOS workoutActionRow (#1068). When a strap is bonded they sit side by side; unbonded shows Add alone (moved up out of the range bar so it stays reachable without a strap), which also frees the range pill from the old Add-vs-5-segment width fight (#234/#339). Add stays visible in every state: beside Start when bonded-idle, alone when unbonded, and below the live banner during an active workout — so no control disappears in any state. WorkoutStartSection is now the single home for Add, so the empty-state card no longer renders its own (that would double it) and the dead `loaded` gate is dropped. Display-only: Start is still bonded-gated (a live session needs the strap), no change to when it's available. Android-only. compileFullDebugKotlin + i18n audit green; layout not device-verified (Compose does not render on Linux).
Restructure the in-exercise screen to the glanceable hierarchy from the iOS live-workout redesign (#1068): a sport-name header with a recording-status capsule, then centered TIME / HEART RATE / EFFORT heroes, with the HR-zone label as a capsule on the zone-rail header row. Same data and sources throughout - the elapsed clock, smoothed bpm + zone tint, ActiveWorkout .liveStrain effort, avg/peak/effort stats, the sensor readout, and the #845 scroll + End-confirm are all unchanged. Effort keeps its "of 21" / "of 100" scale caption (localized de/es/fr/pt-PT/ zh) - a bare number on the WHOOP 0-21 scale is ambiguous without it, so unlike the iOS change we keep it. Display-only: no analytics, decoder, stored-data, or BLE change. Android-only (iOS has this via #1068). compileFullDebugKotlin + i18n audit green; visual layout not device-verified (Compose does not render on this Linux box).
Bring the Android Workouts screen closer to the iOS redesign (#1068): - Co-lay Start + Add as an equal-width action row (EXP-018). Bonded → side by side; unbonded → Add alone (moved up out of the range bar so it's reachable without a strap, which also retires the #234/#339 Add-vs-range-pill fit fix); during an active workout the live banner keeps Add below it. Add stays visible in every state; WorkoutStartSection is its single home so the empty-state card no longer doubles it, and the dead `loaded` gate is dropped. - Give the sport picker per-sport glyphs (shared `sportIcon` catalogue) so it reads by icon like the iOS workout selection screen and the Workouts list rows, not a bare text list. - Move the Clear-filters chip onto the search-field row with a 48dp hit target, matching iOS's search-row placement + 44pt target. Display-only: Start is still bonded-gated (a live session needs the strap), no change to when it's available. Android-only (iOS via #1068). No new strings. compileFullDebugKotlin + i18n audit green; layout not device-verified.
Drop the green chargeColor surface glow from the HRV/RHR small multiples, Week-in-Review, and Charge history cards, keeping the semantic color inside the data graphics. Matches the iOS #1068 Trends cleanup. Display-only, Android-only.
Restructure the in-exercise screen to the glanceable hierarchy from iOS #1068: sport-name header + recording capsule, centered TIME/HEART RATE/EFFORT heroes, HR-zone capsule on the zone-rail header. Effort keeps its localized of-N scale. Same liveStrain/bpm/zone sources; display-only, Android-only.
Hey Ryan, could I get an update on what's going on? |
|
Rebase would be a start |
|
Thanks for this — the new look is really nice and it's clearly gone down well with people. A few things to sort before it can go in: Blockers
A couple of things to confirm
Once the two blockers are sorted I'm glad to take another look. Thanks again for the effort here! |
Drop contributor working docs that are not part of upstream and should not ship with ryanbr#1068. Co-authored-by: Cursor <cursoragent@cursor.com>
Fixed both blockers — Liquid Glass is now gated to iOS with the existing fallback on macOS, and the Sleep accessibility label is localized. Clean iOS and macOS builds both pass, i18n passes, and I removed the contributor-only .cursor/ledger files from the PR. The Today/Devices sync-status move is intentional. |
Gate glass APIs behind iOS-only helpers with standard macOS fallbacks, and add the Sleep performance accessibility string to the catalog so i18n CI passes. Co-authored-by: Cursor <cursoragent@cursor.com>
Resolve LiquidTodayView and Localizable.xcstrings conflicts while keeping the ryanbr#1068 panel/gauge UI, iOS-only Liquid Glass helpers, Sleep a11y catalog entry, and upstream theme-aware/NOOP Limitations work. Co-authored-by: Cursor <cursoragent@cursor.com>
#1068) (#1170) iOS #1068 repoints the chrome accent from the WHOOP blue to a mint world (NoopVisualStyle.mint/mintGlow). Match it on Android so the two apps share the brand accent: accent/accentHover/focusRing map directly from the iOS light/dark mint values; accentMuted is a green-shifted analog of the old muted surface (Android's light theme is a warm paper theme, so that shade may want an on-device tweak). The recovery/strain/sleep colour worlds are untouched — gold stays in the recovery world, exactly as on iOS. Chrome-accent only; no data, no strings, no layout. compileFullDebugKotlin clean.
|
Now this is done. On to android 💪🙏? |
85 commits from ryanbr/noop (a26bd3a..d94d568, 6–9 Aug): Oura decode fixes (0x5D bucket order and pairing, RSA gating), a batch of BLE work (5/MG bond watchdog, empty-offload backoff, MTU settle, battery poll throttle, DIS-based 4.0/5.0 correction), sleep-card reordering, the stress motion gate, the ryanbr#103 SpO₂ @82 candidate, language settings, and the ryanbr#1068 visual-system pass. 24 files conflicted. Resolved so that upstream wins on behaviour and this fork wins on its own visual identity: - Liquid Today, the Sleep hero, ScreenScaffold, StrandCard/Typography/Components keep the fork's design. Upstream's ryanbr#1068 rewrite (NoopPanelSurface everywhere, SF-Rounded roles, native tab bar) is deliberately not adopted — it targets a screen layout this fork replaced. - Features upstream re-mounted in ryanbr#980 are taken: HealthAlertBanner and AutoWorkoutCard now render on Liquid Today, which is the default screen here, so a raised health alert finally has a home surface. `dataSourcesSection` is NOT re-mounted — the reorderable card block already renders it. - Also taken: the ryanbr#1112 Sleep card reordering (all six of this fork's cards are covered by upstream's section enum), ryanbr#989 hydration, ryanbr#804 Fix A's provided hypnogram, the accent-colour storage, SyncChipState, and ryanbr#103's SpO₂ candidate — the last one rerouted through `windowedSpark` so the tile still honours the Detailed-tiles window picker. - The string catalog was merged structurally, per key, rather than textually: 4352 keys, 35 new from upstream, every focus locale complete. Two silent regressions from upstream's rewrite were caught and restored: ScreenScaffold's `overSky` title contrast (fork-only, deleted by ryanbr#1068) and the `icon:` argument that ktile gained. Both heroes now share `LiquidScoreGauge` instead of each inlining a vessel. Verified: Strand (macOS) and NOOPiOS build; StrandTests 1864 pass; WhoopProtocol 558, WhoopStore 367, StrandAnalytics 1302 pass; i18n audit clean. One upstream test (AppLanguageTests, new in ryanbr#1181) asserted through NSArgumentDomain, which this fork's test scheme pins to `en` — rewritten to read the suite's own domain, matching what the test already does for its second assertion. NOT verified: Android does not compile here (no SDK on this machine), and none of the BLE changes have been near a strap.
The three hero scores were flat progress rings. They used to be vessels of liquid filled to the value, with slosh, tilt parallax, surface glints and droplets — the thing the screen is named after. This fork did not lose it. `LiquidCore.swift` (the physics) is byte-identical to upstream, `LiquidVessel` is identical, and `LiquidRender.vessel` was identical too — because upstream itself replaced the fluid renderer with a ring in 6a5856c ("Refine the iOS visual system and key screen layouts", ryanbr#1068, 2026-08-09). The fork inherited that in the syncs days later. So this is a restoration out of history, not a port: the removed doc comment described exactly what was lost, "a circular vessel of liquid filled to sim.level, tinted, with parallax slosh, a light band that follows tilt, surface glints, flake and droplets". `LiquidRender.vessel` goes back to its 6a5856c^ body, unchanged. Nothing else had to move: `LiquidSim` has not been touched since before the removal, so every field the renderer reads is still there and still stepped identically — it compiled first try. Deliberately only this function. `LiquidRender.tube` keeps its post-ryanbr#1068 solid rendering, and `LiquidScoreGauge` — which that same commit introduced — still draws the count-up number on top; the number is pinned white with a shadow, which is what a moving surface underneath needs anyway. The small 30pt/26pt vessels pass `animated: false` and therefore render the same liquid posed still, as they did before ryanbr#1068, so no special case was needed. The motion gates are untouched: Reduce Motion, Low Power and NOOP's own quiet switch still pose the fluid rather than animating it. Cost to know: `LiquidPrimitives.swift` is a file upstream actively reworks, so a future sync touching it will conflict here and has to be resolved by hand — unlike android/, which can be dropped wholesale. Verified in the simulator on seeded data: the three heroes render as filled vessels at 32% / 61% / 93%, and two screenshots seconds apart show the wave surfaces and the drifting flecks in different positions, so the simulation is running rather than posed. macOS StrandTests: 2017 tests, 0 failures. NOT verified here: the tilt parallax, which needs CoreMotion the simulator does not provide, and sustained scroll smoothness with three live 60fps canvases — both want a look on a real device.
Drop the green chargeColor surface glow from the HRV/RHR small multiples, Week-in-Review, and Charge history cards, keeping the semantic color inside the data graphics. Matches the iOS ryanbr#1068 Trends cleanup. Display-only, Android-only.
Restructure the in-exercise screen to the glanceable hierarchy from iOS ryanbr#1068: sport-name header + recording capsule, centered TIME/HEART RATE/EFFORT heroes, HR-zone capsule on the zone-rail header. Effort keeps its localized of-N scale. Same liveStrain/bpm/zone sources; display-only, Android-only.
…ish (ryanbr#1079) Co-lay Start+Add as an equal-width action row (EXP-018, Add visible in every state), give the sport picker per-sport glyphs, and move the Clear-filters chip onto the search row with a 48dp target. Matches iOS ryanbr#1068. Display-only, Android-only, no new strings.
ryanbr#1068) (ryanbr#1170) iOS ryanbr#1068 repoints the chrome accent from the WHOOP blue to a mint world (NoopVisualStyle.mint/mintGlow). Match it on Android so the two apps share the brand accent: accent/accentHover/focusRing map directly from the iOS light/dark mint values; accentMuted is a green-shifted analog of the old muted surface (Android's light theme is a warm paper theme, so that shade may want an on-device tweak). The recovery/strain/sleep colour worlds are untouched — gold stays in the recovery world, exactly as on iOS. Chrome-accent only; no data, no strings, no layout. compileFullDebugKotlin clean.
* Apply custom NOOP UI * Refine NOOP visual design system * Develop experimental NOOP UI * Refine Trends and metric visuals * Document experimental UI audit * Refine live heart rate dashboard card * Refine workout and health UI interactions * Refine Trends summary interactions * Adapt liquid UI surfaces for Light mode Co-authored-by: Cursor <cursoragent@cursor.com> * Refine live workout glanceable layout Co-authored-by: Cursor <cursoragent@cursor.com> * Refine live workout chrome, selection, and search UI. Adds floating bottom controls with unique sport icons, a full-screen workout picker, shared Liquid Glass search fields, and Effort gauge VoiceOver scale context, with the UI customization ledger kept in lockstep. Co-authored-by: Cursor <cursoragent@cursor.com> * Localize live workout and search accessibility strings. Add String Catalog entries for the new workout chrome, workout selection, and Liquid Glass search copy so the i18n CI gate passes without changing UI or behavior. Co-authored-by: Cursor <cursoragent@cursor.com> * Share liquid score gauge across Home and Sleep with refined vessel material. Extract LiquidScoreGauge for hero rings, upgrade LiquidRender.vessel shading, add the immersive Sleep night hero, and match Key Metrics bar fills to the gauge gradient. Co-authored-by: Cursor <cursoragent@cursor.com> * Remove PR-only UI ledger and Cursor rule notes. Drop contributor working docs that are not part of upstream and should not ship with ryanbr#1068. Co-authored-by: Cursor <cursoragent@cursor.com> * Fix macOS Liquid Glass compile and localize Sleep a11y label. Gate glass APIs behind iOS-only helpers with standard macOS fallbacks, and add the Sleep performance accessibility string to the catalog so i18n CI passes. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
The in-exercise hero rounded Effort to a whole number on the 0-100 scale while the EFFORT stat below it showed one decimal, so the smaller read-out was the more precise of the two. The split came in with ryanbr#1068, which replaced the StrainGauge ring (whose valueFormat ran through UnitFormatter.effortDisplay) with a CountUpText and rounded the 0-100 case. Route the visible value and the VoiceOver label through a single one-decimal path, restoring the app-wide effortDisplay convention (#45) this screen was the last to break. The WHOOP 0-21 scale already formatted with one decimal and is unchanged; the stored value stays 0-100. Verified: Strand (macOS) and NOOPiOS both build; in the simulator the live workout hero renders 0.0, matching the EFFORT stat beneath it.
…1814) A user asked why their workout screen has two timers. It has one big TIME readout and one in the floating capsule below - the second is intended, but it was half-hidden behind the pause button, so the visible fragment read as a broken duplicate. #1068 built the capsule as a ZStack with the timer centred independently of the buttons, deliberately "so uneven label widths cannot pull the time off-center". That held while the bar carried one circle per side. #1533 added the discard and pause controls to the LEFT group, and a centred 40pt timer then begins exactly where two 56pt circles plus their spacing end: "0:02" merely touched the pause button and anything wider went under it. Nothing in a ZStack prevents that; the layout assumed no button would be in the middle. allowsHitTesting(false) on the timer was already a tell that it sat beneath something tappable. One HStack now, so overlapping is not expressible. The timer sits centred in the space the buttons leave rather than in the bar, reading slightly right of true centre because the left chrome is heavier. The buttons keep the positions they have shipped with - moving pause would centre the timer better and would also move a control under the thumb of everyone already using this screen. Two review passes each caught a claim rather than a defect. The first commit asserted the layout "keeps full width for a 1:30:00" without checking: three circles leave the timer about 161pt inside a 353pt capsule against roughly 144pt for that clock, which fits on a 393pt screen and does not on a 375pt one - so it scales rather than overflows. The second added layoutPriority(1), which inverted which element gives way: the circles are fixed 56pt frames, so they clip rather than shrink, and sizing the Text first would have amputated a button instead of shrinking a clock. The priority is gone rather than reversed. No Android twin: that screen renders the clock once, and its controls are a Row, so the overlap is not expressible there either. NOT COMPILED AND NOT SEEN - app-target Swift with no local build and no default CI job. swiftc -parse is syntax only. The testing build's iOS leg compiles it; a screenshot is what should be allowed to contradict the layout reasoning above. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RupifTqaTTytpPjfpMvJuj
…/Android token parity iOS had drifted lighter than Android after the ryanbr#1068 refinement; both now carry the pixel-sampled WHOOP values from docs/superpowers/specs/2026-06-22-whoop-design-language.md (canvas #121518, cards #25292C, dim grey captions #9AA4AC/#6B7177) byte-for-byte, with the BrandSleepRamp twin tests re-pinned in lockstep.
Refs #2218, reported by @bartmuskala, who picked this out of a screenshot pair. They raised the same thing on #1068 and got no reply before it was wrapped up. iOS has five tabs and Android had four: iOS Today · Trends · Sleep · Coach · More Android Today · Trends · Sleep · More Coach was promoted to a top-level tab on iOS and this side did not follow, so it sat inside More while the Android shell's own comment claimed the two bars matched. That comment and the drawer's are both true again. Four parts, because the slot alone would have been wrong three ways. The slot, in iOS order, using AutoAwesome: the sparkles glyph iOS uses and the one the More row already showed, so an entry a wearer has learned keeps its face when it moves. The More slot's selected state, which spelled its siblings out by name and would have lit Coach AND More together. It derives from the bar's own lists now, which is why spelling them out made this a two-part change. Coach out of the More sheet, matching iOS at its own promotion: "K3: Coach promoted to a top-level tab, no longer listed under More." Leaving it would have put one destination in two places, which is the duplication the drawer's note says that list exists to avoid. The label bounded to one line. A fifth slot takes about a fifth off every label's width and the bar scale goes to 2x, so the longest can no longer be assumed to fit on a narrow phone. BarSlot takes its accessibility description from the full label rather than the rendered Text, so this truncates what is drawn and not what TalkBack reads. Pinned by a test rather than by reading. The two lists sit four hundred lines apart and neither mentions the other, so the duplication that nearly shipped is not something a reader spots. Four cases, the first being that the bar and the More sheet are disjoint; the control for it fails with "found [Coach]". The declarations moved from private to internal to allow it, which costs nothing: the ledger excludes com/noop/ui entirely, so the scanner never saw them either way. Routing needed no change. The Today Coach launcher already used navigateTopLevel, the same call the bar makes, so Android was treating Coach as top-level while it lived in More. No new strings: nav_coach already exists with every locale it needs. com.noop.ui 1058 tests 0 failures. Android only, and deliberately narrow: this closes a gap where iOS moved and Android did not, and is not an argument that the two must match everywhere.
Since ryanbr#1068 the vessel is drawn as a ring from `sim.level` alone; the waves, tilt, flecks and drops the sim still computes are not drawn. LiquidVessel still ran its 60 fps TimelineView, stepped the whole sim every frame and held the tilt sensor while on screen, so the three Today hero rings and Sleep's ring redrew the same picture 60 times a second once filled. The loop now runs only while the fill eases to its value: on appear and on each change of value. A task checks four times a second whether the fill has arrived (`LiquidSim.fillArrived`, the same 0.0004 at which `step` stops moving the level), then the ring is one plain Canvas drawn from the same sim. Tap and haptic are unchanged. The vessel no longer acquires LiquidMotion: tilt only moved what is not drawn. A vessel that draws no frames while filling is set to its value after ten seconds, so it is never left mid-fill; a loop restarted after resting takes its first step from zero, not one capped 33 ms jump. Android is unchanged: its vessel still draws the liquid. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Since #1068 the vessel is drawn as a ring from `sim.level` alone; the waves, tilt, flecks and drops the sim still computes are not drawn. LiquidVessel still ran its 60 fps TimelineView, stepped the whole sim every frame and held the tilt sensor while on screen, so the three Today hero rings and Sleep's ring redrew the same picture 60 times a second once filled. The loop now runs only while the fill eases to its value: on appear and on each change of value. A task checks four times a second whether the fill has arrived (`LiquidSim.fillArrived`, the same 0.0004 at which `step` stops moving the level), then the ring is one plain Canvas drawn from the same sim. Tap and haptic are unchanged. The vessel no longer acquires LiquidMotion: tilt only moved what is not drawn. A vessel that draws no frames while filling is set to its value after ten seconds, so it is never left mid-fill; a loop restarted after resting takes its first step from zero, not one capped 33 ms jump. Android is unchanged: its vessel still draws the liquid. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Summary
This PR refreshes the iOS presentation layer and improves the layout of several key screens while preserving the existing application behavior and data flow.
The goal is to make the interface feel more consistent, readable, and polished without changing the underlying health, device, or scoring functionality.
Main changes
Intentional layout changes
This PR includes some deliberate layout adjustments in addition to styling:
Verification
The branch was audited against the latest upstream
main.Confirmed unchanged:
The changes are limited to presentation, layout, and user-interface behavior.
Testing
git diff --checkpasses.I’m happy to adjust the scope or split parts of this into smaller PRs if that would make review easier.