From 56e7d92ba44f065667872c996aef563330f291b7 Mon Sep 17 00:00:00 2001 From: DX23876 <176692557+DX23876@users.noreply.github.com> Date: Thu, 17 Sep 2026 16:43:47 +0200 Subject: [PATCH] feat(design): add strength and cardio lane colour tokens Strength and Cardio both used effortColor, so the two training lanes were indistinguishable. Each lane now owns one identity colour for all seven chart styles in light and dark: electric teal for Strength and violet for Cardio, kept clear of the status and Charge colours so a lane never reads as a warning. The hexes live in LaneColorTable and LaneColorTests pin hue separation and white-text contrast on the deep tones. Analysis migration required: no --- .../Sources/StrandDesign/Palette.swift | 124 +++++++++++ .../StrandDesignTests/LaneColorTests.swift | 209 ++++++++++++++++++ 2 files changed, 333 insertions(+) create mode 100644 Packages/StrandDesign/Tests/StrandDesignTests/LaneColorTests.swift diff --git a/Packages/StrandDesign/Sources/StrandDesign/Palette.swift b/Packages/StrandDesign/Sources/StrandDesign/Palette.swift index 26162d26c9..cf8774d79b 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/Palette.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/Palette.swift @@ -730,6 +730,130 @@ public enum StrandPalette { /// 3-stop gauge ramp: calm → balanced → high. public static var stressGradient: Gradient { Gradient(colors: [stressDeep, stressColor, stressBright]) } + // MARK: - Lane colours — Strength / Cardio (redesign) + // + // Teal for Strength: orange, red and yellow are status colours and green is Charge, and a lane must + // never read as a status. `LaneColorTests` pins the hue and contrast margins. + + /// The raw hex table backing the lane tokens below, keyed by `chartStyle`. + enum LaneColorTable { + /// A light/dark hex pair, kept as raw strings so `LaneColorTests` can parse them directly (a + /// SwiftUI `Color` built from a dynamic provider cannot be read back). + struct Hex { + let light: String + let dark: String + } + + /// The six lane swatches for one chart style, each lane as deep → color → bright. + struct Style { + let strengthDeep: Hex + let strengthColor: Hex + let strengthBright: Hex + let cardioDeep: Hex + let cardioColor: Hex + let cardioBright: Hex + } + + static let signature = Style( + strengthDeep: Hex(light: "#064E53", dark: "#086E74"), + strengthColor: Hex(light: "#0A8F97", dark: "#1ED6E0"), + strengthBright: Hex(light: "#0FB3BC", dark: "#7CEBF0"), + cardioDeep: Hex(light: "#291268", dark: "#3A1A93"), + cardioColor: Hex(light: "#440CDF", dark: "#7D51F6"), + cardioBright: Hex(light: "#622FEE", dark: "#A98CF8") + ) + static let titanium = Style( + strengthDeep: Hex(light: "#064A50", dark: "#0A6A72"), + strengthColor: Hex(light: "#0B8791", dark: "#22C7D6"), + strengthBright: Hex(light: "#12A6B2", dark: "#7FE3EC"), + cardioDeep: Hex(light: "#2E1763", dark: "#41218C"), + cardioColor: Hex(light: "#4B1BBB", dark: "#7A4CE6"), + cardioBright: Hex(light: "#662FE4", dark: "#A788F2") + ) + static let classic = Style( + strengthDeep: Hex(light: "#074A4E", dark: "#0B6B70"), + strengthColor: Hex(light: "#0D858C", dark: "#25C4CC"), + strengthBright: Hex(light: "#14A2AA", dark: "#83E0E5"), + cardioDeep: Hex(light: "#2A1763", dark: "#3C218C"), + cardioColor: Hex(light: "#431BBB", dark: "#734CE6"), + cardioBright: Hex(light: "#5D2FE4", dark: "#A288F2") + ) + static let health = Style( + strengthDeep: Hex(light: "#064C52", dark: "#0A7078"), + strengthColor: Hex(light: "#0A8C96", dark: "#1FD3E0"), + strengthBright: Hex(light: "#0FAEB9", dark: "#7FEAF1"), + cardioDeep: Hex(light: "#211268", dark: "#2E1A93"), + cardioColor: Hex(light: "#2F0CDF", dark: "#6C51F6"), + cardioBright: Hex(light: "#4F2FEE", dark: "#9E8CF8") + ) + static let aurora = Style( + strengthDeep: Hex(light: "#1F4648", dark: "#2E6466"), + strengthColor: Hex(light: "#3E8384", dark: "#5FB3B3"), + strengthBright: Hex(light: "#4F9FA0", dark: "#9ACFCE"), + cardioDeep: Hex(light: "#351F51", dark: "#4D2D76"), + cardioColor: Hex(light: "#5A3091", dark: "#8757C7"), + cardioBright: Hex(light: "#723EB6", dark: "#A883D8") + ) + static let sunset = Style( + strengthDeep: Hex(light: "#074A50", dark: "#0B6C74"), + strengthColor: Hex(light: "#0C8891", dark: "#22C9D4"), + strengthBright: Hex(light: "#13A7B1", dark: "#82E4EA"), + cardioDeep: Hex(light: "#36156A", dark: "#4A1F8F"), + cardioColor: Hex(light: "#5A15C6", dark: "#7F48EE"), + cardioBright: Hex(light: "#7431EA", dark: "#A884F5") + ) + static let forest = Style( + strengthDeep: Hex(light: "#1C4545", dark: "#2A6566"), + strengthColor: Hex(light: "#357B7A", dark: "#4FA9A8"), + strengthBright: Hex(light: "#449897", dark: "#8CC9C8"), + cardioDeep: Hex(light: "#311F51", dark: "#482D76"), + cardioColor: Hex(light: "#543091", dark: "#8057C7"), + cardioBright: Hex(light: "#6A3EB6", dark: "#A283D8") + ) + + static func style(_ chartStyle: ChartStyle) -> Style { + switch chartStyle { + case .signature: return signature + case .titanium: return titanium + case .classic: return classic + case .health: return health + case .aurora: return aurora + case .sunset: return sunset + case .forest: return forest + } + } + } + + /// Strength lane identity: electric teal in every style. + public static var strengthDeep: Color { + let hex = LaneColorTable.style(chartStyle).strengthDeep + return Color(light: hex.light, dark: hex.dark) + } + public static var strengthColor: Color { + let hex = LaneColorTable.style(chartStyle).strengthColor + return Color(light: hex.light, dark: hex.dark) + } + public static var strengthBright: Color { + let hex = LaneColorTable.style(chartStyle).strengthBright + return Color(light: hex.light, dark: hex.dark) + } + public static var strengthGradient: Gradient { Gradient(colors: [strengthDeep, strengthBright]) } + + /// Cardio lane identity: electric violet-indigo in every style. + public static var cardioDeep: Color { + let hex = LaneColorTable.style(chartStyle).cardioDeep + return Color(light: hex.light, dark: hex.dark) + } + public static var cardioColor: Color { + let hex = LaneColorTable.style(chartStyle).cardioColor + return Color(light: hex.light, dark: hex.dark) + } + public static var cardioBright: Color { + let hex = LaneColorTable.style(chartStyle).cardioBright + return Color(light: hex.light, dark: hex.dark) + } + public static var cardioGradient: Gradient { Gradient(colors: [cardioDeep, cardioBright]) } + // MARK: Scenic background (NEW) — detail-screen hero gradient + starfield. /// Radial canvas: lit center → deep edge. Used by `ScenicHeroBackground` (warm-lit on light). public static let scenicCenter = Color(light: "#FBF6EA", dark: "#1C2128") diff --git a/Packages/StrandDesign/Tests/StrandDesignTests/LaneColorTests.swift b/Packages/StrandDesign/Tests/StrandDesignTests/LaneColorTests.swift new file mode 100644 index 0000000000..2d991958dd --- /dev/null +++ b/Packages/StrandDesign/Tests/StrandDesignTests/LaneColorTests.swift @@ -0,0 +1,209 @@ +import XCTest +import SwiftUI +@testable import StrandDesign + +/// Verifies the Strength/Cardio lane colour tokens (`StrandPalette.strengthColor`/`cardioColor` and +/// their deep/bright siblings): each lane must read as one fixed, saturated identity, distinct from +/// the other lane and from the existing status/charge colours, in every chart style and colour scheme. +/// Hue and contrast math is reimplemented here rather than imported from `StrandDesign`, so a bug in +/// the app's own helpers can't hide a palette regression from its own test. +final class LaneColorTests: XCTestCase { + + // MARK: - Pure colour helpers (hex -> sRGB -> HSL hue / WCAG contrast) + + private struct RGB { let r: Double; let g: Double; let b: Double } + + private static func rgb(_ hex: String) -> RGB { + var s = hex + if s.hasPrefix("#") { s.removeFirst() } + let v = UInt32(s, radix: 16) ?? 0 + return RGB(r: Double((v >> 16) & 0xFF) / 255.0, + g: Double((v >> 8) & 0xFF) / 255.0, + b: Double(v & 0xFF) / 255.0) + } + + /// Hue in degrees [0, 360) from an sRGB hex triple, via the standard HSL conversion. + private static func hue(_ hex: String) -> Double { + let c = rgb(hex) + let maxV = max(c.r, c.g, c.b) + let minV = min(c.r, c.g, c.b) + let delta = maxV - minV + guard delta > 0 else { return 0 } + var h: Double + if maxV == c.r { + h = 60 * (((c.g - c.b) / delta).truncatingRemainder(dividingBy: 6)) + } else if maxV == c.g { + h = 60 * (((c.b - c.r) / delta) + 2) + } else { + h = 60 * (((c.r - c.g) / delta) + 4) + } + if h < 0 { h += 360 } + return h + } + + /// Shortest angular distance between two hues, in degrees [0, 180]. + private static func hueDistance(_ a: Double, _ b: Double) -> Double { + let d = abs(a - b).truncatingRemainder(dividingBy: 360) + return min(d, 360 - d) + } + + /// WCAG relative luminance of an sRGB hex colour. + private static func relativeLuminance(_ hex: String) -> Double { + let c = rgb(hex) + func linear(_ v: Double) -> Double { + v <= 0.04045 ? v / 12.92 : pow((v + 0.055) / 1.055, 2.4) + } + return 0.2126 * linear(c.r) + 0.7152 * linear(c.g) + 0.0722 * linear(c.b) + } + + /// WCAG contrast ratio between two sRGB hex colours (always >= 1). + private static func contrastRatio(_ a: String, _ b: String) -> Double { + let l1 = relativeLuminance(a) + let l2 = relativeLuminance(b) + let (hi, lo) = l1 > l2 ? (l1, l2) : (l2, l1) + return (hi + 0.05) / (lo + 0.05) + } + + // MARK: - Reference hex tables + + /// `StrandPalette.onDarkPrimary` (Palette.swift) — fixed/scheme-invariant, copied here as a literal + /// since a `Color` built from a dynamic provider can't be read back to hex. + private static let onDarkPrimary = "#F4F6F8" + + private struct StyleRefs { let light: String; let dark: String } + + // Existing token hex values, copied from `StrandPalette` (Palette.swift) for the hue-separation + // checks below — NOT changed by this change; see the file for their canonical definitions. + private static let statusWarning: [ChartStyle: StyleRefs] = [ + .signature: .init(light: "#C2792E", dark: "#F0A020"), + .titanium: .init(light: "#C2792E", dark: "#F0A020"), + .classic: .init(light: "#CFA528", dark: "#F2C53D"), + .health: .init(light: "#FFCC00", dark: "#FFD60A"), + .aurora: .init(light: "#C9A860", dark: "#EBCB8B"), + .sunset: .init(light: "#E0952E", dark: "#FFB74D"), + .forest: .init(light: "#BC8A3E", dark: "#D8A657"), + ] + private static let statusCritical: [ChartStyle: StyleRefs] = [ + .signature: .init(light: "#C84E1E", dark: "#E0662F"), + .titanium: .init(light: "#C84E1E", dark: "#E0662F"), + .classic: .init(light: "#CB3A2F", dark: "#E5483B"), + .health: .init(light: "#FF3B30", dark: "#FF453A"), + .aurora: .init(light: "#A54650", dark: "#BF616A"), + .sunset: .init(light: "#E03656", dark: "#FF4D6D"), + .forest: .init(light: "#9C3524", dark: "#B5432E"), + ] + private static let statusPositive: [ChartStyle: StyleRefs] = [ + .signature: .init(light: "#1F8A5B", dark: "#03E095"), + .titanium: .init(light: "#1F8A5B", dark: "#03E095"), + .classic: .init(light: "#2E9E4F", dark: "#46B45A"), + .health: .init(light: "#34C759", dark: "#30D158"), + .aurora: .init(light: "#6E9460", dark: "#A3BE8C"), + .sunset: .init(light: "#5F9456", dark: "#86B87A"), + .forest: .init(light: "#3B7345", dark: "#4E8C57"), + ] + private static let chargeColor: [ChartStyle: StyleRefs] = [ + .signature: .init(light: "#0C8F62", dark: "#31E39C"), + .titanium: .init(light: "#0F9D62", dark: "#03E095"), + .classic: .init(light: "#2E9E4F", dark: "#46B45A"), + .health: .init(light: "#34C759", dark: "#30D158"), + .aurora: .init(light: "#6E9460", dark: "#A3BE8C"), + .sunset: .init(light: "#E0AE3E", dark: "#FFD166"), + .forest: .init(light: "#437E4C", dark: "#5A9C63"), + ] + // Soft constraint only (not asserted) — recorded per the design brief's request to note styles + // that land within 18° of it. + private static let effortColor: [ChartStyle: StyleRefs] = [ + .signature: .init(light: "#0A63B8", dark: "#3AA0FF"), + .titanium: .init(light: "#2A78C8", dark: "#4090E0"), + .classic: .init(light: "#3A74C4", dark: "#4A90E2"), + .health: .init(light: "#FF9500", dark: "#FF9F0A"), + .aurora: .init(light: "#5C82A6", dark: "#81A1C1"), + .sunset: .init(light: "#E04E50", dark: "#FF6B6B"), + .forest: .init(light: "#AC7239", dark: "#C58A47"), + ] + + private static let allStyles = ChartStyle.allCases + + private func lane(_ style: ChartStyle) -> StrandPalette.LaneColorTable.Style { + StrandPalette.LaneColorTable.style(style) + } + + // MARK: - 1: Strength vs Cardio hue separation + + func testLaneHuesAreAtLeast60DegreesApart() { + for style in Self.allStyles { + let s = lane(style) + for (mode, strengthHex, cardioHex) in [ + ("light", s.strengthColor.light, s.cardioColor.light), + ("dark", s.strengthColor.dark, s.cardioColor.dark), + ] { + let d = Self.hueDistance(Self.hue(strengthHex), Self.hue(cardioHex)) + XCTAssertGreaterThanOrEqual(d, 60, "\(style)/\(mode): strength/cardio hue distance \(d)") + } + } + } + + // MARK: - 2: separation from statusWarning / statusCritical / statusPositive / chargeColor + + func testLaneHuesAreSeparatedFromStatusAndCharge() { + let refTables: [(String, [ChartStyle: StyleRefs])] = [ + ("warning", Self.statusWarning), ("critical", Self.statusCritical), + ("positive", Self.statusPositive), ("charge", Self.chargeColor), + ] + for style in Self.allStyles { + let s = lane(style) + for (mode, strengthHex, cardioHex) in [ + ("light", s.strengthColor.light, s.cardioColor.light), + ("dark", s.strengthColor.dark, s.cardioColor.dark), + ] { + let strengthHue = Self.hue(strengthHex) + let cardioHue = Self.hue(cardioHex) + for (name, table) in refTables { + guard let ref = table[style] else { continue } + let refHex = mode == "light" ? ref.light : ref.dark + let refHue = Self.hue(refHex) + let ds = Self.hueDistance(strengthHue, refHue) + let dc = Self.hueDistance(cardioHue, refHue) + XCTAssertGreaterThanOrEqual(ds, 18, "\(style)/\(mode): strength vs \(name) hue distance \(ds)") + XCTAssertGreaterThanOrEqual(dc, 18, "\(style)/\(mode): cardio vs \(name) hue distance \(dc)") + } + } + } + } + + /// `effortColor` is NOT a hard constraint — this only records (via `print`, visible with + /// `swift test --verbose`) which styles land within 18° of it, per the design brief. + func testEffortProximityIsRecordedNotEnforced() { + for style in Self.allStyles { + let s = lane(style) + guard let ref = Self.effortColor[style] else { continue } + for (mode, strengthHex, refHex) in [ + ("light", s.strengthColor.light, ref.light), + ("dark", s.strengthColor.dark, ref.dark), + ] { + let d = Self.hueDistance(Self.hue(strengthHex), Self.hue(refHex)) + if d < 18 { + print("LaneColorTests: strengthColor is within 18° of effortColor in " + + "\(style)/\(mode) (\u{394}=\(String(format: "%.1f", d))\u{b0})") + } + } + } + } + + // MARK: - 3: deep tones carry small white text (onDarkPrimary) at >= 4.5:1 + + func testLaneDeepTonesHaveSufficientContrastForWhiteText() { + for style in Self.allStyles { + let s = lane(style) + for (mode, strengthDeepHex, cardioDeepHex) in [ + ("light", s.strengthDeep.light, s.cardioDeep.light), + ("dark", s.strengthDeep.dark, s.cardioDeep.dark), + ] { + let cs = Self.contrastRatio(strengthDeepHex, Self.onDarkPrimary) + let cc = Self.contrastRatio(cardioDeepHex, Self.onDarkPrimary) + XCTAssertGreaterThanOrEqual(cs, 4.5, "\(style)/\(mode): strengthDeep contrast \(cs)") + XCTAssertGreaterThanOrEqual(cc, 4.5, "\(style)/\(mode): cardioDeep contrast \(cc)") + } + } + } +}