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
32 changes: 32 additions & 0 deletions .cursor/rules/ui-customization-ledger.mdc
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 1 addition & 12 deletions Packages/StrandDesign/Sources/StrandDesign/ChartHover.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
87 changes: 61 additions & 26 deletions Packages/StrandDesign/Sources/StrandDesign/Components.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -377,26 +377,32 @@ public struct SegmentedPillControl<T: Hashable>: 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<T>, 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<T>, 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)
Expand Down Expand Up @@ -426,11 +432,10 @@ public struct SegmentedPillControl<T: Hashable>: 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
Expand All @@ -439,16 +444,27 @@ public struct SegmentedPillControl<T: Hashable>: 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)
Expand All @@ -460,8 +476,27 @@ public struct SegmentedPillControl<T: Hashable>: 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
)
)
}
}
}

Expand Down
12 changes: 10 additions & 2 deletions Packages/StrandDesign/Sources/StrandDesign/NoopButton.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,25 +58,30 @@ 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 {
case .primary:
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
}
}
}
Expand All @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Bool>.Binding?
@FocusState private var internalFocus: Bool

public init(text: Binding<String>,
prompt: String,
accessibilityLabel: String? = nil,
isFocused: FocusState<Bool>.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<Bool>.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)
)
}
}
}
4 changes: 2 additions & 2 deletions Packages/StrandDesign/Sources/StrandDesign/NoopMotion.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand All @@ -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()
}
}
Expand Down
Loading