From b96b06612959facaf1139aef0adabbe7e77b973d Mon Sep 17 00:00:00 2001 From: liaolo <99437277+liaolo@users.noreply.github.com> Date: Mon, 3 Aug 2026 05:15:02 +0300 Subject: [PATCH 01/13] Apply custom NOOP UI --- .../Sources/StrandDesign/Components.swift | 16 +- .../StrandDesign/NoopVisualStyle.swift | 116 +++++++++++++ .../Sources/StrandDesign/Palette.swift | 28 ++-- .../Sources/StrandDesign/StrandCard.swift | 43 +---- .../Sources/StrandDesign/Typography.swift | 56 +++---- Strand/Liquid/LiquidPrimitives.swift | 157 ++++-------------- Strand/Liquid/LiquidSky.swift | 32 ++-- Strand/Liquid/LiquidTodayView.swift | 46 +---- StrandiOS/App/RootTabView.swift | 11 +- 9 files changed, 226 insertions(+), 279 deletions(-) create mode 100644 Packages/StrandDesign/Sources/StrandDesign/NoopVisualStyle.swift diff --git a/Packages/StrandDesign/Sources/StrandDesign/Components.swift b/Packages/StrandDesign/Sources/StrandDesign/Components.swift index 8973fc4de9..bbab8d12db 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/Components.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/Components.swift @@ -6,11 +6,11 @@ import SwiftUI // the uniform, instrument-grade look from the reference. Do not invent ad-hoc cards. public enum NoopMetrics { - public static let cardRadius: CGFloat = 22 // Apple x WHOOP rounded cards — matches the liquid home card (LiquidTodayView.card) // Apple x WHOOP: rounded cards - public static let cardPadding: CGFloat = 16 // Apple x WHOOP: roomier card interior - public static let gap: CGFloat = 12 // gap between cards - public static let sectionGap: CGFloat = 22 // Apple x WHOOP: breathing room (not cramped) - public static let screenPadding: CGFloat = 18 + public static let cardRadius: CGFloat = NoopVisualStyle.cardRadius + public static let cardPadding: CGFloat = NoopVisualStyle.cardPadding + public static let gap: CGFloat = NoopVisualStyle.itemGap + public static let sectionGap: CGFloat = NoopVisualStyle.sectionGap + public static let screenPadding: CGFloat = NoopVisualStyle.pagePadding public static let tileHeight: CGFloat = 96 // Design Reset: tighter metric tile // Key Metrics grid: one fixed height every tile snaps to, so a sparkline-and-caption tile and a // plain value tile read the same. maxHeight: .infinity can't equalise them inside a LazyVGrid (the @@ -39,9 +39,9 @@ public enum NoopMetrics { // MARK: Named layout constants — the canonical margins/heights screens compose with. /// Horizontal page margin (the gutter on the left/right edge of a screen). Use via `.screenPadding()`. - public static let screenHPadding: CGFloat = 20 + public static let screenHPadding: CGFloat = NoopVisualStyle.pagePadding /// Vertical gap between top-level page sections. - public static let sectionSpacing: CGFloat = 24 + public static let sectionSpacing: CGFloat = NoopVisualStyle.sectionGap /// Interior padding inside a card's content (matches `cardPadding`). public static let cardInnerPadding: CGFloat = 16 /// Vertical gap between stacked elements INSIDE a card. @@ -51,7 +51,7 @@ public enum NoopMetrics { /// Standard interactive-control height (buttons, fields, segmented controls). public static let controlHeight: CGFloat = 48 /// Fully-rounded corner radius — pills, chips, capsule buttons. - public static let pillRadius: CGFloat = 999 + public static let pillRadius: CGFloat = NoopVisualStyle.pillRadius /// Minimum desktop size for a navigation-based customization sheet. public static let editorSheetMinWidth: CGFloat = 440 public static let editorSheetMinHeight: CGFloat = 600 diff --git a/Packages/StrandDesign/Sources/StrandDesign/NoopVisualStyle.swift b/Packages/StrandDesign/Sources/StrandDesign/NoopVisualStyle.swift new file mode 100644 index 0000000000..983b7ed4c5 --- /dev/null +++ b/Packages/StrandDesign/Sources/StrandDesign/NoopVisualStyle.swift @@ -0,0 +1,116 @@ +import SwiftUI + +// MARK: - NOOP visual foundation +// +// These tokens describe the visual treatment used by NOOP's existing views. They deliberately +// contain no navigation, state, or domain semantics: screens keep their current hierarchy and data +// bindings, while cards, gauges, typography, and chrome share one maintainable source of truth. + +public enum NoopVisualStyle { + // Neutral, low-chroma surfaces sampled from the supplied dark-mode reference. + public static let canvas = Color(light: "#F3F4F6", dark: "#1D1E23") + public static let surface = Color(light: "#FFFFFF", dark: "#2A2C34") + public static let surfaceTop = Color(light: "#FFFFFF", dark: "#30323B") + public static let surfaceBottom = Color(light: "#F4F5F7", dark: "#282A31") + public static let inset = Color(light: "#E8E9ED", dark: "#23252C") + + public static let border = Color(light: "#D8DAE0", dark: "#373A44") + public static let borderHighlight = Color(light: "#FFFFFF", dark: "#4B4E59") + public static let divider = Color(light: "#E4E5E9", dark: "#383A43") + + public static let primaryText = Color(light: "#17181C", dark: "#F7F7FA") + public static let secondaryText = Color(light: "#555861", dark: "#C3C4CA") + public static let tertiaryText = Color(light: "#7D808A", dark: "#7D7F88") + + public static let mint = Color(light: "#149A78", dark: "#69DDB8") + public static let mintDeep = Color(light: "#0D765C", dark: "#13A982") + public static let mintGlow = Color(light: "#38C99E", dark: "#54E6BD") + + public static let cardRadius: CGFloat = 22 + public static let compactRadius: CGFloat = 16 + public static let pillRadius: CGFloat = 999 + public static let pagePadding: CGFloat = 16 + public static let cardPadding: CGFloat = 16 + public static let itemGap: CGFloat = 12 + public static let sectionGap: CGFloat = 26 +} + +/// Shared card/panel treatment: a quiet vertical gradient, a top-lit rim, and deep soft elevation. +/// `tint` is intentionally faint so metric identity never turns the whole card into a coloured tile. +public struct NoopPanelSurface: View { + public var tint: Color? + public var cornerRadius: CGFloat + public var elevated: Bool + public var surfaceOpacity: Double + @Environment(\.colorScheme) private var scheme + + public init( + tint: Color? = nil, + cornerRadius: CGFloat = NoopVisualStyle.cardRadius, + elevated: Bool = false, + surfaceOpacity: Double = 1 + ) { + self.tint = tint + self.cornerRadius = cornerRadius + self.elevated = elevated + self.surfaceOpacity = surfaceOpacity + } + + public var body: some View { + let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + shape + .fill( + LinearGradient( + colors: [NoopVisualStyle.surfaceTop, NoopVisualStyle.surfaceBottom], + startPoint: .top, + endPoint: .bottom + ) + ) + .overlay { + if let tint { + shape.fill( + LinearGradient( + colors: [tint.opacity(0.055), tint.opacity(0.012), .clear], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + } + } + .overlay( + shape.strokeBorder( + LinearGradient( + colors: [NoopVisualStyle.borderHighlight.opacity(0.72), NoopVisualStyle.border.opacity(0.52)], + startPoint: .top, + endPoint: .bottom + ), + lineWidth: 0.8 + ) + ) + .shadow( + color: scheme == .dark ? .black.opacity(elevated ? 0.34 : 0.18) : .black.opacity(0.10), + radius: elevated ? 18 : 9, + x: 0, + y: elevated ? 10 : 5 + ) + .opacity(surfaceOpacity) + } +} + +public extension View { + func noopPanel( + tint: Color? = nil, + cornerRadius: CGFloat = NoopVisualStyle.cardRadius, + elevated: Bool = false, + surfaceOpacity: Double = 1 + ) -> some View { + background { + NoopPanelSurface( + tint: tint, + cornerRadius: cornerRadius, + elevated: elevated, + surfaceOpacity: surfaceOpacity + ) + } + } +} diff --git a/Packages/StrandDesign/Sources/StrandDesign/Palette.swift b/Packages/StrandDesign/Sources/StrandDesign/Palette.swift index 6276448a55..b98a115c33 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/Palette.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/Palette.swift @@ -68,17 +68,17 @@ public enum StrandPalette { // MARK: Surfaces — deep navy canvas, tinted frosted cards // Background is a near-black navy (NOT pure black); cards float just above it. - public static let surfaceBase = Color(light: "#F2F2F7", dark: "#121518") // WHOOP dark blue-grey canvas (sampled) - public static let surfaceRaised = Color(light: "#FFFFFF", dark: "#25292C") // WHOOP grey list-card fill (sampled) - public static let surfaceOverlay = Color(light: "#FFFFFF", dark: "#1C1F26") // popovers / sheets / tooltips - public static let surfaceInset = Color(light: "#E9E9EE", dark: "#1F2229") // wells / chart insets / segmented track - public static let hairline = Color(light: "#D8D0BD", dark: "#21304A") // soft 1px border (stronger on light for card edges) - public static let hairlineStrong = Color(light: "#C7BCA4", dark: "#2E3C57") // hover / emphasis border + public static let surfaceBase = NoopVisualStyle.canvas + public static let surfaceRaised = NoopVisualStyle.surface + public static let surfaceOverlay = NoopVisualStyle.surfaceTop + public static let surfaceInset = NoopVisualStyle.inset + public static let hairline = NoopVisualStyle.border + public static let hairlineStrong = NoopVisualStyle.borderHighlight // MARK: Text — deep navy-ink on paper / cool off-white on navy - public static let textPrimary = Color(light: "#1A2230", dark: "#F4F6F8") - public static let textSecondary = Color(light: "#4C5564", dark: "#C8CFD8") - public static let textTertiary = Color(light: "#7C8696", dark: "#8A94A4") + public static let textPrimary = NoopVisualStyle.primaryText + public static let textSecondary = NoopVisualStyle.secondaryText + public static let textTertiary = NoopVisualStyle.tertiaryText // MARK: Text ON a permanently-dark surface (scheme-invariant) // Use these — NOT textPrimary/Secondary/Tertiary — for labels/pills drawn over a fill that is pinned @@ -91,16 +91,16 @@ public enum StrandPalette { public static let onDarkTertiary = Color(hex: "#8A94A4") // MARK: Glow — ambient bloom behind heroes / charts (additive on dark; faint warm on light) - public static let glowAmbient = Color(light: "#F0E4C0", dark: "#3A2D0A") + public static let glowAmbient = NoopVisualStyle.mintGlow.opacity(0.28) // MARK: Accent — chrome anchor (links, selection, focus, generic accent). On DARK this is the brand // GOLD; on LIGHT it shifts to the deep brand BLUE so gold is reserved for the recovery/Charge world // and the gold FAB — keeping the light theme from reading as wall-to-wall gold (the maintainer 2026-06-16). - public static let accent = Color(light: "#234F9E", dark: "#60A0E0") // WHOOP link/action blue (gold killed 2026-06-22) - public static let accentHover = Color(light: "#1C3F80", dark: "#8FBEEC") - public static let accentMuted = Color(light: "#E4ECF6", dark: "#16233A") // selected-row tint (pale blue / dark blue) + public static let accent = NoopVisualStyle.mint + public static let accentHover = NoopVisualStyle.mintGlow + public static let accentMuted = NoopVisualStyle.mintDeep.opacity(0.18) /// Focus ring color (blue on both schemes — WHOOP has no gold). - public static let focusRing = Color(light: "#2F6FCB", dark: "#60A0E0") + public static let focusRing = NoopVisualStyle.mint /// Opacity for dimmed/disabled sections (shared so screens don't invent their own value). public static let disabledOpacity: Double = 0.45 diff --git a/Packages/StrandDesign/Sources/StrandDesign/StrandCard.swift b/Packages/StrandDesign/Sources/StrandDesign/StrandCard.swift index e40b7815e1..f45f46e914 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/StrandCard.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/StrandCard.swift @@ -29,7 +29,6 @@ public struct FrostedCardSurface: View { public var tint: Color? public var cornerRadius: CGFloat public var washStrength: Double - @Environment(\.colorScheme) private var scheme // "Card transparency" setting (reactive): fades the whole glass surface toward the background. 100 = // solid (default). Reading it here makes every card update live when the Settings slider moves. @AppStorage(CardAppearancePrefs.opacityKey) private var cardOpacityPercent = CardAppearancePrefs.defaultPercent @@ -41,43 +40,13 @@ public struct FrostedCardSurface: View { } public var body: some View { - let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) let op = max(0.0, min(1.0, Double(cardOpacityPercent) / 100.0)) - // Base fill: tinted cards deepen into the 150° navy bevel (#15243C → #0B1424, - // = surfaceOverlay → cardFillBottom); neutral cards sit on the flat raised - // surface. The 150° axis ≈ top-trailing → bottom-leading. - // Design Reset: a flat raised fill reads cleaner than the navy bevel gradient. Tinted and - // neutral cards now share the same flat surface; tint identity is carried by the softened - // hue wash + the tinted hairline below, not a gradient, so cards stay familiar but flatten. - let baseFill = AnyShapeStyle(StrandPalette.surfaceRaised) - shape - .fill(baseFill) - .overlay( - // A faint per-domain hue wash — only on tinted cards; neutral stays flat. - shape.fill( - LinearGradient( - colors: [ - (tint ?? .clear).opacity(0.05 * washStrength), - (tint ?? .clear).opacity(0.015 * washStrength), - .clear - ], - startPoint: .topLeading, endPoint: .bottomTrailing - ) - ) - ) - // Liquid redesign (2026-07-02): a 1px resting hairline in BOTH themes so every card - // matches the liquid home card's edge (LiquidTodayView.card), not just fill contrast. - .overlay(shape.strokeBorder(StrandPalette.hairline, lineWidth: 1)) - // LIGHT raises white cards off the warm-paper canvas with a soft resting drop shadow; DARK - // stays flat (the hairline + fill carry the edge, matching the home card which has no shadow). - .shadow( - color: scheme == .light ? Color(hex: "#1A2230").opacity(0.11) : .clear, - radius: scheme == .light ? 10 : 0, - x: 0, y: scheme == .light ? 3 : 0 - ) - // "Card transparency": fade the whole glass surface. The card's content sits above this - // background, so it stays fully readable regardless. - .opacity(op) + NoopPanelSurface( + tint: tint?.opacity(washStrength), + cornerRadius: cornerRadius, + elevated: false, + surfaceOpacity: op + ) } } diff --git a/Packages/StrandDesign/Sources/StrandDesign/Typography.swift b/Packages/StrandDesign/Sources/StrandDesign/Typography.swift index 2e701e3d8a..cfb94320f2 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/Typography.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/Typography.swift @@ -2,10 +2,8 @@ import SwiftUI // MARK: - Strand Typography (§9.2) // -// Helvetica Neue everywhere (Titanium & Gold): a precise, mechanical grotesque -// in place of the old rounded face. Tabular/monospaced digits on every numeric -// role so live values don't reflow. SF Mono stays for raw/log views. Overline = -// sparing ALL-CAPS w/ wide tracking. +// SF Rounded follows the supplied reference's friendly Apple-native geometry. Tabular digits keep live +// metrics stable, while named text styles retain Dynamic Type scaling. SF Mono remains reserved for logs. // // All numeric styles use `.monospacedDigit()` so live values don't reflow. @@ -13,24 +11,8 @@ public enum StrandFont { // MARK: Family - /// The house family — Helvetica Neue, a built-in system face. Weight is applied - /// via `.weight()` since `Font.custom` ignores the design's default weight. - private static let family = "Helvetica Neue" - - /// Helvetica Neue at a FIXED size/weight — used by the big gauge/tile numerals (`display`, - /// `rounded`, `number`) that live in fixed-geometry rings/tiles where unbounded growth would - /// overflow. Prose and inline-number roles use `helveticaScaled` instead. - private static func helvetica(_ size: CGFloat, weight: Font.Weight) -> Font { - .custom(family, size: size).weight(weight) - } - - /// Like `helvetica`, but the size SCALES with the user's Dynamic Type / Larger Text setting, - /// anchored to a matching text style. The plain `.custom(_:size:)` overload produces a FROZEN - /// point size, so every prose/label role used to ignore Dynamic Type entirely — this routes them - /// through `.custom(_:size:relativeTo:)` so they scale (available on the iOS 16 / macOS 13 floor). - private static func helveticaScaled(_ size: CGFloat, weight: Font.Weight, - relativeTo style: Font.TextStyle) -> Font { - .custom(family, size: size, relativeTo: style).weight(weight) + private static func roundedSystem(_ size: CGFloat, weight: Font.Weight) -> Font { + .system(size: size, weight: weight, design: .rounded) } // MARK: Scale (§9.2) @@ -38,7 +20,7 @@ public enum StrandFont { /// Display 64–80 / Bold — the gauge score number. Helvetica Neue 700 with tight /// tracking (≈ -0.04em), tabular digits so a changing value never reflows. public static func display(_ size: CGFloat = 72) -> Font { - helvetica(size, weight: .bold).monospacedDigit() + roundedSystem(size, weight: .bold).monospacedDigit() } /// The tight tracking for big display numbers (≈ -0.04em). Apply alongside @@ -50,39 +32,39 @@ public enum StrandFont { /// A Helvetica-Neue numeric style at an arbitrary size/weight — the house /// numeral. Tabular so live values align. Use anywhere a score/number is shown. public static func rounded(_ size: CGFloat, weight: Font.Weight = .bold) -> Font { - helvetica(size, weight: weight).monospacedDigit() + roundedSystem(size, weight: weight).monospacedDigit() } /// Title1 28 / Bold. Scales with Dynamic Type. - public static let title1 = helveticaScaled(28, weight: .bold, relativeTo: .title) + public static let title1 = Font.system(.title, design: .rounded, weight: .bold) /// Title2 22 / Semibold. Scales with Dynamic Type. - public static let title2 = helveticaScaled(22, weight: .semibold, relativeTo: .title2) + public static let title2 = Font.system(.title2, design: .rounded, weight: .semibold) /// Headline 17 / Semibold. Scales with Dynamic Type. - public static let headline = helveticaScaled(17, weight: .semibold, relativeTo: .headline) + public static let headline = Font.system(.headline, design: .rounded, weight: .semibold) /// Body 15 / Regular. Scales with Dynamic Type. - public static let body = helveticaScaled(15, weight: .regular, relativeTo: .body) + public static let body = Font.system(.body, design: .rounded, weight: .regular) /// Subhead 13. Scales with Dynamic Type. - public static let subhead = helveticaScaled(13, weight: .regular, relativeTo: .subheadline) + public static let subhead = Font.system(.subheadline, design: .rounded, weight: .regular) /// Caption 12. Scales with Dynamic Type. - public static let caption = helveticaScaled(12, weight: .regular, relativeTo: .caption) + public static let caption = Font.system(.caption, design: .rounded, weight: .regular) /// Footnote 11. Scales with Dynamic Type. - public static let footnote = helveticaScaled(11, weight: .regular, relativeTo: .footnote) + public static let footnote = Font.system(.footnote, design: .rounded, weight: .regular) /// Overline 11 / Bold, +1.4 tracking (apply `.tracking(1.4)` at use site; /// `overlineText(_:)` does it for you). Sparing ALL-CAPS labels. Scales with Dynamic Type. - public static let overline = helveticaScaled(11, weight: .bold, relativeTo: .caption2) + public static let overline = Font.system(.caption2, design: .rounded, weight: .semibold) /// `overline` at a custom point size — same Helvetica face, weight and Dynamic-Type scaling /// (relativeTo `.caption2`), just smaller. Passing 11 returns exactly `.overline`. Lets a caller /// shrink an ALL-CAPS label to fit a small container without losing accessibility text-scaling. public static func overlineScaled(_ size: CGFloat) -> Font { - helveticaScaled(size, weight: .bold, relativeTo: .caption2) + roundedSystem(size, weight: .semibold) } /// Mono 13 (SF Mono) — raw / log views. Tabular by nature. @@ -93,15 +75,15 @@ public enum StrandFont { /// A numeric style at an arbitrary size/weight, for live values — Helvetica /// Neue, tabular digits. This is the tile/value numeral. public static func number(_ size: CGFloat, weight: Font.Weight = .semibold) -> Font { - helvetica(size, weight: weight).monospacedDigit() + roundedSystem(size, weight: weight).monospacedDigit() } /// Helvetica-Neue body number — for inline live values that should align. Scales with Dynamic /// Type alongside its sibling `body`/`caption` labels so a value and its label stay matched. - public static let bodyNumber = helveticaScaled(15, weight: .medium, relativeTo: .body).monospacedDigit() + public static let bodyNumber = Font.system(.body, design: .rounded, weight: .medium).monospacedDigit() /// Helvetica-Neue caption number — for small live values (sparklines, chips). Scales with Dynamic Type. - public static let captionNumber = helveticaScaled(12, weight: .medium, relativeTo: .caption).monospacedDigit() + public static let captionNumber = Font.system(.caption, design: .rounded, weight: .medium).monospacedDigit() /// Mono at an arbitrary size. public static func mono(_ size: CGFloat, weight: Font.Weight = .regular) -> Font { @@ -109,7 +91,7 @@ public enum StrandFont { } /// The recommended tracking for overline text (wide ALL-CAPS labels, ≈ 0.13em). - public static let overlineTracking: CGFloat = 1.4 + public static let overlineTracking: CGFloat = 0.45 } // MARK: - Text helpers diff --git a/Strand/Liquid/LiquidPrimitives.swift b/Strand/Liquid/LiquidPrimitives.swift index 5aa72bcfa5..ad14837b6e 100644 --- a/Strand/Liquid/LiquidPrimitives.swift +++ b/Strand/Liquid/LiquidPrimitives.swift @@ -13,135 +13,44 @@ import StrandDesign // NoopMotionState — the shared quiet-motion gate enum LiquidRender { - /// A circular vessel of liquid filled to `sim.level`, tinted, with parallax - /// slosh, a light band that follows tilt, surface glints, flake and droplets. + /// A softly sculpted circular progress ring. The simulation still drives the value and tap response, + /// but the visual treatment follows the reference's calm, recessed score dials instead of a filled orb. static func vessel(_ base: GraphicsContext, _ size: CGSize, _ sim: LiquidSim, now: Double, tint: Color) { - // Floor at 1 so a degenerate sub-3pt Canvas can't drive R negative (negative well rect / chord math). - let R = max(1, min(size.width, size.height) / 2 - 1.5) - let ext = R * 1.8 - let cx = size.width / 2, cy = size.height / 2 - let well = CGRect(x: -R, y: -R, width: 2 * R, height: 2 * R) - + let diameter = max(2, min(size.width, size.height) - 3) + let rect = CGRect(x: (size.width - diameter) / 2, y: (size.height - diameter) / 2, + width: diameter, height: diameter) + let center = CGPoint(x: size.width / 2, y: size.height / 2) + let radius = diameter * 0.39 + let lineWidth = max(5, diameter * 0.105) var ctx = base - ctx.translateBy(x: cx, y: cy) - ctx.fill(Path(ellipseIn: well), with: .color(Color(.sRGB, red: 10/255, green: 11/255, blue: 16/255, opacity: 0.55))) - - var body = ctx - body.clip(to: Path(ellipseIn: well)) - - let lv = sim.level - if lv > 0.004 { - let sy = R * (1 - 2 * min(0.985, lv)) - let amp = (0.018 + sim.energy * 0.09) * R - - // helper to build a wave polygon in a given (already-transformed) context - func wavePolygon(_ w: (Double) -> Double) -> Path { - var p = Path() - p.move(to: CGPoint(x: -ext, y: w(-ext))) - var x = -ext + 4 - while x <= ext { p.addLine(to: CGPoint(x: x, y: w(x))); x += 4 } - p.addLine(to: CGPoint(x: ext, y: w(ext))) - p.addLine(to: CGPoint(x: ext, y: R * 2.4)) - p.addLine(to: CGPoint(x: -ext, y: R * 2.4)) - p.closeSubpath() - return p - } - func surfaceLine(_ w: (Double) -> Double) -> Path { - var p = Path() - p.move(to: CGPoint(x: -ext, y: w(-ext))) - var x = -ext + 4 - while x <= ext { p.addLine(to: CGPoint(x: x, y: w(x))); x += 4 } - p.addLine(to: CGPoint(x: ext, y: w(ext))) - return p - } - - // back parallax layer - let syB = sy - R * 0.04 - let hwB = liquidChordHW(R, syB) - let wB: (Double) -> Double = { - liquidWave($0, amp: amp, R: R, hw: hwB, curl: liquidCurl(sim.abv), - ph1: sim.p1 * 0.92 + 2.1, ph2: sim.p2 * 0.9 + 1.3, ampMul: 1.35) - } - var backCtx = body - backCtx.translateBy(x: 0, y: syB) - backCtx.rotate(by: .radians(sim.ab)) - backCtx.fill(wavePolygon(wB), with: .color(tint.opacity(0.28))) - - // main body - let hw = liquidChordHW(R, sy) - let w: (Double) -> Double = { - liquidWave($0, amp: amp, R: R, hw: hw, curl: liquidCurl(sim.av), - ph1: sim.p1, ph2: sim.p2, ampMul: 1) - } - var mainCtx = body - mainCtx.translateBy(x: 0, y: sy) - mainCtx.rotate(by: .radians(sim.a)) - mainCtx.fill(wavePolygon(w), - with: .linearGradient(Gradient(colors: [tint.opacity(0.74), - tint.liquidDarker(0.28).opacity(0.80)]), - startPoint: CGPoint(x: 0, y: -amp), - endPoint: CGPoint(x: 0, y: R * 1.7))) - - // a sheet of light gliding across as you tilt - var bandCtx = mainCtx - bandCtx.clip(to: wavePolygon(w)) - let bandX = -sim.a * R * 2.2 + sin(now * 0.3) * R * 0.15 - bandCtx.fill(Path(CGRect(x: -R * 2.4, y: -R * 2.4, width: R * 4.8, height: R * 4.8)), - with: .linearGradient(Gradient(colors: [.white.opacity(0), .white.opacity(0.06), .white.opacity(0)]), - startPoint: CGPoint(x: bandX - R * 1.2, y: 0), - endPoint: CGPoint(x: bandX + R * 1.2, y: 0))) - - // surface sheen + glints + line - mainCtx.fill(Path(CGRect(x: -ext, y: 0, width: ext * 2, height: R * 0.15)), - with: .linearGradient(Gradient(colors: [.white.opacity(0.09), .white.opacity(0)]), - startPoint: CGPoint(x: 0, y: 0), endPoint: CGPoint(x: 0, y: R * 0.15))) - var gx = -hw - while gx <= hw { - let slope = (w(gx + 3) - w(gx - 3)) / 6 - if abs(slope) < 0.05 { - let o = 0.22 * (1 - abs(slope) / 0.05) - mainCtx.fill(Path(CGRect(x: gx - 2, y: w(gx) - 0.8, width: 4, height: 1.4)), with: .color(.white.opacity(o))) - } - gx += 6 - } - mainCtx.stroke(surfaceLine(w), with: .color(.white.opacity(0.45)), lineWidth: 1.3) - // droplets - for b in sim.drops { - let rr = max(0.7, b.r * R) - mainCtx.fill(Path(ellipseIn: CGRect(x: b.x * R - rr, y: b.y * R - rr, width: 2 * rr, height: 2 * rr)), - with: .color(.white.opacity(min(0.55, b.life * 0.5) * 0.5))) - } - - // suspended flake (circle frame, only inside the liquid) - let sa = sin(sim.a), ca = cos(sim.a) - for f in sim.flecks { - let fx = f.x * R, fy = f.y * R - if fx * fx + fy * fy > R * R * 0.9 { continue } - if -fx * sa + (fy - sy) * ca < R * 0.02 { continue } - let sVal = sin(f.ph + fx * 0.12 + sim.a * 5 + now * f.sp) - let spark = pow(max(0, sVal), 10) - let sz = 0.7 + f.z * 1.0 + spark * 1.4 - let shade: Color - switch f.kind { - case 2: shade = Color(.sRGB, red: 8/255, green: 10/255, blue: 13/255, opacity: 0.12 + spark * 0.22) - case 1: shade = tint.liquidMix(.white, 0.55).opacity(0.10 + spark * 0.8) - default: shade = .white.opacity(0.08 * f.z + spark * 0.85) - } - body.fill(Path(CGRect(x: fx - sz / 2, y: fy - sz / 2, width: sz, height: sz)), with: .color(shade)) - } + ctx.fill(Path(ellipseIn: rect), with: .linearGradient( + Gradient(colors: [Color.white.opacity(0.08), Color.black.opacity(0.11)]), + startPoint: CGPoint(x: rect.midX, y: rect.minY), + endPoint: CGPoint(x: rect.midX, y: rect.maxY))) + let inset = rect.insetBy(dx: diameter * 0.13, dy: diameter * 0.13) + ctx.fill(Path(ellipseIn: inset), with: .color(Color(.sRGB, red: 43/255, green: 45/255, blue: 54/255, opacity: 1))) + + var track = Path() + track.addArc(center: center, radius: radius, startAngle: .degrees(-90), + endAngle: .degrees(270), clockwise: false) + ctx.stroke(track, with: .color(Color.white.opacity(0.10)), + style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) + + let level = max(0, min(1, sim.level)) + if level > 0.004 { + var progress = Path() + progress.addArc(center: center, radius: radius, startAngle: .degrees(-90), + endAngle: .degrees(-90 + 360 * level), clockwise: false) + ctx.stroke(progress, + with: .linearGradient(Gradient(colors: [tint.opacity(0.72), tint]), + startPoint: CGPoint(x: rect.minX, y: rect.maxY), + endPoint: CGPoint(x: rect.maxX, y: rect.minY)), + style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) } - // inner top shadow - body.fill(Path(CGRect(x: -R, y: -R, width: 2 * R, height: R * 0.75)), - with: .linearGradient(Gradient(colors: [.black.opacity(0.30), .black.opacity(0)]), - startPoint: CGPoint(x: 0, y: -R), endPoint: CGPoint(x: 0, y: -R * 0.30))) - // soft top-left highlight - body.fill(Path(ellipseIn: CGRect(x: -R * 0.72, y: -R * 0.78, width: R * 0.9, height: R * 0.5)), - with: .radialGradient(Gradient(colors: [.white.opacity(0.09), .white.opacity(0)]), - center: CGPoint(x: -R * 0.27, y: -R * 0.5), startRadius: 0, endRadius: R * 0.55)) - // rim - ctx.stroke(Path(ellipseIn: well), with: .color(tint.opacity(0.22)), lineWidth: 1.25) + ctx.stroke(Path(ellipseIn: rect.insetBy(dx: 0.5, dy: 0.5)), + with: .color(Color.white.opacity(0.09)), lineWidth: 1) } /// A horizontal capsule tube filled to `frac`; tilt pushes the liquid along it. diff --git a/Strand/Liquid/LiquidSky.swift b/Strand/Liquid/LiquidSky.swift index 722c25f488..f51077d542 100644 --- a/Strand/Liquid/LiquidSky.swift +++ b/Strand/Liquid/LiquidSky.swift @@ -24,16 +24,16 @@ private func hx(_ hex: UInt32) -> Color { /// The ten keyframes mirror the real app's day-cycle scenes (SceneHeroBackground), /// as pure gradients rather than painted art. let liquidSkyKeys: [LiquidSkyStop] = [ - .init(h: 0, top: hx(0x05060f), mid: hx(0x0b0e22), hor: hx(0x1a1440), stars: 1, warm: 0), - .init(h: 5, top: hx(0x0a0d24), mid: hx(0x1c1a4a), hor: hx(0x4a2a6a), stars: 0.6, warm: 0), - .init(h: 6.5, top: hx(0x1b1b4d), mid: hx(0x4a2f7d), hor: hx(0xb0567a), stars: 0.25, warm: 0.2), - .init(h: 8.5, top: hx(0x2a4a8f), mid: hx(0x7a5aa0), hor: hx(0xf0a060), stars: 0, warm: 0.6), - .init(h: 11, top: hx(0x2a6ac8), mid: hx(0x5a9ae0), hor: hx(0xa8cef0), stars: 0, warm: 0.95), - .init(h: 14, top: hx(0x2f74d0), mid: hx(0x66a6e8), hor: hx(0xb8d8f4), stars: 0, warm: 1), - .init(h: 17.5, top: hx(0x3a4a90), mid: hx(0x9a5a80), hor: hx(0xf0924a), stars: 0, warm: 0.4), - .init(h: 19.5, top: hx(0x221c50), mid: hx(0x4a2a70), hor: hx(0x8a4a80), stars: 0.45, warm: 0), - .init(h: 22, top: hx(0x070818), mid: hx(0x141335), hor: hx(0x2a1d55), stars: 1, warm: 0), - .init(h: 24, top: hx(0x05060f), mid: hx(0x0b0e22), hor: hx(0x1a1440), stars: 1, warm: 0), + .init(h: 0, top: hx(0x191A1F), mid: hx(0x1D1E23), hor: hx(0x22242B), stars: 0.20, warm: 0), + .init(h: 5, top: hx(0x1A1B20), mid: hx(0x1D1F24), hor: hx(0x23252C), stars: 0.12, warm: 0), + .init(h: 6.5, top: hx(0x1B1C21), mid: hx(0x1F2026), hor: hx(0x25272E), stars: 0.06, warm: 0), + .init(h: 8.5, top: hx(0x1C1D22), mid: hx(0x202229), hor: hx(0x272A31), stars: 0, warm: 0), + .init(h: 11, top: hx(0x1D1E23), mid: hx(0x21232A), hor: hx(0x292C33), stars: 0, warm: 0), + .init(h: 14, top: hx(0x1D1E23), mid: hx(0x22242B), hor: hx(0x292C34), stars: 0, warm: 0), + .init(h: 17.5, top: hx(0x1C1D22), mid: hx(0x202229), hor: hx(0x272930), stars: 0, warm: 0), + .init(h: 19.5, top: hx(0x1B1C21), mid: hx(0x1F2026), hor: hx(0x24262D), stars: 0.05, warm: 0), + .init(h: 22, top: hx(0x191A1F), mid: hx(0x1D1E23), hor: hx(0x22242B), stars: 0.16, warm: 0), + .init(h: 24, top: hx(0x191A1F), mid: hx(0x1D1E23), hor: hx(0x22242B), stars: 0.20, warm: 0), ] private func lerp(_ a: Double, _ b: Double, _ t: Double) -> Double { a + (b - a) * t } @@ -81,9 +81,9 @@ struct LiquidSky: View { // so there is no hard seam where the sky meets the page — light mode made this glaring. let dark = scheme == .dark let settle = Color(.sRGB, - red: dark ? 18.0 / 255.0 : 242.0 / 255.0, - green: dark ? 21.0 / 255.0 : 242.0 / 255.0, - blue: dark ? 24.0 / 255.0 : 247.0 / 255.0, + red: dark ? 29.0 / 255.0 : 242.0 / 255.0, + green: dark ? 30.0 / 255.0 : 242.0 / 255.0, + blue: dark ? 35.0 / 255.0 : 247.0 / 255.0, opacity: 1) Canvas { ctx, size in render(ctx, size, hour: h, now: now, settle: settle) @@ -189,9 +189,9 @@ struct LiquidSkyStatic: View { let h = hour ?? liveHour() let dark = scheme == .dark let settle = Color(.sRGB, - red: dark ? 18.0 / 255.0 : 242.0 / 255.0, - green: dark ? 21.0 / 255.0 : 242.0 / 255.0, - blue: dark ? 24.0 / 255.0 : 247.0 / 255.0, + red: dark ? 29.0 / 255.0 : 242.0 / 255.0, + green: dark ? 30.0 / 255.0 : 242.0 / 255.0, + blue: dark ? 35.0 / 255.0 : 247.0 / 255.0, opacity: 1) Canvas { ctx, size in let S = liquidSkyAt(h) diff --git a/Strand/Liquid/LiquidTodayView.swift b/Strand/Liquid/LiquidTodayView.swift index 3195966903..4fe148a187 100644 --- a/Strand/Liquid/LiquidTodayView.swift +++ b/Strand/Liquid/LiquidTodayView.swift @@ -112,7 +112,7 @@ struct LiquidTodayView: View { /// The liquid heart pink (matches LiquidThread's default + the mockup #ff6b81). private let liquidHeart = Color(.sRGB, red: 1, green: 107 / 255, blue: 129 / 255, opacity: 1) /// Hero card fill: a translucent near-black so it floats over the sky (mock rgba(13,14,20,.78)). - private let heroFill = Color(.sRGB, red: 13 / 255, green: 14 / 255, blue: 20 / 255, opacity: 0.80) + private let heroFill = NoopVisualStyle.surface /// "Card transparency" (0–100, default 100): fades every liquid card surface here — the hero, the /// session-start row, the metric tiles and the `card` helper — in lockstep with the frosted cards. /// Content sits above the surface so it stays readable. Mirrors Kotlin `NoopPrefs.cardOpacityPercent`. @@ -450,7 +450,8 @@ struct LiquidTodayView: View { .font(.system(size: 14, weight: .bold)) .foregroundStyle(.white) .frame(width: 34, height: 34) - .background(Circle().fill(.white.opacity(0.16))) + .background(Circle().fill(NoopVisualStyle.surfaceTop.opacity(0.94))) + .overlay(Circle().strokeBorder(NoopVisualStyle.borderHighlight.opacity(0.55), lineWidth: 0.8)) } .buttonStyle(LiquidPressStyle()) .accessibilityLabel("Customize Today") @@ -494,13 +495,7 @@ struct LiquidTodayView: View { } .padding(.horizontal, 14) .padding(.vertical, 11) - .background( - RoundedRectangle(cornerRadius: 18, style: .continuous) - .fill(heroFill) - .overlay(RoundedRectangle(cornerRadius: 18, style: .continuous) - .strokeBorder(.white.opacity(0.11), lineWidth: 1)) - .opacity(cardOpacity) - ) + .background(NoopPanelSurface(cornerRadius: 18, surfaceOpacity: cardOpacity)) } .buttonStyle(LiquidPressStyle()) .accessibilityLabel("Start a live session. Beta. Silent strap coaching against today's Charge.") @@ -540,14 +535,7 @@ struct LiquidTodayView: View { } .padding(.vertical, NoopMetrics.space4) .padding(.horizontal, NoopMetrics.space3) - .background( - RoundedRectangle(cornerRadius: 26, style: .continuous) - .fill(heroFill) - .overlay(RoundedRectangle(cornerRadius: 26, style: .continuous) - .strokeBorder(.white.opacity(0.11), lineWidth: 1)) - .shadow(color: .black.opacity(0.6), radius: 30, y: 16) - .opacity(cardOpacity) - ) + .background(NoopPanelSurface(cornerRadius: 26, elevated: true, surfaceOpacity: cardOpacity)) } // MARK: - Heart rate @@ -686,13 +674,7 @@ struct LiquidTodayView: View { } .padding(.horizontal, 14) .padding(.vertical, 11) - .background( - RoundedRectangle(cornerRadius: 20, style: .continuous) - .fill(StrandPalette.surfaceRaised) - .overlay(RoundedRectangle(cornerRadius: 20, style: .continuous) - .strokeBorder(StrandPalette.hairline, lineWidth: 1)) - .opacity(cardOpacity) - ) + .background(NoopPanelSurface(tint: tint, cornerRadius: 20, surfaceOpacity: cardOpacity)) } .buttonStyle(LiquidPressStyle()) } @@ -951,13 +933,7 @@ struct LiquidTodayView: View { .padding(.horizontal, 12) .padding(.vertical, 11) .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(StrandPalette.surfaceRaised) - .overlay(RoundedRectangle(cornerRadius: 16, style: .continuous) - .strokeBorder(StrandPalette.hairline, lineWidth: 1)) - .opacity(cardOpacity) - ) + .background(NoopPanelSurface(tint: tint, cornerRadius: 16, surfaceOpacity: cardOpacity)) // #430 parity: tap -> the metric's trend detail (the same Explore dossier its MetricRow pushes, // closure-based NavigationLink per #38). A metric with no catalog entry stays inert. return Group { @@ -1052,13 +1028,7 @@ struct LiquidTodayView: View { content() .padding(16) .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 22, style: .continuous) - .fill(StrandPalette.surfaceRaised) - .overlay(RoundedRectangle(cornerRadius: 22, style: .continuous) - .strokeBorder(StrandPalette.hairline, lineWidth: 1)) - .opacity(cardOpacity) - ) + .background(NoopPanelSurface(cornerRadius: 22, surfaceOpacity: cardOpacity)) } // MARK: - Data diff --git a/StrandiOS/App/RootTabView.swift b/StrandiOS/App/RootTabView.swift index bb8a5aed2e..aa8fdc735e 100644 --- a/StrandiOS/App/RootTabView.swift +++ b/StrandiOS/App/RootTabView.swift @@ -683,16 +683,16 @@ private struct FloatingTabBar: View { // a blur material has nothing to dissolve and hardens into a solid lozenge (2026-07-02: // "clips into a solid shape"). A faint translucent scrim INSIDE the same Capsule keeps the pill // reading as tinted glass, not a slab, even against dead-flat colour. - .background(.white.opacity(0.06), in: Capsule()) + .background(NoopVisualStyle.surfaceTop.opacity(0.88), in: Capsule()) // Soft top-lit rim instead of one hard hairline, so there's no crisp cut-out edge. .overlay( Capsule().strokeBorder( - LinearGradient(colors: [.white.opacity(0.22), .white.opacity(0.04)], + LinearGradient(colors: [NoopVisualStyle.borderHighlight.opacity(0.78), NoopVisualStyle.border.opacity(0.42)], startPoint: .top, endPoint: .bottom), lineWidth: 0.75) ) // Lighter, wider shadow: real elevation without stamping a dark halo on the flat canvas. - .shadow(color: .black.opacity(0.22), radius: 18, x: 0, y: 8) + .shadow(color: .black.opacity(0.34), radius: 20, x: 0, y: 10) .padding(.horizontal, 22) .padding(.bottom, 4) } @@ -712,9 +712,10 @@ private struct FloatingTabBar: View { Text(item.title) .font(.system(size: 10, weight: active ? .semibold : .medium)) } - .foregroundStyle(active ? StrandPalette.accent : StrandPalette.textSecondary) + .foregroundStyle(active ? StrandPalette.textPrimary : StrandPalette.textSecondary) .frame(maxWidth: .infinity) - .padding(.vertical, 3) + .padding(.vertical, 6) + .background(active ? NoopVisualStyle.inset : .clear, in: Capsule()) .contentShape(Rectangle()) } .buttonStyle(.plain) From 18db457f5dc08620da051157e0d40685436b284a Mon Sep 17 00:00:00 2001 From: liaolo <99437277+liaolo@users.noreply.github.com> Date: Mon, 3 Aug 2026 09:56:54 +0300 Subject: [PATCH 02/13] Refine NOOP visual design system --- .../Sources/StrandDesign/ChartHover.swift | 13 +------ .../Sources/StrandDesign/NoopButton.swift | 12 +++++- .../Sources/StrandDesign/NoopMotion.swift | 4 +- .../StrandDesign/NoopVisualStyle.swift | 19 ++++++++++ .../StrandDesign/OverviewHRChart.swift | 10 +---- .../Sources/StrandDesign/Sparkline.swift | 2 +- .../Sources/StrandDesign/StatePill.swift | 2 +- .../Sources/StrandDesign/Typography.swift | 21 +++++++++- Strand/App/RootView.swift | 2 +- Strand/Liquid/LiveSessionView.swift | 8 +--- Strand/MenuBar/MenuBarContent.swift | 2 +- Strand/Onboarding/OnboardingWizard.swift | 11 ++---- Strand/Screens/AppleWatchSetupView.swift | 2 +- Strand/Screens/CoachView.swift | 2 +- Strand/Screens/CompareView.swift | 10 +---- Strand/Screens/CoupledView.swift | 10 +---- Strand/Screens/DevicesView.swift | 14 +++---- Strand/Screens/EditableLayoutList.swift | 2 +- Strand/Screens/HRVSnapshotView.swift | 2 +- Strand/Screens/HydrationView.swift | 8 +--- Strand/Screens/InsightsHubView.swift | 2 +- Strand/Screens/InsightsView.swift | 6 +-- Strand/Screens/LiveView.swift | 38 ++++--------------- Strand/Screens/ManualWorkoutSheet.swift | 4 +- Strand/Screens/MetricExplorerView.swift | 7 +++- Strand/Screens/NotificationSettingsView.swift | 3 +- Strand/Screens/ScoringGuideView.swift | 2 +- Strand/Screens/SleepView.swift | 8 ++-- Strand/Screens/TodayView.swift | 2 +- Strand/Screens/TrendsReportView.swift | 8 ++-- Strand/Screens/TrendsView.swift | 2 +- Strand/Screens/UpdatesInboxView.swift | 2 +- Strand/Screens/WeeklyDigestView.swift | 7 ++-- StrandiOS/App/RootTabView.swift | 7 ++-- 34 files changed, 115 insertions(+), 139 deletions(-) diff --git a/Packages/StrandDesign/Sources/StrandDesign/ChartHover.swift b/Packages/StrandDesign/Sources/StrandDesign/ChartHover.swift index 1f9e5ee4cd..422e5444b1 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/ChartHover.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/ChartHover.swift @@ -27,8 +27,6 @@ public struct ChartTooltip: View { /// gradient colour for that datum) so the tooltip explains the colour. public var accent: Color? - @Environment(\.colorScheme) private var scheme - public init(value: String, label: String? = nil, accent: Color? = nil) { self.value = value self.label = label @@ -57,16 +55,7 @@ public struct ChartTooltip: View { } .padding(.horizontal, 9) .padding(.vertical, 6) - .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(StrandPalette.surfaceOverlay) - ) - .overlay( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .stroke(StrandPalette.hairlineStrong, lineWidth: 1) - ) - .shadow(color: scheme == .light ? Color(hex: "#1A2230").opacity(0.18) : Color.black.opacity(0.45), - radius: scheme == .light ? 8 : 10, x: 0, y: scheme == .light ? 4 : 6) + .background(NoopPanelSurface(cornerRadius: 8, elevated: true)) .fixedSize() .accessibilityElement(children: .ignore) .accessibilityLabel(label != nil ? "\(value), \(label!)" : value) diff --git a/Packages/StrandDesign/Sources/StrandDesign/NoopButton.swift b/Packages/StrandDesign/Sources/StrandDesign/NoopButton.swift index 0662b11a78..8c7e50f826 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/NoopButton.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/NoopButton.swift @@ -58,6 +58,7 @@ struct NoopButtonAppearance { let fill: Color? // nil = no fill (tertiary) let label: Color let border: Color? // nil = no hairline edge + let usesPanelSurface: Bool init(_ kind: NoopButtonKind) { switch kind { @@ -65,18 +66,22 @@ struct NoopButtonAppearance { fill = StrandPalette.accent label = StrandPalette.goldDeepText // designated crisp white for text on accent fills border = nil + usesPanelSurface = false case .secondary: - fill = StrandPalette.surfaceRaised + fill = nil label = StrandPalette.textPrimary - border = StrandPalette.hairline + border = nil + usesPanelSurface = true case .tertiary: fill = nil label = StrandPalette.accent border = nil + usesPanelSurface = false case .destructive: fill = StrandPalette.statusCritical label = StrandPalette.goldDeepText // crisp white on the critical fill border = nil + usesPanelSurface = false } } } @@ -91,6 +96,9 @@ private struct NoopButtonBackground: View { var body: some View { let shape = RoundedRectangle(cornerRadius: NoopButtonMetrics.cornerRadius, style: .continuous) ZStack { + if appearance.usesPanelSurface { + NoopPanelSurface(cornerRadius: NoopButtonMetrics.cornerRadius) + } if let fill = appearance.fill { shape.fill(fill) } diff --git a/Packages/StrandDesign/Sources/StrandDesign/NoopMotion.swift b/Packages/StrandDesign/Sources/StrandDesign/NoopMotion.swift index 45443ea712..37abbf550c 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/NoopMotion.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/NoopMotion.swift @@ -383,7 +383,7 @@ private struct NoopMotionDemo: View { } .foregroundStyle(StrandPalette.textPrimary) .padding(.horizontal, 16).padding(.vertical, 12) - .background(StrandPalette.surfaceRaised, in: RoundedRectangle(cornerRadius: 14)) + .background(NoopPanelSurface(cornerRadius: 14)) .staggeredAppear(index: i) } } @@ -404,7 +404,7 @@ private struct NoopMotionDemo: View { .foregroundStyle(StrandPalette.textPrimary) .frame(maxWidth: .infinity, alignment: .leading) .padding(16) - .background(StrandPalette.surfaceRaised, in: RoundedRectangle(cornerRadius: 14)) + .background(NoopPanelSurface(cornerRadius: 14)) .softCardTransition() } } diff --git a/Packages/StrandDesign/Sources/StrandDesign/NoopVisualStyle.swift b/Packages/StrandDesign/Sources/StrandDesign/NoopVisualStyle.swift index 983b7ed4c5..31491203ab 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/NoopVisualStyle.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/NoopVisualStyle.swift @@ -97,6 +97,25 @@ public struct NoopPanelSurface: View { } } +/// Shared edge-to-edge chrome for sheet and split-view headers. Unlike a card it has no +/// rounded outline or elevation, but it uses the same top-lit surface ramp and divider token. +public struct NoopChromeSurface: View { + public init() {} + + public var body: some View { + LinearGradient( + colors: [NoopVisualStyle.surfaceTop, NoopVisualStyle.surfaceBottom], + startPoint: .top, + endPoint: .bottom + ) + .overlay(alignment: .bottom) { + Rectangle() + .fill(NoopVisualStyle.divider) + .frame(height: 0.5) + } + } +} + public extension View { func noopPanel( tint: Color? = nil, diff --git a/Packages/StrandDesign/Sources/StrandDesign/OverviewHRChart.swift b/Packages/StrandDesign/Sources/StrandDesign/OverviewHRChart.swift index 2fb157b2eb..55914ab802 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/OverviewHRChart.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/OverviewHRChart.swift @@ -571,10 +571,7 @@ private struct MarkerLabel: View { .foregroundStyle(color) .padding(.horizontal, 6) .padding(.vertical, 3) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(StrandPalette.surfaceOverlay.opacity(0.92)) - ) + .background(NoopPanelSurface(cornerRadius: 6, elevated: true, surfaceOpacity: 0.92)) .fixedSize() .allowsHitTesting(false) } @@ -591,10 +588,7 @@ private struct SleepBandLabel: View { .foregroundStyle(StrandPalette.sleepLight) .padding(.horizontal, 6) .padding(.vertical, 3) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(StrandPalette.surfaceOverlay.opacity(0.92)) - ) + .background(NoopPanelSurface(cornerRadius: 6, elevated: true, surfaceOpacity: 0.92)) .fixedSize() .allowsHitTesting(false) } diff --git a/Packages/StrandDesign/Sources/StrandDesign/Sparkline.swift b/Packages/StrandDesign/Sources/StrandDesign/Sparkline.swift index 8e33ca07e0..84d1498239 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/Sparkline.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/Sparkline.swift @@ -224,7 +224,7 @@ private func sampleHR() -> [Double] { } .padding(24) .frame(width: 380, height: 240) - .background(StrandPalette.surfaceRaised) + .background(NoopChromeSurface()) .preferredColorScheme(.dark) } #endif diff --git a/Packages/StrandDesign/Sources/StrandDesign/StatePill.swift b/Packages/StrandDesign/Sources/StrandDesign/StatePill.swift index 85fdc2cc91..c19e0ab8a2 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/StatePill.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/StatePill.swift @@ -149,7 +149,7 @@ public struct ConnectionDot: View { ConnectionDot(tone: .positive, pulsing: true) } .padding(12) - .background(StrandPalette.surfaceRaised, in: RoundedRectangle(cornerRadius: 12)) + .background(NoopPanelSurface(cornerRadius: 12)) .frame(width: 300) } .padding(28) diff --git a/Packages/StrandDesign/Sources/StrandDesign/Typography.swift b/Packages/StrandDesign/Sources/StrandDesign/Typography.swift index cfb94320f2..c84ed4ac15 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/Typography.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/Typography.swift @@ -1,4 +1,9 @@ import SwiftUI +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif // MARK: - Strand Typography (§9.2) // @@ -64,7 +69,21 @@ public enum StrandFont { /// (relativeTo `.caption2`), just smaller. Passing 11 returns exactly `.overline`. Lets a caller /// shrink an ALL-CAPS label to fit a small container without losing accessibility text-scaling. public static func overlineScaled(_ size: CGFloat) -> Font { - roundedSystem(size, weight: .semibold) + #if canImport(UIKit) + let base = UIFont.systemFont(ofSize: size, weight: .semibold) + let descriptor = base.fontDescriptor.withDesign(.rounded) ?? base.fontDescriptor + let rounded = UIFont(descriptor: descriptor, size: size) + return Font(UIFontMetrics(forTextStyle: .caption2).scaledFont(for: rounded)) + #elseif canImport(AppKit) + let base = NSFont.systemFont(ofSize: size, weight: .semibold) + guard let descriptor = base.fontDescriptor.withDesign(.rounded), + let rounded = NSFont(descriptor: descriptor, size: size) else { + return Font(base) + } + return Font(rounded) + #else + return roundedSystem(size, weight: .semibold) + #endif } /// Mono 13 (SF Mono) — raw / log views. Tabular by nature. diff --git a/Strand/App/RootView.swift b/Strand/App/RootView.swift index 004530cb9a..ceaa1db60e 100644 --- a/Strand/App/RootView.swift +++ b/Strand/App/RootView.swift @@ -539,7 +539,7 @@ private struct SidebarStatus: View { Spacer() } .padding(10) - .background(StrandPalette.surfaceRaised, in: RoundedRectangle(cornerRadius: 10)) + .background(NoopPanelSurface(cornerRadius: 10)) } // Shares LiveState.connectionStatus* with the Settings strap card so the two never disagree (#266): diff --git a/Strand/Liquid/LiveSessionView.swift b/Strand/Liquid/LiveSessionView.swift index 42a6d72377..45201114e3 100644 --- a/Strand/Liquid/LiveSessionView.swift +++ b/Strand/Liquid/LiveSessionView.swift @@ -375,13 +375,7 @@ struct LiveSessionSummarySheet: View { VStack(alignment: .leading, spacing: NoopMetrics.rowSpacing) { content() } .padding(16) .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 22, style: .continuous) - .fill(StrandPalette.surfaceRaised) - .overlay(RoundedRectangle(cornerRadius: 22, style: .continuous) - .strokeBorder(StrandPalette.hairline, lineWidth: 1)) - .opacity(cardOpacity) - ) + .background(NoopPanelSurface(cornerRadius: 22, surfaceOpacity: cardOpacity)) } private var cueLine: String { diff --git a/Strand/MenuBar/MenuBarContent.swift b/Strand/MenuBar/MenuBarContent.swift index d12e0ad4f3..21d589740d 100644 --- a/Strand/MenuBar/MenuBarContent.swift +++ b/Strand/MenuBar/MenuBarContent.swift @@ -137,7 +137,7 @@ public struct MenuBarContent: View { } .padding(16) .frame(width: 268) - .background(StrandPalette.surfaceOverlay) + .background(NoopChromeSurface()) .preferredColorScheme(AppearanceMode.resolve(appearanceRaw).colorScheme) } diff --git a/Strand/Onboarding/OnboardingWizard.swift b/Strand/Onboarding/OnboardingWizard.swift index 112e25918d..d25558060e 100644 --- a/Strand/Onboarding/OnboardingWizard.swift +++ b/Strand/Onboarding/OnboardingWizard.swift @@ -399,8 +399,7 @@ private struct ExpectationsStep: View { } .padding(14) .frame(maxWidth: 520, alignment: .leading) - .background(StrandPalette.surfaceRaised, in: RoundedRectangle(cornerRadius: 14)) - .overlay(RoundedRectangle(cornerRadius: 14).strokeBorder(StrandPalette.hairline)) + .background(NoopPanelSurface(cornerRadius: 14)) .opacity(shown ? 1 : 0) .offset(y: shown ? 0 : 8) .animation(StrandMotion.gentle.delay(Double(index) * 0.08), value: shown) @@ -443,8 +442,7 @@ private struct ExpectationsStep: View { } .padding(14) .frame(maxWidth: 520, alignment: .leading) - .background(StrandPalette.surfaceRaised, in: RoundedRectangle(cornerRadius: 14)) - .overlay(RoundedRectangle(cornerRadius: 14).strokeBorder(StrandPalette.hairline)) + .background(NoopPanelSurface(cornerRadius: 14)) } } @@ -1390,10 +1388,7 @@ private struct SecondaryButtonStyle: ButtonStyle { .foregroundStyle(StrandPalette.textPrimary) .padding(.vertical, 11) .padding(.horizontal, 18) - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(StrandPalette.surfaceOverlay) - ) + .background(NoopPanelSurface(cornerRadius: 12)) .overlay( RoundedRectangle(cornerRadius: 12, style: .continuous) .stroke(configuration.isPressed ? StrandPalette.hairlineStrong : StrandPalette.hairline, lineWidth: 1) diff --git a/Strand/Screens/AppleWatchSetupView.swift b/Strand/Screens/AppleWatchSetupView.swift index b964970346..25c94bee0a 100644 --- a/Strand/Screens/AppleWatchSetupView.swift +++ b/Strand/Screens/AppleWatchSetupView.swift @@ -37,7 +37,7 @@ struct AppleWatchSetupView: View { var body: some View { VStack(spacing: 0) { header - .background(StrandPalette.surfaceRaised) + .background(NoopChromeSurface()) Divider().overlay(StrandPalette.hairline) ScrollView { VStack(alignment: .leading, spacing: NoopMetrics.sectionGap) { diff --git a/Strand/Screens/CoachView.swift b/Strand/Screens/CoachView.swift index 4a36a95355..2348961c57 100644 --- a/Strand/Screens/CoachView.swift +++ b/Strand/Screens/CoachView.swift @@ -586,7 +586,7 @@ struct CoachView: View { .accessibilityLabel("Send") } .padding(8) - .background(StrandPalette.surfaceOverlay, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + .background(NoopPanelSurface(cornerRadius: 16)) .overlay(RoundedRectangle(cornerRadius: 16, style: .continuous) .strokeBorder(StrandPalette.hairline, lineWidth: 1)) } diff --git a/Strand/Screens/CompareView.swift b/Strand/Screens/CompareView.swift index 8292e872f8..5a3f6be54a 100644 --- a/Strand/Screens/CompareView.swift +++ b/Strand/Screens/CompareView.swift @@ -1111,15 +1111,7 @@ private struct MultiTooltip: View { } } .padding(10) - .background( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(StrandPalette.surfaceOverlay) - ) - .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .stroke(StrandPalette.hairline, lineWidth: 1) - ) - .shadow(color: .black.opacity(0.4), radius: 10, y: 6) + .background(NoopPanelSurface(cornerRadius: 10, elevated: true)) .frame(width: tooltipWidth, alignment: .leading) .position(x: clampedX, y: tooltipHeight / 2 + 8) .allowsHitTesting(false) diff --git a/Strand/Screens/CoupledView.swift b/Strand/Screens/CoupledView.swift index 27f6c77bbb..277f70f135 100644 --- a/Strand/Screens/CoupledView.swift +++ b/Strand/Screens/CoupledView.swift @@ -600,7 +600,7 @@ struct CoupledView: View { .foregroundStyle(StrandPalette.textTertiary) } .padding(14) - .background(RoundedRectangle(cornerRadius: 14).fill(StrandPalette.surfaceInset)) + .background(NoopPanelSurface(cornerRadius: 14)) .contentShape(Rectangle()) } .buttonStyle(.plain) @@ -683,13 +683,7 @@ struct CoupledView: View { content() .padding(16) .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 22, style: .continuous) - .fill(StrandPalette.surfaceRaised) - .overlay(RoundedRectangle(cornerRadius: 22, style: .continuous) - .strokeBorder(StrandPalette.hairline, lineWidth: 1)) - .opacity(cardOpacity) - ) + .background(NoopPanelSurface(cornerRadius: 22, surfaceOpacity: cardOpacity)) } private func clockString(_ ts: Int) -> String { diff --git a/Strand/Screens/DevicesView.swift b/Strand/Screens/DevicesView.swift index 8c4a45c12e..7396b422cd 100644 --- a/Strand/Screens/DevicesView.swift +++ b/Strand/Screens/DevicesView.swift @@ -121,7 +121,7 @@ private struct DevicesContent: View { Spacer(minLength: 0) } .padding(NoopMetrics.space3) - .background(StrandPalette.surfaceRaised, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + .background(NoopPanelSurface(cornerRadius: 18)) .overlay(RoundedRectangle(cornerRadius: 18, style: .continuous) .strokeBorder(StrandPalette.statusWarning.opacity(0.5), lineWidth: 1)) .accessibilityElement(children: .combine) @@ -1065,7 +1065,7 @@ private struct ExtendedBatteryProbeResultView: View { } .padding(20) .frame(minWidth: 340, minHeight: 260) - .background(StrandPalette.surfaceOverlay) + .background(NoopChromeSurface()) } } @@ -1134,7 +1134,7 @@ private struct BodyLocationProbeResultView: View { } .padding(20) .frame(minWidth: 340, minHeight: 260) - .background(StrandPalette.surfaceOverlay) + .background(NoopChromeSurface()) } } @@ -1253,7 +1253,7 @@ private struct FeatureFlagProbeResultView: View { } .padding(20) .frame(minWidth: 340, minHeight: 260) - .background(StrandPalette.surfaceOverlay) + .background(NoopChromeSurface()) } } @@ -1287,7 +1287,7 @@ private struct EcgWristSheet: View { } .padding(20) .frame(minWidth: 340, minHeight: 220) - .background(StrandPalette.surfaceOverlay) + .background(NoopChromeSurface()) } } @@ -1331,7 +1331,7 @@ private struct EcgProbeResultView: View { } .padding(20) .frame(minWidth: 340, minHeight: 260) - .background(StrandPalette.surfaceOverlay) + .background(NoopChromeSurface()) } } @@ -1402,7 +1402,7 @@ private struct DeviceConfigProbeResultView: View { } .padding(20) .frame(minWidth: 340, minHeight: 260) - .background(StrandPalette.surfaceOverlay) + .background(NoopChromeSurface()) } } diff --git a/Strand/Screens/EditableLayoutList.swift b/Strand/Screens/EditableLayoutList.swift index 5895ebdda4..a127f47513 100644 --- a/Strand/Screens/EditableLayoutList.swift +++ b/Strand/Screens/EditableLayoutList.swift @@ -164,7 +164,7 @@ private struct EditableLayoutRow: View { .accessibilityLabel(visibilityLabel) } .contentShape(Rectangle()) - .listRowBackground(StrandPalette.surfaceRaised) + .listRowBackground(NoopChromeSurface()) } private var visibilityLabel: String { diff --git a/Strand/Screens/HRVSnapshotView.swift b/Strand/Screens/HRVSnapshotView.swift index fde15e2043..6e54535035 100644 --- a/Strand/Screens/HRVSnapshotView.swift +++ b/Strand/Screens/HRVSnapshotView.swift @@ -333,7 +333,7 @@ struct HRVSnapshotView: View { } .frame(maxWidth: .infinity, alignment: .leading) .padding(12) - .background(StrandPalette.surfaceInset, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .background(NoopPanelSurface(tint: accent, cornerRadius: 10)) } // MARK: - Methodology diff --git a/Strand/Screens/HydrationView.swift b/Strand/Screens/HydrationView.swift index 4528d98475..d5f865ec10 100644 --- a/Strand/Screens/HydrationView.swift +++ b/Strand/Screens/HydrationView.swift @@ -132,13 +132,7 @@ struct HydrationView: View { content() .padding(padding) .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 22, style: .continuous) - .fill(StrandPalette.surfaceRaised) - .overlay(RoundedRectangle(cornerRadius: 22, style: .continuous) - .strokeBorder(StrandPalette.hairline, lineWidth: 1)) - .opacity(cardOpacity) - ) + .background(NoopPanelSurface(cornerRadius: 22, surfaceOpacity: cardOpacity)) } // MARK: - Quick log (Sip / Cup / Bottle, secondary style) diff --git a/Strand/Screens/InsightsHubView.swift b/Strand/Screens/InsightsHubView.swift index bbae207c5b..9bfca37e78 100644 --- a/Strand/Screens/InsightsHubView.swift +++ b/Strand/Screens/InsightsHubView.swift @@ -389,7 +389,7 @@ private struct DoseResponseCardView: View { } .padding(NoopMetrics.space3) .frame(maxWidth: .infinity, alignment: .leading) - .background(StrandPalette.surfaceInset, in: RoundedRectangle(cornerRadius: 8)) + .background(NoopPanelSurface(cornerRadius: 8)) } private func signed(_ v: Double, suffix: String) -> String { diff --git a/Strand/Screens/InsightsView.swift b/Strand/Screens/InsightsView.swift index deda065bf9..af2c34b2d6 100644 --- a/Strand/Screens/InsightsView.swift +++ b/Strand/Screens/InsightsView.swift @@ -707,8 +707,7 @@ struct InsightsView: View { } .padding(NoopMetrics.space3) .frame(maxWidth: .infinity, minHeight: 82, alignment: .topLeading) - .background(StrandPalette.surfaceInset, in: RoundedRectangle(cornerRadius: 8)) - .overlay(RoundedRectangle(cornerRadius: 8).stroke(StrandPalette.hairline, lineWidth: 1)) + .background(NoopPanelSurface(cornerRadius: 8)) } private func experimentMeasure(_ label: LocalizedStringKey, @@ -733,8 +732,7 @@ struct InsightsView: View { } .padding(NoopMetrics.space3) .frame(maxWidth: .infinity, minHeight: 92, alignment: .topLeading) - .background(StrandPalette.surfaceInset, in: RoundedRectangle(cornerRadius: 8)) - .overlay(RoundedRectangle(cornerRadius: 8).stroke(StrandPalette.hairline, lineWidth: 1)) + .background(NoopPanelSurface(tint: tint, cornerRadius: 8)) } /// Behaviours the user actually has data for: distinct logged journal questions diff --git a/Strand/Screens/LiveView.swift b/Strand/Screens/LiveView.swift index ddf256cf6b..ac63dba928 100644 --- a/Strand/Screens/LiveView.swift +++ b/Strand/Screens/LiveView.swift @@ -165,13 +165,7 @@ struct LiveView: View { content() .padding(16) .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 22, style: .continuous) - .fill(StrandPalette.surfaceRaised) - .overlay(RoundedRectangle(cornerRadius: 22, style: .continuous) - .strokeBorder(StrandPalette.hairline, lineWidth: 1)) - .opacity(cardOpacity) - ) + .background(NoopPanelSurface(cornerRadius: 22, surfaceOpacity: cardOpacity)) } // MARK: - Console header @@ -427,7 +421,7 @@ struct LiveView: View { Spacer(minLength: 0) } .padding(NoopMetrics.space3) - .background(StrandPalette.surfaceRaised, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + .background(NoopPanelSurface(cornerRadius: 18)) .overlay(RoundedRectangle(cornerRadius: 18, style: .continuous) .strokeBorder(StrandPalette.statusWarning.opacity(0.5), lineWidth: 1)) .accessibilityElement(children: .combine) @@ -449,7 +443,7 @@ struct LiveView: View { Spacer(minLength: 0) } .padding(NoopMetrics.space3) - .background(StrandPalette.surfaceRaised, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + .background(NoopPanelSurface(cornerRadius: 18)) .overlay(RoundedRectangle(cornerRadius: 18, style: .continuous) .strokeBorder(StrandPalette.statusWarning.opacity(0.5), lineWidth: 1)) .accessibilityElement(children: .combine) @@ -486,7 +480,7 @@ struct LiveView: View { Spacer(minLength: 0) } .padding(NoopMetrics.space3) - .background(StrandPalette.surfaceRaised, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + .background(NoopPanelSurface(tint: StrandPalette.accent, cornerRadius: 18)) .overlay(RoundedRectangle(cornerRadius: 18, style: .continuous) .strokeBorder(StrandPalette.accent.opacity(0.4), lineWidth: 1)) .accessibilityElement(children: .combine) @@ -598,9 +592,7 @@ struct LiveView: View { .accessibilityHidden(true) } .padding(NoopMetrics.space3) - .background(StrandPalette.surfaceRaised, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) - .overlay(RoundedRectangle(cornerRadius: 18, style: .continuous) - .strokeBorder(StrandPalette.hairline, lineWidth: 1)) + .background(NoopPanelSurface(cornerRadius: 18)) .contentShape(Rectangle()) } .buttonStyle(LiquidPressStyle()) @@ -942,9 +934,7 @@ private struct LivePhysiology: View { } .frame(maxWidth: .infinity, alignment: .leading) .padding(NoopMetrics.rowSpacing) - .background(StrandPalette.surfaceInset, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) - .overlay(RoundedRectangle(cornerRadius: 14, style: .continuous) - .strokeBorder(StrandPalette.hairline, lineWidth: 1)) + .background(NoopPanelSurface(cornerRadius: 14)) .accessibilityElement(children: .combine) .accessibilityLabel("\(label): \(value)") } @@ -1172,13 +1162,7 @@ private struct LiveLogCard: View { } .padding(16) .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 22, style: .continuous) - .fill(StrandPalette.surfaceRaised) - .overlay(RoundedRectangle(cornerRadius: 22, style: .continuous) - .strokeBorder(StrandPalette.hairline, lineWidth: 1)) - .opacity(cardOpacity) - ) + .background(NoopPanelSurface(cornerRadius: 22, surfaceOpacity: cardOpacity)) } // MARK: - Strap-log export (issue #17 — let macOS users share the log for bug reports) @@ -1259,13 +1243,7 @@ private struct SignalTrustTile: View { .padding(14) .frame(minHeight: 112, alignment: .top) .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 20, style: .continuous) - .fill(StrandPalette.surfaceRaised) - .overlay(RoundedRectangle(cornerRadius: 20, style: .continuous) - .strokeBorder(StrandPalette.hairline, lineWidth: 1)) - .opacity(cardOpacity) - ) + .background(NoopPanelSurface(cornerRadius: 20, surfaceOpacity: cardOpacity)) .accessibilityElement(children: .combine) .accessibilityLabel("\(tile.title): \(tile.value). \(tile.detail)") } diff --git a/Strand/Screens/ManualWorkoutSheet.swift b/Strand/Screens/ManualWorkoutSheet.swift index 747bc27a35..8a2bce7363 100644 --- a/Strand/Screens/ManualWorkoutSheet.swift +++ b/Strand/Screens/ManualWorkoutSheet.swift @@ -121,7 +121,7 @@ struct ManualWorkoutSheet: View { // "recents vanish" was really the panel being clipped along with its off-screen anchor. .noopSheetPresentation(largeFirst: true) #endif - .background(StrandPalette.surfaceOverlay) + .background(NoopChromeSurface()) // Lets the user dismiss the decimal pad (which has no return key) and reach Cancel/Add. No-op on macOS. .keyboardDoneToolbar($focusedField) } @@ -487,7 +487,7 @@ struct StartWorkoutSheet: View { .frame(maxWidth: .infinity) .noopSheetPresentation(largeFirst: false) #endif - .background(StrandPalette.surfaceOverlay) + .background(NoopChromeSurface()) } /// One tappable sport row — shared by the #297 Recent block and the full catalogue list. diff --git a/Strand/Screens/MetricExplorerView.swift b/Strand/Screens/MetricExplorerView.swift index d585f6dc2e..fba33f607b 100644 --- a/Strand/Screens/MetricExplorerView.swift +++ b/Strand/Screens/MetricExplorerView.swift @@ -858,8 +858,11 @@ struct MetricDetailView: View { } } .padding(NoopMetrics.cardPadding) - .background(ScenicHeroBackground(domain: domain)) - .clipShape(RoundedRectangle(cornerRadius: NoopMetrics.cardRadius, style: .continuous)) + .background { + NoopPanelSurface(tint: domain.color, + cornerRadius: NoopMetrics.cardRadius, + elevated: true) + } // The hero shows the LATEST available point (range-independent), so the vessel fills once on // appear (0 → its fraction) and settles — like TodayView's rings. .onAppear { diff --git a/Strand/Screens/NotificationSettingsView.swift b/Strand/Screens/NotificationSettingsView.swift index 7db2d32a1c..7ce43cdb72 100644 --- a/Strand/Screens/NotificationSettingsView.swift +++ b/Strand/Screens/NotificationSettingsView.swift @@ -92,8 +92,7 @@ struct NotificationSettingsView: View { } .padding(NoopMetrics.space3) .frame(maxWidth: .infinity, alignment: .leading) - .background(StrandPalette.surfaceInset, - in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .background(NoopPanelSurface(tint: StrandPalette.accent, cornerRadius: 10)) .overlay(RoundedRectangle(cornerRadius: 10, style: .continuous) .stroke(StrandPalette.accent.opacity(0.22), lineWidth: 1)) } diff --git a/Strand/Screens/ScoringGuideView.swift b/Strand/Screens/ScoringGuideView.swift index 03b3356eb2..c30843980e 100644 --- a/Strand/Screens/ScoringGuideView.swift +++ b/Strand/Screens/ScoringGuideView.swift @@ -82,7 +82,7 @@ struct ScoringGuideView: View { header // Design Reset: a FLAT opaque WHOOP-grey title surface — no scenic hero, no bloom, no // domain tint. The header reads as a clean raised card edge, matching the Today look. - .background(StrandPalette.surfaceRaised) + .background(NoopChromeSurface()) Divider().overlay(StrandPalette.hairline) ScrollViewReader { proxy in ScrollView { diff --git a/Strand/Screens/SleepView.swift b/Strand/Screens/SleepView.swift index a1d85e8455..302bd1e47b 100644 --- a/Strand/Screens/SleepView.swift +++ b/Strand/Screens/SleepView.swift @@ -930,7 +930,7 @@ struct SleepView: View { } .padding(NoopMetrics.cardInnerPadding) .frame(width: 260) - .background(StrandPalette.surfaceOverlay) + .background(NoopPanelSurface(cornerRadius: NoopVisualStyle.compactRadius, elevated: true)) .accessibilityElement(children: .combine) } @@ -2420,7 +2420,7 @@ struct SleepView: View { .font(StrandFont.subhead) .foregroundStyle(StrandPalette.textTertiary) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) - .background(StrandPalette.surfaceInset, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .background(NoopPanelSurface(tint: StrandPalette.restColor, cornerRadius: 12)) } /// Hero chart slot for a NAVIGATED session with no decodable stages — honest about the @@ -2430,7 +2430,7 @@ struct SleepView: View { .font(StrandFont.footnote) .foregroundStyle(StrandPalette.textTertiary) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) - .background(StrandPalette.surfaceInset, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .background(NoopPanelSurface(tint: StrandPalette.restColor, cornerRadius: 12)) } // MARK: - Formatting helpers @@ -3125,7 +3125,7 @@ private struct SleepTimeEditor: View { } .padding(NoopMetrics.screenPadding) .frame(minWidth: 360) - .background(StrandPalette.surfaceOverlay) + .background(NoopChromeSurface()) // #940 guard 1: a time-only roll that lands the bed in the future, or at/after the night's // wake, almost always means the PREVIOUS evening (23:00 "yesterday", not tonight). Snap the // date back a day so the picker visibly shows the night the user meant. Pure rule + tests: diff --git a/Strand/Screens/TodayView.swift b/Strand/Screens/TodayView.swift index 1dc816b278..67d006e416 100644 --- a/Strand/Screens/TodayView.swift +++ b/Strand/Screens/TodayView.swift @@ -1939,7 +1939,7 @@ struct TodayView: View { .foregroundStyle(StrandPalette.textTertiary) } .padding(14) - .background(RoundedRectangle(cornerRadius: 14).fill(StrandPalette.surfaceInset)) + .background(NoopPanelSurface(cornerRadius: 14)) .contentShape(Rectangle()) } .buttonStyle(.plain) diff --git a/Strand/Screens/TrendsReportView.swift b/Strand/Screens/TrendsReportView.swift index 0979d7a001..673d4bef1d 100644 --- a/Strand/Screens/TrendsReportView.swift +++ b/Strand/Screens/TrendsReportView.swift @@ -214,11 +214,11 @@ struct TrendsReportPage: View { // MARK: Header private var header: some View { - // WHOOP-flat header: a plain raised surface, no scenic hero gradient or starfield. Fill - // contrast carries the edge; the blue NOOP wordmark is the only accent. + // Report chrome uses the same shared panel surface as the in-app cards. ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: NoopMetrics.cardRadius, style: .continuous) - .fill(StrandPalette.surfaceRaised) + NoopPanelSurface(tint: StrandPalette.accent, + cornerRadius: NoopMetrics.cardRadius, + elevated: true) VStack(alignment: .leading, spacing: NoopMetrics.space1) { HStack(alignment: .firstTextBaseline) { BrandMark(size: 22) diff --git a/Strand/Screens/TrendsView.swift b/Strand/Screens/TrendsView.swift index 80e2eab597..bb6390bc2b 100644 --- a/Strand/Screens/TrendsView.swift +++ b/Strand/Screens/TrendsView.swift @@ -760,7 +760,7 @@ struct TrendsView: View { .font(StrandFont.subhead) .foregroundStyle(StrandPalette.textTertiary) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) - .background(StrandPalette.surfaceInset, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .background(NoopPanelSurface(cornerRadius: 12)) } } diff --git a/Strand/Screens/UpdatesInboxView.swift b/Strand/Screens/UpdatesInboxView.swift index e7ab96a531..db88b45df5 100644 --- a/Strand/Screens/UpdatesInboxView.swift +++ b/Strand/Screens/UpdatesInboxView.swift @@ -21,7 +21,7 @@ struct UpdatesInboxView: View { var body: some View { VStack(spacing: 0) { header - .background(StrandPalette.surfaceRaised) + .background(NoopChromeSurface()) Divider().overlay(StrandPalette.hairline) content if !updateStore.items.isEmpty { diff --git a/Strand/Screens/WeeklyDigestView.swift b/Strand/Screens/WeeklyDigestView.swift index c721d45f94..941c884db7 100644 --- a/Strand/Screens/WeeklyDigestView.swift +++ b/Strand/Screens/WeeklyDigestView.swift @@ -134,7 +134,7 @@ struct WeeklyDigestContent: View { /// The three headline 0–100 scores shown as domain summaries. private static let scoreOrder: [WeeklyMetric] = [.charge, .effort, .rest] - /// The Bevel colour world for each weekly metric — drives the summary card tint, + /// The shared colour world for each weekly metric — drives the summary card tint, /// the gauge stroke and the secondary-signal accents. private func domain(for m: WeeklyMetric) -> DomainTheme { switch m { @@ -163,8 +163,9 @@ struct WeeklyDigestContent: View { private var header: some View { ZStack(alignment: .leading) { - ScenicHeroBackground(domain: .charge, starCount: 26) - .clipShape(RoundedRectangle(cornerRadius: NoopMetrics.cardRadius, style: .continuous)) + NoopPanelSurface(tint: DomainTheme.charge.color, + cornerRadius: NoopMetrics.cardRadius, + elevated: true) HStack(alignment: .firstTextBaseline) { VStack(alignment: .leading, spacing: 2) { Text("Week in review").strandOverline() diff --git a/StrandiOS/App/RootTabView.swift b/StrandiOS/App/RootTabView.swift index aa8fdc735e..3033505183 100644 --- a/StrandiOS/App/RootTabView.swift +++ b/StrandiOS/App/RootTabView.swift @@ -613,7 +613,7 @@ private struct QuickActionSheet: View { } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .background( - StrandPalette.surfaceOverlay + NoopChromeSurface() .overlay(alignment: .top) { // Gold hairline top edge per the bottom-sheet spec. Rectangle() @@ -643,8 +643,7 @@ private struct QuickActionSheet: View { } .padding(.vertical, 10) .padding(.horizontal, 12) - .background(RoundedRectangle(cornerRadius: 14, style: .continuous).fill(StrandPalette.surfaceRaised)) - .overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(StrandPalette.hairline, lineWidth: 1)) + .background(NoopPanelSurface(cornerRadius: 14)) .contentShape(Rectangle()) } .buttonStyle(.plain) @@ -714,7 +713,7 @@ private struct FloatingTabBar: View { } .foregroundStyle(active ? StrandPalette.textPrimary : StrandPalette.textSecondary) .frame(maxWidth: .infinity) - .padding(.vertical, 6) + .padding(.vertical, 3) .background(active ? NoopVisualStyle.inset : .clear, in: Capsule()) .contentShape(Rectangle()) } From c0f454412bc7f6e7dc07da20b3bbdb54b9dfd015 Mon Sep 17 00:00:00 2001 From: liaolo <99437277+liaolo@users.noreply.github.com> Date: Tue, 4 Aug 2026 08:25:03 +0300 Subject: [PATCH 03/13] Develop experimental NOOP UI --- .../Sources/StrandDesign/Components.swift | 71 +++-- Strand/Liquid/LiquidTodayView.swift | 170 ++++++----- Strand/Screens/DevicesView.swift | 69 +++++ Strand/Screens/SleepView.swift | 147 +++++++++- Strand/Screens/TodayView.swift | 52 +--- Strand/Screens/TrendsView.swift | 21 +- Strand/Screens/WeeklyDigestView.swift | 8 +- StrandiOS/App/RootTabView.swift | 165 +++-------- docs/UI_CUSTOMIZATION_LEDGER.md | 271 ++++++++++++++++++ 9 files changed, 677 insertions(+), 297 deletions(-) create mode 100644 docs/UI_CUSTOMIZATION_LEDGER.md diff --git a/Packages/StrandDesign/Sources/StrandDesign/Components.swift b/Packages/StrandDesign/Sources/StrandDesign/Components.swift index bbab8d12db..0bcc7f1af8 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/Components.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/Components.swift @@ -377,26 +377,32 @@ public struct SegmentedPillControl: View { /// option exists) but renders extra-dim and ignores taps; VoiceOver announces it dimmed. /// Defaults to everything enabled; ADDED additively, no existing call site touched. let isEnabled: (T) -> Bool + let fillsAvailableWidth: Bool @Binding var selection: T - @Environment(\.colorScheme) private var scheme @Environment(\.dynamicTypeSize) private var dynamicTypeSize public init(_ items: [T], selection: Binding, adaptsToAvailableWidth: Bool = false, + fillsAvailableWidth: Bool = false, label: @escaping (T) -> String) { self.init(items, selection: selection, adaptsToAvailableWidth: adaptsToAvailableWidth, + fillsAvailableWidth: fillsAvailableWidth, isEnabled: { _ in true }, label: label) } public init(_ items: [T], selection: Binding, adaptsToAvailableWidth: Bool = false, + fillsAvailableWidth: Bool = false, isEnabled: @escaping (T) -> Bool, label: @escaping (T) -> String) { self.items = items self._selection = selection self.adaptsToAvailableWidth = adaptsToAvailableWidth + self.fillsAvailableWidth = fillsAvailableWidth self.isEnabled = isEnabled self.label = label } @ViewBuilder public var body: some View { - if adaptsToAvailableWidth { + if fillsAvailableWidth { + track(equalWidth: true) + } else if adaptsToAvailableWidth { if dynamicTypeSize > .large { ScrollView(.horizontal, showsIndicators: false) { track(equalWidth: false) @@ -426,11 +432,10 @@ public struct SegmentedPillControl: View { Text(label(item)) .font(StrandFont.captionNumber) .lineLimit(equalWidth ? 1 : nil) - // Active segment is SELECTION CHROME, so it follows the accent: on dark a - // gold-gradient pill with gold-deep ink; on light a flat blue accent pill with - // white ink (so the light theme's selection matches its blue chrome, not gold). + // Range selection stays deliberately neutral so the control works above charts + // from every metric colour world without borrowing their green/blue/amber tint. // Disabled segments drop to a fainter tertiary so the lock reads at a glance. - .foregroundStyle(sel ? (scheme == .light ? Color.white : StrandPalette.textPrimary) + .foregroundStyle(sel ? StrandPalette.textPrimary : StrandPalette.textTertiary.opacity(enabled ? 1 : 0.35)) // Fill the segment height so the selected pill has EQUAL margins to the track // on every side. (The old compact pill inside a taller 44pt touch frame left @@ -439,16 +444,27 @@ public struct SegmentedPillControl: View { maxWidth: equalWidth ? .infinity : nil, maxHeight: .infinity) .padding(.horizontal, equalWidth ? NoopMetrics.space1 : 9) - .background( - // WHOOP selection chrome: a flat LIGHTER-grey pill on dark (white ink), a flat - // blue accent pill on light — no gold, no gradient. - Capsule(style: .continuous) - .fill(sel ? (scheme == .light - ? AnyShapeStyle(StrandPalette.accent) - : AnyShapeStyle(Color(hex: "#363B41"))) - : AnyShapeStyle(Color.clear)) - ) - .contentShape(Capsule(style: .continuous)) + .background { + if sel { + let selectedShape = RoundedRectangle(cornerRadius: 10, style: .continuous) + selectedShape + .fill( + LinearGradient( + colors: [NoopVisualStyle.surfaceTop, NoopVisualStyle.surface], + startPoint: .top, + endPoint: .bottom + ) + ) + .overlay( + selectedShape.strokeBorder( + NoopVisualStyle.borderHighlight.opacity(0.62), + lineWidth: 0.75 + ) + ) + .shadow(color: .black.opacity(0.20), radius: 4, x: 0, y: 2) + } + } + .contentShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) } .buttonStyle(.plain) .frame(maxWidth: equalWidth ? .infinity : nil) @@ -460,8 +476,27 @@ public struct SegmentedPillControl: View { } .padding(3) .frame(maxWidth: equalWidth ? .infinity : nil) - .background(StrandPalette.surfaceInset, in: Capsule(style: .continuous)) - .overlay(Capsule(style: .continuous).strokeBorder(StrandPalette.hairline, lineWidth: 1)) + .background { + let trackShape = RoundedRectangle(cornerRadius: 13, style: .continuous) + trackShape + .fill( + LinearGradient( + colors: [NoopVisualStyle.inset, NoopVisualStyle.canvas.opacity(0.78)], + startPoint: .top, + endPoint: .bottom + ) + ) + .overlay( + trackShape.strokeBorder( + LinearGradient( + colors: [NoopVisualStyle.borderHighlight.opacity(0.48), NoopVisualStyle.border], + startPoint: .top, + endPoint: .bottom + ), + lineWidth: 0.8 + ) + ) + } } } diff --git a/Strand/Liquid/LiquidTodayView.swift b/Strand/Liquid/LiquidTodayView.swift index 4fe148a187..d21c7017ca 100644 --- a/Strand/Liquid/LiquidTodayView.swift +++ b/Strand/Liquid/LiquidTodayView.swift @@ -432,17 +432,21 @@ struct LiquidTodayView: View { HStack(spacing: 8) { // Profile pic (the one set in Settings) → opens Settings, matching the classic Today. Button { showSettings = true } label: { - ProfileAvatarView(imageData: profile.avatarImageData, size: 34) - .frame(width: 34, height: 34) + Color.clear.frame(width: 34, height: 34) } - .buttonStyle(LiquidPressStyle()) + .nativeLiquidGlassHeaderButton() + .overlay { + GeometryReader { proxy in + let diameter = min(proxy.size.width, proxy.size.height) + ProfileAvatarView(imageData: profile.avatarImageData, size: diameter) + .frame(width: diameter, height: diameter) + .position(x: proxy.size.width / 2, y: proxy.size.height / 2) + } + .allowsHitTesting(false) + } + .nativeLiquidGlassPhotoFinish() .accessibilityLabel("Profile and settings") LiquidAddButton() - // #245: the Liquid header shipped with no sync indication at all (B1) — add it next to - // the battery button, matching the issue's own ask ("near the battery percentage") and - // the layout Android already uses (its SyncStatusChip sits in the same row as the - // battery ring). - LiquidSyncChip() LiquidBatteryButton() // One entry point for section order/visibility and both nested card editors. Button { customizationDestination = .today } label: { @@ -450,10 +454,8 @@ struct LiquidTodayView: View { .font(.system(size: 14, weight: .bold)) .foregroundStyle(.white) .frame(width: 34, height: 34) - .background(Circle().fill(NoopVisualStyle.surfaceTop.opacity(0.94))) - .overlay(Circle().strokeBorder(NoopVisualStyle.borderHighlight.opacity(0.55), lineWidth: 0.8)) } - .buttonStyle(LiquidPressStyle()) + .nativeLiquidGlassHeaderButton() .accessibilityLabel("Customize Today") } } @@ -849,7 +851,13 @@ struct LiquidTodayView: View { // #430 parity: the grid honours the Key-Metrics editor (selection + order, all ten metrics) // instead of a hard-coded six — the bespoke Sleep-hours ktile gives way to the shared REST // score tile, aligning the liquid grid with the classic macOS grid and Android. - LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 3), spacing: 8) { + LazyVGrid( + columns: Array( + repeating: GridItem(.flexible(), spacing: NoopMetrics.gap), + count: 2 + ), + spacing: NoopMetrics.gap + ) { ForEach(enabledKeyMetrics) { metric in ktileFor(metric, hrv: hrv, rhr: rhr) } @@ -874,45 +882,71 @@ struct LiquidTodayView: View { // hero are the same number, so a carry that reached only one of them would put two answers for // Charge on one screen. (#543: one prior row feeds every recovery-derived read-out.) Strain below // stays raw, matching the Effort hero, which correctly does not carry. - ktile(String(localized: "Recovery"), intText(chargeDisplay.pct), "%", StrandPalette.chargeColor, frac(chargeDisplay.pct), key: "recovery") + ktile(String(localized: "Recovery"), icon: keyMetricIcon(metric), intText(chargeDisplay.pct), "%", StrandPalette.chargeColor, frac(chargeDisplay.pct), key: "recovery") case .effort: - ktile(String(localized: "Strain"), intText(displayDay?.strain), "%", StrandPalette.effortColor, frac(displayDay?.strain), key: "strain") + ktile(String(localized: "Strain"), icon: keyMetricIcon(metric), intText(displayDay?.strain), "%", StrandPalette.effortColor, frac(displayDay?.strain), key: "strain") case .rest: - ktile(String(localized: "Rest"), intText(restScore), "%", StrandPalette.restColor, frac(restScore), key: "sleep_performance") + ktile(String(localized: "Rest"), icon: keyMetricIcon(metric), intText(restScore), "%", StrandPalette.restColor, frac(restScore), key: "sleep_performance") case .hrv: - ktile("HRV", intText(hrv), "ms", StrandPalette.metricCyan, fracOver(hrv, 120), key: "hrv") + ktile("HRV", icon: keyMetricIcon(metric), intText(hrv), "ms", StrandPalette.metricCyan, fracOver(hrv, 120), key: "hrv") case .restingHr: - ktile(String(localized: "Rest HR"), intText(rhr), "bpm", StrandPalette.metricRose, fracOver(rhr, 100), key: "rhr") + ktile(String(localized: "Rest HR"), icon: keyMetricIcon(metric), intText(rhr), "bpm", StrandPalette.metricRose, fracOver(rhr, 100), key: "rhr") case .bloodOxygen: let spo2 = displayDay?.spo2Pct ?? vitalsDay?.spo2Pct - ktile(String(localized: "Blood Oxygen"), intText(spo2), "%", StrandPalette.metricCyan, fracOver(spo2, 100), key: "spo2") + ktile(String(localized: "Blood Oxygen"), icon: keyMetricIcon(metric), intText(spo2), "%", StrandPalette.metricCyan, fracOver(spo2, 100), key: "spo2") case .respiratory: let resp = displayDay?.respRateBpm ?? vitalsDay?.respRateBpm - ktile(String(localized: "Respiratory"), resp.map { String(format: "%.1f", $0) } ?? "—", "rpm", StrandPalette.accent, fracOver(resp, 24), key: "resp_rate") + ktile(String(localized: "Respiratory"), icon: keyMetricIcon(metric), resp.map { String(format: "%.1f", $0) } ?? "—", "rpm", StrandPalette.accent, fracOver(resp, 24), key: "resp_rate") case .steps: - ktile(String(localized: "Steps"), stepsText, "", StrandPalette.chargeColor, + ktile(String(localized: "Steps"), icon: keyMetricIcon(metric), stepsText, "", StrandPalette.chargeColor, fracOver(stepCount, 10000), key: stepsDetailKey, detailMetric: stepsDetailMetric) case .weight: - ktile(String(localized: "Weight"), "—", "", StrandPalette.metricAmber, nil, key: "weight") + ktile(String(localized: "Weight"), icon: keyMetricIcon(metric), "—", "", StrandPalette.metricAmber, nil, key: "weight") case .calories: // #616: imported-first value (imported ?: activeKcalEst) + route the tap to the matching // detail source, so the number, its sparkline and the chart it opens all agree. - ktile(String(localized: "Calories"), intText(caloriesCount), "kcal", StrandPalette.metricAmber, + ktile(String(localized: "Calories"), icon: keyMetricIcon(metric), intText(caloriesCount), "kcal", StrandPalette.metricAmber, fracOver(caloriesCount, 800), key: "energy_kcal", detailMetric: caloriesDetailMetric) } } - private func ktile(_ label: String, _ value: String, _ unit: String, _ tint: Color, _ frac: Double?, + private func keyMetricIcon(_ metric: KeyMetric) -> String { + switch metric { + case .charge: return "heart.fill" + case .effort: return "bolt.fill" + case .rest: return "moon.stars.fill" + case .hrv: return "waveform.path.ecg" + case .restingHr: return "heart.circle.fill" + case .bloodOxygen: return "drop.fill" + case .respiratory: return "lungs.fill" + case .steps: return "figure.walk" + case .weight: return "scalemass.fill" + case .calories: return "flame.fill" + } + } + + private func ktile(_ label: String, icon: String, _ value: String, _ unit: String, _ tint: Color, _ frac: Double?, key: String? = nil, detailMetric: MetricDescriptor? = nil) -> some View { - let tile = VStack(alignment: .leading, spacing: 6) { - Text(label.uppercased()).font(StrandFont.overlineScaled(9)).tracking(1.2) - .foregroundStyle(StrandPalette.textTertiary) - (Text(value).font(StrandFont.number(17)) - + Text(unit.isEmpty ? "" : " \(unit)").font(StrandFont.caption)) + let tile = VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 6) { + Image(systemName: icon) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(tint.opacity(0.72)) + .frame(width: 14) + Text(label.uppercased()) + .font(StrandFont.overlineScaled(10)) + .tracking(1.0) + .foregroundStyle(StrandPalette.textTertiary) + .lineLimit(1) + .minimumScaleFactor(0.82) + } + (Text(value).font(StrandFont.number(24)) + + Text(unit.isEmpty ? "" : (unit == "%" ? unit : " \(unit)")) + .font(StrandFont.number(24))) .foregroundStyle(StrandPalette.textPrimary) .lineLimit(1) - .minimumScaleFactor(0.7) - LiquidTube(frac: frac ?? 0, tint: tint, height: 8, animated: false) + .minimumScaleFactor(0.75) + LiquidTube(frac: frac ?? 0, tint: tint, height: 9, animated: false) // #430 parity: DETAILED tiles grow the trend graph under the bar, tinted to the metric and // windowed to the editor's 2-day / 1-week / 2-week choice (the Android twin). A metric with no // windowed series keeps a clear placeholder of the same height so every tile in a detailed row @@ -930,10 +964,11 @@ struct LiquidTodayView: View { } } } - .padding(.horizontal, 12) - .padding(.vertical, 11) + .padding(.horizontal, 14) + .padding(.vertical, 14) .frame(maxWidth: .infinity, alignment: .leading) - .background(NoopPanelSurface(tint: tint, cornerRadius: 16, surfaceOpacity: cardOpacity)) + .frame(minHeight: keyMetricsDetailed ? 154 : 116, alignment: .topLeading) + .background(NoopPanelSurface(tint: tint, cornerRadius: 18, surfaceOpacity: cardOpacity)) // #430 parity: tap -> the metric's trend detail (the same Explore dossier its MetricRow pushes, // closure-based NavigationLink per #38). A metric with no catalog entry stays inert. return Group { @@ -1583,9 +1618,8 @@ private struct LiquidAddButton: View { .font(.system(size: 16, weight: .bold)) .foregroundStyle(.white) .frame(width: 34, height: 34) - .background(Circle().fill(.white.opacity(0.16))) } - .buttonStyle(LiquidPressStyle()) + .nativeLiquidGlassHeaderButton() .accessibilityLabel("Quick actions") } } @@ -1806,8 +1840,6 @@ private struct LiquidBatteryButton: View { var body: some View { Button { router.openDevices() } label: { ZStack { - Circle().fill(Color(.sRGB, red: 10 / 255, green: 11 / 255, blue: 16 / 255, opacity: 0.5)) - Circle().strokeBorder(.white.opacity(0.15), lineWidth: 1) switch display { case .charge(let pct, let charging): Circle() @@ -1840,7 +1872,7 @@ private struct LiquidBatteryButton: View { } .frame(width: 34, height: 34) } - .buttonStyle(LiquidPressStyle()) + .nativeLiquidGlassHeaderButton() .accessibilityLabel(batteryAccessibility) } /// Never "Strap battery" alone for a no-reading state — that was indistinguishable from a real one. @@ -1864,46 +1896,34 @@ private struct LiquidBatteryButton: View { } } -/// #245: the always-visible sync-status chip for the Liquid header, next to `LiquidBatteryButton`. -/// -/// B1 (docs/bugs/2026-07-15-strap-battery-backfill-observability.md): the v8 Liquid redesign shipped no -/// backfill indication AT ALL in the header, so a multi-hour history recovery was completely invisible — -/// the wearer could not tell a working strap mid-drain from a dead one, only `LiquidSyncStatusRow` below -/// (buried in the collapsible Data Sources card) said anything, and only once expanded. This closes that -/// gap using the SAME state (`SyncChipState`, shared with the classic Today's `SyncStatusChip`) so the two -/// headers can't disagree on when syncing is happening — restyled to this header's own dark-hero icon -/// idiom (`.white.opacity(0.16)` fill, white content, matching `LiquidAddButton`) rather than reusing -/// `SyncStatusChip`'s light-surface chrome, which would read poorly over the photo/gradient hero. -private struct LiquidSyncChip: View { - @EnvironmentObject var live: LiveState - - var body: some View { - switch SyncChipState.resolve(live: live) { - case .syncing(let chunks): - pill(system: "arrow.triangle.2.circlepath", text: "\(chunks)", - a11y: String(localized: "Syncing strap history, \(chunks) chunks")) - case .synced(let agoText): - pill(system: "checkmark", text: agoText, - a11y: String(localized: "Strap history synced \(agoText) ago")) - case .experimentalLive: - pill(system: "checkmark", text: String(localized: "live"), - a11y: String(localized: "Connected; strap history sync is experimental on this strap")) - case .hidden: - EmptyView() +private extension View { + /// The edge-to-edge photo is overlaid after the native button style so it can fill the face. Finish + /// the composed control with interactive system glass as the topmost visual layer; otherwise the + /// opaque photo would conceal the button style's refraction and highlight. + @ViewBuilder + func nativeLiquidGlassPhotoFinish() -> some View { + if #available(iOS 26.0, *) { + self.glassEffect(.regular.interactive(), in: Circle()) + } else { + self } } - private func pill(system: String, text: String, a11y: String) -> some View { - HStack(spacing: 4) { - Image(systemName: system).font(.system(size: 11, weight: .bold)) - Text(text).font(.system(size: 12, weight: .bold)) + /// Platform-owned Home-header button chrome. iOS 26 supplies the interactive Liquid Glass button + /// material; older supported releases keep the same circular geometry with a native system material. + @ViewBuilder + func nativeLiquidGlassHeaderButton() -> some View { + if #available(iOS 26.0, *) { + self + .buttonStyle(.glass) + .buttonBorderShape(.circle) + .controlSize(.small) + } else { + self + .buttonStyle(LiquidPressStyle()) + .background(.ultraThinMaterial, in: Circle()) + .overlay(Circle().strokeBorder(.white.opacity(0.16), lineWidth: 0.8)) } - .foregroundStyle(.white) - .padding(.horizontal, 10) - .frame(height: 34) - .background(Capsule().fill(.white.opacity(0.16))) - .accessibilityElement(children: .ignore) - .accessibilityLabel(Text(a11y)) } } @@ -1920,7 +1940,7 @@ private struct LiquidSyncChip: View { /// it has pulled, and when one last completed. It does NOT yet say "~15h behind" — that needs the /// persisted data frontier (max HR ts) compared against `strapRange.newestUnix`, and the frontier is a /// Repository read that LiveState does not carry. That remains open in B1. Kept here in the Data Sources -/// card as the detailed view; `LiquidSyncChip` above is the header's ambient at-a-glance signal. +/// card as the detailed view; the Devices screen now owns the larger at-a-glance sync card. private struct LiquidSyncStatusRow: View { @EnvironmentObject var live: LiveState var body: some View { diff --git a/Strand/Screens/DevicesView.swift b/Strand/Screens/DevicesView.swift index 7396b422cd..b2b599c8f3 100644 --- a/Strand/Screens/DevicesView.swift +++ b/Strand/Screens/DevicesView.swift @@ -136,6 +136,7 @@ private struct DevicesContent: View { // with the guide already armed, because nothing on Devices said so. Same state and same string // as LiveView's banner; no new copy. if let guide = live.reconnectGuide { repairGuideBanner(guide) } + DeviceSyncStatusCard() // UPPERCASE overline section header, matching the liquid Today. Counts the paired bands so the // multi-WHOOP reality reads at a glance. sectionHead("YOUR BANDS", trailing: activeDevices.count == 1 @@ -444,6 +445,74 @@ private struct DevicesContent: View { } } +// MARK: - Strap-history sync card + +/// The sync status formerly shown as a compact control in the Today header. Devices is the natural home +/// for this device-level state, and the full card gives the status enough room to read without crowding +/// Today's primary actions. This remains display-only and resolves through the existing shared state. +private struct DeviceSyncStatusCard: View { + @EnvironmentObject private var live: LiveState + + var body: some View { + switch SyncChipState.resolve(live: live) { + case .syncing(let chunks): + statusCard( + systemImage: "arrow.triangle.2.circlepath", + detail: chunks > 0 + ? String(localized: "Syncing… \(chunks) chunks") + : String(localized: "Syncing…"), + tint: StrandPalette.accent, + accessibility: String(localized: "Syncing strap history, \(chunks) chunks") + ) + case .synced(let agoText): + statusCard( + systemImage: "checkmark.circle.fill", + detail: String(localized: "Synced \(agoText) ago"), + tint: StrandPalette.statusPositive, + accessibility: String(localized: "Strap history synced \(agoText) ago") + ) + case .experimentalLive: + statusCard( + systemImage: "checkmark.circle.fill", + detail: String(localized: "Connected; strap history sync is experimental on this strap"), + tint: StrandPalette.textSecondary, + accessibility: String(localized: "Connected; strap history sync is experimental on this strap") + ) + case .hidden: + EmptyView() + } + } + + private func statusCard( + systemImage: String, + detail: String, + tint: Color, + accessibility: String + ) -> some View { + NoopCard(tint: tint) { + HStack(alignment: .center, spacing: NoopMetrics.space3) { + Image(systemName: systemImage) + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(tint) + .frame(width: 24) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: NoopMetrics.space1) { + Text("Strap history") + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.textPrimary) + Text(detail) + .font(StrandFont.subhead) + .foregroundStyle(StrandPalette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 0) + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(accessibility)) + } +} + // MARK: - Device card pill state (pure, testable) /// The device card's state-pill label/tone/pulsing, as a priority-ordered pure decision (#221): archived diff --git a/Strand/Screens/SleepView.swift b/Strand/Screens/SleepView.swift index 302bd1e47b..603dc6d0ed 100644 --- a/Strand/Screens/SleepView.swift +++ b/Strand/Screens/SleepView.swift @@ -377,7 +377,7 @@ struct SleepView: View { let night = heroNight(model) let score = performanceScore(for: night) VStack(alignment: .leading, spacing: NoopMetrics.gap) { - SectionHeader("Sleep performance", overline: nightRelativeLabel, trailing: String(localized: "Rest")) + SectionHeader("Sleep performance", overline: nightRelativeLabel) // A subtle night atmosphere sits behind the sleep hero ONLY (the Rest world's whisper: // faint indigo wash + crescent moon over the near-black canvas, no glow), clipped to the // card. Replaces the now-flat ScenicHeroBackground here. @@ -391,6 +391,7 @@ struct SleepView: View { ZStack { LiquidVessel(value: heroFraction, tint: StrandPalette.restColor, animated: true) .frame(width: 184, height: 184) + .shadow(color: StrandPalette.restGlow.opacity(0.18), radius: 12) VStack(spacing: 0) { CountUpText( value: score, @@ -407,7 +408,13 @@ struct SleepView: View { } Text(sleepScoreWord(score)) .font(StrandFont.subhead.weight(.semibold)) - .foregroundStyle(StrandPalette.restColor) + .foregroundStyle(StrandPalette.textPrimary) + .padding(.horizontal, NoopMetrics.space3) + .padding(.vertical, NoopMetrics.space1) + .background( + Capsule(style: .continuous) + .fill(NoopVisualStyle.surfaceTop.opacity(0.42)) + ) } .padding(.top, NoopMetrics.space1) .accessibilityElement(children: .ignore) @@ -433,8 +440,24 @@ struct SleepView: View { } .padding(NoopMetrics.cardInnerPadding + NoopMetrics.space1) .frame(maxWidth: .infinity) - .timeOfDayBackground(.night) + .background(SleepPerformanceNightScene()) .clipShape(RoundedRectangle(cornerRadius: NoopMetrics.cardRadius, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: NoopMetrics.cardRadius, style: .continuous) + .strokeBorder( + LinearGradient( + colors: [ + NoopVisualStyle.borderHighlight.opacity(0.46), + StrandPalette.restColor.opacity(0.16), + NoopVisualStyle.border.opacity(0.42) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + lineWidth: 0.8 + ) + } + .shadow(color: .black.opacity(0.28), radius: 16, x: 0, y: 9) } } @@ -1477,7 +1500,7 @@ struct SleepView: View { let debt = model.sleepDebt VStack(alignment: .leading, spacing: NoopMetrics.gap) { - SectionHeader("Night detail", overline: "Metrics", trailing: String(localized: "vs typical")) + SectionHeader("Night detail", overline: "Metrics") #if os(iOS) // On iOS, Sleep Debt is the actionable summary for the section, so it leads at the @@ -1568,8 +1591,7 @@ struct SleepView: View { private func sleepDebtLedger(_ model: SleepModel) -> some View { let ledger = model.sleepDebtLedger VStack(alignment: .leading, spacing: NoopMetrics.gap) { - SectionHeader("Sleep-debt ledger", overline: "Last 14 nights", - trailing: String(localized: "running balance")) + SectionHeader("Sleep-debt ledger", overline: "Last 14 nights") NoopCard(tint: StrandPalette.restColor) { if ledger.nightCount == 0 { Text("No nights with sleep data yet. Your ledger fills in as you wear the strap to bed.") @@ -1655,8 +1677,7 @@ struct SleepView: View { // Per-stage typical means are computed ONCE in the model build (each a full pass // over repo.days) and read here. VStack(alignment: .leading, spacing: NoopMetrics.gap) { - SectionHeader("Stages vs typical", overline: "Last night", - trailing: String(localized: "hatch = typical")) + SectionHeader("Stages vs typical", overline: "Last night") NoopCard(tint: StrandPalette.restColor) { VStack(alignment: .leading, spacing: NoopMetrics.space4) { stageRow(stage: String(localized: "Deep"), last: s.deep, typical: model.typicalDeepMin, nightTotal: s.total, color: StrandPalette.sleepDeep) @@ -1758,7 +1779,7 @@ struct SleepView: View { let pts = model.trendPoints let avg = model.typicalTotalMin.map { $0 / 60.0 } VStack(alignment: .leading, spacing: NoopMetrics.gap) { - SectionHeader("Asleep duration", overline: "Trend", trailing: String(localized: "Last 30 days")) + SectionHeader("Asleep duration", overline: "Trend") ChartCard( title: "Hours asleep", subtitle: String(localized: "Per night, trailing 30 days"), @@ -2218,7 +2239,32 @@ struct SleepView: View { .disabled(nightOffset >= lastIndex) .accessibilityLabel("Previous night") - SectionHeader(title, overline: "Sleep", trailing: trailing) + HStack(alignment: .bottom, spacing: NoopMetrics.space3) { + VStack(alignment: .leading, spacing: 2) { + Text("Sleep").strandOverline() + Text(title) + .font(StrandFont.title2) + .foregroundStyle(StrandPalette.textPrimary) + } + Spacer(minLength: NoopMetrics.space2) + Text(trailing) + .font(StrandFont.caption.weight(.semibold)) + .foregroundStyle(StrandPalette.textSecondary) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + .padding(.horizontal, NoopMetrics.space3) + .padding(.vertical, NoopMetrics.space2) + .background( + Capsule(style: .continuous) + .fill(StrandPalette.surfaceInset) + .overlay { + Capsule(style: .continuous) + .stroke(StrandPalette.hairline, lineWidth: 1) + } + ) + .padding(.bottom, 1) + } + .frame(maxWidth: .infinity, alignment: .leading) Button { if nightOffset > 0 { nightOffset -= 1 } } label: { Image(systemName: "chevron.right") @@ -2643,6 +2689,85 @@ struct SleepView: View { }() } +/// Decorative-only background for the Sleep Performance hero. It intentionally owns no score, sleep, +/// navigation, or animation state: a deterministic star field, quiet navy atmosphere, and one SF Symbol +/// crescent replace the generic time-of-day scene without touching the hero's existing content hierarchy. +private struct SleepPerformanceNightScene: View { + private struct Star { + let x: CGFloat + let y: CGFloat + let size: CGFloat + let opacity: Double + } + + private let stars: [Star] = [ + .init(x: 0.08, y: 0.16, size: 1.2, opacity: 0.30), + .init(x: 0.16, y: 0.31, size: 0.9, opacity: 0.22), + .init(x: 0.24, y: 0.11, size: 1.0, opacity: 0.26), + .init(x: 0.34, y: 0.23, size: 1.3, opacity: 0.34), + .init(x: 0.46, y: 0.09, size: 0.8, opacity: 0.22), + .init(x: 0.58, y: 0.19, size: 1.0, opacity: 0.28), + .init(x: 0.69, y: 0.10, size: 0.8, opacity: 0.20), + .init(x: 0.77, y: 0.28, size: 1.1, opacity: 0.26), + .init(x: 0.91, y: 0.19, size: 0.9, opacity: 0.24), + .init(x: 0.12, y: 0.58, size: 0.8, opacity: 0.18), + .init(x: 0.88, y: 0.55, size: 1.0, opacity: 0.20), + .init(x: 0.20, y: 0.79, size: 1.1, opacity: 0.18), + .init(x: 0.72, y: 0.76, size: 0.8, opacity: 0.16), + .init(x: 0.94, y: 0.83, size: 1.2, opacity: 0.18) + ] + + var body: some View { + ZStack(alignment: .topTrailing) { + LinearGradient( + colors: [ + NoopVisualStyle.inset, + StrandPalette.restDeep.opacity(0.32), + NoopVisualStyle.canvas, + Color.black.opacity(0.90) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + + RadialGradient( + colors: [StrandPalette.restGlow.opacity(0.13), .clear], + center: .center, + startRadius: 12, + endRadius: 190 + ) + + Canvas { context, size in + for star in stars { + let rect = CGRect( + x: size.width * star.x, + y: size.height * star.y, + width: star.size, + height: star.size + ) + context.fill(Path(ellipseIn: rect), + with: .color(StrandPalette.scenicStar.opacity(star.opacity))) + } + } + .allowsHitTesting(false) + + Image(systemName: "moon.fill") + .font(.system(size: 29, weight: .light)) + .foregroundStyle( + LinearGradient( + colors: [Color.white.opacity(0.78), StrandPalette.restBright.opacity(0.58)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .shadow(color: StrandPalette.restGlow.opacity(0.14), radius: 8) + .padding(.top, NoopMetrics.cardInnerPadding) + .padding(.trailing, NoopMetrics.cardInnerPadding) + .accessibilityHidden(true) + } + } +} + // MARK: - Live-observing leaf subviews (scroll-stutter isolation) // // SleepView itself does NOT observe `LiveState` (a connected strap publishes at ~1 Hz, which would @@ -2667,7 +2792,7 @@ private struct SleepMarkCard: View { var body: some View { VStack(alignment: .leading, spacing: NoopMetrics.gap) { - SectionHeader("Sleep marks", overline: "Tap to log", trailing: String(localized: "Phase 1")) + SectionHeader("Sleep marks", overline: "Tap to log") NoopCard(tint: StrandPalette.restColor) { VStack(alignment: .leading, spacing: NoopMetrics.cardInnerSpacing) { Text("Tap when you're heading to bed or when you wake. Each tap is logged with the time. It doesn't change tonight's detected sleep.") diff --git a/Strand/Screens/TodayView.swift b/Strand/Screens/TodayView.swift index 67d006e416..e75dd07f1b 100644 --- a/Strand/Screens/TodayView.swift +++ b/Strand/Screens/TodayView.swift @@ -1122,12 +1122,6 @@ struct TodayView: View { Spacer(minLength: 8) - // #245: a compact sync-status chip, visible to EVERY user (not only those still building - // scores — the big SyncingHistoryNote below is gated on `recovery == nil`). Three states - // (syncing / last-synced / experimental), so the absence of active syncing reads as caught-up; - // nothing only on a cold start. Owns its LiveState observation so a tick refreshes only it. - SyncStatusChip() - // Uniform 36pt circular icon set: recording-status light, updates bell, quick-add (+), menu. HStack(spacing: 8) { // Recording status, a colour-coded light (green recording / amber synced / red not @@ -4519,9 +4513,8 @@ struct TodayDayScopedCache { // ~1 Hz publish re-renders only the affected dot / note / row, never the rings, scene, sparklines, // HR chart or cards. They render byte-for-byte what the inline code did before the extraction. -/// #245: the sync-status state that both header styles render — the classic top bar's `SyncStatusChip` -/// and the Liquid header's `LiquidSyncChip` — resolved once from `LiveState` so the two chromes can't -/// drift on WHEN to show what. THREE states so the ABSENCE of active syncing reads as "caught up", not +/// #245: the sync-status state used by the Devices screen's larger sync card, resolved once from +/// `LiveState`. THREE states mean the ABSENCE of active syncing reads as "caught up", not /// "missing indicator" (the real #245 confusion): actively offloading → `⟳ N`; idle with a known /// last-sync → `✓ Xm`; a 5/MG whose history sync is experimental (live-connected, no completed offload /// yet) → `✓ live`. `.hidden` only on a true cold start (the building-scores note owns that case). Twin @@ -4540,7 +4533,7 @@ enum SyncChipState: Equatable { return .hidden } - /// Compact relative age for the header chip ("now" / "Nm" / "Nh" / "Nd") — deliberately terse. + /// Compact relative age for the status card ("now" / "Nm" / "Nh" / "Nd") — deliberately terse. /// "now" is the only word in here (the rest is digits + a unit letter), so it's the only piece that /// needs a catalog entry to translate; localized here rather than at each of the two call sites. private static func shortAgo(_ ts: TimeInterval) -> String { @@ -4554,45 +4547,6 @@ enum SyncChipState: Equatable { } } -/// #245: a compact sync-status chip for the Today top bar, shown to EVERY user. The full-width -/// `SyncingHistoryNote` only renders while scores are still building (`recovery == nil`), so an -/// established user — and especially a WHOOP 5/MG owner, whose history offloads are rare — saw no sync -/// feedback on Today, only on the Live screen. Owns its `LiveState` observation so a live tick refreshes -/// only this chip. DRAFT (#245): final styling/wording still to be finalised. -struct SyncStatusChip: View { - @EnvironmentObject private var live: LiveState - - var body: some View { - switch SyncChipState.resolve(live: live) { - case .syncing(let chunks): - chip(system: "arrow.triangle.2.circlepath", text: "\(chunks)", tint: StrandPalette.accent, - a11y: String(localized: "Syncing strap history, \(chunks) chunks")) - case .synced(let agoText): - chip(system: "checkmark", text: agoText, tint: StrandPalette.textSecondary, - a11y: String(localized: "Strap history synced \(agoText) ago")) - case .experimentalLive: - chip(system: "checkmark", text: String(localized: "live"), tint: StrandPalette.textSecondary, - a11y: String(localized: "Connected; strap history sync is experimental on this strap")) - case .hidden: - EmptyView() - // cold start — render nothing; the building-scores SyncingHistoryNote covers it. - } - } - - private func chip(system: String, text: String, tint: Color, a11y: String) -> some View { - HStack(spacing: 4) { - Image(systemName: system).font(.system(size: 11, weight: .semibold)) - Text(text).font(StrandFont.captionNumber) - } - .foregroundStyle(tint) - .padding(.horizontal, 8) - .padding(.vertical, 5) - .background(Capsule().fill(StrandPalette.surfaceInset)) - .accessibilityElement(children: .ignore) - .accessibilityLabel(Text(a11y)) - } -} - /// The compact 36pt recording-status light in the iOS top bar, a colour-coded dot (green recording, /// amber last-synced, red not recording, accent for experimental 5.0 history). Taps to Devices. Owns /// the `LiveState` observation so a live-HR tick refreshes only this dot. diff --git a/Strand/Screens/TrendsView.swift b/Strand/Screens/TrendsView.swift index bb6390bc2b..890e6a3a15 100644 --- a/Strand/Screens/TrendsView.swift +++ b/Strand/Screens/TrendsView.swift @@ -375,8 +375,6 @@ struct TrendsView: View { Text(weekOffset == 0 ? String(localized: "This week") : weekOffsetLabel) .font(StrandFont.headline) .foregroundStyle(StrandPalette.textPrimary) - Text("Week in review") - .strandOverline() } Spacer() @@ -412,7 +410,7 @@ struct TrendsView: View { let effortAvg = mean(effort.points) // stored 0–100 internal Effort scale let restAvg = mean(rest.points) if chargeAvg != nil || effortAvg != nil || restAvg != nil { - NoopCard(tint: StrandPalette.chargeColor) { + NoopCard { VStack(alignment: .leading, spacing: NoopMetrics.cardInnerSpacing) { SectionHeader("Week in review", overline: "Charge · Effort · Rest") if let v = chargeAvg { @@ -514,9 +512,11 @@ struct TrendsView: View { let isWide = recovery.widened return VStack(alignment: .leading, spacing: NoopMetrics.space2) { HStack { - SegmentedPillControl(Range.allCases, selection: $range) { $0.label } - Spacer() - Text(rangeSubtitle).strandOverline() + SegmentedPillControl( + Range.allCases, + selection: $range, + fillsAvailableWidth: true + ) { $0.label } } Text(cap) .font(StrandFont.footnote) @@ -540,7 +540,6 @@ struct TrendsView: View { subtitle: rangeSubtitle, trailing: avg.map { "\(Int($0.rounded()))" }, height: NoopMetrics.chartHeight, - tint: StrandPalette.chargeColor, chart: { if pts.count >= 2 { glowChart(points: pts, @@ -599,7 +598,7 @@ struct TrendsView: View { points: hrvPts, gradient: gradient(StrandPalette.metricPurple), tip: StrandPalette.metricPurple, - tint: StrandPalette.chargeColor, + tint: nil, higherIsBetter: true, range: valueRange(hrvPts, fallback: 20...120), fmt: { "\(Int($0.rounded()))" } @@ -611,7 +610,7 @@ struct TrendsView: View { points: rhrPts, gradient: gradient(StrandPalette.metricRose), tip: StrandPalette.metricRose, - tint: StrandPalette.chargeColor, + tint: nil, higherIsBetter: false, range: valueRange(rhrPts, fallback: 40...80), fmt: { "\(Int($0.rounded()))" } @@ -647,7 +646,7 @@ struct TrendsView: View { subtitle: String? = nil, gradient: Gradient, tip: Color, - tint: Color, + tint: Color?, higherIsBetter: Bool?, range: ClosedRange, fmt: @escaping (Double) -> String @@ -699,7 +698,7 @@ struct TrendsView: View { return RecoveryDay(date: dt, score: d.recovery) } let title = (range == .all && repo.days.count > 365) ? String(localized: "Charge (all history)") : String(localized: "Charge (past year)") - return NoopCard(tint: StrandPalette.chargeColor) { + return NoopCard { VStack(alignment: .leading, spacing: NoopMetrics.cardInnerSpacing) { SectionHeader("\(title)", overline: "Calendar", trailing: String(localized: "\(recoveryDays.filter { $0.score != nil }.count) days")) if recoveryDays.isEmpty { diff --git a/Strand/Screens/WeeklyDigestView.swift b/Strand/Screens/WeeklyDigestView.swift index 941c884db7..4a95bff8b4 100644 --- a/Strand/Screens/WeeklyDigestView.swift +++ b/Strand/Screens/WeeklyDigestView.swift @@ -163,8 +163,7 @@ struct WeeklyDigestContent: View { private var header: some View { ZStack(alignment: .leading) { - NoopPanelSurface(tint: DomainTheme.charge.color, - cornerRadius: NoopMetrics.cardRadius, + NoopPanelSurface(cornerRadius: NoopMetrics.cardRadius, elevated: true) HStack(alignment: .firstTextBaseline) { VStack(alignment: .leading, spacing: 2) { @@ -589,6 +588,11 @@ private struct DigestScoreCard: View { .font(StrandFont.footnote) .foregroundStyle(StrandPalette.textTertiary) .lineLimit(1) + // Trends keeps the Charge scale caption but drops the redundant caption from + // Effort and Rest. Opacity preserves the exact three-column geometry and gauge + // alignment while accessibility remains owned by the card-level summary label. + .opacity(summary.metric == .charge ? 1 : 0) + .accessibilityHidden(summary.metric != .charge) } if isEmbedded && hasComparison { TrendChip(text: deltaSigned, color: deltaTone) diff --git a/StrandiOS/App/RootTabView.swift b/StrandiOS/App/RootTabView.swift index 3033505183..58e80b883f 100644 --- a/StrandiOS/App/RootTabView.swift +++ b/StrandiOS/App/RootTabView.swift @@ -49,18 +49,29 @@ struct RootTabView: View { if liquidTodayEnabled { LiquidTodayView() } else { TodayView() } } - init() { - // Plain Titanium bar: pin the background to `surfaceBase` and clear the system - // selection-indicator tint so there is NO gold/accent pill behind the selected - // icon — the gold `.tint` below colours only the selected icon + label, nothing - // is filled behind it. (UIKit derives a selection-indicator fill from the tint - // unless it's explicitly cleared.) - let appearance = UITabBarAppearance() - appearance.configureWithOpaqueBackground() - appearance.backgroundColor = UIColor(StrandPalette.surfaceBase) - appearance.selectionIndicatorTintColor = .clear - UITabBar.appearance().standardAppearance = appearance - UITabBar.appearance().scrollEdgeAppearance = appearance + /// Native tab selection binding. SwiftUI sends taps on the already-selected item through the + /// setter, which lets the system tab bar retain the app's refresh / pop-to-root / scroll-to-top + /// convention without placing a custom hit-testing layer over the platform bar. + private var nativeTabSelection: Binding { + Binding( + get: { selectedTab }, + set: { tag in + if tag == selectedTab { + reselectTab(tag) + } else { + selectedTab = tag + } + } + ) + } + + private func reselectTab(_ tag: Int) { + Task { await repo.refresh() } + if !tabPaths[tag].isEmpty { + tabPaths[tag] = NavigationPath() + } else { + scrollTop[tag] += 1 + } } /// The anywhere-swipe tab-switch drag (2026-07-02). Held as a property so the attachment site can @@ -86,21 +97,16 @@ struct RootTabView: View { } var body: some View { - // The native TabView keeps every existing destination + system gesture; the signature - // raised gold FAB is overlaid on top, bottom-centre, floating ~20pt above the bar (a - // native TabView can't host a centre item that overflows the bar, so we float it). - ZStack(alignment: .bottom) { - // A custom floating bar — two frosted "glass" islands with the gold action button nested - // cleanly in the gap between them — replaces the native tab bar: no overlap, no glow. The - // native TabView still drives content + per-tab nav state; only its bar is hidden. - TabView(selection: $selectedTab) { - tab(todayTabRoot, "Today", "square.grid.2x2", path: $tabPaths[0], scrollSignal: scrollTop[0]).tag(0) - tab(TrendsView(), "Trends", "chart.line.uptrend.xyaxis", path: $tabPaths[1], scrollSignal: scrollTop[1]).tag(1) - tab(SleepView(), "Sleep", "bed.double", path: $tabPaths[2], scrollSignal: scrollTop[2]).tag(2) - moreTab(path: $tabPaths[3], scrollSignal: scrollTop[3]).tag(3) - } - .tint(StrandPalette.accent) - .toolbar(.hidden, for: .tabBar) + // The platform tab bar is intentionally left fully native. iOS 26 supplies Liquid Glass and + // its dynamic interaction with scrolling content automatically; older supported releases use + // the corresponding system material and safe-area behaviour from the same TabView. + TabView(selection: nativeTabSelection) { + tab(todayTabRoot, "Today", "square.grid.2x2", path: $tabPaths[0], scrollSignal: scrollTop[0]).tag(0) + tab(TrendsView(), "Trends", "chart.line.uptrend.xyaxis", path: $tabPaths[1], scrollSignal: scrollTop[1]).tag(1) + tab(SleepView(), "Sleep", "bed.double", path: $tabPaths[2], scrollSignal: scrollTop[2]).tag(2) + moreTab(path: $tabPaths[3], scrollSignal: scrollTop[3]).tag(3) + } + .tint(StrandPalette.accent) // Tab crossfade — README §Motion: ~240ms opacity swap between tab roots, global calm // easing cubic-bezier(0.22,1,0.36,1). .animation(.timingCurve(0.22, 1, 0.36, 1, duration: 0.24), value: selectedTab) @@ -125,20 +131,6 @@ struct RootTabView: View { // interactive-pop itself: far worse than the bug being fixed. .simultaneousGesture(tabSwipeGesture, including: tabPaths[selectedTab].isEmpty ? .all : .subviews) - - FloatingTabBar(selection: $selectedTab, onReselect: { tag in - // Re-tapping the active tab refreshes that page's data (2026-07-02) and, from a - // subpage, pops that tab's stack back to its root (#135) — an animated pop via the - // path, not a rebuild. At the root the pop is skipped, so scroll position survives - // and the refresh doesn't double with a re-run of the root's `.task` (#198). - Task { await repo.refresh() } - if !tabPaths[tag].isEmpty { - tabPaths[tag] = NavigationPath() // on a subpage: animated pop back to the root - } else { - scrollTop[tag] += 1 // already at root: scroll to the top (#198 follow-up) - } - }) - } .task { await repo.refresh() // Backup & Sync: on-launch catch-up (see RootView). Detached + utility priority so a @@ -341,7 +333,6 @@ struct RootTabView: View { // Drive this tab's root scroll-to-top on an at-root re-tap (#198 follow-up); read by ScreenScaffold // / LiquidTodayView inside. Only THIS tab's token changes on its reselect, so the others don't scroll. .environment(\.scrollToTopSignal, scrollSignal) - .toolbar(.hidden, for: .tabBar) // we draw our own FloatingTabBar .tabItem { Label(title, systemImage: icon) } } @@ -404,7 +395,6 @@ struct RootTabView: View { MoreRow("Settings", "gearshape.fill", .settings) } } - .toolbar(.hidden, for: .tabBar) // we draw our own FloatingTabBar // The rows push MoreDestination VALUES so a re-tap of the More tab can pop them off the // bound path (#135/#198). Each destination keeps the per-screen wrapper the rows used to // apply inline (surfaceBase background, inline title bar, hidden bar background): @@ -421,7 +411,7 @@ struct RootTabView: View { } // Scroll the More index to the top on an at-root re-tap (#198 follow-up); read by its ScreenScaffold. .environment(\.scrollToTopSignal, scrollSignal) - .tabItem { Label("More", systemImage: "ellipsis.circle.fill") } + .tabItem { Label("More", systemImage: "ellipsis") } } /// One titled, COLLAPSIBLE group in the More index (S2): the app's overline (UPPERCASE) becomes a @@ -650,91 +640,4 @@ private struct QuickActionSheet: View { } } -// MARK: - Floating tab bar - -/// The signature bottom bar: two frosted "glass" islands (Today·Trends / Sleep·More) with the gold -/// action button nested cleanly in the gap between them — no overlap, no glow. Real iOS 26 Liquid -/// Glass where available, a `.ultraThinMaterial` fallback below. Replaces the hidden native tab bar. -private struct FloatingTabBar: View { - @Binding var selection: Int - /// Fires when the user taps the ALREADY-active tab (2026-07-02: re-tap should refresh). - var onReselect: (Int) -> Void = { _ in } - - private struct Item: Identifiable { let title: LocalizedStringKey; let icon: String; let tag: Int; var id: Int { tag } } - private let nav = [Item(title: "Today", icon: "square.grid.2x2", tag: 0), - Item(title: "Trends", icon: "chart.line.uptrend.xyaxis", tag: 1), - Item(title: "Sleep", icon: "bed.double", tag: 2), - Item(title: "More", icon: "ellipsis", tag: 3)] - - var body: some View { - // One frosted glass bar, four evenly-spaced tabs. The quick-action "+" now lives in the - // top-right of each screen's header (balancing the profile avatar on the left). - HStack(spacing: 2) { - tabButton(nav[0]) - tabButton(nav[1]) - tabButton(nav[2]) - tabButton(nav[3]) - } - .padding(.vertical, 7) - .padding(.horizontal, 8) - .liquidGlass(in: Capsule()) - // Over the liquid Today the sky ends at ~340pt, so the bar floats on flat opaque surfaceBase — - // a blur material has nothing to dissolve and hardens into a solid lozenge (2026-07-02: - // "clips into a solid shape"). A faint translucent scrim INSIDE the same Capsule keeps the pill - // reading as tinted glass, not a slab, even against dead-flat colour. - .background(NoopVisualStyle.surfaceTop.opacity(0.88), in: Capsule()) - // Soft top-lit rim instead of one hard hairline, so there's no crisp cut-out edge. - .overlay( - Capsule().strokeBorder( - LinearGradient(colors: [NoopVisualStyle.borderHighlight.opacity(0.78), NoopVisualStyle.border.opacity(0.42)], - startPoint: .top, endPoint: .bottom), - lineWidth: 0.75) - ) - // Lighter, wider shadow: real elevation without stamping a dark halo on the flat canvas. - .shadow(color: .black.opacity(0.34), radius: 20, x: 0, y: 10) - .padding(.horizontal, 22) - .padding(.bottom, 4) - } - - private func tabButton(_ item: Item) -> some View { - let active = selection == item.tag - return Button { - if active { - onReselect(item.tag) - } else { - withAnimation(.timingCurve(0.22, 1, 0.36, 1, duration: 0.24)) { selection = item.tag } - } - } label: { - VStack(spacing: 3) { - Image(systemName: item.icon) - .font(.system(size: 18, weight: active ? .semibold : .regular)) - Text(item.title) - .font(.system(size: 10, weight: active ? .semibold : .medium)) - } - .foregroundStyle(active ? StrandPalette.textPrimary : StrandPalette.textSecondary) - .frame(maxWidth: .infinity) - .padding(.vertical, 3) - .background(active ? NoopVisualStyle.inset : .clear, in: Capsule()) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityLabel(item.title) - .accessibilityAddTraits(active ? [.isButton, .isSelected] : .isButton) - } - -} - -// MARK: - Liquid Glass (iOS 26) with a Material fallback - -private extension View { - /// Real iOS 26 Liquid Glass where available; `.ultraThinMaterial` on iOS 17–25 — a clean - /// blended degrade so the bar stays modern on new OSes without breaking older ones. - @ViewBuilder func liquidGlass(in shape: some Shape) -> some View { - if #available(iOS 26.0, *) { - self.glassEffect(.regular, in: shape) - } else { - self.background(.ultraThinMaterial, in: shape) - } - } -} #endif diff --git a/docs/UI_CUSTOMIZATION_LEDGER.md b/docs/UI_CUSTOMIZATION_LEDGER.md new file mode 100644 index 0000000000..e9bc7c8015 --- /dev/null +++ b/docs/UI_CUSTOMIZATION_LEDGER.md @@ -0,0 +1,271 @@ +# NOOP Custom UI Change Ledger + +This document is the merge source of truth for the custom NOOP interface. Update it in the same +working session as every UI change. It deliberately separates the stable visual redesign from the +larger layout/UX experiments on `ui-experimental`, so either layer can be reviewed or merged later. + +## Branch lineage and comparison baseline + +- Upstream baseline at branch creation: `origin/main` at `3b86b6ef` (`Release 9.3.1: add to AltStore source`). +- Stable custom UI branch: `custom/noop-ui`. +- Experimental branch: `ui-experimental`, branched from `custom/noop-ui` at `18db457f`. +- Stable UI commits: + - `b96b0661` — `Apply custom NOOP UI` + - `18db457f` — `Refine NOOP visual design system` +- Experimental edits after `18db457f` are currently intentionally uncommitted. +- Before merging, refresh the upstream reference and repeat the audit against Ryan's then-current main. + +## Non-negotiable merge boundaries + +Unless a future ledger entry explicitly says otherwise, custom UI work must not modify: + +- BLE communication or wearable protocols +- HealthKit behavior, permissions, capabilities, or entitlements +- scoring, calculations, algorithms, or analytics semantics +- persistence, database schemas, migrations, or data models +- networking or import behavior +- business logic, timers, async data loading, or state ownership +- bundle identifiers, signing, targets, schemes, or build configuration +- navigation destinations or the content/functionality of screens + +Generated localization catalogs are not part of the redesign. The following files were intentionally +reverted and must remain excluded unless a later UI change genuinely introduces new localized copy: + +- `NOOPWatch/Localizable.xcstrings` +- `NOOPWatchComplications/Localizable.xcstrings` +- `Packages/StrandDesign/Sources/StrandDesign/Resources/Localizable.xcstrings` +- `Strand/Resources/Localizable.xcstrings` + +## Stable custom UI layer (`custom/noop-ui`) + +### Shared design system + +| File | Intentional change | Merge notes | +| --- | --- | --- | +| `Packages/StrandDesign/Sources/StrandDesign/NoopVisualStyle.swift` | Added the shared visual tokens and reusable chrome surfaces for canvas, elevated/inset panels, border highlights, shadows, radii, and materials. | Core dependency for nearly every styling migration; merge first. | +| `Packages/StrandDesign/Sources/StrandDesign/Palette.swift` | Reworked the app palette toward the dark neutral custom UI with consistent semantic accent colors. | Visual tokens only. | +| `Packages/StrandDesign/Sources/StrandDesign/Typography.swift` | Standardized SF Rounded typography and restored Dynamic Type scaling for the custom overline style. | Accessibility scaling must remain intact. | +| `Packages/StrandDesign/Sources/StrandDesign/StrandCard.swift` | Routed card rendering through the shared panel surface instead of maintaining separate hard-coded card chrome. | Presentation only; card content API retained. | +| `Packages/StrandDesign/Sources/StrandDesign/Components.swift` | Migrated shared cards, chart containers, controls, and section components to design-system surfaces and typography. | Widely reused; resolve upstream conflicts carefully. | +| `Packages/StrandDesign/Sources/StrandDesign/NoopButton.swift` | Updated shared button materials, borders, and pressed appearance. | Actions and hit targets unchanged. | +| `Packages/StrandDesign/Sources/StrandDesign/StatePill.swift` | Updated state-pill chrome to shared tokens. | State semantics unchanged. | +| `Packages/StrandDesign/Sources/StrandDesign/ChartHover.swift` | Restyled chart hover/selection presentation. | Chart values and gesture behavior unchanged. | +| `Packages/StrandDesign/Sources/StrandDesign/OverviewHRChart.swift` | Migrated chart presentation details to the shared visual language. | Sampling and chart data unchanged. | +| `Packages/StrandDesign/Sources/StrandDesign/Sparkline.swift` | Updated sparkline presentation token usage. | Data path unchanged. | +| `Packages/StrandDesign/Sources/StrandDesign/NoopMotion.swift` | Adjusted presentation-related motion constants/usage for the redesign. | Keep Reduce Motion behavior; re-audit if upstream motion logic changes. | + +### App-wide screens and surfaces + +| File | Intentional change | Merge notes | +| --- | --- | --- | +| `Strand/App/RootView.swift` | Applied shared root/sidebar surface styling. | Navigation destinations unchanged. | +| `Strand/MenuBar/MenuBarContent.swift` | Applied shared menu-bar surface token. | Menu actions unchanged. | +| `Strand/Onboarding/OnboardingWizard.swift` | Restyled onboarding cards/backgrounds/buttons using the shared system. | Onboarding order and completion logic unchanged. | +| `Strand/Liquid/LiquidPrimitives.swift` | Replaced the liquid-slosh vessel drawing with a calmer circular progress-ring renderer. | Intentional visual renderer replacement; simulation values and tap plumbing retained. This is one of the two approved presentation differences from upstream. | +| `Strand/Liquid/LiquidSky.swift` | Retuned the liquid sky colors/gradients to match the custom palette. | Visual only. | +| `Strand/Liquid/LiquidTodayView.swift` | Migrated Today cards, headers, and dashboard chrome to the design system. | Stable commit preserves data bindings; experimental grid work is documented separately below. | +| `Strand/Liquid/LiveSessionView.swift` | Restyled live-session surfaces. | Session behavior unchanged. | +| `Strand/Screens/TodayView.swift` | Migrated classic Today presentation to shared surfaces. | Logic and routes unchanged. | +| `Strand/Screens/TrendsView.swift` | Migrated Trends presentation to shared surfaces. | Experimental cleanup is documented separately below. | +| `Strand/Screens/SleepView.swift` | Migrated Sleep cards, placeholders, and editor chrome to shared surfaces. | Sleep data/model logic unchanged. | +| `Strand/Screens/DevicesView.swift` | Migrated Devices presentation to shared cards and surfaces. | Pairing and device management logic unchanged. | +| `Strand/Screens/LiveView.swift` | Migrated Live presentation to shared surfaces. | BLE/live-session behavior unchanged. | +| `Strand/Screens/CoachView.swift` | Applied shared page/card styling. | Content and logic unchanged. | +| `Strand/Screens/CompareView.swift` | Applied shared comparison-card styling. | Comparison calculations unchanged. | +| `Strand/Screens/CoupledView.swift` | Applied shared coupled-metric styling. | Data bindings unchanged. | +| `Strand/Screens/InsightsHubView.swift` | Applied shared page/card styling. | Navigation unchanged. | +| `Strand/Screens/InsightsView.swift` | Applied shared insight-card styling. | Journal behavior unchanged. | +| `Strand/Screens/MetricExplorerView.swift` | Applied shared metric/chart styling. | Metric selection and data unchanged. | +| `Strand/Screens/TrendsReportView.swift` | Applied shared report-card styling. | Report contents unchanged. | +| `Strand/Screens/WeeklyDigestView.swift` | Applied shared weekly-digest styling. | Digest calculations unchanged. | +| `Strand/Screens/UpdatesInboxView.swift` | Applied shared page/card styling. | Update actions unchanged. | +| `Strand/Screens/AppleWatchSetupView.swift` | Applied shared setup-card styling. | Watch setup behavior unchanged. | +| `Strand/Screens/EditableLayoutList.swift` | Applied shared editor-row styling. | Reordering behavior unchanged. | +| `Strand/Screens/HRVSnapshotView.swift` | Applied shared snapshot styling. | Measurement behavior unchanged. | +| `Strand/Screens/HydrationView.swift` | Applied shared hydration-card styling. | Logging behavior unchanged. | +| `Strand/Screens/ManualWorkoutSheet.swift` | Applied shared sheet styling. | Workout creation unchanged. | +| `Strand/Screens/NotificationSettingsView.swift` | Applied shared settings styling. | Notification settings behavior unchanged. | +| `Strand/Screens/ScoringGuideView.swift` | Applied shared guide styling. | Scoring descriptions/logic unchanged. | +| `StrandiOS/App/RootTabView.swift` | Stable layer restyled the iPhone shell and quick-action surfaces. | Experimental native-tab replacement supersedes the custom bar; see below. | + +### Approved stable differences from upstream + +1. Updated page and section spacing remains intentional. +2. The progress-ring renderer remains intentional instead of restoring the liquid-slosh renderer. + +The earlier audit confirmed no intended stable changes to business logic, BLE, protocol, scoring, +persistence, networking, navigation flow, data models, entitlements, or build configuration. + +## Experimental UI/UX layer (`ui-experimental`) + +These entries describe the current working-tree changes after `18db457f`. They are ordered by user-visible +feature rather than by file so they can be reviewed and ported independently. + +### EXP-001 — Two-column Key Metrics grid + +- **Files:** `Strand/Liquid/LiquidTodayView.swift` +- Changed the Home/Today Key Metrics grid from three columns to two. +- Increased card minimum height, corner radius, padding, internal spacing, progress-bar height, title size, + and value hierarchy. +- Added subtle SF Symbols for Recovery, Strain, Rest, HRV, Resting HR, Blood Oxygen, Respiratory, Steps, + Weight, and Calories. +- Units/suffixes now use the same 24-point rounded number style as the main value. +- Percentage values have no separating space (`82%`); textual units retain one (`48 ms`, `62 bpm`). +- Preserved metric ordering, editor preferences, detailed-card mode, sparks, routes, and data bindings. +- **Type:** intentional layout and presentation change; no data or calculation impact. + +### EXP-002 — Move sync status from Today header to Devices + +- **Files:** `Strand/Liquid/LiquidTodayView.swift`, `Strand/Screens/TodayView.swift`, + `Strand/Screens/DevicesView.swift` +- Removed the small sync-status circles/chips from both Liquid Today and classic Today headers. +- Retained the existing shared `SyncChipState` resolver. +- Added a larger Devices-screen status card using the same state and accessibility descriptions. +- The card reports syncing progress, last-sync age, experimental live state, or hides on cold start exactly + as the prior header indicator did. +- **Type:** intentional presentation/location change; sync behavior and BLE logic unchanged. + +### EXP-003 — Shared time-range selector redesign + +- **Files:** `Packages/StrandDesign/Sources/StrandDesign/Components.swift`, + `Strand/Screens/TrendsView.swift` +- Restyled the existing shared `SegmentedPillControl` with a neutral inset rounded track and a raised, + bordered selected segment. +- Added the opt-in `fillsAvailableWidth` parameter; existing callers retain their previous sizing. +- Trends enables full-width mode for `W / M / 3M / 6M / 1Y / All`. +- Removed the redundant range-summary text that previously occupied the selector's right side. +- Dynamic Type adaptation and selection bindings remain intact. +- **Type:** presentation plus intentional Trends layout use; filtering logic unchanged. + +### EXP-004 — Neutralize Trends cards + +- **Files:** `Strand/Screens/TrendsView.swift` +- Removed the green-tinted surface/glow from Week in Review, Charge charts, HRV/RHR trend cards, and the + history strip while preserving semantic colors inside the actual data graphics. +- Removed the redundant `WEEK IN REVIEW` subtitle beneath `This week`. +- Kept card contents, order, chart data, and interactions unchanged. +- **Type:** visual cleanup only. + +### EXP-005 — Sleep header and label cleanup + +- **Files:** `Strand/Screens/SleepView.swift` +- Removed small right-side explanatory labels from Sleep Performance, Night Detail, Sleep-debt Ledger, + Stages vs Typical, Asleep Duration, and Sleep Marks section headers. +- Redesigned the navigated-night date beside `Sleep / Last night` as a compact neutral capsule with + semibold caption typography and intentional bottom alignment. +- Kept previous/next-night buttons, date value, selected night, accessibility, and data unchanged. +- **Type:** visual cleanup and localized header layout only. + +### EXP-006 — Native Apple tab bar / Liquid Glass + +- **Files:** `StrandiOS/App/RootTabView.swift` +- Confirmed a native SwiftUI `TabView` already existed beneath the custom floating bar. +- Removed `FloatingTabBar`, its custom buttons, background, blur/material, gradients, borders, shadows, + active-tab pill, custom `.glassEffect()` helper, tab-bar hiding, and the global `UITabBarAppearance` + override. +- Restored the system-rendered tab bar for Today, Trends, Sleep, and More. +- iOS 26+ receives Apple's native Liquid Glass automatically; older supported iOS versions receive the + standard native system tab bar from the same `TabView`. +- Preserved the selection state, per-tab `NavigationPath`, tab swipe, routes, screen state, and native + reselection forwarding to the existing refresh / pop-to-root / scroll-to-top behavior. +- More uses the visible custom-bar icon (`ellipsis`) in the restored native item. +- **Type:** intentional navigation-chrome implementation change; destinations and screen content unchanged. + +### EXP-007 — Native Liquid Glass Home header buttons + +- **Date:** 2026-08-04 +- **Files:** `Strand/Liquid/LiquidTodayView.swift` +- Replaced the custom circular fills, borders, and press-only styling on the Home header's Profile, + Quick Actions, Battery/Devices, and Customize Today buttons with Apple's native `.glass` button style. +- The profile control uses the exact same native `.glass` style, circular border shape, and small control + size as the other three buttons. Its photo is overlaid across the button's measured circular bounds so + it fills the face without changing the control's outer size or hit area. Apple's interactive + `.glassEffect` is then applied as the final layer over the composed photo control so the edge-to-edge + image cannot conceal the system glass refraction and highlight. +- iOS 26+ uses interactive system Liquid Glass with a circular button border shape; older supported iOS + versions retain the same circular geometry using native `ultraThinMaterial` and the existing press motion. +- Preserved all four actions, SF Symbols/avatar content, battery state rendering, accessibility labels, + routes, and hit behavior. +- **Type:** visual button-chrome change only; no app logic or navigation destination changes. + +### EXP-008 — Refined Sleep Performance night scene + +- **Date:** 2026-08-04 +- **Files:** `Strand/Screens/SleepView.swift` +- Replaced only the Sleep Performance hero's generic time-of-day decoration with a deterministic, + card-local night scene: deep shared-token navy/black gradients, a restrained star Canvas, a single + intentional `moon.fill` crescent, and faint central blue atmosphere. +- Added a subtle card-local moonlight shadow to the existing progress ring without changing its value, + animation, renderer, tap behavior, or progress fraction. +- Integrated the existing score-state word into a low-contrast capsule and retained the existing source + badge, score typography, labels, content order, spacing system, radius, border, and elevation language. +- Preserved sleep scoring, model construction, data loading, bindings, navigation, calculations, + accessibility value, and all interactions. +- **Type:** decorative visual redesign only. + +### EXP-009 — Trends weekly-summary cleanup + +- **Date:** 2026-08-04 +- **Files:** `Strand/Screens/WeeklyDigestView.swift` +- Removed the remaining Charge/green tint from the Week in Review header card by using the shared neutral + `NoopPanelSurface` with the same radius and elevation. +- Hid the scale caption beneath only the embedded Effort and Rest gauges while retaining the Charge + caption. The invisible caption views retain their original layout space, so card height, gauge alignment, + spacing, and the three-column geometry remain unchanged. +- Preserved digest values, gauge fractions, animations, week navigation, comparison chips, calculations, + bindings, interactions, and card-level accessibility summaries. +- **Type:** visual cleanup only. + +## Verification history + +| Date | Scope | Result | +| --- | --- | --- | +| 2026-08-03 | Stable redesign audit against Ryan main | `git diff --check` passed; generated catalogs reverted; no business logic, BLE, protocol, scoring, persistence, networking, navigation-flow, data-model, entitlement, or build-configuration changes retained. | +| 2026-08-04 | Sleep date badge | Debug device build succeeded; installed and launched on Liam's iPhone using `NOOPiOS`, `com.liammazuz.noop`, team `P2874N8GRQ`. | +| 2026-08-04 | Key Metric typography and percentage spacing | Incremental Debug device builds succeeded; installed and launched on the connected iPhone. Existing unrelated compiler warnings remained. | +| 2026-08-04 | Native system tab bar | Debug physical-device build, signing, installation, and launch succeeded. Automated mirrored interaction was unavailable because the phone was in use; source verification confirmed all custom bar rendering paths were removed. | +| 2026-08-04 | Native Liquid Glass Home header buttons | `NOOPiOS` Debug physical-device build succeeded; installed and launched on the connected iPhone with `com.liammazuz.noop` and team `P2874N8GRQ`. Existing unrelated compiler warnings remained. | +| 2026-08-04 | Refined Sleep Performance night scene | `NOOPiOS` Debug physical-device build succeeded; installed and launched on the connected iPhone. Source audit confirmed the score source, progress fraction, animation, labels, source badge, accessibility value, and interactions were unchanged. | +| 2026-08-04 | Trends weekly-summary cleanup | `NOOPiOS` Debug physical-device build succeeded; installed and launched on the connected iPhone. `git diff --check` passed; existing unrelated compiler warnings remained. | + +## Required workflow for every future custom UI change + +1. Add or update an `EXP-###` entry in this file during the same work session. +2. Record every touched source file, the visible outcome, and whether layout or navigation chrome changed. +3. State which bindings, actions, routes, accessibility behavior, and data semantics were deliberately preserved. +4. Record any shared-design-system API added or changed and list all opt-in callers. +5. Keep generated files and unrelated workspace changes out of the change. +6. Run `git diff --check` and an appropriate build. +7. When installed on a device, record the scheme, bundle ID, signing team, and result in Verification History. +8. Before a commit, compare the complete diff against the latest upstream main and explicitly audit the + protected categories listed under Non-negotiable merge boundaries. +9. Do not commit or push unless explicitly requested. + +### Template for the next entry + +```markdown +### EXP-### — Short feature name + +- **Date:** YYYY-MM-DD +- **Files:** `path/to/file.swift` +- **Request:** What the user asked to change. +- **Implementation:** Exact visual/layout implementation. +- **Preserved:** Bindings, actions, navigation, accessibility, data, and logic intentionally unchanged. +- **Compatibility:** Availability behavior or older-iOS fallback, if applicable. +- **Verification:** `git diff --check`, build target/result, and physical-device result. +- **Type:** visual only / intentional layout / navigation chrome / other approved scope. +``` + +## Pre-merge checklist + +- [ ] Fetch Ryan's latest main without merging it. +- [ ] Compare both committed and uncommitted changes against that exact upstream commit. +- [ ] Confirm this ledger lists every changed source file. +- [ ] Confirm generated localization catalogs are clean. +- [ ] Confirm no generated project/build artifacts are tracked. +- [ ] Run `git diff --check`. +- [ ] Build the `NOOPiOS` Debug scheme. +- [ ] Re-run the protected-category audit. +- [ ] Review stable and experimental changes separately. +- [ ] Decide which experimental entries are approved for the merge. +- [ ] Commit in small, ledger-aligned groups only after approval. From bd94c5eeb7b017dc0020b4cd5f63d16f5f6f5516 Mon Sep 17 00:00:00 2001 From: liaolo <99437277+liaolo@users.noreply.github.com> Date: Tue, 4 Aug 2026 10:50:28 +0300 Subject: [PATCH 04/13] Refine Trends and metric visuals --- Strand/Liquid/LiquidPrimitives.swift | 44 ++++++++++++++++++------- Strand/Liquid/LiquidTodayView.swift | 3 +- Strand/Screens/TrendsView.swift | 14 ++++++-- Strand/Screens/WeeklyDigestView.swift | 47 ++++++++++++++------------- docs/UI_CUSTOMIZATION_LEDGER.md | 43 ++++++++++++++++++++++++ 5 files changed, 113 insertions(+), 38 deletions(-) diff --git a/Strand/Liquid/LiquidPrimitives.swift b/Strand/Liquid/LiquidPrimitives.swift index ad14837b6e..a7db585935 100644 --- a/Strand/Liquid/LiquidPrimitives.swift +++ b/Strand/Liquid/LiquidPrimitives.swift @@ -54,7 +54,9 @@ enum LiquidRender { } /// A horizontal capsule tube filled to `frac`; tilt pushes the liquid along it. - static func tube(_ base: GraphicsContext, _ size: CGSize, _ sim: LiquidSim, now: Double, frac: Double, tint: Color) { + static func tube(_ base: GraphicsContext, _ size: CGSize, _ sim: LiquidSim, now: Double, + frac: Double, tint: Color, showsHighlight: Bool = true, + usesCleanFill: Bool = false) { let w = size.width, h = size.height, r = h / 2 let outline = Path(roundedRect: CGRect(x: 0.5, y: 0.5, width: w - 1, height: h - 1), cornerRadius: r) var ctx = base @@ -72,15 +74,28 @@ enum LiquidRender { p.addQuadCurve(to: CGPoint(x: edge - r * 0.3, y: h), control: CGPoint(x: edge + bulge, y: h / 2)) p.addLine(to: CGPoint(x: 0, y: h)) p.closeSubpath() - clip.fill(p, with: .linearGradient(Gradient(colors: [tint.opacity(0.84), tint.liquidDarker(0.28).opacity(0.86)]), - startPoint: CGPoint(x: 0, y: 0), endPoint: CGPoint(x: 0, y: h))) - clip.fill(Path(CGRect(x: 2, y: 1.2, width: max(0, edge - r * 0.6), height: 1)), with: .color(.white.opacity(0.12))) - for i in 0.. some View { let atOldest = weekOffset <= minWeekOffset let atNewest = weekOffset >= 0 return HStack(spacing: NoopMetrics.cardInnerSpacing) { @@ -375,7 +375,15 @@ struct TrendsView: View { Text(weekOffset == 0 ? String(localized: "This week") : weekOffsetLabel) .font(StrandFont.headline) .foregroundStyle(StrandPalette.textPrimary) + Text("\(weeklyDigestRangeLabel(digest)) · \(digest.daysWithData)/7 days") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + .lineLimit(1) + .minimumScaleFactor(0.85) + .accessibilityLabel("\(weeklyDigestRangeLabel(digest)), \(digest.daysWithData) of 7 days had data") } + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) Spacer() Button { stepWeek(1) } label: { diff --git a/Strand/Screens/WeeklyDigestView.swift b/Strand/Screens/WeeklyDigestView.swift index 4a95bff8b4..5a358343fd 100644 --- a/Strand/Screens/WeeklyDigestView.swift +++ b/Strand/Screens/WeeklyDigestView.swift @@ -115,11 +115,28 @@ struct WeeklyDigestView: View { // MARK: - Shared content +/// Compact, localized range shared by the standalone digest card and the Trends week navigator. +func weeklyDigestRangeLabel(_ digest: WeeklyDigest) -> String { + "\(weeklyDigestShortDate(digest.weekStart))–\(weeklyDigestShortDate(digest.weekEnd))" +} + +/// "Jun 8" from "2026-06-08", via the engine's own pure parse (no Calendar). +private func weeklyDigestShortDate(_ ymd: String) -> String { + guard let (_, month, day) = WeeklyDigestEngine.parseYMD(ymd) else { return ymd } + let months = [String(localized: "Jan"), String(localized: "Feb"), String(localized: "Mar"), + String(localized: "Apr"), String(localized: "May"), String(localized: "Jun"), + String(localized: "Jul"), String(localized: "Aug"), String(localized: "Sep"), + String(localized: "Oct"), String(localized: "Nov"), String(localized: "Dec")] + let name = (1...12).contains(month) ? months[month - 1] : "\(month)" + return "\(name) \(day)" +} + /// The inner content shared by the card and the full screen. `compact` trims the /// metric grid to the headline rows for the card; the full screen shows everything. struct WeeklyDigestContent: View { let digest: WeeklyDigest var compact: Bool = false + var showsHeader: Bool = true /// The Effort display scale (#268), so the Week-in-review Effort gauge matches the Today tile /// and the Trends small-multiple instead of being stuck on "of 100". Charge/Rest stay 0–100. @@ -149,7 +166,9 @@ struct WeeklyDigestContent: View { var body: some View { VStack(alignment: .leading, spacing: NoopMetrics.gap) { // Headline over a subtle scenic backdrop (Charge-tinted starfield). - header + if showsHeader { + header + } // The three headline scores, each with a domain-tinted gauge and week-over-week context. scoreRow @@ -220,9 +239,9 @@ struct WeeklyDigestContent: View { } } } else { - // Top alignment keeps labels and gauges level when only some metrics have - // enough previous-week data to render the optional comparison chip. - HStack(alignment: .top, spacing: 0) { + // Center each complete metric group against the tallest group so the labels, + // gauges, values and optional comparison chips sit naturally within the card. + HStack(alignment: .center, spacing: 0) { ForEach(Array(summaries.enumerated()), id: \.element.metric.rawValue) { index, summary in scoreCard(summary: summary, presentation: .embedded) if index < summaries.count - 1 { @@ -386,18 +405,7 @@ struct WeeklyDigestContent: View { // MARK: - Formatting private var weekRangeLabel: String { - "\(shortDate(digest.weekStart))-\(shortDate(digest.weekEnd))" - } - - /// "Jun 8" from "2026-06-08", via the engine's own pure parse (no Calendar). - private func shortDate(_ ymd: String) -> String { - guard let (_, m, d) = WeeklyDigestEngine.parseYMD(ymd) else { return ymd } - let months = [String(localized: "Jan"), String(localized: "Feb"), String(localized: "Mar"), - String(localized: "Apr"), String(localized: "May"), String(localized: "Jun"), - String(localized: "Jul"), String(localized: "Aug"), String(localized: "Sep"), - String(localized: "Oct"), String(localized: "Nov"), String(localized: "Dec")] - let name = (1...12).contains(m) ? months[m - 1] : "\(m)" - return "\(name) \(d)" + weeklyDigestRangeLabel(digest) } private func meanText(_ s: WeeklyMetricSummary, effortScale: EffortScale) -> String { @@ -583,16 +591,11 @@ private struct DigestScoreCard: View { animatedFraction: animatedFraction ) .frame(maxWidth: .infinity) - if isEmbedded && summary.thisWeek.n > 0 { + if isEmbedded && summary.thisWeek.n > 0 && summary.metric == .charge { Text(captionText) .font(StrandFont.footnote) .foregroundStyle(StrandPalette.textTertiary) .lineLimit(1) - // Trends keeps the Charge scale caption but drops the redundant caption from - // Effort and Rest. Opacity preserves the exact three-column geometry and gauge - // alignment while accessibility remains owned by the card-level summary label. - .opacity(summary.metric == .charge ? 1 : 0) - .accessibilityHidden(summary.metric != .charge) } if isEmbedded && hasComparison { TrendChip(text: deltaSigned, color: deltaTone) diff --git a/docs/UI_CUSTOMIZATION_LEDGER.md b/docs/UI_CUSTOMIZATION_LEDGER.md index e9bc7c8015..65310fcebc 100644 --- a/docs/UI_CUSTOMIZATION_LEDGER.md +++ b/docs/UI_CUSTOMIZATION_LEDGER.md @@ -216,6 +216,46 @@ feature rather than by file so they can be reviewed and ported independently. bindings, interactions, and card-level accessibility summaries. - **Type:** visual cleanup only. +### EXP-010 — Flat Home Key Metrics progress fills + +- **Date:** 2026-08-04 +- **Files:** `Strand/Liquid/LiquidPrimitives.swift`, `Strand/Liquid/LiquidTodayView.swift` +- Added a default-on `showsHighlight` presentation option to the shared `LiquidTube` renderer and disabled + it only for the Home Key Metrics tiles. +- Removed the one-point white reflection strip from the top of those filled bars while preserving each + metric's existing tint gradient and subtle depth. +- The dark track, bar height, capsule radius, spacing, fill edge, animation mode, fractions, calculations, + bindings, and every non-Key-Metrics `LiquidTube` caller remain unchanged. +- **Type:** Home Key Metrics visual cleanup only. + +### EXP-011 — Clean gradient Key Metrics progress fills + +- **Date:** 2026-08-04 +- **Files:** `Strand/Liquid/LiquidPrimitives.swift`, `Strand/Liquid/LiquidTodayView.swift` +- Added a default-off `usesCleanFill` renderer mode and enabled it only for Home Key Metrics. +- Replaced those fills with a restrained horizontal dark-to-base gradient derived from each metric's + existing tint, keeping green, blue, orange, cyan, and other metric identities unchanged. +- Suppressed all internal flecks/particles and their decorative animation in clean-fill mode; the prior + top-reflection opt-out remains enabled. +- Preserved the dark track, height, capsule radius, dimensions, spacing, fill fraction, calculations, + bindings, layout, and existing progress update behavior. Other `LiquidTube` callers retain their defaults. +- **Type:** Home Key Metrics visual cleanup only. + +### EXP-012 — Compact Trends weekly summary + +- **Date:** 2026-08-04 +- **Files:** `Strand/Screens/TrendsView.swift`, `Strand/Screens/WeeklyDigestView.swift` +- Removed the standalone Week in Review date surface only from the Trends embedding and moved its + localized date range plus days-with-data count beneath the centered selected-week title. +- Kept both week-navigation arrows and the existing selected-week digest source, offset binding, + range boundaries, button actions, empty-week handling, and accessibility descriptions. +- Vertically centered the visible Charge, Effort, and Rest metric groups independently within the + compact shared row. Removed the invisible caption and comparison placeholders that made Effort and + Rest appear top-heavy, without increasing the card height. Preserved the three columns, dividers, + gauges, values, animations, calculations, and bindings. +- The Today embedding and full Week in Review screen retain their existing header by default. +- **Type:** Intentional Trends layout cleanup only. + ## Verification history | Date | Scope | Result | @@ -227,6 +267,9 @@ feature rather than by file so they can be reviewed and ported independently. | 2026-08-04 | Native Liquid Glass Home header buttons | `NOOPiOS` Debug physical-device build succeeded; installed and launched on the connected iPhone with `com.liammazuz.noop` and team `P2874N8GRQ`. Existing unrelated compiler warnings remained. | | 2026-08-04 | Refined Sleep Performance night scene | `NOOPiOS` Debug physical-device build succeeded; installed and launched on the connected iPhone. Source audit confirmed the score source, progress fraction, animation, labels, source badge, accessibility value, and interactions were unchanged. | | 2026-08-04 | Trends weekly-summary cleanup | `NOOPiOS` Debug physical-device build succeeded; installed and launched on the connected iPhone. `git diff --check` passed; existing unrelated compiler warnings remained. | +| 2026-08-04 | Flat Home Key Metrics progress fills | `NOOPiOS` Debug physical-device build succeeded; installed and launched on the connected iPhone. `git diff --check` passed; existing unrelated compiler warnings remained. | +| 2026-08-04 | Clean gradient Key Metrics progress fills | `NOOPiOS` Debug physical-device build succeeded; installed and launched on the connected iPhone. `git diff --check` passed; existing unrelated compiler warnings remained. | +| 2026-08-04 | Compact Trends weekly summary | `NOOPiOS` Debug physical-device build succeeded; installed and launched on the connected iPhone using `com.liammazuz.noop`. Source verification confirmed the date range and day count use the selected digest and the existing week-navigation actions remain unchanged; `git diff --check` passed. Existing unrelated compiler warnings remained. | ## Required workflow for every future custom UI change From f3b6cb452e87fd2de1262f8f7e55a5e9a862dd87 Mon Sep 17 00:00:00 2001 From: liaolo <99437277+liaolo@users.noreply.github.com> Date: Tue, 4 Aug 2026 10:56:07 +0300 Subject: [PATCH 05/13] Document experimental UI audit --- docs/UI_CUSTOMIZATION_LEDGER.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/UI_CUSTOMIZATION_LEDGER.md b/docs/UI_CUSTOMIZATION_LEDGER.md index 65310fcebc..790bf67f1d 100644 --- a/docs/UI_CUSTOMIZATION_LEDGER.md +++ b/docs/UI_CUSTOMIZATION_LEDGER.md @@ -12,7 +12,9 @@ larger layout/UX experiments on `ui-experimental`, so either layer can be review - Stable UI commits: - `b96b0661` — `Apply custom NOOP UI` - `18db457f` — `Refine NOOP visual design system` -- Experimental edits after `18db457f` are currently intentionally uncommitted. +- Experimental commits after `18db457f`: + - `c0f45441` — `Develop experimental NOOP UI` + - `bd94c5ee` — `Refine Trends and metric visuals` - Before merging, refresh the upstream reference and repeat the audit against Ryan's then-current main. ## Non-negotiable merge boundaries @@ -270,6 +272,7 @@ feature rather than by file so they can be reviewed and ported independently. | 2026-08-04 | Flat Home Key Metrics progress fills | `NOOPiOS` Debug physical-device build succeeded; installed and launched on the connected iPhone. `git diff --check` passed; existing unrelated compiler warnings remained. | | 2026-08-04 | Clean gradient Key Metrics progress fills | `NOOPiOS` Debug physical-device build succeeded; installed and launched on the connected iPhone. `git diff --check` passed; existing unrelated compiler warnings remained. | | 2026-08-04 | Compact Trends weekly summary | `NOOPiOS` Debug physical-device build succeeded; installed and launched on the connected iPhone using `com.liammazuz.noop`. Source verification confirmed the date range and day count use the selected digest and the existing week-navigation actions remain unchanged; `git diff --check` passed. Existing unrelated compiler warnings remained. | +| 2026-08-04 | Pre-PR audit against Ryan main `3b86b6ef` | Branch is 4 commits ahead and 0 behind. Full 41-file diff audited; no BLE, protocol, scoring, sleep-calculation, persistence, networking, HealthKit, data-model, permission, entitlement, or build-configuration changes found. Changes are limited to presentation, approved layout, sync-status placement, and native tab-bar chrome/reselection forwarding. | ## Required workflow for every future custom UI change From 45fdb6822877d0d0f2528105f64822b78e96cf00 Mon Sep 17 00:00:00 2001 From: liaolo <99437277+liaolo@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:02:55 +0300 Subject: [PATCH 06/13] Refine live heart rate dashboard card --- Strand/Liquid/LiquidTodayView.swift | 115 ++++++++++++++++++++-------- docs/UI_CUSTOMIZATION_LEDGER.md | 28 +++++++ 2 files changed, 111 insertions(+), 32 deletions(-) diff --git a/Strand/Liquid/LiquidTodayView.swift b/Strand/Liquid/LiquidTodayView.swift index 8e57509449..f5b0e14ede 100644 --- a/Strand/Liquid/LiquidTodayView.swift +++ b/Strand/Liquid/LiquidTodayView.swift @@ -546,10 +546,7 @@ struct LiquidTodayView: View { VStack(spacing: 8) { sectionHead("HEART RATE", trailing: "Live") // #979: the whole-day HR trend (Deep Timeline) still exists but was buried behind Metrics → - // Show all → Deep Timeline. Make the live HR card a one-tap route into it, with a visible - // "Full day" affordance so it's discoverable again. (This comment used to claim the Deep - // Timeline already drew sleep + activity bands — it didn't at the time; the #979 spin-off - // added that parity in FullDayChartView.) + // Show all → Deep Timeline. The whole live HR card remains a one-tap route into it. NavigationLink(value: TabRoute.fullDayChart) { card { VStack(spacing: 10) { @@ -557,12 +554,6 @@ struct LiquidTodayView: View { // this card, never the whole Today. Shows the current bpm live with a rolling // beat-by-beat trace; falls back to today's banked 5-minute trace when idle. LiquidLiveHR(tint: liquidHeart, fallback: hrValues, animated: dataLoaded) - HStack(spacing: 4) { - Spacer() - Text("Full day").font(StrandFont.caption).foregroundStyle(StrandPalette.accent) - Image(systemName: "chevron.right").font(.system(size: 10, weight: .semibold)) - .foregroundStyle(StrandPalette.accent) - } } } } @@ -863,10 +854,9 @@ struct LiquidTodayView: View { } } NavigationLink(value: TabRoute.metricExplorer) { - Text("Show all metrics").font(StrandFont.subhead).foregroundStyle(StrandPalette.accent) - .frame(maxWidth: .infinity).padding(.top, 2) + LiquidFullWidthNavigationAction("Show all metrics") } - .buttonStyle(.plain) + .buttonStyle(LiquidPressStyle()) } } @@ -1625,6 +1615,36 @@ private struct LiquidAddButton: View { } } +/// Shared quiet, full-width navigation affordance used for a secondary dashboard destination. +/// The containing NavigationLink owns the destination and pressed interaction; this view owns one +/// consistent token-based surface, typography, geometry, and trailing chevron. +private struct LiquidFullWidthNavigationAction: View { + let title: LocalizedStringKey + + init(_ title: LocalizedStringKey) { + self.title = title + } + + var body: some View { + HStack(spacing: NoopButtonMetrics.iconSpacing) { + Text(title) + .font(StrandFont.subhead.weight(.semibold)) + Spacer(minLength: NoopMetrics.space2) + Image(systemName: "chevron.right") + .font(.system(size: 11, weight: .semibold)) + .accessibilityHidden(true) + } + .foregroundStyle(StrandPalette.accent) + .padding(.horizontal, NoopButtonMetrics.hPadding) + .frame(maxWidth: .infinity) + .frame(height: NoopButtonMetrics.height) + .frame(minHeight: NoopButtonMetrics.minHitTarget) + .contentShape(Rectangle()) + .background(NoopPanelSurface(cornerRadius: NoopButtonMetrics.cornerRadius)) + .clipShape(RoundedRectangle(cornerRadius: NoopButtonMetrics.cornerRadius, style: .continuous)) + } +} + /// The live heart-rate readout leaf. Owns LiveState so the ~1 Hz HR notifies re-render ONLY this card, /// never the whole Today (the isolation the classic Today depends on). Keeps its own rolling buffer of /// live samples, shows the current bpm live with a beat-by-beat trace, and falls back to today's banked @@ -1646,28 +1666,25 @@ private struct LiquidLiveHR: View { if let last = fallback.last { return Int(last.rounded()) } return nil } - private var subtitle: String { - if isLive { return String(localized: "Live · beat by beat") } - if fallback.count >= 2 { return String(localized: "5-minute average · since midnight") } - return live.connected ? String(localized: "Waiting for the strap") : String(localized: "Strap not connected") - } - var body: some View { VStack(alignment: .leading, spacing: 10) { - HStack(alignment: .firstTextBaseline) { - VStack(alignment: .leading, spacing: 2) { - Text("BEATS PER MINUTE").font(StrandFont.overline).tracking(1.6) - .foregroundStyle(StrandPalette.textSecondary) - Text(subtitle).font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary) - } - Spacer() + HStack(alignment: .center, spacing: NoopMetrics.space2) { + Text("BEATS PER MINUTE") + .font(StrandFont.overline) + .tracking(1.6) + .foregroundStyle(StrandPalette.textSecondary) + .lineLimit(1) + .minimumScaleFactor(0.75) + Spacer(minLength: NoopMetrics.space2) if isLive { - // A gentle heartbeat dot that pulses with each incoming sample. - Circle().fill(tint).frame(width: 7, height: 7) - .scaleEffect(beat ? 1.35 : 0.85) - .opacity(beat ? 1 : 0.45) + // Reuses the existing incoming-HR event pulse; no timer or continuous redraw loop. + Image(systemName: "heart.fill") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(tint) + .scaleEffect(beat ? 1.18 : 0.88) + .opacity(beat ? 1 : 0.62) .animation(.easeOut(duration: 0.28), value: beat) - .padding(.trailing, 2) + .accessibilityHidden(true) } if let hr = bigBpm { (Text("\(hr)").font(StrandFont.rounded(22)).monospacedDigit() @@ -1678,7 +1695,12 @@ private struct LiquidLiveHR: View { } } if series.count >= 2 { - LiquidThread(bpm: series, tint: tint, height: 92, animated: animated) + ZStack { + LiquidHeartRateGrid() + LiquidThread(bpm: series, tint: tint, height: 92, animated: animated) + } + .frame(height: 92) + .clipShape(RoundedRectangle(cornerRadius: NoopMetrics.space2, style: .continuous)) HStack { stat(String(localized: "Min"), series.min()) Spacer() @@ -1712,6 +1734,35 @@ private struct LiquidLiveHR: View { } } +/// Static technical grid behind the live trace. Canvas draws only when layout/style changes, so the +/// incoming heart-rate samples remain the card's sole animation driver. +private struct LiquidHeartRateGrid: View { + var body: some View { + Canvas { context, size in + var path = Path() + let columns = 8 + let rows = 4 + + for column in 1.. Date: Tue, 4 Aug 2026 15:48:47 +0300 Subject: [PATCH 07/13] Refine workout and health UI interactions --- .../Sources/StrandDesign/StrainGauge.swift | 5 +- Strand/Liquid/LiquidTodayView.swift | 38 ++- Strand/Resources/Localizable.xcstrings | 64 +++++ Strand/Screens/LiveWorkoutView.swift | 237 ++++++++++++------ Strand/Screens/TrendsView.swift | 6 +- Strand/Screens/WeeklyDigestView.swift | 2 +- Strand/Screens/WorkoutsView.swift | 76 +++--- docs/UI_CUSTOMIZATION_LEDGER.md | 79 +++++- 8 files changed, 367 insertions(+), 140 deletions(-) diff --git a/Packages/StrandDesign/Sources/StrandDesign/StrainGauge.swift b/Packages/StrandDesign/Sources/StrandDesign/StrainGauge.swift index 588a16e2f5..59ce9aa9c3 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/StrainGauge.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/StrainGauge.swift @@ -60,8 +60,8 @@ public struct StrainGauge: View { /// A short load word for the strain value, mirroring the recovery state idea. Computed off the /// fraction (not the raw value) so the bands read the same on the 0–100 and 0–21 display scales. - private var strainWord: String { - switch fraction { + public static func stateLabel(forFraction fraction: Double) -> String { + switch min(max(fraction, 0), 1) { case ..<(6.0 / 21): return String(localized: "LIGHT", bundle: .module) case ..<(10.0 / 21): return String(localized: "MODERATE", bundle: .module) case ..<(14.0 / 21): return String(localized: "STRENUOUS", bundle: .module) @@ -69,6 +69,7 @@ public struct StrainGauge: View { default: return String(localized: "ALL-OUT", bundle: .module) } } + private var strainWord: String { Self.stateLabel(forFraction: fraction) } // The 240° open-gauge geometry + bloom now live in the shared `BevelGauge`. @State private var animatedFraction: Double = 0 diff --git a/Strand/Liquid/LiquidTodayView.swift b/Strand/Liquid/LiquidTodayView.swift index f5b0e14ede..187d57d312 100644 --- a/Strand/Liquid/LiquidTodayView.swift +++ b/Strand/Liquid/LiquidTodayView.swift @@ -549,12 +549,10 @@ struct LiquidTodayView: View { // Show all → Deep Timeline. The whole live HR card remains a one-tap route into it. NavigationLink(value: TabRoute.fullDayChart) { card { - VStack(spacing: 10) { - // Isolated leaf: it observes LiveState so the ~1 Hz HR notifies re-render ONLY - // this card, never the whole Today. Shows the current bpm live with a rolling - // beat-by-beat trace; falls back to today's banked 5-minute trace when idle. - LiquidLiveHR(tint: liquidHeart, fallback: hrValues, animated: dataLoaded) - } + // Isolated leaf: it observes LiveState so the ~1 Hz HR notifies re-render ONLY + // this card, never the whole Today. Shows the current bpm live with a rolling + // beat-by-beat trace; falls back to today's banked 5-minute trace when idle. + LiquidLiveHR(tint: liquidHeart, fallback: hrValues, animated: dataLoaded) } } .buttonStyle(LiquidPressStyle()) @@ -1666,15 +1664,28 @@ private struct LiquidLiveHR: View { if let last = fallback.last { return Int(last.rounded()) } return nil } + private var subtitle: String { + if isLive { return String(localized: "Live · beat by beat") } + if fallback.count >= 2 { return String(localized: "5-minute average · since midnight") } + return live.connected ? String(localized: "Waiting for the strap") : String(localized: "Strap not connected") + } var body: some View { VStack(alignment: .leading, spacing: 10) { HStack(alignment: .center, spacing: NoopMetrics.space2) { - Text("BEATS PER MINUTE") - .font(StrandFont.overline) - .tracking(1.6) - .foregroundStyle(StrandPalette.textSecondary) - .lineLimit(1) - .minimumScaleFactor(0.75) + HStack(spacing: NoopMetrics.space1) { + Text("BEATS PER MINUTE") + .font(StrandFont.overline) + .tracking(1.6) + .foregroundStyle(StrandPalette.textSecondary) + .lineLimit(1) + .minimumScaleFactor(0.65) + Image(systemName: "chevron.right") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(tint) + .fixedSize() + .accessibilityHidden(true) + } + .layoutPriority(1) Spacer(minLength: NoopMetrics.space2) if isLive { // Reuses the existing incoming-HR event pulse; no timer or continuous redraw loop. @@ -1694,6 +1705,9 @@ private struct LiquidLiveHR: View { .animation(.easeOut(duration: 0.25), value: hr) } } + Text(subtitle) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) if series.count >= 2 { ZStack { LiquidHeartRateGrid() diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index e3f2f9f375..7d09d89091 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -9003,6 +9003,64 @@ } } }, + "%lld of 7 days had data": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Für %lld von 7 Tagen sind Daten vorhanden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld of 7 days had data" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%lld de 7 días tuvieron datos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%lld jours sur 7 contenaient des données" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "%lld giorni su 7 contenevano dati" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "%lld de 7 dias tinham dados" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%lld из 7 дней содержали данные" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "7 天中有 %lld 天有数据" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "7 天中有 %lld 天有資料" + } + } + } + }, "%lld of 7 days had data this week": { "localizations": { "de": { @@ -11602,6 +11660,12 @@ "value": "%lld/7 Tage" } }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld/7 days" + } + }, "es": { "stringUnit": { "state": "translated", diff --git a/Strand/Screens/LiveWorkoutView.swift b/Strand/Screens/LiveWorkoutView.swift index 9a969c608e..09f535438e 100644 --- a/Strand/Screens/LiveWorkoutView.swift +++ b/Strand/Screens/LiveWorkoutView.swift @@ -48,7 +48,6 @@ struct LiveWorkoutView: View { AnyView(header), AnyView(heroHeartRate), AnyView(effortGauge), - AnyView(zoneRail), AnyView(statsGrid), ] ForEach(Array(cards.enumerated()), id: \.offset) { index, card in @@ -107,46 +106,79 @@ struct LiveWorkoutView: View { private var header: some View { HStack(alignment: .center) { - VStack(alignment: .leading, spacing: 2) { + Text("Workout") + .font(StrandFont.title1) + .foregroundStyle(StrandPalette.textPrimary) + Spacer() + HStack(spacing: NoopMetrics.space1) { + Circle() + .fill(StrandPalette.metricRose) + .frame(width: 7, height: 7) Text("RECORDING WORKOUT") .font(StrandFont.overline).tracking(StrandFont.overlineTracking) .foregroundStyle(StrandPalette.metricRose) - Text("Workout") - .font(StrandFont.title1).foregroundStyle(StrandPalette.textPrimary) - } - Spacer() - if let start = model.activeWorkout?.start { - TimelineView(.periodic(from: .now, by: 1)) { _ in - Text(Self.elapsed(since: start)) - .font(StrandFont.number(34)).monospacedDigit() - .foregroundStyle(StrandPalette.textPrimary) - } } + .padding(.horizontal, NoopMetrics.space2) + .padding(.vertical, NoopMetrics.space1) + .background(NoopPanelSurface(tint: StrandPalette.metricRose, cornerRadius: 14)) + .clipShape(Capsule()) } } private var heroHeartRate: some View { let tint = zone >= 1 ? StrandPalette.hrZoneColor(zone) : StrandPalette.effortColor return NoopCard(padding: NoopMetrics.space6, tint: StrandPalette.effortColor) { - VStack(spacing: NoopMetrics.space2) { - Text("HEART RATE") - .font(StrandFont.overline).tracking(StrandFont.overlineTracking) - .foregroundStyle(StrandPalette.textSecondary) - // The big live HR ticks up to its new reading on each beat — crisp, flat, no halo. - if let bpm = model.bpm { - CountUpText(value: Double(bpm), - format: { "\(Int($0.rounded()))" }, - font: StrandFont.rounded(80, weight: .semibold), - color: tint) - } else { - Text("—") - .font(StrandFont.rounded(80, weight: .semibold)) + VStack(spacing: NoopMetrics.space5) { + if let start = model.activeWorkout?.start { + VStack(spacing: NoopMetrics.space1) { + Text("TIME") + .font(StrandFont.overline).tracking(StrandFont.overlineTracking) + .foregroundStyle(StrandPalette.textSecondary) + TimelineView(.periodic(from: .now, by: 1)) { _ in + Text(Self.elapsed(since: start)) + .font(StrandFont.number(48)).monospacedDigit() + .foregroundStyle(StrandPalette.textPrimary) + .contentTransition(.numericText()) + } + } + } + + Rectangle() + .fill(StrandPalette.hairline) + .frame(height: 1) + + HStack(alignment: .center, spacing: NoopMetrics.space4) { + VStack(alignment: .leading, spacing: NoopMetrics.space1) { + Text("HEART RATE") + .font(StrandFont.overline).tracking(StrandFont.overlineTracking) + .foregroundStyle(StrandPalette.textSecondary) + HStack(alignment: .firstTextBaseline, spacing: NoopMetrics.space1) { + if let bpm = model.bpm { + CountUpText(value: Double(bpm), + format: { "\(Int($0.rounded()))" }, + font: StrandFont.rounded(72, weight: .semibold), + color: tint) + } else { + Text("—") + .font(StrandFont.rounded(72, weight: .semibold)) + .foregroundStyle(tint) + } + Text("bpm") + .font(StrandFont.subhead) + .foregroundStyle(StrandPalette.textSecondary) + } + } + Spacer(minLength: 0) + Text(zone >= 1 ? "Zone \(zone) · \(Self.zoneName(zone))" : "Below Zone 1") + .font(StrandFont.captionNumber) .foregroundStyle(tint) + .multilineTextAlignment(.trailing) + .padding(.horizontal, NoopMetrics.space2) + .padding(.vertical, NoopMetrics.space1) + .background(tint.opacity(0.12), in: Capsule()) } - Text("bpm").font(StrandFont.subhead).foregroundStyle(StrandPalette.textSecondary) - Text(zone >= 1 ? "Zone \(zone) · \(Self.zoneName(zone))" : "Below Zone 1") - .font(StrandFont.captionNumber) - .foregroundStyle(tint) + + zoneRail } .frame(maxWidth: .infinity) } @@ -158,18 +190,46 @@ struct LiveWorkoutView: View { /// read-outs (mirrors TodayView's effort hero). Display-only — the captured value stays 0–100. private var effortGauge: some View { let strain = model.activeWorkout?.liveStrain ?? 0 + let displayEffort = UnitFormatter.effortValue(strain, scale: effortScale) + let maxValue = effortScale == .whoop ? 21.0 : 100.0 + let maxLabel = UnitFormatter.effortScaleMax(effortScale) + let fraction = min(max(displayEffort / maxValue, 0), 1) return NoopCard(padding: NoopMetrics.cardInnerPadding, tint: StrandPalette.effortColor) { - VStack(spacing: NoopMetrics.rowSpacing) { - Text("EFFORT BUILDING") - .font(StrandFont.overline).tracking(StrandFont.overlineTracking) - .foregroundStyle(StrandPalette.effortColor) - StrainGauge( - strain: UnitFormatter.effortValue(strain, scale: effortScale), - outOf: effortScale == .whoop ? 21 : 100, - diameter: 150, lineWidth: 14, showsHover: false, - valueFormat: { _ in UnitFormatter.effortDisplay(strain, scale: effortScale) } - ) - .frame(maxWidth: .infinity) + HStack(spacing: NoopMetrics.space5) { + VStack(alignment: .leading, spacing: NoopMetrics.space2) { + Image(systemName: "bolt.fill") + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(StrandPalette.effortColor) + Text("EFFORT BUILDING") + .font(StrandFont.overline).tracking(StrandFont.overlineTracking) + .foregroundStyle(StrandPalette.effortColor) + Text(StrainGauge.stateLabel(forFraction: fraction)) + .font(StrandFont.captionNumber) + .foregroundStyle(StrandPalette.textSecondary) + } + Spacer(minLength: 0) + ZStack { + LiquidVessel(value: fraction, tint: StrandPalette.effortColor, animated: true) + VStack(spacing: 1) { + CountUpText(value: displayEffort, + format: { value in + effortScale == .whoop + ? String(format: "%.1f", value) + : "\(Int(value.rounded()))" + }, + font: StrandFont.rounded(30, weight: .semibold), + color: .white) + Text(String(localized: "of \(maxLabel)")) + .font(StrandFont.footnote) + .foregroundStyle(.white.opacity(0.72)) + } + .shadow(color: .black.opacity(0.5), radius: 6, y: 1) + .allowsHitTesting(false) + } + .frame(width: 124, height: 124) + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(UnitFormatter.effortDisplay(strain, scale: effortScale))) + .accessibilityValue(Text(StrainGauge.stateLabel(forFraction: fraction))) } .frame(maxWidth: .infinity) } @@ -177,9 +237,12 @@ struct LiveWorkoutView: View { private var zoneRail: some View { VStack(alignment: .leading, spacing: 8) { - Text("HR ZONE") - .font(StrandFont.overline).tracking(StrandFont.overlineTracking) - .foregroundStyle(StrandPalette.textSecondary) + HStack { + Text("HR ZONE") + .font(StrandFont.overline).tracking(StrandFont.overlineTracking) + .foregroundStyle(StrandPalette.textSecondary) + Spacer() + } HStack(spacing: 6) { ForEach(1...5, id: \.self) { z in let active = z == zone @@ -210,29 +273,37 @@ struct LiveWorkoutView: View { private var statsGrid: some View { let w = model.activeWorkout - return HStack(spacing: NoopMetrics.gap) { - stat(String(localized: "AVG"), (w?.avgHr ?? 0) > 0 ? "\(w!.avgHr)" : "—", - tint: (w?.avgHr ?? 0) > 0 ? StrandPalette.metricRose : StrandPalette.textPrimary) - stat(String(localized: "PEAK"), (w?.peakHr ?? 0) > 0 ? "\(w!.peakHr)" : "—", - tint: (w?.peakHr ?? 0) > 0 ? StrandPalette.metricRose : StrandPalette.textPrimary) - stat(String(localized: "EFFORT"), UnitFormatter.effortDisplay(w?.liveStrain ?? 0, scale: effortScale), - tint: StrandPalette.strainColor(w?.liveStrain ?? 0)) + return NoopCard(padding: NoopMetrics.cardInnerPadding) { + HStack(spacing: 0) { + stat(String(localized: "AVG"), (w?.avgHr ?? 0) > 0 ? "\(w!.avgHr)" : "—", + tint: (w?.avgHr ?? 0) > 0 ? StrandPalette.metricRose : StrandPalette.textPrimary) + statDivider + stat(String(localized: "PEAK"), (w?.peakHr ?? 0) > 0 ? "\(w!.peakHr)" : "—", + tint: (w?.peakHr ?? 0) > 0 ? StrandPalette.metricRose : StrandPalette.textPrimary) + statDivider + stat(String(localized: "EFFORT"), UnitFormatter.effortDisplay(w?.liveStrain ?? 0, scale: effortScale), + tint: StrandPalette.strainColor(w?.liveStrain ?? 0)) + } } } private func stat(_ title: String, _ value: String, tint: Color = StrandPalette.textPrimary) -> some View { - NoopCard(padding: 14, tint: tint) { - VStack(alignment: .leading, spacing: 6) { - Text(title) - .font(StrandFont.overline).tracking(StrandFont.overlineTracking) - .foregroundStyle(StrandPalette.textSecondary) - Text(value) - .font(StrandFont.number(26)) - .foregroundStyle(tint) - .lineLimit(1).minimumScaleFactor(0.6) - } - .frame(maxWidth: .infinity, alignment: .leading) + VStack(spacing: NoopMetrics.space1) { + Text(title) + .font(StrandFont.overline).tracking(StrandFont.overlineTracking) + .foregroundStyle(StrandPalette.textSecondary) + Text(value) + .font(StrandFont.number(28)) + .foregroundStyle(tint) + .lineLimit(1).minimumScaleFactor(0.6) } + .frame(maxWidth: .infinity) + } + + private var statDivider: some View { + Rectangle() + .fill(StrandPalette.hairline) + .frame(width: 1, height: 48) } private var endButton: some View { @@ -264,7 +335,7 @@ struct LiveWorkoutView: View { /// Additive readout for a connected standard fitness sensor (a footpod / bike speed-cadence sensor / /// power meter) feeding RSC/CSC/CPS ALONGSIDE heart rate. Only the fields the sensor actually sent -/// render — each tile is dropped when its value is absent, and the WHOLE block (row + entrance stagger) +/// render — each metric is dropped when its value is absent, and the WHOLE block (panel + entrance stagger) /// is hidden when nothing is present (`live.hasSensorMetrics`), so a plain HR-only workout looks exactly /// as before. Honest units: speed km/h, cadence per-minute (steps for running / rpm for cycling), power /// watts. Tinted with the Effort world so it reads as part of the hero, not a competing accent. Nothing @@ -282,34 +353,34 @@ private struct SensorRowIfPresent: View { let speed = LiveState.formatSpeedKmh(live.sensorSpeedKmh) let cadence = LiveState.formatCadence(live.sensorCadence) let power = LiveState.formatPowerWatts(live.sensorPowerWatts) - VStack(alignment: .leading, spacing: 8) { - Text("SENSOR") - .font(StrandFont.overline).tracking(StrandFont.overlineTracking) - .foregroundStyle(StrandPalette.textSecondary) - HStack(spacing: NoopMetrics.gap) { - if let speed { stat(String(localized: "SPEED"), "\(speed) km/h", tint: StrandPalette.effortColor) } - if let cadence { stat(String(localized: "CADENCE"), "\(cadence)/min", tint: StrandPalette.effortColor) } - if let power { stat(String(localized: "POWER"), "\(power) W", tint: StrandPalette.effortColor) } + NoopCard(padding: NoopMetrics.cardInnerPadding, tint: StrandPalette.effortColor) { + VStack(alignment: .leading, spacing: NoopMetrics.space3) { + Text("SENSOR") + .font(StrandFont.overline).tracking(StrandFont.overlineTracking) + .foregroundStyle(StrandPalette.textSecondary) + HStack(spacing: NoopMetrics.gap) { + if let speed { stat(String(localized: "SPEED"), "\(speed) km/h", tint: StrandPalette.effortColor) } + if let cadence { stat(String(localized: "CADENCE"), "\(cadence)/min", tint: StrandPalette.effortColor) } + if let power { stat(String(localized: "POWER"), "\(power) W", tint: StrandPalette.effortColor) } + } } } .staggeredAppear(index: 5) } } - /// Same metric tile as `LiveWorkoutView.stat` (the HR stats grid) — duplicated here, unchanged, so the - /// leaf is self-contained and the rendered tile is identical. + /// Compact sensor value used inside this leaf's shared panel, keeping its high-frequency updates + /// isolated from the rest of the workout screen. private func stat(_ title: String, _ value: String, tint: Color = StrandPalette.textPrimary) -> some View { - NoopCard(padding: 14, tint: tint) { - VStack(alignment: .leading, spacing: 6) { - Text(title) - .font(StrandFont.overline).tracking(StrandFont.overlineTracking) - .foregroundStyle(StrandPalette.textSecondary) - Text(value) - .font(StrandFont.number(26)) - .foregroundStyle(tint) - .lineLimit(1).minimumScaleFactor(0.6) - } - .frame(maxWidth: .infinity, alignment: .leading) + VStack(alignment: .leading, spacing: NoopMetrics.space1) { + Text(title) + .font(StrandFont.overline).tracking(StrandFont.overlineTracking) + .foregroundStyle(StrandPalette.textSecondary) + Text(value) + .font(StrandFont.number(26)) + .foregroundStyle(tint) + .lineLimit(1).minimumScaleFactor(0.6) } + .frame(maxWidth: .infinity, alignment: .leading) } } diff --git a/Strand/Screens/TrendsView.swift b/Strand/Screens/TrendsView.swift index 42b716d0e2..3a29b63fe2 100644 --- a/Strand/Screens/TrendsView.swift +++ b/Strand/Screens/TrendsView.swift @@ -361,6 +361,8 @@ struct TrendsView: View { private func weekNavBar(digest: WeeklyDigest) -> some View { let atOldest = weekOffset <= minWeekOffset let atNewest = weekOffset >= 0 + let daysSummary = String(localized: "\(digest.daysWithData)/7 days") + let daysAccessibility = String(localized: "\(digest.daysWithData) of 7 days had data") return HStack(spacing: NoopMetrics.cardInnerSpacing) { Button { stepWeek(-1) } label: { Image(systemName: "chevron.left").font(StrandFont.headline.weight(.semibold)) @@ -375,12 +377,12 @@ struct TrendsView: View { Text(weekOffset == 0 ? String(localized: "This week") : weekOffsetLabel) .font(StrandFont.headline) .foregroundStyle(StrandPalette.textPrimary) - Text("\(weeklyDigestRangeLabel(digest)) · \(digest.daysWithData)/7 days") + Text("\(weeklyDigestRangeLabel(digest)) · \(daysSummary)") .font(StrandFont.footnote) .foregroundStyle(StrandPalette.textSecondary) .lineLimit(1) .minimumScaleFactor(0.85) - .accessibilityLabel("\(weeklyDigestRangeLabel(digest)), \(digest.daysWithData) of 7 days had data") + .accessibilityLabel("\(weeklyDigestRangeLabel(digest)), \(daysAccessibility)") } .multilineTextAlignment(.center) .frame(maxWidth: .infinity) diff --git a/Strand/Screens/WeeklyDigestView.swift b/Strand/Screens/WeeklyDigestView.swift index 5a358343fd..d7d157e2ac 100644 --- a/Strand/Screens/WeeklyDigestView.swift +++ b/Strand/Screens/WeeklyDigestView.swift @@ -591,7 +591,7 @@ private struct DigestScoreCard: View { animatedFraction: animatedFraction ) .frame(maxWidth: .infinity) - if isEmbedded && summary.thisWeek.n > 0 && summary.metric == .charge { + if isEmbedded && summary.thisWeek.n > 0 { Text(captionText) .font(StrandFont.footnote) .foregroundStyle(StrandPalette.textTertiary) diff --git a/Strand/Screens/WorkoutsView.swift b/Strand/Screens/WorkoutsView.swift index 6fcddb0176..c8d9ac41d3 100644 --- a/Strand/Screens/WorkoutsView.swift +++ b/Strand/Screens/WorkoutsView.swift @@ -151,7 +151,7 @@ struct WorkoutsView: View { ? "No workouts yet. They come from your WHOOP and Apple Health history. Import in Data Sources to bring them in, or add one you tracked elsewhere." : "Loading your sessions…") if loaded { - HStack(spacing: NoopMetrics.rowSpacing) { startLiveWorkoutButton; addWorkoutButton } + workoutActionRow } } } else { @@ -166,7 +166,7 @@ struct WorkoutsView: View { let groups = sportGroups(from: windowRows) let zonesSummary = WorkoutZones.summary(from: windowRows) - HStack { startLiveWorkoutButton; Spacer() } + workoutActionRow rangeBar(rows: windowRows, effectiveRange: resolved) if let postLogNote { postLogBanner(postLogNote) } effortHero(rows: windowRows, effectiveRange: resolved, groups: groups) @@ -437,24 +437,13 @@ struct WorkoutsView: View { private func rangeBar(rows: [WorkoutRow], effectiveRange: Range) -> some View { let fellBack = effectiveRange != range let caption = rangeCaption(rows: rows, effectiveRange: effectiveRange, fellBack: fellBack) - #if os(iOS) - let stacked = hSizeClass == .compact - #else - let stacked = false - #endif return VStack(alignment: .leading, spacing: 8) { - if stacked { - // iPhone: button on its own row, the range pill full-width below — no crushed sliver. - addWorkoutButton - SegmentedPillControl(Range.allCases, selection: $range) { $0.label } - .frame(maxWidth: .infinity, alignment: .leading) - } else { - HStack(spacing: 12) { - addWorkoutButton - Spacer() - SegmentedPillControl(Range.allCases, selection: $range) { $0.label } - } - } + SegmentedPillControl( + Range.allCases, + selection: $range, + fillsAvailableWidth: true + ) { $0.label } + .frame(maxWidth: .infinity, alignment: .leading) filterBar Text(caption) .font(StrandFont.footnote) @@ -481,6 +470,7 @@ struct WorkoutsView: View { Button(s) { sportFilter = s } } } + .frame(maxWidth: .infinity) filterMenu( title: sourceFilter.map(Self.sourceFilterLabel) ?? String(localized: "All sources"), active: sourceFilter != nil, @@ -492,19 +482,7 @@ struct WorkoutsView: View { Button(Self.sourceFilterLabel(opt)) { sourceFilter = opt } } } - if filter.isActive { - Button { - withAnimation(.easeOut(duration: 0.15)) { - sportFilter = nil; sourceFilter = nil; searchText = "" - } - } label: { - Label(String(localized: "Clear"), systemImage: "xmark.circle.fill") - .font(StrandFont.footnote) - .foregroundStyle(StrandPalette.textSecondary) - } - .accessibilityLabel(String(localized: "Clear filters")) - } - Spacer(minLength: 0) + .frame(maxWidth: .infinity) } HStack(spacing: 6) { Image(systemName: "magnifyingglass") @@ -527,6 +505,18 @@ struct WorkoutsView: View { } .accessibilityLabel(String(localized: "Clear search")) } + if filter.isActive { + Button { + withAnimation(.easeOut(duration: 0.15)) { + sportFilter = nil; sourceFilter = nil; searchText = "" + } + } label: { + Label(String(localized: "Clear"), systemImage: "xmark.circle.fill") + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + } + .accessibilityLabel(String(localized: "Clear filters")) + } } .padding(.horizontal, 10) .padding(.vertical, 7) @@ -546,16 +536,18 @@ struct WorkoutsView: View { Text(title).font(StrandFont.footnote).lineLimit(1) Image(systemName: "chevron.down").font(.system(size: 9, weight: .semibold)) } + .frame(maxWidth: .infinity) .foregroundStyle(active ? StrandPalette.effortColor : StrandPalette.textSecondary) .padding(.horizontal, 10) .padding(.vertical, 6) .background( (active ? StrandPalette.effortColor.opacity(0.14) : StrandPalette.surfaceInset.opacity(0.6)), - in: Capsule() + in: RoundedRectangle(cornerRadius: 10, style: .continuous) ) + .contentShape(Rectangle()) } .menuStyle(.borderlessButton) - .fixedSize() + .frame(maxWidth: .infinity) .accessibilityLabel(a11y) .accessibilityValue(title) } @@ -579,7 +571,7 @@ struct WorkoutsView: View { /// Opens the add sheet (editing == nil). Present on the populated screen and the empty state so a /// user with no imports can still log a session. private var addWorkoutButton: some View { - NoopButton("Add workout", systemImage: "plus", kind: .secondary) { + NoopButton("Add workout", systemImage: "plus", kind: .secondary, fullWidth: true) { sheet = WorkoutSheetTarget(editing: nil) } .accessibilityLabel("Add a workout") @@ -591,7 +583,8 @@ struct WorkoutsView: View { private var startLiveWorkoutButton: some View { NoopButton(model.activeWorkout == nil ? "Start workout" : "View active workout", systemImage: model.activeWorkout == nil ? "figure.run" : "timer", - kind: .primary) { + kind: .primary, + fullWidth: true) { // No active session → pick a named sport first (#519), then the sheet's onStart begins it // and opens the in-exercise view. Already active → jump straight back into the live view. if model.activeWorkout == nil { showStartSport = true } @@ -600,6 +593,17 @@ struct WorkoutsView: View { .accessibilityLabel(model.activeWorkout == nil ? "Start a workout" : "View the active workout") } + /// Equal-width primary actions share the same content width as every card below them. + private var workoutActionRow: some View { + HStack(spacing: NoopMetrics.rowSpacing) { + startLiveWorkoutButton + .frame(maxWidth: .infinity) + addWorkoutButton + .frame(maxWidth: .infinity) + } + .frame(maxWidth: .infinity) + } + /// The latest session start (anchors every window — windows are relative to the /// most recent session, not "now", so an old log still resolves). private var latestTs: Int? { allRows.map(\.startTs).max() } diff --git a/docs/UI_CUSTOMIZATION_LEDGER.md b/docs/UI_CUSTOMIZATION_LEDGER.md index a39a19037f..e73bb0a190 100644 --- a/docs/UI_CUSTOMIZATION_LEDGER.md +++ b/docs/UI_CUSTOMIZATION_LEDGER.md @@ -262,8 +262,9 @@ feature rather than by file so they can be reviewed and ported independently. - **Date:** 2026-08-04 - **Files:** `Strand/Liquid/LiquidTodayView.swift` -- Removed the secondary live-status subtitle and consolidated the card header into one balanced row with - the metric heading on the left and the live heart icon, BPM value, and unit on the right. +- Consolidated the card header into one balanced row with the metric heading on the left and the live + heart icon, BPM value, and unit on the right. The upstream state-dependent subtitle remains directly + beneath it for live, five-minute fallback, waiting-for-strap, and disconnected states. - Replaced the live dot with an SF Symbol heart driven by the same incoming `live.heartRate` change event, without adding a timer or continuous animation loop. - Added a static, clipped Canvas grid behind the existing trace using the shared hairline token. @@ -278,12 +279,80 @@ feature rather than by file so they can be reviewed and ported independently. - Added a reusable `LiquidFullWidthNavigationAction` presentation using the shared panel surface, button geometry tokens, typography, mint accent, trailing chevron, and accessible control height. - Replaced the standalone Show all metrics text with the full-width action surface. -- Removed the separate Full day affordance from the Heart Rate card; its existing whole-card - `NavigationLink` remains, so tapping anywhere on the card still opens the same full-day route. +- Applied the same reusable full-width action surface to Full day inside the Heart Rate card. Its existing + whole-card `NavigationLink` remains, so the visible action and the surrounding card open the same route. - Preserved both `NavigationLink` values, the existing whole-card Heart Rate interaction, Key Metrics layout, chart layout/data, calculations, bindings, and BLE behavior. - **Type:** Dashboard action presentation only. +### EXP-015 — PR review presentation restorations + +- **Date:** 2026-08-04 +- **Files:** `Strand/Liquid/LiquidTodayView.swift`, `Strand/Screens/TrendsView.swift`, + `Strand/Screens/WeeklyDigestView.swift`, `Strand/Resources/Localizable.xcstrings` +- Localized the compact Trends days-with-data text and matching accessibility description in English, + German, Spanish, and French using the existing string-catalog workflow. +- Restored the upstream Live HR subtitle states and conditions while retaining the redesigned header, + heart pulse, grid, chart, data sources, and update timing. +- Restored embedded weekly-gauge scale captions for populated Effort and Rest gauges, including Effort's + existing selected-scale denominator, without changing scores, calculations, or gauge behavior. +- Restored the visible Full day action with the same shared full-width component used by Show all metrics; + its route and the whole-card navigation behavior are unchanged. +- The Today sync indicator was deliberately left unchanged for separate review. +- **Type:** Localization and presentation-information restoration only. + +### EXP-016 — Live workout glanceable hierarchy + +- **Date:** 2026-08-04 +- **Files:** `Strand/Screens/LiveWorkoutView.swift` +- Rebuilt the live-workout presentation around two dominant readings: elapsed time and live heart rate. + The recording state is now a compact status capsule, while the selected HR zone sits beside the live + reading and the complete five-zone rail plus its exact bounds remain directly below. +- Reframed the existing Effort gauge as a compact supporting card and consolidated Avg, Peak, and Effort + into one evenly divided summary surface to reduce competing card chrome. +- Consolidated optional speed, cadence, and power values into one sensor panel while preserving the + independently observing `SensorRowIfPresent` leaf and its conditional fields. +- Preserved the workout timer source, live BPM and zone derivation, effort scale and calculation, sensor + values and units, realtime-stream lifecycle, keep-awake behavior, active-workout dismissal, End action, + destructive confirmation, save behavior, and every existing state dependency. +- **Type:** Experimental live-workout layout and presentation redesign only. + +### EXP-017 — Home-style live Effort vessel + +- **Date:** 2026-08-04 +- **Files:** `Strand/Screens/LiveWorkoutView.swift` +- Replaced only the live-workout Effort circle renderer with the same shared `LiquidVessel` visual used + by the Home Charge, Effort, and Rest hero scores, including its motion and Reduce Motion behavior. +- Preserved `ActiveWorkout.liveStrain` as the source, the selected 0–100/0–21 display conversion, exact + fill fraction, live updates, formatted value, scale denominator, card placement, and accessibility value. +- Restored the original dynamic Effort intensity label (`LIGHT` through `ALL-OUT`) using the exact shared + `StrainGauge` thresholds and translations, placing it beneath `EFFORT BUILDING`; the scale denominator + remains inside the vessel beneath the live number. +- **Type:** Effort gauge rendering only; no workout behavior or calculation changes. + +### EXP-018 — Workouts control layout + +- **Date:** 2026-08-04 +- **Files:** `Strand/Screens/WorkoutsView.swift` +- Placed Start workout and Add workout side by side as equal-width actions spanning the standard card + width, with the existing day-range selector on its own full-width row below. +- Made the existing Sport and Source filter menus equal-width controls that together span that same card + width. Search and clear-filter behavior remain available immediately below the selectors. +- Preserved every action, sheet, live-workout destination, range/filter binding, caption, and data path. +- **Type:** Experimental Workouts control layout only. + +### EXP-019 — Inline Live HR destination affordance + +- **Date:** 2026-08-04 +- **Files:** `Strand/Liquid/LiquidTodayView.swift` +- Removed the full-width Full day button from the Today live-heart-rate card and placed a compact + heart-rate-coloured chevron directly beside the localized Beats per minute heading instead. +- The heading group has higher layout priority, a fixed-size chevron, and controlled text scaling so the + arrow stays attached to longer localized text without colliding with the changing BPM value. +- Preserved the whole-card `TabRoute.fullDayChart` navigation link, destination, live subtitle states, + chart, statistics, sampling, animation, and accessibility hint. +- **Type:** Live HR affordance presentation only; destination and behavior unchanged. + ## Verification history | Date | Scope | Result | @@ -301,6 +370,8 @@ feature rather than by file so they can be reviewed and ported independently. | 2026-08-04 | Pre-PR audit against Ryan main `3b86b6ef` | Branch is 4 commits ahead and 0 behind. Full 41-file diff audited; no BLE, protocol, scoring, sleep-calculation, persistence, networking, HealthKit, data-model, permission, entitlement, or build-configuration changes found. Changes are limited to presentation, approved layout, sync-status placement, and native tab-bar chrome/reselection forwarding. | | 2026-08-04 | Live heart-rate card refinement | `NOOPiOS` Debug physical-device build succeeded; installed and launched on the connected iPhone using `com.liammazuz.noop`. Source verification confirmed the pulse reuses incoming heart-rate changes and the static grid adds no timer or continuous redraw loop; `git diff --check` passed. Existing unrelated compiler warnings remained. | | 2026-08-04 | Full-width metrics action | `NOOPiOS` Debug physical-device build succeeded; installed and launched on the connected iPhone using `com.liammazuz.noop`. Show all metrics uses the reusable full-width surface; the Heart Rate card retains its whole-card route with no separate Full day control. `git diff --check` passed. Existing unrelated compiler warnings remained. | +| 2026-08-04 | PR review presentation restorations | i18n CI audit and `git diff --check` passed. A clean `NOOPiOS` Debug physical-device build succeeded, including `DevicesView`; the signed `com.liammazuz.noop` build was installed and launched on the connected iPhone. Source-path verification confirmed the upstream Live HR subtitle branches, weekly gauge captions, selected-week localization inputs, and unchanged Full day destination. | +| 2026-08-04 | Live workout glanceable hierarchy | i18n CI audit and `git diff --check` passed. The `NOOPiOS` Debug physical-device build succeeded and the signed `com.liammazuz.noop` app was installed and launched on the connected iPhone. Diff verification confirmed all workout data sources, calculations, lifecycle hooks, sensor isolation, actions, and confirmation behavior remain unchanged. Existing unrelated compiler warnings remained. | ## Required workflow for every future custom UI change From 36a2f21df555c55cd2871f2c2658255607357a51 Mon Sep 17 00:00:00 2001 From: liaolo <99437277+liaolo@users.noreply.github.com> Date: Tue, 4 Aug 2026 18:20:27 +0300 Subject: [PATCH 08/13] Refine Trends summary interactions --- Strand/Screens/TrendsView.swift | 1 + Strand/Screens/WeeklyDigestView.swift | 45 ++++++++++++++++++++++----- docs/UI_CUSTOMIZATION_LEDGER.md | 10 +++--- 3 files changed, 44 insertions(+), 12 deletions(-) diff --git a/Strand/Screens/TrendsView.swift b/Strand/Screens/TrendsView.swift index 3a29b63fe2..a6f2f8b92c 100644 --- a/Strand/Screens/TrendsView.swift +++ b/Strand/Screens/TrendsView.swift @@ -351,6 +351,7 @@ struct TrendsView: View { message: "Step to another week with the arrows above to see its review.") } else { WeeklyDigestContent(digest: digest, compact: true, showsHeader: false) + .padding(.top, NoopMetrics.space1) } } } diff --git a/Strand/Screens/WeeklyDigestView.swift b/Strand/Screens/WeeklyDigestView.swift index d7d157e2ac..19734e97e8 100644 --- a/Strand/Screens/WeeklyDigestView.swift +++ b/Strand/Screens/WeeklyDigestView.swift @@ -239,9 +239,9 @@ struct WeeklyDigestContent: View { } } } else { - // Center each complete metric group against the tallest group so the labels, - // gauges, values and optional comparison chips sit naturally within the card. - HStack(alignment: .center, spacing: 0) { + // Align from the shared label/gauge rows. Optional content below one gauge must + // never shift that gauge relative to its neighbours. + HStack(alignment: .top, spacing: 0) { ForEach(Array(summaries.enumerated()), id: \.element.metric.rawValue) { index, summary in scoreCard(summary: summary, presentation: .embedded) if index < summaries.count - 1 { @@ -513,6 +513,7 @@ private struct DigestScoreCard: View { @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.dynamicTypeSize) private var dynamicTypeSize @State private var animatedFraction: Double = 0 + @State private var showScaleGuide = false /// The Effort card is the only one that follows the 0–100/0–21 toggle; the rest are fixed 0–100. private var isEffort: Bool { summary.metric == .effort } @@ -553,6 +554,8 @@ private struct DigestScoreCard: View { } .accessibilityElement(children: .ignore) .accessibilityLabel(accessibility) + .accessibilityAddTraits(.isButton) + .accessibilityAction { showScaleGuide = true } } private var content: some View { @@ -591,11 +594,22 @@ private struct DigestScoreCard: View { animatedFraction: animatedFraction ) .frame(maxWidth: .infinity) - if isEmbedded && summary.thisWeek.n > 0 { - Text(captionText) - .font(StrandFont.footnote) - .foregroundStyle(StrandPalette.textTertiary) - .lineLimit(1) + .contentShape(Circle()) + .onTapGesture { showScaleGuide = true } + .popover(isPresented: $showScaleGuide, arrowEdge: .bottom) { + VStack(spacing: NoopMetrics.space1) { + Text(summary.metric.label) + .font(StrandFont.subhead.weight(.semibold)) + .foregroundStyle(domain.color) + Text(captionText) + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textSecondary) + } + .padding(NoopMetrics.cardInnerPadding) + .frame(minWidth: 120) + .background(NoopPanelSurface(cornerRadius: NoopVisualStyle.compactRadius, elevated: true)) + .accessibilityElement(children: .combine) + .digestScalePopoverAdaptation() } if isEmbedded && hasComparison { TrendChip(text: deltaSigned, color: deltaTone) @@ -624,6 +638,21 @@ private struct DigestScoreCard: View { } } +private extension View { + @ViewBuilder + func digestScalePopoverAdaptation() -> some View { + #if os(iOS) + if #available(iOS 16.4, *) { + presentationCompactAdaptation(.popover) + } else { + self + } + #else + self + #endif + } +} + #if DEBUG private func previewDigest() -> WeeklyDigest { var charge: [String: Double] = [:], effort: [String: Double] = [:] diff --git a/docs/UI_CUSTOMIZATION_LEDGER.md b/docs/UI_CUSTOMIZATION_LEDGER.md index e73bb0a190..bd4a95f866 100644 --- a/docs/UI_CUSTOMIZATION_LEDGER.md +++ b/docs/UI_CUSTOMIZATION_LEDGER.md @@ -251,10 +251,12 @@ feature rather than by file so they can be reviewed and ported independently. localized date range plus days-with-data count beneath the centered selected-week title. - Kept both week-navigation arrows and the existing selected-week digest source, offset binding, range boundaries, button actions, empty-week handling, and accessibility descriptions. -- Vertically centered the visible Charge, Effort, and Rest metric groups independently within the - compact shared row. Removed the invisible caption and comparison placeholders that made Effort and - Rest appear top-heavy, without increasing the card height. Preserved the three columns, dividers, - gauges, values, animations, calculations, and bindings. +- Top-aligns the shared label and gauge rows so Charge, Effort, and Rest stay level without reserving + empty caption or comparison rows beneath the gauges. This removes the unused lower-card space while + preserving optional visible comparison chips. +- Moves the localized `of 100` / `of 21` scale into a compact popover opened by tapping its gauge; Effort + continues to follow the selected display scale. Adds a small design-token gap between the selected-week + header and gauge card. Values, calculations, bindings, dividers, and gauge animation remain unchanged. - The Today embedding and full Week in Review screen retain their existing header by default. - **Type:** Intentional Trends layout cleanup only. From e268f492be9ba33ed8391eca62d4159933ef50a4 Mon Sep 17 00:00:00 2001 From: liaolo <99437277+liaolo@users.noreply.github.com> Date: Wed, 5 Aug 2026 04:47:15 +0300 Subject: [PATCH 09/13] Adapt liquid UI surfaces for Light mode Co-authored-by: Cursor --- Strand/Liquid/LiquidPrimitives.swift | 12 ++++----- Strand/Liquid/LiquidSky.swift | 30 +++++++++++++++++----- Strand/Liquid/LiquidTodayView.swift | 38 ++++++++++++---------------- Strand/Screens/LiveWorkoutView.swift | 4 +-- Strand/Screens/ScreenScaffold.swift | 14 +++------- docs/UI_CUSTOMIZATION_LEDGER.md | 20 +++++++++++++++ 6 files changed, 70 insertions(+), 48 deletions(-) diff --git a/Strand/Liquid/LiquidPrimitives.swift b/Strand/Liquid/LiquidPrimitives.swift index a7db585935..52afdb8e06 100644 --- a/Strand/Liquid/LiquidPrimitives.swift +++ b/Strand/Liquid/LiquidPrimitives.swift @@ -25,16 +25,16 @@ enum LiquidRender { var ctx = base ctx.fill(Path(ellipseIn: rect), with: .linearGradient( - Gradient(colors: [Color.white.opacity(0.08), Color.black.opacity(0.11)]), + Gradient(colors: [NoopVisualStyle.surfaceTop, NoopVisualStyle.surfaceBottom]), startPoint: CGPoint(x: rect.midX, y: rect.minY), endPoint: CGPoint(x: rect.midX, y: rect.maxY))) let inset = rect.insetBy(dx: diameter * 0.13, dy: diameter * 0.13) - ctx.fill(Path(ellipseIn: inset), with: .color(Color(.sRGB, red: 43/255, green: 45/255, blue: 54/255, opacity: 1))) + ctx.fill(Path(ellipseIn: inset), with: .color(NoopVisualStyle.inset)) var track = Path() track.addArc(center: center, radius: radius, startAngle: .degrees(-90), endAngle: .degrees(270), clockwise: false) - ctx.stroke(track, with: .color(Color.white.opacity(0.10)), + ctx.stroke(track, with: .color(NoopVisualStyle.border.opacity(0.72)), style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) let level = max(0, min(1, sim.level)) @@ -50,7 +50,7 @@ enum LiquidRender { } ctx.stroke(Path(ellipseIn: rect.insetBy(dx: 0.5, dy: 0.5)), - with: .color(Color.white.opacity(0.09)), lineWidth: 1) + with: .color(NoopVisualStyle.borderHighlight.opacity(0.55)), lineWidth: 1) } /// A horizontal capsule tube filled to `frac`; tilt pushes the liquid along it. @@ -60,8 +60,8 @@ enum LiquidRender { let w = size.width, h = size.height, r = h / 2 let outline = Path(roundedRect: CGRect(x: 0.5, y: 0.5, width: w - 1, height: h - 1), cornerRadius: r) var ctx = base - ctx.fill(outline, with: .color(Color(.sRGB, red: 14/255, green: 14/255, blue: 18/255, opacity: 1))) - ctx.stroke(outline, with: .color(.white.opacity(0.07)), lineWidth: 1) + ctx.fill(outline, with: .color(NoopVisualStyle.inset)) + ctx.stroke(outline, with: .color(NoopVisualStyle.border.opacity(0.72)), lineWidth: 1) var clip = ctx clip.clip(to: outline) diff --git a/Strand/Liquid/LiquidSky.swift b/Strand/Liquid/LiquidSky.swift index f51077d542..759afe0746 100644 --- a/Strand/Liquid/LiquidSky.swift +++ b/Strand/Liquid/LiquidSky.swift @@ -36,16 +36,32 @@ let liquidSkyKeys: [LiquidSkyStop] = [ .init(h: 24, top: hx(0x191A1F), mid: hx(0x1D1E23), hor: hx(0x22242B), stars: 0.20, warm: 0), ] +/// Light appearance keeps the same time-of-day movement without beginning from the dark-only +/// keyframes above. The restrained blue-gray atmosphere settles naturally into the light canvas. +private let liquidLightSkyKeys: [LiquidSkyStop] = [ + .init(h: 0, top: hx(0xDCE3ED), mid: hx(0xE5EAF1), hor: hx(0xEEF1F5), stars: 0.08, warm: 0), + .init(h: 5, top: hx(0xDDE5EE), mid: hx(0xE7EBF1), hor: hx(0xEFF2F5), stars: 0.05, warm: 0), + .init(h: 6.5, top: hx(0xE1E8EF), mid: hx(0xE9EDF2), hor: hx(0xF0F2F5), stars: 0.02, warm: 0), + .init(h: 8.5, top: hx(0xE3EBF1), mid: hx(0xEAF0F3), hor: hx(0xF1F3F5), stars: 0, warm: 0), + .init(h: 11, top: hx(0xE1EAF0), mid: hx(0xE9EEF2), hor: hx(0xF1F3F5), stars: 0, warm: 0), + .init(h: 14, top: hx(0xDFE8EF), mid: hx(0xE8EDF2), hor: hx(0xF0F2F5), stars: 0, warm: 0), + .init(h: 17.5, top: hx(0xE1E7ED), mid: hx(0xE8ECF1), hor: hx(0xEFF1F4), stars: 0, warm: 0), + .init(h: 19.5, top: hx(0xDDE4EC), mid: hx(0xE6EAF0), hor: hx(0xEEF1F4), stars: 0.02, warm: 0), + .init(h: 22, top: hx(0xDAE2EC), mid: hx(0xE4E9F0), hor: hx(0xEDF0F4), stars: 0.06, warm: 0), + .init(h: 24, top: hx(0xDCE3ED), mid: hx(0xE5EAF1), hor: hx(0xEEF1F5), stars: 0.08, warm: 0), +] + private func lerp(_ a: Double, _ b: Double, _ t: Double) -> Double { a + (b - a) * t } private func lerpColor(_ a: Color, _ b: Color, _ t: Double) -> Color { let x = a.liquidComponents(), y = b.liquidComponents() return Color(.sRGB, red: lerp(x.r, y.r, t), green: lerp(x.g, y.g, t), blue: lerp(x.b, y.b, t), opacity: 1) } -func liquidSkyAt(_ hour: Double) -> (top: Color, mid: Color, hor: Color, stars: Double, warm: Double) { +func liquidSkyAt(_ hour: Double, light: Bool = false) -> (top: Color, mid: Color, hor: Color, stars: Double, warm: Double) { + let keys = light ? liquidLightSkyKeys : liquidSkyKeys var i = 0 - while i < liquidSkyKeys.count - 2 && liquidSkyKeys[i + 1].h <= hour { i += 1 } - let a = liquidSkyKeys[i], b = liquidSkyKeys[i + 1] + while i < keys.count - 2 && keys[i + 1].h <= hour { i += 1 } + let a = keys[i], b = keys[i + 1] let t = max(0, min(1, (hour - a.h) / (b.h - a.h))) return (lerpColor(a.top, b.top, t), lerpColor(a.mid, b.mid, t), lerpColor(a.hor, b.hor, t), lerp(a.stars, b.stars, t), lerp(a.warm, b.warm, t)) @@ -86,7 +102,7 @@ struct LiquidSky: View { blue: dark ? 35.0 / 255.0 : 247.0 / 255.0, opacity: 1) Canvas { ctx, size in - render(ctx, size, hour: h, now: now, settle: settle) + render(ctx, size, hour: h, now: now, settle: settle, light: !dark) } } } @@ -97,8 +113,8 @@ struct LiquidSky: View { } private func render(_ base: GraphicsContext, _ size: CGSize, hour: Double, now: Double, - settle: Color) { - let S = liquidSkyAt(hour) + settle: Color, light: Bool) { + let S = liquidSkyAt(hour, light: light) let w = size.width, h = size.height var ctx = base // the gradient IS the scene @@ -194,7 +210,7 @@ struct LiquidSkyStatic: View { blue: dark ? 35.0 / 255.0 : 247.0 / 255.0, opacity: 1) Canvas { ctx, size in - let S = liquidSkyAt(h) + let S = liquidSkyAt(h, light: !dark) let w = size.width, hh = size.height ctx.fill(Path(CGRect(x: 0, y: 0, width: w, height: hh)), with: .linearGradient(Gradient(stops: [ diff --git a/Strand/Liquid/LiquidTodayView.swift b/Strand/Liquid/LiquidTodayView.swift index 187d57d312..f39a261264 100644 --- a/Strand/Liquid/LiquidTodayView.swift +++ b/Strand/Liquid/LiquidTodayView.swift @@ -408,11 +408,11 @@ struct LiquidTodayView: View { VStack(alignment: .leading, spacing: 2) { Text(dayTitle) .font(StrandFont.rounded(28)) - .foregroundStyle(.white) + .foregroundStyle(StrandPalette.textPrimary) .shadow(color: .black.opacity(0.4), radius: 10, y: 1) Text(dateLine) .font(StrandFont.caption) - .foregroundStyle(.white.opacity(0.78)) + .foregroundStyle(StrandPalette.textSecondary) .shadow(color: .black.opacity(0.35), radius: 8, y: 1) } .contentShape(Rectangle()) @@ -452,7 +452,7 @@ struct LiquidTodayView: View { Button { customizationDestination = .today } label: { Image(systemName: "slider.horizontal.3") .font(.system(size: 14, weight: .bold)) - .foregroundStyle(.white) + .foregroundStyle(StrandPalette.textPrimary) .frame(width: 34, height: 34) } .nativeLiquidGlassHeaderButton() @@ -479,21 +479,18 @@ struct LiquidTodayView: View { Image(systemName: "shield.lefthalf.filled") .font(.system(size: 14, weight: .semibold)) .foregroundStyle(StrandPalette.metricCyan) - // The session-start row shares the hero card's pinned-dark `heroFill`, so its text/chevron - // use the on-dark tokens — textPrimary/Secondary/Tertiary flip to dark ink in Light mode and - // went dark-on-near-black here too (#1013). Text("Start session") .font(StrandFont.subhead) - .foregroundStyle(StrandPalette.onDarkPrimary) + .foregroundStyle(StrandPalette.textPrimary) Text("BETA") .font(StrandFont.overlineScaled(8.5)).tracking(1.2) - .foregroundStyle(StrandPalette.onDarkSecondary) + .foregroundStyle(StrandPalette.textSecondary) .padding(.horizontal, 8).padding(.vertical, 2.5) - .background(Capsule().fill(.white.opacity(0.05)) - .overlay(Capsule().strokeBorder(.white.opacity(0.18), lineWidth: 1))) + .background(Capsule().fill(StrandPalette.surfaceInset.opacity(0.72)) + .overlay(Capsule().strokeBorder(StrandPalette.hairline, lineWidth: 1))) Spacer(minLength: 8) Image(systemName: "chevron.right").font(.system(size: 12, weight: .semibold)) - .foregroundStyle(StrandPalette.onDarkTertiary) + .foregroundStyle(StrandPalette.textTertiary) } .padding(.horizontal, 14) .padding(.vertical, 11) @@ -525,7 +522,7 @@ struct LiquidTodayView: View { animated: dataLoaded, onGuide: { guideSection = .rest }) .overlay(alignment: .top) { if let sourceLabel = heroSourceLabel { - SourceBadge("\(sourceLabel)", tint: StrandPalette.onDarkSecondary) + SourceBadge("\(sourceLabel)", tint: StrandPalette.textSecondary) // Match the badge's trailing edge to the Rest vessel and centre it on the card border. .fixedSize() .frame(width: HeroScoreCell.vesselDiameter, alignment: .trailing) @@ -1417,7 +1414,7 @@ private struct LiquidWordmark: View { ForEach(Array("NOOP".enumerated()), id: \.offset) { _, ch in Text(String(ch)) .font(StrandFont.rounded(16, weight: .bold)) - .foregroundStyle(.white.opacity(0.5)) + .foregroundStyle(StrandPalette.textTertiary) } } .shadow(color: .black.opacity(0.25), radius: 6, y: 1) @@ -1494,7 +1491,7 @@ private struct HeroScoreCell: View { Text("–").font(StrandFont.rounded(26)) } } - .foregroundStyle(.white) + .foregroundStyle(StrandPalette.textPrimary) .shadow(color: .black.opacity(0.5), radius: 6, y: 1) .lineLimit(1) .minimumScaleFactor(0.6) @@ -1508,10 +1505,7 @@ private struct HeroScoreCell: View { .lineLimit(1).minimumScaleFactor(0.7) Image(systemName: "chevron.right").font(.system(size: 9, weight: .semibold)).opacity(0.6) } - // The hero card fill is pinned dark in BOTH themes, so the CHARGE/EFFORT/REST label must use - // the scheme-invariant on-dark token — textSecondary flips to dark ink in Light mode and - // went dark-on-near-black here (#1013). - .foregroundStyle(StrandPalette.onDarkSecondary) + .foregroundStyle(StrandPalette.textSecondary) } .buttonStyle(.plain) .accessibilityLabel(Text("\(label), \(score.map { decimals > 0 ? String(format: "%.\(decimals)f", $0) : String(Int($0.rounded())) } ?? String(localized: "no data yet")). See how it is scored.")) @@ -1605,7 +1599,7 @@ private struct LiquidAddButton: View { Button { router.requestQuickActions() } label: { Image(systemName: "plus") .font(.system(size: 16, weight: .bold)) - .foregroundStyle(.white) + .foregroundStyle(StrandPalette.textPrimary) .frame(width: 34, height: 34) } .nativeLiquidGlassHeaderButton() @@ -1915,7 +1909,7 @@ private struct LiquidBatteryButton: View { .padding(2.5) Text("\(Int(pct.rounded()))") .font(.system(size: 9, weight: .bold)) - .foregroundStyle(.white.opacity(0.9)) + .foregroundStyle(StrandPalette.textPrimary) if charging { // #972: the default Today never surfaced charging state — only the % ring. A small // bolt over the ring gives the same signal as the "· Charging" text on Mac/Android. @@ -1929,11 +1923,11 @@ private struct LiquidBatteryButton: View { // that is the one thing we actually know, and it is the wearer's live question. Image(systemName: charging ? "bolt.fill" : "ellipsis") .font(.system(size: charging ? 11 : 9, weight: .bold)) - .foregroundStyle(charging ? StrandPalette.chargeColor : .white.opacity(0.5)) + .foregroundStyle(charging ? StrandPalette.chargeColor : StrandPalette.textTertiary) case .offline: Image(systemName: "bolt.slash") .font(.system(size: 11)) - .foregroundStyle(.white.opacity(0.5)) + .foregroundStyle(StrandPalette.textTertiary) } } .frame(width: 34, height: 34) diff --git a/Strand/Screens/LiveWorkoutView.swift b/Strand/Screens/LiveWorkoutView.swift index 09f535438e..3454345f28 100644 --- a/Strand/Screens/LiveWorkoutView.swift +++ b/Strand/Screens/LiveWorkoutView.swift @@ -218,10 +218,10 @@ struct LiveWorkoutView: View { : "\(Int(value.rounded()))" }, font: StrandFont.rounded(30, weight: .semibold), - color: .white) + color: StrandPalette.textPrimary) Text(String(localized: "of \(maxLabel)")) .font(StrandFont.footnote) - .foregroundStyle(.white.opacity(0.72)) + .foregroundStyle(StrandPalette.textSecondary) } .shadow(color: .black.opacity(0.5), radius: 6, y: 1) .allowsHitTesting(false) diff --git a/Strand/Screens/ScreenScaffold.swift b/Strand/Screens/ScreenScaffold.swift index 565e7c2da7..6889ccc798 100644 --- a/Strand/Screens/ScreenScaffold.swift +++ b/Strand/Screens/ScreenScaffold.swift @@ -115,23 +115,15 @@ struct ScreenScaffold: View { } private var header: some View { - // When a `topBackground` (the day-cycle liquid sky) sits behind the header, that band is dark in - // BOTH themes — so the title/subtitle must use the scheme-invariant on-dark tokens. The regular - // text tokens flip to dark ink in Light mode and went dark-on-dark over the sky, exactly the #1013 - // pattern the Liquid Today hero hit (osifaind's Trends-tab sibling report). Flat-canvas screens - // (no topBackground) keep the theme tokens so the header reads on the light/dark surfaceBase. - let overSky = topBackground != nil - let titleColor = overSky ? StrandPalette.onDarkPrimary : StrandPalette.textPrimary - let subtitleColor = overSky ? StrandPalette.onDarkSecondary : StrandPalette.textSecondary - return HStack(alignment: .center, spacing: 12) { + HStack(alignment: .center, spacing: 12) { VStack(alignment: .leading, spacing: 2) { if let title { // Match the liquid home's title face (SF Rounded 28) so every page's header reads // identically (2026-07-02 cohesion pass). - Text(title).font(StrandFont.rounded(28)).foregroundStyle(titleColor) + Text(title).font(StrandFont.rounded(28)).foregroundStyle(StrandPalette.textPrimary) } if let subtitle { - Text(subtitle).font(StrandFont.subhead).foregroundStyle(subtitleColor) + Text(subtitle).font(StrandFont.subhead).foregroundStyle(StrandPalette.textSecondary) } } Spacer(minLength: 0) diff --git a/docs/UI_CUSTOMIZATION_LEDGER.md b/docs/UI_CUSTOMIZATION_LEDGER.md index bd4a95f866..94b3b8ed5b 100644 --- a/docs/UI_CUSTOMIZATION_LEDGER.md +++ b/docs/UI_CUSTOMIZATION_LEDGER.md @@ -355,6 +355,26 @@ feature rather than by file so they can be reviewed and ported independently. chart, statistics, sampling, animation, and accessibility hint. - **Type:** Live HR affordance presentation only; destination and behavior unchanged. +### EXP-020 — Adaptive Light-mode liquid surfaces + +- **Date:** 2026-08-05 +- **Files:** `Strand/Liquid/LiquidPrimitives.swift`, `Strand/Liquid/LiquidSky.swift`, + `Strand/Liquid/LiquidTodayView.swift`, `Strand/Screens/LiveWorkoutView.swift`, + `Strand/Screens/ScreenScaffold.swift` +- Replaced fixed dark gauge interiors, gauge tracks, progress-bar tracks, and borders with the existing + adaptive NOOP surface and border tokens so these elements resolve correctly in Light and Dark modes. +- Updated the Today hero values, labels, source badge, and Start session row to use adaptive text and + inset-surface tokens because their shared panel surface already adapts with the selected appearance. +- Replaced fixed-white Today sky headings, header controls, wordmark, battery states, and Live Workout + effort-vessel labels with adaptive primary, secondary, and tertiary text tokens. +- Updated the shared `ScreenScaffold` header to use adaptive title and subtitle tokens over both the + new light sky and the original dark sky, fixing Trends and every other sky-backed screen consistently. +- Added a restrained light-appearance sky ramp so the shared tab background no longer begins with the + dark-only near-black keyframes before fading into the light canvas; Dark mode retains its original sky. +- Preserved gauge values, progress fractions, animations, taps, session action, score-guide actions, + accessibility descriptions, card geometry, and all data bindings. +- **Type:** Appearance compatibility only; no layout, navigation, state, or behavior changes. + ## Verification history | Date | Scope | Result | From ef1cb91e342c81b8d0c0ea2f6f9925bd3b0569fb Mon Sep 17 00:00:00 2001 From: liaolo <99437277+liaolo@users.noreply.github.com> Date: Wed, 5 Aug 2026 05:17:50 +0300 Subject: [PATCH 10/13] Refine live workout glanceable layout Co-authored-by: Cursor --- Strand/Screens/LiveWorkoutView.swift | 178 ++++++++++++--------------- docs/UI_CUSTOMIZATION_LEDGER.md | 20 +++ 2 files changed, 98 insertions(+), 100 deletions(-) diff --git a/Strand/Screens/LiveWorkoutView.swift b/Strand/Screens/LiveWorkoutView.swift index 3454345f28..ba1c510ada 100644 --- a/Strand/Screens/LiveWorkoutView.swift +++ b/Strand/Screens/LiveWorkoutView.swift @@ -46,8 +46,10 @@ struct LiveWorkoutView: View { VStack(alignment: .leading, spacing: NoopMetrics.sectionSpacing) { let cards: [AnyView] = [ AnyView(header), - AnyView(heroHeartRate), + AnyView(timeBlock), + AnyView(heartRateBlock), AnyView(effortGauge), + AnyView(zoneSection), AnyView(statsGrid), ] ForEach(Array(cards.enumerated()), id: \.offset) { index, card in @@ -125,123 +127,98 @@ struct LiveWorkoutView: View { } } - private var heroHeartRate: some View { - let tint = zone >= 1 ? StrandPalette.hrZoneColor(zone) : StrandPalette.effortColor - return NoopCard(padding: NoopMetrics.space6, tint: StrandPalette.effortColor) { - VStack(spacing: NoopMetrics.space5) { - if let start = model.activeWorkout?.start { - VStack(spacing: NoopMetrics.space1) { - Text("TIME") - .font(StrandFont.overline).tracking(StrandFont.overlineTracking) - .foregroundStyle(StrandPalette.textSecondary) - TimelineView(.periodic(from: .now, by: 1)) { _ in - Text(Self.elapsed(since: start)) - .font(StrandFont.number(48)).monospacedDigit() - .foregroundStyle(StrandPalette.textPrimary) - .contentTransition(.numericText()) - } - } - } - - Rectangle() - .fill(StrandPalette.hairline) - .frame(height: 1) - - HStack(alignment: .center, spacing: NoopMetrics.space4) { - VStack(alignment: .leading, spacing: NoopMetrics.space1) { - Text("HEART RATE") - .font(StrandFont.overline).tracking(StrandFont.overlineTracking) - .foregroundStyle(StrandPalette.textSecondary) - HStack(alignment: .firstTextBaseline, spacing: NoopMetrics.space1) { - if let bpm = model.bpm { - CountUpText(value: Double(bpm), - format: { "\(Int($0.rounded()))" }, - font: StrandFont.rounded(72, weight: .semibold), - color: tint) - } else { - Text("—") - .font(StrandFont.rounded(72, weight: .semibold)) - .foregroundStyle(tint) - } - Text("bpm") - .font(StrandFont.subhead) - .foregroundStyle(StrandPalette.textSecondary) - } + /// Centered elapsed-time stack — same TimelineView source as before; card chrome removed so + /// TIME sits as a free hero metric above heart rate. + private var timeBlock: some View { + Group { + if let start = model.activeWorkout?.start { + VStack(spacing: NoopMetrics.space1) { + Text("TIME") + .font(StrandFont.overline).tracking(StrandFont.overlineTracking) + .foregroundStyle(StrandPalette.textSecondary) + TimelineView(.periodic(from: .now, by: 1)) { _ in + Text(Self.elapsed(since: start)) + .font(StrandFont.number(56)).monospacedDigit() + .foregroundStyle(StrandPalette.textPrimary) + .contentTransition(.numericText()) } - Spacer(minLength: 0) - Text(zone >= 1 ? "Zone \(zone) · \(Self.zoneName(zone))" : "Below Zone 1") - .font(StrandFont.captionNumber) - .foregroundStyle(tint) - .multilineTextAlignment(.trailing) - .padding(.horizontal, NoopMetrics.space2) - .padding(.vertical, NoopMetrics.space1) - .background(tint.opacity(0.12), in: Capsule()) } + .frame(maxWidth: .infinity) + } + } + } - zoneRail + /// Centered live HR stack — bpm unit sits under the value; the zone capsule moved to `zoneSection`. + private var heartRateBlock: some View { + let tint = zone >= 1 ? StrandPalette.hrZoneColor(zone) : StrandPalette.effortColor + return VStack(spacing: NoopMetrics.space1) { + Text("HEART RATE") + .font(StrandFont.overline).tracking(StrandFont.overlineTracking) + .foregroundStyle(StrandPalette.textSecondary) + if let bpm = model.bpm { + CountUpText(value: Double(bpm), + format: { "\(Int($0.rounded()))" }, + font: StrandFont.rounded(72, weight: .semibold), + color: tint) + } else { + Text("—") + .font(StrandFont.rounded(72, weight: .semibold)) + .foregroundStyle(tint) } - .frame(maxWidth: .infinity) + Text("bpm") + .font(StrandFont.subhead) + .foregroundStyle(StrandPalette.textSecondary) } + .frame(maxWidth: .infinity) } - /// The accumulating Effort, on the same layered StrainGauge the rest of the app uses — the live - /// `liveStrain` is on NOOP's 0–100 Effort axis. The gauge renders on the user's selected Effort - /// scale (#313): 0–100 native, or rescaled to WHOOP's 0–21, matching the rest of the app's - /// read-outs (mirrors TodayView's effort hero). Display-only — the captured value stays 0–100. + /// Centered Effort stack — same `liveStrain` / Effort-scale conversion and `StrainGauge` intensity + /// label as before. Card chrome and side-by-side layout removed so the value sits as a free hero + /// metric between heart rate and the zone rail. Display-only; captured value stays 0–100. private var effortGauge: some View { let strain = model.activeWorkout?.liveStrain ?? 0 let displayEffort = UnitFormatter.effortValue(strain, scale: effortScale) let maxValue = effortScale == .whoop ? 21.0 : 100.0 - let maxLabel = UnitFormatter.effortScaleMax(effortScale) let fraction = min(max(displayEffort / maxValue, 0), 1) - return NoopCard(padding: NoopMetrics.cardInnerPadding, tint: StrandPalette.effortColor) { - HStack(spacing: NoopMetrics.space5) { - VStack(alignment: .leading, spacing: NoopMetrics.space2) { - Image(systemName: "bolt.fill") - .font(.system(size: 24, weight: .semibold)) - .foregroundStyle(StrandPalette.effortColor) - Text("EFFORT BUILDING") - .font(StrandFont.overline).tracking(StrandFont.overlineTracking) - .foregroundStyle(StrandPalette.effortColor) - Text(StrainGauge.stateLabel(forFraction: fraction)) - .font(StrandFont.captionNumber) - .foregroundStyle(StrandPalette.textSecondary) - } - Spacer(minLength: 0) - ZStack { - LiquidVessel(value: fraction, tint: StrandPalette.effortColor, animated: true) - VStack(spacing: 1) { - CountUpText(value: displayEffort, - format: { value in - effortScale == .whoop - ? String(format: "%.1f", value) - : "\(Int(value.rounded()))" - }, - font: StrandFont.rounded(30, weight: .semibold), - color: StrandPalette.textPrimary) - Text(String(localized: "of \(maxLabel)")) - .font(StrandFont.footnote) - .foregroundStyle(StrandPalette.textSecondary) - } - .shadow(color: .black.opacity(0.5), radius: 6, y: 1) - .allowsHitTesting(false) - } - .frame(width: 124, height: 124) - .accessibilityElement(children: .ignore) - .accessibilityLabel(Text(UnitFormatter.effortDisplay(strain, scale: effortScale))) - .accessibilityValue(Text(StrainGauge.stateLabel(forFraction: fraction))) - } - .frame(maxWidth: .infinity) + return VStack(spacing: NoopMetrics.space1) { + CountUpText(value: displayEffort, + format: { value in + effortScale == .whoop + ? String(format: "%.1f", value) + : "\(Int(value.rounded()))" + }, + font: StrandFont.rounded(56, weight: .semibold), + color: StrandPalette.textPrimary) + .accessibilityLabel(Text(UnitFormatter.effortDisplay(strain, scale: effortScale))) + .accessibilityValue(Text(StrainGauge.stateLabel(forFraction: fraction))) + + Text("EFFORT BUILDING") + .font(StrandFont.overline).tracking(StrandFont.overlineTracking) + .foregroundStyle(StrandPalette.effortColor) + Text(StrainGauge.stateLabel(forFraction: fraction)) + .font(StrandFont.captionNumber) + .foregroundStyle(StrandPalette.textSecondary) } + .frame(maxWidth: .infinity) } - private var zoneRail: some View { - VStack(alignment: .leading, spacing: 8) { + /// Zone status capsule + rail + caption — same zone derivation and copy; capsule sits on the + /// HR ZONE header row instead of beside the heart-rate value. + private var zoneSection: some View { + let tint = zone >= 1 ? StrandPalette.hrZoneColor(zone) : StrandPalette.effortColor + return VStack(alignment: .leading, spacing: 8) { HStack { Text("HR ZONE") .font(StrandFont.overline).tracking(StrandFont.overlineTracking) .foregroundStyle(StrandPalette.textSecondary) Spacer() + Text(zone >= 1 ? "Zone \(zone) · \(Self.zoneName(zone))" : "Below Zone 1") + .font(StrandFont.captionNumber) + .foregroundStyle(tint) + .multilineTextAlignment(.trailing) + .padding(.horizontal, NoopMetrics.space2) + .padding(.vertical, NoopMetrics.space1) + .background(tint.opacity(0.12), in: Capsule()) } HStack(spacing: 6) { ForEach(1...5, id: \.self) { z in @@ -344,7 +321,8 @@ struct LiveWorkoutView: View { /// This is a standalone leaf that owns its OWN `@EnvironmentObject live` (the parent `LiveWorkoutView` /// no longer observes `LiveState`), so an incoming sensor / R-R packet re-renders only this row, not the /// HR hero / effort gauge / zone rail above. The gate, layout and `staggeredAppear(index: 5)` are -/// preserved verbatim, so the rendered output is byte-for-byte the previous inline code. +/// preserved verbatim (index bumped to 6 after the glanceable layout split TIME / HR / Effort / zone +/// into separate stagger slots), so the rendered output matches the previous inline code. private struct SensorRowIfPresent: View { @EnvironmentObject private var live: LiveState @@ -365,7 +343,7 @@ private struct SensorRowIfPresent: View { } } } - .staggeredAppear(index: 5) + .staggeredAppear(index: 6) } } diff --git a/docs/UI_CUSTOMIZATION_LEDGER.md b/docs/UI_CUSTOMIZATION_LEDGER.md index 94b3b8ed5b..ea608e6b7e 100644 --- a/docs/UI_CUSTOMIZATION_LEDGER.md +++ b/docs/UI_CUSTOMIZATION_LEDGER.md @@ -375,6 +375,25 @@ feature rather than by file so they can be reviewed and ported independently. accessibility descriptions, card geometry, and all data bindings. - **Type:** Appearance compatibility only; no layout, navigation, state, or behavior changes. +### EXP-021 — Live workout glanceable vertical stack + +- **Date:** 2026-08-05 +- **Files:** `Strand/Screens/LiveWorkoutView.swift` +- **Request:** Match the live workout screen to the provided glanceable mock by rearranging existing + elements only — no functional changes. +- **Implementation:** Split the combined hero card into a vertical stack of centered TIME, HEART RATE + (bpm under the value), and Effort (live number with EFFORT BUILDING + intensity label beneath). Moved + the zone status capsule onto the HR ZONE header row beside the label, with the existing Z1–Z5 rail and + caption directly below. Removed the side-by-side Effort card chrome / bolt affordance and the in-vessel + scale denominator so the Effort block matches the mock’s centered text hierarchy. AVG / PEAK / EFFORT + summary, End workout control, recording badge, scenic Effort backdrop, and optional sensor row retain + their prior roles; sensor stagger index bumped to 6 to follow the extra layout slots. +- **Preserved:** Workout timer (`TimelineView` from `activeWorkout.start`), live BPM and zone derivation, + Effort scale conversion and `StrainGauge` intensity thresholds, avg/peak/effort stats sources, realtime + HR arming, keep-awake behavior, End confirmation + `endWorkout` / `onClose`, sensor leaf isolation, and + all accessibility values for Effort. +- **Type:** Experimental live-workout layout rearrangement only; no workout behavior or calculation changes. + ## Verification history | Date | Scope | Result | @@ -394,6 +413,7 @@ feature rather than by file so they can be reviewed and ported independently. | 2026-08-04 | Full-width metrics action | `NOOPiOS` Debug physical-device build succeeded; installed and launched on the connected iPhone using `com.liammazuz.noop`. Show all metrics uses the reusable full-width surface; the Heart Rate card retains its whole-card route with no separate Full day control. `git diff --check` passed. Existing unrelated compiler warnings remained. | | 2026-08-04 | PR review presentation restorations | i18n CI audit and `git diff --check` passed. A clean `NOOPiOS` Debug physical-device build succeeded, including `DevicesView`; the signed `com.liammazuz.noop` build was installed and launched on the connected iPhone. Source-path verification confirmed the upstream Live HR subtitle branches, weekly gauge captions, selected-week localization inputs, and unchanged Full day destination. | | 2026-08-04 | Live workout glanceable hierarchy | i18n CI audit and `git diff --check` passed. The `NOOPiOS` Debug physical-device build succeeded and the signed `com.liammazuz.noop` app was installed and launched on the connected iPhone. Diff verification confirmed all workout data sources, calculations, lifecycle hooks, sensor isolation, actions, and confirmation behavior remain unchanged. Existing unrelated compiler warnings remained. | +| 2026-08-05 | Live workout glanceable vertical stack | `NOOPiOS` Debug physical-device build succeeded; signed `com.liammazuz.noop` (team `P2874N8GRQ`) installed and launched on Liam's iPhone. Source verification confirmed timer, BPM/zone, Effort scale, stats, realtime HR, keep-awake, End confirm, and sensor leaf behavior unchanged. | ## Required workflow for every future custom UI change From 44de5ddc879e89841e6b99b59544cd11f47b38c1 Mon Sep 17 00:00:00 2001 From: liaolo <99437277+liaolo@users.noreply.github.com> Date: Wed, 5 Aug 2026 06:41:00 +0300 Subject: [PATCH 11/13] 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 --- .cursor/rules/ui-customization-ledger.mdc | 32 + .../NoopLiquidGlassSearchField.swift | 83 ++ .../Sources/StrandDesign/SportIcon.swift | 749 +++++++++++++++++- .../WorkoutTypeIconTests.swift | 52 ++ Strand/Screens/LiveView.swift | 2 +- Strand/Screens/LiveWorkoutView.swift | 134 +++- Strand/Screens/ManualWorkoutSheet.swift | 153 ---- Strand/Screens/MarkerEditorView.swift | 17 +- Strand/Screens/WorkoutSelectionScreen.swift | 401 ++++++++++ Strand/Screens/WorkoutsView.swift | 35 +- StrandTests/WorkoutCatalogTests.swift | 21 + docs/UI_CUSTOMIZATION_LEDGER.md | 123 +++ 12 files changed, 1550 insertions(+), 252 deletions(-) create mode 100644 .cursor/rules/ui-customization-ledger.mdc create mode 100644 Packages/StrandDesign/Sources/StrandDesign/NoopLiquidGlassSearchField.swift create mode 100644 Packages/StrandDesign/Tests/StrandDesignTests/WorkoutTypeIconTests.swift create mode 100644 Strand/Screens/WorkoutSelectionScreen.swift diff --git a/.cursor/rules/ui-customization-ledger.mdc b/.cursor/rules/ui-customization-ledger.mdc new file mode 100644 index 0000000000..cc009342db --- /dev/null +++ b/.cursor/rules/ui-customization-ledger.mdc @@ -0,0 +1,32 @@ +--- +description: Document every app UI change in the customization ledger +alwaysApply: true +--- + +# UI Customization Ledger + +Every app UI / presentation change in this repo must be documented in +[`docs/UI_CUSTOMIZATION_LEDGER.md`](docs/UI_CUSTOMIZATION_LEDGER.md) in the **same working session** +as the code change. Do not ship UI diffs without a matching ledger entry. + +## When this applies + +Any change under `Strand/`, `StrandiOS/`, `StrandiOSShared/`, `Packages/StrandDesign/`, or other +app presentation surfaces — visuals, layout, chrome, tokens, motion, accessibility presentation. + +## Required steps (same session) + +1. Read the ledger’s latest `EXP-###` number and append the next sequential entry. +2. Follow the template under **Required workflow for every future custom UI change** in that file. +3. Include: date, every touched source file, visible outcome, what was preserved (bindings, actions, + routes, accessibility, data/logic), type, and verification notes when applicable. +4. Respect **Non-negotiable merge boundaries** — do not touch BLE, scoring, persistence, HealthKit, + networking, navigation destinations, or build config unless the ledger entry explicitly approves it. +5. Prefer updating the ledger in the same commit as the UI change (or immediately before commit when + the user asks to commit). + +## Do not + +- Leave undocumented UI changes for “later.” +- Skip the ledger for “small” token/color/typography tweaks. +- Treat the ledger as optional documentation — it is the merge source of truth for custom UI work. diff --git a/Packages/StrandDesign/Sources/StrandDesign/NoopLiquidGlassSearchField.swift b/Packages/StrandDesign/Sources/StrandDesign/NoopLiquidGlassSearchField.swift new file mode 100644 index 0000000000..e4f6c016df --- /dev/null +++ b/Packages/StrandDesign/Sources/StrandDesign/NoopLiquidGlassSearchField.swift @@ -0,0 +1,83 @@ +import SwiftUI + +// MARK: - Native Liquid Glass search field +// +// Shared search chrome for every in-app filter/search bar. iOS 26 uses the platform +// `glassEffect` capsule; older supported releases keep a solid elevated pill (not a +// hand-rolled blur stack). Clear control, magnifying-glass glyph, and accessibility +// wiring stay identical across call sites. + +/// Full-width rounded search field with native Liquid Glass on iOS 26+. +public struct NoopLiquidGlassSearchField: View { + @Binding private var text: String + private let prompt: String + private let accessibilityPrompt: String + private var externalFocus: FocusState.Binding? + @FocusState private var internalFocus: Bool + + public init(text: Binding, + prompt: String, + accessibilityLabel: String? = nil, + isFocused: FocusState.Binding? = nil) { + self._text = text + self.prompt = prompt + self.accessibilityPrompt = accessibilityLabel ?? prompt + self.externalFocus = isFocused + } + + public var body: some View { + HStack(spacing: NoopMetrics.space2) { + Image(systemName: "magnifyingglass") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(StrandPalette.textSecondary) + .accessibilityHidden(true) + TextField(prompt, text: $text) + .textFieldStyle(.plain) + .font(StrandFont.body) + .foregroundStyle(StrandPalette.textPrimary) + .focused(focusBinding) + .submitLabel(.search) + #if os(iOS) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + #endif + if !text.isEmpty { + Button { + text = "" + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(StrandPalette.textTertiary) + .frame(width: 28, height: 28) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .accessibilityLabel(Text("Clear search")) + } + } + .padding(.horizontal, NoopMetrics.space4) + .padding(.vertical, NoopMetrics.space3) + .nativeLiquidGlassSearchChrome() + .accessibilityElement(children: .contain) + .accessibilityLabel(Text(accessibilityPrompt)) + } + + private var focusBinding: FocusState.Binding { + externalFocus ?? $internalFocus + } +} + +public extension View { + /// Capsule Liquid Glass search chrome. iOS 26 / watchOS 26 / macOS 26 use interactive + /// `glassEffect`; older OS versions use the shared elevated pill surface (not ultra-thin material stacks). + @ViewBuilder + func nativeLiquidGlassSearchChrome() -> some View { + if #available(iOS 26.0, macOS 26.0, watchOS 26.0, *) { + self.glassEffect(.regular.interactive(), in: Capsule()) + } else { + self.background( + NoopPanelSurface(cornerRadius: NoopVisualStyle.pillRadius, elevated: false) + ) + } + } +} diff --git a/Packages/StrandDesign/Sources/StrandDesign/SportIcon.swift b/Packages/StrandDesign/Sources/StrandDesign/SportIcon.swift index 69dec9ef2c..a87c7c8389 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/SportIcon.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/SportIcon.swift @@ -1,49 +1,712 @@ -import Foundation +import SwiftUI +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif -// MARK: - Sport → SF Symbol +// MARK: - Known workout types (catalog lockstep) // -// Maps a free-text sport/activity name to an SF Symbol name. Shared so a sport -// reads identically everywhere it appears — the Workouts list, per-sport -// breakdown cards, and the Today HR overview's workout annotations. +// Raw values match `WorkoutCatalog.all` / Android `WorkoutSport.all` display names exactly. +// Free-text sports resolve into these cases (or the unknown fallback) so UI never switches on +// ad-hoc strings. When the catalogue gains a sport, add a case here in the same PR. -/// The SF Symbol that best represents a free-text `sport` label (case-insensitive, -/// substring-matched). Falls back to `figure.mixed.cardio` for anything unrecognised. +/// Every named sport the workout pickers suggest. Exhaustive for iconography. +public enum KnownWorkoutType: String, CaseIterable, Sendable { + case running = "Running" + case walking = "Walking" + case hiking = "Hiking" + case cycling = "Cycling" + case openWaterSwim = "Open-water swim" + case rowing = "Rowing" + case treadmillRun = "Treadmill run" + case treadmillWalk = "Treadmill walk" + case indoorCycle = "Indoor cycle" + case poolSwim = "Pool swim" + case rowMachine = "Row machine" + case elliptical = "Elliptical" + case strength = "Strength" + case bodybuilding = "Bodybuilding" + case weightlifting = "Weightlifting" + case hiit = "HIIT" + case yoga = "Yoga" + case pilates = "Pilates" + case boxing = "Boxing" + case basketball = "Basketball" + case soccer = "Soccer" + case baseball = "Baseball" + case badminton = "Badminton" + case tennis = "Tennis" + case squash = "Squash" + case racquetball = "Racquetball" + case tableTennis = "Table tennis" + case volleyball = "Volleyball" + case martialArts = "Martial arts" + case dancing = "Dancing" + case golf = "Golf" + case climbing = "Climbing" + case stretching = "Stretching" + case skiing = "Skiing" + case snowboarding = "Snowboarding" + case padel = "Padel" + case pickleball = "Pickleball" + case bowling = "Bowling" + case other = "Other" + + /// Case-insensitive exact match against a stored/free-typed sport label. + public static func exact(matching name: String) -> KnownWorkoutType? { + let q = name.trimmingCharacters(in: .whitespaces) + guard !q.isEmpty else { return nil } + return allCases.first { $0.rawValue.caseInsensitiveCompare(q) == .orderedSame } + } + + /// Best-effort resolve for free-text labels that are not exact catalogue names. + public static func resolving(_ sport: String) -> KnownWorkoutType? { + if let exact = exact(matching: sport) { return exact } + let s = sport.lowercased() + // Order matters: more specific tokens before broader ones (padel before tennis, treadmill before run). + switch true { + case s.contains("treadmill") && s.contains("walk"): return .treadmillWalk + case s.contains("treadmill"): return .treadmillRun + case s.contains("open") && s.contains("swim"): return .openWaterSwim + case s.contains("pool") && s.contains("swim"): return .poolSwim + case s.contains("indoor") && (s.contains("cycl") || s.contains("bike")): + return .indoorCycle + case s.contains("row") && (s.contains("machine") || s.contains("indoor") || s.contains("erg")): + return .rowMachine + case s.contains("pickle"): return .pickleball + case s.contains("padel"): return .padel + case s.contains("racquet"): return .racquetball + case s.contains("table") && s.contains("tennis"): return .tableTennis + case s.contains("badminton"): return .badminton + case s.contains("squash"): return .squash + case s.contains("tennis"): return .tennis + case s.contains("snowboard"): return .snowboarding + case s.contains("ski"): return .skiing + case s.contains("hike") || s.contains("hiking"): return .hiking + case s.contains("walk"): return .walking + case s.contains("run"): return .running + case s.contains("cycl") || s.contains("bike") || s.contains("ride"): + return .cycling + case s.contains("swim"): return .poolSwim + case s.contains("row"): return .rowing + case s.contains("elliptical"): return .elliptical + case s.contains("bodybuild"): return .bodybuilding + case s.contains("weightlift") || s.contains("olympic"): + return .weightlifting + case s.contains("strength") || s.contains("weight") || s.contains("lift"): + return .strength + case s.contains("hiit") || s.contains("interval"): return .hiit + case s.contains("yoga"): return .yoga + case s.contains("pilates"): return .pilates + case s.contains("box"): return .boxing + case s.contains("basket"): return .basketball + case s.contains("soccer") || s.contains("football"): return .soccer + case s.contains("baseball"): return .baseball + case s.contains("volley"): return .volleyball + case s.contains("martial") || s.contains("jiu") || s.contains("judo") + || s.contains("karate") || s.contains("mma"): return .martialArts + case s.contains("dance"): return .dancing + case s.contains("golf"): return .golf + case s.contains("climb"): return .climbing + case s.contains("stretch") || s.contains("mobility") || s.contains("flex"): + return .stretching + case s.contains("bowl"): return .bowling + case s.contains("other") || s.contains("activity") || s.contains("detected"): + return .other + default: return nil + } + } +} + +// MARK: - Iconography (central mapping) + +/// Resolves a unique monochrome icon for each known workout type. Prefer SF Symbols; draw a custom +/// vector when no suitable (or available) system symbol exists so types never share a glyph. +public enum WorkoutTypeIconography { + + public enum Glyph: Equatable, Sendable { + case system(String) + case custom(Custom) + } + + public enum Custom: String, CaseIterable, Equatable, Sendable { + case padelRacket + case pickleballPaddle + case squashRacket + case racquetballRacket + case tableTennisPaddle + case shuttlecock + case treadmillRunBadge + case treadmillWalkBadge + case indoorCycleBadge + case rowMachineBadge + case ellipticalBadge + case bodybuildingBadge + case barbellBadge + case snowboardBadge + case openWaterWaves + case hikingStick + } + + /// Exhaustive per-type glyph. Preferred SF Symbol when present on the OS; otherwise a unique + /// custom vector — never borrow another type's primary system name. + public static func glyph(for type: KnownWorkoutType) -> Glyph { + switch type { + case .running: + return .system("figure.run") + case .walking: + return .system("figure.walk") + case .hiking: + return systemOrCustom("figure.hiking", .hikingStick) + case .cycling: + return systemOrCustom("figure.outdoor.cycle", fallbackSystem: "bicycle") + case .openWaterSwim: + return systemOrCustom("figure.open.water.swim", .openWaterWaves) + case .rowing: + return systemOrCustom("figure.outdoor.rowing", fallbackSystem: "figure.rower") + case .treadmillRun: + return systemOrCustom("figure.run.treadmill", .treadmillRunBadge) + case .treadmillWalk: + return systemOrCustom("figure.walk.treadmill", .treadmillWalkBadge) + case .indoorCycle: + return systemOrCustom("figure.indoor.cycle", .indoorCycleBadge) + case .poolSwim: + return .system("figure.pool.swim") + case .rowMachine: + return systemOrCustom("figure.indoor.rowing", .rowMachineBadge) + case .elliptical: + return systemOrCustom("figure.elliptical", .ellipticalBadge) + case .strength: + return systemOrCustom("dumbbell.fill", fallbackSystem: "dumbbell") + case .bodybuilding: + return systemOrCustom("figure.strengthtraining.traditional", .bodybuildingBadge) + case .weightlifting: + return systemOrCustom("figure.strengthtraining.functional", .barbellBadge) + case .hiit: + return systemOrCustom("figure.highintensity.intervaltraining", fallbackSystem: "bolt.fill") + case .yoga: + return systemOrCustom("figure.yoga", fallbackSystem: "figure.mind.and.body") + case .pilates: + return systemOrCustom("figure.pilates", fallbackSystem: "figure.flexibility") + case .boxing: + return .system("figure.boxing") + case .basketball: + return systemOrCustom("figure.basketball", fallbackSystem: "basketball.fill") + case .soccer: + return systemOrCustom("figure.outdoor.soccer", fallbackSystem: "soccerball") + case .baseball: + return systemOrCustom("figure.baseball", fallbackSystem: "baseball.fill") + case .badminton: + return systemOrCustom("figure.badminton", .shuttlecock) + case .tennis: + return .system("figure.tennis") + case .squash: + return systemOrCustom("figure.squash", .squashRacket) + case .racquetball: + return systemOrCustom("figure.racquetball", .racquetballRacket) + case .tableTennis: + return systemOrCustom("figure.table.tennis", .tableTennisPaddle) + case .volleyball: + return .system("figure.volleyball") + case .martialArts: + return .system("figure.martial.arts") + case .dancing: + return .system("figure.dance") + case .golf: + return .system("figure.golf") + case .climbing: + return .system("figure.climbing") + case .stretching: + return .system("figure.flexibility") + case .skiing: + return systemOrCustom("figure.skiing.downhill", fallbackSystem: "figure.skiing.crosscountry") + case .snowboarding: + return systemOrCustom("figure.snowboarding", .snowboardBadge) + case .padel: + return .custom(.padelRacket) + case .pickleball: + return systemOrCustom("figure.pickleball", .pickleballPaddle) + case .bowling: + return .system("figure.bowling") + case .other: + return .system("figure.mixed.cardio") + } + } + + /// Preferred glyph identity ignoring OS availability — used to assert catalogue uniqueness. + public static func preferredIdentity(for type: KnownWorkoutType) -> String { + switch type { + case .running: return "system:figure.run" + case .walking: return "system:figure.walk" + case .hiking: return "system:figure.hiking" + case .cycling: return "system:figure.outdoor.cycle" + case .openWaterSwim: return "system:figure.open.water.swim" + case .rowing: return "system:figure.outdoor.rowing" + case .treadmillRun: return "system:figure.run.treadmill" + case .treadmillWalk: return "system:figure.walk.treadmill" + case .indoorCycle: return "system:figure.indoor.cycle" + case .poolSwim: return "system:figure.pool.swim" + case .rowMachine: return "system:figure.indoor.rowing" + case .elliptical: return "system:figure.elliptical" + case .strength: return "system:dumbbell.fill" + case .bodybuilding: return "system:figure.strengthtraining.traditional" + case .weightlifting: return "system:figure.strengthtraining.functional" + case .hiit: return "system:figure.highintensity.intervaltraining" + case .yoga: return "system:figure.yoga" + case .pilates: return "system:figure.pilates" + case .boxing: return "system:figure.boxing" + case .basketball: return "system:figure.basketball" + case .soccer: return "system:figure.outdoor.soccer" + case .baseball: return "system:figure.baseball" + case .badminton: return "system:figure.badminton" + case .tennis: return "system:figure.tennis" + case .squash: return "system:figure.squash" + case .racquetball: return "system:figure.racquetball" + case .tableTennis: return "system:figure.table.tennis" + case .volleyball: return "system:figure.volleyball" + case .martialArts: return "system:figure.martial.arts" + case .dancing: return "system:figure.dance" + case .golf: return "system:figure.golf" + case .climbing: return "system:figure.climbing" + case .stretching: return "system:figure.flexibility" + case .skiing: return "system:figure.skiing.downhill" + case .snowboarding: return "system:figure.snowboarding" + case .padel: return "custom:padelRacket" + case .pickleball: return "system:figure.pickleball" + case .bowling: return "system:figure.bowling" + case .other: return "system:figure.mixed.cardio" + } + } + + /// Runtime glyph identity (after availability resolution). + public static func identity(for type: KnownWorkoutType) -> String { + switch glyph(for: type) { + case .system(let name): return "system:\(name)" + case .custom(let custom): return "custom:\(custom.rawValue)" + } + } + + /// SF Symbol name for call sites that still need `Image(systemName:)`. Custom-only types return + /// the nearest system stand-in so charts/lists stay populated. + public static func systemSymbolName(for sport: String) -> String { + let type = KnownWorkoutType.resolving(sport) ?? .other + switch glyph(for: type) { + case .system(let name): + return name + case .custom(.padelRacket), .custom(.pickleballPaddle), .custom(.squashRacket), + .custom(.racquetballRacket), .custom(.tableTennisPaddle), .custom(.shuttlecock): + return "figure.tennis" + case .custom(.treadmillRunBadge): + return "figure.run" + case .custom(.treadmillWalkBadge): + return "figure.walk" + case .custom(.indoorCycleBadge): + return "bicycle" + case .custom(.rowMachineBadge): + return "figure.rower" + case .custom(.ellipticalBadge): + return "figure.mixed.cardio" + case .custom(.bodybuildingBadge): + return "dumbbell.fill" + case .custom(.barbellBadge): + return "dumbbell" + case .custom(.snowboardBadge): + return "figure.skiing.downhill" + case .custom(.openWaterWaves): + return "figure.pool.swim" + case .custom(.hikingStick): + return "figure.walk" + } + } + + private static func systemOrCustom(_ preferred: String, _ custom: Custom) -> Glyph { + systemSymbolExists(preferred) ? .system(preferred) : .custom(custom) + } + + private static func systemOrCustom(_ preferred: String, fallbackSystem: String) -> Glyph { + if systemSymbolExists(preferred) { return .system(preferred) } + if systemSymbolExists(fallbackSystem) { return .system(fallbackSystem) } + return .system(preferred) + } + + private static func systemSymbolExists(_ name: String) -> Bool { + #if canImport(UIKit) + return UIImage(systemName: name) != nil + #elseif canImport(AppKit) + return NSImage(systemSymbolName: name, accessibilityDescription: nil) != nil + #else + return true + #endif + } +} + +// MARK: - WorkoutTypeIcon + +/// Monochrome workout-type glyph for Liquid Glass controls, lists, and badges. +public struct WorkoutTypeIcon: View { + private let sport: String + private let size: CGFloat + private let weight: Font.Weight + private let color: Color + + public init(workoutType: String, + size: CGFloat = 22, + weight: Font.Weight = .medium, + color: Color = StrandPalette.textPrimary) { + self.sport = workoutType + self.size = size + self.weight = weight + self.color = color + } + + public init(workoutType: KnownWorkoutType, + size: CGFloat = 22, + weight: Font.Weight = .medium, + color: Color = StrandPalette.textPrimary) { + self.sport = workoutType.rawValue + self.size = size + self.weight = weight + self.color = color + } + + public var body: some View { + let type = KnownWorkoutType.resolving(sport) ?? .other + Group { + switch WorkoutTypeIconography.glyph(for: type) { + case .system(let name): + Image(systemName: name) + .font(.system(size: size, weight: weight)) + .symbolRenderingMode(.monochrome) + case .custom(let custom): + customStroke(custom) + } + } + .foregroundStyle(color) + .frame(width: size, height: size, alignment: .center) + .accessibilityHidden(true) + } + + private var strokeWidth: CGFloat { max(1.35, size * 0.085) } + + @ViewBuilder + private func customStroke(_ custom: WorkoutTypeIconography.Custom) -> some View { + let style = StrokeStyle(lineWidth: strokeWidth, lineCap: .round, lineJoin: .round) + Group { + switch custom { + case .padelRacket: PadelRacketShape().stroke(style: style) + case .pickleballPaddle: PickleballPaddleShape().stroke(style: style) + case .squashRacket: SquashRacketShape().stroke(style: style) + case .racquetballRacket: RacquetballRacketShape().stroke(style: style) + case .tableTennisPaddle: TableTennisPaddleShape().stroke(style: style) + case .shuttlecock: ShuttlecockShape().stroke(style: style) + case .treadmillRunBadge: TreadmillBadgeShape(kind: .run).stroke(style: style) + case .treadmillWalkBadge: TreadmillBadgeShape(kind: .walk).stroke(style: style) + case .indoorCycleBadge: IndoorCycleBadgeShape().stroke(style: style) + case .rowMachineBadge: RowMachineBadgeShape().stroke(style: style) + case .ellipticalBadge: EllipticalBadgeShape().stroke(style: style) + case .bodybuildingBadge: BodybuildingBadgeShape().stroke(style: style) + case .barbellBadge: BarbellBadgeShape().stroke(style: style) + case .snowboardBadge: SnowboardBadgeShape().stroke(style: style) + case .openWaterWaves: OpenWaterWavesShape().stroke(style: style) + case .hikingStick: HikingStickShape().stroke(style: style) + } + } + .frame(width: size, height: size) + } +} + +// MARK: - Custom vectors (SF-Symbol weight / centering) + +struct PadelRacketShape: Shape { + func path(in rect: CGRect) -> Path { + let s = min(rect.width, rect.height) + let cx = rect.midX + var path = Path() + let headW = s * 0.62 + let headH = s * 0.56 + let headRect = CGRect(x: cx - headW / 2, y: rect.minY + s * 0.05, + width: headW, height: headH) + path.addRoundedRect(in: headRect, cornerSize: CGSize(width: headW * 0.28, height: headH * 0.28)) + path.move(to: CGPoint(x: cx, y: headRect.maxY - s * 0.02)) + path.addLine(to: CGPoint(x: cx, y: rect.minY + s * 0.90)) + let butt = s * 0.14 + path.addEllipse(in: CGRect(x: cx - butt / 2, y: rect.minY + s * 0.84, + width: butt, height: s * 0.10)) + let r = s * 0.04 + for (dx, dy) in [(-0.12, 0.22), (0.12, 0.22), (0.0, 0.36)] as [(CGFloat, CGFloat)] { + path.addEllipse(in: CGRect(x: cx + dx * s - r, y: rect.minY + dy * s - r, + width: r * 2, height: r * 2)) + } + return path + } +} + +struct PickleballPaddleShape: Shape { + func path(in rect: CGRect) -> Path { + let s = min(rect.width, rect.height) + let cx = rect.midX + var path = Path() + let headW = s * 0.58 + let headH = s * 0.50 + let headRect = CGRect(x: cx - headW / 2, y: rect.minY + s * 0.06, + width: headW, height: headH) + path.addRoundedRect(in: headRect, cornerSize: CGSize(width: headW * 0.22, height: headH * 0.22)) + path.move(to: CGPoint(x: cx, y: headRect.maxY)) + path.addLine(to: CGPoint(x: cx, y: rect.minY + s * 0.88)) + // Perforated ball to the side of the paddle. + let ballR = s * 0.11 + path.addEllipse(in: CGRect(x: cx + s * 0.22, y: rect.minY + s * 0.55, + width: ballR * 2, height: ballR * 2)) + return path + } +} + +struct SquashRacketShape: Shape { + func path(in rect: CGRect) -> Path { + let s = min(rect.width, rect.height) + let cx = rect.midX + var path = Path() + // Teardrop squash head (taller / narrower than tennis). + let head = CGRect(x: cx - s * 0.22, y: rect.minY + s * 0.04, + width: s * 0.44, height: s * 0.55) + path.addEllipse(in: head) + path.move(to: CGPoint(x: cx, y: head.maxY - s * 0.02)) + path.addLine(to: CGPoint(x: cx, y: rect.minY + s * 0.92)) + // Small ball near throat. + let r = s * 0.07 + path.addEllipse(in: CGRect(x: cx + s * 0.18, y: head.midY, width: r * 2, height: r * 2)) + return path + } +} + +struct TreadmillBadgeShape: Shape { + enum Kind { case run, walk } + var kind: Kind + + func path(in rect: CGRect) -> Path { + let s = min(rect.width, rect.height) + var path = Path() + // Deck + path.move(to: CGPoint(x: rect.minX + s * 0.08, y: rect.minY + s * 0.72)) + path.addLine(to: CGPoint(x: rect.minX + s * 0.92, y: rect.minY + s * 0.72)) + // Console upright + path.move(to: CGPoint(x: rect.minX + s * 0.78, y: rect.minY + s * 0.72)) + path.addLine(to: CGPoint(x: rect.minX + s * 0.78, y: rect.minY + s * 0.28)) + path.addLine(to: CGPoint(x: rect.minX + s * 0.58, y: rect.minY + s * 0.28)) + // Figure cue — forward lean for run, upright for walk. + let figureX = rect.minX + s * 0.38 + let headY = kind == .run ? rect.minY + s * 0.30 : rect.minY + s * 0.26 + path.addEllipse(in: CGRect(x: figureX - s * 0.07, y: headY, width: s * 0.14, height: s * 0.14)) + path.move(to: CGPoint(x: figureX, y: headY + s * 0.14)) + path.addLine(to: CGPoint(x: figureX + (kind == .run ? s * 0.06 : 0), + y: rect.minY + s * 0.58)) + return path + } +} + +struct OpenWaterWavesShape: Shape { + func path(in rect: CGRect) -> Path { + let s = min(rect.width, rect.height) + var path = Path() + for i in 0..<3 { + let y = rect.minY + s * (0.30 + CGFloat(i) * 0.22) + path.move(to: CGPoint(x: rect.minX + s * 0.10, y: y)) + path.addQuadCurve(to: CGPoint(x: rect.minX + s * 0.50, y: y), + control: CGPoint(x: rect.minX + s * 0.30, y: y - s * 0.10)) + path.addQuadCurve(to: CGPoint(x: rect.minX + s * 0.90, y: y), + control: CGPoint(x: rect.minX + s * 0.70, y: y + s * 0.10)) + } + return path + } +} + +struct HikingStickShape: Shape { + func path(in rect: CGRect) -> Path { + let s = min(rect.width, rect.height) + var path = Path() + path.move(to: CGPoint(x: rect.minX + s * 0.62, y: rect.minY + s * 0.08)) + path.addLine(to: CGPoint(x: rect.minX + s * 0.38, y: rect.minY + s * 0.92)) + path.addEllipse(in: CGRect(x: rect.minX + s * 0.22, y: rect.minY + s * 0.18, + width: s * 0.16, height: s * 0.16)) + path.move(to: CGPoint(x: rect.minX + s * 0.30, y: rect.minY + s * 0.34)) + path.addLine(to: CGPoint(x: rect.minX + s * 0.30, y: rect.minY + s * 0.62)) + path.move(to: CGPoint(x: rect.minX + s * 0.30, y: rect.minY + s * 0.62)) + path.addLine(to: CGPoint(x: rect.minX + s * 0.18, y: rect.minY + s * 0.82)) + path.move(to: CGPoint(x: rect.minX + s * 0.30, y: rect.minY + s * 0.62)) + path.addLine(to: CGPoint(x: rect.minX + s * 0.44, y: rect.minY + s * 0.82)) + return path + } +} + +struct RacquetballRacketShape: Shape { + func path(in rect: CGRect) -> Path { + let s = min(rect.width, rect.height) + let cx = rect.midX + var path = Path() + let head = CGRect(x: cx - s * 0.28, y: rect.minY + s * 0.06, width: s * 0.56, height: s * 0.48) + path.addEllipse(in: head) + path.move(to: CGPoint(x: cx, y: head.maxY)) + path.addLine(to: CGPoint(x: cx, y: rect.minY + s * 0.92)) + let r = s * 0.08 + path.addEllipse(in: CGRect(x: cx + s * 0.20, y: head.midY - r, width: r * 2, height: r * 2)) + return path + } +} + +struct TableTennisPaddleShape: Shape { + func path(in rect: CGRect) -> Path { + let s = min(rect.width, rect.height) + let cx = rect.midX + var path = Path() + let head = CGRect(x: cx - s * 0.30, y: rect.minY + s * 0.08, width: s * 0.56, height: s * 0.50) + path.addEllipse(in: head) + path.move(to: CGPoint(x: cx - s * 0.02, y: head.maxY - s * 0.02)) + path.addLine(to: CGPoint(x: cx - s * 0.08, y: rect.minY + s * 0.92)) + let r = s * 0.07 + path.addEllipse(in: CGRect(x: cx + s * 0.22, y: head.maxY - r, width: r * 2, height: r * 2)) + return path + } +} + +struct ShuttlecockShape: Shape { + func path(in rect: CGRect) -> Path { + let s = min(rect.width, rect.height) + let cx = rect.midX + var path = Path() + // Cork + path.addEllipse(in: CGRect(x: cx - s * 0.12, y: rect.minY + s * 0.62, width: s * 0.24, height: s * 0.22)) + // Skirt feathers + for dx in [-0.28, -0.14, 0.0, 0.14, 0.28] as [CGFloat] { + path.move(to: CGPoint(x: cx, y: rect.minY + s * 0.68)) + path.addLine(to: CGPoint(x: cx + dx * s, y: rect.minY + s * 0.12)) + } + return path + } +} + +struct IndoorCycleBadgeShape: Shape { + func path(in rect: CGRect) -> Path { + let s = min(rect.width, rect.height) + var path = Path() + path.addEllipse(in: CGRect(x: rect.minX + s * 0.18, y: rect.minY + s * 0.42, + width: s * 0.64, height: s * 0.48)) + path.move(to: CGPoint(x: rect.minX + s * 0.50, y: rect.minY + s * 0.42)) + path.addLine(to: CGPoint(x: rect.minX + s * 0.50, y: rect.minY + s * 0.18)) + path.addLine(to: CGPoint(x: rect.minX + s * 0.72, y: rect.minY + s * 0.18)) + return path + } +} + +struct RowMachineBadgeShape: Shape { + func path(in rect: CGRect) -> Path { + let s = min(rect.width, rect.height) + var path = Path() + // Rail + path.move(to: CGPoint(x: rect.minX + s * 0.08, y: rect.minY + s * 0.70)) + path.addLine(to: CGPoint(x: rect.minX + s * 0.92, y: rect.minY + s * 0.70)) + // Seat + path.addRoundedRect(in: CGRect(x: rect.minX + s * 0.30, y: rect.minY + s * 0.52, + width: s * 0.28, height: s * 0.14), + cornerSize: CGSize(width: 3, height: 3)) + // Flywheel + path.addEllipse(in: CGRect(x: rect.minX + s * 0.68, y: rect.minY + s * 0.38, + width: s * 0.24, height: s * 0.24)) + return path + } +} + +struct EllipticalBadgeShape: Shape { + func path(in rect: CGRect) -> Path { + let s = min(rect.width, rect.height) + var path = Path() + path.addEllipse(in: CGRect(x: rect.minX + s * 0.12, y: rect.minY + s * 0.48, + width: s * 0.76, height: s * 0.36)) + path.move(to: CGPoint(x: rect.minX + s * 0.28, y: rect.minY + s * 0.48)) + path.addLine(to: CGPoint(x: rect.minX + s * 0.28, y: rect.minY + s * 0.20)) + path.move(to: CGPoint(x: rect.minX + s * 0.72, y: rect.minY + s * 0.48)) + path.addLine(to: CGPoint(x: rect.minX + s * 0.72, y: rect.minY + s * 0.20)) + return path + } +} + +struct BodybuildingBadgeShape: Shape { + func path(in rect: CGRect) -> Path { + let s = min(rect.width, rect.height) + let cx = rect.midX + var path = Path() + // Wide dumbbell — thicker plates than Strength's system glyph cue. + path.addRoundedRect(in: CGRect(x: cx - s * 0.42, y: rect.minY + s * 0.38, + width: s * 0.18, height: s * 0.24), + cornerSize: CGSize(width: 2, height: 2)) + path.addRoundedRect(in: CGRect(x: cx + s * 0.24, y: rect.minY + s * 0.38, + width: s * 0.18, height: s * 0.24), + cornerSize: CGSize(width: 2, height: 2)) + path.move(to: CGPoint(x: cx - s * 0.24, y: rect.midY)) + path.addLine(to: CGPoint(x: cx + s * 0.24, y: rect.midY)) + return path + } +} + +struct BarbellBadgeShape: Shape { + func path(in rect: CGRect) -> Path { + let s = min(rect.width, rect.height) + let cy = rect.midY + var path = Path() + path.move(to: CGPoint(x: rect.minX + s * 0.06, y: cy)) + path.addLine(to: CGPoint(x: rect.minX + s * 0.94, y: cy)) + for x in [0.16, 0.28, 0.72, 0.84] as [CGFloat] { + path.addRect(CGRect(x: rect.minX + s * x - s * 0.04, y: cy - s * 0.18, + width: s * 0.08, height: s * 0.36)) + } + return path + } +} + +struct SnowboardBadgeShape: Shape { + func path(in rect: CGRect) -> Path { + let s = min(rect.width, rect.height) + var path = Path() + path.addRoundedRect(in: CGRect(x: rect.minX + s * 0.18, y: rect.minY + s * 0.12, + width: s * 0.28, height: s * 0.76), + cornerSize: CGSize(width: s * 0.14, height: s * 0.14)) + path.move(to: CGPoint(x: rect.minX + s * 0.46, y: rect.minY + s * 0.35)) + path.addLine(to: CGPoint(x: rect.minX + s * 0.70, y: rect.minY + s * 0.22)) + path.move(to: CGPoint(x: rect.minX + s * 0.46, y: rect.minY + s * 0.55)) + path.addLine(to: CGPoint(x: rect.minX + s * 0.78, y: rect.minY + s * 0.70)) + return path + } +} + +// MARK: - Legacy `sportSymbol` bridge + +/// The SF Symbol that best represents a free-text `sport` label. Shared so a sport reads identically +/// in lists/charts that still take `Image(systemName:)`. Prefer `WorkoutTypeIcon` for new surfaces. public func sportSymbol(_ sport: String) -> String { - let s = sport.lowercased() - switch true { - case s.contains("run"): return "figure.run" - case s.contains("walk") || s.contains("hike"): return "figure.walk" - case s.contains("cycl") || s.contains("bike") || s.contains("ride"): - return "figure.outdoor.cycle" - case s.contains("swim"): return "figure.pool.swim" - case s.contains("row"): return "figure.rower" - case s.contains("yoga"): return "figure.yoga" - case s.contains("strength") || s.contains("weight") || s.contains("lift"): - return "dumbbell.fill" - case s.contains("box"): return "figure.boxing" - case s.contains("martial") || s.contains("jiu") || s.contains("judo") || s.contains("karate"): - return "figure.martial.arts" - case s.contains("hiit") || s.contains("functional"): - return "figure.highintensity.intervaltraining" - case s.contains("elliptical"): return "figure.elliptical" - case s.contains("snowboard"): return "figure.snowboarding" - case s.contains("ski"): return "figure.skiing.downhill" - // "padel"/"pickleball" deliberately precede "tennis" so they don't get swallowed by a - // broader racket match; all the racket sports share the tennis glyph (no dedicated SF Symbol). - case s.contains("padel") || s.contains("pickle") || s.contains("tennis") - || s.contains("squash") || s.contains("racquet") || s.contains("badminton"): - return "figure.tennis" - case s.contains("volleyball"): return "figure.volleyball" - case s.contains("stretch"): return "figure.flexibility" - case s.contains("golf"): return "figure.golf" - case s.contains("bowl"): return "figure.bowling" - case s.contains("soccer") || s.contains("football"): - return "figure.soccer" - case s.contains("basketball"): return "figure.basketball" - case s.contains("dance"): return "figure.dance" - case s.contains("climb"): return "figure.climbing" - case s.contains("pilates"): return "figure.pilates" - case s.contains("meditat"): return "figure.mind.and.body" - default: return "figure.mixed.cardio" + WorkoutTypeIconography.systemSymbolName(for: sport) +} + +#if DEBUG +#Preview("Workout type icons") { + ScrollView { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 96))], spacing: 16) { + ForEach(KnownWorkoutType.allCases, id: \.rawValue) { type in + VStack(spacing: 8) { + ZStack { + Circle().fill(StrandPalette.surfaceRaised) + WorkoutTypeIcon(workoutType: type, size: 22, weight: .semibold) + } + .frame(width: 56, height: 56) + Text(type.rawValue) + .font(.caption2) + .multilineTextAlignment(.center) + .foregroundStyle(StrandPalette.textSecondary) + } + .frame(maxWidth: .infinity) + } + } + .padding() } + .background(StrandPalette.surfaceBase) } +#endif diff --git a/Packages/StrandDesign/Tests/StrandDesignTests/WorkoutTypeIconTests.swift b/Packages/StrandDesign/Tests/StrandDesignTests/WorkoutTypeIconTests.swift new file mode 100644 index 0000000000..a8ed9bdf11 --- /dev/null +++ b/Packages/StrandDesign/Tests/StrandDesignTests/WorkoutTypeIconTests.swift @@ -0,0 +1,52 @@ +import XCTest +@testable import StrandDesign + +final class WorkoutTypeIconTests: XCTestCase { + + func testPreferredIdentitiesAreUnique() { + var seen = Set() + for type in KnownWorkoutType.allCases { + let id = WorkoutTypeIconography.preferredIdentity(for: type) + XCTAssertFalse(seen.contains(id), "Duplicate preferred icon identity \(id) for \(type.rawValue)") + seen.insert(id) + } + XCTAssertEqual(seen.count, KnownWorkoutType.allCases.count) + } + + func testRuntimeIdentitiesAreUnique() { + var seen = Set() + for type in KnownWorkoutType.allCases { + let id = WorkoutTypeIconography.identity(for: type) + XCTAssertFalse(seen.contains(id), "Duplicate runtime icon identity \(id) for \(type.rawValue)") + seen.insert(id) + } + XCTAssertEqual(seen.count, KnownWorkoutType.allCases.count) + } + + func testExactResolveMatchesRawValues() { + for type in KnownWorkoutType.allCases { + XCTAssertEqual(KnownWorkoutType.exact(matching: type.rawValue), type) + XCTAssertEqual(KnownWorkoutType.exact(matching: type.rawValue.lowercased()), type) + } + } + + func testFuzzyResolveCoversCommonAliases() { + XCTAssertEqual(KnownWorkoutType.resolving("Morning Run"), .running) + XCTAssertEqual(KnownWorkoutType.resolving("trail hike"), .hiking) + XCTAssertEqual(KnownWorkoutType.resolving("indoor bike"), .indoorCycle) + XCTAssertEqual(KnownWorkoutType.resolving("open water swimming"), .openWaterSwim) + XCTAssertEqual(KnownWorkoutType.resolving("detected"), .other) + XCTAssertNil(KnownWorkoutType.resolving("")) + } + + func testPadelUsesCustomGlyph() { + XCTAssertEqual(WorkoutTypeIconography.glyph(for: .padel), + .custom(.padelRacket)) + } + + func testSportSymbolBridgeNonEmpty() { + for type in KnownWorkoutType.allCases { + XCTAssertFalse(sportSymbol(type.rawValue).isEmpty) + } + } +} diff --git a/Strand/Screens/LiveView.swift b/Strand/Screens/LiveView.swift index ac63dba928..ed1d239a4c 100644 --- a/Strand/Screens/LiveView.swift +++ b/Strand/Screens/LiveView.swift @@ -137,7 +137,7 @@ struct LiveView: View { } // Pick a named sport before starting (#519) — the live workout view then opens // off the activeWorkout change above, so no extra navigation is needed here. - .sheet(isPresented: $showStartSport) { + .workoutSelectionCover(isPresented: $showStartSport) { StartWorkoutSheet { name in model.startWorkout(sport: name) } } // Manual HRV snapshot (#127) — a still, seated 60s R-R reading. diff --git a/Strand/Screens/LiveWorkoutView.swift b/Strand/Screens/LiveWorkoutView.swift index ba1c510ada..b0ce080ac8 100644 --- a/Strand/Screens/LiveWorkoutView.swift +++ b/Strand/Screens/LiveWorkoutView.swift @@ -34,8 +34,8 @@ struct LiveWorkoutView: View { /// the moment it leaves, which is exactly the bounded usage Apple asks for. iOS-only (no-op on Mac). @AppStorage("workoutKeepScreenOn") private var keepScreenOn = false - /// Guards the destructive End action behind a confirm (#517) — a stray tap on the full-width button - /// used to end the workout instantly with no way back. + /// Guards the destructive End action behind a confirm (#517) — a stray tap on the compact exit + /// control must not end the workout instantly with no way back. @State private var showEndConfirm = false private var zoneSet: HRZoneSet { HRZones.zones(maxHR: Double(model.profile.hrMax)) } @@ -59,13 +59,17 @@ struct LiveWorkoutView: View { // standard fitness sensor is feeding metrics, refreshing on its own packets without // re-rendering the HR hero / effort gauge above (scroll-stutter isolation). SensorRowIfPresent() - Spacer(minLength: NoopMetrics.space3) - endButton } .screenPadding() .padding(.vertical, NoopMetrics.space6) + .padding(.bottom, NoopMetrics.space8) .frame(maxWidth: .infinity, alignment: .leading) } + // Floating end / elapsed / sport-type controls sit in the bottom safe area so the scroll + // content never owns the chrome and the timer can stay screen-centered. + .safeAreaInset(edge: .bottom, spacing: 0) { + bottomControlRow + } // A scenic Effort-tinted backdrop behind the whole in-exercise screen, fading to the base — the // live workout reads as an Effort-world hero, not a flat panel. .background { @@ -92,8 +96,8 @@ struct LiveWorkoutView: View { // flipped off mid-workout, this clears any hold we placed. ScreenIdle.keepAwake(false) } - // Confirm before ending (#517): a stray tap on "End workout" used to stop the session and - // discard the in-progress recording with no way back. + // Confirm before ending (#517): ending still requires an explicit confirm so a stray tap on the + // compact exit control cannot discard the in-progress recording with no way back. .alert("End this workout?", isPresented: $showEndConfirm) { Button("Cancel", role: .cancel) { } @@ -108,10 +112,6 @@ struct LiveWorkoutView: View { private var header: some View { HStack(alignment: .center) { - Text("Workout") - .font(StrandFont.title1) - .foregroundStyle(StrandPalette.textPrimary) - Spacer() HStack(spacing: NoopMetrics.space1) { Circle() .fill(StrandPalette.metricRose) @@ -124,7 +124,10 @@ struct LiveWorkoutView: View { .padding(.vertical, NoopMetrics.space1) .background(NoopPanelSurface(tint: StrandPalette.metricRose, cornerRadius: 14)) .clipShape(Capsule()) + Spacer(minLength: 0) } + .accessibilityElement(children: .combine) + .accessibilityLabel(Text("Recording workout")) } /// Centered elapsed-time stack — same TimelineView source as before; card chrome removed so @@ -180,6 +183,14 @@ struct LiveWorkoutView: View { let displayEffort = UnitFormatter.effortValue(strain, scale: effortScale) let maxValue = effortScale == .whoop ? 21.0 : 100.0 let fraction = min(max(displayEffort / maxValue, 0), 1) + // VoiceOver needs the selected scale maximum (0–21 / 0–100) even though the visible denominator + // was removed from the glanceable layout. Reuse the same localized "of %@" caption as Today / + // Week-in-review, and format the spoken value like the on-screen CountUpText. + let valueText = effortScale == .whoop + ? String(format: "%.1f", displayEffort) + : "\(Int(displayEffort.rounded()))" + let scaleCaption = String(localized: "of \(UnitFormatter.effortScaleMax(effortScale))") + let effortAccessibilityLabel = "\(String(localized: "Effort")) \(valueText) \(scaleCaption)" return VStack(spacing: NoopMetrics.space1) { CountUpText(value: displayEffort, format: { value in @@ -189,7 +200,7 @@ struct LiveWorkoutView: View { }, font: StrandFont.rounded(56, weight: .semibold), color: StrandPalette.textPrimary) - .accessibilityLabel(Text(UnitFormatter.effortDisplay(strain, scale: effortScale))) + .accessibilityLabel(effortAccessibilityLabel) .accessibilityValue(Text(StrainGauge.stateLabel(forFraction: fraction))) Text("EFFORT BUILDING") @@ -283,10 +294,83 @@ struct LiveWorkoutView: View { .frame(width: 1, height: 48) } - private var endButton: some View { - NoopButton("End workout", systemImage: "stop.fill", kind: .destructive, fullWidth: true) { - showEndConfirm = true + // MARK: - Bottom floating controls + + /// Diameter shared by the exit and workout-type Liquid Glass circles so the centered timer stays + /// optically balanced against equal side chrome. + private static let bottomControlDiameter: CGFloat = 56 + /// Tight inset so the glass circles nest into the capsule ends (stopwatch-bar proportions). + private static let bottomBarInset: CGFloat = 4 + + /// One shared dark floating capsule: Liquid Glass exit · elapsed · Liquid Glass workout-type. + /// The timer is centered in the bar via ZStack; the glass circles sit in a separate HStack so + /// uneven label widths cannot pull the time off-center. The capsule itself is solid elevated + /// chrome — not Liquid Glass. + private var bottomControlRow: some View { + ZStack { + bottomElapsedTimer + .allowsHitTesting(false) + + HStack(spacing: 0) { + endWorkoutGlassButton + Spacer(minLength: 0) + workoutTypeGlassButton + } + } + .padding(Self.bottomBarInset) + .background { + NoopPanelSurface(cornerRadius: NoopVisualStyle.pillRadius, elevated: true) } + .padding(.horizontal, NoopMetrics.space4) + .padding(.top, NoopMetrics.space2) + .padding(.bottom, NoopMetrics.space3) + } + + /// Same `activeWorkout.start` + `TimelineView` source as the hero TIME block — plain primary text, + /// no card / glass / capsule behind it (the shared bar owns the surface). + private var bottomElapsedTimer: some View { + Group { + if let start = model.activeWorkout?.start { + TimelineView(.periodic(from: .now, by: 1)) { _ in + Text(Self.elapsed(since: start)) + .font(StrandFont.number(40)).monospacedDigit() + .foregroundStyle(StrandPalette.textPrimary) + .contentTransition(.numericText()) + } + .accessibilityLabel(Text("Elapsed time")) + .accessibilityValue(Text(Self.elapsed(since: start))) + } + } + .frame(maxWidth: .infinity) + } + + private var endWorkoutGlassButton: some View { + Button { showEndConfirm = true } label: { + Image(systemName: "xmark") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(StrandPalette.textPrimary) + .frame(width: Self.bottomControlDiameter, height: Self.bottomControlDiameter) + .contentShape(Circle()) + } + .nativeLiquidGlassWorkoutControl() + .accessibilityLabel(Text("End workout")) + .accessibilityHint(Text("Stops recording and saves what's captured so far")) + } + + private var activeSportName: String { + model.activeWorkout?.sport ?? WorkoutCatalog.defaultSportName + } + + private var workoutTypeGlassButton: some View { + Button { + // Placeholder — sport-type picker lands later; chrome and sizing stay put. + } label: { + WorkoutTypeIcon(workoutType: activeSportName, size: 22, weight: .semibold) + .frame(width: Self.bottomControlDiameter, height: Self.bottomControlDiameter) + .contentShape(Circle()) + } + .nativeLiquidGlassWorkoutControl() + .accessibilityLabel(Text("\(WorkoutSource.displaySport(activeSportName)) workout")) } // MARK: - Helpers @@ -308,6 +392,28 @@ struct LiveWorkoutView: View { } } +// MARK: - Native Liquid Glass workout controls + +private extension View { + /// Platform-owned circular chrome for the live-workout bottom controls. iOS 26 uses the interactive + /// Liquid Glass button material; older supported releases keep the same circular geometry with the + /// same native-system material fallback the Home header buttons already use. + @ViewBuilder + func nativeLiquidGlassWorkoutControl() -> some View { + if #available(iOS 26.0, *) { + self + .buttonStyle(.glass) + .buttonBorderShape(.circle) + .controlSize(.large) + } else { + self + .buttonStyle(LiquidPressStyle()) + .background(.ultraThinMaterial, in: Circle()) + .overlay(Circle().strokeBorder(.white.opacity(0.16), lineWidth: 0.8)) + } + } +} + // MARK: - Live-observing leaf (scroll-stutter isolation) /// Additive readout for a connected standard fitness sensor (a footpod / bike speed-cadence sensor / diff --git a/Strand/Screens/ManualWorkoutSheet.swift b/Strand/Screens/ManualWorkoutSheet.swift index 8a2bce7363..081949b9fc 100644 --- a/Strand/Screens/ManualWorkoutSheet.swift +++ b/Strand/Screens/ManualWorkoutSheet.swift @@ -368,154 +368,6 @@ struct ManualWorkoutSheet: View { } } -// MARK: - Live workout start picker -// -// The Apple-side entry point for LIVE tracking, mirroring Android's StartWorkoutSheet (WorkoutStart.kt): -// pick a named sport from the shared WorkoutCatalog, then begin the session. Brings the iOS/macOS live -// tracker to parity with Android, which has had a named-sport picker on Start since #115 — previously -// the Apple "Start workout" buttons called `startWorkout()` with no sport and every live session saved -// as the generic "Workout". A host presents this and forwards the chosen name to -// `AppModel.startWorkout(sport:)`. Free-text isn't offered here (a live start is a quick tap from a -// fixed list); an unusual sport can still be set afterwards via the manual edit sheet's free-text field. - -struct StartWorkoutSheet: View { - /// Called with the chosen sport name once the user taps the action button. The host wires this to - /// `model.startWorkout(sport:)` (and presents the live workout view) by default, or (#64) to name a - /// merged session when the title/action are overridden. - let onStart: (_ sport: String) -> Void - - /// #64: heading + explainer + action-verb overrides so this picker doubles as the "name the merged - /// session" prompt. Defaults keep the "Start a workout" behaviour byte-identical. - private let heading: String - private let explainer: String - private let actionVerb: String - - init(title: String? = nil, subtitle: String? = nil, actionVerb: String? = nil, - onStart: @escaping (_ sport: String) -> Void) { - self.onStart = onStart - self.heading = title ?? String(localized: "Start a workout") - self.explainer = subtitle - ?? String(localized: "Pick a sport. NOOP records HR, peak, average and effort from the live feed.") - self.actionVerb = actionVerb ?? String(localized: "Start") - } - - @Environment(\.dismiss) private var dismiss - @State private var query = "" - @State private var selected = WorkoutCatalog.defaultSportName - - private var filtered: [WorkoutCatalog.Sport] { WorkoutCatalog.matching(query) } - private var inputShape: RoundedRectangle { RoundedRectangle(cornerRadius: 10, style: .continuous) } - - /// #297: the user's last selections, one tap away above the full catalogue. Only catalogue-resolvable - /// recents show here — a live start is catalogue-only by design (no free text), and the shared store - /// can hold free-typed names from the manual sheet. Hidden once the user starts searching. - private var recentSports: [WorkoutCatalog.Sport] { - RecentSportsPrefs.recent().compactMap { WorkoutCatalog.sport(named: $0) } - } - - private var showRecentSports: Bool { - query.trimmingCharacters(in: .whitespaces).isEmpty && !recentSports.isEmpty - } - - var body: some View { - VStack(alignment: .leading, spacing: NoopMetrics.space4) { - HStack(alignment: .top, spacing: NoopMetrics.space3) { - Image(systemName: "figure.run") - .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(StrandPalette.effortColor) - .frame(width: 30, height: 30) - .background(StrandPalette.effortColor.opacity(0.14), - in: RoundedRectangle(cornerRadius: 9, style: .continuous)) - .accessibilityHidden(true) - VStack(alignment: .leading, spacing: 2) { - Text(heading) - .font(StrandFont.title2) - .foregroundStyle(StrandPalette.textPrimary) - Text(explainer) - .font(StrandFont.subhead) - .foregroundStyle(StrandPalette.textSecondary) - .fixedSize(horizontal: false, vertical: true) - } - Spacer(minLength: 0) - } - - TextField("Search sport", text: $query) - .textFieldStyle(.plain) - .font(StrandFont.body) - .foregroundStyle(StrandPalette.textPrimary) - .padding(.horizontal, 12).padding(.vertical, 9) - .background(StrandPalette.surfaceInset, in: inputShape) - .overlay(inputShape.strokeBorder(StrandPalette.hairline, lineWidth: 1)) - .accessibilityLabel("Search sport") - - ScrollView { - VStack(alignment: .leading, spacing: 0) { - if showRecentSports { - Text("Recent").strandOverline() - .padding(.horizontal, 12).padding(.top, 8) - ForEach(recentSports) { sp in - sportRow(sp) - } - Text("All activities").strandOverline() - .padding(.horizontal, 12).padding(.top, 8) - } - ForEach(filtered) { sp in - sportRow(sp) - } - } - } - .frame(maxHeight: 240) - .background(StrandPalette.surfaceInset, in: inputShape) - .overlay(inputShape.strokeBorder(StrandPalette.hairline, lineWidth: 1)) - - HStack(spacing: NoopMetrics.space3) { - NoopButton("Cancel", kind: .tertiary) { dismiss() } - Spacer() - NoopButton("\(actionVerb) \(selected)", systemImage: "figure.run", kind: .primary) { - // #297: a confirmed start (or merge-name) is a real selection — fold it into the recents. - RecentSportsPrefs.recordSelection(selected) - onStart(selected) - dismiss() - } - .accessibilityLabel("\(actionVerb) \(selected)") - } - } - .padding(NoopMetrics.space6) - #if os(macOS) - .frame(width: 420) - #else - .frame(maxWidth: .infinity) - .noopSheetPresentation(largeFirst: false) - #endif - .background(NoopChromeSurface()) - } - - /// One tappable sport row — shared by the #297 Recent block and the full catalogue list. - private func sportRow(_ sp: WorkoutCatalog.Sport) -> some View { - Button { - selected = sp.name - } label: { - HStack(spacing: 6) { - Text(sp.name) - .font(StrandFont.body) - .foregroundStyle(sp.name == selected - ? StrandPalette.accent : StrandPalette.textPrimary) - if sp.isDistanceSport { - Text("· GPS") - .font(StrandFont.footnote) - .foregroundStyle(StrandPalette.textTertiary) - } - Spacer(minLength: 0) - } - .contentShape(Rectangle()) - .padding(.horizontal, 12).padding(.vertical, 9) - } - .buttonStyle(.plain) - .accessibilityLabel("Pick \(sp.name)") - .accessibilityAddTraits(sp.name == selected ? [.isSelected] : []) - } -} - #if DEBUG #Preview("Add") { ManualWorkoutSheet { _, _ in } @@ -529,9 +381,4 @@ struct StartWorkoutSheet: View { avgHr: 148, maxHr: 172, strain: 12.4, distanceM: nil, zonesJSON: nil, notes: nil)) { _, _ in } .preferredColorScheme(.dark) } - -#Preview("Start") { - StartWorkoutSheet { _ in } - .preferredColorScheme(.dark) -} #endif diff --git a/Strand/Screens/MarkerEditorView.swift b/Strand/Screens/MarkerEditorView.swift index f76a70b59b..0115ae3058 100644 --- a/Strand/Screens/MarkerEditorView.swift +++ b/Strand/Screens/MarkerEditorView.swift @@ -102,20 +102,9 @@ struct MarkerEditorView: View { } private var searchField: some View { - HStack(spacing: 8) { - Image(systemName: "magnifyingglass") - .font(.system(size: 13)) - .foregroundStyle(StrandPalette.textTertiary) - .accessibilityHidden(true) - TextField("Search markers (e.g. LDL, ferritin)", text: $search) - .textFieldStyle(.plain) - .font(StrandFont.body) - .foregroundStyle(StrandPalette.textPrimary) - .accessibilityLabel("Search markers") - } - .padding(.horizontal, 12).padding(.vertical, 9) - .background(StrandPalette.surfaceInset, in: inputShape) - .overlay(inputShape.strokeBorder(StrandPalette.hairline, lineWidth: 1)) + NoopLiquidGlassSearchField(text: $search, + prompt: String(localized: "Search markers (e.g. LDL, ferritin)"), + accessibilityLabel: String(localized: "Search markers")) } private var filteredCatalog: [MarkerDefinition] { diff --git a/Strand/Screens/WorkoutSelectionScreen.swift b/Strand/Screens/WorkoutSelectionScreen.swift new file mode 100644 index 0000000000..427c293e03 --- /dev/null +++ b/Strand/Screens/WorkoutSelectionScreen.swift @@ -0,0 +1,401 @@ +import SwiftUI +import StrandDesign + +// MARK: - Workout selection browser +// +// Full-screen activity picker for live start (and the merge-name reuse). Catalogue, recents, GPS +// flags, and `onStart` / `RecentSportsPrefs` are unchanged — only the presentation is rebuilt into +// large destination cards with native Liquid Glass search. + +/// Public entry used by Live / Workouts. Keeps the prior `onStart` + optional title overrides so the +/// merge-name prompt can reuse the same browser. +struct StartWorkoutSheet: View { + let onStart: (_ sport: String) -> Void + private let heading: String + private let explainer: String + private let actionVerb: String + + init(title: String? = nil, subtitle: String? = nil, actionVerb: String? = nil, + onStart: @escaping (_ sport: String) -> Void) { + self.onStart = onStart + self.heading = title ?? String(localized: "Choose a workout") + self.explainer = subtitle + ?? String(localized: "Pick an activity to begin recording heart rate, effort, peak, and average.") + self.actionVerb = actionVerb ?? String(localized: "Start") + } + + var body: some View { + WorkoutSelectionScreen(heading: heading, explainer: explainer, actionVerb: actionVerb, + onStart: onStart) + } +} + +// MARK: - Screen + +struct WorkoutSelectionScreen: View { + let heading: String + let explainer: String + let actionVerb: String + let onStart: (_ sport: String) -> Void + + @Environment(\.dismiss) private var dismiss + @State private var query = "" + @FocusState private var searchFocused: Bool + + private var trimmedQuery: String { query.trimmingCharacters(in: .whitespaces) } + private var filtered: [WorkoutCatalog.Sport] { WorkoutCatalog.matching(query) } + private var recentSports: [WorkoutCatalog.Sport] { + RecentSportsPrefs.recent().compactMap { WorkoutCatalog.sport(named: $0) } + } + private var showRecent: Bool { trimmedQuery.isEmpty && !recentSports.isEmpty } + + var body: some View { + NavigationStack { + ScrollView { + LazyVStack(alignment: .leading, spacing: NoopMetrics.space5) { + headerCopy + WorkoutSearchField(query: $query, isFocused: $searchFocused) + .padding(.top, NoopMetrics.space1) + + if showRecent { + recentSection + } + + if filtered.isEmpty { + emptyResults + .padding(.top, NoopMetrics.space8) + } else { + LazyVStack(spacing: NoopMetrics.space4) { + ForEach(filtered) { sport in + WorkoutSelectionCard(sport: sport, actionVerb: actionVerb) { + select(sport.name) + } + } + } + } + } + .padding(.horizontal, NoopMetrics.space5) + .padding(.top, NoopMetrics.space2) + .padding(.bottom, NoopMetrics.space10) + } + .scrollDismissesKeyboard(.interactively) + .background { + StrandPalette.surfaceBase.ignoresSafeArea() + } + .navigationBarTitleDisplayModeCompat() + .toolbar { + ToolbarItem(placement: .topBarTrailingCompat) { + Button { dismiss() } label: { + Image(systemName: "xmark") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(StrandPalette.textPrimary) + .frame(width: 34, height: 34) + .contentShape(Circle()) + } + .nativeLiquidGlassWorkoutSelectionControl() + .accessibilityLabel(Text("Close")) + } + } + } + #if os(macOS) + .frame(minWidth: 480, minHeight: 640) + #endif + } + + private var headerCopy: some View { + VStack(alignment: .leading, spacing: NoopMetrics.space2) { + Text(heading) + .font(StrandFont.rounded(34, weight: .bold)) + .foregroundStyle(StrandPalette.textPrimary) + .fixedSize(horizontal: false, vertical: true) + Text(explainer) + .font(StrandFont.subhead) + .foregroundStyle(StrandPalette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + } + + private var recentSection: some View { + VStack(alignment: .leading, spacing: NoopMetrics.space3) { + Text("Recent") + .font(StrandFont.overline).tracking(StrandFont.overlineTracking) + .foregroundStyle(StrandPalette.textSecondary) + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: NoopMetrics.space2) { + ForEach(recentSports) { sport in + RecentWorkoutChip(sport: sport) { select(sport.name) } + } + } + } + } + } + + private var emptyResults: some View { + VStack(spacing: NoopMetrics.space3) { + Image(systemName: "magnifyingglass") + .font(.system(size: 28, weight: .semibold)) + .foregroundStyle(StrandPalette.textTertiary) + Text("No workouts found") + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.textPrimary) + Text("Try a different activity name.") + .font(StrandFont.subhead) + .foregroundStyle(StrandPalette.textSecondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, NoopMetrics.space8) + .accessibilityElement(children: .combine) + } + + private func select(_ name: String) { + searchFocused = false + RecentSportsPrefs.recordSelection(name) + onStart(name) + dismiss() + } +} + +// MARK: - Search + +struct WorkoutSearchField: View { + @Binding var query: String + var isFocused: FocusState.Binding + + var body: some View { + NoopLiquidGlassSearchField(text: $query, + prompt: String(localized: "Search workouts"), + isFocused: isFocused) + } +} + +// MARK: - Recent chip + +struct RecentWorkoutChip: View { + let sport: WorkoutCatalog.Sport + let onTap: () -> Void + + private var accent: Color { StrandPalette.effortColor } + + var body: some View { + Button(action: onTap) { + HStack(spacing: NoopMetrics.space2) { + WorkoutTypeIcon(workoutType: sport.name, size: 18, weight: .semibold, color: accent) + Text(sport.name) + .font(StrandFont.subhead) + .foregroundStyle(StrandPalette.textPrimary) + .lineLimit(1) + } + .padding(.horizontal, NoopMetrics.space3) + .padding(.vertical, NoopMetrics.space2) + .frame(minHeight: 44) + .contentShape(Capsule()) + } + .nativeLiquidGlassWorkoutSelectionControl(capsule: true) + .accessibilityLabel(Text("\(sport.name) workout")) + .accessibilityHint(Text("Double tap to start")) + } +} + +// MARK: - Activity card + +struct WorkoutSelectionCard: View { + let sport: WorkoutCatalog.Sport + let actionVerb: String + let onSelect: () -> Void + + private var accent: Color { StrandPalette.effortColor } + private var meta: [WorkoutActivityMeta.Item] { + WorkoutActivityMeta.items(for: sport) + } + + var body: some View { + Button(action: onSelect) { + HStack(alignment: .center, spacing: NoopMetrics.space4) { + WorkoutTypeIcon(workoutType: sport.name, size: 42, weight: .medium, color: accent) + .frame(width: 52, height: 52) + .background(accent.opacity(0.12), in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + + VStack(alignment: .leading, spacing: NoopMetrics.space1) { + Text(sport.name) + .font(StrandFont.title2) + .foregroundStyle(StrandPalette.textPrimary) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + if !meta.isEmpty { + WorkoutActivityMetadataView(items: meta) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + + Image(systemName: "play.fill") + .font(.system(size: 18, weight: .bold)) + .foregroundStyle(StrandPalette.goldDeepText) + .frame(width: 52, height: 52) + .background(Circle().fill(StrandPalette.accent)) + .accessibilityHidden(true) + } + .padding(.horizontal, NoopMetrics.space5) + .padding(.vertical, NoopMetrics.space5) + .frame(maxWidth: .infinity, minHeight: 96, alignment: .leading) + .background { + NoopPanelSurface(tint: accent, cornerRadius: 28, elevated: true) + } + .contentShape(RoundedRectangle(cornerRadius: 28, style: .continuous)) + } + .buttonStyle(LiquidPressStyle()) + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(accessibilityLabelText)) + .accessibilityHint(Text("Double tap to \(actionVerb.lowercased())")) + .accessibilityAddTraits(.isButton) + } + + private var accessibilityLabelText: String { + let labels = meta.map(\.text) + if labels.isEmpty { return "\(sport.name) workout" } + return "\(sport.name) workout, \(labels.joined(separator: ", "))" + } +} + +// MARK: - Metadata + +enum WorkoutActivityMeta { + struct Item: Equatable { + var symbol: String? + var text: String + } + + /// Labels derived only from catalogue flags / known types — no invented capabilities. + static func items(for sport: WorkoutCatalog.Sport) -> [Item] { + var items: [Item] = [] + if sport.isDistanceSport { + items.append(Item(symbol: "location.fill", text: "GPS")) + } + if let type = KnownWorkoutType.exact(matching: sport.name) { + switch type { + case .treadmillRun, .treadmillWalk, .indoorCycle, .poolSwim, .rowMachine, .elliptical: + items.append(Item(symbol: nil, text: "Indoor")) + case .running, .walking, .hiking, .cycling, .openWaterSwim, .rowing, .skiing, .snowboarding: + items.append(Item(symbol: nil, text: "Outdoor")) + case .strength, .bodybuilding, .weightlifting: + items.append(Item(symbol: nil, text: "Strength")) + case .yoga, .pilates, .stretching: + items.append(Item(symbol: nil, text: "Mindfulness")) + case .hiit: + items.append(Item(symbol: nil, text: "Cardio")) + default: + break + } + } + return items + } +} + +struct WorkoutActivityMetadataView: View { + let items: [WorkoutActivityMeta.Item] + + var body: some View { + HStack(spacing: NoopMetrics.space3) { + ForEach(Array(items.enumerated()), id: \.offset) { _, item in + HStack(spacing: 4) { + if let symbol = item.symbol { + Image(systemName: symbol) + .font(.system(size: 11, weight: .semibold)) + } + Text(item.text) + .font(StrandFont.footnote) + } + .foregroundStyle(StrandPalette.textSecondary) + } + } + .accessibilityHidden(true) + } +} + +// MARK: - Presentation helper + +extension View { + /// Full-screen workout browser on iOS; plain sheet on macOS (no fullScreenCover there). + @ViewBuilder + func workoutSelectionCover(isPresented: Binding, + @ViewBuilder content: @escaping () -> StartWorkoutSheet) -> some View { + #if os(iOS) + self.fullScreenCover(isPresented: isPresented, content: content) + #else + self.sheet(isPresented: isPresented, content: content) + #endif + } + + @ViewBuilder + func workoutSelectionCover(item: Binding, + @ViewBuilder content: @escaping (Item) -> StartWorkoutSheet) -> some View { + #if os(iOS) + self.fullScreenCover(item: item, content: content) + #else + self.sheet(item: item, content: content) + #endif + } +} + +// MARK: - Native Liquid Glass chrome (selection browser) + +private extension View { + /// Circular (or capsule) interactive Liquid Glass for close / recent chips. iOS 26 uses the + /// platform glass button; older releases keep circular geometry with the shared material fallback + /// already used by Home header / live-workout controls. + @ViewBuilder + func nativeLiquidGlassWorkoutSelectionControl(capsule: Bool = false) -> some View { + if #available(iOS 26.0, *) { + self + .buttonStyle(.glass) + .buttonBorderShape(capsule ? .capsule : .circle) + .controlSize(.regular) + } else { + self + .buttonStyle(LiquidPressStyle()) + .background { + if capsule { + Capsule().fill(.ultraThinMaterial) + } else { + Circle().fill(.ultraThinMaterial) + } + } + } + } + + /// Native Liquid Glass search field chrome. iOS 26 uses `glassEffect`; older OS versions use a + /// raised solid surface (not a simulated glass stack). + @ViewBuilder + func nativeLiquidGlassSearchField() -> some View { + self.nativeLiquidGlassSearchChrome() + } +} + +private extension View { + @ViewBuilder + func navigationBarTitleDisplayModeCompat() -> some View { + #if os(iOS) + self.navigationBarTitleDisplayMode(.inline) + #else + self + #endif + } +} + +private extension ToolbarItemPlacement { + static var topBarTrailingCompat: ToolbarItemPlacement { + #if os(iOS) + .topBarTrailing + #else + .automatic + #endif + } +} + +#if DEBUG +#Preview("Choose a workout") { + StartWorkoutSheet { _ in } + .preferredColorScheme(.dark) +} +#endif diff --git a/Strand/Screens/WorkoutsView.swift b/Strand/Screens/WorkoutsView.swift index c8d9ac41d3..3049d55de9 100644 --- a/Strand/Screens/WorkoutsView.swift +++ b/Strand/Screens/WorkoutsView.swift @@ -248,7 +248,7 @@ struct WorkoutsView: View { } // #519: name the sport before a live session starts, then open the in-exercise view directly // (same direct present as the button's already-active path — no cross-view auto-present race). - .sheet(isPresented: $showStartSport) { + .workoutSelectionCover(isPresented: $showStartSport) { StartWorkoutSheet { name in model.startWorkout(sport: name) showLiveWorkout = true @@ -256,7 +256,7 @@ struct WorkoutsView: View { } // #64: name the merged session when every selected row is a bare detected bout (there's no sport // to inherit). Reuses the "Start a workout" named-sport picker. - .sheet(item: $mergeSportPrompt) { target in + .workoutSelectionCover(item: $mergeSportPrompt) { target in StartWorkoutSheet(title: String(localized: "Name the merged session"), subtitle: String(localized: "These sessions have no sport label yet. Pick one for the merged session."), actionVerb: String(localized: "Merge")) { name in @@ -484,27 +484,9 @@ struct WorkoutsView: View { } .frame(maxWidth: .infinity) } - HStack(spacing: 6) { - Image(systemName: "magnifyingglass") - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(StrandPalette.textTertiary) - .accessibilityHidden(true) - TextField(String(localized: "Search sport"), text: $searchText) - .font(StrandFont.subhead) - .foregroundStyle(StrandPalette.textPrimary) - .textFieldStyle(.plain) - #if os(iOS) - .autocorrectionDisabled() - .textInputAutocapitalization(.never) - #endif - if !searchText.isEmpty { - Button { searchText = "" } label: { - Image(systemName: "xmark.circle.fill") - .font(.system(size: 12)) - .foregroundStyle(StrandPalette.textTertiary) - } - .accessibilityLabel(String(localized: "Clear search")) - } + HStack(alignment: .center, spacing: NoopMetrics.space2) { + NoopLiquidGlassSearchField(text: $searchText, + prompt: String(localized: "Search sport")) if filter.isActive { Button { withAnimation(.easeOut(duration: 0.15)) { @@ -514,14 +496,13 @@ struct WorkoutsView: View { Label(String(localized: "Clear"), systemImage: "xmark.circle.fill") .font(StrandFont.footnote) .foregroundStyle(StrandPalette.textSecondary) + .padding(.horizontal, 10) + .padding(.vertical, 7) + .frame(minHeight: 44) } .accessibilityLabel(String(localized: "Clear filters")) } } - .padding(.horizontal, 10) - .padding(.vertical, 7) - .background(StrandPalette.surfaceInset.opacity(0.6), - in: RoundedRectangle(cornerRadius: 10, style: .continuous)) } } diff --git a/StrandTests/WorkoutCatalogTests.swift b/StrandTests/WorkoutCatalogTests.swift index 0d8f1e5854..daebc3655f 100644 --- a/StrandTests/WorkoutCatalogTests.swift +++ b/StrandTests/WorkoutCatalogTests.swift @@ -1,5 +1,6 @@ import XCTest @testable import Strand +import StrandDesign /// Pins the suggestion catalogue (#714): the two new indoor presets exist, are spelled byte-for-byte /// the way Android persists them (the stored sport label round-trips cross-platform via CSV / export), @@ -71,4 +72,24 @@ final class WorkoutCatalogTests: XCTestCase { } XCTAssertLessThan(bowling, other, "Bowling extra must sit before the generic Other") } + + /// Iconography lockstep: every catalogue sport must resolve to a `KnownWorkoutType` with a unique + /// preferred glyph so the live-workout Liquid Glass control never shares icons across types. + func testCatalogueSportsHaveUniqueWorkoutTypeIcons() { + let catalogNames = WorkoutCatalog.all.map(\.name) + let knownNames = KnownWorkoutType.allCases.map(\.rawValue) + XCTAssertEqual(Set(catalogNames), Set(knownNames), + "KnownWorkoutType raw values must match WorkoutCatalog.all names exactly") + + var identities = Set() + for sport in WorkoutCatalog.all { + guard let type = KnownWorkoutType.exact(matching: sport.name) else { + return XCTFail("No KnownWorkoutType for catalogue sport \(sport.name)") + } + let id = WorkoutTypeIconography.preferredIdentity(for: type) + XCTAssertFalse(identities.contains(id), "Duplicate icon \(id) for \(sport.name)") + identities.insert(id) + } + XCTAssertEqual(identities.count, WorkoutCatalog.all.count) + } } diff --git a/docs/UI_CUSTOMIZATION_LEDGER.md b/docs/UI_CUSTOMIZATION_LEDGER.md index ea608e6b7e..fb96ed3459 100644 --- a/docs/UI_CUSTOMIZATION_LEDGER.md +++ b/docs/UI_CUSTOMIZATION_LEDGER.md @@ -394,6 +394,123 @@ feature rather than by file so they can be reviewed and ported independently. all accessibility values for Effort. - **Type:** Experimental live-workout layout rearrangement only; no workout behavior or calculation changes. +### EXP-022 — Live workout floating bottom controls + +- **Date:** 2026-08-05 +- **Files:** `Strand/Screens/LiveWorkoutView.swift` +- **Request:** Replace the wide orange End workout button with a compact floating bottom control row: + native Liquid Glass exit, centered elapsed timer text, and a matching workout-type placeholder. +- **Implementation:** Removed the full-width `NoopButton` end control. Added a bottom `safeAreaInset` row + with equal 56pt circular controls — left `xmark` ends the workout (same confirm alert + `endWorkout` / + `onClose`), right `figure.run` placeholder labeled “Workout type” with no action yet. Both use the + native iOS 26 `.buttonStyle(.glass)` / `.buttonBorderShape(.circle)` path (same pattern as Home header + Liquid Glass), with the existing ultra-thin-material circular fallback on older OS versions. Centered + elapsed time is plain `StrandPalette.textPrimary` monospaced digits from the same `activeWorkout.start` + `TimelineView` source as the hero TIME block, laid out in a ZStack so it stays screen-centered + regardless of side-control widths. No shared card/capsule behind the row. +- **Preserved:** End confirmation, workout-ending logic, hero TIME block and all other metrics/zones/ + stats/sensor content, realtime HR, keep-awake, and accessibility for End workout. +- **Type:** Bottom control chrome / layout only; no workout behavior or calculation changes. + +### EXP-023 — Unique workout-type icons in bottom control + +- **Date:** 2026-08-05 +- **Files:** `Packages/StrandDesign/Sources/StrandDesign/SportIcon.swift`, + `Packages/StrandDesign/Tests/StrandDesignTests/WorkoutTypeIconTests.swift`, + `Strand/Screens/LiveWorkoutView.swift`, `StrandTests/WorkoutCatalogTests.swift` +- **Request:** Give every catalogue workout type its own clearly recognizable monochrome icon in the + right Liquid Glass bottom control, via a reusable `WorkoutTypeIcon` component. +- **Implementation:** Added `KnownWorkoutType` (raw values lockstep with `WorkoutCatalog.all`), + centralized `WorkoutTypeIconography` with an exhaustive unique preferred SF Symbol per type, custom + SwiftUI `Shape` vectors when no suitable/available system glyph exists (Padel always; other customs + only as OS-availability fallbacks that never borrow another type’s primary symbol), and + `WorkoutTypeIcon(workoutType:size:weight:)` rendering monochrome primary foreground. Wired the live + workout right control to `WorkoutTypeIcon` bound to `activeWorkout.sport` with accessibility + “{sport} workout”. `sportSymbol(_:)` now bridges through the same resolver for legacy + `Image(systemName:)` call sites. Uniqueness + catalogue lockstep covered by unit tests. +- **Preserved:** Bottom control Liquid Glass chrome, diameters, placement, safe-area inset, end-workout + flow, and all other workout-screen content/behavior. +- **Type:** Iconography only; no workout behavior or calculation changes. + +### EXP-024 — Shared floating workout control bar + +- **Date:** 2026-08-05 +- **Files:** `Strand/Screens/LiveWorkoutView.swift` +- **Request:** Contain the left exit button, centered timer, and right workout-type button inside one + shared horizontal floating capsule matching the stopwatch-bar reference — not three free-floating + elements. +- **Implementation:** Wrapped the existing ZStack (centered timer + equal Liquid Glass end controls) in + a single elevated `NoopPanelSurface` with pill corner radius so the bar reads as one dark solid + floating surface slightly lighter than the workout canvas. Tight 4pt inset nests the circular glass + controls into the capsule ends; small horizontal screen margins keep the bar near full width. Timer + stays plain primary monospaced text with no private chrome. Exit confirm / `endWorkout`, + `WorkoutTypeIcon`, Liquid Glass button APIs, diameters, and safe-area placement unchanged. +- **Preserved:** End-workout confirmation and ending logic, workout-type icon mapping, hero metrics/ + zones/stats, realtime HR, keep-awake, and all other workout-screen content. +- **Type:** Bottom control chrome / layout only; no workout behavior or calculation changes. + +### EXP-025 — Recording badge as live-workout header + +- **Date:** 2026-08-05 +- **Files:** `Strand/Screens/LiveWorkoutView.swift` +- **Request:** Remove the top-left “Workout” title and place the existing RECORDING WORKOUT badge in + that leading position instead. +- **Implementation:** Dropped the title1 “Workout” label; the rose recording capsule now leads the + header row with a trailing spacer. Badge chrome, copy, and tint unchanged. +- **Preserved:** Recording indicator semantics, all metrics/zones/stats, bottom controls, and + workout-ending behavior. +- **Type:** Header presentation only; no workout behavior changes. + +### EXP-026 — Full-screen workout selection browser + +- **Date:** 2026-08-05 +- **Files:** `Strand/Screens/WorkoutSelectionScreen.swift`, `Strand/Screens/ManualWorkoutSheet.swift`, + `Strand/Screens/LiveView.swift`, `Strand/Screens/WorkoutsView.swift`, + `Packages/StrandDesign/Sources/StrandDesign/SportIcon.swift`, `docs/UI_CUSTOMIZATION_LEDGER.md` +- **Request:** Replace the compact sport-picker sheet with a full-screen premium activity browser + (Liquid Glass search, recent chips, large start cards) while preserving start/merge behavior. +- **Implementation:** Added `WorkoutSelectionScreen` with reusable `WorkoutSearchField` (native iOS 26 + `glassEffect` capsule), `RecentWorkoutChip`, `WorkoutSelectionCard`, and `WorkoutActivityMeta` labels + from catalogue GPS / known-type indoor-outdoor-strength-mindfulness-cardio flags. Cards use + `WorkoutTypeIcon` (optional accent color) + play affordance; tap records recent + calls existing + `onStart` then dismisses — footer Cancel/Start row removed. `StartWorkoutSheet` kept as the public + entry (merge title overrides intact). Live/Workouts present via `workoutSelectionCover` + (`fullScreenCover` on iOS, sheet on macOS). +- **Preserved:** `AppModel.startWorkout(sport:)`, merge naming `onStart`, `RecentSportsPrefs`, + `WorkoutCatalog.matching`, GPS `isDistanceSport`, dismiss/close, and workouts-history UI behind the + cover. +- **Type:** Selection presentation redesign only; no workout scoring, BLE, or persistence changes. + +### EXP-027 — Native Liquid Glass search fields app-wide + +- **Date:** 2026-08-05 +- **Files:** `Packages/StrandDesign/Sources/StrandDesign/NoopLiquidGlassSearchField.swift`, + `Strand/Screens/WorkoutSelectionScreen.swift`, `Strand/Screens/WorkoutsView.swift`, + `Strand/Screens/MarkerEditorView.swift` +- **Request:** Update all in-app search bars to the native Liquid Glass search control. +- **Implementation:** Added shared `NoopLiquidGlassSearchField` + `nativeLiquidGlassSearchChrome()` in + StrandDesign (iOS/macOS/watchOS 26 `glassEffect` capsule; elevated pill fallback on older OS). Migrated + workout-selection search, Workouts filter search, and Marker editor search to the shared component. + macOS sidebar `.searchable` in `RootView` left as the platform-native NavigationSplitView search + placement (not a hand-rolled field). +- **Preserved:** Search/filter predicates, clear-filter actions, marker catalog filtering, and focus/ + dismiss behavior. +- **Type:** Search chrome only; no query logic or data-path changes. + +### EXP-028 — Live workout Effort gauge scale in accessibility + +- **Date:** 2026-08-05 +- **Files:** `Strand/Screens/LiveWorkoutView.swift` +- **Request:** Restore 0–21 / 0–100 scale context for VoiceOver after the glanceable Effort redesign hid + the visible denominator, without changing the on-screen gauge. +- **Implementation:** Extended the Effort `CountUpText` accessibility label to + `Effort {value} {localized of N}` using `UnitFormatter.effortScaleMax` and the existing `"of %@"` + caption. Kept the intensity word on `accessibilityValue`. Value formatting matches the visible + CountUpText (one decimal on WHOOP 0–21, integer on 0–100). +- **Preserved:** Visible layout, typography, colors, state label, animation, Effort calculation / + scale conversion / progress fraction, and existing accessibility traits (label + value only). +- **Type:** Accessibility presentation only; no visible UI or workout-logic changes. + ## Verification history | Date | Scope | Result | @@ -414,6 +531,12 @@ feature rather than by file so they can be reviewed and ported independently. | 2026-08-04 | PR review presentation restorations | i18n CI audit and `git diff --check` passed. A clean `NOOPiOS` Debug physical-device build succeeded, including `DevicesView`; the signed `com.liammazuz.noop` build was installed and launched on the connected iPhone. Source-path verification confirmed the upstream Live HR subtitle branches, weekly gauge captions, selected-week localization inputs, and unchanged Full day destination. | | 2026-08-04 | Live workout glanceable hierarchy | i18n CI audit and `git diff --check` passed. The `NOOPiOS` Debug physical-device build succeeded and the signed `com.liammazuz.noop` app was installed and launched on the connected iPhone. Diff verification confirmed all workout data sources, calculations, lifecycle hooks, sensor isolation, actions, and confirmation behavior remain unchanged. Existing unrelated compiler warnings remained. | | 2026-08-05 | Live workout glanceable vertical stack | `NOOPiOS` Debug physical-device build succeeded; signed `com.liammazuz.noop` (team `P2874N8GRQ`) installed and launched on Liam's iPhone. Source verification confirmed timer, BPM/zone, Effort scale, stats, realtime HR, keep-awake, End confirm, and sensor leaf behavior unchanged. | +| 2026-08-05 | Live workout Effort gauge accessibility scale | Accessibility-only change in `LiveWorkoutView` Effort gauge. `git diff --check` passed. Clean `NOOPiOS` Debug `generic/platform=iOS` build succeeded. i18n audit: this change added no new literals; pre-existing uncommitted EXP-022–027 search/workout a11y strings still fail the baseline gate. Visible Effort UI unchanged. | +| 2026-08-05 | Live workout floating bottom controls | `NOOPiOS` Debug generic-iOS build succeeded (`CODE_SIGNING_ALLOWED=NO`). Source verification confirmed End confirm + `endWorkout`/`onClose` preserved, hero metrics/zones/stats unchanged, and bottom timer shares `activeWorkout.start`. | +| 2026-08-05 | Unique workout-type icons | `swift test --filter WorkoutTypeIconTests` passed (6 tests, unique preferred + runtime identities). `NOOPiOS` Debug generic-iOS build succeeded. | +| 2026-08-05 | Shared floating workout control bar | `NOOPiOS` Debug generic-iOS build succeeded. Source verification confirmed Liquid Glass end controls, timer centering, end-confirm, and `WorkoutTypeIcon` preserved inside one elevated pill `NoopPanelSurface`. | +| 2026-08-05 | Full-screen workout selection browser | `NOOPiOS` Debug generic-iOS build succeeded after `xcodegen generate`. Source verification confirmed `onStart` / `RecentSportsPrefs` / catalogue matching / merge overrides preserved; footer start row removed in favor of card tap. | +| 2026-08-05 | Native Liquid Glass search fields | `NOOPiOS` Debug generic-iOS build succeeded. Migrated workout selection, Workouts filter, and Marker editor search to shared `NoopLiquidGlassSearchField`. | ## Required workflow for every future custom UI change From 8024d9a7d76c057ebe9676d50c6464194be45cf5 Mon Sep 17 00:00:00 2001 From: liaolo <99437277+liaolo@users.noreply.github.com> Date: Wed, 5 Aug 2026 06:49:29 +0300 Subject: [PATCH 12/13] 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 --- .../Resources/Localizable.xcstrings | 58 +++ Strand/Resources/Localizable.xcstrings | 416 ++++++++++++++++++ 2 files changed, 474 insertions(+) diff --git a/Packages/StrandDesign/Sources/StrandDesign/Resources/Localizable.xcstrings b/Packages/StrandDesign/Sources/StrandDesign/Resources/Localizable.xcstrings index 93279004d4..c584822512 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/Resources/Localizable.xcstrings +++ b/Packages/StrandDesign/Sources/StrandDesign/Resources/Localizable.xcstrings @@ -3440,6 +3440,64 @@ } } } + }, + "Clear search": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Suche löschen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Borrar búsqueda" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Effacer la recherche" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Cancella ricerca" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Limpar pesquisa" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Очистить поиск" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "清除搜索" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "清除搜尋" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Clear search" + } + } + } } }, "version": "1.0" diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 7d09d89091..6f68fa7062 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -173424,6 +173424,422 @@ } } } + }, + "Recording workout": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Workout wird aufgezeichnet" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Grabando entrenamiento" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Enregistrement de l'entraînement" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Registrazione allenamento" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "A gravar treino" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Запись тренировки" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在记录锻炼" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在記錄鍛鍊" + } + } + } + }, + "Elapsed time": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Vergangene Zeit" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Tiempo transcurrido" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Temps écoulé" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Tempo trascorso" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Tempo decorrido" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Прошедшее время" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已经过时间" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已經過時間" + } + } + } + }, + "Stops recording and saves what's captured so far": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Beendet die Aufzeichnung und speichert, was bisher erfasst wurde" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Deja de grabar y guarda lo capturado hasta ahora" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Arrête l'enregistrement et enregistre ce qui a été capturé jusqu'ici" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Interrompe la registrazione e salva ciò che è stato catturato finora" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Para a gravação e guarda o que foi capturado até agora" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Останавливает запись и сохраняет уже собранные данные" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "停止记录并保存目前已采集的内容" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "停止記錄並儲存目前已擷取的內容" + } + } + } + }, + "%@ workout": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "%@-Workout" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "entrenamiento %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "entraînement %@" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "allenamento %@" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "treino %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "тренировка %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ 锻炼" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ 鍛鍊" + } + } + } + }, + "No workouts found": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine Workouts gefunden" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se encontraron entrenamientos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucun entraînement trouvé" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nessun allenamento trovato" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Nenhum treino encontrado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Тренировки не найдены" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未找到锻炼" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "找不到鍛鍊" + } + } + } + }, + "Try a different activity name.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Versuch einen anderen Aktivitätsnamen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Prueba con otro nombre de actividad." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Essayez un autre nom d'activité." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Prova un nome di attività diverso." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Experimente outro nome de atividade." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Попробуйте другое название активности." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请尝试其他活动名称。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "請嘗試其他活動名稱。" + } + } + } + }, + "Double tap to start": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zum Starten doppeltippen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Toca dos veces para empezar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Double-tapez pour démarrer" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Tocca due volte per iniziare" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Toque duas vezes para começar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Дважды нажмите, чтобы начать" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "双击以开始" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "雙擊以開始" + } + } + } + }, + "Double tap to %@": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zum %@ doppeltippen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Toca dos veces para %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Double-tapez pour %@" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Tocca due volte per %@" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Toque duas vezes para %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Дважды нажмите, чтобы %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "双击以%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "雙擊以%@" + } + } + } } }, "version": "1.0" From 846fd5f396d0906dee804a0f7a8e503ae2cae671 Mon Sep 17 00:00:00 2001 From: liaolo <99437277+liaolo@users.noreply.github.com> Date: Wed, 5 Aug 2026 07:05:08 +0300 Subject: [PATCH 13/13] Bring macOS UI toward iOS redesign parity. Adapt shared liquid chrome for the sidebar shell, restore Today quick actions and Devices sync visibility, and document intentional desktop differences. Co-authored-by: Cursor --- Strand/App/NavRouter.swift | 4 +- Strand/App/RootView.swift | 74 +++++++++++++++++ Strand/Liquid/LiquidTodayView.swift | 18 +++-- Strand/Resources/Localizable.xcstrings | 52 ++++++++++++ Strand/Screens/DevicesView.swift | 10 ++- Strand/Screens/LiveView.swift | 3 + Strand/Screens/LiveWorkoutView.swift | 12 ++- Strand/Screens/ScreenScaffold.swift | 12 ++- Strand/Screens/WeeklyDigestView.swift | 10 --- Strand/Screens/WorkoutSelectionScreen.swift | 6 +- Strand/Screens/WorkoutsView.swift | 3 + docs/MACOS_UI_CUSTOMIZATION_LEDGER.md | 90 +++++++++++++++++++++ 12 files changed, 264 insertions(+), 30 deletions(-) create mode 100644 docs/MACOS_UI_CUSTOMIZATION_LEDGER.md diff --git a/Strand/App/NavRouter.swift b/Strand/App/NavRouter.swift index c0fc3e9b8f..b070c0b5db 100644 --- a/Strand/App/NavRouter.swift +++ b/Strand/App/NavRouter.swift @@ -44,8 +44,8 @@ final class NavRouter: ObservableObject { /// active shell (macOS sidebar / iOS tab) reacts and routes itself, then resets this to nil. @Published var requestedDestination: Destination? - /// Set when a screen's top-bar "+" asks the shell to open the quick-action sheet (the sheet lives - /// in the iOS shell). The shell presents it, then resets this to false. + /// Set when a screen's top-bar "+" asks the shell to open the quick-action sheet. Both shells + /// present it (iOS tab shell / macOS sidebar shell), then reset this to false. @Published var quickActionsRequested = false /// One-shot: `LiveView` reads this on appear to present the in-exercise screen for an already-running diff --git a/Strand/App/RootView.swift b/Strand/App/RootView.swift index ceaa1db60e..bbc16c520e 100644 --- a/Strand/App/RootView.swift +++ b/Strand/App/RootView.swift @@ -207,6 +207,9 @@ struct RootView: View { /// going empty to non-empty), so clearing the search puts every group back exactly as they left /// it. `nil` means no search is in flight; whitespace-only input never arms one. @State private var preSearchExpansion: Set? = nil + /// Today header "+" quick-action sheet (Live / Workouts / Journal / Breathe). Mirrored from the iOS + /// tab shell — without this listener the button sets `router.quickActionsRequested` and nothing opens. + @State private var showQuickActions = false /// The groups expanded at rest: every single-item group (so its lone row is visible) plus the group /// owning the current selection. Keeps the sidebar to "headers + the active group" as the spec asks. @@ -324,6 +327,20 @@ struct RootView: View { } if dest != nil { router.requestedDestination = nil } } + // Today header "+" → quick-action sheet (parity with RootTabView). Then jump the sidebar to + // the chosen destination so the action lands on the same screens iOS presents as sheets. + .onChangeCompat(of: router.quickActionsRequested) { req in + guard req else { return } + showQuickActions = true + router.quickActionsRequested = false + } + .sheet(isPresented: $showQuickActions) { + MacQuickActionsSheet { item in + showQuickActions = false + selection = item + } + .frame(minWidth: 380, idealWidth: 420, minHeight: 340, idealHeight: 380) + } // Whenever the selection moves (a cross-screen route, or restoring a deep destination), make sure // the group that owns it is expanded so the selected row is actually visible, not hidden inside a // collapsed section (S1). User-driven collapses of OTHER groups are preserved. @@ -518,6 +535,63 @@ struct BrandMark: View { } } +/// macOS quick-action menu for the Today header "+". Same destinations as the iOS FAB sheet; picking +/// one selects the matching sidebar row (desktop shells don't present those screens as nested sheets). +private struct MacQuickActionsSheet: View { + let onPick: (NavItem) -> Void + + var body: some View { + VStack(spacing: 0) { + Text("QUICK ACTIONS") + .font(StrandFont.overline) + .tracking(1.6) + .foregroundStyle(StrandPalette.textTertiary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.top, 18) + .padding(.bottom, 10) + + VStack(spacing: 8) { + row("Live HR", icon: "waveform.path.ecg", tint: StrandPalette.metricRose, item: .live) + row("Start workout", icon: "figure.run", tint: StrandPalette.effortColor, item: .workouts) + row("Log journal", icon: "square.and.pencil", tint: StrandPalette.accent, item: .insights) + row("Breathe", icon: "wind", tint: StrandPalette.restColor, item: .breathe) + } + .padding(.horizontal, 16) + .padding(.bottom, 18) + + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(StrandPalette.surfaceBase.ignoresSafeArea()) + } + + private func row(_ title: LocalizedStringKey, icon: String, tint: Color, item: NavItem) -> some View { + Button { onPick(item) } label: { + HStack(spacing: 13) { + Image(systemName: icon) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(tint) + .frame(width: 38, height: 38) + .background(RoundedRectangle(cornerRadius: 11, style: .continuous).fill(StrandPalette.surfaceInset)) + Text(title) + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.textPrimary) + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(StrandPalette.textTertiary) + } + .padding(.vertical, 10) + .padding(.horizontal, 12) + .background(NoopPanelSurface(cornerRadius: 14)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(title) + } +} + /// Isolated live-status pill — owns the LiveState observation so the rest of RootView (sidebar /// list + detail) does not re-render on the ~1 Hz HR / frame stream. private struct SidebarStatus: View { diff --git a/Strand/Liquid/LiquidTodayView.swift b/Strand/Liquid/LiquidTodayView.swift index f39a261264..0740c33c56 100644 --- a/Strand/Liquid/LiquidTodayView.swift +++ b/Strand/Liquid/LiquidTodayView.swift @@ -273,15 +273,19 @@ struct LiquidTodayView: View { } } dataSourcesSection + #if os(iOS) Color.clear.frame(height: 90) // floating tab-bar clearance + #else + Color.clear.frame(height: 28) // sidebar shell — modest bottom breathing room only + #endif } .padding(.horizontal, 16) .padding(.top, 30) // sit the title lower into the sky, not jammed under the status bar } #if os(macOS) - // Keep the phone-shaped column readable + centred on the wide mac detail pane. The sky is a - // ScrollView background (full-bleed), so constraining the content column here doesn't touch it. - .frame(maxWidth: 680) + // Readable desktop column: wider than the phone stack so Key Metrics / cards can breathe on + // a detail pane, still capped so lines do not stretch edge-to-edge. Sky stays full-bleed. + .frame(maxWidth: 920) .frame(maxWidth: .infinity) #endif } @@ -1962,18 +1966,18 @@ private extension View { /// opaque photo would conceal the button style's refraction and highlight. @ViewBuilder func nativeLiquidGlassPhotoFinish() -> some View { - if #available(iOS 26.0, *) { + if #available(iOS 26.0, macOS 26.0, *) { self.glassEffect(.regular.interactive(), in: Circle()) } else { self } } - /// Platform-owned Home-header button chrome. iOS 26 supplies the interactive Liquid Glass button - /// material; older supported releases keep the same circular geometry with a native system material. + /// Platform-owned Home-header button chrome. iOS/macOS 26 supplies the interactive Liquid Glass + /// button material; older supported releases keep the same circular geometry with a native system material. @ViewBuilder func nativeLiquidGlassHeaderButton() -> some View { - if #available(iOS 26.0, *) { + if #available(iOS 26.0, macOS 26.0, *) { self .buttonStyle(.glass) .buttonBorderShape(.circle) diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 6f68fa7062..f8ccac6a2d 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -173840,6 +173840,58 @@ } } } + }, + "No strap history synced yet": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Noch keine Strap-Historie synchronisiert" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Aún no se ha sincronizado el historial de la pulsera" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucun historique de bracelet synchronisé pour l'instant" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nessuna cronologia dello strap sincronizzata ancora" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Ainda não há histórico da pulseira sincronizado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "История ремешка ещё не синхронизирована" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "尚未同步腕带历史" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "尚未同步腕帶歷程" + } + } + } } }, "version": "1.0" diff --git a/Strand/Screens/DevicesView.swift b/Strand/Screens/DevicesView.swift index b2b599c8f3..60a1e72ee8 100644 --- a/Strand/Screens/DevicesView.swift +++ b/Strand/Screens/DevicesView.swift @@ -479,7 +479,15 @@ private struct DeviceSyncStatusCard: View { accessibility: String(localized: "Connected; strap history sync is experimental on this strap") ) case .hidden: - EmptyView() + // Devices always surfaces a sync card so moving the status off Today does not look like the + // feature disappeared on a cold start (no completed offload yet). Resolver still returns + // `.hidden` for the compact header indicators that intentionally stay quiet. + statusCard( + systemImage: "arrow.triangle.2.circlepath", + detail: String(localized: "No strap history synced yet"), + tint: StrandPalette.textTertiary, + accessibility: String(localized: "No strap history synced yet") + ) } } diff --git a/Strand/Screens/LiveView.swift b/Strand/Screens/LiveView.swift index ed1d239a4c..cb2c37a01e 100644 --- a/Strand/Screens/LiveView.swift +++ b/Strand/Screens/LiveView.swift @@ -134,6 +134,9 @@ struct LiveView: View { LiveWorkoutView(onClose: { showLiveWorkout = false }) .environmentObject(model) .environmentObject(live) + #if os(macOS) + .frame(minWidth: 720, idealWidth: 840, minHeight: 760, idealHeight: 900) + #endif } // Pick a named sport before starting (#519) — the live workout view then opens // off the activeWorkout change above, so no extra navigation is needed here. diff --git a/Strand/Screens/LiveWorkoutView.swift b/Strand/Screens/LiveWorkoutView.swift index b0ce080ac8..a890ba455e 100644 --- a/Strand/Screens/LiveWorkoutView.swift +++ b/Strand/Screens/LiveWorkoutView.swift @@ -64,6 +64,10 @@ struct LiveWorkoutView: View { .padding(.vertical, NoopMetrics.space6) .padding(.bottom, NoopMetrics.space8) .frame(maxWidth: .infinity, alignment: .leading) + #if os(macOS) + .frame(maxWidth: 720, alignment: .center) + .frame(maxWidth: .infinity, alignment: .center) + #endif } // Floating end / elapsed / sport-type controls sit in the bottom safe area so the scroll // content never owns the chrome and the timer can stay screen-centered. @@ -395,12 +399,12 @@ struct LiveWorkoutView: View { // MARK: - Native Liquid Glass workout controls private extension View { - /// Platform-owned circular chrome for the live-workout bottom controls. iOS 26 uses the interactive - /// Liquid Glass button material; older supported releases keep the same circular geometry with the - /// same native-system material fallback the Home header buttons already use. + /// Platform-owned circular chrome for the live-workout bottom controls. iOS/macOS 26 uses the + /// interactive Liquid Glass button material; older supported releases keep the same circular + /// geometry with the same native-system material fallback the Home header buttons already use. @ViewBuilder func nativeLiquidGlassWorkoutControl() -> some View { - if #available(iOS 26.0, *) { + if #available(iOS 26.0, macOS 26.0, *) { self .buttonStyle(.glass) .buttonBorderShape(.circle) diff --git a/Strand/Screens/ScreenScaffold.swift b/Strand/Screens/ScreenScaffold.swift index 6889ccc798..00dc826377 100644 --- a/Strand/Screens/ScreenScaffold.swift +++ b/Strand/Screens/ScreenScaffold.swift @@ -47,7 +47,7 @@ struct ScreenScaffold: View { column #if os(iOS) // Unified side margins matching the liquid home (16pt) so every page's cards + header line up - // to the same edges (2026-07-02); macOS keeps the classic 28 in the #else branch. + // to the same edges (2026-07-02). .padding(.horizontal, 16) .padding(.top, 24) // The tab bar floats over the scroll content, so the last card sat hidden behind it. @@ -59,8 +59,14 @@ struct ScreenScaffold: View { alignment: hSizeClass == .regular ? .center : .leading) .frame(maxWidth: .infinity, alignment: .center) #else - .padding(28) - .frame(maxWidth: .infinity, alignment: .leading) + // macOS: same 16/24 content rhythm as iOS so shared cards align with Liquid Today, but no + // tab-bar clearance (sidebar shell). Cap + centre the readable column on wide detail panes + // so lines stay comfortable without stretching phone chrome edge-to-edge. + .padding(.horizontal, 16) + .padding(.top, 24) + .padding(.bottom, 28) + .frame(maxWidth: 980, alignment: .center) + .frame(maxWidth: .infinity, alignment: .center) #endif } #if os(iOS) diff --git a/Strand/Screens/WeeklyDigestView.swift b/Strand/Screens/WeeklyDigestView.swift index 19734e97e8..96b4e5ef4b 100644 --- a/Strand/Screens/WeeklyDigestView.swift +++ b/Strand/Screens/WeeklyDigestView.swift @@ -142,9 +142,7 @@ struct WeeklyDigestContent: View { /// and the Trends small-multiple instead of being stuck on "of 100". Charge/Rest stay 0–100. @AppStorage(UnitPrefs.effortScaleKey) private var effortScaleRaw = EffortScale.hundred.rawValue private var effortScale: EffortScale { UnitPrefs.resolveEffortScale(effortScaleRaw) } - #if os(iOS) @Environment(\.dynamicTypeSize) private var dynamicTypeSize - #endif /// Display order: the two daily scores first, then the nightly signals. private static let order: [WeeklyMetric] = [.charge, .effort, .rest, .hrv, .rhr] @@ -205,20 +203,13 @@ struct WeeklyDigestContent: View { @ViewBuilder private var scoreRow: some View { - #if os(iOS) if compact { compactScoreRow } else { scoreGrid } - #else - // `compact` describes the embedded digest, not a compact desktop width. - // Keep macOS on its original adaptive, individually surfaced score cards. - scoreGrid - #endif } - #if os(iOS) @ViewBuilder private var compactScoreRow: some View { let summaries = Self.scoreOrder.compactMap { digest.summary($0) } @@ -255,7 +246,6 @@ struct WeeklyDigestContent: View { } } } - #endif private var scoreGrid: some View { LazyVGrid( diff --git a/Strand/Screens/WorkoutSelectionScreen.swift b/Strand/Screens/WorkoutSelectionScreen.swift index 427c293e03..83096e6687 100644 --- a/Strand/Screens/WorkoutSelectionScreen.swift +++ b/Strand/Screens/WorkoutSelectionScreen.swift @@ -98,7 +98,7 @@ struct WorkoutSelectionScreen: View { } } #if os(macOS) - .frame(minWidth: 480, minHeight: 640) + .frame(minWidth: 640, idealWidth: 760, minHeight: 720, idealHeight: 860) #endif } @@ -341,12 +341,12 @@ extension View { // MARK: - Native Liquid Glass chrome (selection browser) private extension View { - /// Circular (or capsule) interactive Liquid Glass for close / recent chips. iOS 26 uses the + /// Circular (or capsule) interactive Liquid Glass for close / recent chips. iOS/macOS 26 uses the /// platform glass button; older releases keep circular geometry with the shared material fallback /// already used by Home header / live-workout controls. @ViewBuilder func nativeLiquidGlassWorkoutSelectionControl(capsule: Bool = false) -> some View { - if #available(iOS 26.0, *) { + if #available(iOS 26.0, macOS 26.0, *) { self .buttonStyle(.glass) .buttonBorderShape(capsule ? .capsule : .circle) diff --git a/Strand/Screens/WorkoutsView.swift b/Strand/Screens/WorkoutsView.swift index 3049d55de9..26a9eb85ec 100644 --- a/Strand/Screens/WorkoutsView.swift +++ b/Strand/Screens/WorkoutsView.swift @@ -245,6 +245,9 @@ struct WorkoutsView: View { // Inject the shared live snapshot so the in-exercise sensor readout (speed/cadence/power) // resolves here too, matching how LiveView presents the same screen. .environmentObject(model.live) + #if os(macOS) + .frame(minWidth: 720, idealWidth: 840, minHeight: 760, idealHeight: 900) + #endif } // #519: name the sport before a live session starts, then open the in-exercise view directly // (same direct present as the button's already-active path — no cross-view auto-present race). diff --git a/docs/MACOS_UI_CUSTOMIZATION_LEDGER.md b/docs/MACOS_UI_CUSTOMIZATION_LEDGER.md new file mode 100644 index 0000000000..d2859f00c9 --- /dev/null +++ b/docs/MACOS_UI_CUSTOMIZATION_LEDGER.md @@ -0,0 +1,90 @@ +# NOOP macOS UI Customization Ledger + +Documents intentional macOS visual/layout work on `macos-ui-experimental`, branched from +`ui-experimental` for desktop parity with the iOS redesign **without** replacing macOS navigation +or behavior. + +## Branch lineage + +- Base: `ui-experimental` at `8024d9a7` (`Localize live workout and search accessibility strings.`). +- Working branch: `macos-ui-experimental`. +- Upstream comparison target: Ryan’s `main` on `ryanbr/noop`. +- iOS redesign source of truth: shared `Strand/` + `Packages/StrandDesign` on `ui-experimental`. + +## Non-negotiable boundaries + +Same as the iOS UI ledger: no BLE, protocol, scoring, persistence, networking, HealthKit, +permissions, entitlements, or build-config changes unless strictly required for a compile and called +out here. Do not replace the macOS sidebar with an iOS tab bar. Do not remove menu-bar, window, or +keyboard behavior. + +## Shared styling reused directly (no macOS fork) + +Already ships on Mac via shared code from `ui-experimental`: + +- Adaptive light/dark palette and liquid surfaces +- Typography, `NoopCard` / panel surfaces, borders, gradients, corner radii +- Key Metrics presentation, Trends chart framing, Sleep night-scene hero +- Workouts equal-width actions + Liquid Glass search field (macOS 26 aware) +- Live workout glanceable hierarchy, Effort a11y scale, workout-type icons +- Devices sync status card, full-width action styling patterns + +## Intentional macOS differences from iOS + +| Topic | macOS choice | Why | +| --- | --- | --- | +| Navigation shell | Keep `NavigationSplitView` sidebar + detail | Desktop convention; do not port bottom tabs | +| Sidebar search | System `.searchable(placement: .sidebar)` | Native Mac placement; not a hand-rolled field | +| Workout / Live Session presentation | Large `.sheet` (not `fullScreenCover`) | `fullScreenCover` is iOS-only | +| Sleep Debt tile | Stays in adaptive metric grid | Avoid stretching a phone lead-tile across an unbounded detail pane | +| Classic Today title | “Control Center” + window-toolbar Updates | Existing Mac chrome; Liquid Today is the default | +| Menu bar extra | Retained, token-aligned | No iOS twin (widgets instead) | +| Hover | `NoopCard` hover border | Pointer platform affordance | + +## macOS-specific parity work (this branch) + +### MAC-001 — ScreenScaffold desktop gutters + readable column + +- **Files:** `Strand/Screens/ScreenScaffold.swift` +- Align horizontal/top padding with iOS (16 / 24); drop tab-bar clearance; centre content at max + width **980** on wide detail panes. + +### MAC-002 — Weekly digest compact score row on Mac + +- **Files:** `Strand/Screens/WeeklyDigestView.swift` +- Enable the embedded three-gauge compact row (Trends / Today embeddings) on macOS so Week-in-review + matches the iOS EXP-012 treatment. Full digest still uses the adaptive score grid. + +### MAC-003 — Liquid Today desktop column + no tab spacer + +- **Files:** `Strand/Liquid/LiquidTodayView.swift` +- Widen readable column to **920**; gate the 90pt floating-tab spacer to iOS only; extend header / + photo Liquid Glass availability to **macOS 26** (material fallback unchanged on older macOS). + +### MAC-004 — Live workout + selection glass and sheet sizing + +- **Files:** `Strand/Screens/LiveWorkoutView.swift`, `Strand/Screens/WorkoutSelectionScreen.swift`, + `Strand/Screens/LiveView.swift`, `Strand/Screens/WorkoutsView.swift` +- Extend workout-control / selection Liquid Glass to macOS 26; enlarge Mac sheet min/ideal frames; + centre live-workout content at max width **720** inside the sheet. + +### MAC-005 — Today “+” quick actions + Devices sync card always visible + +- **Files:** `Strand/App/RootView.swift`, `Strand/App/NavRouter.swift`, `Strand/Screens/DevicesView.swift`, + `Strand/Resources/Localizable.xcstrings` +- **Bug:** Liquid/classic Today header `+` called `router.requestQuickActions()`, but only the iOS + `RootTabView` listened — on macOS the flag flipped and nothing opened. +- **Fix:** macOS shell presents `MacQuickActionsSheet` and routes picks to sidebar destinations + (Live / Workouts / Insights / Breathe). +- **Bug:** `DeviceSyncStatusCard` rendered `EmptyView` on cold-start `.hidden`, so Devices often looked + like it had no sync status after the indicator moved off Today. +- **Fix:** Devices always shows the card; cold start uses localized “No strap history synced yet”. + `SyncChipState.resolve` unchanged (header chips still hide on cold start). + +## Verification history + +| Date | Scope | Result | +| --- | --- | --- | +| 2026-08-05 | Branch created | `macos-ui-experimental` from `ui-experimental` @ `8024d9a7` | +| 2026-08-05 | MAC-001…004 | `git diff --check` passed; i18n audit exit 0; clean `Strand` macOS Debug build succeeded; app launched locally. `ui-experimental` left at `8024d9a7`. No BLE/protocol/scoring/persistence/HealthKit/entitlement/`project.yml` changes. | +| 2026-08-05 | MAC-005 | Fixed Today `+` (macOS quick-action sheet) and Devices sync card cold-start visibility. i18n exit 0; macOS Debug build succeeded; app relaunched. |