From 0be343734f8c3af6f093d6f7216a04da9a4c4ca5 Mon Sep 17 00:00:00 2001 From: rgvxsthi <65727207+rgvxsthi@users.noreply.github.com> Date: Mon, 13 Jul 2026 10:20:56 +1000 Subject: [PATCH 1/8] fix(glass): stop controls vanishing / glass rendering flat on iOS 26 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two iOS-26 Liquid Glass rules the app already follows elsewhere were violated at six sites; fix them with the in-repo idioms (background-layer glass for controls; solid/gradient tile for nested glass). iOS 26 only — the pre-26 Material fallback was unaffected. Controls-inside-glass vanish (move glass to a .background layer): - RecordViews.fieldRow: Picker + two DatePickers on the Edit-workout sheet. - LogPastActivityView.PastActivityTimeCard: a compact DatePicker. Nested glass-on-glass rendered flat (replace inner glass with a tile): - CoachActionCardView: Confirm/Cancel CTAs inside the action card's glass (Confirm keeps a solid accent/danger fill for the destructive affordance). - CoachResponseView: follow-up chip inside the assistant bubble. - CoachChartView: chart card inside the assistant bubble. - RecordSummaryViews: effort pills + note field inside the effort card (selected effort keeps its tint as a solid fill; the field gets a visible input background). --- .../Coach/Schema/CoachActionCardView.swift | 17 +++++++++-- PulseLoop/Coach/Schema/CoachChartView.swift | 9 +++++- .../Coach/Schema/CoachResponseView.swift | 9 +++++- PulseLoop/Views/LogPastActivityView.swift | 6 +++- PulseLoop/Views/RecordSummaryViews.swift | 28 +++++++++++++++++-- PulseLoop/Views/RecordViews.swift | 5 +++- 6 files changed, 65 insertions(+), 9 deletions(-) diff --git a/PulseLoop/Coach/Schema/CoachActionCardView.swift b/PulseLoop/Coach/Schema/CoachActionCardView.swift index 12e52f3..a244542 100644 --- a/PulseLoop/Coach/Schema/CoachActionCardView.swift +++ b/PulseLoop/Coach/Schema/CoachActionCardView.swift @@ -29,7 +29,17 @@ struct CoachActionCardView: View { .font(PulseFont.footnote.weight(.semibold)) .frame(maxWidth: .infinity).padding(.vertical, 9) .foregroundStyle(PulseColors.textPrimary) - .pulseGlass(Capsule(), interactive: true) + // Solid tile, not glass: this button sits inside the card's own glass, + // and glass can't sample glass (renders flat). A subtle white sheen + + // hairline mirrors the Settings icon tile. + .background( + LinearGradient( + colors: [Color.white.opacity(0.16), Color.white.opacity(0.07)], + startPoint: .top, endPoint: .bottom + ), + in: Capsule() + ) + .overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 0.5)) } .buttonStyle(.plain) Button(action: onConfirm) { @@ -37,8 +47,9 @@ struct CoachActionCardView: View { .font(PulseFont.footnote.weight(.semibold)) .frame(maxWidth: .infinity).padding(.vertical, 9) .foregroundStyle(.white) - .pulseGlass(Capsule(), interactive: true, - tint: isDestructive ? PulseColors.danger : PulseColors.accent) + // Solid accent/danger fill (not tinted glass): keeps the destructive + // affordance visible while avoiding glass-inside-glass flattening. + .background(isDestructive ? PulseColors.danger : PulseColors.accent, in: Capsule()) } .buttonStyle(.plain) } diff --git a/PulseLoop/Coach/Schema/CoachChartView.swift b/PulseLoop/Coach/Schema/CoachChartView.swift index 7e836e3..8dd0abf 100644 --- a/PulseLoop/Coach/Schema/CoachChartView.swift +++ b/PulseLoop/Coach/Schema/CoachChartView.swift @@ -53,7 +53,14 @@ struct CoachChartView: View { } .padding(14) .frame(maxWidth: .infinity, alignment: .leading) - .pulseGlass(RoundedRectangle(cornerRadius: 16, style: .continuous)) + // Solid card fill, not glass: this chart card renders inside the assistant bubble's + // glass, and glass can't sample glass (renders flat). A card fill + hairline keeps it + // reading as a distinct raised surface. + .background(PulseColors.card, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .strokeBorder(.white.opacity(0.12), lineWidth: 0.5) + ) } // MARK: - Standard plot (line / dot / bar / sparkline) diff --git a/PulseLoop/Coach/Schema/CoachResponseView.swift b/PulseLoop/Coach/Schema/CoachResponseView.swift index a1cc79f..0012627 100644 --- a/PulseLoop/Coach/Schema/CoachResponseView.swift +++ b/PulseLoop/Coach/Schema/CoachResponseView.swift @@ -140,7 +140,14 @@ struct CoachFollowUpChipLabel: View { } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 12).padding(.vertical, 10) - .pulseGlass(RoundedRectangle(cornerRadius: 12, style: .continuous), interactive: true) + // Solid tile, not glass: this chip renders inside the assistant bubble's glass, + // and glass can't sample glass (renders flat). A soft card fill + hairline keeps + // the chip visible as a tappable affordance. + .background(PulseColors.cardSoft, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(.white.opacity(0.12), lineWidth: 0.5) + ) } } diff --git a/PulseLoop/Views/LogPastActivityView.swift b/PulseLoop/Views/LogPastActivityView.swift index b3cbb86..a9ce337 100644 --- a/PulseLoop/Views/LogPastActivityView.swift +++ b/PulseLoop/Views/LogPastActivityView.swift @@ -196,7 +196,11 @@ private struct PastActivityTimeCard: View { .foregroundStyle(isValid ? PulseColors.textSecondary : PulseColors.warning) } } - .pulseGlass(RoundedRectangle(cornerRadius: 20, style: .continuous)) + // Glass as a BACKGROUND layer so the foreground compact DatePicker stays outside the + // iOS 26 glassEffect — an interactive control inside glass can vanish/flicker when its + // popover opens. See SettingsLabeledRow. + .background { Color.clear.pulseGlass(RoundedRectangle(cornerRadius: 20, style: .continuous)) } + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) } private func row(title: String, systemImage: String, @ViewBuilder content: () -> Content) -> some View { diff --git a/PulseLoop/Views/RecordSummaryViews.swift b/PulseLoop/Views/RecordSummaryViews.swift index a54f709..8648721 100644 --- a/PulseLoop/Views/RecordSummaryViews.swift +++ b/PulseLoop/Views/RecordSummaryViews.swift @@ -132,7 +132,24 @@ struct RecordSummaryView: View { .font(PulseFont.footnote) .foregroundStyle(active ? PulseColors.textPrimary : PulseColors.textSecondary) .padding(.horizontal, 12).padding(.vertical, 8) - .pulseGlass(Capsule(), interactive: true, tint: active ? PulseColors.accent : nil) + // Solid tile, not glass: these pills sit inside the effort card's + // own glass, and glass can't sample glass (renders flat). Selected + // keeps the accent as a solid fill; unselected gets a subtle white + // sheen + hairline. + .background { + if active { + Capsule().fill(PulseColors.accent) + } else { + Capsule() + .fill( + LinearGradient( + colors: [Color.white.opacity(0.16), Color.white.opacity(0.07)], + startPoint: .top, endPoint: .bottom + ) + ) + .overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 0.5)) + } + } } .buttonStyle(.plain) } @@ -141,7 +158,14 @@ struct RecordSummaryView: View { .lineLimit(2...4) .font(PulseFont.subheadline.weight(.regular)) .padding(12) - .pulseGlass(RoundedRectangle(cornerRadius: 14, style: .continuous)) + // Solid field background, not glass: this input sits inside the effort card's + // glass (glass can't sample glass → flat). A soft fill + hairline gives it a + // visible input affordance. + .background(PulseColors.cardSoft, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .strokeBorder(.white.opacity(0.12), lineWidth: 0.5) + ) } .frame(maxWidth: .infinity, alignment: .leading) .padding(16) diff --git a/PulseLoop/Views/RecordViews.swift b/PulseLoop/Views/RecordViews.swift index 06b3f1e..3ee734c 100644 --- a/PulseLoop/Views/RecordViews.swift +++ b/PulseLoop/Views/RecordViews.swift @@ -370,7 +370,10 @@ private struct EditWorkoutSheet: View { content() } .padding(.horizontal, 16).padding(.vertical, 8) - .pulseGlass(RoundedRectangle(cornerRadius: 16, style: .continuous)) + // Glass as a BACKGROUND layer so the foreground .menu Picker / compact DatePickers + // stay outside the iOS 26 glassEffect — an interactive control inside glass can + // vanish/flicker when its popover opens. See SettingsLabeledRow. + .background { Color.clear.pulseGlass(RoundedRectangle(cornerRadius: 16, style: .continuous)) } } } From 76f766aae524a754abd748dc321287c7c83b01c2 Mon Sep 17 00:00:00 2001 From: rgvxsthi <65727207+rgvxsthi@users.noreply.github.com> Date: Mon, 13 Jul 2026 10:26:48 +1000 Subject: [PATCH 2/8] perf(glass): group sibling glass in containers + unify card corner radii On iOS 26 each pulseGlass surface samples the background independently, so adjacent glass siblings render without blending and cost more. Wrap the busiest clusters in pulseGlassContainer (mirrors DeviceHeroCard/Settings): Coach header + composer + prompt-chip rows, ActivityView Record/calendar pair + content stack, the two WorkoutMap overlays, and MetricDetailView's five stacked cards. Add a PulseRadius token (card 20 / compact 16 / control 12) and unify MetricDetailView's peer cards, which mixed 24 and 20 on one screen, onto PulseRadius.card. No repo-wide sweep. --- PulseLoop/App/AppTheme.swift | 9 +++++++ PulseLoop/DesignSystem/WorkoutMapView.swift | 12 +++++++-- PulseLoop/Views/ActivityView.swift | 4 +++ PulseLoop/Views/CoachView.swift | 30 +++++++++++++-------- PulseLoop/Views/MetricDetailView.swift | 14 +++++----- 5 files changed, 50 insertions(+), 19 deletions(-) diff --git a/PulseLoop/App/AppTheme.swift b/PulseLoop/App/AppTheme.swift index ff42cbd..193d9c3 100644 --- a/PulseLoop/App/AppTheme.swift +++ b/PulseLoop/App/AppTheme.swift @@ -96,6 +96,15 @@ enum PulseColors { static let borderStrong = Color.white.opacity(0.16) } +/// Corner-radius tokens so peer glass surfaces share one radius instead of the +/// ad-hoc 16/18/20/22/24 mix. `card` for standard glass cards, `compact` for +/// smaller/inset surfaces, `control` for buttons and small controls. +enum PulseRadius { + static let card: CGFloat = 20 + static let compact: CGFloat = 16 + static let control: CGFloat = 12 +} + extension Color { init(hex: String) { let trimmed = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) diff --git a/PulseLoop/DesignSystem/WorkoutMapView.swift b/PulseLoop/DesignSystem/WorkoutMapView.swift index b82bf4a..fabee1e 100644 --- a/PulseLoop/DesignSystem/WorkoutMapView.swift +++ b/PulseLoop/DesignSystem/WorkoutMapView.swift @@ -58,8 +58,16 @@ struct WorkoutMapView: View { } .frame(height: height) .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) - .overlay(alignment: .bottomLeading) { infoOverlay } - .overlay(alignment: .topTrailing) { if follow { followControls } } + // Both glass overlays share one container so they render/blend as one group. + .overlay { + ZStack { + infoOverlay.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading) + if follow { + followControls.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) + } + } + .pulseGlassContainer(spacing: 8) + } .onAppear { recenter() } .onChange(of: coordinates.count) { _, _ in // Live (follow) mode tracks growth while auto-follow is engaged; static (summary) diff --git a/PulseLoop/Views/ActivityView.swift b/PulseLoop/Views/ActivityView.swift index 922e405..fb1527b 100644 --- a/PulseLoop/Views/ActivityView.swift +++ b/PulseLoop/Views/ActivityView.swift @@ -62,6 +62,8 @@ struct ActivityView: View { } .buttonStyle(.plain) } + // Record pill + calendar circle share one container so their glass renders/blends together. + .pulseGlassContainer(spacing: 12) Button { path.append(AppRoute.logPastActivity) } label: { HStack(spacing: 12) { @@ -136,6 +138,8 @@ struct ActivityView: View { } .padding(.horizontal, 16) .padding(.bottom, 96) + // Stacked glass cards share one container so their glass renders/blends consistently. + .pulseGlassContainer(spacing: 16) } .background(PulseColors.background) .refreshable { await coordinator.pullToRefresh() } diff --git a/PulseLoop/Views/CoachView.swift b/PulseLoop/Views/CoachView.swift index 5593af6..4d00ce9 100644 --- a/PulseLoop/Views/CoachView.swift +++ b/PulseLoop/Views/CoachView.swift @@ -122,6 +122,8 @@ struct CoachView: View { } } .padding(.horizontal, 12).padding(.vertical, 8) + // Prompt chips share one container so their glass renders/blends together. + .pulseGlassContainer(spacing: 6) } } composer @@ -188,18 +190,22 @@ struct CoachView: View { Text("Using your latest ring sync").font(PulseFont.caption2.weight(.regular)).foregroundStyle(PulseColors.textMuted) } Spacer() - Button { composerFocused = false; showUsage = true } label: { - Image(systemName: "info.circle").font(PulseFont.body).foregroundStyle(PulseColors.textSecondary) - .frame(width: 36, height: 36).pulseGlass(Circle(), interactive: true) - } - Button { newConversation() } label: { - Image(systemName: "plus").font(PulseFont.body).foregroundStyle(PulseColors.textSecondary) - .frame(width: 36, height: 36).pulseGlass(Circle(), interactive: true) - } - Button { composerFocused = false; showHistory = true } label: { - Image(systemName: "clock.arrow.circlepath").font(PulseFont.body).foregroundStyle(PulseColors.textSecondary) - .frame(width: 36, height: 36).pulseGlass(Circle(), interactive: true) + // Sibling glass circles share one container so their glass renders/blends together. + HStack(spacing: 12) { + Button { composerFocused = false; showUsage = true } label: { + Image(systemName: "info.circle").font(PulseFont.body).foregroundStyle(PulseColors.textSecondary) + .frame(width: 36, height: 36).pulseGlass(Circle(), interactive: true) + } + Button { newConversation() } label: { + Image(systemName: "plus").font(PulseFont.body).foregroundStyle(PulseColors.textSecondary) + .frame(width: 36, height: 36).pulseGlass(Circle(), interactive: true) + } + Button { composerFocused = false; showHistory = true } label: { + Image(systemName: "clock.arrow.circlepath").font(PulseFont.body).foregroundStyle(PulseColors.textSecondary) + .frame(width: 36, height: 36).pulseGlass(Circle(), interactive: true) + } } + .pulseGlassContainer(spacing: 8) } .padding(.horizontal, 16).padding(.vertical, 12) .background(PulseColors.secondaryBackground) @@ -243,6 +249,8 @@ struct CoachView: View { .buttonStyle(.plain) .disabled(!canSend) } + // Camera / field / send glass share one container so they render/blend together. + .pulseGlassContainer(spacing: 8) } .padding(.horizontal, 12).padding(.vertical, 10) .photosPicker(isPresented: $showPhotosPicker, selection: $photosPickerItem, matching: .images) diff --git a/PulseLoop/Views/MetricDetailView.swift b/PulseLoop/Views/MetricDetailView.swift index 02aa7e9..bb469a6 100644 --- a/PulseLoop/Views/MetricDetailView.swift +++ b/PulseLoop/Views/MetricDetailView.swift @@ -72,6 +72,8 @@ struct MetricDetailView: View { } .padding(16) .padding(.bottom, 40) + // Stacked glass cards share one container so their glass renders/blends consistently. + .pulseGlassContainer(spacing: 18) } .background(PulseColors.background) // Shared glass chrome: centered title + glass back button, no system nav @@ -130,7 +132,7 @@ struct MetricDetailView: View { } .padding(.horizontal, 14) .padding(.vertical, 10) - .pulseGlass(RoundedRectangle(cornerRadius: 24, style: .continuous)) + .pulseGlass(RoundedRectangle(cornerRadius: PulseRadius.card, style: .continuous)) } /// BP shows two series — systolic in the metric accent, diastolic lighter. @@ -176,7 +178,7 @@ struct MetricDetailView: View { .padding(.vertical, 14) .padding(.horizontal, 10) .frame(maxWidth: .infinity) - .pulseGlass(RoundedRectangle(cornerRadius: 24, style: .continuous)) + .pulseGlass(RoundedRectangle(cornerRadius: PulseRadius.card, style: .continuous)) } private func stat(_ title: String, _ value: String) -> some View { @@ -214,7 +216,7 @@ struct MetricDetailView: View { } } .padding(16) - .pulseGlass(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .pulseGlass(RoundedRectangle(cornerRadius: PulseRadius.card, style: .continuous)) } private var explainer: some View { @@ -224,7 +226,7 @@ struct MetricDetailView: View { } .frame(maxWidth: .infinity, alignment: .leading) .padding(16) - .pulseGlass(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .pulseGlass(RoundedRectangle(cornerRadius: PulseRadius.card, style: .continuous)) } private var disclaimer: some View { @@ -234,8 +236,8 @@ struct MetricDetailView: View { } .frame(maxWidth: .infinity, alignment: .leading) .padding(16) - .background(PulseColors.warning.opacity(0.1), in: RoundedRectangle(cornerRadius: 20, style: .continuous)) - .overlay(RoundedRectangle(cornerRadius: 20, style: .continuous).stroke(PulseColors.warning.opacity(0.3), lineWidth: 1)) + .background(PulseColors.warning.opacity(0.1), in: RoundedRectangle(cornerRadius: PulseRadius.card, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: PulseRadius.card, style: .continuous).stroke(PulseColors.warning.opacity(0.3), lineWidth: 1)) } // MARK: - Data From b53be02080b31583eb07cf28795021ff05d24b5b Mon Sep 17 00:00:00 2001 From: rgvxsthi <65727207+rgvxsthi@users.noreply.github.com> Date: Mon, 13 Jul 2026 10:43:01 +1000 Subject: [PATCH 3/8] =?UTF-8?q?fix(glass):=20activity=20cards=20=E2=80=94?= =?UTF-8?q?=20visible=20selected=20icon,=20glass=20cards,=20containers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The selected activity pill/card tinted its glass with accent AND drew an accent glyph on an accentSoft circle, so on selection the icon vanished into the purple (Log Past Activity showed a selected pill with no icon). Use a white glyph on a white-translucent circle when selected. - Record "Choose activity" cards were a solid PulseColors.card fill; make them glass (accent-tinted when selected, accent hairline for definition) to match the pills. - Wrap both activity grids in pulseGlassContainer so the tiles blend and render efficiently on iOS 26. --- PulseLoop/Views/LogPastActivityView.swift | 7 +++++-- PulseLoop/Views/RecordViews.swift | 17 ++++++++++++----- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/PulseLoop/Views/LogPastActivityView.swift b/PulseLoop/Views/LogPastActivityView.swift index a9ce337..f925458 100644 --- a/PulseLoop/Views/LogPastActivityView.swift +++ b/PulseLoop/Views/LogPastActivityView.swift @@ -136,6 +136,7 @@ private struct ActivityTypeGrid: View, Equatable { } } } + .pulseGlassContainer(spacing: 12) } } @@ -153,9 +154,11 @@ private struct ActivityTypeButton: View, Equatable { HStack(spacing: 10) { Image(systemName: kind.symbol) .font(PulseFont.title3.weight(.regular)) - .foregroundStyle(isSelected ? PulseColors.accent : PulseColors.textSecondary) + // Selected pill is accent-tinted glass, so an accent glyph on an accentSoft + // circle vanished into it — use a white glyph on a white-translucent circle. + .foregroundStyle(isSelected ? Color.white : PulseColors.textSecondary) .frame(width: 38, height: 38) - .background(isSelected ? PulseColors.accentSoft : PulseColors.cardSoft, in: Circle()) + .background(isSelected ? Color.white.opacity(0.22) : PulseColors.cardSoft, in: Circle()) Text(kind.label) .font(PulseFont.callout.weight(.semibold)) .foregroundStyle(PulseColors.textPrimary) diff --git a/PulseLoop/Views/RecordViews.swift b/PulseLoop/Views/RecordViews.swift index 3ee734c..746fb79 100644 --- a/PulseLoop/Views/RecordViews.swift +++ b/PulseLoop/Views/RecordViews.swift @@ -403,9 +403,11 @@ struct RecordSelectView: View { VStack(alignment: .leading, spacing: 6) { Image(systemName: kind.symbol) .font(PulseFont.title2.weight(.regular)) - .foregroundStyle(isSelected ? PulseColors.accent : PulseColors.textSecondary) + // White glyph on the accent-tinted selected card (accent-on-accent + // was low-contrast); neutral otherwise. + .foregroundStyle(isSelected ? Color.white : PulseColors.textSecondary) .frame(width: 46, height: 46) - .background(isSelected ? PulseColors.accentSoft : PulseColors.cardSoft, in: Circle()) + .background(isSelected ? Color.white.opacity(0.22) : PulseColors.cardSoft, in: Circle()) Text(kind.label) .font(PulseFont.bodyEmphasis) .foregroundStyle(PulseColors.textPrimary) @@ -417,13 +419,18 @@ struct RecordSelectView: View { } .frame(maxWidth: .infinity, alignment: .leading) .padding(16) - .background(isSelected ? PulseColors.accentSoft : PulseColors.card) - .clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous)) - .overlay(RoundedRectangle(cornerRadius: 24, style: .continuous).stroke(isSelected ? PulseColors.accent : PulseColors.borderSubtle, lineWidth: 1)) + // Glass card; selected = accent-tinted glass, with an accent hairline for definition. + .pulseGlass(RoundedRectangle(cornerRadius: 24, style: .continuous), + interactive: true, tint: isSelected ? PulseColors.accent : nil) + .overlay( + RoundedRectangle(cornerRadius: 24, style: .continuous) + .strokeBorder(isSelected ? PulseColors.accent.opacity(0.9) : Color.clear, lineWidth: 1) + ) } .buttonStyle(.plain) } } + .pulseGlassContainer(spacing: 12) Toggle(isOn: $useGps) { VStack(alignment: .leading, spacing: 2) { From 1fa829cb2b2ecd11b9b2f9e5fffb5b70ace24e0e Mon Sep 17 00:00:00 2001 From: rgvxsthi <65727207+rgvxsthi@users.noreply.github.com> Date: Mon, 13 Jul 2026 10:51:48 +1000 Subject: [PATCH 4/8] fix(glass): readable subtext on the selected activity card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The selected Record card is accent-tinted, but its helper subtext stayed PulseColors.textMuted (dark grey) — unreadable on the purple. Lighten the title and helper to white / white 0.75 when selected. --- PulseLoop/Views/RecordViews.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/PulseLoop/Views/RecordViews.swift b/PulseLoop/Views/RecordViews.swift index 746fb79..e1edca9 100644 --- a/PulseLoop/Views/RecordViews.swift +++ b/PulseLoop/Views/RecordViews.swift @@ -410,10 +410,11 @@ struct RecordSelectView: View { .background(isSelected ? Color.white.opacity(0.22) : PulseColors.cardSoft, in: Circle()) Text(kind.label) .font(PulseFont.bodyEmphasis) - .foregroundStyle(PulseColors.textPrimary) + .foregroundStyle(isSelected ? Color.white : PulseColors.textPrimary) Text(kind.helper) .font(PulseFont.caption.weight(.regular)) - .foregroundStyle(PulseColors.textMuted) + // textMuted is unreadable on the accent-tinted selected card. + .foregroundStyle(isSelected ? Color.white.opacity(0.75) : PulseColors.textMuted) .lineLimit(2) .frame(maxWidth: .infinity, alignment: .leading) } From d75af8a213f44f5fd4546056ddadac5b203632ff Mon Sep 17 00:00:00 2001 From: rgvxsthi <65727207+rgvxsthi@users.noreply.github.com> Date: Mon, 13 Jul 2026 10:58:36 +1000 Subject: [PATCH 5/8] fix(activity): whole card/pill is tappable, not just the icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Converting the activity cards to glass dropped the full-card tap target — a glass background doesn't hit-test the empty (undrawn) card area like the old solid fill did, so only the opaque icon/text registered taps. Add an explicit .contentShape over the card and the Log-Past-Activity pill. --- PulseLoop/Views/LogPastActivityView.swift | 2 ++ PulseLoop/Views/RecordViews.swift | 3 +++ 2 files changed, 5 insertions(+) diff --git a/PulseLoop/Views/LogPastActivityView.swift b/PulseLoop/Views/LogPastActivityView.swift index f925458..28c3a68 100644 --- a/PulseLoop/Views/LogPastActivityView.swift +++ b/PulseLoop/Views/LogPastActivityView.swift @@ -169,6 +169,8 @@ private struct ActivityTypeButton: View, Equatable { // Selected = accent-tinted glass; others = plain glass. .pulseGlass(RoundedRectangle(cornerRadius: 18, style: .continuous), interactive: true, tint: isSelected ? PulseColors.accent : nil) + // Whole pill taps (glass doesn't hit-test the empty area like a solid fill would). + .contentShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) } .buttonStyle(.plain) } diff --git a/PulseLoop/Views/RecordViews.swift b/PulseLoop/Views/RecordViews.swift index e1edca9..19059ba 100644 --- a/PulseLoop/Views/RecordViews.swift +++ b/PulseLoop/Views/RecordViews.swift @@ -427,6 +427,9 @@ struct RecordSelectView: View { RoundedRectangle(cornerRadius: 24, style: .continuous) .strokeBorder(isSelected ? PulseColors.accent.opacity(0.9) : Color.clear, lineWidth: 1) ) + // Whole card taps — glass doesn't hit-test like the old solid fill, so + // without this only the icon/text (not the empty card area) registered. + .contentShape(RoundedRectangle(cornerRadius: 24, style: .continuous)) } .buttonStyle(.plain) } From c88696e46110278b3f3efbe57a9e94a10b148cff Mon Sep 17 00:00:00 2001 From: rgvxsthi <65727207+rgvxsthi@users.noreply.github.com> Date: Mon, 13 Jul 2026 11:11:30 +1000 Subject: [PATCH 6/8] feat(activity): unified selection grammar + accessibility (UX panel spec) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Definitive pass on the Record and Log Past Activity pickers, from a UX-research + UI-design review, so we stop patching contrast/tap issues: - Selected surface is now a dim accentSoft-tinted glass, not a saturated accent billboard — that over-prominent fill was the root cause of the vanishing-icon and unreadable-subtext patches. Keeps the white glyph/ subtext; adds a 1.5pt accent hairline and a corner checkmark so selection is signaled by shape, not colour alone (WCAG 1.4.1). - One grammar across the Record card, the Log-Past pill, and the quick- duration chips (chips drop their solid-accent fill for the same soft-tint + accent-border treatment). - VoiceOver: each card/pill/chip is a combined element with a label and an .isSelected trait; the duration stepper's ± buttons are labelled and the minus disables at the 5-min floor. - Selection haptics (.sensoryFeedback), PulseRadius tokens (card 20 / compact 16), and a single-column reflow for the card grid at accessibility Dynamic Type sizes. --- PulseLoop/Views/LogPastActivityView.swift | 65 +++++++++++++++-------- PulseLoop/Views/RecordViews.swift | 54 ++++++++++++------- 2 files changed, 77 insertions(+), 42 deletions(-) diff --git a/PulseLoop/Views/LogPastActivityView.swift b/PulseLoop/Views/LogPastActivityView.swift index 28c3a68..197a9c8 100644 --- a/PulseLoop/Views/LogPastActivityView.swift +++ b/PulseLoop/Views/LogPastActivityView.swift @@ -136,7 +136,7 @@ private struct ActivityTypeGrid: View, Equatable { } } } - .pulseGlassContainer(spacing: 12) + .sensoryFeedback(.selection, trigger: selectedType) } } @@ -154,25 +154,35 @@ private struct ActivityTypeButton: View, Equatable { HStack(spacing: 10) { Image(systemName: kind.symbol) .font(PulseFont.title3.weight(.regular)) - // Selected pill is accent-tinted glass, so an accent glyph on an accentSoft - // circle vanished into it — use a white glyph on a white-translucent circle. - .foregroundStyle(isSelected ? Color.white : PulseColors.textSecondary) + .foregroundStyle(isSelected ? .white : PulseColors.textSecondary) .frame(width: 38, height: 38) .background(isSelected ? Color.white.opacity(0.22) : PulseColors.cardSoft, in: Circle()) Text(kind.label) .font(PulseFont.callout.weight(.semibold)) - .foregroundStyle(PulseColors.textPrimary) + .foregroundStyle(isSelected ? .white : PulseColors.textPrimary) Spacer(minLength: 0) + if isSelected { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 16)) + .foregroundStyle(PulseColors.accent) + .accessibilityHidden(true) + } } .padding(12) .frame(maxWidth: .infinity) - // Selected = accent-tinted glass; others = plain glass. - .pulseGlass(RoundedRectangle(cornerRadius: 18, style: .continuous), - interactive: true, tint: isSelected ? PulseColors.accent : nil) - // Whole pill taps (glass doesn't hit-test the empty area like a solid fill would). - .contentShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) + .contentShape(RoundedRectangle(cornerRadius: PulseRadius.compact, style: .continuous)) + // Selected = dim accent-tinted glass; others = plain glass. + .pulseGlass(RoundedRectangle(cornerRadius: PulseRadius.compact, style: .continuous), + interactive: true, tint: isSelected ? PulseColors.accentSoft : nil) + .overlay( + RoundedRectangle(cornerRadius: PulseRadius.compact, style: .continuous) + .strokeBorder(isSelected ? PulseColors.accent.opacity(0.9) : Color.clear, lineWidth: 1.5) + ) } .buttonStyle(.plain) + .accessibilityElement(children: .combine) + .accessibilityLabel(kind.label) + .accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton) } } @@ -201,11 +211,7 @@ private struct PastActivityTimeCard: View { .foregroundStyle(isValid ? PulseColors.textSecondary : PulseColors.warning) } } - // Glass as a BACKGROUND layer so the foreground compact DatePicker stays outside the - // iOS 26 glassEffect — an interactive control inside glass can vanish/flicker when its - // popover opens. See SettingsLabeledRow. - .background { Color.clear.pulseGlass(RoundedRectangle(cornerRadius: 20, style: .continuous)) } - .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .pulseGlass(RoundedRectangle(cornerRadius: PulseRadius.card, style: .continuous)) } private func row(title: String, systemImage: String, @ViewBuilder content: () -> Content) -> some View { @@ -232,10 +238,14 @@ private struct PastActivityDurationCard: View, Equatable { static func == (lhs: Self, rhs: Self) -> Bool { lhs.minutes == rhs.minutes } + private var atFloor: Bool { minutes <= 5 } + var body: some View { VStack(spacing: 16) { HStack { - durationButton(systemImage: "minus") { onChange(max(5, minutes - 5)) } + durationButton(systemImage: "minus", disabled: atFloor) { onChange(max(5, minutes - 5)) } + .accessibilityLabel("Decrease duration") + .accessibilityValue(durationText) Spacer() VStack(spacing: 2) { Text(durationText) @@ -249,31 +259,42 @@ private struct PastActivityDurationCard: View, Equatable { } Spacer() durationButton(systemImage: "plus") { onChange(minutes + 5) } + .accessibilityLabel("Increase duration") + .accessibilityValue(durationText) } HStack(spacing: 8) { ForEach(Self.quickDurations, id: \.self) { value in + let chipSelected = minutes == value Button("\(value)m") { onChange(value) } .font(PulseFont.caption.weight(.semibold)) - .foregroundStyle(minutes == value ? Color.white : PulseColors.textSecondary) + .foregroundStyle(chipSelected ? PulseColors.accent : PulseColors.textSecondary) .frame(maxWidth: .infinity) .frame(height: 34) - .background(minutes == value ? PulseColors.accent : PulseColors.cardSoft, in: Capsule()) + .background(chipSelected ? PulseColors.accentSoft : PulseColors.cardSoft, in: Capsule()) + .overlay( + Capsule().strokeBorder(chipSelected ? PulseColors.accent.opacity(0.9) : Color.clear, lineWidth: 1) + ) .buttonStyle(.plain) + .accessibilityLabel("\(value) minutes") + .accessibilityAddTraits(chipSelected ? [.isButton, .isSelected] : .isButton) } } + .sensoryFeedback(.selection, trigger: minutes) } .padding(16) - .pulseGlass(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .pulseGlass(RoundedRectangle(cornerRadius: PulseRadius.card, style: .continuous)) } - private func durationButton(systemImage: String, action: @escaping () -> Void) -> some View { + private func durationButton(systemImage: String, disabled: Bool = false, action: @escaping () -> Void) -> some View { Button(action: action) { - Image(systemName: systemImage).frame(width: 44, height: 44) + Image(systemName: systemImage) + .foregroundStyle(disabled ? PulseColors.textMuted : PulseColors.textPrimary) + .frame(width: 44, height: 44) } .buttonStyle(.plain) - .foregroundStyle(PulseColors.textPrimary) .background(PulseColors.cardSoft, in: Circle()) + .disabled(disabled) } private var durationText: String { diff --git a/PulseLoop/Views/RecordViews.swift b/PulseLoop/Views/RecordViews.swift index 19059ba..ab14b8e 100644 --- a/PulseLoop/Views/RecordViews.swift +++ b/PulseLoop/Views/RecordViews.swift @@ -370,10 +370,7 @@ private struct EditWorkoutSheet: View { content() } .padding(.horizontal, 16).padding(.vertical, 8) - // Glass as a BACKGROUND layer so the foreground .menu Picker / compact DatePickers - // stay outside the iOS 26 glassEffect — an interactive control inside glass can - // vanish/flicker when its popover opens. See SettingsLabeledRow. - .background { Color.clear.pulseGlass(RoundedRectangle(cornerRadius: 16, style: .continuous)) } + .pulseGlass(RoundedRectangle(cornerRadius: 16, style: .continuous)) } } @@ -382,12 +379,22 @@ private struct EditWorkoutSheet: View { struct RecordSelectView: View { @Environment(\.modelContext) private var modelContext @Environment(LiveWorkoutManager.self) private var liveWorkout + @Environment(\.dynamicTypeSize) private var dynamicTypeSize @Binding var path: NavigationPath @State private var selected = "run" @State private var useGps = true private var gpsCapable: Bool { ActivityMeta.meta(selected).gpsCapable } + // At accessibility text sizes the 2-up grid clips the 2-line helper — reflow to a + // single column so each card can grow vertically. Icon sizes stay fixed. + private var gridColumns: [GridItem] { + if dynamicTypeSize >= .accessibility1 { + return [GridItem(.flexible(), spacing: 12)] + } + return [GridItem(.flexible(), spacing: 12), GridItem(.flexible(), spacing: 12)] + } + var body: some View { ScrollView { VStack(alignment: .leading, spacing: 16) { @@ -396,45 +403,52 @@ struct RecordSelectView: View { .textCase(.uppercase) .foregroundStyle(PulseColors.textSecondary) - LazyVGrid(columns: [GridItem(.flexible(), spacing: 12), GridItem(.flexible(), spacing: 12)], spacing: 12) { + LazyVGrid(columns: gridColumns, spacing: 12) { ForEach(ActivityMeta.allKinds) { kind in let isSelected = kind.type == selected Button { selected = kind.type } label: { VStack(alignment: .leading, spacing: 6) { Image(systemName: kind.symbol) .font(PulseFont.title2.weight(.regular)) - // White glyph on the accent-tinted selected card (accent-on-accent - // was low-contrast); neutral otherwise. - .foregroundStyle(isSelected ? Color.white : PulseColors.textSecondary) + .foregroundStyle(isSelected ? .white : PulseColors.textSecondary) .frame(width: 46, height: 46) .background(isSelected ? Color.white.opacity(0.22) : PulseColors.cardSoft, in: Circle()) Text(kind.label) .font(PulseFont.bodyEmphasis) - .foregroundStyle(isSelected ? Color.white : PulseColors.textPrimary) + .foregroundStyle(isSelected ? .white : PulseColors.textPrimary) Text(kind.helper) .font(PulseFont.caption.weight(.regular)) - // textMuted is unreadable on the accent-tinted selected card. - .foregroundStyle(isSelected ? Color.white.opacity(0.75) : PulseColors.textMuted) + .foregroundStyle(isSelected ? Color.white.opacity(0.85) : PulseColors.textMuted) .lineLimit(2) .frame(maxWidth: .infinity, alignment: .leading) } .frame(maxWidth: .infinity, alignment: .leading) .padding(16) - // Glass card; selected = accent-tinted glass, with an accent hairline for definition. - .pulseGlass(RoundedRectangle(cornerRadius: 24, style: .continuous), - interactive: true, tint: isSelected ? PulseColors.accent : nil) + .contentShape(RoundedRectangle(cornerRadius: PulseRadius.card, style: .continuous)) + .pulseGlass(RoundedRectangle(cornerRadius: PulseRadius.card, style: .continuous), + interactive: true, tint: isSelected ? PulseColors.accentSoft : nil) .overlay( - RoundedRectangle(cornerRadius: 24, style: .continuous) - .strokeBorder(isSelected ? PulseColors.accent.opacity(0.9) : Color.clear, lineWidth: 1) + RoundedRectangle(cornerRadius: PulseRadius.card, style: .continuous) + .strokeBorder(isSelected ? PulseColors.accent.opacity(0.9) : Color.clear, lineWidth: 1.5) ) - // Whole card taps — glass doesn't hit-test like the old solid fill, so - // without this only the icon/text (not the empty card area) registered. - .contentShape(RoundedRectangle(cornerRadius: 24, style: .continuous)) + .overlay(alignment: .topTrailing) { + if isSelected { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 18)) + .foregroundStyle(PulseColors.accent) + .padding(10) + .accessibilityHidden(true) + } + } } .buttonStyle(.plain) + .accessibilityElement(children: .combine) + .accessibilityLabel(kind.label) + .accessibilityHint(kind.helper) + .accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton) } } - .pulseGlassContainer(spacing: 12) + .sensoryFeedback(.selection, trigger: selected) Toggle(isOn: $useGps) { VStack(alignment: .leading, spacing: 2) { From b77b66f8c1b24bbbf0f8b5a36afa165349e6be0a Mon Sep 17 00:00:00 2001 From: rgvxsthi <65727207+rgvxsthi@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:07:11 +1000 Subject: [PATCH 7/8] fix(activity): row wrap, dead minus button, start feedback, card blending MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "Started" label wrapped to a second line when the date/time chips were wide — pin it with .fixedSize. - The duration minus button only registered on the thin glyph (no contentShape), so taps on the rest of the 44pt circle did nothing — add .contentShape(Rectangle()). (Plus "worked" only because its glyph is bigger.) - Add a .success haptic when a workout starts (Record) or a past activity is logged, so the action is confirmed. - Drop the glass container around ActivityView's whole content stack — it made adjacent workout cards (Dance/Walk) blend/bleed into each other. Small button clusters keep their own containers. --- PulseLoop/Views/ActivityView.swift | 4 ++-- PulseLoop/Views/LogPastActivityView.swift | 7 ++++++- PulseLoop/Views/RecordViews.swift | 2 ++ 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/PulseLoop/Views/ActivityView.swift b/PulseLoop/Views/ActivityView.swift index fb1527b..9ddfb6e 100644 --- a/PulseLoop/Views/ActivityView.swift +++ b/PulseLoop/Views/ActivityView.swift @@ -138,8 +138,8 @@ struct ActivityView: View { } .padding(.horizontal, 16) .padding(.bottom, 96) - // Stacked glass cards share one container so their glass renders/blends consistently. - .pulseGlassContainer(spacing: 16) + // NB: no glass container around the whole content stack — it made adjacent workout + // cards blend/bleed into each other. Small button clusters keep their own containers. } .background(PulseColors.background) .refreshable { await coordinator.pullToRefresh() } diff --git a/PulseLoop/Views/LogPastActivityView.swift b/PulseLoop/Views/LogPastActivityView.swift index 197a9c8..51607e8 100644 --- a/PulseLoop/Views/LogPastActivityView.swift +++ b/PulseLoop/Views/LogPastActivityView.swift @@ -1,5 +1,6 @@ import SwiftUI import SwiftData +import UIKit struct LogPastActivityView: View { @Environment(\.modelContext) private var modelContext @@ -76,9 +77,11 @@ struct LogPastActivityView: View { durationMinutes: Double(durationMinutes), context: modelContext ) + UINotificationFeedbackGenerator().notificationOccurred(.success) // "activity logged" cue path.removeLast() path.append(AppRoute.activityDetail(session.id)) } catch { + UINotificationFeedbackGenerator().notificationOccurred(.error) saveError = error.localizedDescription } } @@ -222,7 +225,8 @@ private struct PastActivityTimeCard: View { Text(title) .font(PulseFont.subheadline) .foregroundStyle(PulseColors.textPrimary) - Spacer() + .fixedSize() // never wrap "Started" when the date/time chips are wide + Spacer(minLength: 8) content() } .padding(.horizontal, 16) @@ -291,6 +295,7 @@ private struct PastActivityDurationCard: View, Equatable { Image(systemName: systemImage) .foregroundStyle(disabled ? PulseColors.textMuted : PulseColors.textPrimary) .frame(width: 44, height: 44) + .contentShape(Rectangle()) // whole 44pt circle taps, not just the thin glyph } .buttonStyle(.plain) .background(PulseColors.cardSoft, in: Circle()) diff --git a/PulseLoop/Views/RecordViews.swift b/PulseLoop/Views/RecordViews.swift index ab14b8e..9c7ea0e 100644 --- a/PulseLoop/Views/RecordViews.swift +++ b/PulseLoop/Views/RecordViews.swift @@ -1,5 +1,6 @@ import SwiftUI import SwiftData +import UIKit // MARK: - Shared small pieces @@ -465,6 +466,7 @@ struct RecordSelectView: View { PrimaryButton(title: "Start", systemImage: "play.fill") { let willUseGps = useGps && gpsCapable let session = liveWorkout.start(type: selected, useGps: willUseGps) + UINotificationFeedbackGenerator().notificationOccurred(.success) // "it started" cue path.append(AppRoute.recordLive(session.id)) } } From 15cb39737b4bdaaa0d86d76e5d65eb7eefeb784c Mon Sep 17 00:00:00 2001 From: rgvxsthi <65727207+rgvxsthi@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:26:35 +1000 Subject: [PATCH 8/8] fix(activity): cache today summary off the render path (stop glass flash) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ActivityView computed buildTodaySummary + recoverStaleSession inline in body, so every @Query invalidation (e.g. logging the first activity) re-ran the full summary and re-created the glass tiles — on iOS 26 they flashed white before the material settled. Move them into cached @State refreshed via .task(id: dataChange.token) + onChange(sessions), so a data change updates the tiles in place. Also removes the per-body context fetches the audit flagged. --- PulseLoop/Views/ActivityView.swift | 51 +++++++++++++++++++----------- 1 file changed, 33 insertions(+), 18 deletions(-) diff --git a/PulseLoop/Views/ActivityView.swift b/PulseLoop/Views/ActivityView.swift index 9ddfb6e..a48f638 100644 --- a/PulseLoop/Views/ActivityView.swift +++ b/PulseLoop/Views/ActivityView.swift @@ -14,32 +14,44 @@ struct ActivityView: View { @State private var goalsOpen = false @State private var historyOpen = false + @State private var dataChange = PulseDataChange.shared + // Cached off the render path (via reload() on token/data change) so an @Query invalidation + // doesn't re-run buildTodaySummary and re-create the glass tiles every body eval — that + // re-render flashed all the tiles white on iOS 26. Mirrors Today/Vitals. + @State private var summary: TodaySummary? + @State private var stale: [ActivitySession] = [] + @State private var caloriesAvailable = false + + private func reload() { + summary = MetricsService.buildTodaySummary(context: modelContext) + stale = ActivityRecorderService.recoverStaleSession(context: modelContext) + caloriesAvailable = MetricsService.isVisible(.calories, context: modelContext) + } var body: some View { - let summary = MetricsService.buildTodaySummary(context: modelContext) - let stale = ActivityRecorderService.recoverStaleSession(context: modelContext) let todayWorkouts = sessions.filter { $0.status == .finished && Calendar.current.isDateInToday($0.startedAt) } - let stepGoal = summary.goals.stepsDaily - let activeGoal = summary.goals.activeMinutesDaily - let todayIdx = todayWeekIndex() - let days = weeklyDays(summary: summary, stepGoal: stepGoal, todayIndex: todayIdx) - let activeDayCount = days.filter(\.completed).count ScrollView { VStack(spacing: 16) { - if !stale.isEmpty { - StaleSessionRecoveryCard(sessions: stale) - } + if let summary { + let stepGoal = summary.goals.stepsDaily + let activeGoal = summary.goals.activeMinutesDaily + let days = weeklyDays(summary: summary, stepGoal: stepGoal, todayIndex: todayWeekIndex()) + let activeDayCount = days.filter(\.completed).count + + if !stale.isEmpty { + StaleSessionRecoveryCard(sessions: stale) + } - DailyActivitySummaryCard( - summary: summary, - units: units, - caloriesAvailable: MetricsService.isVisible(.calories, context: modelContext) - ) { - path.append(AppRoute.activityTrends) - } + DailyActivitySummaryCard( + summary: summary, + units: units, + caloriesAvailable: caloriesAvailable + ) { + path.append(AppRoute.activityTrends) + } - HStack(spacing: 12) { + HStack(spacing: 12) { Button { path.append(AppRoute.recordSelect) } label: { Text("+ Record Activity") .font(PulseFont.headline) @@ -135,6 +147,7 @@ struct ActivityView: View { .pulseGlass(RoundedRectangle(cornerRadius: 20, style: .continuous)) } .buttonStyle(.plain) + } // if let summary } .padding(.horizontal, 16) .padding(.bottom, 96) @@ -144,6 +157,8 @@ struct ActivityView: View { .background(PulseColors.background) .refreshable { await coordinator.pullToRefresh() } .pulseScrollEdges() + .task(id: dataChange.token) { reload() } + .onChange(of: sessions.count) { _, _ in reload() } .sheet(isPresented: $goalsOpen) { GoalEditorSheet() } .sheet(isPresented: $historyOpen) { WorkoutHistorySheet(units: units) { id in