# Calls native design

The first Oficina native design pass uses Calls' existing teal identity,
quiet surfaces, clear type hierarchy and native controls. Apple's communication
and productivity apps are references for interaction quality; Oficina retains
its own artwork, wording and colour. Mac and phone share the joining ceremony
while their call layouts suit the available space.

## First pass

- `CallsLobby` renders welcome, browser approval, ready-to-join and error states
  on Mac, iPhone and iPad. The confirmation code and browser approval remain
  part of the existing authentication flow. Signed out, the welcome is the
  server's name, one prominent **Sign in** (the suite's label, exactly) and a
  secondary **Join a call as a guest**; there is no address field until the
  person is signed in. A link waiting on a sign-in names the server it is on.
- `GuestJoinView` (a sheet on both platforms) is one invite-link field with a
  Paste control, inline plain-words validation, Join and Cancel.
- `ServerSettingsSection` is the **Calls server** setting: a section in the Mac's
  Settings scene after the mini-window settings, and the whole of the iPhone's
  settings sheet, reached by the home screen's gear.
- `MacCallView` defaults to a remote participant filling the stage with a local
  thumbnail. Speaker/Grid switches layouts (Focus until 0.4.0; see below); a
  pin menu chooses what is on the stage in group calls. Video crops to fill while preserving its aspect ratio. Chat
  starts hidden and can open as a sidebar or a separate window; its draft survives
  moving and closing. The compact, coloured icon controls remain below the stage. On iPhone, chat remains a sheet and controls remain
  in the bottom safe area.
- Video names sit on an opaque dark label over the image. Microphone and camera
  actions use both text and changing symbols, so colour is never the only cue.
- `CallsAppearance` and `CallControl` live in CallsKit for both Apple clients.
  These are Calls presentation choices. General native infrastructure belongs
  in Rastrillo Native; Oficina's shared visual components belong in Estilo as
  subsequent apps establish what they need in common.

## Visual review

After `swift build --package-path native/apple`, render the shipping lobby:

```sh
native/apple/.build/debug/CallsInterop --lobby-snapshots native/build/polish
```

This generates light and dark welcome, approval, home, ready, error, expired,
link-on-another-server and visiting fixtures, the signed-out home and the
settings sheet at iPhone width (`phone-*`), the guest screen empty and with an
invalid link (`guest-*`), and the Mac Settings window (`settings-*`), without a
server, real account or device permissions. Models read the server from a
throwaway defaults suite. The off-screen window is never key, so prominent
buttons draw untinted in these fixtures. The fixture entry point
is only in CallsInterop, never the application.

`TestNativeMacChat` also captures `native/build/call-focus.png`, `call-chat.png` and `call-grid.png` using the
shipping Mac call view and messages received from a real browser participant.
The off-screen bitmap capture does not capture Metal video frames; media
continuity is verified separately by the existing decoded-frame/audio counters.

The first pass was checked with Mac and iOS Simulator builds, the iPhone
invalid-link UI test, and native/browser authentication, media, chat and sharing
checks. Lobby review includes light/dark Mac states and iPhone at standard and
accessibility text sizes. Physical-device call controls, VoiceOver, camera and
microphone behaviour still need hands-on acceptance.

## Next iterations

Camera preview and device selection before joining, remote camera-off signalling,
automatic speaker selection, and keyboard commands are follow-up product work.
Those should be designed alongside the relevant media and lifecycle behaviour.

Run `swift test --package-path native/apple` for chat-window lifecycle and draft
retention checks. Window closure never invokes the meeting leave action.

## iPhone and iPad call treatment · 2026-09-12

The iOS call stage now defaults to remote focus with a local thumbnail, offers
Focus/Grid and participant selection, and uses the same compact colored icon
control vocabulary. Chat is a native sheet with unread count and a retained
draft. iOS continues to end calls when backgrounded, except while sharing the screen;
general background-call continuity remains follow-up media work.

Safari member/guest sign-in and the real iPhone simulator native-media tests
passed after this change. The signed development app was built and installed on
Paul's paired iPhone. That installation does not substitute for a physical
camera/microphone and interruption acceptance call.

## Full-window call · 2026-09-25 (0.3.4)

The call fills the window (Mac) or the screen (iPhone), with the controls over
it on demand. **Call layout: Full window (new) / Classic** — Calls → Settings…
on Mac, the gear's sheet on iPhone — keeps the previous layout one choice away;
Full window is the default. The mini window and tab are unchanged.

