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>
20 files changed,
+674
−200
internal/calls/static/calls.css+38 −15internal/calls/static/room.js+37 −29native/DESIGN.md+36 −4native/README.md+5 −4native/apple/Sources/CallsInterop/CallSnapshots.swift+8 −0native/apple/Sources/CallsKit/ArrangeTiles.swift+1 −1native/apple/Sources/CallsKit/AudioMeter.swift+5 −60native/apple/Sources/CallsKit/CallFixtures.swift+67 −0native/apple/Sources/CallsKit/CallsAppearance.swift+4 −3native/apple/Sources/CallsKit/MacVideoTile.swift+2 −12native/apple/Sources/CallsKit/SpeakingGlow.swift+26 −12native/apple/Sources/CallsKit/WarmRim.swift+199 −0native/apple/Tests/CallsKitTests/SpeakingGlowTests.swift+5 −6native/apple/Tests/CallsKitTests/WarmRimTests.swift+120 −0native/ios/Sources/CallsPhone.swift+6 −13native/ios/Sources/PhoneCallFixture.swift+6 −1native/ios/UITests/CallsPhoneUITests.swift+14 −0native/ios/project.yml+1 −1test/browser/call.mjs+21 −10test/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'); |