@polymorfa/react 0.1.0-dev.20260922094209

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/calls.js ADDED
@@ -0,0 +1,944 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ // Polymorfa Calls UI — React components over `CallsController`.
3
+ //
4
+ // Shaped after the official WhatsApp desktop call windows in a monochrome
5
+ // Material-3 voice: black identity, tonal fills, hairline outlines, 8px
6
+ // radius; the verbs carry the only color (success green answers, danger red
7
+ // declines/hangs up). Cards follow the appearance theme; the call stage — and
8
+ // the incoming card once it carries a camera preview — is always dark, like an
9
+ // OS call screen. Widgets are fluid and never resize when a panel opens;
10
+ // device panels overlay them. Every affordance gates on the snapshot's
11
+ // reported `capabilities`.
12
+ import {} from "@polymorfa/browser";
13
+ import { appearanceToCssVariables } from "@polymorfa/ui";
14
+ import { useCallback, useEffect, useMemo, useRef, useState, } from "react";
15
+ import { createPortal } from "react-dom";
16
+ import { usePolymorfa } from "./context.js";
17
+ import { useController, useResolvedController } from "./hooks.js";
18
+ /**
19
+ * Run a synchronous controller call from a DOM handler.
20
+ *
21
+ * The controller's mutators assert it is live and throw once it is disposed,
22
+ * and a disposed controller keeps its last snapshot — so a surface stays
23
+ * mounted and its buttons stay clickable. React does not catch throws from
24
+ * event handlers, so without this the error reaches window.onerror. There is
25
+ * nothing to report: the call these controls belonged to is gone.
26
+ */
27
+ function ignoreDisposed(run) {
28
+ try {
29
+ run();
30
+ }
31
+ catch {
32
+ // Disposed controller — no snapshot left to change.
33
+ }
34
+ }
35
+ /** A call this client answered or joined that has not ended. */
36
+ const ANSWERED_STATUSES = new Set([
37
+ "accepted",
38
+ "connecting",
39
+ "connected",
40
+ "reconnecting",
41
+ ]);
42
+ const ACTIVE_STATUSES = new Set([
43
+ "ringing",
44
+ "accepted",
45
+ "connecting",
46
+ "connected",
47
+ "reconnecting",
48
+ ]);
49
+ function Icon({ children, ...props }) {
50
+ return (_jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", ...props, children: children }));
51
+ }
52
+ const PhoneIcon = (p) => (_jsx(Icon, { ...p, children: _jsx("path", { d: "M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6A19.79 19.79 0 0 1 2.08 4.18 2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" }) }));
53
+ /** The end-call handset: the phone glyph rotated to point down. */
54
+ const HangupIcon = ({ style, ...p }) => (_jsx(PhoneIcon, { ...p, style: { transform: "rotate(135deg)", ...style } }));
55
+ const MicIcon = (p) => (_jsxs(Icon, { ...p, children: [_jsx("path", { d: "M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" }), _jsx("path", { d: "M19 10v2a7 7 0 0 1-14 0v-2" }), _jsx("line", { x1: "12", y1: "19", x2: "12", y2: "23" }), _jsx("line", { x1: "8", y1: "23", x2: "16", y2: "23" })] }));
56
+ const MicOffIcon = (p) => (_jsxs(Icon, { ...p, children: [_jsx("line", { x1: "1", y1: "1", x2: "23", y2: "23" }), _jsx("path", { d: "M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6" }), _jsx("path", { d: "M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23" }), _jsx("line", { x1: "12", y1: "19", x2: "12", y2: "23" }), _jsx("line", { x1: "8", y1: "23", x2: "16", y2: "23" })] }));
57
+ const VideoIcon = (p) => (_jsxs(Icon, { ...p, children: [_jsx("path", { d: "M23 7l-7 5 7 5V7z" }), _jsx("rect", { x: "1", y: "5", width: "15", height: "14", rx: "2", ry: "2" })] }));
58
+ const VideoOffIcon = (p) => (_jsxs(Icon, { ...p, children: [_jsx("path", { d: "M16 16v1a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2m5.66 0H14a2 2 0 0 1 2 2v3.34l1 1L23 7v10" }), _jsx("line", { x1: "1", y1: "1", x2: "23", y2: "23" })] }));
59
+ const EraseIcon = (p) => (_jsxs(Icon, { ...p, children: [_jsx("path", { d: "M21 4H8l-7 8 7 8h13a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2z" }), _jsx("line", { x1: "18", y1: "9", x2: "12", y2: "15" }), _jsx("line", { x1: "12", y1: "9", x2: "18", y2: "15" })] }));
60
+ const UserIcon = (p) => (_jsxs(Icon, { ...p, children: [_jsx("path", { d: "M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" }), _jsx("circle", { cx: "12", cy: "7", r: "4" })] }));
61
+ const ChevronDownIcon = (p) => (_jsx(Icon, { ...p, children: _jsx("polyline", { points: "6 9 12 15 18 9" }) }));
62
+ const CloseIcon = (p) => (_jsxs(Icon, { ...p, children: [_jsx("line", { x1: "18", y1: "6", x2: "6", y2: "18" }), _jsx("line", { x1: "6", y1: "6", x2: "18", y2: "18" })] }));
63
+ const LeaveIcon = (p) => (_jsxs(Icon, { ...p, children: [_jsx("path", { d: "M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" }), _jsx("polyline", { points: "16 17 21 12 16 7" }), _jsx("line", { x1: "21", y1: "12", x2: "9", y2: "12" })] }));
64
+ const MoreIcon = (p) => (_jsxs(Icon, { ...p, children: [_jsx("circle", { cx: "12", cy: "12", r: "1" }), _jsx("circle", { cx: "19", cy: "12", r: "1" }), _jsx("circle", { cx: "5", cy: "12", r: "1" })] }));
65
+ const PopoutIcon = (p) => (_jsxs(Icon, { ...p, children: [_jsx("path", { d: "M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" }), _jsx("polyline", { points: "15 3 21 3 21 9" }), _jsx("line", { x1: "10", y1: "14", x2: "21", y2: "3" })] }));
66
+ // ── Root / theming ────────────────────────────────────────────────────
67
+ const STYLE_TAG_ID = "pmfa-calls-styles";
68
+ /**
69
+ * Inject the calls stylesheet once per document. SSR-safe (no-op without a
70
+ * `document`) and idempotent.
71
+ */
72
+ export function injectCallsStyles(target = globalThis.document) {
73
+ if (target === undefined)
74
+ return;
75
+ if (target.getElementById(STYLE_TAG_ID))
76
+ return;
77
+ const tag = target.createElement("style");
78
+ tag.id = STYLE_TAG_ID;
79
+ tag.textContent = CALLS_STYLES;
80
+ target.head.appendChild(tag);
81
+ }
82
+ function useCallsRoot() {
83
+ const configuration = usePolymorfa();
84
+ const unstyled = configuration.appearance.unstyled === true;
85
+ useEffect(() => {
86
+ if (!unstyled)
87
+ injectCallsStyles();
88
+ }, [unstyled]);
89
+ const call = configuration.appearance.elements["call"];
90
+ const theme = configuration.appearance.theme;
91
+ const className = [
92
+ "pmfa-calls",
93
+ theme === "dark"
94
+ ? "pmfa-calls-dark"
95
+ : theme === "system"
96
+ ? "pmfa-calls-auto"
97
+ : "",
98
+ call?.className ?? "",
99
+ ]
100
+ .filter(Boolean)
101
+ .join(" ");
102
+ const style = useMemo(() => ({
103
+ ...appearanceToCssVariables(configuration.appearance),
104
+ ...call?.styles,
105
+ }), [configuration.appearance, call]);
106
+ return {
107
+ className,
108
+ style,
109
+ dir: configuration.locale.direction,
110
+ locale: configuration.locale,
111
+ };
112
+ }
113
+ function t(locale, key, values = {}) {
114
+ return locale.messages[key].replace(/\{(\w+)\}/g, (_, name) => values[name] ?? "");
115
+ }
116
+ // ── Building blocks ───────────────────────────────────────────────────
117
+ function initialsOf(name) {
118
+ if (name === undefined)
119
+ return null;
120
+ const words = name.trim().split(/\s+/).filter(Boolean);
121
+ const first = words[0];
122
+ if (first === undefined || /^[+\d(]/.test(first))
123
+ return null;
124
+ const last = words.length > 1 ? words[words.length - 1] : undefined;
125
+ const initials = `${first[0] ?? ""}${last?.[0] ?? ""}`.toUpperCase();
126
+ return initials.length > 0 ? initials : null;
127
+ }
128
+ /**
129
+ * Deterministic per-caller avatar color: a stable hash of the peer id picks
130
+ * one of 8 palette tones, so the same caller always renders the same color.
131
+ * A resolved profile picture wins.
132
+ */
133
+ function avatarColorClass(peer) {
134
+ if (peer === undefined)
135
+ return "";
136
+ let h = 5381;
137
+ for (let i = 0; i < peer.length; i += 1)
138
+ h = ((h * 33) ^ peer.charCodeAt(i)) >>> 0;
139
+ return ` pmfa-calls-avatar-c${(h % 8) + 1}`;
140
+ }
141
+ function CallAvatar({ displayName, avatarUrl, peer, }) {
142
+ const initials = initialsOf(displayName);
143
+ return (_jsx("div", { className: "pmfa-calls-avatar-wrap", children: _jsx("div", { className: `pmfa-calls-avatar${avatarUrl === undefined ? avatarColorClass(peer) : ""}`, children: avatarUrl !== undefined ? (_jsx("img", { src: avatarUrl, alt: "" })) : initials !== null ? (initials) : (_jsx(UserIcon, { style: { width: 30, height: 30, opacity: 0.9 } })) }) }));
144
+ }
145
+ function usePeerAvatar(peer, resolveAvatar, explicit) {
146
+ const [resolved, setResolved] = useState({});
147
+ useEffect(() => {
148
+ if (peer === undefined || resolveAvatar === undefined || peer in resolved)
149
+ return;
150
+ let cancelled = false;
151
+ void Promise.resolve()
152
+ .then(() => resolveAvatar(peer))
153
+ .catch(() => undefined)
154
+ .then((url) => {
155
+ if (!cancelled)
156
+ setResolved((previous) => ({ ...previous, [peer]: url }));
157
+ });
158
+ return () => {
159
+ cancelled = true;
160
+ };
161
+ }, [peer, resolveAvatar, resolved]);
162
+ return explicit ?? (peer === undefined ? undefined : resolved[peer]);
163
+ }
164
+ /**
165
+ * A muted, mirrored self-view for the pre-answer preview on video offers.
166
+ * Null while the camera warms up or when access was denied — callers fall
167
+ * back to the dark block.
168
+ */
169
+ function useSelfPreview(enabled, videoInput) {
170
+ const [stream, setStream] = useState(null);
171
+ useEffect(() => {
172
+ const devices = globalThis.navigator?.mediaDevices;
173
+ if (!enabled ||
174
+ devices === undefined ||
175
+ typeof devices.getUserMedia !== "function")
176
+ return;
177
+ let cancelled = false;
178
+ let acquired = null;
179
+ void devices
180
+ .getUserMedia({
181
+ video: videoInput === undefined ? true : { deviceId: { ideal: videoInput } },
182
+ audio: false,
183
+ })
184
+ .then((s) => {
185
+ acquired = s;
186
+ if (cancelled)
187
+ for (const track of s.getTracks())
188
+ track.stop();
189
+ else
190
+ setStream(s);
191
+ })
192
+ .catch(() => undefined);
193
+ return () => {
194
+ cancelled = true;
195
+ if (acquired !== null)
196
+ for (const track of acquired.getTracks())
197
+ track.stop();
198
+ setStream(null);
199
+ };
200
+ }, [enabled, videoInput]);
201
+ return stream;
202
+ }
203
+ function attach(element, stream) {
204
+ if (element !== null && stream != null && element.srcObject !== stream)
205
+ element.srcObject = stream;
206
+ }
207
+ // ── Device panels ─────────────────────────────────────────────────────
208
+ const canSetSink = typeof HTMLMediaElement !== "undefined" &&
209
+ "setSinkId" in HTMLMediaElement.prototype;
210
+ const DEVICE_KIND = {
211
+ audioInput: "audioinput",
212
+ videoInput: "videoinput",
213
+ audioOutput: "audiooutput",
214
+ };
215
+ const DEVICE_LABEL = {
216
+ audioInput: "calls.microphone",
217
+ videoInput: "calls.camera",
218
+ audioOutput: "calls.speaker",
219
+ };
220
+ /**
221
+ * One-panel-at-a-time open state plus the label unlock: device labels are
222
+ * empty until the page holds a media permission, so opening a panel takes
223
+ * (and immediately releases) a throwaway audio capture so real names show.
224
+ */
225
+ function useDevicePanels(controller, snapshot) {
226
+ const [open, setOpen] = useState(null);
227
+ const toggle = (panel) => {
228
+ const next = open === panel ? null : panel;
229
+ setOpen(next);
230
+ if (next === null)
231
+ return;
232
+ void (async () => {
233
+ const devices = globalThis.navigator?.mediaDevices;
234
+ if (snapshot.devices.every((d) => d.label.length === 0) &&
235
+ devices !== undefined &&
236
+ typeof devices.getUserMedia === "function") {
237
+ try {
238
+ const s = await devices.getUserMedia({ audio: true });
239
+ for (const track of s.getTracks())
240
+ track.stop();
241
+ }
242
+ catch {
243
+ // Denied — enumerate anyway; generic labels render.
244
+ }
245
+ }
246
+ await controller.refreshDevices().catch(() => undefined);
247
+ })();
248
+ };
249
+ return { open, toggle };
250
+ }
251
+ /**
252
+ * Device selects, scoped per panel: the mic dropdown carries microphone +
253
+ * speaker, the camera dropdown carries the camera only (the official
254
+ * split-button layout). Selections apply live mid-call.
255
+ */
256
+ function DeviceFields({ controller, snapshot, fields, locale, }) {
257
+ const select = (kind, deviceId) => {
258
+ ignoreDisposed(() => {
259
+ if (kind === "audioOutput")
260
+ controller.setPreferredDevices({ audioOutput: deviceId });
261
+ else if (deviceId.length > 0)
262
+ void controller.switchDevice(kind, deviceId);
263
+ else
264
+ controller.setPreferredDevices({ [kind]: undefined });
265
+ });
266
+ };
267
+ return (_jsx(_Fragment, { children: fields.map((kind) => {
268
+ if (kind === "audioOutput" && !canSetSink)
269
+ return null;
270
+ const group = snapshot.devices.filter((d) => d.kind === DEVICE_KIND[kind]);
271
+ return (_jsxs("label", { className: "pmfa-calls-field", children: [t(locale, DEVICE_LABEL[kind]), _jsxs("select", { className: "pmfa-calls-select", value: snapshot.selectedDevices[kind] ?? "", onChange: (event) => select(kind, event.currentTarget.value), children: [_jsx("option", { value: "", children: t(locale, "calls.defaultDevice") }), group.map((d, i) => (_jsx("option", { value: d.deviceId, children: d.label.length > 0
272
+ ? d.label
273
+ : `${t(locale, DEVICE_LABEL[kind])} ${i + 1}` }, d.deviceId.length > 0 ? d.deviceId : i)))] })] }, kind));
274
+ }) }));
275
+ }
276
+ /**
277
+ * The incoming-call window: name, "WhatsApp audio/video call" subtitle, the
278
+ * media middle (avatar for audio; a mirrored self-preview with camera/mic
279
+ * toggles and a ⋯ device menu for video), then Reject / Answer. Answering a
280
+ * video offer with the camera toggled off still acquires video muted, so the
281
+ * in-call camera toggle can enable it later.
282
+ *
283
+ * A call another participant answered without claiming shows Join instead of
284
+ * Answer. A call another participant claimed shows only Dismiss: declining it
285
+ * would end it for the participant who answered. Other waiting calls are
286
+ * listed below; the card never declines them.
287
+ */
288
+ export function IncomingCallCard({ controller, createController, displayName, avatarUrl, resolveAvatar, className, exclusive = false, resolveName, }) {
289
+ const resolved = useResolvedController(controller, createController);
290
+ const snapshot = useController(resolved);
291
+ const root = useCallsRoot();
292
+ const { locale } = root;
293
+ const incoming = snapshot.status === "incoming";
294
+ // A claimed call is not answered here, so it opens no camera preview.
295
+ const offersVideo = incoming &&
296
+ !snapshot.claimedByOther &&
297
+ snapshot.video &&
298
+ snapshot.capabilities.video;
299
+ const [cameraOn, setCameraOn] = useState(true);
300
+ const [preMuted, setPreMuted] = useState(false);
301
+ // The card returns null between calls instead of unmounting, so these
302
+ // toggles would otherwise carry into the next call and mute it with nothing
303
+ // on screen to explain why. Reset during render, before the preview reads
304
+ // them, so the camera is never acquired against a stale choice.
305
+ const [toggledFor, setToggledFor] = useState(snapshot.callId);
306
+ if (snapshot.callId !== toggledFor) {
307
+ setToggledFor(snapshot.callId);
308
+ setCameraOn(true);
309
+ setPreMuted(false);
310
+ }
311
+ const preview = useSelfPreview(offersVideo && cameraOn, snapshot.selectedDevices.videoInput);
312
+ const previewRef = useRef(null);
313
+ const peerAvatar = usePeerAvatar(snapshot.peer, resolveAvatar, avatarUrl);
314
+ const { open, toggle } = useDevicePanels(resolved, snapshot);
315
+ useEffect(() => attach(previewRef.current, preview), [preview]);
316
+ if (!incoming)
317
+ return null;
318
+ const peer = snapshot.peer ?? "";
319
+ const name = displayName ?? peer;
320
+ // A remote hang-up between the click and the call reaching the controller
321
+ // makes both of these reject: the call is no longer incoming. The snapshot
322
+ // already shows the call ended, so the rejection only needs absorbing.
323
+ const claimed = snapshot.claimedByOther;
324
+ const joinable = !claimed && snapshot.canJoin;
325
+ // An answer or join is in flight (possibly for another listed call): the
326
+ // controller refuses these until it settles.
327
+ const busy = snapshot.answering === true;
328
+ // A failed answer: this call's own (still ringing), or a previous call's
329
+ // that gave way to this one.
330
+ const failure = snapshot.error;
331
+ const failureKey = failure === undefined ||
332
+ failure.code === "call_control_failed" ||
333
+ failure.code === "call_claimed"
334
+ ? undefined
335
+ : failure.callId === undefined || failure.callId === snapshot.callId
336
+ ? "calls.answerFailed"
337
+ : "calls.previousFailed";
338
+ const answeredId = snapshot.callId;
339
+ const applyPreToggles = () => {
340
+ // The answer can settle without this call becoming active: it ended, was
341
+ // dismissed, or another call is displayed now. Those choices belong to
342
+ // this call only.
343
+ const now = resolved.getSnapshot();
344
+ if (now.callId !== answeredId || !ANSWERED_STATUSES.has(now.status))
345
+ return;
346
+ if (preMuted || (offersVideo && !cameraOn))
347
+ resolved.setMuted({
348
+ ...(preMuted ? { audio: true } : {}),
349
+ ...(offersVideo && !cameraOn ? { video: true } : {}),
350
+ });
351
+ };
352
+ const join = () => void resolved
353
+ .join({ video: offersVideo })
354
+ .then(applyPreToggles)
355
+ .catch(() => undefined);
356
+ const others = (snapshot.invitations ?? []).filter((invitation) => invitation.callId !== snapshot.callId);
357
+ const accept = () => void resolved
358
+ .answer({ video: offersVideo, exclusive })
359
+ .then(applyPreToggles)
360
+ .catch(() => undefined);
361
+ const micToggle = (_jsx("button", { type: "button", className: `pmfa-calls-btn pmfa-calls-mini${preMuted ? " pmfa-calls-on" : ""}`, onClick: () => setPreMuted((v) => !v),
362
+ // The pending answer applies the value from when it was pressed.
363
+ disabled: busy, "aria-label": t(locale, preMuted ? "calls.answerUnmuted" : "calls.answerMuted"), "aria-pressed": preMuted, children: preMuted ? _jsx(MicOffIcon, {}) : _jsx(MicIcon, {}) }));
364
+ return (_jsx("div", { className: root.className, style: root.style, dir: root.dir, "data-pmfa": "call", children: _jsxs("div", { className: `pmfa-calls-card pmfa-calls-incoming${className === undefined ? "" : ` ${className}`}`, role: "alertdialog", "aria-label": t(locale, "calls.incomingFrom", { peer: name }), children: [_jsx("div", { className: "pmfa-calls-name", children: name }), _jsx("div", { className: "pmfa-calls-subtitle", role: snapshot.error?.code === "call_control_failed" ||
365
+ failureKey !== undefined ||
366
+ claimed ||
367
+ joinable
368
+ ? "status"
369
+ : undefined, children: t(locale, snapshot.error?.code === "call_control_failed"
370
+ ? "calls.controlFailed"
371
+ : failureKey !== undefined
372
+ ? failureKey
373
+ : claimed
374
+ ? "calls.answeredElsewhere"
375
+ : joinable
376
+ ? "calls.joinable"
377
+ : offersVideo
378
+ ? "calls.videoCall"
379
+ : "calls.audioCall") }), offersVideo ? (_jsxs("div", { className: "pmfa-calls-media", children: [cameraOn && preview !== null ? (_jsx("video", { ref: previewRef, className: "pmfa-calls-preview", autoPlay: true, playsInline: true, muted: true })) : null, _jsx("div", { className: "pmfa-calls-more", children: _jsx("button", { type: "button", className: `pmfa-calls-btn pmfa-calls-mini${open === "more" ? " pmfa-calls-on" : ""}`, onClick: () => toggle("more"), "aria-label": t(locale, "calls.deviceSettings"), "aria-expanded": open === "more", children: _jsx(MoreIcon, {}) }) }), _jsxs("div", { className: "pmfa-calls-media-ctrls", children: [_jsx("button", { type: "button", className: `pmfa-calls-btn pmfa-calls-mini${cameraOn ? "" : " pmfa-calls-on"}`, onClick: () => setCameraOn((v) => !v), disabled: busy, "aria-label": t(locale, cameraOn
380
+ ? "calls.answerWithoutCamera"
381
+ : "calls.answerWithCamera"), "aria-pressed": !cameraOn, children: cameraOn ? _jsx(VideoIcon, {}) : _jsx(VideoOffIcon, {}) }), micToggle] })] })) : (_jsx("div", { className: "pmfa-calls-media pmfa-calls-media-audio", children: _jsx(CallAvatar, { displayName: displayName, avatarUrl: peerAvatar, peer: peer }) })), open === "more" && (_jsx("div", { className: "pmfa-calls-pop pmfa-calls-pop-float", role: "group", "aria-label": t(locale, "calls.deviceSettings"), children: _jsx(DeviceFields, { controller: resolved, snapshot: snapshot, fields: ["audioInput", "videoInput", "audioOutput"], locale: locale }) })), _jsxs("div", { className: "pmfa-calls-actions", children: [claimed || joinable ? (_jsxs("div", { className: "pmfa-calls-action", children: [_jsx("button", { type: "button", className: "pmfa-calls-btn pmfa-calls-btn-dismiss", onClick: () => ignoreDisposed(() => resolved.dismiss()), "aria-label": t(locale, "calls.dismiss"), children: _jsx(CloseIcon, {}) }), t(locale, "calls.dismiss")] })) : (_jsxs("div", { className: "pmfa-calls-action", children: [_jsx("button", { type: "button", className: "pmfa-calls-btn pmfa-calls-btn-decline", onClick: () => void resolved.reject().catch(() => undefined), disabled: busy, "aria-label": t(locale, "calls.reject"), children: _jsx(HangupIcon, {}) }), t(locale, "calls.reject")] })), joinable ? (_jsxs("div", { className: "pmfa-calls-action", children: [_jsx("button", { type: "button", className: "pmfa-calls-btn pmfa-calls-btn-answer", onClick: join, disabled: busy, "aria-label": t(locale, "calls.join"), children: offersVideo && cameraOn ? _jsx(VideoIcon, {}) : _jsx(PhoneIcon, {}) }), t(locale, "calls.join")] })) : claimed ? null : (_jsxs("div", { className: "pmfa-calls-action", children: [_jsx("button", { type: "button", className: "pmfa-calls-btn pmfa-calls-btn-answer", onClick: accept, disabled: busy, "aria-label": t(locale, "calls.answer"), children: offersVideo && cameraOn ? _jsx(VideoIcon, {}) : _jsx(PhoneIcon, {}) }), t(locale, "calls.answer")] }))] }), others.length > 0 && (_jsxs("div", { className: "pmfa-calls-others", children: [_jsx("div", { className: "pmfa-calls-others-title", children: t(locale, "calls.otherIncoming") }), _jsx("ul", { "aria-label": t(locale, "calls.otherIncoming"), children: others.map((invitation) => (_jsxs("li", { children: [_jsx("span", { children: resolveName?.(invitation.from) ?? invitation.from }), _jsx("span", { className: "pmfa-calls-others-state", children: invitation.claimedByOther
382
+ ? t(locale, "calls.answeredElsewhere")
383
+ : invitation.canJoin
384
+ ? t(locale, "calls.join")
385
+ : t(locale, invitation.video
386
+ ? "calls.videoCall"
387
+ : "calls.audioCall") }), _jsx("button", { type: "button", className: "pmfa-calls-chip", onClick: () => ignoreDisposed(() => resolved.select(invitation.callId)), children: t(locale, "calls.show") })] }, invitation.callId))) })] }))] }) }));
388
+ }
389
+ // ── Duration ──────────────────────────────────────────────────────────
390
+ /**
391
+ * Seconds since the call connected, ticking once a second (0 before that).
392
+ * A call that drops to `reconnecting` keeps counting: the controller holds
393
+ * `connectedAt` across the flap, and the call it measures is the same one.
394
+ */
395
+ export function useCallDuration(snapshot) {
396
+ const connectedAt = snapshot.status === "connected" || snapshot.status === "reconnecting"
397
+ ? snapshot.connectedAt
398
+ : undefined;
399
+ const [seconds, setSeconds] = useState(0);
400
+ useEffect(() => {
401
+ if (connectedAt === undefined) {
402
+ setSeconds(0);
403
+ return;
404
+ }
405
+ const tick = () => setSeconds(Math.max(0, Math.floor((Date.now() - connectedAt) / 1000)));
406
+ tick();
407
+ const timer = setInterval(tick, 1000);
408
+ return () => clearInterval(timer);
409
+ }, [connectedAt]);
410
+ return seconds;
411
+ }
412
+ /** Format seconds as `m:ss` (or `h:mm:ss` past an hour). */
413
+ export function formatDuration(totalSeconds) {
414
+ const h = Math.floor(totalSeconds / 3600);
415
+ const m = Math.floor((totalSeconds % 3600) / 60);
416
+ const s = totalSeconds % 60;
417
+ const mm = h > 0 ? String(m).padStart(2, "0") : String(m);
418
+ const ss = String(s).padStart(2, "0");
419
+ return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`;
420
+ }
421
+ /**
422
+ * Name with the line under it: "Ringing +number…" while an outgoing call
423
+ * rings, the peer number until an incoming call connects, then alternating
424
+ * number ⇄ duration on the 5-second cadence (crossfaded via keyed remount).
425
+ */
426
+ function CallIdentity({ name, peer, live, ringingLine, errorLine, flashPeer, seconds, }) {
427
+ const line = errorLine ??
428
+ (live
429
+ ? peer !== null && flashPeer
430
+ ? peer
431
+ : formatDuration(seconds)
432
+ : (ringingLine ?? peer));
433
+ const lineKey = errorLine !== null
434
+ ? "error"
435
+ : live && !(peer !== null && flashPeer)
436
+ ? "duration"
437
+ : "peer";
438
+ return (_jsxs("div", { children: [_jsx("div", { className: "pmfa-calls-name", children: name }), line !== null && (_jsx("div", { className: "pmfa-calls-peer pmfa-calls-flash", role: errorLine === null ? undefined : "status", children: line }, lineKey))] }));
439
+ }
440
+ /**
441
+ * The in-call stage: one tile per remote participant video when anyone sends
442
+ * video, the centered avatar hero otherwise, the local preview as a
443
+ * picture-in-picture tile, and the identity (name + flashing number/duration
444
+ * line). The merged call audio plays through a hidden element. Always dark by
445
+ * design.
446
+ */
447
+ export function CallStage({ controller, createController, displayName, avatarUrl, resolveAvatar, className, labelVideo, renderMedia, }) {
448
+ const resolved = useResolvedController(controller, createController);
449
+ const snapshot = useController(resolved);
450
+ const root = useCallsRoot();
451
+ const { locale } = root;
452
+ const remoteRef = useRef(null);
453
+ const localRef = useRef(null);
454
+ // Snapshots from custom controllers may omit the newer arrays.
455
+ const remoteVideos = snapshot.remoteVideos ?? [];
456
+ const remoteHasVideo = remoteVideos.length > 0;
457
+ const seconds = useCallDuration(snapshot);
458
+ const peerAvatar = usePeerAvatar(snapshot.peer, resolveAvatar, avatarUrl);
459
+ const live = snapshot.status === "connected";
460
+ const localStream = resolved.localStream;
461
+ const remoteStream = resolved.remoteStream;
462
+ const [flashPeer, setFlashPeer] = useState(false);
463
+ useEffect(() => {
464
+ if (!live) {
465
+ setFlashPeer(false);
466
+ return;
467
+ }
468
+ const timer = setInterval(() => setFlashPeer((v) => !v), 5000);
469
+ return () => clearInterval(timer);
470
+ }, [live]);
471
+ useEffect(() => attach(remoteRef.current, remoteStream), [remoteStream, snapshot.revision]);
472
+ useEffect(() => attach(localRef.current, localStream), [localStream, snapshot.revision]);
473
+ const sink = snapshot.selectedDevices.audioOutput;
474
+ useEffect(() => {
475
+ const element = remoteRef.current;
476
+ if (element?.setSinkId !== undefined && sink !== undefined)
477
+ void element.setSinkId(sink).catch(() => undefined);
478
+ }, [sink, snapshot.revision]);
479
+ if (!ACTIVE_STATUSES.has(snapshot.status))
480
+ return null;
481
+ const peer = snapshot.peer ?? "";
482
+ const showLocalVideo = snapshot.video && !snapshot.videoMuted;
483
+ // `live` is false while reconnecting, but the call is established and its
484
+ // layout should stay put — the pre-answer hero preview belongs to a call
485
+ // that has not connected yet.
486
+ const preAccept = showLocalVideo && !live && snapshot.status !== "reconnecting";
487
+ const ringingLine = snapshot.status === "reconnecting"
488
+ ? `${t(locale, "calls.reconnecting")}…`
489
+ : // A placed call rings until the controller reports the answer.
490
+ snapshot.status === "ringing" && snapshot.direction === "outgoing"
491
+ ? t(locale, "calls.ringingTo", { peer })
492
+ : null;
493
+ const identity = (_jsx(CallIdentity, { name: displayName ?? peer, peer: displayName === undefined ? null : peer, live: live, ringingLine: ringingLine, errorLine: snapshot.error?.code === "call_control_failed"
494
+ ? t(locale, "calls.controlFailed")
495
+ : null, flashPeer: flashPeer, seconds: seconds }));
496
+ return (_jsx("div", { className: root.className, style: root.style, dir: root.dir, "data-pmfa": "call", children: _jsx("div", { className: `pmfa-calls-stage${className === undefined ? "" : ` ${className}`}`, children: renderMedia !== undefined ? (renderMedia({
497
+ ...(localStream === undefined ? {} : { local: localStream }),
498
+ ...(remoteStream === undefined ? {} : { remote: remoteStream }),
499
+ videos: remoteVideos.flatMap((info) => {
500
+ const stream = resolved.remoteVideos.find((video) => video.key === info.key)?.stream;
501
+ return stream === undefined
502
+ ? []
503
+ : [{ key: info.key, info, stream }];
504
+ }),
505
+ })) : (_jsxs(_Fragment, { children: [_jsx("video", { ref: remoteRef, className: "pmfa-calls-audio-sink", autoPlay: true, playsInline: true, "aria-hidden": "true" }), remoteHasVideo && (_jsx(ParticipantVideoGrid, { controller: resolved, ...(labelVideo === undefined ? {} : { labelVideo }) })), !remoteHasVideo ? (preAccept ? (_jsxs("div", { className: "pmfa-calls-hero-preview", children: [identity, _jsx("div", { className: "pmfa-calls-inset", children: _jsx("video", { ref: localRef, className: "pmfa-calls-preview", autoPlay: true, playsInline: true, muted: true }) })] })) : (_jsxs("div", { className: "pmfa-calls-audio-hero", children: [_jsx(CallAvatar, { displayName: displayName, avatarUrl: peerAvatar, peer: peer }), identity] }))) : (_jsx("div", { className: "pmfa-calls-stage-id", children: identity })), showLocalVideo && !preAccept && (_jsx("div", { className: "pmfa-calls-pip", children: _jsx("video", { ref: localRef, autoPlay: true, playsInline: true, muted: true }) }))] })) }) }));
506
+ }
507
+ function defaultVideoLabel(video, locale) {
508
+ return (video.participant?.phoneNumber ??
509
+ video.participant?.id ??
510
+ video.connectionParticipant ??
511
+ t(locale, "calls.participant"));
512
+ }
513
+ function VideoTile({ stream, label, locale, }) {
514
+ const ref = useRef(null);
515
+ useEffect(() => attach(ref.current, stream), [stream]);
516
+ return (_jsxs("li", { className: "pmfa-calls-tile", children: [_jsx("video", { ref: ref, autoPlay: true, playsInline: true,
517
+ // Audio is the merged call stream; tiles carry video only.
518
+ muted: true, "aria-label": t(locale, "calls.videoFrom", { name: label }) }), _jsx("span", { className: "pmfa-calls-tile-label", children: label })] }));
519
+ }
520
+ /**
521
+ * One tile per remote participant video, keyed by participant or
522
+ * connection so a tile survives source changes. Videos are separate
523
+ * streams; nothing is composed. Renders nothing without remote video.
524
+ */
525
+ export function ParticipantVideoGrid({ controller, createController, className, labelVideo, }) {
526
+ const resolved = useResolvedController(controller, createController);
527
+ const snapshot = useController(resolved);
528
+ const root = useCallsRoot();
529
+ const { locale } = root;
530
+ const videos = snapshot.remoteVideos ?? [];
531
+ if (videos.length === 0)
532
+ return null;
533
+ const count = videos.length;
534
+ return (_jsx("ul", { className: `pmfa-calls-grid${className === undefined ? "" : ` ${className}`}`, "data-count": count > 4 ? "many" : String(count), "aria-label": t(locale, "calls.participantVideos"), children: videos.map((video) => (_jsx(VideoTile, { stream: resolved.remoteVideos.find((remote) => remote.key === video.key)
535
+ ?.stream, label: labelVideo?.(video) ?? defaultVideoLabel(video, locale), locale: locale }, video.key))) }));
536
+ }
537
+ const PARTICIPANT_STATE = {
538
+ invited: "calls.participantInvited",
539
+ ringing: "calls.participantRinging",
540
+ connected: "calls.participantConnected",
541
+ left: undefined,
542
+ };
543
+ /** The displayed call's WhatsApp participants and their state. */
544
+ export function ParticipantList({ controller, createController, className, resolveName, }) {
545
+ const resolved = useResolvedController(controller, createController);
546
+ const snapshot = useController(resolved);
547
+ const root = useCallsRoot();
548
+ const { locale } = root;
549
+ const participants = (snapshot.participants ?? []).filter((p) => p.state !== "left");
550
+ if (participants.length === 0)
551
+ return null;
552
+ return (_jsx("div", { className: root.className, style: root.style, dir: root.dir, "data-pmfa": "call", children: _jsxs("div", { className: `pmfa-calls-card pmfa-calls-people${className === undefined ? "" : ` ${className}`}`, children: [_jsx("div", { className: "pmfa-calls-others-title", id: `pmfa-people-${snapshot.callId ?? ""}`, children: t(locale, "calls.participants") }), _jsx("ul", { "aria-labelledby": `pmfa-people-${snapshot.callId ?? ""}`, children: participants.map((participant) => {
553
+ const state = PARTICIPANT_STATE[participant.state];
554
+ return (_jsxs("li", { children: [_jsx("span", { children: resolveName?.(participant) ??
555
+ participant.phoneNumber ??
556
+ participant.id }), _jsx("span", { className: "pmfa-calls-others-state", children: [
557
+ state === undefined ? undefined : t(locale, state),
558
+ participant.audioMuted
559
+ ? t(locale, "calls.participantMuted")
560
+ : undefined,
561
+ ]
562
+ .filter(Boolean)
563
+ .join(" · ") })] }, participant.id));
564
+ }) })] }) }));
565
+ }
566
+ /**
567
+ * The in-call dock, after the official window: the camera split group
568
+ * ([toggle|⌄] — the dropdown picks the camera) and the mic split group
569
+ * ([toggle|⌄] — the dropdown picks microphone + speaker) on the left, the
570
+ * red hang-up pill on the right. The camera group renders whenever the line
571
+ * can carry video (unless `disableVideo`) and is disabled until the call
572
+ * connects. Video is per direction: the camera controls only the outgoing
573
+ * stream.
574
+ */
575
+ export function CallControls({ controller, createController, className, disableVideo, showLeave, }) {
576
+ const resolved = useResolvedController(controller, createController);
577
+ const snapshot = useController(resolved);
578
+ const root = useCallsRoot();
579
+ const { locale } = root;
580
+ const { open, toggle } = useDevicePanels(resolved, snapshot);
581
+ if (!ACTIVE_STATUSES.has(snapshot.status))
582
+ return null;
583
+ const live = snapshot.status === "connected";
584
+ // Leaving a call this client placed before it connected would keep the
585
+ // callee ringing; only ending it is offered.
586
+ const placing = snapshot.direction === "outgoing" &&
587
+ !live &&
588
+ snapshot.connectedAt === undefined;
589
+ const leaveShown = !placing && (showLeave ?? !snapshot.exclusive);
590
+ const showCamera = snapshot.capabilities.video && disableVideo !== true;
591
+ // The affordance stays visible on an audio call (the settled design) and is
592
+ // disabled until it can act: connected, and either already a video call or
593
+ // an upgrade the media session can actually perform. A session that cannot
594
+ // renegotiate therefore shows the same disabled button as a call that has
595
+ // not connected yet, instead of a live one that does nothing.
596
+ const cameraReady = live && (snapshot.video || resolved.canEnableVideo);
597
+ const cameraOff = !snapshot.video || snapshot.videoMuted;
598
+ const onCamera = () => {
599
+ // On a video call the button mutes/unmutes the outgoing track; on an
600
+ // audio call it upgrades to video (camera + re-offer on the same
601
+ // connection) through the controller.
602
+ if (snapshot.video)
603
+ ignoreDisposed(() => resolved.setMuted({ video: !snapshot.videoMuted }));
604
+ // A denied camera, a failed re-offer, or a call that ended mid-upgrade all
605
+ // reject here. The upgrade rolls itself back, so the audio call carries on
606
+ // and the button stays live for another try; swallowing the rejection just
607
+ // keeps a declined camera from surfacing as an unhandled error.
608
+ else
609
+ void resolved.enableVideo().catch(() => undefined);
610
+ };
611
+ return (_jsx("div", { className: root.className, style: root.style, dir: root.dir, "data-pmfa": "call", children: _jsxs("div", { className: "pmfa-calls-dock-col", children: [open === "mic" && (_jsx("div", { className: "pmfa-calls-pop", role: "group", "aria-label": t(locale, "calls.microphoneSettings"), children: _jsx(DeviceFields, { controller: resolved, snapshot: snapshot, fields: ["audioInput", "audioOutput"], locale: locale }) })), open === "cam" && (_jsx("div", { className: "pmfa-calls-pop", role: "group", "aria-label": t(locale, "calls.cameraSettings"), children: _jsx(DeviceFields, { controller: resolved, snapshot: snapshot, fields: ["videoInput"], locale: locale }) })), _jsxs("div", { className: `pmfa-calls-dock${className === undefined ? "" : ` ${className}`}`, children: [showCamera && (_jsxs("div", { className: `pmfa-calls-group${cameraReady ? "" : " pmfa-calls-disabled"}`, children: [_jsx("button", { type: "button", className: `pmfa-calls-btn pmfa-calls-btn-ctrl${cameraOff ? " pmfa-calls-on" : ""}`, onClick: onCamera, disabled: !cameraReady, "aria-label": t(locale, cameraOff ? "calls.cameraOn" : "calls.cameraOff"), "aria-pressed": cameraOff, children: cameraOff ? _jsx(VideoOffIcon, {}) : _jsx(VideoIcon, {}) }), _jsx("button", { type: "button", className: `pmfa-calls-btn pmfa-calls-btn-acc${open === "cam" ? " pmfa-calls-open" : ""}`, onClick: () => toggle("cam"), disabled: !live, "aria-label": t(locale, "calls.cameraSettings"), "aria-expanded": open === "cam", children: _jsx(ChevronDownIcon, {}) })] })), snapshot.capabilities.mute && (_jsxs("div", { className: "pmfa-calls-group", children: [_jsx("button", { type: "button", className: `pmfa-calls-btn pmfa-calls-btn-ctrl${snapshot.audioMuted ? " pmfa-calls-on" : ""}`, onClick: () => ignoreDisposed(() => resolved.setMuted({ audio: !snapshot.audioMuted })), "aria-label": t(locale, snapshot.audioMuted ? "calls.unmute" : "calls.mute"), "aria-pressed": snapshot.audioMuted, children: snapshot.audioMuted ? _jsx(MicOffIcon, {}) : _jsx(MicIcon, {}) }), _jsx("button", { type: "button", className: `pmfa-calls-btn pmfa-calls-btn-acc${open === "mic" ? " pmfa-calls-open" : ""}`, onClick: () => toggle("mic"), "aria-label": t(locale, "calls.microphoneSettings"), "aria-expanded": open === "mic", children: _jsx(ChevronDownIcon, {}) })] })), leaveShown && (_jsx("button", { type: "button", className: "pmfa-calls-btn pmfa-calls-btn-ctrl pmfa-calls-btn-leave", onClick: () => void resolved.leave().catch(() => undefined), "aria-label": t(locale, "calls.leave"), title: t(locale, "calls.leave"), children: _jsx(LeaveIcon, {}) })), _jsx("button", { type: "button", className: "pmfa-calls-btn pmfa-calls-btn-hangup",
612
+ // Rejects on a disposed controller, which a mounted surface
613
+ // holding an application-supplied one can still be clicked
614
+ // against; the call is over either way.
615
+ onClick: () => void resolved.hangup().catch(() => undefined), "aria-label": t(locale, leaveShown ? "calls.end" : "calls.hangup"), title: t(locale, leaveShown ? "calls.end" : "calls.hangup"), children: _jsx(HangupIcon, {}) })] })] }) }));
616
+ }
617
+ // ── DialPad ───────────────────────────────────────────────────────────
618
+ const KEYS = [
619
+ "1",
620
+ "2",
621
+ "3",
622
+ "4",
623
+ "5",
624
+ "6",
625
+ "7",
626
+ "8",
627
+ "9",
628
+ "+",
629
+ "0",
630
+ "#",
631
+ ];
632
+ /**
633
+ * A dial pad card: E.164 entry plus place-call buttons — audio always, and a
634
+ * video-call button unless `allowVideo` is false.
635
+ */
636
+ export function DialPad({ controller, createController, defaultValue = "", allowVideo = true, onPlaced, className, }) {
637
+ const resolved = useResolvedController(controller, createController);
638
+ const snapshot = useController(resolved);
639
+ const root = useCallsRoot();
640
+ const { locale } = root;
641
+ const [value, setValue] = useState(defaultValue);
642
+ const busy = snapshot.status === "incoming" || ACTIVE_STATUSES.has(snapshot.status);
643
+ const canVideo = allowVideo;
644
+ const dial = (video) => {
645
+ const to = value.trim();
646
+ // The buttons are disabled while a call is up, but the input keeps focus
647
+ // and its value, so Enter would otherwise place a second call — and
648
+ // `place()` aborts the current operation, tearing down the live call.
649
+ if (busy || to.length === 0)
650
+ return;
651
+ // `place()` resolves even when the placement failed — the controller
652
+ // reports that through the snapshot — so the snapshot is what says
653
+ // whether there is now a call to announce.
654
+ void resolved
655
+ .place(to, { video })
656
+ .then(() => {
657
+ if (ACTIVE_STATUSES.has(resolved.getSnapshot().status))
658
+ onPlaced?.(to);
659
+ })
660
+ // A placement failure lands in the snapshot, not here, but `place()`
661
+ // does reject on a disposed controller — which a mounted pad holding a
662
+ // shared one can still be dialled against.
663
+ .catch(() => undefined);
664
+ };
665
+ return (_jsx("div", { className: root.className, style: root.style, dir: root.dir, "data-pmfa": "call", children: _jsxs("div", { className: `pmfa-calls-card pmfa-calls-dialpad${className === undefined ? "" : ` ${className}`}`, children: [_jsx("input", { className: "pmfa-calls-input", type: "tel", inputMode: "tel", placeholder: "+1 555 000 0000", value: value, onChange: (event) => setValue(event.currentTarget.value), onKeyDown: (event) => {
666
+ if (event.key === "Enter")
667
+ dial(false);
668
+ }, "aria-label": t(locale, "calls.dialPlaceholder") }), _jsx("div", { className: "pmfa-calls-keys", children: KEYS.map((digit) => (_jsx("button", { type: "button", className: "pmfa-calls-key", onClick: () => setValue((v) => v + digit), "aria-label": digit, children: _jsx("span", { className: "pmfa-calls-key-digit", children: digit }) }, digit))) }), _jsxs("div", { className: "pmfa-calls-dial-actions", children: [_jsx("button", { type: "button", className: "pmfa-calls-btn pmfa-calls-btn-answer", onClick: () => dial(false), disabled: busy || value.trim().length === 0, "aria-label": t(locale, "calls.placeAudioCall"), children: _jsx(PhoneIcon, {}) }), canVideo && (_jsx("button", { type: "button", className: "pmfa-calls-btn pmfa-calls-btn-answer", onClick: () => dial(true), disabled: busy || value.trim().length === 0, "aria-label": t(locale, "calls.placeVideoCall"), children: _jsx(VideoIcon, {}) })), _jsx("button", { type: "button", className: "pmfa-calls-key pmfa-calls-key-erase", onClick: () => setValue((v) => v.slice(0, -1)), disabled: value.length === 0, "aria-label": t(locale, "calls.deleteDigit"), children: _jsx(EraseIcon, {}) })] })] }) }));
669
+ }
670
+ /**
671
+ * Owns a separate, freely resizable call window (like the desktop client's)
672
+ * and hands back a container to portal the stage + controls into. Media
673
+ * streams keep playing across same-origin windows, so portal'd `<video>`
674
+ * elements just work.
675
+ */
676
+ /**
677
+ * @param title Window title. `CallSurface` passes the localized
678
+ * `calls.popoutTitle`; the default keeps the hook usable on its own.
679
+ */
680
+ export function useCallPopout(title = "Call") {
681
+ const windowRef = useRef(null);
682
+ const [container, setContainer] = useState(null);
683
+ const close = useCallback(() => {
684
+ windowRef.current?.close();
685
+ windowRef.current = null;
686
+ setContainer(null);
687
+ }, []);
688
+ const open = useCallback(() => {
689
+ if (typeof window === "undefined" || windowRef.current !== null)
690
+ return;
691
+ const popup = window.open("", "pmfa-call", "popup=yes,width=420,height=600");
692
+ if (popup === null)
693
+ return; // popup blocked — the call stays inline
694
+ const doc = popup.document;
695
+ doc.title = title;
696
+ const style = doc.createElement("style");
697
+ style.textContent = `html,body{margin:0;height:100%;background:oklch(0.15 0 0);}${CALLS_STYLES}`;
698
+ doc.head.appendChild(style);
699
+ const root = doc.createElement("div");
700
+ root.style.height = "100%";
701
+ doc.body.appendChild(root);
702
+ windowRef.current = popup;
703
+ setContainer(root);
704
+ }, [title]);
705
+ useEffect(() => {
706
+ if (container === null)
707
+ return;
708
+ const timer = setInterval(() => {
709
+ if (windowRef.current?.closed === true) {
710
+ windowRef.current = null;
711
+ setContainer(null);
712
+ }
713
+ }, 500);
714
+ return () => clearInterval(timer);
715
+ }, [container]);
716
+ useEffect(() => () => windowRef.current?.close(), []);
717
+ return { popped: container !== null, open, close, container };
718
+ }
719
+ /**
720
+ * The whole call experience in one component: a fixed overlay that shows the
721
+ * incoming-call card while ringing and the stage + control dock during a
722
+ * call. The ↗ button pops the call into its own resizable browser window;
723
+ * closing that window (or the call ending) pops it back inline.
724
+ */
725
+ export function CallSurface({ controller, createController, resolveName, resolveAvatar, disableVideo, popout: allowPopout = true, renderMedia, exclusive = false, showParticipants = true, showLeave, labelVideo, }) {
726
+ const resolved = useResolvedController(controller, createController);
727
+ const snapshot = useController(resolved);
728
+ const root = useCallsRoot();
729
+ const { locale } = root;
730
+ const popout = useCallPopout(t(locale, "calls.popoutTitle"));
731
+ const peer = snapshot.peer;
732
+ const displayName = peer === undefined ? undefined : resolveName?.(peer);
733
+ const active = ACTIVE_STATUSES.has(snapshot.status);
734
+ const incoming = snapshot.status === "incoming";
735
+ const { popped, close } = popout;
736
+ useEffect(() => {
737
+ if (!active && popped)
738
+ close();
739
+ }, [active, popped, close]);
740
+ if (!incoming && !active)
741
+ return null;
742
+ const overlay = {
743
+ position: "fixed",
744
+ right: 20,
745
+ bottom: 20,
746
+ zIndex: 2147483000,
747
+ display: "flex",
748
+ flexDirection: "column",
749
+ alignItems: "center",
750
+ gap: 12,
751
+ width: 340,
752
+ maxWidth: "calc(100vw - 40px)",
753
+ };
754
+ const stageProps = {
755
+ controller: resolved,
756
+ ...(displayName === undefined ? {} : { displayName }),
757
+ ...(resolveAvatar === undefined ? {} : { resolveAvatar }),
758
+ ...(renderMedia === undefined ? {} : { renderMedia }),
759
+ ...(labelVideo === undefined ? {} : { labelVideo }),
760
+ };
761
+ const callUi = (_jsxs(_Fragment, { children: [_jsx(CallStage, { ...stageProps }), _jsx(CallControls, { controller: resolved, ...(disableVideo === undefined ? {} : { disableVideo }), ...(showLeave === undefined ? {} : { showLeave }) }), showParticipants && _jsx(ParticipantList, { controller: resolved })] }));
762
+ return (_jsxs("div", { style: overlay, "data-pmfa": "call-surface", children: [_jsx(IncomingCallCard, { controller: resolved, exclusive: exclusive, ...(displayName === undefined ? {} : { displayName }), ...(resolveAvatar === undefined ? {} : { resolveAvatar }), ...(resolveName === undefined ? {} : { resolveName }) }), active &&
763
+ (popout.popped && popout.container !== null ? (_jsxs(_Fragment, { children: [createPortal(_jsx("div", { className: `${root.className} pmfa-calls-popout`, style: root.style, dir: root.dir, children: callUi }), popout.container), _jsx("div", { className: root.className, style: root.style, dir: root.dir, children: _jsxs("div", { className: "pmfa-calls-popbar", children: [t(locale, "calls.popOut"), _jsx("button", { type: "button", className: "pmfa-calls-btn pmfa-calls-mini", onClick: popout.close, "aria-label": t(locale, "calls.popIn"), children: _jsx(PopoutIcon, { style: { transform: "scaleX(-1)" } }) })] }) })] })) : (_jsxs("div", { style: { position: "relative", width: "100%" }, children: [callUi, allowPopout && (_jsx("div", { className: root.className, style: root.style, dir: root.dir, children: _jsx("div", { className: "pmfa-calls-more", children: _jsx("button", { type: "button", className: "pmfa-calls-btn pmfa-calls-mini", onClick: popout.open, "aria-label": t(locale, "calls.popOut"), children: _jsx(PopoutIcon, {}) }) }) }))] })))] }));
764
+ }
765
+ // ── Stylesheet ────────────────────────────────────────────────────────
766
+ /**
767
+ * One injected `<style>` tag, scoped under `.pmfa-calls` so nothing leaks into
768
+ * the host app, inside the `polymorfa` cascade layer so unlayered host CSS
769
+ * wins. Colors derive from the shared `--pmfa-*` appearance variables
770
+ * with the monochrome Material-3 palette as fallbacks; the verbs use
771
+ * `--pmfa-color-success` / `--pmfa-color-danger`. All motion is disabled under
772
+ * `prefers-reduced-motion: reduce`.
773
+ */
774
+ export const CALLS_STYLES = `
775
+ @layer polymorfa {
776
+ .pmfa-calls {
777
+ --pmfa-calls-accent: var(--pmfa-color-foreground, oklch(0.15 0 0));
778
+ --pmfa-calls-accent-soft: oklch(0.96 0 0);
779
+ --pmfa-calls-answer: var(--pmfa-color-success, oklch(0.76 0.19 150));
780
+ --pmfa-calls-answer-strong: oklch(0.68 0.18 150);
781
+ --pmfa-calls-danger: var(--pmfa-color-danger, oklch(0.5 0.19 28));
782
+ --pmfa-calls-danger-strong: oklch(0.44 0.18 28);
783
+ --pmfa-calls-card: var(--pmfa-color-background, oklch(1 0 0));
784
+ --pmfa-calls-fg: var(--pmfa-color-foreground, oklch(0.17 0 0));
785
+ --pmfa-calls-muted: var(--pmfa-color-muted, oklch(0.49 0.008 250));
786
+ --pmfa-calls-border: var(--pmfa-color-border, oklch(0.91 0 0));
787
+ --pmfa-calls-stage: oklch(0.15 0 0);
788
+ --pmfa-calls-stage-fg: oklch(0.985 0 0);
789
+ --pmfa-calls-stage-muted: oklch(0.75 0 0);
790
+ --pmfa-calls-stage-line: oklch(1 0 0 / 0.12);
791
+ --pmfa-calls-avatar-1: oklch(0.55 0.09 200);
792
+ --pmfa-calls-avatar-2: oklch(0.55 0.12 255);
793
+ --pmfa-calls-avatar-3: oklch(0.55 0.14 292);
794
+ --pmfa-calls-avatar-4: oklch(0.58 0.14 10);
795
+ --pmfa-calls-avatar-5: oklch(0.6 0.11 70);
796
+ --pmfa-calls-avatar-6: oklch(0.55 0.11 150);
797
+ --pmfa-calls-avatar-7: oklch(0.5 0.13 275);
798
+ --pmfa-calls-avatar-8: oklch(0.55 0.14 330);
799
+ --pmfa-calls-radius: var(--pmfa-radius-medium, 0.5rem);
800
+ --pmfa-calls-font: var(--pmfa-font-family, "Roboto", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif);
801
+ font-family: var(--pmfa-calls-font);
802
+ color: var(--pmfa-calls-fg);
803
+ font-size: 14px;
804
+ line-height: 1.45;
805
+ -webkit-font-smoothing: antialiased;
806
+ }
807
+ .pmfa-calls *, .pmfa-calls *::before, .pmfa-calls *::after { box-sizing: border-box; }
808
+ .pmfa-calls :focus-visible { outline: 2px solid var(--pmfa-calls-accent); outline-offset: 2px; }
809
+ .pmfa-calls.pmfa-calls-dark {
810
+ --pmfa-calls-accent: oklch(0.92 0 0);
811
+ --pmfa-calls-accent-soft: oklch(1 0 0 / 0.08);
812
+ --pmfa-calls-card: oklch(0.21 0 0);
813
+ --pmfa-calls-fg: oklch(0.985 0 0);
814
+ --pmfa-calls-muted: oklch(0.74 0 0);
815
+ --pmfa-calls-border: oklch(1 0 0 / 0.12);
816
+ }
817
+ @media (prefers-color-scheme: dark) {
818
+ .pmfa-calls.pmfa-calls-auto {
819
+ --pmfa-calls-accent: oklch(0.92 0 0);
820
+ --pmfa-calls-accent-soft: oklch(1 0 0 / 0.08);
821
+ --pmfa-calls-card: oklch(0.21 0 0);
822
+ --pmfa-calls-fg: oklch(0.985 0 0);
823
+ --pmfa-calls-muted: oklch(0.74 0 0);
824
+ --pmfa-calls-border: oklch(1 0 0 / 0.12);
825
+ }
826
+ }
827
+ .pmfa-calls-card { background: var(--pmfa-calls-card); border: 1px solid var(--pmfa-calls-border); border-radius: var(--pmfa-calls-radius); }
828
+ .pmfa-calls-incoming { position: relative; width: 100%; padding: 22px 16px 18px; display: flex; flex-direction: column; text-align: center; background: var(--pmfa-calls-stage); color: var(--pmfa-calls-stage-fg); border-color: var(--pmfa-calls-stage-line); animation: pmfa-calls-rise 0.28s cubic-bezier(0.16, 1, 0.3, 1); }
829
+ .pmfa-calls-incoming .pmfa-calls-name { font-size: 19px; font-weight: 600; }
830
+ .pmfa-calls-subtitle { margin-top: 3px; font-size: 13px; color: var(--pmfa-calls-stage-muted); }
831
+ .pmfa-calls-media { position: relative; width: 100%; margin-top: 16px; border-radius: var(--pmfa-calls-radius); overflow: hidden; aspect-ratio: 16 / 10; background: oklch(0.08 0 0); }
832
+ .pmfa-calls-preview { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; transform: scaleX(-1); }
833
+ .pmfa-calls-media-audio { overflow: visible; background: transparent; display: flex; align-items: center; justify-content: center; }
834
+ .pmfa-calls-media-audio .pmfa-calls-avatar-wrap { margin-bottom: 0; }
835
+ .pmfa-calls-mini { width: 36px; height: 36px; background: oklch(1 0 0 / 0.14); color: oklch(0.985 0 0); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); }
836
+ .pmfa-calls-mini:hover { background: oklch(1 0 0 / 0.22); }
837
+ .pmfa-calls-mini svg { width: 16px; height: 16px; }
838
+ .pmfa-calls-mini.pmfa-calls-on { background: oklch(0.985 0 0); color: oklch(0.2 0 0); }
839
+ .pmfa-calls-more { position: absolute; top: 10px; right: 10px; z-index: 2; }
840
+ .pmfa-calls-media-ctrls { position: absolute; bottom: 10px; left: 0; right: 0; display: flex; justify-content: center; gap: 10px; z-index: 2; }
841
+ .pmfa-calls-pop-float { position: absolute; left: 50%; transform: translateX(-50%); top: 96px; z-index: 4; }
842
+ .pmfa-calls-avatar-wrap { position: relative; width: 72px; height: 72px; margin-bottom: 14px; }
843
+ .pmfa-calls-avatar { position: relative; z-index: 1; width: 72px; height: 72px; border-radius: 50%; display: flex; align-items: center; justify-content: center; overflow: hidden; color: oklch(0.985 0 0); font-size: 24px; font-weight: 500; letter-spacing: 0.01em; background: oklch(0.22 0 0); }
844
+ .pmfa-calls-audio-hero .pmfa-calls-avatar { border: 1.5px solid var(--pmfa-calls-stage-line); }
845
+ .pmfa-calls-avatar-c1 { background: var(--pmfa-calls-avatar-1); }
846
+ .pmfa-calls-avatar-c2 { background: var(--pmfa-calls-avatar-2); }
847
+ .pmfa-calls-avatar-c3 { background: var(--pmfa-calls-avatar-3); }
848
+ .pmfa-calls-avatar-c4 { background: var(--pmfa-calls-avatar-4); }
849
+ .pmfa-calls-avatar-c5 { background: var(--pmfa-calls-avatar-5); }
850
+ .pmfa-calls-avatar-c6 { background: var(--pmfa-calls-avatar-6); }
851
+ .pmfa-calls-avatar-c7 { background: var(--pmfa-calls-avatar-7); }
852
+ .pmfa-calls-avatar-c8 { background: var(--pmfa-calls-avatar-8); }
853
+ .pmfa-calls-avatar img { width: 100%; height: 100%; object-fit: cover; }
854
+ .pmfa-calls-name { font-size: 16px; font-weight: 500; letter-spacing: 0.01em; text-wrap: balance; }
855
+ .pmfa-calls-peer { font-size: 12.5px; font-variant-numeric: tabular-nums; color: var(--pmfa-calls-muted); letter-spacing: 0.01em; }
856
+ .pmfa-calls-flash { animation: pmfa-calls-swap 0.35s ease; }
857
+ @keyframes pmfa-calls-swap { from { opacity: 0; transform: translateY(2px); } to { opacity: 1; transform: none; } }
858
+ .pmfa-calls-btn { appearance: none; border: none; margin: 0; padding: 0; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; border-radius: 50%; color: oklch(0.985 0 0); transition: background-color 0.14s ease; }
859
+ .pmfa-calls-btn:disabled { opacity: 0.5; cursor: not-allowed; }
860
+ .pmfa-calls-btn svg { width: 22px; height: 22px; }
861
+ .pmfa-calls-actions { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; margin-top: 22px; width: 100%; }
862
+ .pmfa-calls-action { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 8px; font-size: 11.5px; color: var(--pmfa-calls-stage-muted); }
863
+ .pmfa-calls-btn-answer { width: 56px; height: 56px; background: var(--pmfa-calls-answer); animation: pmfa-calls-beckon 2s ease-in-out infinite; }
864
+ .pmfa-calls-btn-answer:hover { background: var(--pmfa-calls-answer-strong); }
865
+ .pmfa-calls-btn-decline { width: 56px; height: 56px; background: var(--pmfa-calls-danger); }
866
+ .pmfa-calls-btn-decline:hover { background: var(--pmfa-calls-danger-strong); }
867
+ @keyframes pmfa-calls-beckon { 0%, 100% { box-shadow: 0 0 0 0 oklch(0.76 0.19 150 / 0.4); } 50% { box-shadow: 0 0 0 10px oklch(0.76 0.19 150 / 0); } }
868
+ @keyframes pmfa-calls-fade { from { opacity: 0; } to { opacity: 1; } }
869
+ .pmfa-calls-pop { width: 236px; padding: 12px; display: flex; flex-direction: column; gap: 10px; text-align: left; background: var(--pmfa-calls-card); color: var(--pmfa-calls-fg); border: 1px solid var(--pmfa-calls-border); border-radius: calc(var(--pmfa-calls-radius) + 4px); animation: pmfa-calls-fade 0.16s ease; }
870
+ .pmfa-calls-field { display: flex; flex-direction: column; gap: 4px; font-size: 10.5px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.07em; color: var(--pmfa-calls-muted); }
871
+ .pmfa-calls-select { width: 100%; padding: 7px 8px; font: inherit; font-family: var(--pmfa-calls-font); font-size: 12.5px; font-weight: 400; text-transform: none; letter-spacing: normal; color: var(--pmfa-calls-fg); background: transparent; border: 1px solid var(--pmfa-calls-border); border-radius: var(--pmfa-calls-radius); }
872
+ .pmfa-calls-select:focus { outline: none; border-color: var(--pmfa-calls-accent); }
873
+ .pmfa-calls-select option { color: initial; background: initial; }
874
+ .pmfa-calls-stage { position: relative; width: 100%; aspect-ratio: 4 / 3; border-radius: var(--pmfa-calls-radius); overflow: hidden; background: radial-gradient(130% 90% at 50% 0%, oklch(1 0 0 / 0.06) 0%, transparent 62%), var(--pmfa-calls-stage); color: var(--pmfa-calls-stage-fg); }
875
+ .pmfa-calls-audio-sink { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
876
+ .pmfa-calls-grid { position: absolute; inset: 0; margin: 0; padding: 4px; list-style: none; display: grid; gap: 4px; grid-template-columns: repeat(auto-fit, minmax(min(100%, 120px), 1fr)); grid-auto-rows: 1fr; background: var(--pmfa-calls-stage); }
877
+ .pmfa-calls-grid[data-count="1"] { grid-template-columns: 1fr; padding: 0; }
878
+ .pmfa-calls-tile { position: relative; min-height: 0; overflow: hidden; border-radius: var(--pmfa-calls-radius); background: oklch(0.08 0 0); }
879
+ .pmfa-calls-grid[data-count="1"] .pmfa-calls-tile { border-radius: 0; }
880
+ .pmfa-calls-tile video { width: 100%; height: 100%; object-fit: cover; display: block; }
881
+ .pmfa-calls-tile-label { position: absolute; left: 6px; bottom: 6px; max-width: calc(100% - 12px); padding: 2px 6px; border-radius: 4px; font-size: 11.5px; color: var(--pmfa-calls-stage-fg); background: oklch(0.1 0 0 / 0.6); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
882
+ .pmfa-calls-btn-dismiss { width: 56px; height: 56px; background: oklch(1 0 0 / 0.14); }
883
+ .pmfa-calls-btn-dismiss:hover { background: oklch(1 0 0 / 0.22); }
884
+ .pmfa-calls-btn-leave { width: 46px; }
885
+ .pmfa-calls-others, .pmfa-calls-people { width: 100%; margin-top: 14px; text-align: left; }
886
+ .pmfa-calls-people { margin-top: 0; padding: 12px 14px; }
887
+ .pmfa-calls-others ul, .pmfa-calls-people ul { list-style: none; margin: 6px 0 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
888
+ .pmfa-calls-others li, .pmfa-calls-people li { display: flex; align-items: center; gap: 8px; font-size: 13px; min-width: 0; }
889
+ .pmfa-calls-others li > span:first-child, .pmfa-calls-people li > span:first-child { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
890
+ .pmfa-calls-others-title { font-size: 10.5px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.07em; color: var(--pmfa-calls-muted); }
891
+ .pmfa-calls-incoming .pmfa-calls-others-title, .pmfa-calls-others-state { color: var(--pmfa-calls-stage-muted); }
892
+ .pmfa-calls-people .pmfa-calls-others-state { color: var(--pmfa-calls-muted); }
893
+ .pmfa-calls-others-state { font-size: 12px; white-space: nowrap; }
894
+ .pmfa-calls-chip { appearance: none; border: 1px solid var(--pmfa-calls-stage-line); background: transparent; color: inherit; font: inherit; font-size: 12px; padding: 4px 10px; border-radius: 999px; cursor: pointer; }
895
+ .pmfa-calls-chip:hover { background: oklch(1 0 0 / 0.12); }
896
+ .pmfa-calls-audio-hero { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; text-align: center; }
897
+ .pmfa-calls-audio-hero .pmfa-calls-name { font-size: 19px; }
898
+ .pmfa-calls-audio-hero .pmfa-calls-peer { color: var(--pmfa-calls-stage-muted); }
899
+ .pmfa-calls-hero-preview { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 18px; padding: 0 24px; text-align: center; }
900
+ .pmfa-calls-hero-preview .pmfa-calls-peer { color: var(--pmfa-calls-stage-muted); }
901
+ .pmfa-calls-inset { position: relative; width: 82%; aspect-ratio: 16 / 10; border-radius: var(--pmfa-calls-radius); overflow: hidden; background: oklch(0.08 0 0); }
902
+ .pmfa-calls-pip { position: absolute; right: 14px; bottom: 14px; width: 96px; aspect-ratio: 3 / 4; border-radius: var(--pmfa-calls-radius); overflow: hidden; border: 1.5px solid var(--pmfa-calls-stage-line); background: oklch(0.24 0 0); }
903
+ .pmfa-calls-pip video { width: 100%; height: 100%; object-fit: cover; transform: scaleX(-1); }
904
+ .pmfa-calls-stage-id { position: absolute; top: 0; left: 0; right: 0; padding: 16px 18px 30px; background: linear-gradient(oklch(0.1 0 0 / 0.6), transparent); color: var(--pmfa-calls-stage-fg); }
905
+ .pmfa-calls-stage-id .pmfa-calls-peer { color: var(--pmfa-calls-stage-muted); }
906
+ .pmfa-calls-dock-col { position: relative; display: inline-flex; flex-direction: column; align-items: center; }
907
+ .pmfa-calls-dock-col > .pmfa-calls-pop { position: absolute; bottom: calc(100% + 10px); left: 50%; transform: translateX(-50%); z-index: 5; }
908
+ .pmfa-calls-dock { display: inline-flex; align-items: center; gap: 12px; padding: 10px 14px; border-radius: 999px; border: 1px solid var(--pmfa-calls-stage-line); background: oklch(0.17 0 0 / 0.72); backdrop-filter: blur(18px) saturate(1.4); -webkit-backdrop-filter: blur(18px) saturate(1.4); animation: pmfa-calls-rise 0.32s cubic-bezier(0.16, 1, 0.3, 1); }
909
+ .pmfa-calls-btn-ctrl { width: 46px; height: 46px; background: oklch(1 0 0 / 0.1); color: oklch(0.985 0 0); }
910
+ .pmfa-calls-btn-ctrl:hover { background: oklch(1 0 0 / 0.18); }
911
+ .pmfa-calls-group { display: inline-flex; align-items: stretch; border-radius: 999px; background: oklch(1 0 0 / 0.1); overflow: hidden; }
912
+ .pmfa-calls-group.pmfa-calls-disabled { opacity: 0.45; }
913
+ .pmfa-calls-group .pmfa-calls-btn-ctrl { background: transparent; border-radius: 0; }
914
+ .pmfa-calls-group .pmfa-calls-btn-ctrl:hover { background: oklch(1 0 0 / 0.12); }
915
+ .pmfa-calls-group .pmfa-calls-btn-ctrl.pmfa-calls-on { background: oklch(0.985 0 0); }
916
+ .pmfa-calls-btn-acc { width: 30px; height: 46px; border-radius: 0; background: transparent; color: oklch(0.985 0 0 / 0.8); border-left: 1px solid oklch(1 0 0 / 0.12); }
917
+ .pmfa-calls-btn-acc:hover { background: oklch(1 0 0 / 0.12); transform: none; }
918
+ .pmfa-calls-btn-acc svg { width: 14px; height: 14px; transition: transform 0.18s ease; }
919
+ .pmfa-calls-btn-acc.pmfa-calls-open svg { transform: rotate(180deg); }
920
+ .pmfa-calls-btn-ctrl.pmfa-calls-on { background: oklch(0.985 0 0); color: oklch(0.2 0 0); }
921
+ .pmfa-calls-btn-hangup { width: 64px; height: 46px; border-radius: 999px; background: var(--pmfa-calls-danger); }
922
+ .pmfa-calls-btn-hangup:hover { background: var(--pmfa-calls-danger-strong); }
923
+ .pmfa-calls-dialpad { width: 100%; padding: 20px; display: flex; flex-direction: column; gap: 16px; }
924
+ .pmfa-calls-input { width: 100%; border: none; background: transparent; color: var(--pmfa-calls-fg); font-variant-numeric: tabular-nums; font-size: 22px; text-align: center; letter-spacing: 0.03em; padding: 6px 2px 10px; border-bottom: 1px solid var(--pmfa-calls-border); }
925
+ .pmfa-calls-input:focus { outline: none; border-bottom-color: var(--pmfa-calls-accent); }
926
+ .pmfa-calls-input::placeholder { color: var(--pmfa-calls-muted); opacity: 0.6; }
927
+ .pmfa-calls-keys { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; justify-items: center; }
928
+ .pmfa-calls-key { appearance: none; border: none; margin: 0; width: 62px; height: 48px; border-radius: var(--pmfa-calls-radius); background: transparent; color: var(--pmfa-calls-fg); font-family: var(--pmfa-calls-font); display: flex; align-items: center; justify-content: center; cursor: pointer; transition: background-color 0.12s ease; }
929
+ .pmfa-calls-key:hover { background: var(--pmfa-calls-accent-soft); }
930
+ .pmfa-calls-key-digit { font-size: 21px; font-weight: 500; font-variant-numeric: tabular-nums; line-height: 1.1; }
931
+ .pmfa-calls-dial-actions { display: flex; align-items: center; justify-content: center; gap: 18px; }
932
+ .pmfa-calls-dialpad .pmfa-calls-btn-answer { animation: none; }
933
+ .pmfa-calls-key-erase { color: var(--pmfa-calls-muted); }
934
+ .pmfa-calls-key-erase:hover { color: var(--pmfa-calls-fg); background: var(--pmfa-calls-accent-soft); }
935
+ .pmfa-calls-key-erase svg { width: 20px; height: 20px; }
936
+ .pmfa-calls-popout { display: flex; flex-direction: column; align-items: center; gap: 12px; height: 100%; padding: 12px; background: var(--pmfa-calls-stage); }
937
+ .pmfa-calls-popout .pmfa-calls-stage { flex: 1; min-height: 0; aspect-ratio: auto; }
938
+ .pmfa-calls-popbar { display: flex; align-items: center; justify-content: space-between; gap: 10px; width: 100%; padding: 8px 8px 8px 14px; border-radius: var(--pmfa-calls-radius); border: 1px solid var(--pmfa-calls-stage-line); background: var(--pmfa-calls-stage); color: var(--pmfa-calls-stage-fg); font-size: 13px; }
939
+ @keyframes pmfa-calls-rise { from { opacity: 0; transform: translateY(8px) scale(0.97); } to { opacity: 1; transform: translateY(0) scale(1); } }
940
+ @media (prefers-reduced-motion: reduce) {
941
+ .pmfa-calls *, .pmfa-calls *::before, .pmfa-calls *::after { animation: none !important; transition: none !important; }
942
+ }
943
+ }
944
+ `;