- **When the controls show** is `ControlsVisibility` (CallChrome.swift), one
  state machine for both apps with the time passed in, so tests drive it. Mac:
  pointer movement shows them, 3 s still hides them (`pointerDelay`; 1.5 s in
  0.3.4 was too quick), leaving the window hides them 1.5 s later
  (`pointerExitDelay`, never later than they were due anyway), and the pointer
  hides with them over the picture (`NSCursor.setHiddenUntilMouseMoves`).
  iPhone: a tap toggles, 3 s after the last touch hides (`touchDelay`). They stay up while the pointer is on the
  bar, a menu, popover, sheet or panel is open (AppKit's menu-tracking and
  window-sheet notifications cover the ones SwiftUI does not report), a bar
  control has keyboard focus, VoiceOver is running, or an alert shows. Tab
  reveals them. Space and ⌘⇧M work with them hidden. Fades are opacity only,
  eased out: 0.35 s going, 0.2 s coming back; Reduce Motion shortens them to a
  0.1 s opacity change and panels fade instead of sliding.
- **The stage** is edge to edge: square tiles for Focus and a screen, a grid
  that fits every tile without scrolling until they would be under 200 pt
  (`GridFit`). On Mac there is no title bar while the call is full window
  (0.3.5): the picture goes to the window's top edge, the traffic lights sit
  over it and fade with the controls (not in full screen, where the system
  shows them), a shade behind them keeps them legible, and a 32 pt strip
  across the top drags the window and double-clicks as the person's title-bar
  setting says (`WindowDragStrip`, `TitleBarDoubleClick`). Since 0.3.7 the
  picture does too, as in FaceTime (`WindowMove`): a drag anywhere on the
  stage that is not a control, a panel or the self view moves the window
  once it has gone 4 pt (less is a click, which shows the controls), and a
  double click there does what the title bar's would. Resizing is AppKit's
  own, from every edge and corner, the top included. The title pill sits
  to the right of the traffic lights and the notices in a column beneath it.
  `CallWindowChrome` sets the window's title bar, watches it so SwiftUI cannot
  quietly restore it mid-call, and puts it back for the lobby and Classic.
  Names are small capsules with the muted mark (shape and word) inside, lifted
  clear of the bar while it shows, visible while it does not.
- **The self view** floats in Focus, draggable, settling in the corner it was
  flicked towards (`PiPLayout`), kept clear of the bar, an open chat and the
  camera strip beside a shared screen, and remembered. It always says Muted
  when you are. VoiceOver has "Move self view to …" actions. In Grid you are a
  tile, as before.
- **Title and notices.** Title and status go with the controls. Reconnecting,
  waiting to be let in, full, ended (with Join again), problems (dismissible),
  "You are sharing your screen" and each person waiting with **Let in** stay
  until resolved (`CallNotices`); the relay-floor line fades.
- **Over the picture** (0.3.5): one dark system, whatever the system
  appearance, because what is underneath is video (`CallOverlay`,
  `CallOverlaySurface`, `CallOverlayButtonStyle`). Every surface — the bar,
  the title pill, notices, name labels, the iPhone's round buttons — is black
  at 70 % over a dark blur with a hairline edge; the black alone holds the
  contrast, so white on it is at least 8.5:1 even over a white wall. Buttons
  are white symbols on a light fill (5.5:1 worst case); a control that is on
  (sharing, a panel open) is near-white with a dark symbol; microphone or
  camera off is amber with the slashed symbol; Leave is a deeper red than the
  system's (5.0:1 for its symbol). `CallOverlayContrastTests` holds the ratios
  over white, black and grey. Classic keeps its own look.
- **Mac bar**: a dark bar along the bottom — microphone, camera, share,
  devices, layout (Focus/Grid and who is on the stage, in a popover), share link
  — with chat and Leave on the right. Devices is a floating panel over the
  stage above the bar, not a column that shrinks it.
- **Mac chat** is an overlay sidebar sliding over the stage's trailing edge,
  with the existing pop-out to a separate window kept. Chosen over the window
  alone because the window is window management mid-call on a laptop screen,
  while the sidebar is one click and leaves the picture its full size underneath;
  anyone who wants it beside the call still has the window.
- **iPhone bar**: a kebab (More controls) on the left and End call on the right.
  The kebab opens a sheet: mute, camera, flip camera, share screen (the system
  broadcast picker, which is why it is a sheet and not a menu), devices, Mic
  Mode, layout, share link, chat and Reduce background noise. The status bar and
  home indicator hide with the controls. `-CallsMicBesideMenu YES` puts a
  microphone button beside the kebab, off by default.

Snapshots: `CallsInterop --call-snapshots <dir>` (also part of
`--lobby-snapshots`) renders the Mac call, both layouts, light and dark. The
iPhone's come from `CallsPhoneUITests.testFullScreenCallSnapshots` against the
debug build's `-CallsCallFixture` calls (`CallFixture`), written to
`CALLS_SNAPSHOT_DIR` or `/tmp/immersive-call`.

