| 1 | # Calls native design |
| 2 | |
| 3 | The first Oficina native design pass uses Calls' existing teal identity, |
| 4 | quiet surfaces, clear type hierarchy and native controls. Apple's communication |
| 5 | and productivity apps are references for interaction quality; Oficina retains |
| 6 | its own artwork, wording and colour. Mac and phone share the joining ceremony |
| 7 | while their call layouts suit the available space. |
| 8 | |
| 9 | ## First pass |
| 10 | |
| 11 | - `CallsLobby` renders welcome, browser approval, ready-to-join and error states |
| 12 | on Mac, iPhone and iPad. The confirmation code and browser approval remain |
| 13 | part of the existing authentication flow. Signed out, the welcome is the |
| 14 | server's name, one prominent **Sign in** (the suite's label, exactly) and a |
| 15 | secondary **Join a call as a guest**; there is no address field until the |
| 16 | person is signed in. A link waiting on a sign-in names the server it is on. |
| 17 | - `GuestJoinView` (a sheet on both platforms) is one invite-link field with a |
| 18 | Paste control, inline plain-words validation, Join and Cancel. |
| 19 | - `ServerSettingsSection` is the **Calls server** setting: a section in the Mac's |
| 20 | Settings scene after the mini-window settings, and the whole of the iPhone's |
| 21 | settings sheet, reached by the home screen's gear. |
| 22 | - `MacCallView` defaults to a remote participant filling the stage with a local |
| 23 | thumbnail. Speaker/Grid switches layouts (Focus until 0.4.0; see below); a |
| 24 | pin menu chooses what is on the stage in group calls. Video crops to fill while preserving its aspect ratio. Chat |
| 25 | starts hidden and can open as a sidebar or a separate window; its draft survives |
| 26 | moving and closing. The compact, coloured icon controls remain below the stage. On iPhone, chat remains a sheet and controls remain |
| 27 | in the bottom safe area. |
| 28 | - Video names sit on an opaque dark label over the image. Microphone and camera |
| 29 | actions use both text and changing symbols, so colour is never the only cue. |
| 30 | - `CallsAppearance` and `CallControl` live in CallsKit for both Apple clients. |
| 31 | These are Calls presentation choices. General native infrastructure belongs |
| 32 | in Rastrillo Native; Oficina's shared visual components belong in Estilo as |
| 33 | subsequent apps establish what they need in common. |
| 34 | |
| 35 | ## Visual review |
| 36 | |
| 37 | After `swift build --package-path native/apple`, render the shipping lobby: |
| 38 | |
| 39 | ```sh |
| 40 | native/apple/.build/debug/CallsInterop --lobby-snapshots native/build/polish |
| 41 | ``` |
| 42 | |
| 43 | This generates light and dark welcome, approval, home, ready, error, expired, |
| 44 | link-on-another-server and visiting fixtures, the signed-out home and the |
| 45 | settings sheet at iPhone width (`phone-*`), the guest screen empty and with an |
| 46 | invalid link (`guest-*`), and the Mac Settings window (`settings-*`), without a |
| 47 | server, real account or device permissions. Models read the server from a |
| 48 | throwaway defaults suite. The off-screen window is never key, so prominent |
| 49 | buttons draw untinted in these fixtures. The fixture entry point |
| 50 | is only in CallsInterop, never the application. |
| 51 | |
| 52 | `TestNativeMacChat` also captures `native/build/call-focus.png`, `call-chat.png` and `call-grid.png` using the |
| 53 | shipping Mac call view and messages received from a real browser participant. |
| 54 | The off-screen bitmap capture does not capture Metal video frames; media |
| 55 | continuity is verified separately by the existing decoded-frame/audio counters. |
| 56 | |
| 57 | The first pass was checked with Mac and iOS Simulator builds, the iPhone |
| 58 | invalid-link UI test, and native/browser authentication, media, chat and sharing |
| 59 | checks. Lobby review includes light/dark Mac states and iPhone at standard and |
| 60 | accessibility text sizes. Physical-device call controls, VoiceOver, camera and |
| 61 | microphone behaviour still need hands-on acceptance. |
| 62 | |
| 63 | ## Next iterations |
| 64 | |
| 65 | Camera preview and device selection before joining, remote camera-off signalling, |
| 66 | automatic speaker selection, and keyboard commands are follow-up product work. |
| 67 | Those should be designed alongside the relevant media and lifecycle behaviour. |
| 68 | |
| 69 | Run `swift test --package-path native/apple` for chat-window lifecycle and draft |
| 70 | retention checks. Window closure never invokes the meeting leave action. |
| 71 | |
| 72 | ## iPhone and iPad call treatment · 2026-09-12 |
| 73 | |
| 74 | The iOS call stage now defaults to remote focus with a local thumbnail, offers |
| 75 | Focus/Grid and participant selection, and uses the same compact colored icon |
| 76 | control vocabulary. Chat is a native sheet with unread count and a retained |
| 77 | draft. iOS continues to end calls when backgrounded, except while sharing the screen; |
| 78 | general background-call continuity remains follow-up media work. |
| 79 | |
| 80 | Safari member/guest sign-in and the real iPhone simulator native-media tests |
| 81 | passed after this change. The signed development app was built and installed on |
| 82 | Paul's paired iPhone. That installation does not substitute for a physical |
| 83 | camera/microphone and interruption acceptance call. |
| 84 | |
| 85 | ## Full-window call · 2026-09-25 (0.3.4) |
| 86 | |
| 87 | The call fills the window (Mac) or the screen (iPhone), with the controls over |
| 88 | it on demand. **Call layout: Full window (new) / Classic** — Calls → Settings… |
| 89 | on Mac, the gear's sheet on iPhone — keeps the previous layout one choice away; |
| 90 | Full window is the default. The mini window and tab are unchanged. |
| 91 | |
| 92 | - **When the controls show** is `ControlsVisibility` (CallChrome.swift), one |
| 93 | state machine for both apps with the time passed in, so tests drive it. Mac: |
| 94 | pointer movement shows them, 3 s still hides them (`pointerDelay`; 1.5 s in |
| 95 | 0.3.4 was too quick), leaving the window hides them 1.5 s later |
| 96 | (`pointerExitDelay`, never later than they were due anyway), and the pointer |
| 97 | hides with them over the picture (`NSCursor.setHiddenUntilMouseMoves`). |
| 98 | iPhone: a tap toggles, 3 s after the last touch hides (`touchDelay`). They stay up while the pointer is on the |
| 99 | bar, a menu, popover, sheet or panel is open (AppKit's menu-tracking and |
| 100 | window-sheet notifications cover the ones SwiftUI does not report), a bar |
| 101 | control has keyboard focus, VoiceOver is running, or an alert shows. Tab |
| 102 | reveals them. Space and ⌘⇧M work with them hidden. Fades are opacity only, |
| 103 | eased out: 0.35 s going, 0.2 s coming back; Reduce Motion shortens them to a |
| 104 | 0.1 s opacity change and panels fade instead of sliding. |
| 105 | - **The stage** is edge to edge: square tiles for Focus and a screen, a grid |
| 106 | that fits every tile without scrolling until they would be under 200 pt |
| 107 | (`GridFit`). On Mac there is no title bar while the call is full window |
| 108 | (0.3.5): the picture goes to the window's top edge, the traffic lights sit |
| 109 | over it and fade with the controls (not in full screen, where the system |
| 110 | shows them), a shade behind them keeps them legible, and a 32 pt strip |
| 111 | across the top drags the window and double-clicks as the person's title-bar |
| 112 | setting says (`WindowDragStrip`, `TitleBarDoubleClick`). Since 0.3.7 the |
| 113 | picture does too, as in FaceTime (`WindowMove`): a drag anywhere on the |
| 114 | stage that is not a control, a panel or the self view moves the window |
| 115 | once it has gone 4 pt (less is a click, which shows the controls), and a |
| 116 | double click there does what the title bar's would. Resizing is AppKit's |
| 117 | own, from every edge and corner, the top included. The title pill sits |
| 118 | to the right of the traffic lights and the notices in a column beneath it. |
| 119 | `CallWindowChrome` sets the window's title bar, watches it so SwiftUI cannot |
| 120 | quietly restore it mid-call, and puts it back for the lobby and Classic. |
| 121 | Names are small capsules with the muted mark (shape and word) inside, lifted |
| 122 | clear of the bar while it shows, visible while it does not. |
| 123 | - **The self view** floats in Focus, draggable, settling in the corner it was |
| 124 | flicked towards (`PiPLayout`), kept clear of the bar, an open chat and the |
| 125 | camera strip beside a shared screen, and remembered. It always says Muted |
| 126 | when you are. VoiceOver has "Move self view to …" actions. In Grid you are a |
| 127 | tile, as before. |
| 128 | - **Title and notices.** Title and status go with the controls. Reconnecting, |
| 129 | waiting to be let in, full, ended (with Join again), problems (dismissible), |
| 130 | "You are sharing your screen" and each person waiting with **Let in** stay |
| 131 | until resolved (`CallNotices`); the relay-floor line fades. |
| 132 | - **Over the picture** (0.3.5): one dark system, whatever the system |
| 133 | appearance, because what is underneath is video (`CallOverlay`, |
| 134 | `CallOverlaySurface`, `CallOverlayButtonStyle`). Every surface — the bar, |
| 135 | the title pill, notices, name labels, the iPhone's round buttons — is black |
| 136 | at 70 % over a dark blur with a hairline edge; the black alone holds the |
| 137 | contrast, so white on it is at least 8.5:1 even over a white wall. Buttons |
| 138 | are white symbols on a light fill (5.5:1 worst case); a control that is on |
| 139 | (sharing, a panel open) is near-white with a dark symbol; microphone or |
| 140 | camera off is amber with the slashed symbol; Leave is a deeper red than the |
| 141 | system's (5.0:1 for its symbol). `CallOverlayContrastTests` holds the ratios |
| 142 | over white, black and grey. Classic keeps its own look. |
| 143 | - **Mac bar**: a dark bar along the bottom — microphone, camera, share, |
| 144 | devices, layout (Focus/Grid and who is on the stage, in a popover), share link |
| 145 | — with chat and Leave on the right. Devices is a floating panel over the |
| 146 | stage above the bar, not a column that shrinks it. |
| 147 | - **Mac chat** is an overlay sidebar sliding over the stage's trailing edge, |
| 148 | with the existing pop-out to a separate window kept. Chosen over the window |
| 149 | alone because the window is window management mid-call on a laptop screen, |
| 150 | while the sidebar is one click and leaves the picture its full size underneath; |
| 151 | anyone who wants it beside the call still has the window. |
| 152 | - **iPhone bar**: a kebab (More controls) on the left and End call on the right. |
| 153 | The kebab opens a sheet: mute, camera, flip camera, share screen (the system |
| 154 | broadcast picker, which is why it is a sheet and not a menu), devices, Mic |
| 155 | Mode, layout, share link, chat and Reduce background noise. The status bar and |
| 156 | home indicator hide with the controls. `-CallsMicBesideMenu YES` puts a |
| 157 | microphone button beside the kebab, off by default. |
| 158 | |
| 159 | Snapshots: `CallsInterop --call-snapshots <dir>` (also part of |
| 160 | `--lobby-snapshots`) renders the Mac call, both layouts, light and dark. The |
| 161 | iPhone's come from `CallsPhoneUITests.testFullScreenCallSnapshots` against the |
| 162 | debug build's `-CallsCallFixture` calls (`CallFixture`), written to |
| 163 | `CALLS_SNAPSHOT_DIR` or `/tmp/immersive-call`. |
| 164 | |
| 165 | ## Glass and the speaking glow · 2026-09-25 (0.3.7) |
| 166 | |
| 167 | Supersedes the hairline edges and button tiles of 0.3.5's **Over the picture**. |
| 168 | |
| 169 | - **One glass language, no hairlines.** Every surface over the picture — the |
| 170 | bar, the title pill, notices, name labels, the iPhone's round buttons and the |
| 171 | mini window's tab — is borderless glass: Liquid Glass on macOS 26 and iOS 26, |
| 172 | the ultra-thin material before, with black at 62 % on it |
| 173 | (`CallOverlay.scrim`). Rendered over white the glass measures #5B5B5B, so |
| 174 | white on it is 6.8:1 (6.2:1 from the black alone). The environment value |
| 175 | `callOverlayGlass` turns the glass off to show the fallback. |
| 176 | - **Buttons are glyphs on the pane**: white, a light wash under the pointer, |
| 177 | and a capsule only for a state — near-white for on (sharing, a panel open), |
| 178 | amber with the slashed symbol for microphone or camera off, red for Leave. |
| 179 | - **Devices** over the picture is opaque and dark (`CallOverlay.panelFill`) |
| 180 | with the dark appearance's labels: 16.7:1 primary and 5.1:1 secondary as |
| 181 | rendered. It holds the controls up while open and never fades with them. |
| 182 | - **The speaking glow** replaces the white ring round a tile and the meter |
| 183 | beside the name, on Mac, iPhone, web and the mini window's tab: the accent's |
| 184 | hue lit up as a halo behind the name label, bright as the voice |
| 185 | (`SpeakingGlow`, and `judgeGlow`/`glowStyle` in room.js). The halo redraws |
| 186 | on a timeline only until it arrives. "No sound from …" and the microphone |
| 187 | warnings stay as words by the name; the Devices panel keeps a compact meter. |
| 188 | *The teal halo is superseded by the warm rim (0.4.3, below).* |
| 189 | |
| 190 | Snapshots: `CallsInterop --glass-snapshots <dir>` renders real windows below |
| 191 | the desktop and captures them with `screencapture -l` (an off-screen bitmap |
| 192 | draws no Liquid Glass); the iPhone's are |
| 193 | `CallsPhoneUITests.testGlowAndGlassSnapshots` (`-CallsFixtureSpeaking`, |
| 194 | `-CallsFixtureNoGlass`). |
| 195 | |
| 196 | |
| 197 | ## The warm rim · 2026-09-28 (0.4.3) |
| 198 | |
| 199 | Supersedes the teal halo of 0.3.7's **speaking glow**. After three rounds of |
| 200 | HTML mock-ups (`speaking-indicator-options/`, served at |
| 201 | calls-led-options.bds.oncarlos.com) Paul chose **T5, "Warm soft breath"**, and |
| 202 | it is drawn exactly as that mock-up's box-shadow recipe, on Mac, iPhone, web |
| 203 | and the mini window's tab (`WarmRim`, `TileSpeakingName`; `glowStyle` and |
| 204 | `.calls-tile-name::after` on the web). |
| 205 | |
| 206 | Over the name's own glass and in its shape, where `on` is 0 or 1 and `lvl` |
| 207 | the smoothed level 0…1 (-42 to -12 dBFS, no floor any more): |
| 208 | |
| 209 | | Layer | Drawn | Colour | Opacity | |
| 210 | | --- | --- | --- | --- | |
| 211 | | Rim | 1 pt inside the edge | hsl(38°, 80 %, 82 %) | on × (0.10 + lvl × 0.45) | |
| 212 | | Top highlight | none in T5 | — | 0 | |
| 213 | | Inner light | inset blur 3 + lvl × 6 pt | hsl(38°, 80 %, 72 %) | on × (0.06 + lvl × 0.30) | |
| 214 | | Outer breath | blur 1 + lvl × 7 pt, outside only | hsl(38°, 80 %, 66 %) | on × lvl × 0.25 | |
| 215 | |
| 216 | Blurs are CSS box-shadow radii; SwiftUI draws them at half (its radius is the |
| 217 | Gaussian's deviation). The timing is 0.3.7's: lit once speech has kept up |
| 218 | 0.15 s, 50 ms attack, 300 ms release. Once no longer lit, `on` holds until the |
| 219 | level has fallen under 0.05 (`SpeakingGlow.outBelow`; on the web `--on` waits |
| 220 | out the release), as the mock-up's does, so the rim fades rather than |
| 221 | vanishing. Reduce Motion: a steady lvl of 0.6 while lit, and nothing moves. |
| 222 | Only the overlay observes the level, and it redraws only until it arrives. |
| 223 | |
| 224 | Snapshots: `CallsInterop --glass-snapshots <dir>` writes `mac-rim-<level>.png` |
| 225 | (`WarmRimGallery`: the mock-up's dark and bright tiles, white, and the busy |
| 226 | frame); the iPhone's are `CallsPhoneUITests.testWarmRimSnapshots` |
| 227 | (`-CallsFixtureRimGallery <level>`). |
| 228 | |
| 229 | ## Speaker view and your own order · 2026-09-27 (0.4.0) |
| 230 | |
| 231 | Paul asked whether he could rearrange the people in Grid, and for a Speaker |
| 232 | view: the speaker in the main window, everyone else underneath. |
| 233 | |
| 234 | - **Two layouts: Speaker and Grid.** Speaker replaced Focus. Focus was "one |
| 235 | person on the stage, chosen by hand", which is Speaker with that person |
| 236 | pinned, so keeping both would have been two names for one thing with a |
| 237 | worse default. Choosing a pin (from the layout menu, a tile's menu or a |
| 238 | click on a filmstrip tile) switches to Speaker. The choice of layout is now |
| 239 | kept (`VideoLayout.key`, `videoLayout` in UserDefaults; `calls.videoLayout` |
| 240 | in the browser's localStorage), and a kept `focus` reads as Speaker. |
| 241 | Nothing had kept one before 0.4.0 — every call opened in Focus — so the |
| 242 | migration is a rule in `VideoLayout.stored`/`storedLayout` rather than data |
| 243 | to move. The iPhone's ⋯ sheet says Speaker/Grid and "On the stage". |
| 244 | - **The stage** is, in order: what you pinned while it is there; otherwise the |
| 245 | newest shared screen (as before, so a new screen takes the stage unless you |
| 246 | have pinned something); otherwise whoever has the floor; otherwise the first |
| 247 | other person in your order (`Stage.speaker`). You are on it only if you pin |
| 248 | yourself. |
| 249 | - **Who has the floor** is `ActiveSpeaker`, fed five times a second from the |
| 250 | per-peer levels and speaking gate the glow already uses. Someone takes the |
| 251 | stage once they have been the loudest speaker for `dominance` (1.3 s), and |
| 252 | whoever is on it keeps it for at least `minimumHold` (3 s). A pause keeps |
| 253 | the speaker; an interjection shorter than 1.3 s never takes it; two people |
| 254 | trading sentences faster than that do not make it flap; someone who leaves |
| 255 | loses it at once. You are never the speaker. room.js's `speakerStep` is |
| 256 | the same rule with the same constants (`SPEAKER_DOMINANCE`, `SPEAKER_HOLD`), |
| 257 | and `room-layout.test.mjs` holds them equal. |
| 258 | - **The filmstrip** holds everything not on the stage — other screens first, |
| 259 | then everyone in your order, you included. On the Mac it runs along the |
| 260 | bottom of the window, tiles a sixth of the window high (72–120 pt; five fit |
| 261 | across the smallest window, 760 × 540, and more scroll sideways), and rises |
| 262 | clear of the bar while the controls show, as the self view does; with the |
| 263 | controls hidden nothing lies over the stage. On iPhone it is a sideways |
| 264 | scroller of upright 3:4 tiles above the home indicator (4:3 in landscape), |
| 265 | rising with the bar in the same way. Tiles in it keep the glass name |
| 266 | labels, the Muted mark and the speaking glow. |
| 267 | - **You in the filmstrip, not floating** — except in a call of two. The |
| 268 | order is one list shared by Grid and the filmstrip, and you are in it |
| 269 | like anyone else, so you can put yourself first or last; a floating self |
| 270 | view would have been the one tile that ignored the order. In a call of |
| 271 | two the filmstrip would hold nobody but you, so there you float in the |
| 272 | corner exactly as since 0.3.4 (`Stage.selfFloats`), and the full-window |
| 273 | look of the commonest call is unchanged. |
| 274 | - **Pinning.** A click or tap on a filmstrip tile, or Pin in a tile's menu |
| 275 | (Mac context menu) or its accessibility actions, pins it. A pinned stage |
| 276 | carries a **Pinned** badge; pressing it, or Unpin, goes back to following |
| 277 | the speaker. On the Mac a single click on a filmstrip tile pins once the |
| 278 | double-click interval has passed (a double click zooms the window, as on |
| 279 | the picture). |
| 280 | - **Your own order.** Drag a tile onto another's place — in Grid or in the |
| 281 | filmstrip — and the others make room; the dragged tile lifts (larger, with |
| 282 | a shadow and the accent edge) and follows the pointer. On iPhone and iPad a |
| 283 | long press picks it up. With Reduce Motion nothing grows or springs. Every |
| 284 | platform has a way without dragging: the Mac's tile context menu, and on |
| 285 | every platform the tile's accessibility actions (Pin/Unpin, Move earlier, |
| 286 | Move later, Move to first); the browser gives each tile those as buttons. |
| 287 | The order is yours alone and never sent. It is kept per meeting for the |
| 288 | rest of the app's run (`ParticipantOrders`; the browser's sessionStorage, |
| 289 | `calls.order.<meeting>`), under keys that survive a rejoin — "self" and the |
| 290 | person's name, since a peer id is new on every join. New arrivals go on the |
| 291 | end; someone who leaves is removed; a remembered person who comes back |
| 292 | takes their old place (`ParticipantOrder`). |
| 293 | - **VoiceOver** hears the stage tile as "On stage: Tony" (", pinned" when |
| 294 | it is), and a pin, an unpin or a move is announced once, politely. An |
| 295 | automatic change of speaker is not announced: in a lively call it would |
| 296 | talk over the talking. |
| 297 | - **The mini window** shows Speaker view's stage, whichever layout the call |
| 298 | window is in. |
| 299 | |
| 300 | Snapshots: `CallsInterop --speaker-snapshots <dir>` (Mac, real windows: the |
| 301 | filmstrip with the controls up and down and at 760 × 540, a pin, a shared |
| 302 | screen, a drag half way in Speaker and in Grid, Grid after a reorder, and a |
| 303 | call of two); `CallsPhoneUITests.testSpeakerViewSnapshots` (iPhone, portrait |
| 304 | and landscape, with `-CallsFixtureLayout`, `-CallsFixtureSpeaker`, |
| 305 | `-CallsFixturePinned`, `-CallsFixtureLift` and `-CallsFixtureOrder`); and the |
| 306 | browser gate's `speaker` scenario with `CALLS_SPEAKER_SNAPSHOTS=<dir>`. |
| 307 | |