import SwiftUI // 0.4.3: the speaking light on a name label is the warm rim — "T5, warm // soft breath", chosen from three rounds of mock-ups on 2026-09-28 and // drawn to match that mock-up (speaking-indicator-options/index.html), // whose box-shadow recipe this is. Over the label's own glass, in the // label's own shape, four layers: // // 1. the rim: a 1 pt line just inside the edge; // 2. a top highlight — none, in T5; // 3. the inner light: the edge's light bleeding inwards; // 4. the outer breath: the same light just outside the edge, only once // the voice is loud enough to push it out. // // All warm white (hue 38°, saturation 80 %), a little deeper for each layer // further from the edge. `on` is whether the light is on at all (lit, per // SpeakingGlow, or still fading from it); `level` is its smoothed level, // 0...1. The numbers are CSS's: a blur is a box-shadow's blur radius, // which is twice the Gaussian's standard deviation; SwiftUI's blur radius // is the deviation itself, so it is drawn at half. /// The warm rim's four layers at one moment: how opaque each is and how /// far the blurred ones spread. public struct WarmRim: Equatable, Sendable { /// The rim's line, 1 pt inside the edge. public static let rimWidth: CGFloat = 1 public static let hue: Double = 38 public static let saturation: Double = 0.80 public static let rimLightness: Double = 0.82 public static let innerLightness: Double = 0.72 public static let outerLightness: Double = 0.66 public let on: Bool public let level: Double /// 1. The rim: 0.10 lit, up to 0.55 at the loudest. public let rimOpacity: Double /// 2. The top highlight's: none in T5, and so never drawn. public let topOpacity: Double /// 3. The inner light: 0.06 lit, up to 0.36; blur 3 pt up to 9 pt. public let innerOpacity: Double public let innerBlur: Double /// 4. The outer breath: nothing until the voice rises, up to 0.25; /// blur 1 pt up to 8 pt. public let outerOpacity: Double public let outerBlur: Double public init(on: Bool, level: Double) { let o: Double = on ? 1 : 0 let l = min(1, max(0, level.isFinite ? level : 0)) self.on = on self.level = l rimOpacity = o * (0.10 + l * 0.45) topOpacity = 0 innerOpacity = o * (0.06 + l * 0.30) innerBlur = 3 + l * 6 outerOpacity = o * (0 + l * 0.25) outerBlur = 1 + l * 7 } public static let off = WarmRim(on: false, level: 0) /// sRGB components of hsl(h°, s, l), all 0...1. public static func rgb(hue: Double, saturation s: Double, lightness l: Double) -> ( red: Double, green: Double, blue: Double ) { let c = (1 - abs(2 * l - 1)) * s let h = (hue.truncatingRemainder(dividingBy: 360) + 360).truncatingRemainder(dividingBy: 360) / 60 let x = c * (1 - abs(h.truncatingRemainder(dividingBy: 2) - 1)) let (r, g, b): (Double, Double, Double) = switch h { case ..<1: (c, x, 0) case ..<2: (x, c, 0) case ..<3: (0, c, x) case ..<4: (0, x, c) case ..<5: (x, 0, c) default: (c, 0, x) } let m = l - c / 2 return (r + m, g + m, b + m) } static func color(lightness: Double) -> Color { let c = rgb(hue: hue, saturation: saturation, lightness: lightness) return Color(.sRGB, red: c.red, green: c.green, blue: c.blue) } static let rimColor = color(lightness: rimLightness) static let innerColor = color(lightness: innerLightness) static let outerColor = color(lightness: outerLightness) } extension View { /// The speaking light on a name label (0.4.3): the warm rim in the /// label's own `shape`, drawn over it, lit while that person speaks and /// following their voice, from `glow` (nil draws nothing: a screen's /// label). Outside the label's layout, so nothing moves when it lights. public func speakingRim(_ glow: GlowLevel?, in shape: S) -> some View { overlay { if let glow { SpeakingRimOverlay(level: glow, shape: shape) } } } } /// The rim itself. It redraws only while the light is moving: a timeline /// that runs from a new reading until the light has arrived where that /// reading sent it (`SpeakingGlow.settles`), at up to 30 Hz, and stops. In /// silence it draws nothing and nothing runs. With Reduce Motion there is /// no timeline at all: a steady rim while lit. struct SpeakingRimOverlay: View { @ObservedObject var level: GlowLevel let shape: S @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { let glow = level.glow if reduceMotion { WarmRimLayers(rim: glow.rim(at: 0, reduceMotion: true), shape: shape) } else { TimelineView(GlowSchedule(until: Self.date(uptime: glow.settles))) { _ in WarmRimLayers(rim: glow.rim(at: ProcessInfo.processInfo.systemUptime), shape: shape) } } } private static func date(uptime: Double) -> Date { Date().addingTimeInterval(uptime - ProcessInfo.processInfo.systemUptime) } } /// The four layers at one moment, over `shape`; nothing at all while off. struct WarmRimLayers: View { let rim: WarmRim let shape: S /// How far past the edge the inner light's source reaches: far enough /// that its blur, at the widest, fades inside as an infinite edge would. private static var band: CGFloat { 16 } var body: some View { if rim.on { ZStack { // 4. The outer breath: the shape's light, blurred, only outside it. if rim.outerOpacity > 0.001 { shape.fill(WarmRim.outerColor.opacity(rim.outerOpacity)) .blur(radius: rim.outerBlur / 2) .mask(OutsideOf(shape: shape, margin: 24).fill(style: FillStyle(eoFill: true))) } // 3. The inner light: an inset shadow — light from a band all round // outside the edge, blurred, and only what falls inside it kept. shape.inset(by: -Self.band) .strokeBorder(WarmRim.innerColor.opacity(rim.innerOpacity), lineWidth: Self.band) .blur(radius: rim.innerBlur / 2) .clipShape(shape) // 1. The rim. shape.strokeBorder(WarmRim.rimColor.opacity(rim.rimOpacity), lineWidth: WarmRim.rimWidth) } .allowsHitTesting(false) .accessibilityHidden(true) } } } /// Everything within `margin` of a shape's frame except the shape itself, /// filled even-odd: where the outer breath may show. struct OutsideOf: Shape { let shape: S let margin: CGFloat func path(in rect: CGRect) -> Path { var path = Path(rect.insetBy(dx: -margin, dy: -margin)) path.addPath(shape.path(in: rect)) return path } } /// A tile's name over the picture, on the Mac and the iPhone alike: the /// glass pill (or, compact, the label with its muted mark), with the warm /// rim on it while that person speaks. public struct TileSpeakingName: View { let label: String let muted: Bool let compact: Bool let glow: GlowLevel? public init(label: String, muted: Bool, compact: Bool, glow: GlowLevel?) { self.label = label self.muted = muted self.compact = compact self.glow = glow } public var body: some View { Group { if compact { TileNameLabel(label: label, muted: muted) } else { Text(label).font(.callout.bold()).foregroundStyle(.white) .lineLimit(1).padding(.horizontal, 10).padding(.vertical, 6) .callOverlaySurface(Capsule()) } } .speakingRim(glow, in: Capsule()) } }