From e308358f2d60f0ee73fcbc7fbafd0bdb11ff3573 Mon Sep 17 00:00:00 2001 From: DX23876 <176692557+DX23876@users.noreply.github.com> Date: Thu, 17 Sep 2026 20:54:54 +0200 Subject: [PATCH] feat(design): give the chrome a deliberate cool identity, and gate it on contrast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NoopVisualStyle was the Apple system grey ramp verbatim (#000000 / #1C1C1E / #2C2C2E, border #38383A). Measured, that is why the app read as washed out: border on surface 1.45:1 dividers and card rims dissolved into the fill tertiaryText (dark) 2.50:1 units and section labels, under the 4.5:1 AA floor tertiaryText (light) 1.74:1 effectively invisible secondaryText (light) 3.44:1 ordinary body copy, also under AA surface saturation 0.034 grey in all but name Re-values the whole set to a cool slate hierarchy — near-black in Dark, paper in Light, both carrying an actual blue-cool cast. Token NAMES are untouched, so no call site moves. Now: text clears 4.5:1 over canvas, surface and inset in both schemes; primary copy clears 12:1; borders clear 1.8:1; the surface/canvas and inset/surface steps clear 1.18:1. Two things the old values quietly broke and this fixes: - surfaceTop/surfaceBottom/surface all held the same hex, so NoopPanelSurface's "quiet vertical gradient" — promised in its own doc comment — rendered flat. They are distinct now. - The rim was stroked at .opacity(0.72)/.52, which put the PAINTED card rim at 1.34:1 even where the token itself measured fine. Those opacities are now named constants (0.92/0.80) and the gate checks what is actually painted, not just the token behind it. Adds ChromeContrastTests: 7 tests, reimplementing the sRGB/WCAG/HSL math locally the way LaneColorTests does, and reading NoopVisualStyle.ChromeHex — the same table the tokens are built from — so it cannot pass by checking a re-typed copy of itself. A saturation floor keeps a later tidy-up from sliding the surfaces back to neutral with every contrast assertion still green. Chrome deliberately still does not branch on ChartStyle. A cool cast is what lets one chrome carry all seven styles; a green or gold one would fight Aurora's frost and Forest's earth. Verified: swift test in StrandDesign (164 tests; the 5 failures in PlaceholderTests are pre-existing and locale-bound, identical on a clean tree). xcodebuild build for Strand (macOS) and NOOPiOS (iOS). Checked on the simulator in both dark and light. Confirmed the gate rejects the old values rather than merely passing on the new ones. Analysis migration required: no — presentation only. --- .../StrandDesign/NoopVisualStyle.swift | 85 +++++-- .../ChromeContrastTests.swift | 215 ++++++++++++++++++ 2 files changed, 284 insertions(+), 16 deletions(-) create mode 100644 Packages/StrandDesign/Tests/StrandDesignTests/ChromeContrastTests.swift diff --git a/Packages/StrandDesign/Sources/StrandDesign/NoopVisualStyle.swift b/Packages/StrandDesign/Sources/StrandDesign/NoopVisualStyle.swift index d989afeaa5..40834d37de 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/NoopVisualStyle.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/NoopVisualStyle.swift @@ -7,26 +7,78 @@ import SwiftUI // bindings, while cards, gauges, typography, and chrome share one maintainable source of truth. public enum NoopVisualStyle { - // Apple grouped-background hierarchy. These values mirror the public iOS system colours while - // remaining explicit dynamic tokens, so macOS, widgets, and watchOS resolve the same design language. - public static let canvas = Color(light: "#F2F2F7", dark: "#000000") - public static let surface = Color(light: "#FFFFFF", dark: "#1C1C1E") - public static let surfaceTop = Color(light: "#FFFFFF", dark: "#1C1C1E") - public static let surfaceBottom = Color(light: "#FFFFFF", dark: "#1C1C1E") - public static let inset = Color(light: "#F2F2F7", dark: "#2C2C2E") - - public static let border = Color(light: "#C6C6C8", dark: "#38383A") - public static let borderHighlight = Color(light: "#FFFFFF", dark: "#48484A") - public static let divider = Color(light: "#C6C6C8", dark: "#38383A") - - public static let primaryText = Color(light: "#000000", dark: "#FFFFFF") - public static let secondaryText = Color(light: "#3C3C4399", dark: "#EBEBF599") - public static let tertiaryText = Color(light: "#3C3C434D", dark: "#EBEBF54D") + // A cool slate hierarchy: near-black in Dark, paper in Light, both carrying a deliberate blue-cool + // cast rather than the neutral grey these tokens used to mirror from iOS. + // + // WHY THE VALUES MOVED. The previous set was the Apple system grey ramp verbatim (#000000 / + // #1C1C1E / #2C2C2E, border #38383A), which measured as follows and read as washed out for it: + // border carried only 1.45:1 against the surface, so dividers and card rims dissolved into the + // fill; `tertiaryText` reached 2.50:1 in Dark and 1.74:1 in Light; and `secondaryText` in Light + // sat at 3.44:1, under the 4.5:1 AA floor for body text. Surface saturation was 0.034 — grey in + // all but name. Every value below is chosen against `ChromeContrastTests`, which re-checks the + // same arithmetic, so a future edit cannot quietly walk any of it back. + // + // `surfaceTop`/`surfaceBottom` are now genuinely distinct. `NoopPanelSurface` has always drawn a + // LinearGradient between them and promised "a quiet vertical gradient" in its own doc comment, + // but all three tokens held the same hex, so the gradient rendered flat. + // + // This layer deliberately does NOT branch on `ChartStyle` — chart styles recolour data encodings, + // never chrome (see `Appearance.swift`). A cool cast is what lets one chrome carry all seven of + // them; a green or gold one would fight Aurora's frost and Forest's earth. + /// The raw hex behind every chrome token, kept as strings so `ChromeContrastTests` can parse the + /// very values the tokens are built from. A `Color` backed by a dynamic provider cannot be read + /// back, and a test that re-typed the literals would only be checking its own copy — the same + /// reason `StrandPalette.LaneColorTable` exists. + enum ChromeHex { + struct Pair { + let light: String + let dark: String + } + static let canvas = Pair(light: "#E7EBF3", dark: "#03050A") + static let surface = Pair(light: "#FFFFFF", dark: "#151B2A") + static let surfaceTop = Pair(light: "#FFFFFF", dark: "#181F30") + static let surfaceBottom = Pair(light: "#F9FAFD", dark: "#121724") + static let inset = Pair(light: "#E8ECF5", dark: "#212B3D") + static let border = Pair(light: "#98A3B6", dark: "#374861") + static let borderHighlight = Pair(light: "#FFFFFF", dark: "#4C5F83") + static let divider = Pair(light: "#98A3B6", dark: "#374861") + static let primaryText = Pair(light: "#0B0F18", dark: "#F4F7FC") + // Translucent, NOT opaque: cards and the Liquid hero are partly transparent, so secondary and + // tertiary copy composites over whatever is actually behind it. Only the opacity rose — enough + // to clear 4.5:1 over canvas, surface and inset alike. + static let secondaryText = Pair(light: "#28313FCC", dark: "#E6EDFAC4") + static let tertiaryText = Pair(light: "#28313FB8", dark: "#E6EDFA96") + } + + private static func token(_ pair: ChromeHex.Pair) -> Color { + Color(light: pair.light, dark: pair.dark) + } + + public static let canvas = token(ChromeHex.canvas) + public static let surface = token(ChromeHex.surface) + public static let surfaceTop = token(ChromeHex.surfaceTop) + public static let surfaceBottom = token(ChromeHex.surfaceBottom) + public static let inset = token(ChromeHex.inset) + + public static let border = token(ChromeHex.border) + public static let borderHighlight = token(ChromeHex.borderHighlight) + public static let divider = token(ChromeHex.divider) + + public static let primaryText = token(ChromeHex.primaryText) + public static let secondaryText = token(ChromeHex.secondaryText) + public static let tertiaryText = token(ChromeHex.tertiaryText) 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") + /// The opacities `NoopPanelSurface` strokes its rim with, named rather than inlined so + /// `ChromeContrastTests` can check what is actually PAINTED and not merely the token behind it. + /// They used to be 0.72 / 0.52, which put the rendered card rim at 1.34:1 even once the `border` + /// token itself cleared 1.85:1 — the gate would have been asserting something the eye never got. + public static let panelRimHighlightOpacity: Double = 0.92 + public static let panelRimBorderOpacity: Double = 0.80 + public static let cardRadius: CGFloat = 22 public static let compactRadius: CGFloat = 16 public static let pillRadius: CGFloat = 999 @@ -92,7 +144,8 @@ public struct NoopPanelSurface: View { .overlay( shape.strokeBorder( LinearGradient( - colors: [NoopVisualStyle.borderHighlight.opacity(0.72), NoopVisualStyle.border.opacity(0.52)], + colors: [NoopVisualStyle.borderHighlight.opacity(NoopVisualStyle.panelRimHighlightOpacity), + NoopVisualStyle.border.opacity(NoopVisualStyle.panelRimBorderOpacity)], startPoint: .top, endPoint: .bottom ), diff --git a/Packages/StrandDesign/Tests/StrandDesignTests/ChromeContrastTests.swift b/Packages/StrandDesign/Tests/StrandDesignTests/ChromeContrastTests.swift new file mode 100644 index 0000000000..1ee444f07b --- /dev/null +++ b/Packages/StrandDesign/Tests/StrandDesignTests/ChromeContrastTests.swift @@ -0,0 +1,215 @@ +import XCTest +import SwiftUI +@testable import StrandDesign + +/// Holds the chrome tokens in `NoopVisualStyle` to a readable floor: text has to clear WCAG AA over +/// every surface it can land on, structure (borders, dividers, the surface/canvas and inset/surface +/// steps) has to stay visible instead of dissolving into its fill, and the dark surfaces have to keep +/// the deliberate cool cast rather than drifting back to neutral grey. +/// +/// The colour math is reimplemented here rather than imported from `StrandDesign`, exactly as +/// `LaneColorTests` does it and for the same reason: a bug in the app's own helpers must not be able +/// to hide a token regression from the test that exists to catch it. Values are read from +/// `NoopVisualStyle.ChromeHex`, which is the source the tokens themselves are built from — a +/// `Color` backed by a dynamic provider cannot be read back, and re-typing the literals here would +/// only check this file against itself. +final class ChromeContrastTests: XCTestCase { + + // MARK: - Pure colour helpers (hex -> sRGB -> alpha compositing -> WCAG contrast / HSL) + + private struct RGBA { + let r: Double + let g: Double + let b: Double + let a: Double + } + + private static func rgba(_ hex: String) -> RGBA { + var s = hex + if s.hasPrefix("#") { s.removeFirst() } + let v = UInt64(s, radix: 16) ?? 0 + if s.count == 8 { // RRGGBBAA + return RGBA(r: Double((v >> 24) & 0xFF) / 255.0, g: Double((v >> 16) & 0xFF) / 255.0, + b: Double((v >> 8) & 0xFF) / 255.0, a: Double(v & 0xFF) / 255.0) + } + return RGBA(r: Double((v >> 16) & 0xFF) / 255.0, g: Double((v >> 8) & 0xFF) / 255.0, + b: Double(v & 0xFF) / 255.0, a: 1.0) + } + + /// Composite a possibly-translucent foreground over an opaque background. The text tokens carry + /// alpha on purpose, so contrast can only be judged against the surface they actually sit on. + private static func composite(_ fg: String, over bg: String) -> RGBA { + let f = rgba(fg) + let b = rgba(bg) + return RGBA(r: f.a * f.r + (1 - f.a) * b.r, + g: f.a * f.g + (1 - f.a) * b.g, + b: f.a * f.b + (1 - f.a) * b.b, + a: 1.0) + } + + private static func relativeLuminance(_ c: RGBA) -> Double { + 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 of `fg` (composited) against an opaque `bg`. + private static func contrastRatio(_ fg: String, on bg: String) -> Double { + let l1 = relativeLuminance(composite(fg, over: bg)) + let l2 = relativeLuminance(rgba(bg)) + let (hi, lo) = l1 > l2 ? (l1, l2) : (l2, l1) + return (hi + 0.05) / (lo + 0.05) + } + + /// HSL saturation of an opaque hex, used to pin the cool cast on the dark surfaces. + private static func saturation(_ hex: String) -> Double { + let c = rgba(hex) + let mx = max(c.r, c.g, c.b) + let mn = min(c.r, c.g, c.b) + let delta = mx - mn + guard delta > 0 else { return 0 } + let light = (mx + mn) / 2 + return light > 0.5 ? delta / (2 - mx - mn) : delta / (mx + mn) + } + + // MARK: - The token set under test + + private typealias Pair = NoopVisualStyle.ChromeHex.Pair + + private enum Scheme: String, CaseIterable { + case light, dark + func hex(_ pair: Pair) -> String { self == .light ? pair.light : pair.dark } + } + + private static let hex = NoopVisualStyle.ChromeHex.self + + /// Every opaque fill that text can be drawn on. + private func backdrops(_ scheme: Scheme) -> [(String, String)] { + [("canvas", scheme.hex(Self.hex.canvas)), + ("surface", scheme.hex(Self.hex.surface)), + ("surfaceTop", scheme.hex(Self.hex.surfaceTop)), + ("surfaceBottom", scheme.hex(Self.hex.surfaceBottom)), + ("inset", scheme.hex(Self.hex.inset))] + } + + // MARK: - 1: text clears WCAG AA on every surface it can land on + + /// 4.5:1 is the AA floor for body text, and it applies to `tertiaryText` too — it labels units and + /// section headings, which are read, not decoration. The previous values missed it badly: + /// `tertiaryText` measured 2.50:1 in Dark and 1.74:1 in Light, and `secondaryText` in Light sat at + /// 3.44:1, i.e. under AA for ordinary copy. + func testTextTokensClearAAOnEverySurface() { + for scheme in Scheme.allCases { + for (name, token) in [("secondaryText", Self.hex.secondaryText), + ("tertiaryText", Self.hex.tertiaryText)] { + let fg = scheme.hex(token) + for (backdropName, backdrop) in backdrops(scheme) { + let r = Self.contrastRatio(fg, on: backdrop) + XCTAssertGreaterThanOrEqual( + r, 4.5, + "\(scheme.rawValue)/\(name) on \(backdropName): \(String(format: "%.2f", r)):1") + } + } + } + } + + /// Primary copy carries the screen; it should be far clear of the floor, not scraping it. + func testPrimaryTextIsStronglyLegible() { + for scheme in Scheme.allCases { + let fg = scheme.hex(Self.hex.primaryText) + for (backdropName, backdrop) in backdrops(scheme) { + let r = Self.contrastRatio(fg, on: backdrop) + XCTAssertGreaterThanOrEqual( + r, 12.0, + "\(scheme.rawValue)/primaryText on \(backdropName): \(String(format: "%.2f", r)):1") + } + } + } + + // MARK: - 2: structure stays visible + + /// A border at 1.45:1 — what `#38383A` on `#1C1C1E` measured — is a border you cannot see, which is + /// why the old sections read as one undifferentiated grey block. 1.8:1 is not AA (borders are not + /// text); it is the point at which a 1px rim is actually perceptible on both fills it separates. + func testBordersAreVisibleAgainstTheFillsTheySeparate() { + for scheme in Scheme.allCases { + for (name, token) in [("border", Self.hex.border), ("divider", Self.hex.divider)] { + let fg = scheme.hex(token) + for (backdropName, backdrop) in [("surface", scheme.hex(Self.hex.surface)), + ("canvas", scheme.hex(Self.hex.canvas))] { + let r = Self.contrastRatio(fg, on: backdrop) + XCTAssertGreaterThanOrEqual( + r, 1.8, + "\(scheme.rawValue)/\(name) on \(backdropName): \(String(format: "%.2f", r)):1") + } + } + } + } + + /// A card has to read as lifted off the canvas, and an inset as recessed into the card, from fill + /// alone — a reader who turns off the borders should still see the hierarchy. + func testSurfaceStepsAreDistinguishable() { + for scheme in Scheme.allCases { + let steps = [("surface over canvas", scheme.hex(Self.hex.surface), scheme.hex(Self.hex.canvas)), + ("inset over surface", scheme.hex(Self.hex.inset), scheme.hex(Self.hex.surface))] + for (name, fg, bg) in steps { + let r = Self.contrastRatio(fg, on: bg) + XCTAssertGreaterThanOrEqual( + r, 1.18, "\(scheme.rawValue)/\(name): \(String(format: "%.2f", r)):1") + } + } + } + + // MARK: - 3: the dark chrome keeps its cool cast + + /// The whole point of the re-value: the old surfaces were the Apple system grey ramp, saturation + /// 0.034 and below. Without a floor here, a later "tidy-up" slides them back to neutral and the app + /// is washed out again with every contrast test still green. + func testDarkSurfacesAreNotNeutralGrey() { + for (name, token) in [("canvas", Self.hex.canvas), ("surface", Self.hex.surface), + ("surfaceTop", Self.hex.surfaceTop), ("surfaceBottom", Self.hex.surfaceBottom), + ("inset", Self.hex.inset), ("border", Self.hex.border)] { + let s = Self.saturation(token.dark) + XCTAssertGreaterThanOrEqual( + s, 0.18, "dark/\(name) saturation \(String(format: "%.3f", s)) — drifting back to grey") + } + } + + /// The card rim is not painted at token strength — `NoopPanelSurface` strokes it through + /// `panelRimBorderOpacity`. Checking only the token would assert a crispness the eye never + /// receives, which is how the rendered rim sat at 1.34:1 while `border` itself measured 1.85:1. + /// + /// The floor here is 1.6 rather than the 1.8 demanded of a full-strength divider, and deliberately + /// so: a card rim is reinforced by the surface/canvas fill step and by the panel's shadow, whereas + /// a divider inside a card has nothing but itself to be seen by. + func testPaintedCardRimStaysVisible() { + for scheme in Scheme.allCases { + let surface = scheme.hex(Self.hex.surface) + let rim = Self.withAlpha(scheme.hex(Self.hex.border), + NoopVisualStyle.panelRimBorderOpacity) + let r = Self.contrastRatio(rim, on: surface) + XCTAssertGreaterThanOrEqual( + r, 1.6, "\(scheme.rawValue)/painted card rim: \(String(format: "%.2f", r)):1") + } + } + + /// Re-express an opaque `#RRGGBB` as `#RRGGBBAA` at the given opacity, so the compositing helper + /// above can be pointed at a stroke that a view draws translucently. + private static func withAlpha(_ hex: String, _ opacity: Double) -> String { + let base = hex.hasPrefix("#") ? String(hex.dropFirst()) : hex + let a = Int((max(0, min(1, opacity)) * 255).rounded()) + return "#" + base + String(format: "%02X", a) + } + + /// `NoopPanelSurface` draws a LinearGradient between these two and its doc comment promises "a + /// quiet vertical gradient". All three surface tokens used to hold the same hex, so it rendered + /// flat. Keep them apart. + func testPanelGradientHasActualEndpoints() { + for scheme in Scheme.allCases { + let top = scheme.hex(Self.hex.surfaceTop) + let bottom = scheme.hex(Self.hex.surfaceBottom) + XCTAssertNotEqual(top, bottom, "\(scheme.rawValue): panel gradient endpoints are identical") + } + } +}