## Glass and the speaking glow · 2026-09-25 (0.3.7)

Supersedes the hairline edges and button tiles of 0.3.5's **Over the picture**.

- **One glass language, no hairlines.** Every surface over the picture — the
  bar, the title pill, notices, name labels, the iPhone's round buttons and the
  mini window's tab — is borderless glass: Liquid Glass on macOS 26 and iOS 26,
  the ultra-thin material before, with black at 62 % on it
  (`CallOverlay.scrim`). Rendered over white the glass measures #5B5B5B, so
  white on it is 6.8:1 (6.2:1 from the black alone). The environment value
  `callOverlayGlass` turns the glass off to show the fallback.
- **Buttons are glyphs on the pane**: white, a light wash under the pointer,
  and a capsule only for a state — near-white for on (sharing, a panel open),
  amber with the slashed symbol for microphone or camera off, red for Leave.
- **Devices** over the picture is opaque and dark (`CallOverlay.panelFill`)
  with the dark appearance's labels: 16.7:1 primary and 5.1:1 secondary as
  rendered. It holds the controls up while open and never fades with them.
- **The speaking glow** replaces the white ring round a tile and the meter
  beside the name, on Mac, iPhone, web and the mini window's tab: the accent's
  hue lit up as a halo behind the name label, bright as the voice
  (`SpeakingGlow`, and `judgeGlow`/`glowStyle` in room.js). The halo redraws
  on a timeline only until it arrives. "No sound from …" and the microphone
  warnings stay as words by the name; the Devices panel keeps a compact meter.
  *The teal halo is superseded by the warm rim (0.4.3, below).*

Snapshots: `CallsInterop --glass-snapshots <dir>` renders real windows below
the desktop and captures them with `screencapture -l` (an off-screen bitmap
draws no Liquid Glass); the iPhone's are
`CallsPhoneUITests.testGlowAndGlassSnapshots` (`-CallsFixtureSpeaking`,
`-CallsFixtureNoGlass`).


## The warm rim · 2026-09-28 (0.4.3)

Supersedes the teal halo of 0.3.7's **speaking glow**. After three rounds of
HTML mock-ups (`speaking-indicator-options/`, served at
calls-led-options.bds.oncarlos.com) Paul chose **T5, "Warm soft breath"**, and
it is drawn exactly as that mock-up's box-shadow recipe, on Mac, iPhone, web
and the mini window's tab (`WarmRim`, `TileSpeakingName`; `glowStyle` and
`.calls-tile-name::after` on the web).

Over the name's own glass and in its shape, where `on` is 0 or 1 and `lvl`
the smoothed level 0…1 (-42 to -12 dBFS, no floor any more):

| Layer | Drawn | Colour | Opacity |
| --- | --- | --- | --- |
| Rim | 1 pt inside the edge | hsl(38°, 80 %, 82 %) | on × (0.10 + lvl × 0.45) |
| Top highlight | none in T5 | — | 0 |
| Inner light | inset blur 3 + lvl × 6 pt | hsl(38°, 80 %, 72 %) | on × (0.06 + lvl × 0.30) |
| Outer breath | blur 1 + lvl × 7 pt, outside only | hsl(38°, 80 %, 66 %) | on × lvl × 0.25 |

