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
942 changes: 942 additions & 0 deletions Packages/StrandDesign/Sources/StrandDesign/ChargeSyncIndicator.swift

Large diffs are not rendered by default.

23 changes: 23 additions & 0 deletions Packages/StrandDesign/Sources/StrandDesign/Components.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,29 @@ public enum NoopMetrics {
public static let sourceBadgeHeight: CGFloat = 18
public static let hypnogramBandMinThickness: CGFloat = 14 // floor so short stages read as bars, not ticks
public static let tabBarClearance: CGFloat = 76 // iOS: extra bottom scroll room so the last card clears the floating tab bar
/// Canonical diameter for compact circular controls in dense header chrome.
public static let compactControlSize: CGFloat = 36
/// Expanded width of the compact charge-to-sync status capsule.
public static let syncIndicatorExpandedWidth: CGFloat = 108
/// Optical space between the sync ring and its transient label.
public static let syncIndicatorLabelSpacing: CGFloat = 5
/// Smallest readable scale for long localized labels inside the sync capsule.
public static let syncIndicatorMinimumLabelScale: CGFloat = 0.72
/// Even inset around the sync control before applying exact-bounds Liquid Glass, matching the inset
/// the system's `.small` glass chrome gives the sibling header circles. Equal on both axes so the
/// compact state stays circular.
public static let syncIndicatorGlassPadding: CGFloat = 5
/// Inset for the indicator's ring in BOTH states — the battery arc and the sync spinner share one
/// radius, so the morph changes colour and sweep without the circle also resizing. Two different
/// radii read as two different controls swapping places rather than one control changing state.
public static let syncIndicatorArcInset: CGFloat = 2.5
/// Width of the soft fade where long header text passes beneath trailing controls.
public static let headerTextFadeWidth: CGFloat = 48
/// Starting guess for the trailing footprint a header control row occupies, used ONLY until the host
/// has measured its own cluster (see `headerTrailingControlFadeMask(reserving:)`). Four compact
/// controls plus their gaps and the sync control's glass inset — deliberately not a fixed budget,
/// because a cluster that gains a control must not silently start mis-fading the title beside it.
public static let headerControlReserveWidth: CGFloat = 168

// MARK: Standardised spacing scale (the ONE source of truth for margins)
//
Expand Down
132 changes: 132 additions & 0 deletions Packages/StrandDesign/Sources/StrandDesign/Motion.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,138 @@ public enum StrandMotion {

/// Standard fade.
public static let fade = Animation.easeInOut(duration: durationStandard)

// MARK: Compact charge-to-sync indicator
//
// Only `syncIndicatorSignalDebounceNanoseconds` is `public`: the screen owns the raw sync signal and
// therefore has to debounce it before handing it over. Everything else here is the indicator's own
// internal timing, consumed exclusively by `ChargeSyncIndicator` in this module, so it stays
// `internal` per the "public API is intentional" rule in docs/CONTRIBUTING.md.

/// Responsive capsule geometry for the compact charge-to-sync indicator.
static let syncIndicatorMorph = Animation.spring(
response: 0.60,
dampingFraction: 0.94,
blendDuration: 0.14
)

/// Coordinated charge-number, arc, and colour transition.
///
/// Matched to what the wind-down takes for an average half-turn (`2 · 180 / rate`), so both halves of
/// the morph are the same length and read as one gesture. At a fixed 0.58s the entry ran to less than
/// half the typical exit, which made the start feel instant against a visibly slower finish.
///
/// Linear on purpose, like the exit: `entryBody` applies smootherstep itself, so the curve it draws is
/// the curve you see instead of an easing composed on top of an easing.
static var syncIndicatorVisual: Animation {
.linear(duration: syncIndicatorMorphDuration)
}

/// Length of BOTH halves of the morph. Constant deceleration over `syncIndicatorExitTravelDegrees`
/// takes `2 · distance / rate`; the entry is given the same, so neither half can feel quicker.
static var syncIndicatorMorphDuration: Double {
2 * syncIndicatorExitTravelDegrees / syncIndicatorSpinRateDegrees
}

/// Half-width, in turns, of the blend band that travels along the arc as it changes colour. Wide
/// enough to read as a gradient rather than a hard edge, narrow enough that the two ends stay
/// distinct mid-sweep.
static let syncIndicatorTintBandTurns: Double = 0.10

/// Transient label fades after the capsule has settled.
static let syncIndicatorLabelIn = Animation.easeOut(duration: 0.26)
static let syncIndicatorLabelOut = Animation.easeInOut(duration: 0.26)

static let syncIndicatorFrameInterval = 1.0 / 60.0
static let syncIndicatorSpinPeriod = 1.25

/// Fixed sweep of the spinner arc, in turns.
///
/// Deliberately NOT breathing. Rotation is anchored to the arc's head, so the tail's speed is
/// `rate − 360·d(arc)/dt` — a breathing length modulates the tail by ~±17%, and with a 1.2s breath
/// against a 1.25s spin that wobble lands at nearly the same screen position every revolution, which
/// reads as the ring repeatedly slowing at one spot. A constant length makes both ends travel at
/// exactly the spin rate, which is also what lets the wind-down hand off cleanly.
static let syncIndicatorSpinnerArcTurns = 0.38

/// Distance the wind-down covers. Fixed rather than "whatever is left to 12 o'clock", so the exit is
/// always the same length as the entry; `endSync` waits for the ring to reach this far out instead.
static let syncIndicatorExitTravelDegrees: Double = 180

/// Grace period the SCREEN applies to the falling edge of the sync signal before it tells the
/// indicator to wind down. Public because the raw signal is the screen's to own: `backfilling` drops
/// between history chunks, and without this the control would flap back to the battery reading in the
/// middle of one logical sync.
public static let syncIndicatorSignalDebounceNanoseconds: UInt64 = 3_000_000_000

static let syncIndicatorLabelDelayNanoseconds: UInt64 = 650_000_000
static let syncIndicatorLabelVisibilityNanoseconds: UInt64 = 1_900_000_000
static let syncIndicatorCollapseDelayNanoseconds: UInt64 = 240_000_000
/// Steady spinner rate in degrees per second. Derived from the spin period rather than written out,
/// so the wind-down cannot drift out of step with the spin it has to hand off from.
static var syncIndicatorSpinRateDegrees: Double { 360 / syncIndicatorSpinPeriod }

/// Grace after the wind-down lands before the morph view is torn down, so the swap back to the
/// static battery ring happens on an already-settled frame rather than mid-motion.
static let syncIndicatorExitSettleMargin: Double = 0.06

/// How far the ring must sweep for the battery colour to finish becoming the sync colour. The tint
/// trails the motion rather than switching on contact.
///
/// A full turn, so the colour lands exactly as the entry ends. At half a turn it finished around the
/// entry's midpoint and read as rushed: `spinDegrees` accrues at the full rate from the outset, so by
/// the close of a 1.25s entry the ring has already carried 360°, not 180°.
static let syncIndicatorColourTravelDegrees: Double = 360

// MARK: Charge-to-sync curve math
//
// Pure, frame-free and therefore unit-testable — see `ChargeSyncIndicatorMathTests`. It lives here
// rather than on the views because BOTH `ChargeSyncIndicator` and its `ChargeSyncMorph` child read
// the same spin clock: the indicator captures the hand-off angle from it while the morph draws from
// it, so two copies of the curve would put the wind-down somewhere the ring is not.

/// Where the spinner is at `date`: rotation in degrees since `start`, and the arc's fixed length in
/// turns. `posed` (a motion-saving mode) parks it at 0° with the arc still at full length, so the
/// static state is the moving one stopped rather than a different shape.
static func syncIndicatorPhase(
since start: Date?,
at date: Date,
posed: Bool
) -> (degrees: Double, arc: Double) {
let arc = syncIndicatorSpinnerArcTurns
guard !posed else { return (0, arc) }
let seconds = max(0, date.timeIntervalSince(start ?? date))
return ((seconds / syncIndicatorSpinPeriod) * 360, arc)
}

/// Smootherstep (6t⁵ − 15t⁴ + 10t³). Every value the morph's ENTRY interpolates — arc, sweep, stroke
/// weight and the colour blend — rides this one curve, so they start, travel and land together.
///
/// The quintic rather than the classic 3t² − 2t³: smoothstep zeroes only the FIRST derivative at each
/// end, so the morph still enters and leaves with a perceptible kick in acceleration. This also zeroes
/// the second derivative, so it eases out of rest and back into it with no such break.
static func syncIndicatorSmootherStep(_ value: Double) -> Double {
let t = max(0, min(1, value))
return t * t * t * (t * (t * 6 - 15) + 10)
}

/// Quadratic ease-out, `1 − (1 − t)²`: slope 2 at t = 0 falling linearly to 0 at t = 1 — constant
/// deceleration. Used for the wind-down, whose duration is derived to match that opening slope to the
/// spinner's actual rate, so the ring coasts to a stop instead of halting and then re-rotating.
static func syncIndicatorEaseOutQuad(_ value: Double) -> Double {
let t = max(0, min(1, value))
return 1 - (1 - t) * (1 - t)
}

/// Position, in turns, of the travelling colour boundary along an arc of length `arc` at `tint`.
///
/// Sweeps from `arc + band` down to `−band`, not from `arc` to 0, so the blend band clears the arc
/// completely at BOTH ends. Sweeping only across `[0, arc]` left half a band width hanging over each
/// end, which showed as a green tail still on the ring after it had otherwise fully turned red.
static func syncIndicatorTintBoundary(tint: Double, arc: Double) -> Double {
let band = syncIndicatorTintBandTurns
return arc + band - tint * (arc + 2 * band)
}
}

#if DEBUG
Expand Down
2 changes: 2 additions & 0 deletions Packages/StrandDesign/Sources/StrandDesign/Palette.swift
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ public enum StrandPalette {
public static var focusRing: Color { accentChoice.focusRing }
/// Opacity for dimmed/disabled sections (shared so screens don't invent their own value).
public static let disabledOpacity: Double = 0.45
/// Liquid-scene activity tint shared by heart-rate feedback and transient sync chrome.
public static let liquidHeart = Color(light: "#D94C64", dark: "#FF6B81")

// MARK: - Chart style (data-viz colour mode) — Titanium (brand) or Classic (throwback)
//
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1525,6 +1525,64 @@
}
}
},
"Syncing": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Sync läuft"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Syncing"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Sincronizando"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Synchronisation"
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "Sincronizzazione"
}
},
"pt-PT": {
"stringUnit": {
"state": "translated",
"value": "A sincronizar"
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "Синхронизация"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "同步中"
}
},
"zh-Hant": {
"stringUnit": {
"state": "translated",
"value": "同步中"
}
}
}
},
"System": {
"localizations": {
"de": {
Expand Down
4 changes: 4 additions & 0 deletions Packages/StrandDesign/Sources/StrandDesign/Typography.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ public enum StrandFont {

/// Overline 11 / Bold, +1.4 tracking (apply `.tracking(1.4)` at use site;
/// `overlineText(_:)` does it for you). Sparing ALL-CAPS labels. Scales with Dynamic Type.
///
/// Also the face for compact status copy in constrained chrome (the Today header's sync capsule),
/// used there WITHOUT the tracking — that is sentence case, not an overline, and the letter-spacing
/// is what makes an overline read as one.
public static let overline = Font.system(.caption2, design: .rounded, weight: .semibold)

/// `overline` at a custom point size — same Helvetica face, weight and Dynamic-Type scaling
Expand Down
Loading