oficina / meet Public

Calls: centre control glyphs by their ink; keep tile placeholders clear of labels (WIP)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 6e3b5a60135ca10863f924590fd306997308481e parent 583d594
13 files changed, +751 −60
  • internal/calls/static/calls.css +55 −0
  • internal/callstest/browser_test.go +1 −0
  • native/apple/Sources/CallsKit/CallActionStyle.swift +2 −0
  • native/apple/Sources/CallsKit/CallGlyph.swift +183 −0
  • native/apple/Sources/CallsKit/MacCallView.swift +7 −7
  • native/apple/Sources/CallsKit/MacVideoTile.swift +13 −10
  • native/apple/Sources/CallsKit/MeetingShareButton.swift +1 −1
  • native/apple/Sources/CallsKit/MiniWindow.swift +3 −3
  • native/apple/Sources/CallsKit/MutedBadge.swift +84 −10
  • native/apple/Tests/CallsKitTests/CallGlyphTests.swift +191 −0
  • native/ios/Sources/CallsPhone.swift +18 −16
  • native/ios/Sources/PhoneFullScreenCall.swift +21 −12
  • test/browser/call.mjs +172 −1
diff --git a/internal/calls/static/calls.css b/internal/calls/static/calls.css
index 9ae6ef5..d8c9019 100644
--- a/internal/calls/static/calls.css
+++ b/internal/calls/static/calls.css
@@ -652,3 +652,58 @@
@media (prefers-reduced-motion: no-preference) {
.calls-tile-lifted { scale: 1.04; }
}
+
+/* 0.4.1: the bar's glyphs sit on the bar's centre line by their ink,
+ not by their boxes. A Lucide glyph is drawn to its 24-unit square,
+ and where its weight falls inside it is its own: the microphone's
+ capsule and arc are its weight, and they are above its stem, so its
+ ink sat 0.9 px low. Each glyph that needs it gets one line here,
+ measured by the browser gate's glyphs scenario, which holds every
+ glyph to within half a pixel of the centre line — the same rule, the
+ same table and the same test as the native CallGlyph. */
+#controls.calls-controls button .icon {
+ flex: none;
+ translate: 0 var(--calls-glyph-dy, 0px);
+}
+#controls.calls-controls #mute { --calls-glyph-dy: -0.9px; }
+
+/* A filmstrip tile, or any small one (0.4.1). The camera-off cover's
+ symbol and words are centred in the space above the caption, never
+ under it, and a tile too small for the words keeps the symbol alone,
+ then a smaller one. The caption stays one line: the name gives up its
+ end to an ellipsis, the muted mark keeps its slashed microphone and
+ drops the word (still read out: it is only visually hidden), and
+ there is room past the caption's end for the speaking glow. */
+.calls-tile { container-type: inline-size; }
+.calls-tile-off { padding-block-end: 2.75rem; }
+.calls-tile-name [rst-badge] { color: #fff; border-color: rgb(255 255 255 / 0.5); }
+@container (max-width: 20rem) {
+ .calls-tile-name {
+ flex-wrap: nowrap;
+ box-sizing: border-box;
+ max-inline-size: calc(100% - 2 * var(--rst-sp-2) - 6px);
+ }
+ .calls-tile-name > span:first-child { flex: 0 1 auto; min-inline-size: 0; }
+ .calls-tile-name > :not(:first-child) { flex: none; }
+ .calls-tile-muted > :not(svg) {
+ position: absolute;
+ inline-size: 1px;
+ block-size: 1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+ }
+}
+@container (max-width: 15rem) {
+ .calls-tile-off > :not(svg) {
+ position: absolute;
+ inline-size: 1px;
+ block-size: 1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+ }
+}
+@container (max-width: 9rem) {
+ .calls-tile-off .icon { inline-size: 1.25rem; block-size: 1.25rem; }
+}
diff --git a/internal/callstest/browser_test.go b/internal/callstest/browser_test.go
index bb259d9..26e16c3 100644
--- a/internal/callstest/browser_test.go
+++ b/internal/callstest/browser_test.go
@@ -82,6 +82,7 @@ const (
var scenarios = []string{
"guestdirect",
"layout",
+ "glyphs",
"devices",
"audiotest",
"call",
diff --git a/native/apple/Sources/CallsKit/CallActionStyle.swift b/native/apple/Sources/CallsKit/CallActionStyle.swift
index 8653a85..bb1dbcf 100644
--- a/native/apple/Sources/CallsKit/CallActionStyle.swift
+++ b/native/apple/Sources/CallsKit/CallActionStyle.swift
@@ -28,6 +28,7 @@ public struct CallActionStyle: ButtonStyle {
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))
@@ -186,6 +187,7 @@ private struct OverlayButtonBody<Label: View>: View {
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())
diff --git a/native/apple/Sources/CallsKit/CallGlyph.swift b/native/apple/Sources/CallsKit/CallGlyph.swift
new file mode 100644
index 0000000..375414a
--- /dev/null
+++ b/native/apple/Sources/CallsKit/CallGlyph.swift
@@ -0,0 +1,183 @@
+import SwiftUI
+
+/// A call control's symbol, centred on its button by its ink (0.4.1).
+///
+/// SF Symbols are laid out to sit on a line of text, not to be centred in
+/// a circle: each is placed by the font's baseline and cap height, and
+/// how its weight is spread inside that box is its own business. In a row
+/// of borderless buttons that shows. Paul measured the 0.4.0 bar: the
+/// share symbol's ink sat 1.25 pt below the others', because its weight
+/// is the tray at the bottom and the arrow above it is a thin line.
+///
+/// So every symbol a call's controls draw goes through here: set at one
+/// point size and weight (`CallGlyphMetrics`, from the button's style),
+/// centred in one square the same for every symbol, and moved by its
+/// entry in `opticalOffsets`, so that its ink's centre of mass lies on
+/// the square's centre line. `CallGlyphTests` renders each symbol at
+/// every size the controls use and holds that to within half a point.
+public struct CallGlyph: View {
+ public let symbol: String
+ /// The ellipsis stood on end, for the iPhone's ⋯ button. The optical
+ /// offset is along the symbol's own vertical, so a quarter turn makes
+ /// it a sideways one; the ellipsis is symmetric, so there is none.
+ public var rotation: Angle
+ /// Off only to measure where SF Symbols alone would put the ink;
+ /// `trial` measures a correction (in ems) before it goes in the table.
+ var corrected = true
+ var trial: CGFloat? = nil
+ @Environment(\.callGlyphMetrics) private var metrics
+ @Environment(\.displayScale) private var displayScale
+
+ public init(_ symbol: String, rotation: Angle = .zero) {
+ self.symbol = symbol
+ self.rotation = rotation
+ }
+
+ public var body: some View {
+ PixelSquare(
+ side: metrics.box,
+ lift: (trial ?? (corrected ? Self.opticalOffset(symbol) : 0)) * metrics.points,
+ scale: displayScale
+ ) {
+ Image(systemName: symbol)
+ .font(.system(size: metrics.points, weight: metrics.weight))
+ .imageScale(.medium)
+ }
+ .rotationEffect(rotation)
+ }
+
+ /// How far each symbol moves to put its ink's centroid on the centre
+ /// line, in ems (a fraction of the point size, so it holds at every
+ /// size); negative is up. Measured by `CallGlyphTests.measure`, which
+ /// prints a fresh table when a symbol drifts past tolerance. A symbol
+ /// not listed is drawn where SF Symbols puts it.
+ public static let opticalOffsets: [String: CGFloat] = [
+ "mic.fill": -0.015,
+ "mic.slash.fill": -0.015,
+ "video.fill": 0.000,
+ "video.slash.fill": 0.000,
+ "rectangle.on.rectangle": 0.017,
+ "rectangle.on.rectangle.slash": 0.000,
+ "slider.horizontal.3": 0.000,
+ "rectangle.bottomthird.inset.filled": -0.067,
+ "square.grid.2x2": 0.000,
+ "square.and.arrow.up": -0.103,
+ "bubble.left.and.bubble.right.fill": 0.074,
+ "phone.down.fill": 0.000,
+ "ellipsis": 0.000,
+ "arrow.triangle.2.circlepath.camera": -0.034,
+ "waveform": 0.000,
+ "arrow.down.right.and.arrow.up.left": 0.000,
+ ]
+
+ public static func opticalOffset(_ symbol: String) -> CGFloat {
+ opticalOffsets[symbol] ?? 0
+ }
+}
+
+/// The square a symbol is centred in, with the symbol placed on whole
+/// pixels by this layout rather than by the renderer. A symbol's box is
+/// a fraction of a point tall, so centring it leaves it half a pixel
+/// off the grid, and which way that half pixel then rounds is the
+/// renderer's choice: a window rounded it one way and an off-screen
+/// render the other, a pixel apart. Rounding here makes the two agree,
+/// and makes what the tests measure what the screen shows.
+struct PixelSquare: Layout {
+ let side: CGFloat
+ let lift: CGFloat
+ let scale: CGFloat
+
+ func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
+ CGSize(width: side, height: side)
+ }
+
+ func placeSubviews(
+ in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()
+ ) {
+ let scale = max(scale, 1)
+ func snap(_ value: CGFloat) -> CGFloat { (value * scale).rounded() / scale }
+ for subview in subviews {
+ let size = subview.sizeThatFits(.unspecified)
+ subview.place(
+ at: CGPoint(
+ x: bounds.minX + snap((side - size.width) / 2),
+ y: bounds.minY + snap((side - size.height) / 2 + lift)),
+ proposal: ProposedViewSize(size))
+ }
+ }
+}
+
+/// The size a button's style sets its symbol at: one point size and
+/// weight for every symbol in a row, and the square they are centred in.
+public struct CallGlyphMetrics: Equatable, Sendable {
+ public var points: CGFloat
+ public var weight: Font.Weight
+ public var box: CGFloat
+
+ public init(points: CGFloat, weight: Font.Weight) {
+ self.points = points
+ self.weight = weight
+ // Wide enough for the widest (the two chat bubbles), and the same
+ // for all of them; an even number of points, so that centred in a
+ // button of 44 or 56 it starts on a whole point.
+ self.box = (points * 0.95).rounded() * 2
+ }
+
+ // The text styles the buttons used before 0.4.1, as point sizes, so no
+ // symbol changed size: macOS's title3 and title2 are 15 and 17 pt,
+ // iOS's 20 and 22 pt at the default text size.
+ #if os(macOS)
+ static let title3: CGFloat = 15
+ static let title2: CGFloat = 17
+ #else
+ static let title3: CGFloat = 20
+ static let title2: CGFloat = 22
+ #endif
+
+ /// The full-window bar's buttons.
+ public static let bar = CallGlyphMetrics(points: title3, weight: .medium)
+ /// A round button: the iPhone's ⋯, microphone and End call.
+ public static let round = CallGlyphMetrics(points: title2, weight: .semibold)
+ /// The classic row, the mini window, and the iPhone's controls sheet.
+ public static let action = CallGlyphMetrics(points: title3, weight: .regular)
+
+ /// `self`, scaled for the person's text size.
+ func scaled(_ factor: CGFloat) -> CallGlyphMetrics {
+ CallGlyphMetrics(points: (points * factor).rounded(), weight: weight)
+ }
+}
+
+/// Sets the `CallGlyph`s inside at `metrics`, grown or shrunk with the
+/// person's text size as the text style the buttons used to set did.
+public struct CallGlyphSizing: ViewModifier {
+ let metrics: CallGlyphMetrics
+ @ScaledMetric(relativeTo: .title3) private var textScale: CGFloat = 1
+ public init(_ metrics: CallGlyphMetrics) { self.metrics = metrics }
+ public func body(content: Content) -> some View {
+ content.environment(\.callGlyphMetrics, textScale == 1 ? metrics : metrics.scaled(textScale))
+ }
+}
+
+private struct CallGlyphMetricsKey: EnvironmentKey {
+ static let defaultValue = CallGlyphMetrics.action
+}
+
+extension EnvironmentValues {
+ /// See `CallGlyphMetrics`. Set by the call buttons' styles.
+ public var callGlyphMetrics: CallGlyphMetrics {
+ get { self[CallGlyphMetricsKey.self] }
+ set { self[CallGlyphMetricsKey.self] = newValue }
+ }
+}
+
+extension Button where Label == SwiftUI.Label<Text, CallGlyph> {
+ /// A call control: its title for VoiceOver and the tooltip, and
+ /// `glyph` drawn as a `CallGlyph`.
+ public init(
+ _ title: String, glyph: String, role: ButtonRole? = nil, action: @escaping () -> Void
+ ) {
+ self.init(role: role, action: action) {
+ SwiftUI.Label { Text(title) } icon: { CallGlyph(glyph) }
+ }
+ }
+}
diff --git a/native/apple/Sources/CallsKit/MacCallView.swift b/native/apple/Sources/CallsKit/MacCallView.swift
index 4e629bc..6769d5f 100644
--- a/native/apple/Sources/CallsKit/MacCallView.swift
+++ b/native/apple/Sources/CallsKit/MacCallView.swift
@@ -344,7 +344,7 @@
HStack(spacing: fullWindow ? 10 : 12) {
Button(
call.muted ? "Unmute microphone" : "Mute microphone",
- systemImage: call.muted ? "mic.slash.fill" : "mic.fill"
+ glyph: call.muted ? "mic.slash.fill" : "mic.fill"
) { call.mute() }
.buttonStyle(
CallActionStyle(
@@ -356,7 +356,7 @@
.focused($focusedControl, equals: .mute)
Button(
call.cameraOff ? "Turn camera on" : "Turn camera off",
- systemImage: call.cameraOff ? "video.slash.fill" : "video.fill"
+ glyph: call.cameraOff ? "video.slash.fill" : "video.fill"
) { call.toggleCamera() }
.buttonStyle(
CallActionStyle(
@@ -368,7 +368,7 @@
.focused($focusedControl, equals: .camera)
Button(
call.sharingScreen ? "Stop sharing your screen" : "Share your screen",
- systemImage: call.sharingScreen ? "rectangle.on.rectangle.slash" : "rectangle.on.rectangle"
+ glyph: call.sharingScreen ? "rectangle.on.rectangle.slash" : "rectangle.on.rectangle"
) {
if call.sharingScreen {
call.stopSharingScreen()
@@ -384,7 +384,7 @@
.disabled(call.state?.phase != "admitted")
.accessibilityIdentifier("shareScreen")
.focused($focusedControl, equals: .share)
- Button("Devices", systemImage: "slider.horizontal.3") {
+ Button("Devices", glyph: "slider.horizontal.3") {
withAnimation(panelAnimation) { showingDevices.toggle() }
}
.buttonStyle(
@@ -402,7 +402,7 @@
.help("Share meeting link")
.focused($focusedControl, equals: .link)
Spacer(minLength: fullWindow ? 24 : nil)
- Button(chatTitle, systemImage: "bubble.left.and.bubble.right.fill") {
+ Button(chatTitle, glyph: "bubble.left.and.bubble.right.fill") {
if chatWindow.isOpen { chatWindow.close() }
withAnimation(panelAnimation) { showingChat.toggle() }
}.buttonStyle(
@@ -426,7 +426,7 @@
chatWindow.open(call: call)
}
}
- Button("Leave meeting", systemImage: "phone.down.fill", role: .destructive, action: leave)
+ Button("Leave meeting", glyph: "phone.down.fill", role: .destructive, action: leave)
.buttonStyle(CallActionStyle(color: .red, emphasized: true, overlay: tone(fullWindow, .leave)))
.help("Leave meeting")
.accessibilityIdentifier("leave")
@@ -439,7 +439,7 @@
/// the small popover it opens.
private var layoutMenu: some View {
Button(
- "Video layout", systemImage: arrangement.layout.symbol
+ "Video layout", glyph: arrangement.layout.symbol
) { showingLayout.toggle() }
.buttonStyle(CallActionStyle(color: .gray, emphasized: showingLayout, overlay: showingLayout ? .on : .normal))
.help("Video layout: Speaker or Grid, and who is pinned to the stage")
diff --git a/native/apple/Sources/CallsKit/MacVideoTile.swift b/native/apple/Sources/CallsKit/MacVideoTile.swift
index f6f45e1..725290b 100644
--- a/native/apple/Sources/CallsKit/MacVideoTile.swift
+++ b/native/apple/Sources/CallsKit/MacVideoTile.swift
@@ -40,6 +40,10 @@
/// hears them as the tile's value.
var iconOnly = false
+ /// The height of the name label and what is over it, lift included,
+ /// which the placeholder stays above.
+ @State private var labelBlock: CGFloat = 0
+
var body: some View {
TileAudioReader(audio) { info in tile(info) }
}
@@ -50,16 +54,11 @@
if let track, !cameraOff, placeholder == nil {
MacVideoView(track: track, fit: screen).scaleEffect(x: mirrored ? -1 : 1, y: 1)
} else {
- VStack(spacing: 12) {
- Image(
- systemName: cameraOff
- ? "video.slash" : screen ? "rectangle.on.rectangle" : "person.crop.circle"
- )
- .font(iconOnly ? .title2 : .largeTitle)
- if !iconOnly {
- Text(waiting).font(.callout).multilineTextAlignment(.center).padding(.horizontal, 12)
- }
- }.foregroundStyle(.white.opacity(0.8))
+ TilePlaceholder(
+ symbol: cameraOff
+ ? "video.slash" : screen ? "rectangle.on.rectangle" : "person.crop.circle",
+ words: iconOnly ? nil : waiting, iconFont: iconOnly ? .title2 : .largeTitle,
+ reserve: labelBlock)
}
}
.overlay(alignment: .bottomLeading) {
@@ -81,6 +80,10 @@
}
}
.padding(compact ? 8 : 10).padding(.bottom, compact ? labelLift : 0)
+ // Room past the label's end for the speaking glow, when a long
+ // name has it truncating.
+ .padding(.trailing, 6)
+ .tileLabelBlock($labelBlock)
}
.overlay(alignment: .topLeading) { if muted, !compact { MutedBadge().padding(10) } }
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
diff --git a/native/apple/Sources/CallsKit/MeetingShareButton.swift b/native/apple/Sources/CallsKit/MeetingShareButton.swift
index 2038ef0..cd317f4 100644
--- a/native/apple/Sources/CallsKit/MeetingShareButton.swift
+++ b/native/apple/Sources/CallsKit/MeetingShareButton.swift
@@ -14,7 +14,7 @@ import SwiftUI
public var body: some View {
if call.standing == "member" {
- Button("Share", systemImage: "square.and.arrow.up") { showing = true }
+ Button("Share", glyph: "square.and.arrow.up") { showing = true }
.accessibilityLabel("Share meeting link")
.sheet(isPresented: $showing) {
MeetingInvitationView(call: call, close: { showing = false })
diff --git a/native/apple/Sources/CallsKit/MiniWindow.swift b/native/apple/Sources/CallsKit/MiniWindow.swift
index 8f82d84..9b819b0 100644
--- a/native/apple/Sources/CallsKit/MiniWindow.swift
+++ b/native/apple/Sources/CallsKit/MiniWindow.swift
@@ -456,14 +456,14 @@
}
Spacer()
if showingControls {
- Button("Collapse to a tab", systemImage: "arrow.down.right.and.arrow.up.left") {
+ Button("Collapse to a tab", glyph: "arrow.down.right.and.arrow.up.left") {
actions.collapse()
}
.buttonStyle(CallActionStyle(color: Color(white: 0.3), emphasized: true))
.help("Collapse to a tab at the edge of the screen")
Button(
call.muted ? "Unmute microphone" : "Mute microphone",
- systemImage: call.muted ? "mic.slash.fill" : "mic.fill"
+ glyph: call.muted ? "mic.slash.fill" : "mic.fill"
) { call.mute() }
.buttonStyle(
CallActionStyle(
@@ -473,7 +473,7 @@
)
.help(call.muted ? "Unmute microphone" : "Mute microphone")
Button(
- "Leave meeting", systemImage: "phone.down.fill", role: .destructive,
+ "Leave meeting", glyph: "phone.down.fill", role: .destructive,
action: actions.leave
)
.buttonStyle(CallActionStyle(color: .red, emphasized: true))
diff --git a/native/apple/Sources/CallsKit/MutedBadge.swift b/native/apple/Sources/CallsKit/MutedBadge.swift
index e734fa9..cddb9ea 100644
--- a/native/apple/Sources/CallsKit/MutedBadge.swift
+++ b/native/apple/Sources/CallsKit/MutedBadge.swift
@@ -27,6 +27,12 @@ public func tileAccessibilityLabel(_ label: String, muted: Bool) -> String {
/// second badge, so a tile keeps one quiet
/// label in its corner that can stay while the controls come and go.
/// Hidden from assistive technology, as the tile's own label says both.
+///
+/// A narrow tile (a filmstrip thumbnail, most of all on an iPhone held
+/// upright) has no room for "Ana García | Muted": there the word goes and
+/// the slashed microphone stays, and the name gives up its end to an
+/// ellipsis rather than running off the tile (0.4.1). The tile's own
+/// accessibility label still says "muted".
public struct TileNameLabel: View {
let label: String
let muted: Bool
@@ -35,22 +41,90 @@ public struct TileNameLabel: View {
self.muted = muted
}
public var body: some View {
- HStack(spacing: 5) {
- Text(label).lineLimit(1)
- if muted {
- Label("Muted", systemImage: "mic.slash.fill").labelStyle(.titleAndIcon)
- .padding(.leading, 5)
- .overlay(alignment: .leading) {
- Rectangle().fill(.white.opacity(0.5)).frame(width: 1, height: 10)
- }
- }
+ ViewThatFits(in: .horizontal) {
+ row(word: true).fixedSize()
+ row(word: false).fixedSize(horizontal: false, vertical: true)
}
.font(.caption.weight(.semibold)).foregroundStyle(.white)
- .fixedSize()
.padding(.horizontal, 7).padding(.vertical, 3)
// The overlay's glass, as the controls have: white on it is 6.2:1
// even over a white wall.
.callOverlaySurface(Capsule())
.accessibilityHidden(true)
}
+
+ private func row(word: Bool) -> some View {
+ HStack(spacing: 5) {
+ Text(label).lineLimit(1).truncationMode(.tail)
+ if muted {
+ Group {
+ if word {
+ Label("Muted", systemImage: "mic.slash.fill").labelStyle(.titleAndIcon)
+ } else {
+ Image(systemName: "mic.slash.fill")
+ }
+ }
+ .padding(.leading, 5)
+ .overlay(alignment: .leading) {
+ Rectangle().fill(.white.opacity(0.5)).frame(width: 1, height: 10)
+ }
+ .fixedSize()
+ .layoutPriority(1)
+ }
+ }
+ }
+}
+
+/// What a tile shows where its picture is not: a symbol, and the words
+/// for it when there is room (0.4.1). Centred in the space the tile has
+/// above its name label (`reserve`), never under it, and on a tile too
+/// short for the words the symbol stands alone, then smaller. The tile's
+/// accessibility value carries the words either way.
+public struct TilePlaceholder: View {
+ let symbol: String
+ let words: String?
+ let iconFont: Font
+ let wordsFont: Font
+ let spacing: CGFloat
+ let reserve: CGFloat
+
+ public init(
+ symbol: String, words: String?, iconFont: Font = .largeTitle, wordsFont: Font = .callout,
+ spacing: CGFloat = 12, reserve: CGFloat
+ ) {
+ self.symbol = symbol
+ self.words = words
+ self.iconFont = iconFont
+ self.wordsFont = wordsFont
+ self.spacing = spacing
+ self.reserve = reserve
+ }
+
+ public var body: some View {
+ ViewThatFits(in: .vertical) {
+ if let words {
+ VStack(spacing: spacing) {
+ Image(systemName: symbol).font(iconFont)
+ Text(words).font(wordsFont).multilineTextAlignment(.center).lineLimit(2)
+ .padding(.horizontal, 12)
+ }
+ }
+ Image(systemName: symbol).font(iconFont)
+ Image(systemName: symbol).font(.body)
+ }
+ .foregroundStyle(.white.opacity(0.8))
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ // A little air between the placeholder and the label as well.
+ .padding(.bottom, reserve > 0 ? reserve + 4 : 0)
+ .accessibilityHidden(true)
+ }
+}
+
+/// A tile's name label block, measured, so its placeholder can keep
+/// clear of it (see `TilePlaceholder`).
+extension View {
+ /// Reports this view's height as the tile's label block.
+ public func tileLabelBlock(_ height: Binding<CGFloat>) -> some View {
+ onGeometryChange(for: CGFloat.self) { $0.size.height } action: { height.wrappedValue = $0 }
+ }
}
diff --git a/native/apple/Tests/CallsKitTests/CallGlyphTests.swift b/native/apple/Tests/CallsKitTests/CallGlyphTests.swift
new file mode 100644
index 0000000..8cba621
--- /dev/null
+++ b/native/apple/Tests/CallsKitTests/CallGlyphTests.swift
@@ -0,0 +1,191 @@
+import AppKit
+import SwiftUI
+import XCTest
+
+@testable import CallsKit
+
+/// Paul, on the 0.4.0 bar: "Are these vertically aligned? Particularly
+/// the share icon." They were not: SF Symbols sit on a text baseline, and
+/// the share symbol's ink was 1.25 pt below its neighbours'. These render
+/// each control symbol as the controls draw it and measure where its ink
+/// is, so a table entry that drifts, a symbol added without one, or a
+/// style that puts the symbol back on a baseline fails here.
+@MainActor final class CallGlyphTests: XCTestCase {
+ /// Half a point either way of the centre line.
+ static let tolerance: CGFloat = 0.5
+
+ /// Every size the Mac's controls draw at: `bar`, `round` and
+ /// `action`, drawn by AppKit as the screen draws them. The iPhone's
+ /// sizes are drawn by UIKit at 3×, which this Mac cannot do; the
+ /// iPhone's call-screen UI tests measure those on the simulator's own
+ /// screenshots, against the same table.
+ static let sizes: [(String, CallGlyphMetrics)] = [
+ ("bar", .bar), ("round", .round), ("action", .action),
+ ]
+
+ /// Every symbol in the table, at every size and weight the controls
+ /// use on either platform: its ink's centroid on the square's centre.
+ func testEverySymbolsInkIsOnTheCentreLine() throws {
+ var failures: [String] = []
+ var worst: [String: CGFloat] = [:]
+ for (name, metrics) in Self.sizes {
+ for symbol in CallGlyph.opticalOffsets.keys.sorted() {
+ let ink = try XCTUnwrap(
+ Ink.measure(CallGlyph(symbol).environment(\.callGlyphMetrics, metrics)),
+ "\(symbol) drew nothing at \(name): not a symbol this system has")
+ XCTAssertEqual(ink.size.width, metrics.box, accuracy: 0.01, "\(symbol) \(name): the square")
+ XCTAssertEqual(ink.size.height, metrics.box, accuracy: 0.01, "\(symbol) \(name): the square")
+ if abs(ink.centroidOffset) > abs(worst[symbol] ?? 0) { worst[symbol] = ink.centroidOffset }
+ if abs(ink.centroidOffset) > Self.tolerance {
+ failures.append(String(format: "%@ at %@: %+.2f pt", symbol, name, ink.centroidOffset))
+ }
+ }
+ }
+ for (symbol, offset) in worst.sorted(by: { $0.key < $1.key }) {
+ print(String(format: "CallGlyph worst %@: %+.2f pt", symbol, offset))
+ }
+ XCTAssert(failures.isEmpty, "ink off the centre line:\n" + failures.joined(separator: "\n") + "\n" + Self.table())
+ }
+
+ /// The bar's own button, as the full-window call draws it: the style
+ /// puts the symbol in the middle of the button, not on a baseline.
+ func testTheBarButtonCentresItsSymbol() throws {
+ for symbol in ["mic.fill", "square.and.arrow.up", "rectangle.bottomthird.inset.filled", "phone.down.fill"] {
+ for tone in [CallOverlayButtonStyle.Tone.normal, .leave] {
+ let button = Button("Control", glyph: symbol) {}
+ .buttonStyle(CallActionStyle(overlay: tone))
+ .foregroundStyle(.white)
+ // White on the red of Leave: count only what is whiter than it.
+ let ink = try XCTUnwrap(Ink.measure(button, whiteOnly: tone == .leave), symbol)
+ XCTAssertEqual(ink.size.height, 44, accuracy: 0.01)
+ XCTAssertLessThanOrEqual(
+ abs(ink.centroidOffset), Self.tolerance,
+ String(format: "%@ (%@) is %+.2f pt off the button's centre", symbol, "\(tone)", ink.centroidOffset))
+ }
+ }
+ }
+
+ /// Without the table the share symbol is where Paul found it, low: so
+ /// the test above is measuring something the table fixes.
+ func testTheShareSymbolIsLowWithoutItsCorrection() throws {
+ var raw = CallGlyph("square.and.arrow.up")
+ raw.corrected = false
+ let ink = try XCTUnwrap(Ink.measure(raw.environment(\.callGlyphMetrics, .bar)))
+ XCTAssertGreaterThan(ink.centroidOffset, 0.75, "square.and.arrow.up is not low uncorrected any more")
+ XCTAssertLessThan(CallGlyph.opticalOffset("square.and.arrow.up"), 0, "and the table lifts it")
+ }
+
+ /// A fresh table from the symbols as this system draws them, over all
+ /// the sizes: the middle of the range each symbol's
+ /// correction can take, which is what to paste into
+ /// `CallGlyph.opticalOffsets`.
+ static func table() -> String {
+ var lines = ["measured offsets (ems, negative up):"]
+ for symbol in CallGlyph.opticalOffsets.keys.sorted() {
+ var ems: [CGFloat] = []
+ for (_, metrics) in sizes {
+ var raw = CallGlyph(symbol)
+ raw.corrected = false
+ if let ink = Ink.measure(raw.environment(\.callGlyphMetrics, metrics)) {
+ ems.append(-ink.centroidOffset / metrics.points)
+ }
+ }
+ let low = ems.min() ?? 0, high = ems.max() ?? 0
+ lines.append(String(format: " \"%@\": %.3f, // %.3f to %.3f", symbol, (low + high) / 2, low, high))
+ }
+ return lines.joined(separator: "\n")
+ }
+
+ /// With CALLS_GLYPH_TUNE=1, the table that does best: for each symbol
+ /// the correction, in thousandths of an em, whose worst size is
+ /// nearest the centre line — the pixel grid makes that a search rather
+ /// than a sum.
+ func testTuneTheTable() throws {
+ try XCTSkipUnless(ProcessInfo.processInfo.environment["CALLS_GLYPH_TUNE"] == "1")
+ var lines = ["tuned offsets (ems, negative up):"]
+ for symbol in CallGlyph.opticalOffsets.keys.sorted() {
+ var best: (em: CGFloat, worst: CGFloat) = (0, .infinity)
+ for step in -150...100 {
+ let em = CGFloat(step) / 1000
+ var worst: CGFloat = 0
+ for (_, metrics) in Self.sizes {
+ var glyph = CallGlyph(symbol)
+ glyph.trial = em
+ guard let ink = Ink.measure(glyph.environment(\.callGlyphMetrics, metrics)) else { continue }
+ worst = max(worst, abs(ink.centroidOffset))
+ }
+ if worst < best.worst - 0.01 || (abs(worst - best.worst) <= 0.01 && abs(em) < abs(best.em)) {
+ best = (em, worst)
+ }
+ }
+ lines.append(String(format: " \"%@\": %.3f, // worst %.2f pt", symbol, best.em, best.worst))
+ }
+ print(lines.joined(separator: "\n"))
+ }
+
+ func testPrintTheTable() {
+ print(Self.table())
+ }
+}
+
+/// Where a view's ink is, as a window draws it: hosted in an AppKit
+/// window and drawn by AppKit (`cacheDisplay`), which places a symbol on
+/// the same pixels the screen does. An `ImageRenderer` does not: it
+/// rounded every half pixel the other way, and put the bar's symbols a
+/// pixel higher than the window it was meant to stand for. Weighted by
+/// alpha, or, over a coloured fill, by whiteness.
+struct Ink {
+ /// The rendered size, in points.
+ var size: CGSize
+ /// The ink's centroid below the view's centre line, in points
+ /// (negative: above it).
+ var centroidOffset: CGFloat
+
+ @MainActor static func measure(_ view: some View, whiteOnly: Bool = false) -> Ink? {
+ let host = NSHostingView(rootView: view.environment(\.colorScheme, .dark))
+ let size = host.fittingSize
+ guard size.width > 0, size.height > 0 else { return nil }
+ host.frame = NSRect(origin: .zero, size: size)
+ let window = NSWindow(
+ contentRect: host.frame, styleMask: [.borderless], backing: .buffered, defer: false)
+ window.isReleasedWhenClosed = false
+ window.backgroundColor = .clear
+ window.isOpaque = false
+ window.contentView = host
+ host.layoutSubtreeIfNeeded()
+ defer { window.close() }
+ guard let rep = host.bitmapImageRepForCachingDisplay(in: host.bounds) else { return nil }
+ host.cacheDisplay(in: host.bounds, to: rep)
+ guard let image = rep.cgImage else { return nil }
+ let w = image.width, h = image.height
+ var pixels = [UInt8](repeating: 0, count: w * h * 4)
+ guard
+ let context = CGContext(
+ data: &pixels, width: w, height: h, bitsPerComponent: 8, bytesPerRow: w * 4,
+ space: CGColorSpaceCreateDeviceRGB(),
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)
+ else { return nil }
+ context.draw(image, in: CGRect(x: 0, y: 0, width: w, height: h))
+ var total = 0.0, moment = 0.0
+ for y in 0..<h {
+ for x in 0..<w {
+ let i = (y * w + x) * 4
+ let weight: Double
+ if whiteOnly {
+ // The least of the three channels: white is high in all of
+ // them, red is not.
+ weight = max(0, Double(min(pixels[i], pixels[i + 1], pixels[i + 2])) - 60)
+ } else {
+ weight = Double(pixels[i + 3])
+ }
+ total += weight
+ moment += weight * (Double(y) + 0.5)
+ }
+ }
+ guard total > 0 else { return nil }
+ // Row 0 of the drawn bitmap is the top of the view.
+ let scale = Double(h) / Double(size.height)
+ let centroid = moment / total / scale
+ return Ink(size: size, centroidOffset: CGFloat(centroid - Double(size.height) / 2))
+ }
+}
diff --git a/native/ios/Sources/CallsPhone.swift b/native/ios/Sources/CallsPhone.swift
index 43e0258..6e407e6 100644
--- a/native/ios/Sources/CallsPhone.swift
+++ b/native/ios/Sources/CallsPhone.swift
@@ -333,7 +333,7 @@ struct PhoneCall: View {
HStack(spacing: 14) {
Button(
call.muted ? "Unmute microphone" : "Mute microphone",
- systemImage: call.muted ? "mic.slash.fill" : "mic.fill"
+ glyph: call.muted ? "mic.slash.fill" : "mic.fill"
) { call.mute() }
// Space on a hardware keyboard (iPad, mostly). It is a
// UIKeyCommand without wantsPriorityOverSystemBehavior, so a
@@ -346,11 +346,11 @@ struct PhoneCall: View {
emphasized: call.muted))
Button(
call.cameraOff ? "Turn camera on" : "Turn camera off",
- systemImage: call.cameraOff ? "video.slash.fill" : "video.fill"
+ glyph: call.cameraOff ? "video.slash.fill" : "video.fill"
) { call.toggleCamera() }
.buttonStyle(CallActionStyle(color: .blue, emphasized: call.cameraOff))
if call.sharingScreen {
- Button("Stop sharing your screen", systemImage: "rectangle.on.rectangle.slash") {
+ Button("Stop sharing your screen", glyph: "rectangle.on.rectangle.slash") {
call.stopSharingScreen()
}
.buttonStyle(CallActionStyle(color: .orange, emphasized: true))
@@ -360,12 +360,12 @@ struct PhoneCall: View {
.disabled(call.state?.phase != "admitted")
.accessibilityIdentifier("shareScreen")
}
- Button("Devices", systemImage: "slider.horizontal.3") { showingDevices = true }
+ Button("Devices", glyph: "slider.horizontal.3") { showingDevices = true }
.buttonStyle(CallActionStyle(color: .teal))
.accessibilityIdentifier("devices")
MeetingShareButton(call: call).buttonStyle(CallActionStyle(color: .indigo))
Spacer(minLength: 0)
- Button(chatButtonTitle, systemImage: "bubble.left.and.bubble.right.fill") {
+ Button(chatButtonTitle, glyph: "bubble.left.and.bubble.right.fill") {
readChatCount = call.receivedChatCount
showingChat = true
}.buttonStyle(CallActionStyle(color: .purple)).accessibilityIdentifier("openChat")
@@ -375,7 +375,7 @@ struct PhoneCall: View {
.padding(4).background(.purple, in: Capsule()).accessibilityHidden(true)
}
}
- Button("Leave meeting", systemImage: "phone.down.fill", role: .destructive, action: leave)
+ Button("Leave meeting", glyph: "phone.down.fill", role: .destructive, action: leave)
.buttonStyle(CallActionStyle(color: .red, emphasized: true))
.accessibilityIdentifier("leave")
}.padding(16).background(.bar)
@@ -419,6 +419,9 @@ struct PhoneVideoTile: View {
var pinned = false
/// A filmstrip thumbnail: the symbol alone for a missing picture.
var iconOnly = false
+ /// The height of the name label and what is over it, which the
+ /// placeholder stays above.
+ @State private var labelBlock: CGFloat = 0
var body: some View {
TileAudioReader(audio) { info in tile(info) }
}
@@ -428,16 +431,11 @@ struct PhoneVideoTile: View {
if let track, !cameraOff, placeholder == nil {
PhoneVideo(track: track, fit: screen).scaleEffect(x: mirrored ? -1 : 1, y: 1)
} else {
- VStack(spacing: 6) {
- Image(
- systemName: cameraOff
- ? "video.slash" : screen ? "rectangle.on.rectangle" : "person.crop.circle"
- )
- .font(.largeTitle)
- if let placeholder, !cameraOff, !iconOnly {
- Text(placeholder).font(.caption).multilineTextAlignment(.center).padding(.horizontal, 6)
- }
- }.foregroundStyle(.white.opacity(0.8))
+ TilePlaceholder(
+ symbol: cameraOff
+ ? "video.slash" : screen ? "rectangle.on.rectangle" : "person.crop.circle",
+ words: cameraOff || iconOnly ? nil : placeholder, wordsFont: .caption, spacing: 6,
+ reserve: labelBlock)
}
}
.overlay(alignment: .bottomLeading) {
@@ -459,6 +457,10 @@ struct PhoneVideoTile: View {
}
}
.padding(compact ? 6 : 8).padding(compact ? labelInsets : EdgeInsets())
+ // Room past the label's end for the speaking glow, when a long
+ // name has it truncating.
+ .padding(.trailing, 6)
+ .tileLabelBlock($labelBlock)
}
.overlay(alignment: .topLeading) { if muted, !compact { MutedBadge().padding(8) } }
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
diff --git a/native/ios/Sources/PhoneFullScreenCall.swift b/native/ios/Sources/PhoneFullScreenCall.swift
index 046fffe..cfdb26f 100644
--- a/native/ios/Sources/PhoneFullScreenCall.swift
+++ b/native/ios/Sources/PhoneFullScreenCall.swift
@@ -338,8 +338,12 @@ extension PhoneCall {
Button {
showingMore = true
} label: {
- Label("More controls", systemImage: "ellipsis")
- .labelStyle(.iconOnly).rotationEffect(.degrees(90))
+ Label {
+ Text("More controls")
+ } icon: {
+ CallGlyph("ellipsis", rotation: .degrees(90))
+ }
+ .labelStyle(.iconOnly)
}
.buttonStyle(RoundCallButtonStyle(.normal))
.overlay(alignment: .topTrailing) {
@@ -353,14 +357,14 @@ extension PhoneCall {
if micBesideMenu {
Button(
call.muted ? "Unmute microphone" : "Mute microphone",
- systemImage: call.muted ? "mic.slash.fill" : "mic.fill"
+ glyph: call.muted ? "mic.slash.fill" : "mic.fill"
) { call.mute() }
.labelStyle(.iconOnly)
.buttonStyle(RoundCallButtonStyle(call.muted ? .off : .normal))
.accessibilityIdentifier("mute")
}
Spacer(minLength: 0)
- Button("End call", systemImage: "phone.down.fill", role: .destructive, action: leave)
+ Button("End call", glyph: "phone.down.fill", role: .destructive, action: leave)
.labelStyle(.iconOnly)
.buttonStyle(RoundCallButtonStyle(.leave))
.accessibilityIdentifier("leave")
@@ -385,6 +389,7 @@ struct RoundCallButtonStyle: ButtonStyle {
init(_ tone: CallOverlayButtonStyle.Tone) { self.tone = tone }
func makeBody(configuration: Configuration) -> some View {
configuration.label.font(.title2.weight(.semibold))
+ .modifier(CallGlyphSizing(.round))
.foregroundStyle(tone == .on ? CallOverlay.onSymbol : .white)
.frame(width: PhoneCall.button, height: PhoneCall.button)
.background {
@@ -434,7 +439,7 @@ struct MoreControls: View {
tile(call.muted ? "Unmute" : "Mute") {
Button(
call.muted ? "Unmute microphone" : "Mute microphone",
- systemImage: call.muted ? "mic.slash.fill" : "mic.fill"
+ glyph: call.muted ? "mic.slash.fill" : "mic.fill"
) { call.mute() }
.buttonStyle(
CallActionStyle(
@@ -445,13 +450,13 @@ struct MoreControls: View {
tile(call.cameraOff ? "Camera on" : "Camera off") {
Button(
call.cameraOff ? "Turn camera on" : "Turn camera off",
- systemImage: call.cameraOff ? "video.slash.fill" : "video.fill"
+ glyph: call.cameraOff ? "video.slash.fill" : "video.fill"
) { call.toggleCamera() }
.buttonStyle(CallActionStyle(color: .blue, emphasized: call.cameraOff))
.accessibilityIdentifier("camera")
}
tile("Flip") {
- Button("Flip camera", systemImage: "arrow.triangle.2.circlepath.camera") {
+ Button("Flip camera", glyph: "arrow.triangle.2.circlepath.camera") {
if let next = CameraFlip.next(from: call.currentCamera, in: call.cameras) {
Task { await call.useCamera(next.id) }
}
@@ -462,7 +467,7 @@ struct MoreControls: View {
}
tile(call.sharingScreen ? "Stop sharing" : "Share screen") {
if call.sharingScreen {
- Button("Stop sharing your screen", systemImage: "rectangle.on.rectangle.slash") {
+ Button("Stop sharing your screen", glyph: "rectangle.on.rectangle.slash") {
call.stopSharingScreen()
}
.buttonStyle(CallActionStyle(color: .orange, emphasized: true))
@@ -474,12 +479,12 @@ struct MoreControls: View {
}
}
tile("Devices") {
- Button("Devices", systemImage: "slider.horizontal.3") { open(.devices) }
+ Button("Devices", glyph: "slider.horizontal.3") { open(.devices) }
.buttonStyle(CallActionStyle(color: .teal))
.accessibilityIdentifier("devices")
}
tile("Mic Mode") {
- Button("Mic Mode", systemImage: "waveform") { micModes.showPicker() }
+ Button("Mic Mode", glyph: "waveform") { micModes.showPicker() }
.buttonStyle(CallActionStyle(color: .teal))
.accessibilityIdentifier("micModeQuick")
.accessibilityHint(micModes.summary)
@@ -488,7 +493,11 @@ struct MoreControls: View {
Menu {
LayoutChoices(call: call, arrangement: arrangement, title: title)
} label: {
- Label("Video layout", systemImage: arrangement.layout.symbol)
+ Label {
+ Text("Video layout")
+ } icon: {
+ CallGlyph(arrangement.layout.symbol)
+ }
}
.buttonStyle(CallActionStyle(color: .gray))
.accessibilityIdentifier("videoLayout")
@@ -499,7 +508,7 @@ struct MoreControls: View {
}
}
tile(unread > 0 ? "Chat (\(unread))" : "Chat") {
- Button(unread == 0 ? "Chat" : "Chat (\(unread) new)", systemImage: "bubble.left.and.bubble.right.fill") {
+ Button(unread == 0 ? "Chat" : "Chat (\(unread) new)", glyph: "bubble.left.and.bubble.right.fill") {
open(.chat)
}
.buttonStyle(CallActionStyle(color: .purple, emphasized: unread > 0))
diff --git a/test/browser/call.mjs b/test/browser/call.mjs
index 6e9d448..eb3b5ff 100644
--- a/test/browser/call.mjs
+++ b/test/browser/call.mjs
@@ -31,7 +31,8 @@
// candidates are the only ones there are, and an obfuscated one connects
// to nobody; and --auto-accept-this-tab-capture answers getDisplayMedia
// with no picker, which the fake-ui flag does not cover (§5.4).
-import { writeSync, mkdirSync } from 'node:fs';
+import { writeSync, mkdirSync, writeFileSync } from 'node:fs';
+import { inflateSync } from 'node:zlib';
import { createInterface } from 'node:readline';
import { chromium } from 'playwright';
@@ -1267,6 +1268,80 @@ function mediaLive(page) {
return page.evaluate(() => ((document.getElementById('media-live') || {}).textContent || '').trim());
}
+// ── ink ───────────────────────────────────────────────────────────────
+//
+// Where a glyph's ink is, read off a screenshot the way Paul read the
+// Mac bar's (0.4.1): the centroid of the pixels brighter than the pane
+// under them, weighted by how much brighter. The PNG is Chromium's own
+// (8-bit, RGB or RGBA, not interlaced), so a decoder this small reads it.
+
+function decodePNG(buf) {
+ let at = 8;
+ let width = 0, height = 0, type = 0;
+ const idat = [];
+ while (at < buf.length) {
+ const len = buf.readUInt32BE(at);
+ const kind = buf.toString('latin1', at + 4, at + 8);
+ const body = buf.subarray(at + 8, at + 8 + len);
+ if (kind === 'IHDR') {
+ width = body.readUInt32BE(0);
+ height = body.readUInt32BE(4);
+ if (body[8] !== 8 || body[12] !== 0) throw new Error('decodePNG: not 8-bit non-interlaced');
+ type = body[9];
+ } else if (kind === 'IDAT') idat.push(body);
+ at += 12 + len;
+ }
+ const bpp = type === 6 ? 4 : type === 2 ? 3 : 0;
+ if (!bpp) throw new Error('decodePNG: colour type ' + type);
+ const raw = inflateSync(Buffer.concat(idat));
+ const stride = width * bpp;
+ const px = Buffer.alloc(height * stride);
+ for (let y = 0; y < height; y++) {
+ const f = raw[y * (stride + 1)];
+ const row = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1));
+ for (let x = 0; x < stride; x++) {
+ const a = x >= bpp ? px[y * stride + x - bpp] : 0;
+ const b = y > 0 ? px[(y - 1) * stride + x] : 0;
+ const c = x >= bpp && y > 0 ? px[(y - 1) * stride + x - bpp] : 0;
+ let v = row[x];
+ if (f === 1) v += a;
+ else if (f === 2) v += b;
+ else if (f === 3) v += (a + b) >> 1;
+ else if (f === 4) {
+ const p = a + b - c, pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
+ v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
+ }
+ px[y * stride + x] = v & 255;
+ }
+ }
+ const lum = (x, y) => {
+ const i = y * stride + x * bpp;
+ return 0.2126 * px[i] + 0.7152 * px[i + 1] + 0.0722 * px[i + 2];
+ };
+ return { width, height, lum };
+}
+
+// inkCentroid screenshots `box` (page CSS pixels, at the page's own
+// device scale) and returns the ink's vertical centroid in page CSS
+// pixels, or null if there was no ink.
+async function inkCentroid(page, box, scale = 1) {
+ const pad = 2;
+ const clip = { x: box.x - pad, y: box.y - pad, width: box.width + 2 * pad, height: box.height + 2 * pad };
+ const img = decodePNG(await page.screenshot({ clip }));
+ let floor = 255;
+ for (let y = 0; y < img.height; y++) for (let x = 0; x < img.width; x++) floor = Math.min(floor, img.lum(x, y));
+ let total = 0, moment = 0;
+ for (let y = 0; y < img.height; y++) {
+ for (let x = 0; x < img.width; x++) {
+ const w = Math.max(0, img.lum(x, y) - floor - 8);
+ total += w;
+ moment += w * (y + 0.5);
+ }
+ }
+ if (!total) return null;
+ return clip.y + moment / total / scale;
+}
+
// ── the scenarios ────────────────────────────────────────────────────
const run = {
@@ -4630,6 +4705,102 @@ const run = {
await noMedia.close();
},
+ // The control bar's glyphs and the filmstrip's labels (0.4.1). Paul
+ // measured the Mac bar's glyphs and found the share symbol low; the
+ // web bar is held to the same rule: every glyph's ink centred on the
+ // bar's centre line, within half a pixel. And a filmstrip tile's
+ // caption stays inside its tile, its muted mark visible, with the
+ // camera-off cover's words never under it, down to a 320 px screen.
+ // CALLS_BAR_SNAPSHOTS=<dir> keeps pictures and the measurements.
+ async glyphs() {
+ const out = process.env.CALLS_BAR_SNAPSHOTS;
+ if (out) mkdirSync(out, { recursive: true });
+ const report = [];
+ const ctxA = await newContext('glyphs A', { cookie: cookieA });
+ const ctxB = await newContext('glyphs B', { cookie: cookieB });
+ const pageA = await openRoom(ctxA);
+ const pageB = await openRoom(ctxB);
+ const pair = await assertPair(pageA, pageB, 'glyphs A', 'glyphs B');
+ if (!pair) throw new Error('glyphs call did not connect');
+ const b = pair.b.peer;
+ // B mutes and turns the camera off, so B's tile carries both marks.
+ await pageB.locator('#mute').click();
+ await pageB.locator('#camera').click();
+ const tileB = pageA.locator('#tiles > figure[data-tile="' + b + '"]');
+ check(await until(async () => await tileB.locator('.calls-tile-muted').isVisible() &&
+ await tileB.locator('.calls-tile-off').isVisible(), 10000), 'B shows muted and camera off on A');
+ await pageA.emulateMedia({ reducedMotion: 'reduce' });
+ await pageA.locator('#layout-speaker').click();
+ // Pin yourself, so B is in the filmstrip.
+ await pageA.locator('#tiles > figure[data-tile="self"] .calls-tool-pin').click({ force: true });
+ check(await until(async () => (await pageA.locator('#tiles > [data-stage]').getAttribute('data-tile')) === 'self', 3000),
+ 'A pinned to the stage, B in the filmstrip');
+ await pageA.mouse.move(1, 1);
+
+ for (const width of [1280, 760]) {
+ await pageA.setViewportSize({ width, height: 800 });
+ await sleep(300);
+ await pageA.locator('#controls').scrollIntoViewIfNeeded();
+ const bar = await pageA.locator('#controls').boundingBox();
+ // The centre line of the row a button is in: the bar's rows
+ // stretch their buttons to one height, so it is the button's own
+ // centre (and the bar's, while the bar is one row).
+ const glyphs = await pageA.locator('#controls button:visible svg.icon').evaluateAll((svgs) => svgs.map((svg) => {
+ const r = svg.getBoundingClientRect();
+ const row = svg.closest('button').getBoundingClientRect();
+ return { id: svg.closest('button').id || 'share-link', x: r.x, y: r.y, width: r.width, height: r.height,
+ centre: row.y + row.height / 2 };
+ }));
+ check(glyphs.length >= 5, width + ' px: the bar has its glyphs (' + glyphs.length + ')');
+ for (const g of glyphs) {
+ const ink = await inkCentroid(pageA, g);
+ const centre = g.centre;
+ const off = ink === null ? NaN : ink - centre;
+ report.push({ width, glyph: g.id, inkCentroid: ink, rowCentre: centre, barCentre: bar.y + bar.height / 2, offset: off });
+ check(Math.abs(off) <= 0.5, width + ' px: ' + g.id + "'s ink is on the bar's centre line (" +
+ (off >= 0 ? '+' : '') + off.toFixed(2) + ' px)');
+ }
+ if (out) await pageA.screenshot({ path: out + '/web-bar-' + width + '.png', clip: bar });
+ }
+
+ // A name as long as a real one can be, so a narrow tile has to
+ // truncate it.
+ await tileB.locator('figcaption > span').first().evaluate((n) => { n.textContent = 'Ana García Fernández'; });
+ for (const width of [1280, 760, 390, 320]) {
+ await pageA.setViewportSize({ width, height: width < 500 ? 844 : 800 });
+ await sleep(300);
+ const t = await tileB.evaluate((fig) => {
+ const box = (el) => { const r = el.getBoundingClientRect(); return { top: r.top, bottom: r.bottom, left: r.left, right: r.right }; };
+ const cap = fig.querySelector('figcaption');
+ const name = cap.querySelector('span');
+ const muted = fig.querySelector('.calls-tile-muted');
+ const mark = muted.querySelector('svg');
+ const off = fig.querySelector('.calls-tile-off');
+ const ink = Array.from(off.children).filter((c) => getComputedStyle(c).display !== 'none' && c.getClientRects().length);
+ return {
+ tile: box(fig), caption: box(cap), mark: mark ? box(mark) : null,
+ markShown: !!mark && getComputedStyle(mark).display !== 'none' && mark.getBoundingClientRect().width > 0,
+ nameFits: name.scrollWidth <= name.clientWidth + 1 || getComputedStyle(name).textOverflow === 'ellipsis',
+ cover: ink.map(box),
+ label: fig.getAttribute('aria-label') || '',
+ mutedText: muted.textContent.trim(),
+ };
+ });
+ report.push({ width, filmstripTile: t });
+ check(t.caption.right <= t.tile.right + 0.5 && t.caption.left >= t.tile.left - 0.5,
+ width + ' px: the caption stays inside the filmstrip tile');
+ check(t.markShown && t.mark.right <= t.tile.right, width + ' px: the muted mark is visible');
+ check(t.nameFits, width + ' px: the name fits or ends in an ellipsis');
+ check(t.cover.every((c) => c.bottom <= t.caption.top), width + " px: the camera-off cover is clear of the caption (" +
+ JSON.stringify(t.cover.map((c) => Math.round(c.bottom))) + ' vs ' + Math.round(t.caption.top) + ')');
+ check(/muted/i.test(t.mutedText), width + ' px: "Muted" is still in the tile for assistive technology');
+ if (out) await pageA.screenshot({ path: out + '/web-filmstrip-' + width + '.png', fullPage: true });
+ }
+ if (out) writeFileSync(out + '/web-measurements.json', JSON.stringify(report, null, 2));
+ await ctxA.close();
+ await ctxB.close();
+ },
+
async scriptless() {
const ctx = await newContext('scriptless', { cookie: cookieA, javaScriptEnabled: false });
const sentence = 'The call needs JavaScript to reach your camera and the other participants; everything else on this page works without it.';