Skip to content

Refine the iOS visual system and key screen layouts - #1068

Merged
ryanbr merged 16 commits into
ryanbr:mainfrom
liaolo:ui-experimental
Aug 9, 2026
Merged

ryanbr merged 16 commits into
ryanbr:mainfrom
liaolo:ui-experimental

Conversation

@liaolo

@liaolo liaolo commented Aug 4, 2026 •

Copy link
Copy Markdown

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

  • Introduces a more consistent shared visual system for cards, typography, surfaces, borders, gradients, spacing, and controls.
  • Applies the shared styling across screens that were still using older presentation patterns.
  • Updates the Today Key Metrics section to use larger two-column cards with metric icons.
  • Simplifies and improves the Trends week header and score-summary layout.
  • Refines the Sleep performance card’s nighttime presentation.
  • Uses native system tab navigation and Liquid Glass where supported.
  • Cleans up progress bars and removes unnecessary decorative animation.
  • Improves consistency across supporting sheets, cards, charts, and detail screens.
  • Design got very positive reviews on reddit & discord (https://www.reddit.com/r/NoopBand/comments/1vf1asv/noop_design_changes/)

Intentional layout changes

This PR includes some deliberate layout adjustments in addition to styling:

  • Larger two-column Key Metrics layout.
  • More compact Trends week navigation and date presentation.
  • Better centering and spacing of Trends summary gauges.
  • Removal of redundant labels and cards where the same information was shown twice.

Verification

The branch was audited against the latest upstream main.

Confirmed unchanged:

  • BLE communication
  • WHOOP protocol handling
  • Health and sleep calculations
  • Scoring and algorithms
  • Data models
  • Persistence and database behavior
  • Networking
  • HealthKit behavior
  • Permissions and entitlements
  • Navigation destinations
  • Build configuration

The changes are limited to presentation, layout, and user-interface behavior.

Testing

  • iOS build succeeds.
  • git diff --check passes.
  • Main tabs and navigation were tested.
  • Existing data bindings and interactions remain functional.

I’m happy to adjust the scope or split parts of this into smaller PRs if that would make review easier.

@ryanbr ryanbr left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. 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.
  2. 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.
  3. WeeklyDigestView:594 now gates the scale caption to .charge only, so embedded Effort gauges lose their "of 21" denominator.
  4. 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!

@liaolo

liaolo commented Aug 4, 2026 •

Copy link
Copy Markdown
Author

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:

  1. 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 Sync indicator missing (or I am dumb?) #245) — fine to move, but it's a tab away now.
  2. 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.
  3. WeeklyDigestView:594 now gates the scale caption to .charge only, so embedded Effort gauges lose their "of 21" denominator.
  4. 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!

@liaolo

liaolo commented Aug 4, 2026

Copy link
Copy Markdown
Author

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:

  1. 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 Sync indicator missing (or I am dumb?) #245) — fine to move, but it's a tab away now.
  2. 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.
  3. WeeklyDigestView:594 now gates the scale caption to .charge only, so embedded Effort gauges lose their "of 21" denominator.
  4. 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!

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?

@bartmuskala

Copy link
Copy Markdown

Will this be iOS only or will we get this for Android as well? Saw the reddit Screencast and love it 👏.

@ryanbr ryanbr left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@liaolo

liaolo commented Aug 5, 2026

Copy link
Copy Markdown
Author

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
Just me tho so let me know if that's should be something I should change and I will be finishing the last changes and let you check them out in the next hour ish
Thank you very much!

@liaolo

liaolo commented Aug 5, 2026

Copy link
Copy Markdown
Author

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.

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.

ryanbr added a commit that referenced this pull request Aug 5, 2026
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).
ryanbr added a commit that referenced this pull request Aug 5, 2026
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).
ryanbr added a commit that referenced this pull request Aug 5, 2026
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).
ryanbr added a commit that referenced this pull request Aug 5, 2026
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).
ryanbr added a commit that referenced this pull request Aug 5, 2026
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).
ryanbr added a commit that referenced this pull request Aug 5, 2026
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.
ryanbr added a commit that referenced this pull request Aug 5, 2026
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.
ryanbr added a commit that referenced this pull request Aug 5, 2026
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.
ryanbr added a commit that referenced this pull request Aug 5, 2026
…ish (#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 #1068. Display-only, Android-only, no new strings.
@liaolo

liaolo commented Aug 6, 2026

Copy link
Copy Markdown
Author

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.

Hey Ryan, could I get an update on what's going on?

@ryanbr

ryanbr commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Rebase would be a start

@ryanbr

ryanbr commented Aug 8, 2026

Copy link
Copy Markdown
Owner

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

  1. It builds on iOS but not on macOS — the new "liquid glass" styling is iOS-only right now, so on Mac it needs to fall back to the standard style. As-is the Mac app won't compile.
  2. One accessibility label on the Sleep screen isn't in the translations file, so it fails the translation check.

A couple of things to confirm

  • The .cursor rules file and the UI ledger doc look like your own working notes — I don't think those belong in the repo. (The ledger also says the translation files should stay untouched, but the PR edits them.)
  • A few changes go a bit beyond styling — e.g. the sync / last-synced status is no longer on the Today screen (it moved to Devices). Happy to keep it, just want to confirm that was intentional.

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>
@liaolo

liaolo commented Aug 8, 2026

Copy link
Copy Markdown
Author

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

  1. It builds on iOS but not on macOS — the new "liquid glass" styling is iOS-only right now, so on Mac it needs to fall back to the standard style. As-is the Mac app won't compile.
  2. One accessibility label on the Sleep screen isn't in the translations file, so it fails the translation check.

A couple of things to confirm

  • The .cursor rules file and the UI ledger doc look like your own working notes — I don't think those belong in the repo. (The ledger also says the translation files should stay untouched, but the PR edits them.)
  • A few changes go a bit beyond styling — e.g. the sync / last-synced status is no longer on the Today screen (it moved to Devices). Happy to keep it, just want to confirm that was intentional.

Once the two blockers are sorted I'm glad to take another look. Thanks again for the effort here!

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.

liaolo and others added 2 commits August 8, 2026 17:14
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>
ryanbr added a commit that referenced this pull request Aug 9, 2026
#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.
@ryanbr
ryanbr merged commit 6a5856c into ryanbr:main Aug 9, 2026
2 checks passed
@bartmuskala

Copy link
Copy Markdown

Now this is done. On to android 💪🙏?

DX23876 added a commit to DX23876/noop that referenced this pull request Aug 12, 2026
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.
DX23876 added a commit to DX23876/noop that referenced this pull request Aug 14, 2026
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.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
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.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
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.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…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.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
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.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
* 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>
DX23876 added a commit to DX23876/noop that referenced this pull request Aug 20, 2026
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.
@bartmuskala

Copy link
Copy Markdown

@liaolo @ryanbr any chance this design van be ported to Android as well? I think it so nice and would give the android version the same appeal the iOs version now has with this design.

Thanks for considering 🙏.

@bartmuskala

Copy link
Copy Markdown

@liaolo @ryanbr any feedback would be appreciated 🙏 it would be so great to have the same experience on Android that iOS users have.

ryanbr added a commit that referenced this pull request Sep 2, 2026
…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
tmiland added a commit to tmiland/noop that referenced this pull request Sep 8, 2026
…/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.
ryanbr added a commit that referenced this pull request Sep 14, 2026
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.
UtkuDenizAltiok added a commit to UtkuDenizAltiok/noop that referenced this pull request Sep 24, 2026
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>
ryanbr pushed a commit that referenced this pull request Sep 27, 2026
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>
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.

3 participants