Blurs are CSS box-shadow radii; SwiftUI draws them at half (its radius is the
Gaussian's deviation). The timing is 0.3.7's: lit once speech has kept up
0.15 s, 50 ms attack, 300 ms release. Once no longer lit, `on` holds until the
level has fallen under 0.05 (`SpeakingGlow.outBelow`; on the web `--on` waits
out the release), as the mock-up's does, so the rim fades rather than
vanishing. Reduce Motion: a steady lvl of 0.6 while lit, and nothing moves.
Only the overlay observes the level, and it redraws only until it arrives.

Snapshots: `CallsInterop --glass-snapshots <dir>` writes `mac-rim-<level>.png`
(`WarmRimGallery`: the mock-up's dark and bright tiles, white, and the busy
frame); the iPhone's are `CallsPhoneUITests.testWarmRimSnapshots`
(`-CallsFixtureRimGallery <level>`).

## Speaker view and your own order · 2026-09-27 (0.4.0)

Paul asked whether he could rearrange the people in Grid, and for a Speaker
view: the speaker in the main window, everyone else underneath.

- **Two layouts: Speaker and Grid.** Speaker replaced Focus. Focus was "one
  person on the stage, chosen by hand", which is Speaker with that person
  pinned, so keeping both would have been two names for one thing with a
  worse default. Choosing a pin (from the layout menu, a tile's menu or a
  click on a filmstrip tile) switches to Speaker. The choice of layout is now
  kept (`VideoLayout.key`, `videoLayout` in UserDefaults; `calls.videoLayout`
  in the browser's localStorage), and a kept `focus` reads as Speaker.
  Nothing had kept one before 0.4.0 — every call opened in Focus — so the
  migration is a rule in `VideoLayout.stored`/`storedLayout` rather than data
  to move. The iPhone's ⋯ sheet says Speaker/Grid and "On the stage".
- **The stage** is, in order: what you pinned while it is there; otherwise the
  newest shared screen (as before, so a new screen takes the stage unless you
  have pinned something); otherwise whoever has the floor; otherwise the first
  other person in your order (`Stage.speaker`). You are on it only if you pin
  yourself.
- **Who has the floor** is `ActiveSpeaker`, fed five times a second from the
  per-peer levels and speaking gate the glow already uses. Someone takes the
  stage once they have been the loudest speaker for `dominance` (1.3 s), and
  whoever is on it keeps it for at least `minimumHold` (3 s). A pause keeps
  the speaker; an interjection shorter than 1.3 s never takes it; two people
  trading sentences faster than that do not make it flap; someone who leaves
  loses it at once. You are never the speaker. room.js's `speakerStep` is
  the same rule with the same constants (`SPEAKER_DOMINANCE`, `SPEAKER_HOLD`),
  and `room-layout.test.mjs` holds them equal.
- **The filmstrip** holds everything not on the stage — other screens first,
  then everyone in your order, you included. On the Mac it runs along the
  bottom of the window, tiles a sixth of the window high (72–120 pt; five fit
  across the smallest window, 760 × 540, and more scroll sideways), and rises
  clear of the bar while the controls show, as the self view does; with the
  controls hidden nothing lies over the stage. On iPhone it is a sideways
  scroller of upright 3:4 tiles above the home indicator (4:3 in landscape),
  rising with the bar in the same way. Tiles in it keep the glass name
  labels, the Muted mark and the speaking glow.
- **You in the filmstrip, not floating** — except in a call of two. The
  order is one list shared by Grid and the filmstrip, and you are in it
  like anyone else, so you can put yourself first or last; a floating self
  view would have been the one tile that ignored the order. In a call of
  two the filmstrip would hold nobody but you, so there you float in the
  corner exactly as since 0.3.4 (`Stage.selfFloats`), and the full-window
  look of the commonest call is unchanged.
- **Pinning.** A click or tap on a filmstrip tile, or Pin in a tile's menu
  (Mac context menu) or its accessibility actions, pins it. A pinned stage
  carries a **Pinned** badge; pressing it, or Unpin, goes back to following
  the speaker. On the Mac a single click on a filmstrip tile pins once the
  double-click interval has passed (a double click zooms the window, as on
  the picture).
- **Your own order.** Drag a tile onto another's place — in Grid or in the
  filmstrip — and the others make room; the dragged tile lifts (larger, with
  a shadow and the accent edge) and follows the pointer. On iPhone and iPad a
  long press picks it up. With Reduce Motion nothing grows or springs. Every
  platform has a way without dragging: the Mac's tile context menu, and on
  every platform the tile's accessibility actions (Pin/Unpin, Move earlier,
  Move later, Move to first); the browser gives each tile those as buttons.
  The order is yours alone and never sent. It is kept per meeting for the
  rest of the app's run (`ParticipantOrders`; the browser's sessionStorage,
  `calls.order.<meeting>`), under keys that survive a rejoin — "self" and the
  person's name, since a peer id is new on every join. New arrivals go on the
  end; someone who leaves is removed; a remembered person who comes back
  takes their old place (`ParticipantOrder`).
- **VoiceOver** hears the stage tile as "On stage: Tony" (", pinned" when
  it is), and a pin, an unpin or a move is announced once, politely. An
  automatic change of speaker is not announced: in a lively call it would
  talk over the talking.
- **The mini window** shows Speaker view's stage, whichever layout the call
  window is in.

Snapshots: `CallsInterop --speaker-snapshots <dir>` (Mac, real windows: the
filmstrip with the controls up and down and at 760 × 540, a pin, a shared
screen, a drag half way in Speaker and in Grid, Grid after a reorder, and a
call of two); `CallsPhoneUITests.testSpeakerViewSnapshots` (iPhone, portrait
and landscape, with `-CallsFixtureLayout`, `-CallsFixtureSpeaker`,
`-CallsFixturePinned`, `-CallsFixtureLift` and `-CallsFixtureOrder`); and the
browser gate's `speaker` scenario with `CALLS_SPEAKER_SNAPSHOTS=<dir>`.
