oficina / meet Public

Speaking light: the warm rim (T5, "Warm soft breath") replaces the teal glow

Paul chose T5 from three rounds of mock-ups (speaking-indicator-options,
28 Sep). Drawn exactly as the mock-up's box-shadow recipe over the name's
own glass and shape, where on is 0/1 and lvl the smoothed level:

- rim: 1 pt inset line, hsl(38 80% 82%), on x (0.10 + lvl x 0.45)
- top highlight: none
- inner light: inset blur 3 + lvl x 6, hsl(38 80% 72%), on x (0.06 + lvl x 0.30)
- outer breath: blur 1 + lvl x 7, hsl(38 80% 66%), on x lvl x 0.25

Timing unchanged (0.15 s onset, 50 ms attack, 300 ms release); the old
0.3 floor is gone. Once no longer lit the rim stays on until the level
falls under 0.05 (SpeakingGlow.outBelow; --on waits out the release on the
web), as the mock-up's does. Reduce Motion: steady lvl 0.6.

Native: WarmRim (the mapping) and TileSpeakingName (the tiles' shared name
label, Mac and iPhone), drawn as an overlay that observes only the level
and redraws only until it arrives. The mini window's tab uses the same.
CallsAppearance.glow is renamed accentLit for its two remaining uses (the
Devices level bars, a drop target's edge).

Web: .calls-tile-name::after box-shadow from --on/--lvl; glowStyle now
returns lvl, on and their transition times.

Tests: WarmRimTests (the four formulas, colours, fade-out, Reduce Motion,
fixtures), room-glow.test.mjs reads the layers back out of calls.css and
checks each formula; call.mjs observes the ::after shadow. Snapshots:
WarmRimGallery in --glass-snapshots (mac-rim-*) and testWarmRimSnapshots.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev bd0615cad2f77995e36643e68063978a980664c2 parent d7b60c9
19 files changed, +673 −199
  • internal/calls/static/calls.css +38 −15
  • internal/calls/static/room.js +37 −29
  • native/DESIGN.md +36 −4
  • native/README.md +5 −4
  • native/apple/Sources/CallsInterop/CallSnapshots.swift +8 −0
  • native/apple/Sources/CallsKit/ArrangeTiles.swift +1 −1
  • native/apple/Sources/CallsKit/AudioMeter.swift +5 −60
  • native/apple/Sources/CallsKit/CallFixtures.swift +67 −0
  • native/apple/Sources/CallsKit/CallsAppearance.swift +4 −3
  • native/apple/Sources/CallsKit/MacVideoTile.swift +2 −12
  • native/apple/Sources/CallsKit/SpeakingGlow.swift +26 −12
  • native/apple/Sources/CallsKit/WarmRim.swift +199 −0
  • native/apple/Tests/CallsKitTests/SpeakingGlowTests.swift +5 −6
  • native/apple/Tests/CallsKitTests/WarmRimTests.swift +120 −0
  • native/ios/Sources/CallsPhone.swift +6 −13
  • native/ios/Sources/PhoneCallFixture.swift +6 −1
  • native/ios/UITests/CallsPhoneUITests.swift +14 −0
  • test/browser/call.mjs +21 −10
  • test/browser/room-glow.test.mjs +73 −29
diff --git a/internal/calls/static/calls.css b/internal/calls/static/calls.css
index d8c9019..1353d87 100644
--- a/internal/calls/static/calls.css
+++ b/internal/calls/static/calls.css
@@ -524,28 +524,51 @@
background: rgb(255 255 255 / 0.14);
}
-/* The speaking glow behind the name, which replaced the ring round the
- tile and the meter beside the name. room.js sets --glow (0..1, how
- loudly they are speaking, 0 when they are not) and --glow-t, the
- transition's duration: quick rising, slower falling, so it breathes
- with the voice. A box-shadow, so nothing moves when it lights; the
- registered property is what lets the number itself be transitioned,
- and a browser without @property steps five times a second instead.
- The teal is the accent's hue lit up (#2ECCDB). With reduced motion,
- room.js holds one steady value and nothing is transitioned. */
-@property --glow {
+/* The speaking light on the name, which replaced the ring round the
+ tile and the meter beside the name (0.3.7), and since 0.4.3 is the
+ warm rim ("T5, warm soft breath"), as the native apps draw it
+ (WarmRim.swift). room.js sets --on (1 while lit), --lvl (0..1, how
+ loudly they are speaking) and the transitions' durations: --lvl-t, quick
+ rising and slower falling, so it breathes with the voice, and --on-t,
+ so --on goes out as --lvl reaches nothing rather than at once. Over the
+ label's glass, in its shape, three layers of warm white (hue 38°):
+ a 1px rim inside the edge, light bleeding inwards from it, and a breath
+ just outside it once the voice rises. A box-shadow, so nothing moves
+ when it lights; the registered properties are what let the numbers
+ themselves be transitioned, and a browser without @property steps five
+ times a second instead. With reduced motion, room.js holds one steady
+ level and nothing is transitioned. */
+@property --lvl {
+ syntax: '<number>';
+ inherits: true;
+ initial-value: 0;
+}
+@property --on {
syntax: '<number>';
inherits: true;
initial-value: 0;
}
.calls-tile-name {
- --glow: 0;
+ --on: 0;
+ --lvl: 0;
+}
+.calls-tile-name::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ border-radius: inherit;
+ pointer-events: none;
box-shadow:
- 0 0 calc(4px + 16px * var(--glow)) calc(1px + 4px * var(--glow)) rgb(46 204 219 / calc(0.9 * var(--glow))),
- 0 0 0 calc(1.5px * var(--glow)) rgb(46 204 219 / calc(0.5 * var(--glow)));
+ inset 0 0 0 1px hsl(38 80% 82% / calc(var(--on) * (0.1 + var(--lvl) * 0.45))),
+ inset 0 0 calc(3px + var(--lvl) * 6px) hsl(38 80% 72% / calc(var(--on) * (0.06 + var(--lvl) * 0.3))),
+ 0 0 calc(1px + var(--lvl) * 7px) hsl(38 80% 66% / calc(var(--on) * var(--lvl) * 0.25));
}
@media (prefers-reduced-motion: no-preference) {
- .calls-tile-name { transition: --glow var(--glow-t, 300ms) ease-out; }
+ .calls-tile-name {
+ transition:
+ --lvl var(--lvl-t, 300ms) ease-out,
+ --on var(--on-t, 0ms) step-end;
+ }
}
/* The room's control bar: one glass pane the buttons sit on, and the
@@ -673,7 +696,7 @@
then a smaller one. The caption stays one line: the name gives up its
end to an ellipsis, the muted mark keeps its slashed microphone and
drops the word (still read out: it is only visually hidden), and
- there is room past the caption's end for the speaking glow. */
+ there is room past the caption's end for the warm rim's breath. */
.calls-tile { container-type: inline-size; }
.calls-tile-off { padding-block-end: 2.75rem; }
.calls-tile-name [rst-badge] { color: #fff; border-color: rgb(255 255 255 / 0.5); }
diff --git a/internal/calls/static/room.js b/internal/calls/static/room.js
index fd8ea9d..43fed76 100644
--- a/internal/calls/static/room.js
+++ b/internal/calls/static/room.js
@@ -237,17 +237,17 @@
const SPEAK_ON = 0.04;
const SPEAK_OFF = 0.015;
const SPEAK_HOLD = 4;
- // The speaking glow behind a tile's name, which replaced the ring round
- // the tile and the meter beside the name (0.3.7), as the native apps
- // have it (SpeakingGlow.swift): a reading's brightness by decibels
- // between GLOW_QUIET_DB and GLOW_LOUD_DB, never under GLOW_FLOOR while
- // lit; lit only once sound at SPEAK_ON has kept up for GLOW_ONSET ms, so
- // a click never lights it; rising over GLOW_ATTACK ms and falling over
- // GLOW_RELEASE ms, which is a CSS transition on --glow; and one steady
- // GLOW_STEADY for anyone who has asked for reduced motion.
+ // The speaking light on a tile's name, which replaced the ring round
+ // the tile and the meter beside the name (0.3.7), and since 0.4.3 is
+ // the warm rim, as the native apps have it (SpeakingGlow.swift,
+ // WarmRim.swift): a level by decibels between GLOW_QUIET_DB and
+ // GLOW_LOUD_DB; lit only once sound at SPEAK_ON has kept up for
+ // GLOW_ONSET ms, so a click never lights it; the level rising over
+ // GLOW_ATTACK ms and falling over GLOW_RELEASE ms, which is a CSS
+ // transition on --lvl, with --on going out once it has fallen; and one
+ // steady GLOW_STEADY for anyone who has asked for reduced motion.
const GLOW_QUIET_DB = -42;
const GLOW_LOUD_DB = -12;
- const GLOW_FLOOR = 0.3;
const GLOW_ONSET = 150;
const GLOW_ATTACK = 50;
const GLOW_RELEASE = 300;
@@ -4592,7 +4592,7 @@
return rec.speaking;
}
- // glowIntensity is a level's brightness for the glow, 0..1: decibels,
+ // glowIntensity is a level for the warm rim, 0..1: decibels,
// because that is how loudness is heard, from nothing at GLOW_QUIET_DB
// to all of it at GLOW_LOUD_DB. Pure, and lifted by name in the node
// test.
@@ -4604,8 +4604,8 @@
// judgeGlow is the glow's gate and target, taken after judgeSpeaking
// on the same reading: lit while speaking once loud readings have kept
- // up for GLOW_ONSET ms, and then as bright as the reading, over the
- // floor. Returns the target brightness. Pure over the record, and
+ // up for GLOW_ONSET ms, and then at the reading's level. Returns the
+ // target level. Pure over the record, and
// lifted by name in the node test.
function judgeGlow(rec, level, now) {
if (!rec.speaking) {
@@ -4619,20 +4619,23 @@
if (rec.speaking && !rec.glowLit && rec.loudFrom && rec.loudLast - rec.loudFrom >= GLOW_ONSET) {
rec.glowLit = true;
}
- rec.glow = rec.glowLit ? GLOW_FLOOR + (1 - GLOW_FLOOR) * glowIntensity(level) : 0;
+ rec.glow = rec.glowLit ? glowIntensity(level) : 0;
return rec.glow;
}
- // glowStyle is what a name label is given for a glow: the value for
- // --glow, as a string with two places so an unchanged glow writes
- // nothing, and the transition's duration for getting there — the
- // attack going up, the release coming down, and none at all under
- // reduced motion, where a lit glow is one steady brightness. Pure, and
+ // glowStyle is what a name label is given for the warm rim: --lvl, as
+ // a string with two places so an unchanged level writes nothing, and
+ // the transition's duration for getting there — the attack going up,
+ // the release coming down; --on, 1 while lit; and how long --on waits
+ // before going out, which is the release, so the rim and its inner
+ // light fade with the level rather than vanishing at once. Under reduced
+ // motion a lit rim is one steady level and nothing waits. Pure, and
// lifted by name in the node test.
function glowStyle(lit, target, before, reduced) {
const value = reduced ? (lit ? GLOW_STEADY : 0) : lit ? target : 0;
const time = reduced ? 0 : value > before ? GLOW_ATTACK : GLOW_RELEASE;
- return { value: value.toFixed(2), time: time + 'ms' };
+ const onTime = reduced || lit ? 0 : GLOW_RELEASE;
+ return { lvl: value.toFixed(2), time: time + 'ms', on: lit ? '1' : '0', onTime: onTime + 'ms' };
}
// hearingShown is what a tile says about its audio, in order: the
@@ -4688,15 +4691,15 @@
'input chosen, a microphone muted on the device itself, or an audio interface sending a ' +
'different input. Calls lets them know.';
- // reducedMotion: the person has asked for less movement, so the glow
- // holds one brightness rather than following the voice.
+ // reducedMotion: the person has asked for less movement, so the warm
+ // rim holds one level rather than following the voice.
function reducedMotion() {
return typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
}
// hearingMarks builds one tile's hearing parts: the sentence with its
- // ⓘ, and the line for a peer's notice. The live sound is the glow on
- // the name label itself (drawHearing), which is a box-shadow and so
+ // ⓘ, and the line for a peer's notice. The live sound is the warm rim
+ // on the name label itself (drawHearing), which is a box-shadow and so
// nothing a screen reader hears — a level moving five times a second is
// noise to one — and everything it means that matters is said in the
// sentence instead.
@@ -4716,7 +4719,7 @@
const notice = document.createElement('span');
notice.className = 'calls-tile-notice';
notice.hidden = true;
- return { box, text, why, notice, kind: '', glow: '0.00' };
+ return { box, text, why, notice, kind: '', glow: '0.00', on: '0' };
}
// drawHearing brings one tile's hearing parts up to date. Called from
@@ -4735,10 +4738,15 @@
t.hearShown = shown;
const lit = shown === 'meter' && !!(rec && rec.glowLit);
const glow = glowStyle(lit, rec ? rec.glow : 0, Number(h.glow), reducedMotion());
- if (h.glow !== glow.value) {
- h.glow = glow.value;
- t.label.style.setProperty('--glow-t', glow.time);
- t.label.style.setProperty('--glow', glow.value);
+ if (h.glow !== glow.lvl) {
+ h.glow = glow.lvl;
+ t.label.style.setProperty('--lvl-t', glow.time);
+ t.label.style.setProperty('--lvl', glow.lvl);
+ }
+ if (h.on !== glow.on) {
+ h.on = glow.on;
+ t.label.style.setProperty('--on-t', glow.onTime);
+ t.label.style.setProperty('--on', glow.on);
}
const litText = lit ? 'on' : 'off';
if (t.label.dataset.glow !== litText) t.label.dataset.glow = litText;
@@ -6197,7 +6205,7 @@
level: rec ? rec.level : 0,
peak: rec ? rec.peak : 0,
silent: !!(rec && rec.silent),
- // The glow behind the name as drawn: lit, and its --glow.
+ // The warm rim on the name as drawn: lit, and its --lvl.
speaking: t.label.dataset.glow === 'on',
glow: Number(t.hear.glow) || 0,
text: t.hear.box.hidden ? '' : t.hear.text.textContent,
diff --git a/native/DESIGN.md b/native/DESIGN.md
index a4175cf..4ac6684 100644
--- a/native/DESIGN.md
+++ b/native/DESIGN.md
@@ -181,11 +181,11 @@ Supersedes the hairline edges and button tiles of 0.3.5's **Over the picture**.
rendered. It holds the controls up while open and never fades with them.
- **The speaking glow** replaces the white ring round a tile and the meter
beside the name, on Mac, iPhone, web and the mini window's tab: the accent's
- hue lit up (`CallsAppearance.glow`) as a halo behind the name label, bright
- as the voice (`SpeakingGlow`, and `judgeGlow`/`glowStyle` in room.js). The
- halo redraws on a timeline only until it arrives; on the web it is a
- registered `--glow` transitioned by CSS. "No sound from …" and the microphone
+ hue lit up as a halo behind the name label, bright as the voice
+ (`SpeakingGlow`, and `judgeGlow`/`glowStyle` in room.js). The halo redraws
+ on a timeline only until it arrives. "No sound from …" and the microphone
warnings stay as words by the name; the Devices panel keeps a compact meter.
+ *The teal halo is superseded by the warm rim (0.4.3, below).*
Snapshots: `CallsInterop --glass-snapshots <dir>` renders real windows below
the desktop and captures them with `screencapture -l` (an off-screen bitmap
@@ -194,6 +194,38 @@ draws no Liquid Glass); the iPhone's are
`-CallsFixtureNoGlass`).
+## The warm rim · 2026-09-28 (0.4.3)
+
+Supersedes the teal halo of 0.3.7's **speaking glow**. After three rounds of
+HTML mock-ups (`speaking-indicator-options/`, served at
+calls-led-options.bds.oncarlos.com) Paul chose **T5, "Warm soft breath"**, and
+it is drawn exactly as that mock-up's box-shadow recipe, on Mac, iPhone, web
+and the mini window's tab (`WarmRim`, `TileSpeakingName`; `glowStyle` and
+`.calls-tile-name::after` on the web).
+
+Over the name's own glass and in its shape, where `on` is 0 or 1 and `lvl`
+the smoothed level 0…1 (-42 to -12 dBFS, no floor any more):
+
+| Layer | Drawn | Colour | Opacity |
+| --- | --- | --- | --- |
+| Rim | 1 pt inside the edge | hsl(38°, 80 %, 82 %) | on × (0.10 + lvl × 0.45) |
+| Top highlight | none in T5 | — | 0 |
+| Inner light | inset blur 3 + lvl × 6 pt | hsl(38°, 80 %, 72 %) | on × (0.06 + lvl × 0.30) |
+| Outer breath | blur 1 + lvl × 7 pt, outside only | hsl(38°, 80 %, 66 %) | on × lvl × 0.25 |
+
+Blurs are CSS box-shadow radii; SwiftUI draws them at half (its radius is the
+Gaussian's deviation). The timing is 0.3.7's: lit once speech has kept up
+0.15 s, 50 ms attack, 300 ms release. Once no longer lit, `on` holds until the
+level has fallen under 0.05 (`SpeakingGlow.outBelow`; on the web `--on` waits
+out the release), as the mock-up's does, so the rim fades rather than
+vanishing. Reduce Motion: a steady lvl of 0.6 while lit, and nothing moves.
+Only the overlay observes the level, and it redraws only until it arrives.
+
+Snapshots: `CallsInterop --glass-snapshots <dir>` writes `mac-rim-<level>.png`
+(`WarmRimGallery`: the mock-up's dark and bright tiles, white, and the busy
+frame); the iPhone's are `CallsPhoneUITests.testWarmRimSnapshots`
+(`-CallsFixtureRimGallery <level>`).
+
## Speaker view and your own order · 2026-09-27 (0.4.0)
Paul asked whether he could rearrange the people in Grid, and for a Speaker
diff --git a/native/README.md b/native/README.md
index ac68fb3..8d2d46a 100644
--- a/native/README.md
+++ b/native/README.md
@@ -108,13 +108,14 @@ wrong (a stretched or broken-up signal, echo cancellation off, noise reduction
bypassed, processing refused — `AudioWarning`).
**Sound is measured, not inferred** (spec §15.20). Since 0.3.7 a tile shows it
-as the speaking glow behind the name (`SpeakingGlow`: -42 to -12 dBFS over a
-0.3 floor, lit once speech has kept up 0.15 s, 50 ms attack, 300 ms release,
-a steady 0.6 under Reduce Motion) in place of the white ring and the meter. On 2026-09-25 Tony, in a
+as the speaking light on the name — since 0.4.3 the warm rim (`SpeakingGlow`
+and `WarmRim`: -42 to -12 dBFS, lit once speech has kept up 0.15 s, 50 ms
+attack, 300 ms release, a steady 0.6 under Reduce Motion; see DESIGN.md) — in
+place of the white ring and the meter. On 2026-09-25 Tony, in a
browser, could not hear Paul on the Mac app, and Paul's panel looked fine
because nothing on it measured sound. Now:
-- **Your tile's glow** and the panel's **Microphone level** meter what is sent — the
+- **Your tile's rim** and the panel's **Microphone level** meter what is sent — the
capture hook's level after noise reduction (`CaptureLevel`, read at 10 Hz).
Unmuted and capturing, with nothing reaching the hook above -90 dBFS
*before* the suppressor for five seconds, the tile says "Your microphone
diff --git a/native/apple/Sources/CallsInterop/CallSnapshots.swift b/native/apple/Sources/CallsInterop/CallSnapshots.swift
index 2892a0b..adfe63b 100644
--- a/native/apple/Sources/CallsInterop/CallSnapshots.swift
+++ b/native/apple/Sources/CallsInterop/CallSnapshots.swift
@@ -102,6 +102,14 @@ import SwiftUI
try await shot("glow-\(level.rawValue)", .grid, grid: true, speaking: level)
try await shot("glow-focus-\(level.rawValue)", .focus, speaking: level)
}
+ // The warm rim on a name at each level over the pictures it has to
+ // read on, for comparing with the mock-up (0.4.3).
+ for level in FixtureSpeaking.allCases {
+ try await renderLive(
+ WarmRimGallery(level: level).background(Color.black).environment(\.colorScheme, .dark),
+ size: CGSize(width: 380, height: 670),
+ to: folder.appendingPathComponent("mac-rim-\(level.rawValue).png"))
+ }
for level in [FixtureSpeaking.silent, .high] {
let call = Call(playAudio: false)
CallFixture.focus.apply(to: call)
diff --git a/native/apple/Sources/CallsKit/ArrangeTiles.swift b/native/apple/Sources/CallsKit/ArrangeTiles.swift
index 485de5d..891048e 100644
--- a/native/apple/Sources/CallsKit/ArrangeTiles.swift
+++ b/native/apple/Sources/CallsKit/ArrangeTiles.swift
@@ -93,7 +93,7 @@ public struct ArrangeableTile: ViewModifier {
)
.overlay {
if lifted {
- RoundedRectangle(cornerRadius: 8).strokeBorder(CallsAppearance.glow, lineWidth: 3)
+ RoundedRectangle(cornerRadius: 8).strokeBorder(CallsAppearance.accentLit, lineWidth: 3)
.allowsHitTesting(false)
}
}
diff --git a/native/apple/Sources/CallsKit/AudioMeter.swift b/native/apple/Sources/CallsKit/AudioMeter.swift
index 0b68e02..ccf985d 100644
--- a/native/apple/Sources/CallsKit/AudioMeter.swift
+++ b/native/apple/Sources/CallsKit/AudioMeter.swift
@@ -302,9 +302,9 @@ public struct AudioLevelBars: View {
self.height = height
}
public var body: some View {
- // The glow's teal on a dark panel; the deeper accent on a light sheet,
- // where the lit one would be too pale to tell from the unlit.
- let lit = scheme == .dark ? CallsAppearance.glow : CallsAppearance.accent
+ // The accent lit up on a dark panel; the deeper accent on a light
+ // sheet, where the lit one would be too pale to tell from the unlit.
+ let lit = scheme == .dark ? CallsAppearance.accentLit : CallsAppearance.accent
HStack(alignment: .bottom, spacing: 2) {
ForEach(0..<MeterScale.steps, id: \.self) { bar in
Capsule()
@@ -388,61 +388,6 @@ struct AudioLine: View {
}
}
-extension View {
- /// The speaking glow behind a name label (0.3.7): the Calls teal as a
- /// soft halo round `shape`, lit while that person speaks and as bright
- /// as their voice, from `glow` (nil draws nothing: a screen's label).
- /// Behind the label and outside its layout, so nothing moves when it
- /// lights; no border, no flash.
- public func speakingGlow<S: Shape>(_ glow: GlowLevel?, in shape: S) -> some View {
- background {
- if let glow { SpeakingGlowHalo(level: glow, shape: shape) }
- }
- }
-}
-
-/// The halo itself. It redraws only while the glow is moving: a
-/// timeline that runs from a new reading until the glow 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 glow while lit.
-struct SpeakingGlowHalo<S: Shape>: View {
- @ObservedObject var level: GlowLevel
- let shape: S
- @Environment(\.accessibilityReduceMotion) private var reduceMotion
-
- var body: some View {
- let glow = level.glow
- if reduceMotion {
- halo(glow.value(at: 0, reduceMotion: true))
- } else {
- TimelineView(GlowSchedule(until: Self.date(uptime: glow.settles))) { _ in
- halo(glow.value(at: ProcessInfo.processInfo.systemUptime))
- }
- }
- }
-
- /// Spread and brightness both follow the value: a faint close halo for
- /// a quiet voice, a wider brighter one for a loud one.
- @ViewBuilder private func halo(_ value: Double) -> some View {
- if value > 0.01 {
- let v = CGFloat(value)
- ZStack {
- shape.fill(CallsAppearance.glow.opacity(0.35 + 0.45 * value))
- .padding(-(2 + 6 * v)).blur(radius: 4 + 8 * v)
- shape.stroke(CallsAppearance.glow.opacity(0.5 * value), lineWidth: 2 + 2 * v)
- .blur(radius: 2)
- }
- .allowsHitTesting(false)
- .accessibilityHidden(true)
- }
- }
-
- private static func date(uptime: Double) -> Date {
- Date().addingTimeInterval(uptime - ProcessInfo.processInfo.systemUptime)
- }
-}
-
/// Every thirtieth of a second from now until `until`, then nothing.
struct GlowSchedule: TimelineSchedule {
let until: Date
@@ -460,7 +405,7 @@ public func tileAccessibilityLabel(_ label: String, muted: Bool, audio: TileAudi
}
/// A waveform while someone is speaking, and nothing otherwise: the mini
-/// window's tab, with the same glow behind it as a name has. Hidden from
+/// window's tab, with the same warm rim round it as a name has. Hidden from
/// assistive technology, which would hear it flicker on and off with
/// every sentence.
public struct SpeakingGlyph: View {
@@ -474,7 +419,7 @@ public struct SpeakingGlyph: View {
Image(systemName: "waveform")
.font(.caption.bold()).foregroundStyle(.white)
.frame(width: 22, height: 22)
- .speakingGlow(glow, in: Circle())
+ .speakingRim(glow, in: Circle())
.opacity(speaking.value ? 1 : 0)
.help("Someone is speaking")
.accessibilityHidden(true)
diff --git a/native/apple/Sources/CallsKit/CallFixtures.swift b/native/apple/Sources/CallsKit/CallFixtures.swift
index 26ab720..071b94b 100644
--- a/native/apple/Sources/CallsKit/CallFixtures.swift
+++ b/native/apple/Sources/CallsKit/CallFixtures.swift
@@ -137,3 +137,70 @@ extension EnvironmentValues {
set { self[FixtureBackdropKey.self] = newValue }
}
}
+
+/// The warm rim on a tile's name at one level, over each kind of picture
+/// it has to read on (0.4.3): the rim mock-up's own dark and bright tiles,
+/// for comparing with it, then white and the busy frame. The name is the
+/// tiles' own `TileSpeakingName`, full size and compact.
+@_spi(Fixtures) public struct WarmRimGallery: View {
+ let level: FixtureSpeaking
+ @State private var glow = GlowLevel()
+ public init(level: FixtureSpeaking) { self.level = level }
+
+ public enum Picture: String, CaseIterable, Sendable { case mockDark, mockBright, white, busy }
+
+ public var body: some View {
+ VStack(spacing: 10) {
+ ForEach(Picture.allCases, id: \.self) { picture in
+ ZStack(alignment: .bottomLeading) {
+ Self.view(picture)
+ HStack(spacing: 12) {
+ TileSpeakingName(label: "Vicky Walsh", muted: false, compact: false, glow: glow)
+ TileSpeakingName(label: "Tony Keane", muted: false, compact: true, glow: glow)
+ }
+ .padding(14)
+ }
+ .frame(width: 360, height: 150)
+ .clipShape(RoundedRectangle(cornerRadius: 16))
+ }
+ }
+ .padding(10)
+ .onAppear { glow.showFixture(level: pow(10, level.decibels / 20)) }
+ }
+
+ @MainActor @ViewBuilder static func view(_ picture: Picture) -> some View {
+ switch picture {
+ case .mockDark:
+ // speaking-indicator-options/index.html's .tile, as near as SwiftUI
+ // draws its gradients.
+ ZStack {
+ LinearGradient(
+ stops: [
+ .init(color: Color(red: 0x3b / 255, green: 0x36 / 255, blue: 0x32 / 255), location: 0),
+ .init(color: Color(red: 0x1d / 255, green: 0x1b / 255, blue: 0x1a / 255), location: 0.6),
+ .init(color: Color(red: 0x12 / 255, green: 0x12 / 255, blue: 0x12 / 255), location: 1),
+ ], startPoint: .topLeading, endPoint: .bottomTrailing)
+ EllipticalGradient(
+ colors: [Color(red: 1, green: 214 / 255, blue: 170 / 255).opacity(0.35), .clear],
+ center: UnitPoint(x: 0.55, y: 0.4), startRadiusFraction: 0, endRadiusFraction: 0.5)
+ EllipticalGradient(
+ colors: [Color(red: 90 / 255, green: 110 / 255, blue: 140 / 255).opacity(0.45), .clear],
+ center: UnitPoint(x: 0.3, y: 0.8), startRadiusFraction: 0, endRadiusFraction: 0.35)
+ }
+ case .mockBright:
+ ZStack {
+ LinearGradient(
+ stops: [
+ .init(color: Color(red: 0xe9 / 255, green: 0xe4 / 255, blue: 0xdc / 255), location: 0),
+ .init(color: Color(red: 0xc9 / 255, green: 0xc2 / 255, blue: 0xb7 / 255), location: 0.6),
+ .init(color: Color(red: 0xa9 / 255, green: 0xa1 / 255, blue: 0x96 / 255), location: 1),
+ ], startPoint: .topLeading, endPoint: .bottomTrailing)
+ EllipticalGradient(
+ colors: [Color.white.opacity(0.9), .clear],
+ center: UnitPoint(x: 0.55, y: 0.4), startRadiusFraction: 0, endRadiusFraction: 0.5)
+ }
+ case .white: FixtureBackdrop.white.view
+ case .busy: FixtureBackdrop.busy.view
+ }
+ }
+}
diff --git a/native/apple/Sources/CallsKit/CallsAppearance.swift b/native/apple/Sources/CallsKit/CallsAppearance.swift
index d7f64f6..e72f292 100644
--- a/native/apple/Sources/CallsKit/CallsAppearance.swift
+++ b/native/apple/Sources/CallsKit/CallsAppearance.swift
@@ -3,8 +3,9 @@ import SwiftUI
/// Calls's first native palette; app-specific until a second app adopts it.
public enum CallsAppearance {
public static let accent = Color(red: 0.03, green: 0.49, blue: 0.54)
- /// The accent's hue lit up, for the speaking glow: the accent itself is
- /// a deep teal that would read as a shadow over video, not a light.
- public static let glow = Color(red: 0.18, green: 0.80, blue: 0.86)
+ /// The accent's hue lit up, for a mark that must read as a light on a
+ /// dark surface (the Devices panel's level bars, a drop target's edge):
+ /// the accent itself is a deep teal that would read as a shadow there.
+ public static let accentLit = Color(red: 0.18, green: 0.80, blue: 0.86)
public static let background = Color.primary.opacity(0.035)
}
diff --git a/native/apple/Sources/CallsKit/MacVideoTile.swift b/native/apple/Sources/CallsKit/MacVideoTile.swift
index 725290b..5d2bd88 100644
--- a/native/apple/Sources/CallsKit/MacVideoTile.swift
+++ b/native/apple/Sources/CallsKit/MacVideoTile.swift
@@ -65,22 +65,12 @@
VStack(alignment: .leading, spacing: 6) {
TileAudioNotices(info)
HStack(spacing: 6) {
- 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())
- }
- }
- // Speaking is the glow behind the name, as bright as the voice.
- .speakingGlow(audio?.glow, in: Capsule())
+ TileSpeakingName(label: label, muted: muted, compact: compact, glow: audio?.glow)
TileAudioStatus(info)
}
}
.padding(compact ? 8 : 10).padding(.bottom, compact ? labelLift : 0)
- // Room past the label's end for the speaking glow, when a long
+ // Room past the label's end for the rim's outer breath, when a long
// name has it truncating.
.padding(.trailing, 6)
.tileLabelBlock($labelBlock)
diff --git a/native/apple/Sources/CallsKit/SpeakingGlow.swift b/native/apple/Sources/CallsKit/SpeakingGlow.swift
index 3af8a57..d872e39 100644
--- a/native/apple/Sources/CallsKit/SpeakingGlow.swift
+++ b/native/apple/Sources/CallsKit/SpeakingGlow.swift
@@ -1,12 +1,13 @@
import Foundation
// 0.3.7: the speaking glow. The white ring round a tile and the meter
-// beside a name were two marks for one thing; they are now one — a soft
-// glow in the Calls teal behind the name label, lit while that person is
-// speaking and breathing with their voice. This is the rule behind it,
-// on the caller's clock, with nothing about views or the main actor in it:
-// what a reading makes the glow aim for, and how bright it is at any
-// moment on the way there.
+// beside a name were two marks for one thing; they are now one — a light
+// on the name label, lit while that person is speaking and breathing with
+// their voice. Since 0.4.3 that light is the warm rim (WarmRim: "T5, warm
+// soft breath"); until then it was a teal halo behind the label. This is
+// the rule behind it, on the caller's clock, with nothing about views or
+// the main actor in it: what a reading makes the light aim for, and how
+// bright it is at any moment on the way there.
//
// The levels arrive a few times a second (Call.watchLevels: 10 Hz for
// your microphone, 5 Hz for everyone else). Between readings the glow is
@@ -23,9 +24,6 @@ public struct SpeakingGlow: Equatable, Sendable {
/// bottom is where the speaking highlight lets go (SpeakingGate.offDb).
public static let quietDb: Float = SpeakingGate.offDb
public static let loudDb: Float = -12
- /// The least a lit glow shows, so it dims between syllables but does
- /// not go out while someone is still talking.
- public static let floor: Double = 0.3
/// Time constants, in seconds: how quickly the glow rises to a louder
/// reading and falls to a quieter one (63 % of the way in that time).
public static let attack: Double = 0.05
@@ -41,6 +39,10 @@ public struct SpeakingGlow: Equatable, Sendable {
public static let resolution: Double = 0.02
/// Closer than this to its target, the glow has arrived.
static let arrived: Double = 0.005
+ /// A glow that has stopped being lit stays on — its rim and inner light
+ /// still showing — until its level has fallen under this, so it fades
+ /// with the release rather than going out at once.
+ public static let outBelow: Double = 0.05
/// Lit: speaking, per SpeakingGate, and past the onset.
public private(set) var lit = false
@@ -55,7 +57,7 @@ public struct SpeakingGlow: Equatable, Sendable {
public init() {}
/// A reading's brightness, 0...1, by decibels between `quietDb` and
- /// `loudDb` — before the floor a lit glow never goes under.
+ /// `loudDb`: the level the warm rim is drawn at.
public static func intensity(level: Float) -> Double {
let db = MeterScale.decibels(level)
guard db.isFinite else { return 0 }
@@ -83,7 +85,7 @@ public struct SpeakingGlow: Equatable, Sendable {
nowLit = last - first >= Self.onset
}
}
- let next = nowLit ? Self.floor + (1 - Self.floor) * Self.intensity(level: level) : 0
+ let next = nowLit ? Self.intensity(level: level) : 0
if nowLit == lit, abs(next - target) < Self.resolution { return false }
from = value(at: now)
at = now
@@ -101,6 +103,18 @@ public struct SpeakingGlow: Equatable, Sendable {
return target + (from - target) * exp(-elapsed / tau)
}
+ /// Whether the light is on at `now`: lit, or still fading from being
+ /// lit (see `outBelow`). With Reduce Motion, lit alone.
+ public func on(at now: Double, reduceMotion: Bool = false) -> Bool {
+ if lit { return true }
+ return !reduceMotion && value(at: now) >= Self.outBelow
+ }
+
+ /// The warm rim as drawn at `now`.
+ public func rim(at now: Double, reduceMotion: Bool = false) -> WarmRim {
+ WarmRim(on: on(at: now, reduceMotion: reduceMotion), level: value(at: now, reduceMotion: reduceMotion))
+ }
+
/// When the glow will have arrived at its target, on the same clock:
/// the moment a view can stop redrawing it.
public var settles: Double {
@@ -115,7 +129,7 @@ public struct SpeakingGlow: Equatable, Sendable {
var glow = SpeakingGlow()
let on = MeterScale.decibels(level) >= SpeakingGate.onDb
glow.lit = on
- glow.target = on ? floor + (1 - floor) * intensity(level: level) : 0
+ glow.target = on ? intensity(level: level) : 0
glow.from = glow.target
return glow
}
diff --git a/native/apple/Sources/CallsKit/WarmRim.swift b/native/apple/Sources/CallsKit/WarmRim.swift
new file mode 100644
index 0000000..6437a81
--- /dev/null
+++ b/native/apple/Sources/CallsKit/WarmRim.swift
@@ -0,0 +1,199 @@
+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<S: InsettableShape>(_ 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<S: InsettableShape>: 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<S: InsettableShape>: 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<S: Shape>: 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())
+ }
+}
diff --git a/native/apple/Tests/CallsKitTests/SpeakingGlowTests.swift b/native/apple/Tests/CallsKitTests/SpeakingGlowTests.swift
index 1034510..9bd944a 100644
--- a/native/apple/Tests/CallsKitTests/SpeakingGlowTests.swift
+++ b/native/apple/Tests/CallsKitTests/SpeakingGlowTests.swift
@@ -77,14 +77,13 @@ final class SpeakingGlowTests: XCTestCase {
XCTAssertFalse(voice.glow.lit)
voice.hear(db: -20, at: 0.2)
XCTAssertTrue(voice.glow.lit, "sound at a speaking level for \(SpeakingGlow.onset)s is")
- XCTAssertEqual(
- voice.glow.target,
- SpeakingGlow.floor + (1 - SpeakingGlow.floor) * SpeakingGlow.intensity(level: level(db: -20)),
- accuracy: 1e-9)
- // Between syllables it dims but stays lit, as the gate does.
+ XCTAssertEqual(voice.glow.target, SpeakingGlow.intensity(level: level(db: -20)), accuracy: 1e-9)
+ // Between syllables its level falls away but it stays lit, as the
+ // gate does: the rim and inner light hold at their least.
voice.hear(db: -45, at: 0.3)
XCTAssertTrue(voice.glow.lit)
- XCTAssertEqual(voice.glow.target, SpeakingGlow.floor, accuracy: 1e-9)
+ XCTAssertEqual(voice.glow.target, 0, accuracy: 1e-9)
+ XCTAssertTrue(voice.glow.on(at: 5))
// And goes out when the gate lets go.
for t in stride(from: 0.4, through: 1.2, by: 0.1) { voice.hear(db: -70, at: t) }
XCTAssertFalse(voice.glow.lit)
diff --git a/native/apple/Tests/CallsKitTests/WarmRimTests.swift b/native/apple/Tests/CallsKitTests/WarmRimTests.swift
new file mode 100644
index 0000000..8842207
--- /dev/null
+++ b/native/apple/Tests/CallsKitTests/WarmRimTests.swift
@@ -0,0 +1,120 @@
+import XCTest
+
+@_spi(Fixtures) @testable import CallsKit
+
+/// The warm rim (0.4.3, "T5, warm soft breath"): the four layers'
+/// opacity and blur for a level, exactly the mock-up's box-shadow recipe;
+/// its colours; when it is on; and Reduce Motion.
+final class WarmRimTests: XCTestCase {
+ private func level(db: Float) -> Float { pow(10, db / 20) }
+
+ func testTheFourLayersFollowTheLevel() {
+ for lvl in stride(from: 0.0, through: 1, by: 0.05) {
+ let rim = WarmRim(on: true, level: lvl)
+ XCTAssertEqual(rim.rimOpacity, 0.10 + lvl * 0.45, accuracy: 1e-12, "rim at \(lvl)")
+ XCTAssertEqual(rim.topOpacity, 0, "T5 has no top highlight")
+ XCTAssertEqual(rim.innerOpacity, 0.06 + lvl * 0.30, accuracy: 1e-12, "inner at \(lvl)")
+ XCTAssertEqual(rim.innerBlur, 3 + lvl * 6, accuracy: 1e-12)
+ XCTAssertEqual(rim.outerOpacity, lvl * 0.25, accuracy: 1e-12, "outer at \(lvl)")
+ XCTAssertEqual(rim.outerBlur, 1 + lvl * 7, accuracy: 1e-12)
+ }
+ // The ends, spelled out.
+ let quiet = WarmRim(on: true, level: 0)
+ XCTAssertEqual(quiet.rimOpacity, 0.10, accuracy: 1e-12)
+ XCTAssertEqual(quiet.innerOpacity, 0.06, accuracy: 1e-12)
+ XCTAssertEqual(quiet.innerBlur, 3)
+ XCTAssertEqual(quiet.outerOpacity, 0, "no breath until the voice rises")
+ XCTAssertEqual(quiet.outerBlur, 1)
+ let loud = WarmRim(on: true, level: 1)
+ XCTAssertEqual(loud.rimOpacity, 0.55, accuracy: 1e-12)
+ XCTAssertEqual(loud.innerOpacity, 0.36, accuracy: 1e-12)
+ XCTAssertEqual(loud.innerBlur, 9)
+ XCTAssertEqual(loud.outerOpacity, 0.25, accuracy: 1e-12)
+ XCTAssertEqual(loud.outerBlur, 8)
+ XCTAssertEqual(WarmRim.rimWidth, 1)
+ }
+
+ func testOffIsNothingAtAnyLevel() {
+ for lvl in [0, 0.3, 1.0] {
+ let rim = WarmRim(on: false, level: lvl)
+ XCTAssertEqual(rim.rimOpacity, 0)
+ XCTAssertEqual(rim.innerOpacity, 0)
+ XCTAssertEqual(rim.outerOpacity, 0)
+ }
+ XCTAssertEqual(WarmRim.off, WarmRim(on: false, level: 0))
+ }
+
+ func testALevelOutOfRangeIsHeldToIt() {
+ XCTAssertEqual(WarmRim(on: true, level: 2), WarmRim(on: true, level: 1))
+ XCTAssertEqual(WarmRim(on: true, level: -1), WarmRim(on: true, level: 0))
+ XCTAssertEqual(WarmRim(on: true, level: .nan), WarmRim(on: true, level: 0))
+ }
+
+ func testTheColoursAreWarmWhite() {
+ // hsl(38°, 80 %, 82 / 72 / 66 %), as Chromium computes them.
+ func check(_ l: Double, _ r: Int, _ g: Int, _ b: Int) {
+ let c = WarmRim.rgb(hue: WarmRim.hue, saturation: WarmRim.saturation, lightness: l)
+ XCTAssertEqual(Int((c.red * 255).rounded()), r, "red at \(l)")
+ XCTAssertEqual(Int((c.green * 255).rounded()), g, "green at \(l)")
+ XCTAssertEqual(Int((c.blue * 255).rounded()), b, "blue at \(l)")
+ }
+ check(WarmRim.rimLightness, 246, 219, 172)
+ check(WarmRim.innerLightness, 241, 199, 126)
+ check(WarmRim.outerLightness, 238, 187, 99)
+ XCTAssertEqual(WarmRim.hue, 38)
+ XCTAssertEqual(WarmRim.saturation, 0.80)
+ }
+
+ func testSpeechTurnsItOnAndItFadesOut() {
+ var glow = SpeakingGlow()
+ XCTAssertEqual(glow.rim(at: 0), .off, "silence: nothing")
+ let loud = level(db: -12)
+ glow.update(level: loud, speaking: true, now: 0)
+ XCTAssertFalse(glow.on(at: 0), "not before the onset")
+ glow.update(level: loud, speaking: true, now: 0.2)
+ XCTAssertTrue(glow.on(at: 0.2))
+ // Lit, the level rises over the attack from nothing.
+ XCTAssertEqual(glow.rim(at: 0.2), WarmRim(on: true, level: 0))
+ XCTAssertEqual(glow.rim(at: 0.2 + SpeakingGlow.attack).level, 1 - exp(-1), accuracy: 1e-6)
+ XCTAssertEqual(glow.rim(at: 5).level, 1, accuracy: 1e-6)
+ // Silence: still on while the level falls over the release, and off
+ // once it is under `outBelow` — not at once.
+ glow.update(level: 0, speaking: false, now: 5)
+ XCTAssertFalse(glow.lit)
+ XCTAssertTrue(glow.on(at: 5 + SpeakingGlow.release), "fading")
+ let out = 5 + SpeakingGlow.release * log(1 / SpeakingGlow.outBelow)
+ XCTAssertTrue(glow.on(at: out - 0.01))
+ XCTAssertFalse(glow.on(at: out + 0.01))
+ let gone = glow.rim(at: out + 0.01)
+ XCTAssertFalse(gone.on)
+ XCTAssertEqual([gone.rimOpacity, gone.innerOpacity, gone.outerOpacity], [0, 0, 0], "nothing drawn")
+ XCTAssertLessThan(out, glow.settles, "and the view is still redrawing when it goes off")
+ }
+
+ func testReduceMotionIsASteadyRimAtPointSix() {
+ var glow = SpeakingGlow()
+ XCTAssertEqual(glow.rim(at: 0, reduceMotion: true), .off)
+ glow.update(level: level(db: -10), speaking: true, now: 0)
+ glow.update(level: level(db: -10), speaking: true, now: 0.2)
+ for (db, t) in [(-10, 0.3), (-35, 0.4), (-20, 0.5)] as [(Float, Double)] {
+ glow.update(level: level(db: db), speaking: true, now: t)
+ for dt in [0, 0.01, 0.1, 1.0] {
+ XCTAssertEqual(glow.rim(at: t + dt, reduceMotion: true), WarmRim(on: true, level: 0.6))
+ }
+ }
+ XCTAssertEqual(SpeakingGlow.steady, 0.6)
+ // Out at once, with no fade.
+ glow.update(level: 0, speaking: false, now: 1)
+ XCTAssertEqual(glow.rim(at: 1, reduceMotion: true), .off)
+ }
+
+ func testTheFixturesLevels() {
+ XCTAssertEqual(SpeakingGlow.fixture(level: level(db: FixtureSpeaking.silent.decibels)).rim(at: 0), .off)
+ let expected: [(FixtureSpeaking, Double)] = [(.low, 13.0 / 30), (.mid, 21.0 / 30), (.high, 1)]
+ for (fixture, lvl) in expected {
+ let rim = SpeakingGlow.fixture(level: level(db: fixture.decibels)).rim(at: 0)
+ XCTAssertTrue(rim.on, "\(fixture)")
+ XCTAssertEqual(rim.level, lvl, accuracy: 1e-5, "\(fixture)")
+ }
+ }
+}
diff --git a/native/ios/Sources/CallsPhone.swift b/native/ios/Sources/CallsPhone.swift
index 6e407e6..926362b 100644
--- a/native/ios/Sources/CallsPhone.swift
+++ b/native/ios/Sources/CallsPhone.swift
@@ -29,7 +29,10 @@ final class CallsPhoneDelegate: NSObject, UIApplicationDelegate {
var body: some Scene {
WindowGroup {
#if DEBUG
- if let fixture = PhoneCallFixture.requested {
+ if let level = PhoneCallFixture.rimGallery {
+ WarmRimGallery(level: level).frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(Color.black).preferredColorScheme(.dark)
+ } else if let fixture = PhoneCallFixture.requested {
PhoneCallFixture(fixture: fixture)
} else {
home
@@ -442,22 +445,12 @@ struct PhoneVideoTile: View {
VStack(alignment: .leading, spacing: 4) {
TileAudioNotices(info)
HStack(spacing: 5) {
- 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())
- }
- }
- // Speaking is the glow behind the name, as bright as the voice.
- .speakingGlow(audio?.glow, in: Capsule())
+ TileSpeakingName(label: label, muted: muted, compact: compact, glow: audio?.glow)
TileAudioStatus(info)
}
}
.padding(compact ? 6 : 8).padding(compact ? labelInsets : EdgeInsets())
- // Room past the label's end for the speaking glow, when a long
+ // Room past the label's end for the rim's outer breath, when a long
// name has it truncating.
.padding(.trailing, 6)
.tileLabelBlock($labelBlock)
diff --git a/native/ios/Sources/PhoneCallFixture.swift b/native/ios/Sources/PhoneCallFixture.swift
index 16d9da5..714f657 100644
--- a/native/ios/Sources/PhoneCallFixture.swift
+++ b/native/ios/Sources/PhoneCallFixture.swift
@@ -16,11 +16,16 @@
/// speaker|grid` (the grid fixture is Grid unless it says), and
/// `-CallsFixtureSpeaker tom`, `-CallsFixturePinned li`, `-CallsFixtureLift
/// li` (a tile half way through a drag) and `-CallsFixtureOrder
- /// li,tom,me,ana`.
+ /// li,tom,me,ana`. `-CallsFixtureRimGallery silent|low|mid|high` shows,
+ /// in place of any call, the warm rim on a name at that level over the
+ /// pictures it has to read on (`WarmRimGallery`, 0.4.3).
struct PhoneCallFixture: View {
static var requested: CallFixture? {
UserDefaults.standard.string(forKey: "CallsCallFixture").flatMap(CallFixture.init(rawValue:))
}
+ static var rimGallery: FixtureSpeaking? {
+ UserDefaults.standard.string(forKey: "CallsFixtureRimGallery").flatMap(FixtureSpeaking.init(rawValue:))
+ }
let fixture: CallFixture
@StateObject private var call = Call(playAudio: false)
diff --git a/native/ios/UITests/CallsPhoneUITests.swift b/native/ios/UITests/CallsPhoneUITests.swift
index f74b276..d9a9a47 100644
--- a/native/ios/UITests/CallsPhoneUITests.swift
+++ b/native/ios/UITests/CallsPhoneUITests.swift
@@ -273,6 +273,20 @@ final class CallsPhoneUITests: XCTestCase {
}
}
+ /// 0.4.3: the warm rim on a name at four levels, over the rim mock-up's
+ /// dark and bright tiles, white, and the busy frame (`WarmRimGallery`).
+ @MainActor func testWarmRimSnapshots() throws {
+ XCUIDevice.shared.orientation = .portrait
+ for level in ["silent", "low", "mid", "high"] {
+ let app = XCUIApplication()
+ app.launchArguments += ["-CallsFixtureRimGallery", level]
+ app.launch()
+ XCTAssertTrue(app.staticTexts["Vicky Walsh"].firstMatch.waitForExistence(timeout: 15))
+ Thread.sleep(forTimeInterval: 1)
+ try snapshot(app, "rim-\(level)")
+ }
+ }
+
/// 0.3.7: the names glowing at four levels, in the grid and in focus,
/// and the glass over black, white and a busy frame, in Liquid Glass and
/// in the material older systems draw instead.
diff --git a/test/browser/call.mjs b/test/browser/call.mjs
index eb3b5ff..e1d80b5 100644
--- a/test/browser/call.mjs
+++ b/test/browser/call.mjs
@@ -1205,20 +1205,29 @@ const refuseFirstPlay = () => {
};
};
-// glowOf is the speaking glow on a tile's name label as it is drawn:
-// whether it is lit, its --glow, and the shadow the browser computed from
-// it — plus whether anything of the ring or the meter it replaced is left
-// on the tile.
+// glowOf is the warm rim on a tile's name label as it is drawn (0.4.3):
+// whether it is lit, its --lvl and --on, the shadow the browser computed
+// for the label's ::after from them, and the most opaque of that shadow's
+// layers — plus whether anything of the ring or the meter it replaced is
+// left on the tile.
function glowOf(page, key) {
return page.evaluate((k) => {
const figure = document.querySelector('#tiles figure[data-tile="' + k + '"]');
const label = figure && figure.querySelector('.calls-tile-name');
if (!label) return null;
const style = getComputedStyle(label);
+ const shadow = getComputedStyle(label, '::after').boxShadow;
+ let alpha = 0;
+ for (const m of shadow.matchAll(/rgba?\(([^)]*)\)/g)) {
+ const parts = m[1].split(',').map(Number);
+ alpha = Math.max(alpha, parts.length === 4 ? parts[3] : 1);
+ }
return {
lit: label.dataset.glow === 'on',
- glow: Number(style.getPropertyValue('--glow')) || 0,
- shadow: style.boxShadow,
+ glow: Number(style.getPropertyValue('--lvl')) || 0,
+ on: Number(style.getPropertyValue('--on')) || 0,
+ shadow,
+ alpha,
// The old ring was an inset shadow on the figure (a floating self
// view's own drop shadow is not one).
ring: figure.classList.contains('calls-tile-speaking') || /inset/.test(getComputedStyle(figure).boxShadow),
@@ -1638,7 +1647,7 @@ const run = {
const lit = await until(async () => {
const g = await glowOf(pageB, a);
const h = await hearingOf(pageB, a);
- return g && g.lit && g.glow > 0 && h && h.speaking && h.glow > 0 && g.shadow !== 'none' ? { g, h } : false;
+ return g && g.lit && g.glow > 0 && g.on === 1 && g.alpha > 0.1 && h && h.speaking && h.glow > 0 ? { g, h } : false;
}, 10000);
check(lit, 'A’s name glows on B while A speaks (' + JSON.stringify(lit || (await glowOf(pageB, a))) + ')');
const selfLit = await until(async () => {
@@ -1653,14 +1662,16 @@ const run = {
const ringless = await glowOf(pageB, a);
check(ringless && !ringless.ring, 'speaking draws no ring round the tile (' + JSON.stringify(ringless) + ')');
// Silence — the track disabled, so packets carry nothing — puts it
- // out, over the release rather than at once. (The fake microphone's
- // beep, back, would light it again: Chromium's audioLevel lingers.)
+ // out, over the release rather than at once: --on waits for --lvl to
+ // fall, then every layer of the rim is transparent. (The fake
+ // microphone's beep, back, would light it again: Chromium's audioLevel
+ // lingers.)
await pageA.evaluate(() => {
for (const [sn] of window.__toneRestore || []) if (sn.track) sn.track.enabled = false;
});
const out = await until(async () => {
const g = await glowOf(pageB, a);
- return g && !g.lit && g.glow === 0 && g.shadow.startsWith('rgba(46, 204, 219, 0)') ? g : false;
+ return g && !g.lit && g.glow === 0 && g.on === 0 && g.shadow !== 'none' && g.alpha === 0 ? g : false;
}, 10000);
await stopSpeaking(pageA);
check(out, 'and it goes out when A stops (' + JSON.stringify(out || (await glowOf(pageB, a))) + ')');
diff --git a/test/browser/room-glow.test.mjs b/test/browser/room-glow.test.mjs
index 80d5790..8246967 100644
--- a/test/browser/room-glow.test.mjs
+++ b/test/browser/room-glow.test.mjs
@@ -1,6 +1,6 @@
-// room-glow.test.mjs — room.js's speaking glow (0.3.7), which replaced the
-// ring round a tile and the meter beside its name, gated without a
-// browser.
+// room-glow.test.mjs — room.js's speaking light (0.3.7), which replaced
+// the ring round a tile and the meter beside its name, and since 0.4.3 is
+// the warm rim ("T5, warm soft breath"), gated without a browser.
//
// node --test test/browser/room-glow.test.mjs
//
@@ -9,11 +9,13 @@
// retuned constant or a renamed function fails here rather than passing
// against a copy.
//
-// glowIntensity a level to the glow's brightness, by decibels
+// glowIntensity a reading to the rim's level, by decibels
// judgeGlow the gate (speaking, and loud for long enough) and the
-// target brightness over the floor
-// glowStyle what a label is given: --glow, and the transition's
-// duration (attack up, release down, none when reduced)
+// target level
+// glowStyle what a label is given: --lvl and its transition's
+// duration (attack up, release down, none when reduced),
+// and --on and how long it waits to go out
+// calls.css the four layers' formulas, as the mock-up has them
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
@@ -44,7 +46,7 @@ function liftConst(name) {
return 'const ' + name + ' = ' + m[1] + ';';
}
-const NAMES = ['SPEAK_ON', 'SPEAK_OFF', 'SPEAK_HOLD', 'GLOW_QUIET_DB', 'GLOW_LOUD_DB', 'GLOW_FLOOR',
+const NAMES = ['SPEAK_ON', 'SPEAK_OFF', 'SPEAK_HOLD', 'GLOW_QUIET_DB', 'GLOW_LOUD_DB',
'GLOW_ONSET', 'GLOW_ATTACK', 'GLOW_RELEASE', 'GLOW_STEADY'];
const CONSTS = NAMES.map(liftConst).join('\n');
// eslint-disable-next-line no-new-func
@@ -67,11 +69,11 @@ const hear = (rec, level, now) => {
};
test('the constants are the native app’s', () => {
- // SpeakingGlow.swift: -42 to -12 dBFS, a 0.3 floor, 150 ms onset,
- // 50 ms attack, 300 ms release, 0.6 steady.
+ // SpeakingGlow.swift: -42 to -12 dBFS, 150 ms onset, 50 ms attack,
+ // 300 ms release, 0.6 steady. The old glow's floor is gone.
assert.equal(c.GLOW_QUIET_DB, -42);
assert.equal(c.GLOW_LOUD_DB, -12);
- assert.equal(c.GLOW_FLOOR, 0.3);
+ assert.doesNotMatch(src, /GLOW_FLOOR/);
assert.equal(c.GLOW_ONSET, 150);
assert.equal(c.GLOW_ATTACK, 50);
assert.ok(c.GLOW_RELEASE >= 250 && c.GLOW_RELEASE <= 400);
@@ -113,9 +115,9 @@ test('a brief noise does not light it; a voice that keeps up does', () => {
assert.equal(hear(rec, db(-20), 4000), 0);
const lit = hear(rec, db(-20), 4200);
assert.equal(rec.glowLit, true);
- assert.ok(Math.abs(lit - (c.GLOW_FLOOR + (1 - c.GLOW_FLOOR) * glowIntensity(db(-20)))) < 1e-9);
- // Between syllables it dims to the floor, still lit.
- assert.equal(hear(rec, db(-45), 4400), c.GLOW_FLOOR);
+ assert.ok(Math.abs(lit - glowIntensity(db(-20))) < 1e-9);
+ // Between syllables its level falls away, still lit.
+ assert.equal(hear(rec, db(-45), 4400), 0);
assert.equal(rec.glowLit, true);
// And goes out once the speaking gate lets go.
let t = 4600;
@@ -124,25 +126,67 @@ test('a brief noise does not light it; a voice that keeps up does', () => {
assert.equal(rec.glow, 0);
});
-test('glowStyle: attack going up, release coming down, a steady glow under reduced motion', () => {
- assert.deepEqual(glowStyle(true, 0.8, 0, false), { value: '0.80', time: c.GLOW_ATTACK + 'ms' });
- assert.deepEqual(glowStyle(true, 0.3, 0.8, false), { value: '0.30', time: c.GLOW_RELEASE + 'ms' });
- assert.deepEqual(glowStyle(false, 0.9, 0.3, false), { value: '0.00', time: c.GLOW_RELEASE + 'ms' },
- 'not lit is nothing, whatever the target');
+test('glowStyle: attack going up, release coming down, a steady rim under reduced motion', () => {
+ assert.deepEqual(glowStyle(true, 0.8, 0, false), { lvl: '0.80', time: c.GLOW_ATTACK + 'ms', on: '1', onTime: '0ms' });
+ assert.deepEqual(glowStyle(true, 0.3, 0.8, false), { lvl: '0.30', time: c.GLOW_RELEASE + 'ms', on: '1', onTime: '0ms' });
+ assert.deepEqual(glowStyle(false, 0.9, 0.3, false),
+ { lvl: '0.00', time: c.GLOW_RELEASE + 'ms', on: '0', onTime: c.GLOW_RELEASE + 'ms' },
+ 'not lit is nothing, whatever the target — and --on waits out the release');
for (const target of [0.3, 0.55, 1]) {
- assert.deepEqual(glowStyle(true, target, 0.1, true), { value: '0.60', time: '0ms' }, 'reduced: one value');
+ assert.deepEqual(glowStyle(true, target, 0.1, true), { lvl: '0.60', time: '0ms', on: '1', onTime: '0ms' },
+ 'reduced: one level');
}
- assert.deepEqual(glowStyle(false, 0, 0.6, true), { value: '0.00', time: '0ms' }, 'reduced: off at once');
- // Two places, so an unchanged glow writes nothing to the style.
- assert.equal(glowStyle(true, 0.41234, 0, false).value, '0.41');
+ assert.deepEqual(glowStyle(false, 0, 0.6, true), { lvl: '0.00', time: '0ms', on: '0', onTime: '0ms' },
+ 'reduced: off at once');
+ // Two places, so an unchanged level writes nothing to the style.
+ assert.equal(glowStyle(true, 0.41234, 0, false).lvl, '0.41');
});
-test('calls.css draws the glow from --glow and has no ring or meter left', () => {
- assert.match(css, /@property --glow/);
- assert.match(css, /\.calls-tile-name \{\s*--glow: 0;\s*box-shadow:[^}]*var\(--glow\)/);
- assert.match(css, /transition: --glow var\(--glow-t/);
- assert.match(css, /@media \(prefers-reduced-motion: no-preference\) \{\s*\.calls-tile-name \{ transition/,
- 'the transition is only for people who have not asked for less motion');
+// The rule the ::after's box-shadow encodes, read back out of calls.css
+// and evaluated: T5's four layers, where `on` is 0 or 1 and `lvl` 0..1.
+function rimLayers(on, lvl) {
+ const block = css.match(/\.calls-tile-name::after \{[^}]*box-shadow:([^;]*);/);
+ assert.ok(block, 'calls.css has no .calls-tile-name::after box-shadow');
+ const layers = block[1].split(/,\s*\n/).map((l) => l.trim());
+ const num = (expr) =>
+ // eslint-disable-next-line no-new-func
+ new Function('on', 'lvl', 'return ' + expr.replace(/calc/g, '').replace(/var\(--on\)/g, 'on')
+ .replace(/var\(--lvl\)/g, 'lvl').replace(/px/g, '') + ';')(on, lvl);
+ return layers.map((l) => {
+ // offset-x offset-y blur [spread] colour
+ const m = l.match(/^(inset )?0 0 (calc\(.*?\)|[0-9.]+(?:px)?)(?: ([0-9.]+px))? hsl\((\d+) (\d+)% (\d+)% \/ (calc\(.*\))\)$/);
+ assert.ok(m, 'a layer calls.css draws that this test cannot read: ' + l);
+ return { inset: !!m[1], blur: num(m[2]), spread: m[3] ? num(m[3]) : 0, h: +m[4], s: +m[5], l: +m[6], a: num(m[7]) };
+ });
+}
+
+test('calls.css draws T5 from --on and --lvl: rim, inner light, outer breath', () => {
+ const close = (a, b, what) => assert.ok(Math.abs(a - b) < 1e-9, what + ': ' + a + ' is not ' + b);
+ for (const lvl of [0, 0.25, 0.5, 0.75, 1]) {
+ const [rim, inner, outer, ...rest] = rimLayers(1, lvl);
+ assert.equal(rest.length, 0, 'three layers; T5 has no top highlight');
+ assert.deepEqual([rim.inset, rim.blur, rim.spread, rim.h, rim.s, rim.l], [true, 0, 1, 38, 80, 82], 'the rim');
+ close(rim.a, 0.1 + lvl * 0.45, 'rim alpha at ' + lvl);
+ assert.deepEqual([inner.inset, inner.spread, inner.h, inner.s, inner.l], [true, 0, 38, 80, 72], 'the inner light');
+ close(inner.blur, 3 + lvl * 6, 'inner blur at ' + lvl);
+ close(inner.a, 0.06 + lvl * 0.3, 'inner alpha at ' + lvl);
+ assert.deepEqual([outer.inset, outer.spread, outer.h, outer.s, outer.l], [false, 0, 38, 80, 66], 'the outer breath');
+ close(outer.blur, 1 + lvl * 7, 'outer blur at ' + lvl);
+ close(outer.a, lvl * 0.25, 'outer alpha at ' + lvl);
+ }
+ for (const lvl of [0, 0.6, 1]) {
+ for (const layer of rimLayers(0, lvl)) assert.equal(layer.a, 0, 'off is nothing at ' + lvl);
+ }
+});
+
+test('calls.css transitions --lvl and --on, only without reduced motion, and has no old glow', () => {
+ assert.match(css, /@property --lvl/);
+ assert.match(css, /@property --on/);
+ assert.match(css, /\.calls-tile-name::after \{[^}]*position: absolute;[^}]*inset: 0;[^}]*border-radius: inherit;[^}]*pointer-events: none;/);
+ assert.match(css, /@media \(prefers-reduced-motion: no-preference\) \{\s*\.calls-tile-name \{\s*transition:\s*--lvl var\(--lvl-t, 300ms\) ease-out,\s*--on var\(--on-t, 0ms\) step-end;/,
+ 'the transitions are only for people who have not asked for less motion');
+ assert.doesNotMatch(css, /--glow|46 204 219/, 'the teal glow is gone');
+ assert.doesNotMatch(src, /--glow/, 'and room.js sets none of it');
assert.doesNotMatch(css, /\.calls-tile-speaking/, 'the ring is gone');
assert.doesNotMatch(css, /\.calls-meter/, 'the meter is gone');
assert.doesNotMatch(src, /calls-meter|calls-tile-speaking/, 'and room.js draws neither');