oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

1import SwiftUI
2
3// 0.4.3: the speaking light on a name label is the warm rim — "T5, warm
4// soft breath", chosen from three rounds of mock-ups on 2026-09-28 and
5// drawn to match that mock-up (speaking-indicator-options/index.html),
6// whose box-shadow recipe this is. Over the label's own glass, in the
7// label's own shape, four layers:
8//
9// 1. the rim: a 1 pt line just inside the edge;
10// 2. a top highlight — none, in T5;
11// 3. the inner light: the edge's light bleeding inwards;
12// 4. the outer breath: the same light just outside the edge, only once
13// the voice is loud enough to push it out.
14//
15// All warm white (hue 38°, saturation 80 %), a little deeper for each layer
16// further from the edge. `on` is whether the light is on at all (lit, per
17// SpeakingGlow, or still fading from it); `level` is its smoothed level,
18// 0...1. The numbers are CSS's: a blur is a box-shadow's blur radius,
19// which is twice the Gaussian's standard deviation; SwiftUI's blur radius
20// is the deviation itself, so it is drawn at half.
21
22/// The warm rim's four layers at one moment: how opaque each is and how
23/// far the blurred ones spread.
24public struct WarmRim: Equatable, Sendable {
25 /// The rim's line, 1 pt inside the edge.
26 public static let rimWidth: CGFloat = 1
27 public static let hue: Double = 38
28 public static let saturation: Double = 0.80
29 public static let rimLightness: Double = 0.82
30 public static let innerLightness: Double = 0.72
31 public static let outerLightness: Double = 0.66
32
33 public let on: Bool
34 public let level: Double
35 /// 1. The rim: 0.10 lit, up to 0.55 at the loudest.
36 public let rimOpacity: Double
37 /// 2. The top highlight's: none in T5, and so never drawn.
38 public let topOpacity: Double
39 /// 3. The inner light: 0.06 lit, up to 0.36; blur 3 pt up to 9 pt.
40 public let innerOpacity: Double
41 public let innerBlur: Double
42 /// 4. The outer breath: nothing until the voice rises, up to 0.25;
43 /// blur 1 pt up to 8 pt.
44 public let outerOpacity: Double
45 public let outerBlur: Double
46
47 public init(on: Bool, level: Double) {
48 let o: Double = on ? 1 : 0
49 let l = min(1, max(0, level.isFinite ? level : 0))
50 self.on = on
51 self.level = l
52 rimOpacity = o * (0.10 + l * 0.45)
53 topOpacity = 0
54 innerOpacity = o * (0.06 + l * 0.30)
55 innerBlur = 3 + l * 6
56 outerOpacity = o * (0 + l * 0.25)
57 outerBlur = 1 + l * 7
58 }
59
60 public static let off = WarmRim(on: false, level: 0)
61
62 /// sRGB components of hsl(h°, s, l), all 0...1.
63 public static func rgb(hue: Double, saturation s: Double, lightness l: Double) -> (
64 red: Double, green: Double, blue: Double
65 ) {
66 let c = (1 - abs(2 * l - 1)) * s
67 let h = (hue.truncatingRemainder(dividingBy: 360) + 360).truncatingRemainder(dividingBy: 360) / 60
68 let x = c * (1 - abs(h.truncatingRemainder(dividingBy: 2) - 1))
69 let (r, g, b): (Double, Double, Double) =
70 switch h {
71 case ..<1: (c, x, 0)
72 case ..<2: (x, c, 0)
73 case ..<3: (0, c, x)
74 case ..<4: (0, x, c)
75 case ..<5: (x, 0, c)
76 default: (c, 0, x)
77 }
78 let m = l - c / 2
79 return (r + m, g + m, b + m)
80 }
81
82 static func color(lightness: Double) -> Color {
83 let c = rgb(hue: hue, saturation: saturation, lightness: lightness)
84 return Color(.sRGB, red: c.red, green: c.green, blue: c.blue)
85 }
86 static let rimColor = color(lightness: rimLightness)
87 static let innerColor = color(lightness: innerLightness)
88 static let outerColor = color(lightness: outerLightness)
89}
90
91extension View {
92 /// The speaking light on a name label (0.4.3): the warm rim in the
93 /// label's own `shape`, drawn over it, lit while that person speaks and
94 /// following their voice, from `glow` (nil draws nothing: a screen's
95 /// label). Outside the label's layout, so nothing moves when it lights.
96 public func speakingRim<S: InsettableShape>(_ glow: GlowLevel?, in shape: S) -> some View {
97 overlay {
98 if let glow { SpeakingRimOverlay(level: glow, shape: shape) }
99 }
100 }
101}
102
103/// The rim itself. It redraws only while the light is moving: a timeline
104/// that runs from a new reading until the light has arrived where that
105/// reading sent it (`SpeakingGlow.settles`), at up to 30 Hz, and stops. In
106/// silence it draws nothing and nothing runs. With Reduce Motion there is
107/// no timeline at all: a steady rim while lit.
108struct SpeakingRimOverlay<S: InsettableShape>: View {
109 @ObservedObject var level: GlowLevel
110 let shape: S
111 @Environment(\.accessibilityReduceMotion) private var reduceMotion
112
113 var body: some View {
114 let glow = level.glow
115 if reduceMotion {
116 WarmRimLayers(rim: glow.rim(at: 0, reduceMotion: true), shape: shape)
117 } else {
118 TimelineView(GlowSchedule(until: Self.date(uptime: glow.settles))) { _ in
119 WarmRimLayers(rim: glow.rim(at: ProcessInfo.processInfo.systemUptime), shape: shape)
120 }
121 }
122 }
123
124 private static func date(uptime: Double) -> Date {
125 Date().addingTimeInterval(uptime - ProcessInfo.processInfo.systemUptime)
126 }
127}
128
129/// The four layers at one moment, over `shape`; nothing at all while off.
130struct WarmRimLayers<S: InsettableShape>: View {
131 let rim: WarmRim
132 let shape: S
133 /// How far past the edge the inner light's source reaches: far enough
134 /// that its blur, at the widest, fades inside as an infinite edge would.
135 private static var band: CGFloat { 16 }
136
137 var body: some View {
138 if rim.on {
139 ZStack {
140 // 4. The outer breath: the shape's light, blurred, only outside it.
141 if rim.outerOpacity > 0.001 {
142 shape.fill(WarmRim.outerColor.opacity(rim.outerOpacity))
143 .blur(radius: rim.outerBlur / 2)
144 .mask(OutsideOf(shape: shape, margin: 24).fill(style: FillStyle(eoFill: true)))
145 }
146 // 3. The inner light: an inset shadow — light from a band all round
147 // outside the edge, blurred, and only what falls inside it kept.
148 shape.inset(by: -Self.band)
149 .strokeBorder(WarmRim.innerColor.opacity(rim.innerOpacity), lineWidth: Self.band)
150 .blur(radius: rim.innerBlur / 2)
151 .clipShape(shape)
152 // 1. The rim.
153 shape.strokeBorder(WarmRim.rimColor.opacity(rim.rimOpacity), lineWidth: WarmRim.rimWidth)
154 }
155 .allowsHitTesting(false)
156 .accessibilityHidden(true)
157 }
158 }
159}
160
161/// Everything within `margin` of a shape's frame except the shape itself,
162/// filled even-odd: where the outer breath may show.
163struct OutsideOf<S: Shape>: Shape {
164 let shape: S
165 let margin: CGFloat
166 func path(in rect: CGRect) -> Path {
167 var path = Path(rect.insetBy(dx: -margin, dy: -margin))
168 path.addPath(shape.path(in: rect))
169 return path
170 }
171}
172
173/// A tile's name over the picture, on the Mac and the iPhone alike: the
174/// glass pill (or, compact, the label with its muted mark), with the warm
175/// rim on it while that person speaks.
176public struct TileSpeakingName: View {
177 let label: String
178 let muted: Bool
179 let compact: Bool
180 let glow: GlowLevel?
181 public init(label: String, muted: Bool, compact: Bool, glow: GlowLevel?) {
182 self.label = label
183 self.muted = muted
184 self.compact = compact
185 self.glow = glow
186 }
187 public var body: some View {
188 Group {
189 if compact {
190 TileNameLabel(label: label, muted: muted)
191 } else {
192 Text(label).font(.callout.bold()).foregroundStyle(.white)
193 .lineLimit(1).padding(.horizontal, 10).padding(.vertical, 6)
194 .callOverlaySurface(Capsule())
195 }
196 }
197 .speakingRim(glow, in: Capsule())
198 }
199}
200