Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 19 additions & 11 deletions Packages/StrandDesign/Sources/StrandDesign/Appearance.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,8 @@ public extension View {
/// `accent`/`accentHover`/`accentMuted`/`focusRing` accessors in `StrandPalette` branch on it. Mirror in
/// Kotlin via `Palette.accentChoice` + `NoopPrefs.accentColor`/`accentCustomHex`.
public enum AccentColor: String, CaseIterable, Identifiable, Sendable {
case mint // the brand default (#1068 NoopVisualStyle.mint world)
case whoopBlue // the classic WHOOP link blue
case mint // the earlier brand default (#1068 NoopVisualStyle.mint world)
case whoopBlue // strain blue: the default, matching the slate dashboard theme
case custom // a user-picked colour (hex stored separately)

public var id: String { rawValue }
Expand All @@ -107,13 +107,19 @@ public enum AccentColor: String, CaseIterable, Identifiable, Sendable {
}
}

public static func resolve(_ raw: String) -> AccentColor { AccentColor(rawValue: raw) ?? .mint }
/// The accent a fresh install uses (and an unreadable stored value falls back to).
public static let defaultChoice: AccentColor = .whoopBlue

public static func resolve(_ raw: String) -> AccentColor { AccentColor(rawValue: raw) ?? defaultChoice }

/// The one blue the `.whoopBlue` accent resolves to (fill, link, focus ring).
static let blue = Color(light: "#007FC8", dark: "#0093E7")

/// The chrome accent. `.custom` resolves the stored hex at read time.
public var accent: Color {
switch self {
case .mint: return NoopVisualStyle.mint
case .whoopBlue: return Color(light: "#234F9E", dark: "#60A0E0")
case .whoopBlue: return AccentColor.blue
case .custom: return Color(hex: StrandPalette.customAccentHex)
}
}
Expand All @@ -122,7 +128,7 @@ public enum AccentColor: String, CaseIterable, Identifiable, Sendable {
public var accentHover: Color {
switch self {
case .mint: return NoopVisualStyle.mintGlow
case .whoopBlue: return Color(light: "#3A6FC0", dark: "#8FBEEC")
case .whoopBlue: return Color(light: "#2B9AD9", dark: "#4DA8FF")
case .custom: return AccentColor.lighten(StrandPalette.customAccentHex)
}
}
Expand All @@ -132,7 +138,7 @@ public enum AccentColor: String, CaseIterable, Identifiable, Sendable {
public var accentMuted: Color {
switch self {
case .mint: return NoopVisualStyle.mintDeep.opacity(0.18)
case .whoopBlue: return Color(light: "#234F9E", dark: "#60A0E0").opacity(0.18)
case .whoopBlue: return AccentColor.blue.opacity(0.18)
case .custom: return Color(hex: StrandPalette.customAccentHex).opacity(0.18)
}
}
Expand Down Expand Up @@ -166,10 +172,10 @@ public extension View {
/// control simply resolves to `.custom`. Theme MODE (System/Light/Dark) is independent and never bundled.
/// Twin of Kotlin `ThemePreset`.
public enum ThemePreset: String, CaseIterable, Identifiable, Sendable {
case mint // brand default: mint accent, Titanium charts, backdrop on, solid cards
case mint // mint accent, Titanium charts, backdrop on, solid cards
case ocean // WHOOP-blue accent, Titanium charts, backdrop on, solid
case classic // WHOOP-blue accent, Classic throwback charts, backdrop on, solid
case midnight // mint accent, Titanium charts, backdrop OFF (plain canvas), solid
case midnight // the default: blue accent, Titanium charts, backdrop OFF (plain slate canvas), solid
case frosted // mint accent, Titanium charts, backdrop on, translucent cards
case custom // sentinel: the live combination matches no preset

Expand Down Expand Up @@ -201,7 +207,7 @@ public enum ThemePreset: String, CaseIterable, Identifiable, Sendable {
case .mint: return Recipe(accent: .mint, chart: .titanium, backdrop: true, cardOpacity: 100)
case .ocean: return Recipe(accent: .whoopBlue, chart: .titanium, backdrop: true, cardOpacity: 100)
case .classic: return Recipe(accent: .whoopBlue, chart: .classic, backdrop: true, cardOpacity: 100)
case .midnight: return Recipe(accent: .mint, chart: .titanium, backdrop: false, cardOpacity: 100)
case .midnight: return Recipe(accent: .whoopBlue, chart: .titanium, backdrop: false, cardOpacity: 100)
case .frosted: return Recipe(accent: .mint, chart: .titanium, backdrop: true, cardOpacity: 85)
case .custom: return nil
}
Expand All @@ -210,7 +216,7 @@ public enum ThemePreset: String, CaseIterable, Identifiable, Sendable {
/// The presets a user can pick (everything except the derived `.custom` sentinel).
public static var selectable: [ThemePreset] { allCases.filter { $0 != .custom } }

public static func resolve(_ raw: String) -> ThemePreset { ThemePreset(rawValue: raw) ?? .mint }
public static func resolve(_ raw: String) -> ThemePreset { ThemePreset(rawValue: raw) ?? .midnight }

/// Which preset the live prefs correspond to, or `.custom` when none match.
public static func matching(accent: AccentColor, chart: ChartStyle,
Expand Down Expand Up @@ -290,8 +296,10 @@ public enum AppearanceMode: String, CaseIterable, Identifiable, Sendable {
/// they stay perfectly readable. Read in `TodayView` via `@AppStorage(SceneBackgroundPrefs.enabledKey)`
/// and toggled from Settings → Appearance. Mirror in Kotlin via `NoopPrefs.showDayCycleBackground`.
public enum SceneBackgroundPrefs {
/// The @AppStorage key shared by TodayView and the Settings toggle. Default value is `true`.
/// The @AppStorage key shared by TodayView and the Settings toggle. Default value is `defaultEnabled`.
public static let enabledKey = "noop.showDayCycleBackground"
/// Off by default: the page is the plain slate gradient; the day-cycle sky is opt-in from Settings.
public static let defaultEnabled = false
}

/// Card-surface opacity as a PERCENT (0 = fully see-through, 100 = solid; default 100). `FrostedCardSurface`
Expand Down
96 changes: 27 additions & 69 deletions Packages/StrandDesign/Sources/StrandDesign/BevelGauge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import SwiftUI

// MARK: - BevelGauge (NEW) — the layered ring gauge primitive
//
// The shared instrument behind RecoveryRing and StrainGauge: a 240° open gauge with
// • a soft frosted inner disc (subtle radial fill, hairline rim)
// • a faint full-span track ring carved from `surfaceInset` (the Titanium "well")
// • a gradient-stroked progress arc (AngularGradient over the domain ramp:
// Charge=green, Effort=blue, Rest=slate-blue — caller-supplied score tokens; WHOOP, no gold)
// • a clean end-cap dot at the arc tip (small white core, very faint shadow) — NO outer bloom
// • a centred SF Pro **Rounded** bold number with an "of N" caption + state word
// The shared instrument behind RecoveryRing and StrainGauge: a full-circle score ring with
// • a deep full-circle track (`NoopVisualStyle.ringTrack`, darker than the page — the "well")
// • a SOLID progress arc in the value's colour (`tipColor`), lightly graded from 85% to full opacity,
// drawn clockwise from 12 o'clock with rounded caps
// • NO inner disc, NO end-cap bead, NO outer bloom — a flat dashboard ring
// • a centred SF Pro condensed bold number with an "of N" caption + state word
//
// `stops` is still accepted (callers pass their domain ramp) but the arc is one colour, the colour of
// the value, so the ring, the state word and every other read-out of the score agree.
//
// It owns no domain logic — callers pass the fraction, the stroke gradient, the tip
// colour, and the centre read-out strings. RecoveryRing / StrainGauge keep their own
Expand Down Expand Up @@ -67,22 +69,19 @@ public struct BevelGauge: View {
self.bloomActive = bloomActive
}

private let arcSpanDegrees: Double = 240
private var startAngle: Angle { .degrees(150) }
private var endAngle: Angle { .degrees(150 + arcSpanDegrees) }

private var gradient: Gradient { Gradient(stops: stops) }
private let arcSpanDegrees: Double = 360
private var startAngle: Angle { .degrees(-90) }

public var body: some View {
ZStack {
// STATIC BACKDROP: the frosted inner disc + the faint full-span track. Neither depends on
// STATIC BACKDROP: the full-circle track. It does not depend on
// `animatedFraction`, so SwiftUI/CoreAnimation already caches it as an unchanged layer and
// does NOT re-render it when only the arc animates. No .drawingGroup() — a per-instance
// offscreen flatten cost more than it saved (it was part of the v7.0.2 lag regression).
staticBackdrop
.frame(width: diameter, height: diameter)

// LIVE LAYER: the gradient progress arc + end-cap, kept OUTSIDE the drawingGroup so the
// LIVE LAYER: the progress arc, kept OUTSIDE the drawingGroup so the
// shape's `animatableData` still animates smoothly (a drawingGroup would freeze it).
animatedArc

Expand All @@ -91,66 +90,25 @@ public struct BevelGauge: View {
.frame(width: diameter, height: diameter)
}

/// The non-animating backdrop: frosted disc behind the arc + the faint full-span track "well".
/// The non-animating backdrop: the deep full-circle track "well" the score arc fills.
private var staticBackdrop: some View {
ZStack {
innerDisc
// Faint full-span track — the inset "well" the score arc sits in.
arcShape(to: 1.0)
.stroke(StrandPalette.surfaceInset,
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
}
arcShape(to: 1.0)
.stroke(NoopVisualStyle.ringTrack,
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
}

/// The live layer: the filled gradient arc + its clean end-cap dot (both driven by animatedFraction).
/// The live layer: the filled arc in the value's colour (driven by animatedFraction).
///
/// NO outer bloom and no end-cap bead: the colour against the dark track carries the arc edge.
/// `bloomActive` stays in the signature (callers still pass it) but no longer renders.
private var animatedArc: some View {
ZStack {
// Filled gradient arc.
arcShape(to: animatedFraction)
.stroke(
AngularGradient(gradient: gradient, center: .center,
startAngle: startAngle, endAngle: endAngle),
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
)

// Clean end-cap dot at the arc tip.
if animatedFraction > 0.001 { endCap }
}
}

// Frosted inner disc behind the arc — gives the gauge a glassy "well".
private var innerDisc: some View {
Circle()
.fill(
RadialGradient(
colors: [StrandPalette.surfaceInset.opacity(0.0), StrandPalette.surfaceInset.opacity(0.55)],
center: .center, startRadius: diameter * 0.10, endRadius: diameter * 0.5
)
arcShape(to: animatedFraction)
.stroke(
LinearGradient(colors: [tipColor.opacity(0.85), tipColor],
startPoint: .leading, endPoint: .trailing),
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
)
.overlay(Circle().strokeBorder(StrandPalette.hairline.opacity(0.5), lineWidth: 1))
.padding(lineWidth * 1.4)
}

// Design Reset (WHOOP): NO outer bloom. Fill-contrast carries the arc edge, so the ring reads as a
// clean, crisp Material instrument rather than a skeuomorphic glow. `bloomActive` stays in the
// signature (callers still pass it) but no longer renders. The track + disc now live in
// `staticBackdrop` and the filled arc + tip in `animatedArc` (see `body`).

private var endCap: some View {
GeometryReader { geo in
let radius = (min(geo.size.width, geo.size.height) - lineWidth) / 2
let center = CGPoint(x: geo.size.width / 2, y: geo.size.height / 2)
let tipAngle = startAngle.radians + (arcSpanDegrees * .pi / 180) * animatedFraction
let pt = CGPoint(x: center.x + radius * cos(tipAngle),
y: center.y + radius * sin(tipAngle))
// Clean Material tip: a single small solid dot at the arc end. The large
// blurred halo is gone; only a very faint shadow keeps it from looking pasted on.
Circle().fill(StrandPalette.tipCore)
.frame(width: lineWidth * 0.7, height: lineWidth * 0.7)
.overlay(Circle().fill(tipColor).opacity(0.35))
.shadow(color: tipColor.opacity(0.35), radius: lineWidth * 0.18)
.position(pt)
}
.opacity(animatedFraction > 0.001 ? 1 : 0)
}

private var centerLabel: some View {
Expand Down
Loading