Skip to content
Merged
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
85 changes: 69 additions & 16 deletions Packages/StrandDesign/Sources/StrandDesign/NoopVisualStyle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
),
Expand Down
Original file line number Diff line number Diff line change
@@ -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")
}
}
}
Loading