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, 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) .modifier(CallGlyphSizing(.action)) .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 (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 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 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 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: 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 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. 5.0:1 /// over the worst-case surface. public static let secondaryText = Color.white.opacity(0.85) /// A notice that must be seen (you are sharing your screen): a light /// 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) } 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. /// /// `rim: false` leaves Liquid Glass out even where the system has it: the /// glass lights its own edge, a faint light hairline that a small pill /// over a dark picture shows plainly (the sharing notice, 2026-09-28). public struct CallOverlaySurface: ViewModifier { let shape: S let rim: Bool @Environment(\.callOverlayGlass) private var glass public init(_ shape: S, rim: Bool = true) { self.shape = shape self.rim = rim } public func body(content: Content) -> some View { if glass, rim, #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) } } } } extension View { /// See `CallOverlaySurface`. public func callOverlaySurface(_ shape: S, rim: Bool = true) -> some View { modifier(CallOverlaySurface(shape, rim: rim)) } } /// 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 the pane. 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 { OverlayButtonBody( label: configuration.label, tone: tone, size: size, round: round, pressed: configuration.isPressed, enabled: isEnabled) } } private struct OverlayButtonBody: View { let label: Label let tone: CallOverlayButtonStyle.Tone let size: CGFloat let round: Bool let pressed: Bool let enabled: Bool @State private var hovering = false var body: some View { // 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)) .modifier(CallGlyphSizing(round ? .round : .bar)) .frame(width: width, height: size) .foregroundStyle(tone == .on ? CallOverlay.onSymbol : .white) .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: 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 } } }