oficina / meet Public

Merge branch 'fix/call-controls-contrast' into main

Legible call controls over video (one dark overlay system, WCAG-checked),
controls that stay 3 s and linger 1.5 s after the pointer leaves, no title
bar in the Mac's full-window call with the traffic lights over the picture
and a drag strip, and Calls 0.3.5.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 16c14ecbc8f88a499792a876263813e78f99cf2b parent 1d0c4d8parent 4130bf5
16 files changed, +890 −158
  • native/DESIGN.md +29 −9
  • native/apple/Sources/CallsInterop/CallSnapshots.swift +13 −2
  • native/apple/Sources/CallsKit/Call.swift +8 −0
  • native/apple/Sources/CallsKit/CallActionStyle.swift +158 −7
  • native/apple/Sources/CallsKit/CallChrome.swift +47 −17
  • native/apple/Sources/CallsKit/CallFixtures.swift +75 −0
  • native/apple/Sources/CallsKit/MacCallView.swift +30 −9
  • native/apple/Sources/CallsKit/MacFullWindowCall.swift +227 −48
  • native/apple/Sources/CallsKit/MutedBadge.swift +4 −2
  • native/apple/Sources/CallsMac/CallsMac.swift +66 −2
  • native/apple/Tests/CallsKitTests/CallChromeTests.swift +157 −32
  • native/ios/Sources/CallsPhone.swift +2 −1
  • native/ios/Sources/PhoneCallFixture.swift +8 −1
  • native/ios/Sources/PhoneFullScreenCall.swift +35 −26
  • native/ios/UITests/CallsPhoneUITests.swift +30 −1
  • native/ios/project.yml +1 −1

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.401s
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	(cached)
?   	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	8.343s
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	(cached)
diff --git a/native/DESIGN.md b/native/DESIGN.md
index bcf520e..1dab73a 100644
--- a/native/DESIGN.md
+++ b/native/DESIGN.md
@@ -91,19 +91,28 @@ Full window is the default. The mini window and tab are unchanged.
- **When the controls show** is `ControlsVisibility` (CallChrome.swift), one
state machine for both apps with the time passed in, so tests drive it. Mac:
- pointer movement shows them, 1.5 s still hides them, leaving the window hides
- them at once, and the pointer hides with them over the picture
- (`NSCursor.setHiddenUntilMouseMoves`). iPhone: a tap toggles, 2.5 s after the
- last touch hides — no hover says the person is still there, and a thumb needs
- the extra second to reach the bar. They stay up while the pointer is on the
+ pointer movement shows them, 3 s still hides them (`pointerDelay`; 1.5 s in
+ 0.3.4 was too quick), leaving the window hides them 1.5 s later
+ (`pointerExitDelay`, never later than they were due anyway), and the pointer
+ hides with them over the picture (`NSCursor.setHiddenUntilMouseMoves`).
+ iPhone: a tap toggles, 3 s after the last touch hides (`touchDelay`). They stay up while the pointer is on the
bar, a menu, popover, sheet or panel is open (AppKit's menu-tracking and
window-sheet notifications cover the ones SwiftUI does not report), a bar
control has keyboard focus, VoiceOver is running, or an alert shows. Tab
- reveals them. Space and ⌘⇧M work with them hidden. Fades are opacity only;
- Reduce Motion shortens them to 0.1 s and panels fade instead of sliding.
+ reveals them. Space and ⌘⇧M work with them hidden. Fades are opacity only,
+ eased out: 0.35 s going, 0.2 s coming back; Reduce Motion shortens them to a
+ 0.1 s opacity change and panels fade instead of sliding.
- **The stage** is edge to edge: square tiles for Focus and a screen, a grid
that fits every tile without scrolling until they would be under 200 pt
- (`GridFit`). On Mac the title bar is transparent with the traffic lights kept.
+ (`GridFit`). On Mac there is no title bar while the call is full window
+ (0.3.5): the picture goes to the window's top edge, the traffic lights sit
+ over it and fade with the controls (not in full screen, where the system
+ shows them), a shade behind them keeps them legible, and a 32 pt strip
+ across the top drags the window and double-clicks as the person's title-bar
+ setting says (`WindowDragStrip`, `TitleBarDoubleClick`). The title pill sits
+ to the right of the traffic lights and the notices in a column beneath it.
+ `CallWindowChrome` sets the window's title bar, watches it so SwiftUI cannot
+ quietly restore it mid-call, and puts it back for the lobby and Classic.
Names are small capsules with the muted mark (shape and word) inside, lifted
clear of the bar while it shows, visible while it does not.
- **The self view** floats in Focus, draggable, settling in the corner it was
@@ -115,7 +124,18 @@ Full window is the default. The mini window and tab are unchanged.
waiting to be let in, full, ended (with Join again), problems (dismissible),
"You are sharing your screen" and each person waiting with **Let in** stay
until resolved (`CallNotices`); the relay-floor line fades.
-- **Mac bar**: a material bar along the bottom — microphone, camera, share,
+- **Over the picture** (0.3.5): one dark system, whatever the system
+ appearance, because what is underneath is video (`CallOverlay`,
+ `CallOverlaySurface`, `CallOverlayButtonStyle`). Every surface — the bar,
+ the title pill, notices, name labels, the iPhone's round buttons — is black
+ at 70 % over a dark blur with a hairline edge; the black alone holds the
+ contrast, so white on it is at least 8.5:1 even over a white wall. Buttons
+ are white symbols on a light fill (5.5:1 worst case); a control that is on
+ (sharing, a panel open) is near-white with a dark symbol; microphone or
+ camera off is amber with the slashed symbol; Leave is a deeper red than the
+ system's (5.0:1 for its symbol). `CallOverlayContrastTests` holds the ratios
+ over white, black and grey. Classic keeps its own look.
+- **Mac bar**: a dark bar along the bottom — microphone, camera, share,
devices, layout (Focus/Grid and who is on the stage, in a popover), share link
— with chat and Leave on the right. Devices is a floating panel over the
stage above the bar, not a column that shrinks it.
diff --git a/native/apple/Sources/CallsInterop/CallSnapshots.swift b/native/apple/Sources/CallsInterop/CallSnapshots.swift
index a45d1e0..5ce2c9c 100644
--- a/native/apple/Sources/CallsInterop/CallSnapshots.swift
+++ b/native/apple/Sources/CallsInterop/CallSnapshots.swift
@@ -16,15 +16,17 @@ import SwiftUI
let suffix = LobbySnapshots.name(scheme)
func shot(
_ name: String, _ fixture: CallFixture, grid: Bool = false, shown: Bool = true,
- devices: Bool = false, chat: Bool = false, style: CallLayoutStyle = .fullWindow
+ devices: Bool = false, chat: Bool = false, style: CallLayoutStyle = .fullWindow,
+ backdrop: FixtureBackdrop? = nil, me: FixtureSelf? = nil
) async throws {
let call = Call(playAudio: false)
fixture.apply(to: call)
+ me?.apply(to: call)
try await LobbySnapshots.render(
MacCallView(
call: call, grid: grid, showingChat: chat, showingDevices: devices, style: style,
controlsShown: shown, leave: {}, rejoin: {}
- ).tint(CallsAppearance.accent),
+ ).tint(CallsAppearance.accent).environment(\.fixtureBackdrop, backdrop),
scheme: scheme, size: size,
to: folder.appendingPathComponent("mac-\(name)-\(suffix).png"))
}
@@ -39,6 +41,15 @@ import SwiftUI
try await shot("notices-hidden", .notices, shown: false)
try await shot("classic-focus", .focus, style: .classic)
try await shot("classic-grid", .grid, grid: true, style: .classic)
+ // The overlay over the worst pictures there are for it, in each of
+ // your own states: it should look the same in light and dark.
+ for backdrop in FixtureBackdrop.allCases {
+ for me in FixtureSelf.allCases {
+ try await shot("contrast-\(backdrop.rawValue)-\(me.rawValue)", .focus, backdrop: backdrop, me: me)
+ }
+ try await shot("contrast-\(backdrop.rawValue)-notices", .notices, backdrop: backdrop)
+ }
+ try await shot("contrast-busy-hidden", .focus, shown: false, backdrop: .busy)
}
}
}
diff --git a/native/apple/Sources/CallsKit/Call.swift b/native/apple/Sources/CallsKit/Call.swift
index 12c1697..8dece0a 100644
--- a/native/apple/Sources/CallsKit/Call.swift
+++ b/native/apple/Sources/CallsKit/Call.swift
@@ -662,4 +662,12 @@ extension Call {
self.muted = muted
self.floor = floor
}
+
+ /// Your own microphone, camera and screen, set over a fixture for the
+ /// snapshots of the controls in each state.
+ @_spi(Fixtures) public func showFixtureSelf(muted: Bool, cameraOff: Bool, sharingScreen: Bool) {
+ self.muted = muted
+ self.cameraOff = cameraOff
+ self.sharingScreen = sharingScreen
+ }
}
diff --git a/native/apple/Sources/CallsKit/CallActionStyle.swift b/native/apple/Sources/CallsKit/CallActionStyle.swift
index 2d99e34..979f80e 100644
--- a/native/apple/Sources/CallsKit/CallActionStyle.swift
+++ b/native/apple/Sources/CallsKit/CallActionStyle.swift
@@ -1,22 +1,173 @@
import SwiftUI
/// Compact call controls retain their Button labels for VoiceOver and tooltips.
+///
+/// With `overlay` set, the button takes the look of controls over video
+/// (see `CallOverlay`) in that state, and `color` and `emphasized` are
+/// not used: the Mac's control row is one set of buttons in two places,
+/// the classic layout's row and the full-window call's bar.
public struct CallActionStyle: ButtonStyle {
public var color: Color
public var emphasized: Bool
+ public var overlay: CallOverlayButtonStyle.Tone?
@Environment(\.isEnabled) private var isEnabled
- public init(color: Color = CallsAppearance.accent, emphasized: Bool = false) {
+ public init(
+ color: Color = CallsAppearance.accent, emphasized: Bool = false,
+ overlay: CallOverlayButtonStyle.Tone? = nil
+ ) {
self.color = color
self.emphasized = emphasized
+ self.overlay = overlay
+ }
+
+ @ViewBuilder public func makeBody(configuration: Configuration) -> some View {
+ if let overlay {
+ OverlayButtonBody(
+ label: configuration.label, tone: overlay, size: 44, round: false,
+ pressed: configuration.isPressed, enabled: isEnabled)
+ } else {
+ configuration.label.labelStyle(.iconOnly).font(.title3)
+ .frame(width: 44, height: 44)
+ .foregroundStyle(emphasized ? .white : color)
+ .background(emphasized ? color : color.opacity(0.12), in: RoundedRectangle(cornerRadius: 14))
+ .opacity(!isEnabled ? 0.4 : configuration.isPressed ? 0.65 : 1)
+ .contentShape(RoundedRectangle(cornerRadius: 14))
+ }
+ }
+}
+
+// MARK: - Over the picture
+
+/// The look of everything the full-window call puts over its video — the
+/// control bar and its buttons, the title pill, the name labels and the
+/// notices — as one system. It is always dark, whatever the system
+/// appearance, because what is under it is always video, and video can be
+/// anything from a black room to a white wall.
+///
+/// Each surface is black at `scrim` over a blur. The blur only softens;
+/// the black does the work, so the contrast holds even where the blur is
+/// not drawn (an off-screen snapshot) and even over pure white, the worst
+/// case: white under 70 % black is #4D4D4D, so white on the bar is 8.5:1
+/// at worst and 21:1 at best (over black). The ratios below are measured
+/// against that worst case; `CallOverlayContrastTests` holds them.
+public enum CallOverlay {
+ /// The black laid over whatever the surface sits on.
+ public static let scrim: Double = 0.7
+ /// A resting button's fill: white, lightly. Over the worst-case bar it
+ /// is #696969, and a white symbol on it is 5.5:1.
+ public static let buttonFill = Color.white.opacity(0.16)
+ /// A hairline round a resting button and round the surfaces, so their
+ /// edge shows over black too, where the scrim alone would vanish.
+ public static let edge = Color.white.opacity(0.18)
+ /// A control that is on — a panel open, the screen being shared: a
+ /// near-white fill with a near-black symbol, 15:1 between them and 7.5:1
+ /// from the bar at its lightest.
+ public static let onFill = Color(white: 0.94)
+ public static let onSymbol = Color(white: 0.1)
+ /// Microphone or camera off: amber, with the slashed symbol in white
+ /// (5.7:1). A warning that others cannot hear or see you, and distinct
+ /// from Leave's red and from the white of a control that is on.
+ public static let offFill = Color(red: 0.65, green: 0.30, blue: 0.02)
+ /// Leave and End call: a red deep enough for white on it to read (5.0:1;
+ /// the system red would be 3.4:1).
+ public static let leaveFill = Color(red: 0.84, green: 0.16, blue: 0.14)
+ /// The unread count on Chat: white on violet, 6.0:1.
+ public static let badgeFill = Color(red: 0.49, green: 0.23, blue: 0.85)
+ /// Secondary text on a surface: the status line, a quiet notice. 6.0:1
+ /// over the worst-case surface.
+ public static let secondaryText = Color.white.opacity(0.78)
+ /// A notice that must be seen (you are sharing your screen): a light
+ /// amber, 4.9:1 over the worst-case surface.
+ public static let alertText = Color(red: 1.0, green: 0.72, blue: 0.35)
+}
+
+/// A surface over the picture: black at `CallOverlay.scrim` over a dark
+/// blur, with a hairline edge. Dark whatever the system appearance; only
+/// the background is made dark, so a sheet or popover the content opens
+/// keeps the person's own appearance.
+public struct CallOverlaySurface<S: InsettableShape>: ViewModifier {
+ let shape: S
+ public init(_ shape: S) { self.shape = shape }
+ public func body(content: Content) -> some View {
+ content
+ .background {
+ ZStack {
+ shape.fill(.ultraThinMaterial)
+ shape.fill(Color.black.opacity(CallOverlay.scrim))
+ }
+ .environment(\.colorScheme, .dark)
+ }
+ .overlay(shape.strokeBorder(CallOverlay.edge, lineWidth: 1))
+ }
+}
+
+extension View {
+ /// See `CallOverlaySurface`.
+ public func callOverlaySurface<S: InsettableShape>(_ shape: S) -> some View {
+ modifier(CallOverlaySurface(shape))
+ }
+}
+
+/// A button over the picture, in one of four states. The state is always
+/// the symbol as well as the fill: a muted microphone is slashed and amber,
+/// a control that is on is white with a dark symbol.
+public struct CallOverlayButtonStyle: ButtonStyle {
+ public enum Tone: Sendable {
+ /// Resting: a white symbol on a light fill.
+ case normal
+ /// On: a panel open, the screen being shared.
+ case on
+ /// Microphone or camera off.
+ case off
+ /// Leave, End call.
+ case leave
+ }
+
+ public var tone: Tone
+ public var size: CGFloat
+ public var round: Bool
+ @Environment(\.isEnabled) private var isEnabled
+
+ public init(_ tone: Tone = .normal, size: CGFloat = 44, round: Bool = false) {
+ self.tone = tone
+ self.size = size
+ self.round = round
}
public func makeBody(configuration: Configuration) -> some View {
- configuration.label.labelStyle(.iconOnly).font(.title3)
- .frame(width: 44, height: 44)
- .foregroundStyle(emphasized ? .white : color)
- .background(emphasized ? color : color.opacity(0.12), in: RoundedRectangle(cornerRadius: 14))
- .opacity(!isEnabled ? 0.4 : configuration.isPressed ? 0.65 : 1)
- .contentShape(RoundedRectangle(cornerRadius: 14))
+ OverlayButtonBody(
+ label: configuration.label, tone: tone, size: size, round: round,
+ pressed: configuration.isPressed, enabled: isEnabled)
+ }
+}
+
+private struct OverlayButtonBody<Label: View>: View {
+ let label: Label
+ let tone: CallOverlayButtonStyle.Tone
+ let size: CGFloat
+ let round: Bool
+ let pressed: Bool
+ let enabled: Bool
+
+ var body: some View {
+ let shape = RoundedRectangle(cornerRadius: round ? size / 2 : 14, style: .continuous)
+ label.labelStyle(.iconOnly)
+ .font(round ? .title2.weight(.semibold) : .title3.weight(.medium))
+ .frame(width: size, height: size)
+ .foregroundStyle(tone == .on ? CallOverlay.onSymbol : .white)
+ .background(fill, in: shape)
+ .overlay { if tone == .normal { shape.strokeBorder(CallOverlay.edge, lineWidth: 1) } }
+ .opacity(!enabled ? 0.45 : pressed ? 0.7 : 1)
+ .contentShape(shape)
+ }
+
+ private var fill: Color {
+ switch tone {
+ case .normal: CallOverlay.buttonFill
+ case .on: CallOverlay.onFill
+ case .off: CallOverlay.offFill
+ case .leave: CallOverlay.leaveFill
+ }
}
}
diff --git a/native/apple/Sources/CallsKit/CallChrome.swift b/native/apple/Sources/CallsKit/CallChrome.swift
index df456f5..86ede6f 100644
--- a/native/apple/Sources/CallsKit/CallChrome.swift
+++ b/native/apple/Sources/CallsKit/CallChrome.swift
@@ -60,17 +60,28 @@ public struct ControlsVisibility: Equatable, Sendable {
public static let alert = Holds(rawValue: 1 << 4)
}
- /// A Mac hides its controls 1.5 s after the pointer stops. The pointer
- /// is still there, and moving it a millimetre brings them back, so the
- /// delay only has to outlast the pause between a glance and a click.
- public static let pointerDelay: TimeInterval = 1.5
- /// A touch screen waits 2.5 s. There is no hover to say the person is
- /// still looking: a tap is the only sign, and after it the thumb still
- /// has to find its target and the eyes have to read the bar — at 1.5 s
- /// the bar would go mid-reach. Apple's own players wait about three;
- /// 2.5 keeps the picture uncluttered a little sooner without racing
- /// the thumb.
- public static let touchDelay: TimeInterval = 2.5
+ /// A Mac keeps its controls fully up for 3 s after the pointer last
+ /// moved. At 1.5 s (0.3.4) they went while the person was still reading
+ /// them; three is what QuickTime and FaceTime give, and moving the
+ /// pointer a millimetre still brings them straight back.
+ public static let pointerDelay: TimeInterval = 3
+ /// A touch screen waits 3 s after the last tap. There is no hover to say
+ /// the person is still looking: a tap is the only sign, and after it the
+ /// thumb still has to find its target and the eyes have to read the bar.
+ public static let touchDelay: TimeInterval = 3
+ /// When the pointer leaves the window the controls linger 1.5 s rather
+ /// than going at once: a pointer that overshoots the edge, or crosses
+ /// out and back on its way to the Dock, should not blink them. Leaving
+ /// only ever shortens the wait — controls due to go sooner still do.
+ public static let pointerExitDelay: TimeInterval = 1.5
+ /// How long the controls take to fade out: long enough to be seen going,
+ /// eased out so the change starts at once and settles.
+ public static let fadeOutDuration: TimeInterval = 0.35
+ /// How long they take to come back: quicker, since the person asked.
+ public static let fadeInDuration: TimeInterval = 0.2
+ /// With Reduce Motion: a short opacity change rather than none, so the
+ /// change is still noticed without anything easing across the screen.
+ public static let reducedMotionFadeDuration: TimeInterval = 0.1
public let input: Input
public private(set) var visible: Bool
@@ -98,13 +109,15 @@ public struct ControlsVisibility: Equatable, Sendable {
reveal(now: now)
}
- /// The pointer left the window: hide at once, unless something holds
- /// the controls up. Leaving the window is also leaving the bar.
+ /// The pointer left the window: hide `pointerExitDelay` later (or when
+ /// they were due already, if sooner), unless something holds the
+ /// controls up. Leaving the window is also leaving the bar.
public mutating func pointerExited(now: TimeInterval) {
pointerInside = false
holds.remove(.pointerOverControls)
- if held { return }
- hide()
+ guard visible, !held else { return }
+ let linger = now + Self.pointerExitDelay
+ hideAt = min(hideAt ?? linger, linger)
}
/// A tap on the picture (touch). It toggles: a tap shows hidden
@@ -154,10 +167,22 @@ public struct ControlsVisibility: Equatable, Sendable {
/// slide; with Reduce Motion a short cross-fade rather than none, so
/// the change is still noticed without anything moving.
public static func fadeDuration(showing: Bool, reduceMotion: Bool) -> TimeInterval {
- if reduceMotion { return 0.1 }
- return showing ? 0.2 : 0.35
+ if reduceMotion { return reducedMotionFadeDuration }
+ return showing ? fadeInDuration : fadeOutDuration
+ }
+
+ /// The fade itself: ease-out, so it starts at once and settles; with
+ /// Reduce Motion a short linear opacity change.
+ public static func fade(showing: Bool, reduceMotion: Bool) -> Animation {
+ let duration = fadeDuration(showing: showing, reduceMotion: reduceMotion)
+ return reduceMotion ? .linear(duration: duration) : .easeOut(duration: duration)
}
+ /// Whether a Mac call window's traffic lights show: with the controls,
+ /// so the picture is clean when they go, as in QuickTime — except in
+ /// full screen, where the system shows them with the menu bar.
+ public func showsWindowButtons(fullScreen: Bool) -> Bool { fullScreen || visible }
+
private mutating func reveal(now: TimeInterval) {
visible = true
hideAt = held ? nil : now + delay
@@ -186,6 +211,11 @@ public struct ControlsVisibility: Equatable, Sendable {
}
public var visible: Bool { pinned ?? state.visible }
+ /// See `ControlsVisibility.showsWindowButtons(fullScreen:)`; a fixture's
+ /// pin decides for the traffic lights as for the controls.
+ public func showsWindowButtons(fullScreen: Bool) -> Bool {
+ fullScreen || visible
+ }
public func pointerMoved() { update { $0.pointerMoved(now: Self.now) } }
public func pointerExited() { update { $0.pointerExited(now: Self.now) } }
diff --git a/native/apple/Sources/CallsKit/CallFixtures.swift b/native/apple/Sources/CallsKit/CallFixtures.swift
index 2bb5f7a..3754da1 100644
--- a/native/apple/Sources/CallsKit/CallFixtures.swift
+++ b/native/apple/Sources/CallsKit/CallFixtures.swift
@@ -1,4 +1,5 @@
import Foundation
+import SwiftUI
/// Described calls for the offline snapshots of the call views (Mac, from
/// CallsInterop) and the iPhone's UI test of its controls (a debug build
@@ -43,3 +44,77 @@ import Foundation
}
}
}
+
+/// What the snapshots put behind the call's overlay in place of video, to
+/// show that the controls read over anything: black, white, and a busy
+/// frame with light and dark and colour and fine detail in it.
+@_spi(Fixtures) public enum FixtureBackdrop: String, CaseIterable, Sendable {
+ case black, white, busy
+}
+
+/// Your own state, for the snapshots of the controls in each.
+@_spi(Fixtures) public enum FixtureSelf: String, CaseIterable, Sendable {
+ case normal, muted, cameraOff, sharing
+
+ @MainActor public func apply(to call: Call) {
+ call.showFixtureSelf(
+ muted: self == .muted, cameraOff: self == .cameraOff, sharingScreen: self == .sharing)
+ }
+}
+
+extension FixtureBackdrop {
+ /// The frame itself, filling whatever it is given.
+ @_spi(Fixtures) @MainActor @ViewBuilder public var view: some View {
+ switch self {
+ case .black: Color.black
+ case .white: Color.white
+ case .busy: BusyFrame()
+ }
+ }
+}
+
+/// A stand-in for a busy picture: a bright window and a dark room, a
+/// saturated poster, and stripes as fine as a patterned shirt, across the
+/// whole frame so the bar and the pills each land on some of everything.
+private struct BusyFrame: View {
+ var body: some View {
+ GeometryReader { geometry in
+ let size = geometry.size
+ ZStack {
+ LinearGradient(
+ colors: [Color(white: 0.95), Color(red: 0.55, green: 0.42, blue: 0.3), Color(white: 0.08)],
+ startPoint: .topLeading, endPoint: .bottomTrailing)
+ Rectangle().fill(Color(red: 0.98, green: 0.85, blue: 0.2))
+ .frame(width: size.width * 0.28, height: size.height * 0.5)
+ .position(x: size.width * 0.2, y: size.height * 0.62)
+ Rectangle().fill(Color(red: 0.1, green: 0.35, blue: 0.85))
+ .frame(width: size.width * 0.22, height: size.height * 0.7)
+ .position(x: size.width * 0.72, y: size.height * 0.55)
+ HStack(spacing: 6) {
+ ForEach(0..<60, id: \.self) { index in
+ Rectangle().fill(index.isMultiple(of: 2) ? Color.white : Color.black)
+ .frame(width: 6)
+ }
+ }
+ .frame(width: size.width * 0.45, height: size.height * 0.35)
+ .position(x: size.width * 0.45, y: size.height * 0.88)
+ Circle().fill(Color(red: 0.9, green: 0.2, blue: 0.3))
+ .frame(width: size.height * 0.3)
+ .position(x: size.width * 0.9, y: size.height * 0.12)
+ }
+ .clipped()
+ }
+ }
+}
+
+private struct FixtureBackdropKey: EnvironmentKey {
+ static let defaultValue: FixtureBackdrop? = nil
+}
+
+extension EnvironmentValues {
+ /// A fixture's frame in place of the call's stage; nil in a real call.
+ @_spi(Fixtures) public var fixtureBackdrop: FixtureBackdrop? {
+ get { self[FixtureBackdropKey.self] }
+ set { self[FixtureBackdropKey.self] = newValue }
+ }
+}
diff --git a/native/apple/Sources/CallsKit/MacCallView.swift b/native/apple/Sources/CallsKit/MacCallView.swift
index e3607bf..b8b326c 100644
--- a/native/apple/Sources/CallsKit/MacCallView.swift
+++ b/native/apple/Sources/CallsKit/MacCallView.swift
@@ -25,6 +25,7 @@
@FocusState var focusedControl: CallControlID?
@Environment(\.accessibilityReduceMotion) var reduceMotion
@Environment(\.accessibilityVoiceOverEnabled) var voiceOver
+ @Environment(\.fixtureBackdrop) var fixtureBackdrop
private let style: CallLayoutStyle?
/// - Parameters:
@@ -277,7 +278,7 @@
.buttonStyle(
CallActionStyle(
color: call.muted ? Color(red: 0.65, green: 0.30, blue: 0.02) : CallsAppearance.accent,
- emphasized: call.muted)
+ emphasized: call.muted, overlay: tone(fullWindow, call.muted ? .off : .normal))
)
.help(call.muted ? "Unmute microphone" : "Mute microphone")
.accessibilityIdentifier("mute")
@@ -289,7 +290,7 @@
.buttonStyle(
CallActionStyle(
color: call.cameraOff ? Color(red: 0.65, green: 0.30, blue: 0.02) : .blue,
- emphasized: call.cameraOff)
+ emphasized: call.cameraOff, overlay: tone(fullWindow, call.cameraOff ? .off : .normal))
)
.help(call.cameraOff ? "Turn camera on" : "Turn camera off")
.accessibilityIdentifier("camera")
@@ -304,7 +305,10 @@
Task { await call.shareScreen(MacScreenCapture()) }
}
}
- .buttonStyle(CallActionStyle(color: .orange, emphasized: call.sharingScreen))
+ .buttonStyle(
+ CallActionStyle(
+ color: .orange, emphasized: call.sharingScreen,
+ overlay: tone(fullWindow, call.sharingScreen ? .on : .normal)))
.help(call.sharingScreen ? "Stop sharing your screen" : "Share a window or your whole screen")
.disabled(call.state?.phase != "admitted")
.accessibilityIdentifier("shareScreen")
@@ -312,28 +316,36 @@
Button("Devices", systemImage: "slider.horizontal.3") {
withAnimation(panelAnimation) { showingDevices.toggle() }
}
- .buttonStyle(CallActionStyle(color: .teal, emphasized: showingDevices))
+ .buttonStyle(
+ CallActionStyle(
+ color: .teal, emphasized: showingDevices,
+ overlay: tone(fullWindow, showingDevices ? .on : .normal)))
.help("Choose the camera, microphone and speaker for this call")
.accessibilityIdentifier("devices")
.focused($focusedControl, equals: .devices)
if fullWindow {
layoutMenu.focused($focusedControl, equals: .layout)
}
- MeetingShareButton(call: call).buttonStyle(CallActionStyle(color: .indigo))
+ MeetingShareButton(call: call)
+ .buttonStyle(CallActionStyle(color: .indigo, overlay: tone(fullWindow, .normal)))
.help("Share meeting link")
.focused($focusedControl, equals: .link)
Spacer(minLength: fullWindow ? 24 : nil)
Button(chatTitle, systemImage: "bubble.left.and.bubble.right.fill") {
if chatWindow.isOpen { chatWindow.close() }
withAnimation(panelAnimation) { showingChat.toggle() }
- }.buttonStyle(CallActionStyle(color: .purple, emphasized: showingChat || chatWindow.isOpen))
+ }.buttonStyle(
+ CallActionStyle(
+ color: .purple, emphasized: showingChat || chatWindow.isOpen,
+ overlay: tone(fullWindow, showingChat || chatWindow.isOpen ? .on : .normal)))
.help(chatTitle)
.accessibilityIdentifier("openChat")
.focused($focusedControl, equals: .chat)
.overlay(alignment: .topTrailing) {
if unread > 0 {
Text("\(unread)").font(.caption.bold()).foregroundStyle(.white)
- .padding(4).background(.purple, in: Capsule()).offset(x: 5, y: -5)
+ .padding(4).background(fullWindow ? CallOverlay.badgeFill : .purple, in: Capsule())
+ .offset(x: 5, y: -5)
.accessibilityHidden(true)
}
}
@@ -344,7 +356,8 @@
}
}
Button("Leave meeting", systemImage: "phone.down.fill", role: .destructive, action: leave)
- .buttonStyle(CallActionStyle(color: .red, emphasized: true)).help("Leave meeting")
+ .buttonStyle(CallActionStyle(color: .red, emphasized: true, overlay: tone(fullWindow, .leave)))
+ .help("Leave meeting")
.accessibilityIdentifier("leave")
.focused($focusedControl, equals: .leave)
}.frame(maxWidth: .infinity)
@@ -357,7 +370,7 @@
Button(
"Video layout", systemImage: grid ? "square.grid.2x2" : "pip"
) { showingLayout.toggle() }
- .buttonStyle(CallActionStyle(color: .gray, emphasized: showingLayout))
+ .buttonStyle(CallActionStyle(color: .gray, emphasized: showingLayout, overlay: showingLayout ? .on : .normal))
.help("Video layout: Focus or Grid, and who is on the main stage")
.accessibilityIdentifier("videoLayout")
.popover(isPresented: $showingLayout, arrowEdge: .top) {
@@ -394,6 +407,14 @@
.buttonStyle(.borderless)
}
+ /// The full-window bar's look for a button in `state`; the classic
+ /// row keeps its own colours.
+ private func tone(_ fullWindow: Bool, _ state: CallOverlayButtonStyle.Tone)
+ -> CallOverlayButtonStyle.Tone?
+ {
+ fullWindow ? state : nil
+ }
+
var panelAnimation: Animation? {
reduceMotion ? .linear(duration: 0.1) : .easeOut(duration: 0.2)
}
diff --git a/native/apple/Sources/CallsKit/MacFullWindowCall.swift b/native/apple/Sources/CallsKit/MacFullWindowCall.swift
index 3e2a2cf..6f2755c 100644
--- a/native/apple/Sources/CallsKit/MacFullWindowCall.swift
+++ b/native/apple/Sources/CallsKit/MacFullWindowCall.swift
@@ -8,11 +8,13 @@
case mute, camera, share, devices, layout, link, chat, leave
}
- /// The full-window call: the picture edge to edge under the window's
- /// traffic lights, with the title, the controls and the panels over it.
- /// The controls come with the pointer and go 1.5 s after it stops, or
- /// as soon as it leaves the window (see ControlsVisibility for what
- /// keeps them up). Notices that matter stay whatever the controls do.
+ /// The full-window call: the picture edge to edge, to the very top of
+ /// the window, with the traffic lights, the title, the controls and the
+ /// panels over it. There is no title bar: a strip across the top drags
+ /// the window instead. The controls and the traffic lights come with the
+ /// pointer and go 3 s after it stops, or 1.5 s after it leaves the
+ /// window (see ControlsVisibility for what keeps them up). Notices that
+ /// matter stay whatever the controls do.
extension MacCallView {
/// Room kept clear at the top for the title bar's traffic lights.
static let titleBarHeight: CGFloat = 32
@@ -21,14 +23,28 @@
static let barReserve: CGFloat = 44 + 20 + 16
static let panelWidth: CGFloat = 320
static let tileBackdrop = Color(white: 0.13)
+ /// Where the traffic lights sit, from the window's top-left corner:
+ /// the pointer over them holds the controls (and them) up, and the
+ /// title keeps to the right of it.
+ static let windowButtonsArea = CGRect(x: 0, y: 0, width: 80, height: titleBarHeight)
var immersive: some View {
GeometryReader { geometry in
let size = geometry.size
ZStack {
Color.black
- fullWindowStage(in: size)
+ if let fixtureBackdrop {
+ fixtureBackdrop.view.accessibilityHidden(true)
+ } else {
+ fullWindowStage(in: size)
+ }
if !grid { selfView(in: size) }
+ topShade
+ // Above the picture, below the title and everything else: what
+ // is left of the strip once they have their clicks drags.
+ WindowDragStrip()
+ .frame(height: Self.titleBarHeight)
+ .frame(maxHeight: .infinity, alignment: .top)
topOverlay
panels(in: size)
bar
@@ -37,12 +53,18 @@
.frame(width: size.width, height: size.height)
}
.ignoresSafeArea()
- .background(FullWindowTitleBar())
+ .background(
+ CallWindowChrome(
+ buttonsVisible: chrome.visible,
+ fade: ControlsVisibility.fadeDuration(showing: chrome.visible, reduceMotion: reduceMotion)))
.background(
CallWindowActivity(
moved: { point in
chrome.pointerMoved()
- chrome.set(.pointerOverControls, chrome.visible && barFrame.contains(point))
+ chrome.set(
+ .pointerOverControls,
+ chrome.visible
+ && (barFrame.contains(point) || Self.windowButtonsArea.contains(point)))
},
exited: { chrome.pointerExited() },
activity: { chrome.activity() },
@@ -68,9 +90,20 @@
}
private var fade: Animation {
- .easeInOut(
- duration: ControlsVisibility.fadeDuration(
- showing: chrome.visible, reduceMotion: reduceMotion))
+ ControlsVisibility.fade(showing: chrome.visible, reduceMotion: reduceMotion)
+ }
+
+ /// A shade down from the top edge while the controls show, so the
+ /// traffic lights and the title read over a bright picture.
+ private var topShade: some View {
+ LinearGradient(
+ colors: [.black.opacity(0.5), .black.opacity(0)], startPoint: .top, endPoint: .bottom
+ )
+ .frame(height: 96)
+ .frame(maxHeight: .infinity, alignment: .top)
+ .opacity(chrome.visible ? 1 : 0)
+ .allowsHitTesting(false)
+ .accessibilityHidden(true)
}
private func updateHolds() {
@@ -239,25 +272,33 @@
}
private var topOverlay: some View {
- ZStack(alignment: .top) {
+ // A column, so a notice is never under the title however tall the
+ // title grows; the title keeps its place while faded, so the notices
+ // do not jump as the controls come and go.
+ VStack(spacing: 8) {
// The title and how the call is going, beside the traffic lights.
// They go with the controls; the notices below them do not.
HStack {
VStack(alignment: .leading, spacing: 2) {
- Text(call.title).font(.headline).lineLimit(1)
- Text(call.status).font(.callout).foregroundStyle(.secondary).lineLimit(1)
+ Text(call.title).font(.headline).foregroundStyle(.white).lineLimit(1)
+ Text(call.status).font(.callout).foregroundStyle(CallOverlay.secondaryText)
+ .lineLimit(1)
.accessibilityAddTraits(.updatesFrequently)
ForEach(notices.filter { !$0.important }) { notice in
- Text(notice.text).font(.callout).foregroundStyle(.secondary)
+ Text(notice.text).font(.callout).foregroundStyle(CallOverlay.secondaryText)
.fixedSize(horizontal: false, vertical: true)
}
}
.padding(.horizontal, 12).padding(.vertical, 7)
- .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
+ .callOverlaySurface(RoundedRectangle(cornerRadius: 12, style: .continuous))
+ .accessibilityElement(children: .contain)
+ .accessibilityIdentifier("callTitle")
.frame(maxWidth: 420, alignment: .leading)
Spacer(minLength: 0)
}
- .padding(.leading, 84).padding(.top, 8).padding(.trailing, 12)
+ // Right of the traffic lights, level with them.
+ .padding(.leading, Self.windowButtonsArea.maxX + 4).padding(.top, 6)
+ .padding(.trailing, 12)
.opacity(chrome.visible ? 1 : 0)
.allowsHitTesting(chrome.visible)
.accessibilityHidden(!chrome.visible)
@@ -275,7 +316,6 @@
.modifier(NoticeSurface())
}
}
- .padding(.top, Self.titleBarHeight + 36)
.padding(.horizontal, 90)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
@@ -286,9 +326,13 @@
switch notice.kind {
case .sharing:
Label(notice.text, systemImage: "rectangle.on.rectangle")
- .foregroundStyle(.orange).bold()
+ .foregroundStyle(CallOverlay.alertText).bold()
case .reconnecting, .waitingToJoin:
- ProgressView().controlSize(.small)
+ // Drawn rather than AppKit's spinner, which stays dark-on-dark
+ // on this surface in a light-appearance app.
+ Image(systemName: "arrow.triangle.2.circlepath")
+ .symbolEffect(.pulse, isActive: !reduceMotion)
+ .accessibilityHidden(true)
Text(notice.text)
case .full:
Label(notice.text, systemImage: "person.3.fill")
@@ -384,9 +428,8 @@
private var bar: some View {
controls(fullWindow: true)
.padding(10)
- .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 20))
- .overlay(RoundedRectangle(cornerRadius: 20).strokeBorder(.white.opacity(0.08)))
- .shadow(color: .black.opacity(0.25), radius: 12, y: 4)
+ .callOverlaySurface(RoundedRectangle(cornerRadius: 20, style: .continuous))
+ .shadow(color: .black.opacity(0.3), radius: 12, y: 4)
.frame(maxWidth: 760)
.background(
GeometryReader { bar in
@@ -416,50 +459,186 @@
}
}
- /// A notice over the picture: a small material capsule.
+ /// A notice over the picture: a capsule of the overlay's dark surface,
+ /// with white text and dark-appearance controls, whatever the system's.
struct NoticeSurface: ViewModifier {
func body(content: Content) -> some View {
- content.font(.callout)
+ content.font(.callout).foregroundStyle(.white)
+ .environment(\.colorScheme, .dark)
.padding(.horizontal, 14).padding(.vertical, 8)
- .background(.regularMaterial, in: Capsule())
- .shadow(color: .black.opacity(0.2), radius: 8, y: 2)
+ .callOverlaySurface(Capsule())
+ .shadow(color: .black.opacity(0.25), radius: 8, y: 2)
}
}
- /// Takes the call window's title bar out of the picture's way while the
- /// full-window call is showing — transparent, no title, content under
- /// it — and puts it back after. The traffic lights stay.
- struct FullWindowTitleBar: NSViewRepresentable {
- func makeNSView(context: Context) -> TitleBarView { TitleBarView() }
- func updateNSView(_ view: TitleBarView, context: Context) {}
+ /// The call window's own chrome while the full-window call shows: no
+ /// title bar — transparent, no title, the content under it to the very
+ /// top — and the traffic lights over the picture, fading with the
+ /// controls. Everything is put back when the call leaves the window or
+ /// changes to Classic, and none of it touches full screen or a panel.
+ ///
+ /// SwiftUI keeps its own idea of the window's title bar and can put it
+ /// back while the call is up (0.3.4 showed an opaque bar over the call);
+ /// so while this view is in the window it watches those properties and
+ /// sets them again whenever something else changes them.
+ struct CallWindowChrome: NSViewRepresentable {
+ var buttonsVisible: Bool
+ var fade: TimeInterval
+
+ func makeNSView(context: Context) -> ChromeView { ChromeView() }
+ func updateNSView(_ view: ChromeView, context: Context) {
+ view.setButtons(visible: buttonsVisible, fade: fade)
+ }
+ static func dismantleNSView(_ view: ChromeView, coordinator: ()) { view.release() }
- final class TitleBarView: NSView {
+ final class ChromeView: NSView {
private weak var styled: NSWindow?
- private var saved: (transparent: Bool, title: NSWindow.TitleVisibility, full: Bool)?
+ private var saved: (transparent: Bool, title: NSWindow.TitleVisibility, full: Bool, separator: NSTitlebarSeparatorStyle)?
+ private var watches: [NSKeyValueObservation] = []
+ private var observers: [NSObjectProtocol] = []
+ private var buttonsVisible = true
- override func viewWillMove(toWindow next: NSWindow?) {
- super.viewWillMove(toWindow: next)
- restore()
- }
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
- guard let window, !(window is NSPanel) else { return }
+ guard let window, !(window is NSPanel) else {
+ release()
+ return
+ }
+ guard styled !== window else { return }
+ release()
+ styled = window
saved = (
window.titlebarAppearsTransparent, window.titleVisibility,
- window.styleMask.contains(.fullSizeContentView)
+ window.styleMask.contains(.fullSizeContentView), window.titlebarSeparatorStyle
)
- styled = window
- window.titlebarAppearsTransparent = true
- window.titleVisibility = .hidden
- window.styleMask.insert(.fullSizeContentView)
+ apply(to: window)
+ showButtons(animated: false)
+ watches = [
+ window.observe(\.titlebarAppearsTransparent) { [weak self] window, _ in
+ MainActor.assumeIsolated { self?.apply(to: window) }
+ },
+ window.observe(\.styleMask) { [weak self] window, _ in
+ MainActor.assumeIsolated { self?.apply(to: window) }
+ },
+ ]
+ let center = NotificationCenter.default
+ observers = [
+ NSWindow.didEnterFullScreenNotification, NSWindow.didExitFullScreenNotification,
+ NSWindow.didBecomeKeyNotification, NSWindow.didResizeNotification,
+ ].map { name in
+ center.addObserver(forName: name, object: window, queue: .main) { [weak self] _ in
+ MainActor.assumeIsolated { self?.showButtons(animated: false) }
+ }
+ }
+ }
+
+ /// Hidden title bar, content to the top. Only what differs is set,
+ /// so the watches that call this do not call it again.
+ private func apply(to window: NSWindow) {
+ guard window === styled else { return }
+ if !window.titlebarAppearsTransparent { window.titlebarAppearsTransparent = true }
+ if window.titleVisibility != .hidden { window.titleVisibility = .hidden }
+ if !window.styleMask.contains(.fullSizeContentView) {
+ window.styleMask.insert(.fullSizeContentView)
+ }
+ if window.titlebarSeparatorStyle != .none { window.titlebarSeparatorStyle = .none }
+ }
+
+ func setButtons(visible: Bool, fade: TimeInterval) {
+ guard visible != buttonsVisible else { return }
+ buttonsVisible = visible
+ NSAnimationContext.runAnimationGroup { context in
+ context.duration = fade
+ context.timingFunction = CAMediaTimingFunction(name: .easeOut)
+ showButtons(animated: true)
+ }
+ }
+
+ /// The traffic lights, shown or hidden with the controls; always
+ /// shown in full screen, where the system reveals them with the menu
+ /// bar, and when the call lets the window go.
+ private func showButtons(animated: Bool) {
+ guard let window = styled else { return }
+ let fullScreen = window.styleMask.contains(.fullScreen)
+ let alpha: CGFloat = buttonsVisible || fullScreen ? 1 : 0
+ for kind in [NSWindow.ButtonType.closeButton, .miniaturizeButton, .zoomButton] {
+ guard let button = window.standardWindowButton(kind) else { continue }
+ if animated { button.animator().alphaValue = alpha } else { button.alphaValue = alpha }
+ }
}
- private func restore() {
+
+ /// Puts the window back as it was found.
+ func release() {
+ watches.forEach { $0.invalidate() }
+ watches = []
+ observers.forEach(NotificationCenter.default.removeObserver)
+ observers = []
guard let window = styled, let saved else { return }
+ styled = nil
+ self.saved = nil
+ for kind in [NSWindow.ButtonType.closeButton, .miniaturizeButton, .zoomButton] {
+ window.standardWindowButton(kind)?.alphaValue = 1
+ }
window.titlebarAppearsTransparent = saved.transparent
window.titleVisibility = saved.title
+ window.titlebarSeparatorStyle = saved.separator
if !saved.full { window.styleMask.remove(.fullSizeContentView) }
- styled = nil
- self.saved = nil
+ }
+
+ override func viewWillMove(toWindow next: NSWindow?) {
+ super.viewWillMove(toWindow: next)
+ if next !== styled { release() }
+ }
+
+ override func hitTest(_ point: NSPoint) -> NSView? { nil }
+ }
+ }
+
+ /// The strip across the top of the full-window call that stands in for
+ /// the title bar it replaces: dragging it moves the window, and a double
+ /// click does what the person's "double-click a window's title bar"
+ /// setting says (zoom, minimise, or nothing).
+ struct WindowDragStrip: NSViewRepresentable {
+ func makeNSView(context: Context) -> StripView { StripView() }
+ func updateNSView(_ view: StripView, context: Context) {}
+
+ final class StripView: NSView {
+ override var mouseDownCanMoveWindow: Bool { true }
+ override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
+
+ override func mouseDown(with event: NSEvent) {
+ guard let window else { return }
+ if event.clickCount == 2 {
+ switch TitleBarDoubleClick.current() {
+ case .zoom: window.zoom(nil)
+ case .minimize: window.miniaturize(nil)
+ case .none: break
+ }
+ } else {
+ window.performDrag(with: event)
+ }
+ }
+ }
+ }
+
+ /// What a double click on a title bar does, from System Settings ›
+ /// Desktop & Dock: "Double-click a window's title bar to…".
+ enum TitleBarDoubleClick: Equatable {
+ case zoom, minimize, none
+
+ static func current(_ defaults: UserDefaults = .standard) -> TitleBarDoubleClick {
+ from(action: defaults.string(forKey: "AppleActionOnDoubleClick"),
+ miniaturize: defaults.object(forKey: "AppleMiniaturizeOnDoubleClick") as? Bool)
+ }
+
+ /// "Maximize" (zoom) is the default, and "Fill" zooms too; the older
+ /// boolean says minimise when the newer key is absent.
+ static func from(action: String?, miniaturize: Bool?) -> TitleBarDoubleClick {
+ switch action {
+ case "Minimize": return .minimize
+ case "None": return .none
+ case nil: return miniaturize == true ? .minimize : .zoom
+ default: return .zoom
}
}
}
diff --git a/native/apple/Sources/CallsKit/MutedBadge.swift b/native/apple/Sources/CallsKit/MutedBadge.swift
index 96ee2db..498ef98 100644
--- a/native/apple/Sources/CallsKit/MutedBadge.swift
+++ b/native/apple/Sources/CallsKit/MutedBadge.swift
@@ -41,14 +41,16 @@ public struct TileNameLabel: View {
Label("Muted", systemImage: "mic.slash.fill").labelStyle(.titleAndIcon)
.padding(.leading, 5)
.overlay(alignment: .leading) {
- Rectangle().fill(.white.opacity(0.4)).frame(width: 1, height: 10)
+ Rectangle().fill(.white.opacity(0.5)).frame(width: 1, height: 10)
}
}
}
.font(.caption.weight(.semibold)).foregroundStyle(.white)
.fixedSize()
.padding(.horizontal, 7).padding(.vertical, 3)
- .background(.black.opacity(0.55), in: Capsule())
+ // The overlay's dark surface, as the controls have: white on it is
+ // 8.5:1 even over a white wall.
+ .callOverlaySurface(Capsule())
.accessibilityHidden(true)
}
}
diff --git a/native/apple/Sources/CallsMac/CallsMac.swift b/native/apple/Sources/CallsMac/CallsMac.swift
index 3dd13b9..cb85552 100644
--- a/native/apple/Sources/CallsMac/CallsMac.swift
+++ b/native/apple/Sources/CallsMac/CallsMac.swift
@@ -3,7 +3,7 @@ import RastrilloMacUpdates
#endif
import AppKit
@preconcurrency import CallsBridge
-import CallsKit
+@_spi(Fixtures) import CallsKit
import SwiftUI
import LiveKitWebRTC
@@ -134,7 +134,9 @@ struct ContentView: View {
let updater: NativeUpdater
var body: some View {
Group {
- if model.joining {
+ if let fixture = MacCallFixtureView.requested {
+ MacCallFixtureView(fixture: fixture)
+ } else if model.joining {
// The layout pads itself: the classic view sits in a margin, the
// full-window one goes to the window's edges.
MacCallView(call: model.call, leave: model.leave, rejoin: model.join)
@@ -147,3 +149,65 @@ struct ContentView: View {
}.tint(CallsAppearance.accent)
}
}
+
+/// The full-window call with no server behind it, in the real window, to
+/// see the title bar, the traffic lights and the drag strip for real.
+/// Debug builds only: `-CallsCallFixture focus` (or grid, screen,
+/// notices), optionally `-CallsFixtureBackdrop black|white|busy`,
+/// `-CallsFixtureSelf muted|cameraOff|sharing` and `-CallsFixtureControls
+/// shown|hidden`; without that last, the pointer decides as in a call.
+struct MacCallFixtureView: View {
+ static var requested: CallFixture? {
+ #if DEBUG
+ UserDefaults.standard.string(forKey: "CallsCallFixture").flatMap(CallFixture.init(rawValue:))
+ #else
+ nil
+ #endif
+ }
+
+ let fixture: CallFixture
+ @StateObject private var call = Call(playAudio: false)
+ /// `-CallsFixtureJoinAfter <s>` starts on a stand-in for the lobby and
+ /// joins after that long; `-CallsFixtureLeaveAfter <s>` goes back to it:
+ /// the window's title bar has to come and go with the call.
+ @State private var inCall = UserDefaults.standard.object(forKey: "CallsFixtureJoinAfter") == nil
+
+ var body: some View {
+ Group {
+ if inCall {
+ callView
+ } else {
+ Text("Lobby stand-in").frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ }
+ .task {
+ let defaults = UserDefaults.standard
+ if defaults.object(forKey: "CallsFixtureJoinAfter") != nil {
+ try? await Task.sleep(for: .seconds(defaults.double(forKey: "CallsFixtureJoinAfter")))
+ inCall = true
+ }
+ if defaults.object(forKey: "CallsFixtureLeaveAfter") != nil {
+ try? await Task.sleep(for: .seconds(defaults.double(forKey: "CallsFixtureLeaveAfter")))
+ inCall = false
+ }
+ }
+ }
+
+ private var callView: some View {
+ let defaults = UserDefaults.standard
+ return MacCallView(
+ call: call, grid: fixture == .grid,
+ controlsShown: defaults.string(forKey: "CallsFixtureControls").map { $0 == "shown" },
+ leave: {}, rejoin: {}
+ )
+ .environment(
+ \.fixtureBackdrop,
+ defaults.string(forKey: "CallsFixtureBackdrop").flatMap(FixtureBackdrop.init(rawValue:)))
+ .onAppear {
+ fixture.apply(to: call)
+ defaults.string(forKey: "CallsFixtureSelf").flatMap(FixtureSelf.init(rawValue:))?
+ .apply(to: call)
+ }
+ }
+}
+
diff --git a/native/apple/Tests/CallsKitTests/CallChromeTests.swift b/native/apple/Tests/CallsKitTests/CallChromeTests.swift
index 84bdf06..3d453a6 100644
--- a/native/apple/Tests/CallsKitTests/CallChromeTests.swift
+++ b/native/apple/Tests/CallsKitTests/CallChromeTests.swift
@@ -12,49 +12,81 @@ final class ControlsVisibilityTests: XCTestCase {
func testStartsVisibleAndHidesAfterTheDelay() {
var pointer = V(input: .pointer, now: 0)
XCTAssertTrue(pointer.visible)
- XCTAssertEqual(pointer.hideAt, 1.5)
- pointer.tick(now: 1.4)
+ XCTAssertEqual(pointer.hideAt, 3)
+ pointer.tick(now: 2.9)
XCTAssertTrue(pointer.visible, "not before the delay")
- pointer.tick(now: 1.5)
+ pointer.tick(now: 3)
XCTAssertFalse(pointer.visible)
XCTAssertNil(pointer.hideAt)
var touch = V(input: .touch, now: 10)
- XCTAssertEqual(touch.hideAt, 12.5, "touch waits longer")
- touch.tick(now: 12.4)
+ XCTAssertEqual(touch.hideAt, 13)
+ touch.tick(now: 12.9)
XCTAssertTrue(touch.visible)
- touch.tick(now: 12.5)
+ touch.tick(now: 13)
XCTAssertFalse(touch.visible)
}
- func testTheDelaysAreOneAndAHalfAndTwoAndAHalfSeconds() {
- XCTAssertEqual(V.pointerDelay, 1.5)
- XCTAssertEqual(V.touchDelay, 2.5)
+ /// Paul, on 0.3.4: "it should hang around for 2–3 seconds before
+ /// fading away".
+ func testTheTimings() {
+ XCTAssertEqual(V.pointerDelay, 3)
+ XCTAssertEqual(V.touchDelay, 3)
+ XCTAssertEqual(V.pointerExitDelay, 1.5)
+ XCTAssertEqual(V.fadeOutDuration, 0.35)
+ XCTAssertEqual(V.fadeDuration(showing: false, reduceMotion: false), 0.35)
+ XCTAssertEqual(V.fadeDuration(showing: true, reduceMotion: false), V.fadeInDuration)
}
func testPointerMovementShowsAndRestartsTheWait() {
var state = V(input: .pointer, now: 0)
- state.tick(now: 2)
+ state.tick(now: 3)
XCTAssertFalse(state.visible)
- state.pointerMoved(now: 3)
- XCTAssertTrue(state.visible)
- XCTAssertEqual(state.hideAt, 4.5)
state.pointerMoved(now: 4)
- state.tick(now: 4.5)
- XCTAssertTrue(state.visible, "movement at 4 pushed the hide to 5.5")
- state.tick(now: 5.5)
+ XCTAssertTrue(state.visible)
+ XCTAssertEqual(state.hideAt, 7)
+ state.pointerMoved(now: 5)
+ state.tick(now: 7)
+ XCTAssertTrue(state.visible, "movement at 5 pushed the hide to 8")
+ state.tick(now: 8)
XCTAssertFalse(state.visible)
}
- func testLeavingTheWindowHidesAtOnce() {
+ func testLeavingTheWindowLingersThenHides() {
var state = V(input: .pointer, now: 0)
state.pointerMoved(now: 0.2)
state.pointerExited(now: 0.3)
- XCTAssertFalse(state.visible, "no waiting out the delay")
+ XCTAssertTrue(state.visible, "not at once")
+ XCTAssertEqual(state.hideAt, 1.8, "1.5 s after leaving, sooner than the 3 s wait")
XCTAssertFalse(state.pointerInside)
+ state.tick(now: 1.7)
+ XCTAssertTrue(state.visible)
+ state.tick(now: 1.8)
+ XCTAssertFalse(state.visible)
XCTAssertFalse(state.hidesPointer, "no pointer to hide outside the window")
}
+ func testLeavingTheWindowNeverLengthensTheWait() {
+ var state = V(input: .pointer, now: 0)
+ state.pointerMoved(now: 0)
+ state.pointerExited(now: 2.5)
+ XCTAssertEqual(state.hideAt, 3, "already due at 3")
+ state.tick(now: 3)
+ XCTAssertFalse(state.visible)
+ state.pointerExited(now: 4)
+ XCTAssertFalse(state.visible, "leaving does not bring hidden controls back")
+ XCTAssertNil(state.hideAt)
+ }
+
+ func testComingBackBeforeTheLingerEndsKeepsThem() {
+ var state = V(input: .pointer, now: 0)
+ state.pointerExited(now: 1)
+ state.pointerMoved(now: 2)
+ state.tick(now: 2.5)
+ XCTAssertTrue(state.visible)
+ XCTAssertEqual(state.hideAt, 5)
+ }
+
func testLeavingTheWindowDoesNotHideHeldControls() {
var state = V(input: .pointer, now: 0)
state.set(.menuOpen, true, now: 0.1)
@@ -62,8 +94,8 @@ final class ControlsVisibilityTests: XCTestCase {
XCTAssertTrue(state.visible, "a menu is open")
state.set(.menuOpen, false, now: 3)
XCTAssertTrue(state.visible, "letting go starts the wait rather than hiding at once")
- XCTAssertEqual(state.hideAt, 4.5)
- state.tick(now: 4.5)
+ XCTAssertEqual(state.hideAt, 6)
+ state.tick(now: 6)
XCTAssertFalse(state.visible)
}
@@ -72,6 +104,8 @@ final class ControlsVisibilityTests: XCTestCase {
state.set(.pointerOverControls, true, now: 0.1)
state.pointerExited(now: 0.2)
XCTAssertFalse(state.holds.contains(.pointerOverControls))
+ XCTAssertEqual(state.hideAt, 1.7, "the bar no longer holds them; they linger, then go")
+ state.tick(now: 1.7)
XCTAssertFalse(state.visible)
}
@@ -113,14 +147,14 @@ final class ControlsVisibilityTests: XCTestCase {
state.tick(now: 100)
XCTAssertTrue(state.visible, "the alert still holds")
state.set(.alert, false, now: 100)
- state.tick(now: 101.5)
+ state.tick(now: 103)
XCTAssertFalse(state.visible)
}
func testReleasingAHoldThatWasNotHeldChangesNothing() {
var state = V(input: .pointer, now: 0)
- state.tick(now: 2)
- state.set(.menuOpen, false, now: 3)
+ state.tick(now: 3)
+ state.set(.menuOpen, false, now: 4)
XCTAssertFalse(state.visible)
XCTAssertNil(state.hideAt)
}
@@ -135,6 +169,7 @@ final class ControlsVisibilityTests: XCTestCase {
var pointer = V(input: .pointer, now: 0)
pointer.set(.voiceOver, true, now: 0)
pointer.pointerExited(now: 1)
+ pointer.tick(now: 1000)
XCTAssertTrue(pointer.visible, "nor the pointer leaving")
}
@@ -144,8 +179,10 @@ final class ControlsVisibilityTests: XCTestCase {
XCTAssertFalse(state.visible, "a tap on showing controls hides them")
state.tapped(now: 1)
XCTAssertTrue(state.visible)
- XCTAssertEqual(state.hideAt, 3.5)
- state.tick(now: 3.5)
+ XCTAssertEqual(state.hideAt, 4)
+ state.tick(now: 3.9)
+ XCTAssertTrue(state.visible)
+ state.tick(now: 4)
XCTAssertFalse(state.visible)
}
@@ -159,19 +196,19 @@ final class ControlsVisibilityTests: XCTestCase {
func testActivityShowsAndRestarts() {
var state = V(input: .pointer, now: 0)
- state.tick(now: 2)
- state.activity(now: 3) // Tab, say
+ state.tick(now: 3)
+ state.activity(now: 4) // Tab, say
XCTAssertTrue(state.visible)
- XCTAssertEqual(state.hideAt, 4.5)
+ XCTAssertEqual(state.hideAt, 7)
}
func testThePointerHidesWithTheControlsOnlyOverTheCall() {
var state = V(input: .pointer, now: 0)
state.pointerMoved(now: 0)
XCTAssertFalse(state.hidesPointer, "controls showing")
- state.tick(now: 1.5)
+ state.tick(now: 3)
XCTAssertTrue(state.hidesPointer)
- state.pointerMoved(now: 2)
+ state.pointerMoved(now: 4)
XCTAssertFalse(state.hidesPointer, "movement brings it back")
var touch = V(input: .touch, now: 0)
touch.tick(now: 5)
@@ -190,12 +227,14 @@ final class ControlsVisibilityTests: XCTestCase {
@MainActor func testTheModelHidesOnItsOwnClock() async throws {
let model = ControlsVisibilityModel(input: .pointer)
XCTAssertTrue(model.visible)
- try await Task.sleep(for: .seconds(1.8))
+ try await Task.sleep(for: .seconds(2))
+ XCTAssertTrue(model.visible, "still up two seconds in")
+ try await Task.sleep(for: .seconds(1.3))
XCTAssertFalse(model.visible, "hidden by its own timer")
model.pointerMoved()
XCTAssertTrue(model.visible)
model.set(.menuOpen, true)
- try await Task.sleep(for: .seconds(1.8))
+ try await Task.sleep(for: .seconds(3.3))
XCTAssertTrue(model.visible, "held")
}
@@ -207,6 +246,92 @@ final class ControlsVisibilityTests: XCTestCase {
shown.pointerExited()
XCTAssertTrue(shown.visible)
}
+
+ /// The traffic lights go and come back with the controls, and are left
+ /// alone in full screen.
+ func testTheWindowButtonsFollowTheControls() {
+ var state = V(input: .pointer, now: 0)
+ XCTAssertTrue(state.showsWindowButtons(fullScreen: false))
+ state.tick(now: 3)
+ XCTAssertFalse(state.showsWindowButtons(fullScreen: false), "gone with the controls")
+ XCTAssertTrue(state.showsWindowButtons(fullScreen: true), "full screen's are the system's")
+ state.pointerMoved(now: 4)
+ XCTAssertTrue(state.showsWindowButtons(fullScreen: false), "back as soon as the pointer moves")
+ state.set(.menuOpen, true, now: 4)
+ state.tick(now: 100)
+ XCTAssertTrue(state.showsWindowButtons(fullScreen: false), "held up with the controls")
+ }
+
+ #if os(macOS)
+ /// The drag strip's double click follows System Settings.
+ func testTitleBarDoubleClickFollowsTheSetting() {
+ XCTAssertEqual(TitleBarDoubleClick.from(action: nil, miniaturize: nil), .zoom, "the default")
+ XCTAssertEqual(TitleBarDoubleClick.from(action: "Maximize", miniaturize: nil), .zoom)
+ XCTAssertEqual(TitleBarDoubleClick.from(action: "Fill", miniaturize: nil), .zoom)
+ XCTAssertEqual(TitleBarDoubleClick.from(action: "Minimize", miniaturize: nil), .minimize)
+ XCTAssertEqual(TitleBarDoubleClick.from(action: "None", miniaturize: true), .none)
+ XCTAssertEqual(TitleBarDoubleClick.from(action: nil, miniaturize: true), .minimize)
+ }
+ #endif
+
+ @MainActor func testAPinnedModelPinsTheWindowButtons() {
+ XCTAssertFalse(
+ ControlsVisibilityModel(input: .pointer, pinned: false).showsWindowButtons(fullScreen: false))
+ XCTAssertTrue(
+ ControlsVisibilityModel(input: .pointer, pinned: true).showsWindowButtons(fullScreen: false))
+ }
+}
+
+/// WCAG 2.2 §1.4.11: the overlay's symbols and text against what they sit
+/// on, at 3:1 or better, over the worst video there is for each — pure
+/// white for anything light on the dark surfaces — as well as over black
+/// and mid-grey.
+final class CallOverlayContrastTests: XCTestCase {
+ typealias RGB = (r: Double, g: Double, b: Double)
+
+ /// Gamma-encoded sRGB components, which is what the compositor blends
+ /// and what WCAG's formula starts from.
+ private func rgb(_ color: Color) -> (RGB, Double) {
+ let c = color.resolve(in: EnvironmentValues())
+ return ((Double(c.red), Double(c.green), Double(c.blue)), Double(c.opacity))
+ }
+ private func over(_ top: Color, _ bottom: RGB) -> RGB {
+ let (t, a) = rgb(top)
+ return (t.r * a + bottom.r * (1 - a), t.g * a + bottom.g * (1 - a), t.b * a + bottom.b * (1 - a))
+ }
+ private func luminance(_ c: RGB) -> Double {
+ func linear(_ v: Double) -> Double { v <= 0.04045 ? v / 12.92 : pow((v + 0.055) / 1.055, 2.4) }
+ return 0.2126 * linear(c.r) + 0.7152 * linear(c.g) + 0.0722 * linear(c.b)
+ }
+ private func ratio(_ a: RGB, _ b: RGB) -> Double {
+ let (x, y) = (luminance(a), luminance(b))
+ return (max(x, y) + 0.05) / (min(x, y) + 0.05)
+ }
+
+ func testEverythingOnTheOverlayIsAtLeastThreeToOne() {
+ // The formula itself, against WebAIM's figures.
+ XCTAssertEqual(ratio((1, 1, 1), (0, 0, 0)), 21, accuracy: 0.01)
+ XCTAssertEqual(ratio((1, 1, 1), (0x77 / 255.0, 0x77 / 255.0, 0x77 / 255.0)), 4.48, accuracy: 0.01)
+ let white: RGB = (1, 1, 1)
+ for (name, video) in [("white", white), ("black", (0, 0, 0)), ("grey", (0.2, 0.2, 0.2))] as [(String, RGB)] {
+ let surface = over(Color.black.opacity(CallOverlay.scrim), video)
+ let resting = over(CallOverlay.buttonFill, surface)
+ let checks: [(String, Double, Double)] = [
+ ("white on the surface", ratio(white, surface), 7),
+ ("white symbol on a resting button", ratio(white, resting), 4.5),
+ ("secondary text", ratio(over(CallOverlay.secondaryText, surface), surface), 4.5),
+ ("alert text", ratio(rgb(CallOverlay.alertText).0, surface), 4.5),
+ ("an on button against the bar", ratio(rgb(CallOverlay.onFill).0, surface), 3),
+ ("an on button's symbol", ratio(rgb(CallOverlay.onSymbol).0, rgb(CallOverlay.onFill).0), 4.5),
+ ("white on amber (off)", ratio(white, rgb(CallOverlay.offFill).0), 4.5),
+ ("white on Leave's red", ratio(white, rgb(CallOverlay.leaveFill).0), 4.5),
+ ("the unread badge", ratio(white, rgb(CallOverlay.badgeFill).0), 4.5),
+ ]
+ for (what, measured, least) in checks {
+ XCTAssertGreaterThanOrEqual(measured, least, "\(what) over \(name) video: \(measured)")
+ }
+ }
+ }
}
final class PiPLayoutTests: XCTestCase {
diff --git a/native/ios/Sources/CallsPhone.swift b/native/ios/Sources/CallsPhone.swift
index 0a8521c..4d50883 100644
--- a/native/ios/Sources/CallsPhone.swift
+++ b/native/ios/Sources/CallsPhone.swift
@@ -1,4 +1,4 @@
-import CallsKit
+@_spi(Fixtures) import CallsKit
import SwiftUI
import LiveKitWebRTC
@@ -124,6 +124,7 @@ struct PhoneCall: View {
@AppStorage("CallsMicBesideMenu") var micBesideMenu = false
@Environment(\.accessibilityReduceMotion) var reduceMotion
@Environment(\.accessibilityVoiceOverEnabled) var voiceOver
+ @Environment(\.fixtureBackdrop) var fixtureBackdrop
private let style: CallLayoutStyle?
init(
diff --git a/native/ios/Sources/PhoneCallFixture.swift b/native/ios/Sources/PhoneCallFixture.swift
index 1bcffdc..b38a236 100644
--- a/native/ios/Sources/PhoneCallFixture.swift
+++ b/native/ios/Sources/PhoneCallFixture.swift
@@ -7,7 +7,9 @@
/// with `-CallsCallFixture focus` (or grid, screen, notices), and
/// optionally `-CallsFixtureControls shown|hidden` to pin the controls,
/// `-CallsFixtureMore YES` to open the controls menu, `-CallsFixtureDark
- /// YES` and `-CallsFixtureClassic YES`.
+ /// YES`, `-CallsFixtureClassic YES`, `-CallsFixtureBackdrop black|white|busy`
+ /// for a frame in place of the stage, and `-CallsFixtureSelf
+ /// muted|cameraOff|sharing` for your own state.
struct PhoneCallFixture: View {
static var requested: CallFixture? {
UserDefaults.standard.string(forKey: "CallsCallFixture").flatMap(CallFixture.init(rawValue:))
@@ -28,11 +30,16 @@
grid: fixture == .grid)
}
.tint(CallsAppearance.accent)
+ .environment(
+ \.fixtureBackdrop,
+ defaults.string(forKey: "CallsFixtureBackdrop").flatMap(FixtureBackdrop.init(rawValue:)))
.preferredColorScheme(defaults.bool(forKey: "CallsFixtureDark") ? .dark : .light)
.onAppear {
guard !applied else { return }
applied = true
fixture.apply(to: call)
+ UserDefaults.standard.string(forKey: "CallsFixtureSelf")
+ .flatMap(FixtureSelf.init(rawValue:))?.apply(to: call)
}
}
}
diff --git a/native/ios/Sources/PhoneFullScreenCall.swift b/native/ios/Sources/PhoneFullScreenCall.swift
index d904ca1..a494ca0 100644
--- a/native/ios/Sources/PhoneFullScreenCall.swift
+++ b/native/ios/Sources/PhoneFullScreenCall.swift
@@ -1,4 +1,4 @@
-import CallsKit
+@_spi(Fixtures) import CallsKit
import SwiftUI
/// What the controls sheet was closed in order to open.
@@ -6,7 +6,7 @@ enum MoreAction { case devices, chat }
/// The full-screen call: the picture under the status bar and the home
/// indicator, both of which go with the controls. A tap shows the
-/// controls and another hides them; they go on their own 2.5 s after the
+/// controls and another hides them; they go on their own 3 s after the
/// last touch (see ControlsVisibility for why that long, and what keeps
/// them up). The bar is a controls menu on the left and End call on the
/// right; everything else is in the menu.
@@ -78,8 +78,7 @@ extension PhoneCall {
}
private var fade: Animation {
- .easeInOut(
- duration: ControlsVisibility.fadeDuration(showing: chrome.visible, reduceMotion: reduceMotion))
+ ControlsVisibility.fade(showing: chrome.visible, reduceMotion: reduceMotion)
}
private func updateHolds() {
@@ -93,7 +92,9 @@ extension PhoneCall {
let lift = EdgeInsets(
top: 0, leading: safe.leading, bottom: safe.bottom + (chrome.visible ? Self.barHeight : 0),
trailing: 0)
- if grid {
+ if let fixtureBackdrop {
+ fixtureBackdrop.view.accessibilityHidden(true)
+ } else if grid {
grid(in: size, safe: safe)
} else {
switch focused {
@@ -231,16 +232,16 @@ extension PhoneCall {
VStack(spacing: 8) {
// The title goes with the controls; the notices under it do not.
VStack(alignment: .leading, spacing: 2) {
- Text(call.title).font(.headline).lineLimit(2)
- Text(call.status).font(.caption).foregroundStyle(.secondary)
+ Text(call.title).font(.headline).foregroundStyle(.white).lineLimit(2)
+ Text(call.status).font(.caption).foregroundStyle(CallOverlay.secondaryText)
.accessibilityAddTraits(.updatesFrequently)
ForEach(notices.filter { !$0.important }) { notice in
- Text(notice.text).font(.caption).foregroundStyle(.secondary)
+ Text(notice.text).font(.caption).foregroundStyle(CallOverlay.secondaryText)
.fixedSize(horizontal: false, vertical: true)
}
}
.padding(.horizontal, 12).padding(.vertical, 8)
- .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
+ .callOverlaySurface(RoundedRectangle(cornerRadius: 14, style: .continuous))
.frame(maxWidth: .infinity, alignment: .leading)
.opacity(chrome.visible ? 1 : 0)
.allowsHitTesting(chrome.visible)
@@ -267,9 +268,10 @@ extension PhoneCall {
HStack(spacing: 10) {
switch notice.kind {
case .sharing:
- Label(notice.text, systemImage: "rectangle.on.rectangle").foregroundStyle(.orange).bold()
+ Label(notice.text, systemImage: "rectangle.on.rectangle")
+ .foregroundStyle(CallOverlay.alertText).bold()
case .reconnecting, .waitingToJoin:
- ProgressView()
+ ProgressView().tint(.white)
Text(notice.text)
case .full:
Text(notice.text)
@@ -297,11 +299,11 @@ extension PhoneCall {
Label("More controls", systemImage: "ellipsis")
.labelStyle(.iconOnly).rotationEffect(.degrees(90))
}
- .buttonStyle(RoundCallButtonStyle(tint: nil))
+ .buttonStyle(RoundCallButtonStyle(.normal))
.overlay(alignment: .topTrailing) {
if unread > 0 {
Text("\(unread)").font(.caption.bold()).foregroundStyle(.white)
- .padding(5).background(.purple, in: Capsule()).accessibilityHidden(true)
+ .padding(5).background(CallOverlay.badgeFill, in: Capsule()).accessibilityHidden(true)
}
}
.accessibilityLabel(unread > 0 ? "More controls, \(unread) new chat messages" : "More controls")
@@ -312,13 +314,13 @@ extension PhoneCall {
systemImage: call.muted ? "mic.slash.fill" : "mic.fill"
) { call.mute() }
.labelStyle(.iconOnly)
- .buttonStyle(RoundCallButtonStyle(tint: call.muted ? Color(red: 0.65, green: 0.30, blue: 0.02) : nil))
+ .buttonStyle(RoundCallButtonStyle(call.muted ? .off : .normal))
.accessibilityIdentifier("mute")
}
Spacer(minLength: 0)
Button("End call", systemImage: "phone.down.fill", role: .destructive, action: leave)
.labelStyle(.iconOnly)
- .buttonStyle(RoundCallButtonStyle(tint: .red))
+ .buttonStyle(RoundCallButtonStyle(.leave))
.accessibilityIdentifier("leave")
}
.padding(.leading, safe.leading + 20).padding(.trailing, safe.trailing + 20)
@@ -333,18 +335,22 @@ extension PhoneCall {
}
}
-/// A round call button: material with a white symbol, or filled with
-/// `tint` (End call's red, a muted microphone's amber).
+/// A round call button over the picture: the overlay's dark surface with
+/// a white symbol, or filled for End call's red and a muted microphone's
+/// amber (see CallOverlay).
struct RoundCallButtonStyle: ButtonStyle {
- let tint: Color?
+ let tone: CallOverlayButtonStyle.Tone
+ init(_ tone: CallOverlayButtonStyle.Tone) { self.tone = tone }
func makeBody(configuration: Configuration) -> some View {
- configuration.label.font(.title2.weight(.semibold)).foregroundStyle(.white)
+ configuration.label.font(.title2.weight(.semibold))
+ .foregroundStyle(tone == .on ? CallOverlay.onSymbol : .white)
.frame(width: PhoneCall.button, height: PhoneCall.button)
.background {
- if let tint {
- Circle().fill(tint)
- } else {
- Circle().fill(.ultraThinMaterial).environment(\.colorScheme, .dark)
+ switch tone {
+ case .normal: Color.clear.callOverlaySurface(Circle())
+ case .on: Circle().fill(CallOverlay.onFill)
+ case .off: Circle().fill(CallOverlay.offFill)
+ case .leave: Circle().fill(CallOverlay.leaveFill)
}
}
.opacity(configuration.isPressed ? 0.7 : 1)
@@ -352,12 +358,15 @@ struct RoundCallButtonStyle: ButtonStyle {
}
}
+/// A notice over the picture: the overlay's dark surface, white text, and
+/// dark-appearance controls whatever the system's.
struct PhoneNoticeSurface: ViewModifier {
func body(content: Content) -> some View {
- content.font(.callout)
+ content.font(.callout).foregroundStyle(.white)
+ .environment(\.colorScheme, .dark)
.padding(.horizontal, 14).padding(.vertical, 8)
- .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
- .shadow(color: .black.opacity(0.2), radius: 8, y: 2)
+ .callOverlaySurface(RoundedRectangle(cornerRadius: 16, style: .continuous))
+ .shadow(color: .black.opacity(0.25), radius: 8, y: 2)
}
}
diff --git a/native/ios/UITests/CallsPhoneUITests.swift b/native/ios/UITests/CallsPhoneUITests.swift
index 8fde901..07b7ce2 100644
--- a/native/ios/UITests/CallsPhoneUITests.swift
+++ b/native/ios/UITests/CallsPhoneUITests.swift
@@ -158,7 +158,7 @@ final class CallsPhoneUITests: XCTestCase {
}
/// Paul's layout: the menu on the left, End call on the right, fading
- /// 2.5 s after the last touch, back with a tap, and every other control
+ /// 3 s after the last touch, back with a tap, and every other control
/// in the menu.
@MainActor func testFullScreenCallControls() throws {
XCUIDevice.shared.orientation = .portrait
@@ -243,6 +243,35 @@ final class CallsPhoneUITests: XCTestCase {
}
}
+ /// The overlay over black, white and a busy frame, in each of your own
+ /// states, light and dark: it should look the same in both. The
+ /// microphone sits beside the menu here so its muted state shows.
+ @MainActor func testOverlayContrastSnapshots() throws {
+ XCUIDevice.shared.orientation = .portrait
+ for dark in [false, true] {
+ for backdrop in ["black", "white", "busy"] {
+ for me in ["normal", "muted", "cameraOff", "sharing"] {
+ let app = launchCall(
+ "focus",
+ [
+ "-CallsFixtureControls", "shown", "-CallsFixtureBackdrop", backdrop,
+ "-CallsFixtureSelf", me, "-CallsMicBesideMenu", "YES",
+ ], dark: dark)
+ XCTAssertTrue(app.buttons["leave"].waitForExistence(timeout: 15))
+ XCTAssertTrue(app.buttons["mute"].exists, "beside the menu")
+ if me == "muted" { XCTAssertEqual(app.buttons["mute"].label, "Unmute microphone") }
+ Thread.sleep(forTimeInterval: 1)
+ try snapshot(app, "contrast-\(backdrop)-\(me)-\(dark ? "dark" : "light")")
+ }
+ }
+ let notices = launchCall(
+ "notices", ["-CallsFixtureControls", "shown", "-CallsFixtureBackdrop", "white"], dark: dark)
+ XCTAssertTrue(notices.buttons["Let in"].waitForExistence(timeout: 15))
+ Thread.sleep(forTimeInterval: 1)
+ try snapshot(notices, "contrast-white-notices-\(dark ? "dark" : "light")")
+ }
+ }
+
/// Landscape keeps both bar buttons on screen, clear of the Dynamic
/// Island's side.
@MainActor func testLandscapeKeepsTheBarOnScreen() throws {
diff --git a/native/ios/project.yml b/native/ios/project.yml
index 9f6ecec..2e12969 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.3.4'
+ MARKETING_VERSION: '0.3.5'
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