Borderless glass over the picture: bar, buttons, pills, notices, tab, Devices panel
One glass pane under the controls (Liquid Glass on macOS/iOS 26, the ultra-thin material before) with black at 62 % on it; no hairline edges. Buttons are white glyphs with a hover highlight, and a capsule only for on (light), off (amber, slashed) and Leave (red). The Devices panel is opaque and dark with full-contrast labels.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 files changed,
+135
−79
native/apple/Sources/CallsKit/CallActionStyle.swift+88 −54native/apple/Sources/CallsKit/MacFullWindowCall.swift+16 −7native/apple/Sources/CallsKit/MiniWindow.swift+6 −5native/apple/Sources/CallsKit/MutedBadge.swift+3 −3native/apple/Tests/CallsKitTests/CallChromeTests.swift+18 −5native/ios/Sources/PhoneFullScreenCall.swift+4 −5
diff --git a/native/apple/Sources/CallsKit/CallActionStyle.swift b/native/apple/Sources/CallsKit/CallActionStyle.swift| index 979f80e..8653a85 100644 |
| --- a/native/apple/Sources/CallsKit/CallActionStyle.swift |
| +++ b/native/apple/Sources/CallsKit/CallActionStyle.swift |
| @@ -41,64 +41,91 @@ public struct CallActionStyle: ButtonStyle { |
| /// 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. |
| +/// notices — as one system (0.3.7): borderless glass panes, dark whatever |
| +/// the system appearance, because what is under them 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. |
| +/// Each surface is Liquid Glass where the system has it (macOS 26, iOS |
| +/// 26), and the ultra-thin material before that, with black at `scrim` |
| +/// laid on it. The glass and the blur only soften; the black does the |
| +/// work, so the contrast holds over anything and even where neither is |
| +/// drawn: white under 62 % black is #616161, so white on a surface is |
| +/// 6.2:1 at worst and 21:1 at best (over black). Rendered, the glass over |
| +/// white measures #5B5B5B (6.8:1). No hairline edges anywhere: a surface |
| +/// is told from the picture by its tone, and from black video by the |
| +/// glass's own light. `CallOverlayContrastTests` holds the ratios. |
| 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) |
| + /// The black laid over the glass or material. |
| + public static let scrim: Double = 0.62 |
| + /// A resting button has no fill of its own: a white symbol on the pane. |
| + /// Under the pointer it lightens by this, and pressed by `pressFill`; |
| + /// white on the hovered pane is still 4.9:1 at worst. |
| + public static let hoverFill = Color.white.opacity(0.1) |
| + public static let pressFill = 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. |
| + /// near-white capsule with a near-black symbol, 15:1 between them and |
| + /// 6.8:1 from the pane 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. |
| + /// Microphone or camera off: an amber capsule, 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). |
| + /// Leave and End call: a red capsule 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 |
| + /// Secondary text on a surface: the status line, a quiet notice. 5.0:1 |
| /// over the worst-case surface. |
| - public static let secondaryText = Color.white.opacity(0.78) |
| + public static let secondaryText = Color.white.opacity(0.85) |
| /// 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) |
| + /// amber, 4.6:1 over the worst-case surface. |
| + public static let alertText = Color(red: 1.0, green: 0.85, blue: 0.6) |
| + /// A panel over the picture that is read rather than glanced at (the |
| + /// Devices panel): opaque, so nothing of the video shows through the |
| + /// text. The dark appearance's own label colours sit on it at full |
| + /// contrast (primary 17:1, secondary 7.9:1). |
| + public static let panelFill = Color(white: 0.12) |
| } |
| -/// 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. |
| +private struct CallOverlayGlassKey: EnvironmentKey { |
| + static let defaultValue = true |
| +} |
| + |
| +extension EnvironmentValues { |
| + /// Whether the overlay's surfaces may use Liquid Glass where the system |
| + /// has it. True in the apps; the snapshots turn it off to show the |
| + /// material every older system draws instead. |
| + public var callOverlayGlass: Bool { |
| + get { self[CallOverlayGlassKey.self] } |
| + set { self[CallOverlayGlassKey.self] = newValue } |
| + } |
| +} |
| + |
| +/// A surface over the picture: borderless glass — Liquid Glass on macOS |
| +/// and iOS 26, the ultra-thin material before — with black at |
| +/// `CallOverlay.scrim` on it. 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 |
| + @Environment(\.callOverlayGlass) private var glass |
| 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)) |
| + if glass, #available(macOS 26, iOS 26, *) { |
| + content |
| + .background(shape.fill(Color.black.opacity(CallOverlay.scrim))) |
| + .glassEffect(.regular, in: shape) |
| + } else { |
| + content |
| + .background { |
| + ZStack { |
| + shape.fill(.ultraThinMaterial) |
| + shape.fill(Color.black.opacity(CallOverlay.scrim)) |
| + } |
| + .environment(\.colorScheme, .dark) |
| } |
| - .environment(\.colorScheme, .dark) |
| - } |
| - .overlay(shape.strokeBorder(CallOverlay.edge, lineWidth: 1)) |
| + } |
| } |
| } |
| @@ -109,12 +136,14 @@ extension View { |
| } |
| } |
| -/// 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. |
| +/// A button over the picture, in one of four states. At rest it is only a |
| +/// white symbol on the pane, lightening under the pointer; a filled |
| +/// capsule appears behind it only in a state that needs one, and 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. |
| + /// Resting: a white symbol on the pane. |
| case normal |
| /// On: a panel open, the screen being shared. |
| case on |
| @@ -149,25 +178,30 @@ private struct OverlayButtonBody<Label: View>: View { |
| let round: Bool |
| let pressed: Bool |
| let enabled: Bool |
| + @State private var hovering = false |
| var body: some View { |
| - let shape = RoundedRectangle(cornerRadius: round ? size / 2 : 14, style: .continuous) |
| + // Leave is wider than it is tall, so it reads as a capsule and not as |
| + // one more round state behind a symbol. |
| + let width = tone == .leave && !round ? size * 1.4 : size |
| label.labelStyle(.iconOnly) |
| .font(round ? .title2.weight(.semibold) : .title3.weight(.medium)) |
| - .frame(width: size, height: size) |
| + .frame(width: width, 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) |
| + .background(fill, in: Capsule()) |
| + .opacity(!enabled ? 0.45 : pressed && tone != .normal ? 0.75 : 1) |
| + .contentShape(Capsule()) |
| + .onHover { hovering = $0 } |
| } |
| private var fill: Color { |
| switch tone { |
| - case .normal: CallOverlay.buttonFill |
| - case .on: CallOverlay.onFill |
| - case .off: CallOverlay.offFill |
| - case .leave: CallOverlay.leaveFill |
| + case .normal: |
| + guard enabled else { return .clear } |
| + return pressed ? CallOverlay.pressFill : hovering ? CallOverlay.hoverFill : .clear |
| + case .on: return CallOverlay.onFill |
| + case .off: return CallOverlay.offFill |
| + case .leave: return CallOverlay.leaveFill |
| } |
| } |
| } |
diff --git a/native/apple/Sources/CallsKit/MacFullWindowCall.swift b/native/apple/Sources/CallsKit/MacFullWindowCall.swift| index 0816cf8..5bf7b04 100644 |
| --- a/native/apple/Sources/CallsKit/MacFullWindowCall.swift |
| +++ b/native/apple/Sources/CallsKit/MacFullWindowCall.swift |
| @@ -230,7 +230,6 @@ |
| compact: true, backdrop: Self.tileBackdrop, audio: call.selfAudio |
| ) |
| .frame(width: pip.width, height: pip.height) |
| - .overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(.white.opacity(0.18))) |
| .shadow(color: .black.opacity(0.35), radius: 8, y: 3) |
| .position(x: frame.midX + pipDrag.width, y: frame.midY + pipDrag.height) |
| .gesture( |
| @@ -388,8 +387,16 @@ |
| .frame(height: max(80, min(devicesHeight, size.height - devicesTop - bottom - 44))) |
| } |
| .frame(width: Self.panelWidth) |
| - .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16)) |
| - .shadow(color: .black.opacity(0.25), radius: 12, y: 4) |
| + // Read, not glanced at, and over video: opaque and dark, with the |
| + // dark appearance's full-contrast labels, whatever the system's |
| + // appearance (a light material over a picture was grey on grey). |
| + // It is not part of the controls' fade: it holds them up while |
| + // open (`.menuOpen`) and carries no opacity of its own. |
| + .foregroundStyle(.white) |
| + .background(CallOverlay.panelFill, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) |
| + .environment(\.colorScheme, .dark) |
| + .shadow(color: .black.opacity(0.35), radius: 14, y: 4) |
| + .accessibilityIdentifier("devicesPanel") |
| .padding(.leading, 16).padding(.bottom, bottom) |
| .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading) |
| .transition(panelTransition(edge: .bottom)) |
| @@ -430,8 +437,9 @@ |
| private var bar: some View { |
| controls(fullWindow: true) |
| .padding(10) |
| - .callOverlaySurface(RoundedRectangle(cornerRadius: 20, style: .continuous)) |
| - .shadow(color: .black.opacity(0.3), radius: 12, y: 4) |
| + // One glass pane, no edge, the buttons sitting on it. |
| + .callOverlaySurface(Capsule()) |
| + .shadow(color: .black.opacity(0.25), radius: 12, y: 4) |
| .frame(maxWidth: 760) |
| .background( |
| GeometryReader { bar in |
| @@ -461,8 +469,9 @@ |
| } |
| } |
| - /// A notice over the picture: a capsule of the overlay's dark surface, |
| - /// with white text and dark-appearance controls, whatever the system's. |
| + /// A notice over the picture: a capsule of the overlay's borderless |
| + /// glass, with white text and dark-appearance controls, whatever the |
| + /// system's. |
| struct NoticeSurface: ViewModifier { |
| func body(content: Content) -> some View { |
| content.font(.callout).foregroundStyle(.white) |
diff --git a/native/apple/Sources/CallsKit/MiniWindow.swift b/native/apple/Sources/CallsKit/MiniWindow.swift| index d0ac85c..43654cb 100644 |
| --- a/native/apple/Sources/CallsKit/MiniWindow.swift |
| +++ b/native/apple/Sources/CallsKit/MiniWindow.swift |
| @@ -446,7 +446,7 @@ |
| if call.muted, !showingControls { |
| Label("Muted", systemImage: "mic.slash.fill").labelStyle(.iconOnly) |
| .font(.callout.bold()).foregroundStyle(.white) |
| - .padding(8).background(.black.opacity(0.65), in: Circle()) |
| + .padding(8).callOverlaySurface(Circle()) |
| .accessibilityLabel("Your microphone is muted") |
| } |
| Spacer() |
| @@ -485,7 +485,8 @@ |
| /// the call is with. Click it to open the mini window again, drag it out |
| /// to open it there, double-click it for the whole call. |
| /// |
| - /// A small waveform shows while someone else is speaking, from the |
| + /// A small waveform shows while someone else is speaking, with the |
| + /// speaking glow behind it following the loudest of them, from the |
| /// receive meters (spec §15.20), which read five times a second — quick |
| /// enough to follow speech, where the five-second audio readout was not. |
| struct MiniTabView: View { |
| @@ -510,7 +511,7 @@ |
| Text(caption).font(.callout.weight(.medium)).lineLimit(1).truncationMode(.tail) |
| .foregroundStyle(.white) |
| Spacer(minLength: 0) |
| - SpeakingGlyph(speaking: call.levels.anyoneSpeaking) |
| + SpeakingGlyph(speaking: call.levels.anyoneSpeaking, glow: call.levels.othersGlow) |
| } |
| .padding(.horizontal, 7) |
| .frame(width: MiniWindowTab.size.width, height: MiniWindowTab.size.height) |
| @@ -520,9 +521,9 @@ |
| click: { count in count >= 2 ? actions.open() : actions.expand() }, |
| dropped: { _ in actions.tabReleased() }, resized: {}) |
| ) |
| - .background(Color(white: 0.12).opacity(0.94)) |
| + // The call's glass, borderless, as over the picture. |
| + .callOverlaySurface(Capsule()) |
| .clipShape(Capsule()) |
| - .overlay(Capsule().strokeBorder(.white.opacity(0.15))) |
| .accessibilityElement(children: .ignore) |
| .accessibilityAddTraits(.isButton) |
| .accessibilityLabel("Calls, \(caption)") |
diff --git a/native/apple/Sources/CallsKit/MutedBadge.swift b/native/apple/Sources/CallsKit/MutedBadge.swift| index 498ef98..e734fa9 100644 |
| --- a/native/apple/Sources/CallsKit/MutedBadge.swift |
| +++ b/native/apple/Sources/CallsKit/MutedBadge.swift |
| @@ -11,7 +11,7 @@ public struct MutedBadge: View { |
| Label("Muted", systemImage: "mic.slash.fill") |
| .font(.caption.bold()).foregroundStyle(.white) |
| .padding(.horizontal, 8).padding(.vertical, 4) |
| - .background(.black.opacity(0.65), in: Capsule()) |
| + .callOverlaySurface(Capsule()) |
| .accessibilityHidden(true) |
| } |
| } |
| @@ -48,8 +48,8 @@ public struct TileNameLabel: View { |
| .font(.caption.weight(.semibold)).foregroundStyle(.white) |
| .fixedSize() |
| .padding(.horizontal, 7).padding(.vertical, 3) |
| - // The overlay's dark surface, as the controls have: white on it is |
| - // 8.5:1 even over a white wall. |
| + // The overlay's glass, as the controls have: white on it is 6.2:1 |
| + // even over a white wall. |
| .callOverlaySurface(Capsule()) |
| .accessibilityHidden(true) |
| } |
diff --git a/native/apple/Tests/CallsKitTests/CallChromeTests.swift b/native/apple/Tests/CallsKitTests/CallChromeTests.swift| index 3d453a6..e174ddb 100644 |
| --- a/native/apple/Tests/CallsKitTests/CallChromeTests.swift |
| +++ b/native/apple/Tests/CallsKitTests/CallChromeTests.swift |
| @@ -314,15 +314,17 @@ final class CallOverlayContrastTests: XCTestCase { |
| 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)] { |
| + // The black alone, as if neither glass nor blur were drawn: the |
| + // rendered glass over white measures darker than this (#5B5B5B). |
| let surface = over(Color.black.opacity(CallOverlay.scrim), video) |
| - let resting = over(CallOverlay.buttonFill, surface) |
| + let hovered = over(CallOverlay.hoverFill, 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), |
| + ("white on the surface (a resting glyph, a name, the title)", ratio(white, surface), 6), |
| + ("a white glyph under the pointer", ratio(white, hovered), 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), |
| + ("an on capsule against the pane", ratio(rgb(CallOverlay.onFill).0, surface), 3), |
| + ("an on capsule'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), |
| @@ -332,6 +334,17 @@ final class CallOverlayContrastTests: XCTestCase { |
| } |
| } |
| } |
| + |
| + /// The Devices panel over the picture: opaque, so the video never |
| + /// enters into it, with the dark appearance's own label colours. |
| + func testTheDevicesPanelIsReadable() { |
| + let panel = rgb(CallOverlay.panelFill).0 |
| + XCTAssertEqual(rgb(CallOverlay.panelFill).1, 1, "opaque") |
| + XCTAssertGreaterThanOrEqual(ratio((1, 1, 1), panel), 15, "primary text") |
| + // secondaryLabelColor in the dark appearance: white at 55 %. |
| + let secondary = over(Color.white.opacity(0.55), panel) |
| + XCTAssertGreaterThanOrEqual(ratio(secondary, panel), 4.5, "secondary text") |
| + } |
| } |
| final class PiPLayoutTests: XCTestCase { |
diff --git a/native/ios/Sources/PhoneFullScreenCall.swift b/native/ios/Sources/PhoneFullScreenCall.swift| index 93cf394..856c147 100644 |
| --- a/native/ios/Sources/PhoneFullScreenCall.swift |
| +++ b/native/ios/Sources/PhoneFullScreenCall.swift |
| @@ -184,7 +184,6 @@ extension PhoneCall { |
| compact: true, backdrop: Self.tileBackdrop, audio: call.selfAudio |
| ) |
| .frame(width: pip.width, height: pip.height) |
| - .overlay(RoundedRectangle(cornerRadius: 14).strokeBorder(.white.opacity(0.2))) |
| .shadow(color: .black.opacity(0.35), radius: 8, y: 3) |
| .position(x: frame.midX + pipDrag.width, y: frame.midY + pipDrag.height) |
| .onTapGesture { chrome.tapped() } |
| @@ -336,8 +335,8 @@ extension PhoneCall { |
| } |
| } |
| -/// 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 |
| +/// A round call button over the picture: a borderless glass circle with a |
| +/// white symbol, or filled for End call's red and a muted microphone's |
| /// amber (see CallOverlay). |
| struct RoundCallButtonStyle: ButtonStyle { |
| let tone: CallOverlayButtonStyle.Tone |
| @@ -359,8 +358,8 @@ struct RoundCallButtonStyle: ButtonStyle { |
| } |
| } |
| -/// A notice over the picture: the overlay's dark surface, white text, and |
| -/// dark-appearance controls whatever the system's. |
| +/// A notice over the picture: the overlay's borderless glass, white text, |
| +/// and dark-appearance controls whatever the system's. |
| struct PhoneNoticeSurface: ViewModifier { |
| func body(content: Content) -> some View { |
| content.font(.callout).foregroundStyle(.white) |