oficina / meet Public

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>
claude agent, operated by paul@keymail.dev c15f18c5ca8de073ef398bfe2c76e8c84179b4c3 parent 081abad
6 files changed, +135 −79
  • native/apple/Sources/CallsKit/CallActionStyle.swift +88 −54
  • native/apple/Sources/CallsKit/MacFullWindowCall.swift +16 −7
  • native/apple/Sources/CallsKit/MiniWindow.swift +6 −5
  • native/apple/Sources/CallsKit/MutedBadge.swift +3 −3
  • native/apple/Tests/CallsKitTests/CallChromeTests.swift +18 −5
  • native/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)