oficina / meet Public

Merge branch 'feat/warm-rim-speaking' into main

The warm rim (T5) replaces the teal speaking glow on Mac, iPhone, web and the mini window's tab; Calls 0.4.4.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev caf0ffe9eebab44a3d3e3aa05e981e0bb99a1b3d parent 24e2824parent 0847b96
20 files changed, +674 −200
  • 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
  • native/ios/project.yml +1 −1
  • test/browser/call.mjs +21 −10
  • test/browser/room-glow.test.mjs +73 −29

CI passed — run details

CI log
=== vet ===
go vet ./...
=== fmt ===
=== test ===
go test ./...
ok  	amadan.net/oficina/calls/callscore	(cached)
?   	amadan.net/oficina/calls/cmd/calls	[no test files]
ok  	amadan.net/oficina/calls/internal/calls	1.434s
ok  	amadan.net/oficina/calls/internal/calls/apns	(cached)
ok  	amadan.net/oficina/calls/internal/calls/hub	(cached)
?   	amadan.net/oficina/calls/internal/calls/icons	[no test files]
ok  	amadan.net/oficina/calls/internal/calls/relay	(cached)
ok  	amadan.net/oficina/calls/internal/calls/seal	(cached)
ok  	amadan.net/oficina/calls/internal/callstest	8.474s
?   	amadan.net/oficina/calls/protocol	[no test files]
=== migration-check ===
go run amadan.net/rastrillo/rastrillo/cmd/rastrillo migration check
rastrillo migration check: models and migrations agree
=== race ===
CGO_ENABLED=1 go test -race ./internal/...
ok  	amadan.net/oficina/calls/internal/calls	9.163s
ok  	amadan.net/oficina/calls/internal/calls/apns	(cached)
ok  	amadan.net/oficina/calls/internal/calls/hub	(cached)
?   	amadan.net/oficina/calls/internal/calls/icons	[no test files]
ok  	amadan.net/oficina/calls/internal/calls/relay	(cached)
ok  	amadan.net/oficina/calls/internal/calls/seal	(cached)
ok  	amadan.net/oficina/calls/internal/callstest	25.852s
diff --git a/internal/calls/static/calls.css b/internal/calls/static/calls.css
index 31841a9..f487799 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 63beac8..b785cc8 100644
--- a/internal/calls/static/room.js
+++ b/internal/calls/static/room.js
@@ -241,17 +241,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;
@@ -4602,7 +4602,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.
@@ -4614,8 +4614,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) {
@@ -4629,20 +4629,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
@@ -4698,15 +4701,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.
@@ -4726,7 +4729,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
@@ -4745,10 +4748,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;
@@ -6208,7 +6216,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 9bb8fc7..2f6f916 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/native/ios/project.yml b/native/ios/project.yml
index c04231a..c72fb95 100644
--- a/native/ios/project.yml
+++ b/native/ios/project.yml
@@ -10,7 +10,7 @@ settings:
base:
SWIFT_VERSION: '5.0'
CODE_SIGN_STYLE: Automatic
- MARKETING_VERSION: '0.4.3'
+ MARKETING_VERSION: '0.4.4'
CURRENT_PROJECT_VERSION: '1'
# The App Group the app and its broadcast extension share: the socket
# the iPhone's screen crosses lives in its container. One setting
diff --git a/test/browser/call.mjs b/test/browser/call.mjs
index 4ae5777..1a10f88 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');