@coventia/softphone 0.2.0 → 0.3.0
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/{call-DUVnDxsz.d.cts → call-CaPDWVk3.d.cts} +50 -1
- package/dist/{call-DUVnDxsz.d.ts → call-CaPDWVk3.d.ts} +50 -1
- package/dist/{chunk-YWI6EQYS.js → chunk-EYEDPD7Y.js} +108 -15
- package/dist/chunk-EYEDPD7Y.js.map +1 -0
- package/dist/{chunk-LMJXX4YM.js → chunk-UHNLEBBO.js} +4 -2
- package/dist/chunk-UHNLEBBO.js.map +1 -0
- package/dist/element/index.d.ts +76 -11
- package/dist/element/index.js +1411 -478
- package/dist/element/index.js.map +1 -1
- package/dist/{engine-EPCKf-Bl.d.ts → engine-BgBsIcEa.d.ts} +1 -1
- package/dist/{engine-CDjkrXHY.d.cts → engine-CU2rEo3g.d.cts} +1 -1
- package/dist/index.cjs +318 -32
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -5
- package/dist/index.d.ts +5 -5
- package/dist/index.js +157 -19
- package/dist/index.js.map +1 -1
- package/dist/react/index.cjs +2 -0
- package/dist/react/index.cjs.map +1 -1
- package/dist/react/index.d.cts +2 -2
- package/dist/react/index.d.ts +2 -2
- package/dist/react/index.js +1 -1
- package/dist/{softphone-BtdgZWkm.d.ts → softphone-DEt0TtQw.d.ts} +26 -10
- package/dist/{softphone-DHG9MTNg.d.cts → softphone-dZMkW8V1.d.cts} +26 -10
- package/dist/softphone.css +192 -46
- package/dist/{sources-5L2R4HTV.js → sources-M3GYMS6V.js} +58 -3
- package/dist/sources-M3GYMS6V.js.map +1 -0
- package/dist/testing/index.d.cts +2 -2
- package/dist/testing/index.d.ts +2 -2
- package/dist/ui/index.cjs +1308 -366
- package/dist/ui/index.cjs.map +1 -1
- package/dist/ui/index.d.cts +40 -7
- package/dist/ui/index.d.ts +40 -7
- package/dist/ui/index.js +1308 -369
- package/dist/ui/index.js.map +1 -1
- package/dist/zadarma/index.cjs +106 -13
- package/dist/zadarma/index.cjs.map +1 -1
- package/dist/zadarma/index.d.cts +2 -2
- package/dist/zadarma/index.d.ts +2 -2
- package/dist/zadarma/index.js +1 -1
- package/dist/zadarma-engine-L5DEFXUS.js +5 -0
- package/dist/{zadarma-engine-4VOPIBXG.js.map → zadarma-engine-L5DEFXUS.js.map} +1 -1
- package/package.json +1 -1
- package/dist/chunk-LMJXX4YM.js.map +0 -1
- package/dist/chunk-YWI6EQYS.js.map +0 -1
- package/dist/sources-5L2R4HTV.js.map +0 -1
- package/dist/zadarma-engine-4VOPIBXG.js +0 -5
package/dist/element/index.js
CHANGED
|
@@ -6931,7 +6931,7 @@ var require_jsx_runtime = __commonJS({
|
|
|
6931
6931
|
});
|
|
6932
6932
|
|
|
6933
6933
|
// src/element/index.ts
|
|
6934
|
-
var
|
|
6934
|
+
var import_react41 = __toESM(require_react(), 1);
|
|
6935
6935
|
var import_client = __toESM(require_client(), 1);
|
|
6936
6936
|
|
|
6937
6937
|
// src/react/context.tsx
|
|
@@ -6950,7 +6950,7 @@ function useSoftphone() {
|
|
|
6950
6950
|
}
|
|
6951
6951
|
|
|
6952
6952
|
// src/ui/SoftphoneWidget.tsx
|
|
6953
|
-
var
|
|
6953
|
+
var import_react39 = __toESM(require_react(), 1);
|
|
6954
6954
|
|
|
6955
6955
|
// ../../node_modules/lucide-react/dist/esm/createLucideIcon.js
|
|
6956
6956
|
var import_react3 = __toESM(require_react());
|
|
@@ -7043,6 +7043,14 @@ var ArrowRight = createLucideIcon("ArrowRight", [
|
|
|
7043
7043
|
["path", { d: "m12 5 7 7-7 7", key: "xquz4c" }]
|
|
7044
7044
|
]);
|
|
7045
7045
|
|
|
7046
|
+
// ../../node_modules/lucide-react/dist/esm/icons/bell-ring.js
|
|
7047
|
+
var BellRing = createLucideIcon("BellRing", [
|
|
7048
|
+
["path", { d: "M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9", key: "1qo2s2" }],
|
|
7049
|
+
["path", { d: "M10.3 21a1.94 1.94 0 0 0 3.4 0", key: "qgo35s" }],
|
|
7050
|
+
["path", { d: "M4 2C2.8 3.7 2 5.7 2 8", key: "tap9e0" }],
|
|
7051
|
+
["path", { d: "M22 8c0-2.3-.8-4.3-2-6", key: "5bb3ad" }]
|
|
7052
|
+
]);
|
|
7053
|
+
|
|
7046
7054
|
// ../../node_modules/lucide-react/dist/esm/icons/calendar-clock.js
|
|
7047
7055
|
var CalendarClock = createLucideIcon("CalendarClock", [
|
|
7048
7056
|
["path", { d: "M21 7.5V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h3.5", key: "1osxxc" }],
|
|
@@ -7157,6 +7165,13 @@ var MonitorSmartphone = createLucideIcon("MonitorSmartphone", [
|
|
|
7157
7165
|
["rect", { width: "6", height: "10", x: "16", y: "12", rx: "2", key: "1egngj" }]
|
|
7158
7166
|
]);
|
|
7159
7167
|
|
|
7168
|
+
// ../../node_modules/lucide-react/dist/esm/icons/music.js
|
|
7169
|
+
var Music = createLucideIcon("Music", [
|
|
7170
|
+
["path", { d: "M9 18V5l12-2v13", key: "1jmyc2" }],
|
|
7171
|
+
["circle", { cx: "6", cy: "18", r: "3", key: "fqmcym" }],
|
|
7172
|
+
["circle", { cx: "18", cy: "16", r: "3", key: "1hluhg" }]
|
|
7173
|
+
]);
|
|
7174
|
+
|
|
7160
7175
|
// ../../node_modules/lucide-react/dist/esm/icons/pause.js
|
|
7161
7176
|
var Pause = createLucideIcon("Pause", [
|
|
7162
7177
|
["rect", { x: "14", y: "4", width: "4", height: "16", rx: "1", key: "zuxfzm" }],
|
|
@@ -7282,6 +7297,19 @@ var Users = createLucideIcon("Users", [
|
|
|
7282
7297
|
["path", { d: "M16 3.13a4 4 0 0 1 0 7.75", key: "1da9ce" }]
|
|
7283
7298
|
]);
|
|
7284
7299
|
|
|
7300
|
+
// ../../node_modules/lucide-react/dist/esm/icons/volume-2.js
|
|
7301
|
+
var Volume2 = createLucideIcon("Volume2", [
|
|
7302
|
+
[
|
|
7303
|
+
"path",
|
|
7304
|
+
{
|
|
7305
|
+
d: "M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z",
|
|
7306
|
+
key: "uqj9uw"
|
|
7307
|
+
}
|
|
7308
|
+
],
|
|
7309
|
+
["path", { d: "M16 9a5 5 0 0 1 0 6", key: "1q6k2b" }],
|
|
7310
|
+
["path", { d: "M19.364 18.364a9 9 0 0 0 0-12.728", key: "ijwkga" }]
|
|
7311
|
+
]);
|
|
7312
|
+
|
|
7285
7313
|
// ../../node_modules/lucide-react/dist/esm/icons/x.js
|
|
7286
7314
|
var X = createLucideIcon("X", [
|
|
7287
7315
|
["path", { d: "M18 6 6 18", key: "1bl5f8" }],
|
|
@@ -7403,7 +7431,9 @@ function useDevices() {
|
|
|
7403
7431
|
outputs: audio.outputs,
|
|
7404
7432
|
currentInputId: audio.currentInputId,
|
|
7405
7433
|
currentOutputId: audio.currentOutputId,
|
|
7434
|
+
currentRingtoneOutputId: audio.currentRingtoneOutputId,
|
|
7406
7435
|
canSelectOutput: audio.canSelectOutput,
|
|
7436
|
+
canSelectRingtoneOutput: audio.canSelectRingtoneOutput,
|
|
7407
7437
|
setInput: (deviceId) => audio.setInput(deviceId),
|
|
7408
7438
|
setOutput: (deviceId) => audio.setOutput(deviceId),
|
|
7409
7439
|
setRingtoneOutput: (deviceId) => audio.setRingtoneOutput(deviceId),
|
|
@@ -7644,174 +7674,605 @@ function digitsBeforeCaret(text, caret) {
|
|
|
7644
7674
|
return (text.slice(0, caret).match(/\d/g) ?? []).length;
|
|
7645
7675
|
}
|
|
7646
7676
|
|
|
7647
|
-
// src/ui/
|
|
7648
|
-
var
|
|
7649
|
-
|
|
7650
|
-
["
|
|
7651
|
-
["
|
|
7652
|
-
["
|
|
7653
|
-
["
|
|
7654
|
-
["
|
|
7655
|
-
["
|
|
7656
|
-
["
|
|
7657
|
-
["
|
|
7658
|
-
["
|
|
7659
|
-
["
|
|
7660
|
-
["
|
|
7661
|
-
["#", "", false]
|
|
7677
|
+
// src/ui/dialing/keys.ts
|
|
7678
|
+
var KEYPAD_KEYS = [
|
|
7679
|
+
["1", ""],
|
|
7680
|
+
["2", "ABC"],
|
|
7681
|
+
["3", "DEF"],
|
|
7682
|
+
["4", "GHI"],
|
|
7683
|
+
["5", "JKL"],
|
|
7684
|
+
["6", "MNO"],
|
|
7685
|
+
["7", "PQRS"],
|
|
7686
|
+
["8", "TUV"],
|
|
7687
|
+
["9", "WXYZ"],
|
|
7688
|
+
["*", ""],
|
|
7689
|
+
["0", "+"],
|
|
7690
|
+
["#", ""]
|
|
7662
7691
|
];
|
|
7663
|
-
|
|
7664
|
-
|
|
7665
|
-
|
|
7666
|
-
|
|
7667
|
-
|
|
7668
|
-
|
|
7669
|
-
|
|
7670
|
-
|
|
7671
|
-
|
|
7672
|
-
const
|
|
7673
|
-
|
|
7674
|
-
|
|
7675
|
-
const
|
|
7676
|
-
|
|
7677
|
-
|
|
7678
|
-
|
|
7679
|
-
|
|
7680
|
-
}
|
|
7681
|
-
|
|
7682
|
-
|
|
7683
|
-
|
|
7684
|
-
};
|
|
7685
|
-
|
|
7686
|
-
|
|
7687
|
-
|
|
7692
|
+
|
|
7693
|
+
// src/ui/audio/classic-ring.ts
|
|
7694
|
+
var C6 = 1046.5;
|
|
7695
|
+
var E6 = 1318.51;
|
|
7696
|
+
var HARMONICITY = 4;
|
|
7697
|
+
var INDEX = 1.8;
|
|
7698
|
+
var CLASSIC_CYCLE_SECONDS = 3;
|
|
7699
|
+
var CLASSIC_GAIN = 0.3841;
|
|
7700
|
+
function classicRingGraph(ctx, dest) {
|
|
7701
|
+
const output = ctx.createGain();
|
|
7702
|
+
output.gain.value = CLASSIC_GAIN;
|
|
7703
|
+
const chain = ["highpass", "highpass", "lowpass", "lowpass"].map((type) => {
|
|
7704
|
+
const f = ctx.createBiquadFilter();
|
|
7705
|
+
f.type = type;
|
|
7706
|
+
f.frequency.value = type === "highpass" ? 250 : 7500;
|
|
7707
|
+
f.Q.value = 0.707;
|
|
7708
|
+
return f;
|
|
7709
|
+
});
|
|
7710
|
+
for (let i = 0; i < chain.length - 1; i++) chain[i].connect(chain[i + 1]);
|
|
7711
|
+
chain[chain.length - 1].connect(output);
|
|
7712
|
+
output.connect(dest);
|
|
7713
|
+
return { input: chain[0], output };
|
|
7714
|
+
}
|
|
7715
|
+
function strike(ctx, input, t, freq, vel, track) {
|
|
7716
|
+
const carrier = ctx.createOscillator();
|
|
7717
|
+
carrier.frequency.value = freq;
|
|
7718
|
+
const modulator = ctx.createOscillator();
|
|
7719
|
+
modulator.frequency.value = freq * HARMONICITY;
|
|
7720
|
+
const depth = ctx.createGain();
|
|
7721
|
+
depth.gain.setValueAtTime(0, t);
|
|
7722
|
+
depth.gain.linearRampToValueAtTime(INDEX * freq, t + 4e-3);
|
|
7723
|
+
depth.gain.setTargetAtTime(0, t + 4e-3, 0.02);
|
|
7724
|
+
modulator.connect(depth);
|
|
7725
|
+
depth.connect(carrier.frequency);
|
|
7726
|
+
const amp = ctx.createGain();
|
|
7727
|
+
amp.gain.setValueAtTime(0, t);
|
|
7728
|
+
amp.gain.linearRampToValueAtTime(vel, t + 5e-3);
|
|
7729
|
+
amp.gain.setTargetAtTime(0, t + 5e-3, 0.15);
|
|
7730
|
+
amp.gain.setTargetAtTime(0, t + 0.045, 0.08);
|
|
7731
|
+
carrier.connect(amp);
|
|
7732
|
+
amp.connect(input);
|
|
7733
|
+
for (const o of [carrier, modulator]) {
|
|
7734
|
+
o.start(t);
|
|
7735
|
+
o.stop(t + 0.7);
|
|
7736
|
+
track(o);
|
|
7737
|
+
}
|
|
7738
|
+
}
|
|
7739
|
+
function scheduleClassicCycle(ctx, input, t0, track) {
|
|
7740
|
+
for (const ring of [0, 0.6]) {
|
|
7741
|
+
for (let i = 0; i < 8; i++) strike(ctx, input, t0 + ring + i * 0.05, i % 2 ? E6 : C6, 0.55 + 0.1 * (i % 2), track);
|
|
7742
|
+
}
|
|
7743
|
+
return CLASSIC_CYCLE_SECONDS;
|
|
7744
|
+
}
|
|
7745
|
+
|
|
7746
|
+
// src/ui/audio/audio.ts
|
|
7747
|
+
var TONE_HZ = 425;
|
|
7748
|
+
function createRemoteAudio() {
|
|
7749
|
+
const el = document.createElement("audio");
|
|
7750
|
+
el.autoplay = true;
|
|
7751
|
+
el.style.display = "none";
|
|
7752
|
+
document.body.appendChild(el);
|
|
7753
|
+
return {
|
|
7754
|
+
attach(stream) {
|
|
7755
|
+
el.srcObject = stream;
|
|
7756
|
+
void el.play().catch(() => {
|
|
7757
|
+
});
|
|
7758
|
+
},
|
|
7759
|
+
detach() {
|
|
7760
|
+
el.srcObject = null;
|
|
7761
|
+
},
|
|
7762
|
+
destroy() {
|
|
7763
|
+
el.srcObject = null;
|
|
7764
|
+
el.remove();
|
|
7765
|
+
},
|
|
7766
|
+
async setOutputDevice(deviceId) {
|
|
7767
|
+
if (typeof el.setSinkId !== "function") return;
|
|
7768
|
+
try {
|
|
7769
|
+
await el.setSinkId(deviceId ?? "");
|
|
7770
|
+
} catch {
|
|
7771
|
+
}
|
|
7772
|
+
}
|
|
7688
7773
|
};
|
|
7689
|
-
|
|
7690
|
-
|
|
7691
|
-
|
|
7692
|
-
|
|
7774
|
+
}
|
|
7775
|
+
var shared = null;
|
|
7776
|
+
function sharedContext() {
|
|
7777
|
+
if (shared) return shared;
|
|
7778
|
+
const Ctor = typeof AudioContext !== "undefined" ? AudioContext : globalThis.webkitAudioContext;
|
|
7779
|
+
if (!Ctor) return null;
|
|
7780
|
+
shared = new Ctor();
|
|
7781
|
+
return shared;
|
|
7782
|
+
}
|
|
7783
|
+
function primeAudio() {
|
|
7784
|
+
const ctx = sharedContext();
|
|
7785
|
+
if (ctx && ctx.state === "suspended") void ctx.resume?.().catch(() => {
|
|
7786
|
+
});
|
|
7787
|
+
}
|
|
7788
|
+
function playRingtone(options = {}) {
|
|
7789
|
+
const ctx = sharedContext();
|
|
7790
|
+
if (!ctx) return { stop() {
|
|
7791
|
+
} };
|
|
7792
|
+
const bus = ctx.createGain();
|
|
7793
|
+
const route = routeToDevice(ctx, bus, options.sinkId ?? null);
|
|
7794
|
+
const voice = options.buffer ? fileVoice(ctx, bus, options.buffer, options.loop ?? null) : synthesisedVoice(ctx, bus);
|
|
7795
|
+
return {
|
|
7796
|
+
stop() {
|
|
7797
|
+
voice.stop();
|
|
7798
|
+
route.stop();
|
|
7799
|
+
}
|
|
7693
7800
|
};
|
|
7694
|
-
|
|
7695
|
-
|
|
7696
|
-
|
|
7697
|
-
|
|
7698
|
-
|
|
7699
|
-
|
|
7700
|
-
|
|
7701
|
-
|
|
7702
|
-
|
|
7703
|
-
|
|
7704
|
-
|
|
7705
|
-
|
|
7706
|
-
|
|
7707
|
-
|
|
7708
|
-
|
|
7709
|
-
|
|
7710
|
-
|
|
7711
|
-
|
|
7712
|
-
|
|
7713
|
-
|
|
7714
|
-
|
|
7715
|
-
|
|
7716
|
-
|
|
7717
|
-
|
|
7718
|
-
|
|
7719
|
-
|
|
7720
|
-
|
|
7721
|
-
|
|
7722
|
-
|
|
7723
|
-
|
|
7724
|
-
|
|
7725
|
-
|
|
7726
|
-
] }),
|
|
7727
|
-
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { className: "cv-dial-outcome", role: "status", part: "error", "data-shown": outcome !== null, children: outcome?.text ?? "" }),
|
|
7728
|
-
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: "cv-keypad", children: KEYS.map(([key, letters, dialable]) => /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(
|
|
7729
|
-
"button",
|
|
7730
|
-
{
|
|
7731
|
-
type: "button",
|
|
7732
|
-
className: "cv-key",
|
|
7733
|
-
"data-dialable": dialable,
|
|
7734
|
-
onClick: () => dialable && append(key),
|
|
7735
|
-
"aria-disabled": !dialable,
|
|
7736
|
-
title: dialable ? void 0 : "Solo disponible durante una llamada",
|
|
7737
|
-
children: [
|
|
7738
|
-
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("b", { children: key }),
|
|
7739
|
-
letters ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("small", { children: letters }) : null
|
|
7740
|
-
]
|
|
7741
|
-
},
|
|
7742
|
-
key
|
|
7743
|
-
)) }),
|
|
7744
|
-
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: "cv-dial-foot", children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
|
|
7745
|
-
"button",
|
|
7746
|
-
{
|
|
7747
|
-
type: "button",
|
|
7748
|
-
className: "cv-round cv-round--answer",
|
|
7749
|
-
onClick: call,
|
|
7750
|
-
disabled: !canCall,
|
|
7751
|
-
"aria-label": "Llamar",
|
|
7752
|
-
part: "call-button",
|
|
7753
|
-
children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(Phone, { size: 26 })
|
|
7801
|
+
}
|
|
7802
|
+
function fileVoice(ctx, bus, buffer, loop) {
|
|
7803
|
+
const FILE_LEVEL = 1;
|
|
7804
|
+
const RAMP = 0.02;
|
|
7805
|
+
const LEAD = 0.08;
|
|
7806
|
+
const gain = ctx.createGain();
|
|
7807
|
+
gain.gain.value = 0;
|
|
7808
|
+
gain.connect(bus);
|
|
7809
|
+
const src = ctx.createBufferSource();
|
|
7810
|
+
src.buffer = buffer;
|
|
7811
|
+
src.loop = true;
|
|
7812
|
+
const points = loop && loop.start >= 0 && loop.end > loop.start && loop.end <= buffer.duration + 1e-3 ? loop : null;
|
|
7813
|
+
if (points) {
|
|
7814
|
+
src.loopStart = points.start;
|
|
7815
|
+
src.loopEnd = points.end;
|
|
7816
|
+
}
|
|
7817
|
+
src.connect(gain);
|
|
7818
|
+
let stopped = false;
|
|
7819
|
+
void Promise.resolve(ctx.resume?.()).catch(() => {
|
|
7820
|
+
}).finally(() => {
|
|
7821
|
+
if (stopped) return;
|
|
7822
|
+
const at = ctx.currentTime + LEAD;
|
|
7823
|
+
gain.gain.setValueAtTime(0, at);
|
|
7824
|
+
gain.gain.linearRampToValueAtTime(FILE_LEVEL, at + RAMP);
|
|
7825
|
+
src.start(at, points?.start ?? 0);
|
|
7826
|
+
});
|
|
7827
|
+
return {
|
|
7828
|
+
stop() {
|
|
7829
|
+
stopped = true;
|
|
7830
|
+
try {
|
|
7831
|
+
src.stop();
|
|
7832
|
+
} catch {
|
|
7754
7833
|
}
|
|
7755
|
-
|
|
7756
|
-
|
|
7834
|
+
try {
|
|
7835
|
+
gain.disconnect();
|
|
7836
|
+
} catch {
|
|
7837
|
+
}
|
|
7838
|
+
}
|
|
7839
|
+
};
|
|
7757
7840
|
}
|
|
7758
|
-
|
|
7759
|
-
|
|
7760
|
-
|
|
7761
|
-
|
|
7762
|
-
|
|
7763
|
-
function
|
|
7764
|
-
const
|
|
7765
|
-
|
|
7766
|
-
|
|
7767
|
-
|
|
7768
|
-
|
|
7769
|
-
|
|
7841
|
+
function routeToDevice(ctx, bus, sinkId) {
|
|
7842
|
+
const toDefault = () => {
|
|
7843
|
+
bus.connect(ctx.destination);
|
|
7844
|
+
};
|
|
7845
|
+
const el = sinkId && typeof document !== "undefined" ? document.createElement("audio") : null;
|
|
7846
|
+
const dest = el && typeof ctx.createMediaStreamDestination === "function" ? ctx.createMediaStreamDestination() : null;
|
|
7847
|
+
const canRoute = el && dest && typeof el.setSinkId === "function";
|
|
7848
|
+
if (!canRoute || !sinkId) {
|
|
7849
|
+
toDefault();
|
|
7850
|
+
return { stop: () => safeDisconnect(bus) };
|
|
7851
|
+
}
|
|
7852
|
+
let stopped = false;
|
|
7853
|
+
el.style.display = "none";
|
|
7854
|
+
el.srcObject = dest.stream;
|
|
7855
|
+
document.body.appendChild(el);
|
|
7856
|
+
bus.connect(dest);
|
|
7857
|
+
void el.setSinkId(sinkId).then(() => el.play()).catch((error) => {
|
|
7858
|
+
if (stopped) return;
|
|
7859
|
+
console.warn("[softphone] sound could not be sent to the chosen device; using the default output", error);
|
|
7860
|
+
safeDisconnect(bus);
|
|
7861
|
+
teardown(el);
|
|
7862
|
+
toDefault();
|
|
7863
|
+
});
|
|
7864
|
+
return {
|
|
7865
|
+
stop() {
|
|
7866
|
+
stopped = true;
|
|
7867
|
+
safeDisconnect(bus);
|
|
7868
|
+
teardown(el);
|
|
7869
|
+
}
|
|
7870
|
+
};
|
|
7770
7871
|
}
|
|
7771
|
-
function
|
|
7772
|
-
|
|
7773
|
-
|
|
7774
|
-
|
|
7775
|
-
|
|
7872
|
+
function safeDisconnect(node) {
|
|
7873
|
+
try {
|
|
7874
|
+
node.disconnect();
|
|
7875
|
+
} catch {
|
|
7876
|
+
}
|
|
7776
7877
|
}
|
|
7777
|
-
function
|
|
7778
|
-
|
|
7779
|
-
|
|
7878
|
+
function teardown(el) {
|
|
7879
|
+
el.pause();
|
|
7880
|
+
el.srcObject = null;
|
|
7881
|
+
el.remove();
|
|
7780
7882
|
}
|
|
7781
|
-
|
|
7782
|
-
|
|
7783
|
-
|
|
7784
|
-
|
|
7785
|
-
|
|
7786
|
-
const
|
|
7787
|
-
|
|
7883
|
+
var TEST_TONE_MS = 900;
|
|
7884
|
+
function playTestTone(sinkId) {
|
|
7885
|
+
const ctx = sharedContext();
|
|
7886
|
+
if (!ctx) return { stop() {
|
|
7887
|
+
} };
|
|
7888
|
+
const bus = ctx.createGain();
|
|
7889
|
+
const route = routeToDevice(ctx, bus, sinkId);
|
|
7890
|
+
const LEVEL = 0.5;
|
|
7891
|
+
const NOTES = [
|
|
7892
|
+
{ hz: 660, at: 0.05 },
|
|
7893
|
+
{ hz: 880, at: 0.4 }
|
|
7894
|
+
];
|
|
7895
|
+
const oscs = [];
|
|
7896
|
+
let stopped = false;
|
|
7897
|
+
let timer = null;
|
|
7898
|
+
const stop = () => {
|
|
7899
|
+
if (stopped) return;
|
|
7900
|
+
stopped = true;
|
|
7901
|
+
if (timer) clearTimeout(timer);
|
|
7902
|
+
for (const o of oscs) {
|
|
7903
|
+
try {
|
|
7904
|
+
o.stop();
|
|
7905
|
+
} catch {
|
|
7906
|
+
}
|
|
7907
|
+
}
|
|
7908
|
+
route.stop();
|
|
7909
|
+
};
|
|
7910
|
+
void Promise.resolve(ctx.resume?.()).catch(() => {
|
|
7911
|
+
}).finally(() => {
|
|
7912
|
+
if (stopped) return;
|
|
7913
|
+
const t0 = ctx.currentTime;
|
|
7914
|
+
for (const { hz, at } of NOTES) {
|
|
7915
|
+
const gain = ctx.createGain();
|
|
7916
|
+
gain.gain.value = 0;
|
|
7917
|
+
gain.connect(bus);
|
|
7918
|
+
const osc = ctx.createOscillator();
|
|
7919
|
+
osc.type = "sine";
|
|
7920
|
+
osc.frequency.value = hz;
|
|
7921
|
+
osc.connect(gain);
|
|
7922
|
+
const start = t0 + at;
|
|
7923
|
+
gain.gain.setValueAtTime(0, start);
|
|
7924
|
+
gain.gain.linearRampToValueAtTime(LEVEL, start + 0.015);
|
|
7925
|
+
gain.gain.setValueAtTime(LEVEL, start + 0.25);
|
|
7926
|
+
gain.gain.linearRampToValueAtTime(0, start + 0.32);
|
|
7927
|
+
osc.start(start);
|
|
7928
|
+
osc.stop(start + 0.34);
|
|
7929
|
+
oscs.push(osc);
|
|
7930
|
+
}
|
|
7931
|
+
timer = setTimeout(stop, TEST_TONE_MS);
|
|
7932
|
+
});
|
|
7933
|
+
return { stop };
|
|
7788
7934
|
}
|
|
7789
|
-
|
|
7790
|
-
|
|
7791
|
-
|
|
7792
|
-
|
|
7793
|
-
|
|
7794
|
-
|
|
7795
|
-
|
|
7796
|
-
|
|
7797
|
-
|
|
7798
|
-
|
|
7799
|
-
|
|
7800
|
-
|
|
7801
|
-
|
|
7802
|
-
|
|
7803
|
-
|
|
7804
|
-
|
|
7805
|
-
|
|
7806
|
-
|
|
7807
|
-
|
|
7808
|
-
|
|
7809
|
-
|
|
7810
|
-
|
|
7811
|
-
|
|
7812
|
-
|
|
7813
|
-
|
|
7814
|
-
|
|
7935
|
+
function synthesisedVoice(ctx, bus) {
|
|
7936
|
+
const { input, output } = classicRingGraph(ctx, bus);
|
|
7937
|
+
const scheduled = /* @__PURE__ */ new Set();
|
|
7938
|
+
const track = (o) => {
|
|
7939
|
+
scheduled.add(o);
|
|
7940
|
+
o.onended = () => scheduled.delete(o);
|
|
7941
|
+
};
|
|
7942
|
+
const LOOKAHEAD = 0.5;
|
|
7943
|
+
const TICK_MS = 150;
|
|
7944
|
+
const LEAD = 0.08;
|
|
7945
|
+
let stopped = false;
|
|
7946
|
+
let timer = null;
|
|
7947
|
+
let nextAt = 0;
|
|
7948
|
+
const pump = () => {
|
|
7949
|
+
while (nextAt < ctx.currentTime + LOOKAHEAD) nextAt += scheduleClassicCycle(ctx, input, nextAt, track);
|
|
7950
|
+
};
|
|
7951
|
+
void Promise.resolve(ctx.resume?.()).catch(() => {
|
|
7952
|
+
}).finally(() => {
|
|
7953
|
+
if (stopped) return;
|
|
7954
|
+
nextAt = ctx.currentTime + LEAD;
|
|
7955
|
+
pump();
|
|
7956
|
+
timer = setInterval(pump, TICK_MS);
|
|
7957
|
+
});
|
|
7958
|
+
return {
|
|
7959
|
+
stop() {
|
|
7960
|
+
stopped = true;
|
|
7961
|
+
if (timer) clearInterval(timer);
|
|
7962
|
+
for (const o of scheduled) {
|
|
7963
|
+
try {
|
|
7964
|
+
o.stop();
|
|
7965
|
+
} catch {
|
|
7966
|
+
}
|
|
7967
|
+
}
|
|
7968
|
+
scheduled.clear();
|
|
7969
|
+
try {
|
|
7970
|
+
output.disconnect();
|
|
7971
|
+
} catch {
|
|
7972
|
+
}
|
|
7973
|
+
}
|
|
7974
|
+
};
|
|
7975
|
+
}
|
|
7976
|
+
function playCallEndedTone() {
|
|
7977
|
+
const ctx = sharedContext();
|
|
7978
|
+
if (!ctx) return;
|
|
7979
|
+
const gain = ctx.createGain();
|
|
7980
|
+
gain.gain.value = 0;
|
|
7981
|
+
gain.connect(ctx.destination);
|
|
7982
|
+
const osc = ctx.createOscillator();
|
|
7983
|
+
osc.type = "sine";
|
|
7984
|
+
osc.frequency.value = TONE_HZ;
|
|
7985
|
+
osc.connect(gain);
|
|
7986
|
+
osc.start();
|
|
7987
|
+
const LEVEL = 0.55;
|
|
7988
|
+
const RAMP = 8e-3;
|
|
7989
|
+
const BEEP = 0.25;
|
|
7990
|
+
void Promise.resolve(ctx.resume?.()).catch(() => {
|
|
7991
|
+
}).finally(() => {
|
|
7992
|
+
const at = ctx.currentTime + 0.02;
|
|
7993
|
+
gain.gain.setValueAtTime(0, at);
|
|
7994
|
+
gain.gain.linearRampToValueAtTime(LEVEL, at + RAMP);
|
|
7995
|
+
gain.gain.setValueAtTime(LEVEL, at + BEEP - RAMP);
|
|
7996
|
+
gain.gain.linearRampToValueAtTime(0, at + BEEP);
|
|
7997
|
+
const totalMs = (at + BEEP - ctx.currentTime) * 1e3;
|
|
7998
|
+
setTimeout(() => {
|
|
7999
|
+
try {
|
|
8000
|
+
osc.stop();
|
|
8001
|
+
} catch {
|
|
8002
|
+
}
|
|
8003
|
+
try {
|
|
8004
|
+
gain.disconnect();
|
|
8005
|
+
} catch {
|
|
8006
|
+
}
|
|
8007
|
+
}, totalMs);
|
|
8008
|
+
});
|
|
8009
|
+
}
|
|
8010
|
+
var DTMF_ROWS_HZ = [697, 770, 852, 941];
|
|
8011
|
+
var DTMF_COLS_HZ = [1209, 1336, 1477];
|
|
8012
|
+
var DTMF_LAYOUT = ["123", "456", "789", "*0#"];
|
|
8013
|
+
function dtmfFrequencies(key) {
|
|
8014
|
+
if (key.length !== 1) return null;
|
|
8015
|
+
for (let row = 0; row < DTMF_LAYOUT.length; row++) {
|
|
8016
|
+
const col = DTMF_LAYOUT[row].indexOf(key);
|
|
8017
|
+
if (col !== -1) return [DTMF_ROWS_HZ[row], DTMF_COLS_HZ[col]];
|
|
8018
|
+
}
|
|
8019
|
+
return null;
|
|
8020
|
+
}
|
|
8021
|
+
function playKeyTone(key) {
|
|
8022
|
+
const hz = dtmfFrequencies(key);
|
|
8023
|
+
if (!hz) return;
|
|
8024
|
+
const ctx = sharedContext();
|
|
8025
|
+
if (!ctx) return;
|
|
8026
|
+
const LEVEL = 0.2;
|
|
8027
|
+
const RAMP = 6e-3;
|
|
8028
|
+
const DURATION = 0.12;
|
|
8029
|
+
const LEAD = 0.01;
|
|
8030
|
+
const gain = ctx.createGain();
|
|
8031
|
+
gain.gain.value = 0;
|
|
8032
|
+
gain.connect(ctx.destination);
|
|
8033
|
+
const oscillators = hz.map((freq) => {
|
|
8034
|
+
const osc = ctx.createOscillator();
|
|
8035
|
+
osc.type = "sine";
|
|
8036
|
+
osc.frequency.value = freq;
|
|
8037
|
+
osc.connect(gain);
|
|
8038
|
+
osc.start();
|
|
8039
|
+
return osc;
|
|
8040
|
+
});
|
|
8041
|
+
const fire = () => {
|
|
8042
|
+
const at = ctx.currentTime + LEAD;
|
|
8043
|
+
gain.gain.setValueAtTime(0, at);
|
|
8044
|
+
gain.gain.linearRampToValueAtTime(LEVEL, at + RAMP);
|
|
8045
|
+
gain.gain.setValueAtTime(LEVEL, at + DURATION - RAMP);
|
|
8046
|
+
gain.gain.linearRampToValueAtTime(0, at + DURATION);
|
|
8047
|
+
for (const osc of oscillators) osc.stop(at + DURATION);
|
|
8048
|
+
oscillators[0].onended = () => gain.disconnect();
|
|
8049
|
+
};
|
|
8050
|
+
if (ctx.state === "running") fire();
|
|
8051
|
+
else void Promise.resolve(ctx.resume?.()).catch(() => {
|
|
8052
|
+
}).finally(fire);
|
|
8053
|
+
}
|
|
8054
|
+
var ringbackUrl = null;
|
|
8055
|
+
function ringbackSource() {
|
|
8056
|
+
if (ringbackUrl) return ringbackUrl;
|
|
8057
|
+
const sampleRate = 48e3;
|
|
8058
|
+
const channels = 2;
|
|
8059
|
+
const bytesPerSample = 2;
|
|
8060
|
+
const amp = 0.5;
|
|
8061
|
+
const toneSec = 1.5;
|
|
8062
|
+
const silenceSec = 3;
|
|
8063
|
+
const totalFrames = Math.round((toneSec + silenceSec) * sampleRate);
|
|
8064
|
+
const toneFrames = Math.round(toneSec * sampleRate);
|
|
8065
|
+
const ramp = Math.round(0.02 * sampleRate);
|
|
8066
|
+
const blockAlign = channels * bytesPerSample;
|
|
8067
|
+
const dataSize = totalFrames * blockAlign;
|
|
8068
|
+
const buffer = new ArrayBuffer(44 + dataSize);
|
|
8069
|
+
const view = new DataView(buffer);
|
|
8070
|
+
const str = (off, s) => {
|
|
8071
|
+
for (let i = 0; i < s.length; i++) view.setUint8(off + i, s.charCodeAt(i));
|
|
8072
|
+
};
|
|
8073
|
+
str(0, "RIFF");
|
|
8074
|
+
view.setUint32(4, 36 + dataSize, true);
|
|
8075
|
+
str(8, "WAVE");
|
|
8076
|
+
str(12, "fmt ");
|
|
8077
|
+
view.setUint32(16, 16, true);
|
|
8078
|
+
view.setUint16(20, 1, true);
|
|
8079
|
+
view.setUint16(22, channels, true);
|
|
8080
|
+
view.setUint32(24, sampleRate, true);
|
|
8081
|
+
view.setUint32(28, sampleRate * blockAlign, true);
|
|
8082
|
+
view.setUint16(32, blockAlign, true);
|
|
8083
|
+
view.setUint16(34, bytesPerSample * 8, true);
|
|
8084
|
+
str(36, "data");
|
|
8085
|
+
view.setUint32(40, dataSize, true);
|
|
8086
|
+
for (let i = 0; i < totalFrames; i++) {
|
|
8087
|
+
let sample = 0;
|
|
8088
|
+
if (i < toneFrames) {
|
|
8089
|
+
let env = 1;
|
|
8090
|
+
if (i < ramp) env = i / ramp;
|
|
8091
|
+
else if (i > toneFrames - ramp) env = Math.max(0, (toneFrames - i) / ramp);
|
|
8092
|
+
sample = Math.sin(2 * Math.PI * TONE_HZ * (i / sampleRate)) * amp * env;
|
|
8093
|
+
}
|
|
8094
|
+
const s16 = Math.round(sample * 32767);
|
|
8095
|
+
const off = 44 + i * blockAlign;
|
|
8096
|
+
for (let c = 0; c < channels; c++) view.setInt16(off + c * bytesPerSample, s16, true);
|
|
8097
|
+
}
|
|
8098
|
+
ringbackUrl = URL.createObjectURL(new Blob([buffer], { type: "audio/wav" }));
|
|
8099
|
+
return ringbackUrl;
|
|
8100
|
+
}
|
|
8101
|
+
function playRingback() {
|
|
8102
|
+
const el = document.createElement("audio");
|
|
8103
|
+
el.src = ringbackSource();
|
|
8104
|
+
el.loop = true;
|
|
8105
|
+
el.style.display = "none";
|
|
8106
|
+
document.body.appendChild(el);
|
|
8107
|
+
void el.play().catch(() => {
|
|
8108
|
+
});
|
|
8109
|
+
return {
|
|
8110
|
+
stop() {
|
|
8111
|
+
el.pause();
|
|
8112
|
+
el.remove();
|
|
8113
|
+
}
|
|
8114
|
+
};
|
|
8115
|
+
}
|
|
8116
|
+
|
|
8117
|
+
// src/ui/views/Dialpad.tsx
|
|
8118
|
+
var import_jsx_runtime3 = __toESM(require_jsx_runtime(), 1);
|
|
8119
|
+
var NOT_DIALABLE = /* @__PURE__ */ new Set(["*", "#"]);
|
|
8120
|
+
function Dialpad({ outcome = null, onIntent, keyTones = true } = {}) {
|
|
8121
|
+
const softphone = useSoftphone();
|
|
8122
|
+
const state = useSoftphoneState();
|
|
8123
|
+
const [value, setValue] = (0, import_react7.useState)("");
|
|
8124
|
+
const [country, setCountry] = (0, import_react7.useState)(DEFAULT_COUNTRY);
|
|
8125
|
+
const format = useNumberFormatter();
|
|
8126
|
+
const inputRef = (0, import_react7.useRef)(null);
|
|
8127
|
+
const caretRef = (0, import_react7.useRef)(null);
|
|
8128
|
+
const display = format(country, value);
|
|
8129
|
+
const target = toE164(country, value);
|
|
8130
|
+
const canCall = state === "online" && target.length > 1;
|
|
8131
|
+
(0, import_react7.useLayoutEffect)(() => {
|
|
8132
|
+
const caret = caretRef.current;
|
|
8133
|
+
if (caret === null || !inputRef.current) return;
|
|
8134
|
+
caretRef.current = null;
|
|
8135
|
+
const pos = caretAfterDigits(display, caret);
|
|
8136
|
+
inputRef.current.setSelectionRange(pos, pos);
|
|
8137
|
+
}, [display]);
|
|
8138
|
+
const append = (key) => {
|
|
8139
|
+
onIntent?.();
|
|
8140
|
+
setValue((v) => v + key);
|
|
8141
|
+
if (keyTones) playKeyTone(key);
|
|
8142
|
+
};
|
|
8143
|
+
const backspace = () => {
|
|
8144
|
+
onIntent?.();
|
|
8145
|
+
setValue((v) => v.slice(0, -1));
|
|
8146
|
+
};
|
|
8147
|
+
const call = () => {
|
|
8148
|
+
if (!canCall) return;
|
|
8149
|
+
onIntent?.();
|
|
8150
|
+
void softphone.call(target);
|
|
8151
|
+
};
|
|
8152
|
+
return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "cv-view", children: [
|
|
8153
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: "cv-dial-prefix", children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(CountryPicker, { value: country, onChange: setCountry }) }),
|
|
8154
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "cv-dial-head", children: [
|
|
8155
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { className: "cv-dial-side", "aria-hidden": true }),
|
|
8156
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
|
|
8157
|
+
"input",
|
|
8158
|
+
{
|
|
8159
|
+
ref: inputRef,
|
|
8160
|
+
className: "cv-display",
|
|
8161
|
+
value: display,
|
|
8162
|
+
onChange: (e) => {
|
|
8163
|
+
onIntent?.();
|
|
8164
|
+
caretRef.current = digitsBeforeCaret(e.target.value, e.target.selectionStart ?? 0);
|
|
8165
|
+
setValue(e.target.value.replace(/[^\d+]/g, ""));
|
|
8166
|
+
},
|
|
8167
|
+
onKeyDown: (e) => e.key === "Enter" && call(),
|
|
8168
|
+
placeholder: "N\xFAmero",
|
|
8169
|
+
"aria-label": "N\xFAmero a marcar",
|
|
8170
|
+
inputMode: "tel"
|
|
8171
|
+
}
|
|
8172
|
+
),
|
|
8173
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
|
|
8174
|
+
"button",
|
|
8175
|
+
{
|
|
8176
|
+
type: "button",
|
|
8177
|
+
className: "cv-dial-side",
|
|
8178
|
+
"data-hidden": value.length === 0,
|
|
8179
|
+
onClick: backspace,
|
|
8180
|
+
"aria-label": "Borrar",
|
|
8181
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(Delete, { size: 19 })
|
|
8182
|
+
}
|
|
8183
|
+
)
|
|
8184
|
+
] }),
|
|
8185
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { className: "cv-dial-outcome", role: "status", part: "error", "data-shown": outcome !== null, children: outcome?.text ?? "" }),
|
|
8186
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: "cv-keypad", children: KEYPAD_KEYS.map(([key, letters]) => {
|
|
8187
|
+
const dialable = !NOT_DIALABLE.has(key);
|
|
8188
|
+
return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(
|
|
8189
|
+
"button",
|
|
8190
|
+
{
|
|
8191
|
+
type: "button",
|
|
8192
|
+
className: "cv-key",
|
|
8193
|
+
"data-dialable": dialable,
|
|
8194
|
+
onClick: () => dialable && append(key),
|
|
8195
|
+
"aria-disabled": !dialable,
|
|
8196
|
+
title: dialable ? void 0 : "Solo disponible durante una llamada",
|
|
8197
|
+
children: [
|
|
8198
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("b", { children: key }),
|
|
8199
|
+
letters ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("small", { children: letters }) : null
|
|
8200
|
+
]
|
|
8201
|
+
},
|
|
8202
|
+
key
|
|
8203
|
+
);
|
|
8204
|
+
}) }),
|
|
8205
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: "cv-dial-foot", children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
|
|
8206
|
+
"button",
|
|
8207
|
+
{
|
|
8208
|
+
type: "button",
|
|
8209
|
+
className: "cv-round cv-round--answer",
|
|
8210
|
+
onClick: call,
|
|
8211
|
+
disabled: !canCall,
|
|
8212
|
+
"aria-label": "Llamar",
|
|
8213
|
+
part: "call-button",
|
|
8214
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(Phone, { size: 26 })
|
|
8215
|
+
}
|
|
8216
|
+
) })
|
|
8217
|
+
] });
|
|
8218
|
+
}
|
|
8219
|
+
|
|
8220
|
+
// src/ui/views/CallScreen.tsx
|
|
8221
|
+
var import_react12 = __toESM(require_react(), 1);
|
|
8222
|
+
|
|
8223
|
+
// src/ui/lib/format.ts
|
|
8224
|
+
function formatDuration(totalSeconds) {
|
|
8225
|
+
const s = Math.max(0, Math.floor(totalSeconds));
|
|
8226
|
+
const hh = Math.floor(s / 3600);
|
|
8227
|
+
const mm = Math.floor(s % 3600 / 60);
|
|
8228
|
+
const ss = s % 60;
|
|
8229
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
8230
|
+
return hh > 0 ? `${hh}:${pad(mm)}:${pad(ss)}` : `${mm}:${pad(ss)}`;
|
|
8231
|
+
}
|
|
8232
|
+
function initials(name) {
|
|
8233
|
+
if (!name) return "#";
|
|
8234
|
+
const words = name.trim().split(/\s+/).filter(Boolean);
|
|
8235
|
+
if (words.length === 0 || /^\+?\d/.test(words[0])) return "#";
|
|
8236
|
+
return words.slice(0, 2).map((w) => w[0].toUpperCase()).join("");
|
|
8237
|
+
}
|
|
8238
|
+
function elapsedSeconds(since, now) {
|
|
8239
|
+
if (!since) return 0;
|
|
8240
|
+
return Math.floor((now - since.getTime()) / 1e3);
|
|
8241
|
+
}
|
|
8242
|
+
function formatAge(totalSeconds) {
|
|
8243
|
+
const s = Math.max(0, Math.floor(totalSeconds));
|
|
8244
|
+
if (s < 60) return "justo ahora";
|
|
8245
|
+
const mm = Math.floor(s / 60);
|
|
8246
|
+
if (mm < 60) return `hace ${mm} min`;
|
|
8247
|
+
const hh = Math.floor(mm / 60);
|
|
8248
|
+
return `hace ${hh} h`;
|
|
8249
|
+
}
|
|
8250
|
+
|
|
8251
|
+
// src/ui/lib/error-text.ts
|
|
8252
|
+
var ERROR_TEXT = {
|
|
8253
|
+
// ── Engine lifecycle ────────────────────────────────────────────────────────
|
|
8254
|
+
// Integrator mistakes rather than user-facing failures, but they must still read as something
|
|
8255
|
+
// rather than as a blank panel if one ever reaches the UI.
|
|
8256
|
+
"engine/duplicate-instance": {
|
|
8257
|
+
title: "Tel\xE9fono duplicado",
|
|
8258
|
+
body: "Hay otro tel\xE9fono abierto en esta p\xE1gina. Cierra uno y vuelve a cargar."
|
|
8259
|
+
},
|
|
8260
|
+
"engine/not-started": { title: "El tel\xE9fono a\xFAn no est\xE1 listo" },
|
|
8261
|
+
"engine/destroyed": {
|
|
8262
|
+
title: "Tel\xE9fono cerrado",
|
|
8263
|
+
body: "Vuelve a cargar la p\xE1gina para usarlo de nuevo."
|
|
8264
|
+
},
|
|
8265
|
+
// ── Auth ────────────────────────────────────────────────────────────────────
|
|
8266
|
+
// Never "token": the user has no idea what one is. What they can act on is the session.
|
|
8267
|
+
"auth/token-fetch-failed": {
|
|
8268
|
+
title: "Sin conexi\xF3n con el servidor",
|
|
8269
|
+
body: "No se ha podido activar el tel\xE9fono. Reint\xE9ntalo en unos segundos."
|
|
8270
|
+
},
|
|
8271
|
+
"auth/token-rejected": {
|
|
8272
|
+
title: "Sesi\xF3n caducada",
|
|
8273
|
+
body: "Vuelve a iniciar sesi\xF3n para poder llamar."
|
|
8274
|
+
},
|
|
8275
|
+
// ── Media ───────────────────────────────────────────────────────────────────
|
|
7815
8276
|
// Verbatim from MicPermissionGate, which already says these well and says them first.
|
|
7816
8277
|
//
|
|
7817
8278
|
// NEVER ACTUALLY SHOWN via this table — these four codes (through `media/playback-blocked`, below)
|
|
@@ -7993,7 +8454,9 @@ function Control({
|
|
|
7993
8454
|
label,
|
|
7994
8455
|
pressed = false,
|
|
7995
8456
|
tone,
|
|
7996
|
-
onClick
|
|
8457
|
+
onClick,
|
|
8458
|
+
disabled = false,
|
|
8459
|
+
title
|
|
7997
8460
|
}) {
|
|
7998
8461
|
return /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("div", { className: "cv-ctrl-item", children: [
|
|
7999
8462
|
/* @__PURE__ */ (0, import_jsx_runtime5.jsx)(
|
|
@@ -8005,6 +8468,8 @@ function Control({
|
|
|
8005
8468
|
"aria-pressed": pressed,
|
|
8006
8469
|
"aria-label": label,
|
|
8007
8470
|
onClick,
|
|
8471
|
+
disabled,
|
|
8472
|
+
title,
|
|
8008
8473
|
children: /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(Icon3, { size: 19 })
|
|
8009
8474
|
}
|
|
8010
8475
|
),
|
|
@@ -8143,18 +8608,22 @@ function TransferPicker({
|
|
|
8143
8608
|
}
|
|
8144
8609
|
function CallScreen({
|
|
8145
8610
|
call,
|
|
8146
|
-
incomingCallAction
|
|
8611
|
+
incomingCallAction,
|
|
8612
|
+
keyTones = true
|
|
8147
8613
|
}) {
|
|
8148
8614
|
const view = useCall(call);
|
|
8149
8615
|
const softphone = useSoftphone();
|
|
8150
|
-
const [
|
|
8616
|
+
const [panel, setPanel] = (0, import_react12.useState)(null);
|
|
8617
|
+
const showTransfer = panel === "transfer";
|
|
8618
|
+
const showKeypad = panel === "keypad";
|
|
8619
|
+
const showDevices = panel === "devices";
|
|
8620
|
+
const togglePanel = (p) => setPanel((cur) => cur === p ? null : p);
|
|
8151
8621
|
const [transferTo, setTransferTo] = (0, import_react12.useState)("");
|
|
8152
8622
|
const [consultCall, setConsultCall] = (0, import_react12.useState)(null);
|
|
8153
8623
|
const [consultOutcome, setConsultOutcome] = (0, import_react12.useState)(null);
|
|
8154
8624
|
const [myUserId, setMyUserId] = (0, import_react12.useState)(null);
|
|
8155
8625
|
const consultView = useCall(consultCall);
|
|
8156
8626
|
const consultIntentRef = (0, import_react12.useRef)(null);
|
|
8157
|
-
const [showDevices, setShowDevices] = (0, import_react12.useState)(false);
|
|
8158
8627
|
const controlsRef = (0, import_react12.useRef)(null);
|
|
8159
8628
|
const now = useTicker(view?.state === "active");
|
|
8160
8629
|
const consultNow = useTicker(consultView?.state === "active");
|
|
@@ -8211,20 +8680,31 @@ function CallScreen({
|
|
|
8211
8680
|
(0, import_react12.useEffect)(() => {
|
|
8212
8681
|
if (!showDevices) return;
|
|
8213
8682
|
const onKey = (e) => {
|
|
8214
|
-
if (e.key === "Escape")
|
|
8683
|
+
if (e.key === "Escape") setPanel(null);
|
|
8684
|
+
};
|
|
8685
|
+
document.addEventListener("keydown", onKey);
|
|
8686
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
8687
|
+
}, [showDevices]);
|
|
8688
|
+
(0, import_react12.useEffect)(() => {
|
|
8689
|
+
if (!showKeypad) return;
|
|
8690
|
+
const onKey = (e) => {
|
|
8691
|
+
if (e.key === "Escape") setPanel(null);
|
|
8215
8692
|
};
|
|
8216
8693
|
document.addEventListener("keydown", onKey);
|
|
8217
8694
|
if (!isConnected) return () => document.removeEventListener("keydown", onKey);
|
|
8218
8695
|
const root = controlsRef.current?.getRootNode() ?? document;
|
|
8219
8696
|
const onDown = (e) => {
|
|
8220
|
-
if (!controlsRef.current?.contains(e.target))
|
|
8697
|
+
if (!controlsRef.current?.contains(e.target)) setPanel(null);
|
|
8221
8698
|
};
|
|
8222
8699
|
root.addEventListener("pointerdown", onDown);
|
|
8223
8700
|
return () => {
|
|
8224
8701
|
document.removeEventListener("keydown", onKey);
|
|
8225
8702
|
root.removeEventListener("pointerdown", onDown);
|
|
8226
8703
|
};
|
|
8227
|
-
}, [
|
|
8704
|
+
}, [showKeypad, isConnected]);
|
|
8705
|
+
(0, import_react12.useEffect)(() => {
|
|
8706
|
+
if (view && view.state !== "active") setPanel((cur) => cur === "keypad" ? null : cur);
|
|
8707
|
+
}, [view?.state]);
|
|
8228
8708
|
if (!view || view.state === "ended") return null;
|
|
8229
8709
|
const s = view.state;
|
|
8230
8710
|
const isRinging = s === "ringing";
|
|
@@ -8250,9 +8730,9 @@ function CallScreen({
|
|
|
8250
8730
|
const [rawM = "0", ss = "00"] = formatDuration(elapsedSeconds(view.answeredAt, now)).split(":");
|
|
8251
8731
|
const mm = rawM.padStart(2, "0");
|
|
8252
8732
|
return /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("div", { className: "cv-view", children: [
|
|
8253
|
-
/* @__PURE__ */ (0, import_jsx_runtime5.jsx)("div", { className: "cv-call", children:
|
|
8733
|
+
/* @__PURE__ */ (0, import_jsx_runtime5.jsx)("div", { className: "cv-call", children: showDevices ? /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("div", { className: "cv-call-devices-inline", children: [
|
|
8254
8734
|
/* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("div", { className: "cv-call-devices-header", children: [
|
|
8255
|
-
/* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("button", { type: "button", className: "cv-call-devices-chip", onClick: () =>
|
|
8735
|
+
/* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("button", { type: "button", className: "cv-call-devices-chip", onClick: () => setPanel(null), children: [
|
|
8256
8736
|
/* @__PURE__ */ (0, import_jsx_runtime5.jsx)(ArrowLeft, { size: 13, "aria-hidden": true }),
|
|
8257
8737
|
"Volver"
|
|
8258
8738
|
] }),
|
|
@@ -8260,7 +8740,7 @@ function CallScreen({
|
|
|
8260
8740
|
] }),
|
|
8261
8741
|
/* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("p", { className: "cv-call-devices-context", children: [
|
|
8262
8742
|
showNumber ? `${number} \xB7 ` : "",
|
|
8263
|
-
STATUS_LABEL[s] ?? ""
|
|
8743
|
+
s === "active" ? `${mm}:${ss}` : STATUS_LABEL[s] ?? ""
|
|
8264
8744
|
] }),
|
|
8265
8745
|
/* @__PURE__ */ (0, import_jsx_runtime5.jsx)("div", { className: "cv-settings-section-body", children: /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(DevicePickers, {}) })
|
|
8266
8746
|
] }) : transferPickerActive ? (
|
|
@@ -8348,7 +8828,7 @@ function CallScreen({
|
|
|
8348
8828
|
/* @__PURE__ */ (0, import_jsx_runtime5.jsx)("i", { children: ":" }),
|
|
8349
8829
|
/* @__PURE__ */ (0, import_jsx_runtime5.jsx)("b", { children: ss })
|
|
8350
8830
|
] }) : /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("div", { className: "cv-call-status", children: s === "held" ? /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("span", { className: "cv-badge-hold", children: STATUS_LABEL[s] }) : STATUS_LABEL[s] ?? "" }),
|
|
8351
|
-
|
|
8831
|
+
/* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("div", { className: "cv-call-chips", children: [
|
|
8352
8832
|
incomingAction ? /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)(
|
|
8353
8833
|
"button",
|
|
8354
8834
|
{
|
|
@@ -8361,11 +8841,11 @@ function CallScreen({
|
|
|
8361
8841
|
]
|
|
8362
8842
|
}
|
|
8363
8843
|
) : null,
|
|
8364
|
-
/* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("button", { type: "button", className: "cv-call-devices-chip", onClick: () =>
|
|
8844
|
+
/* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("button", { type: "button", className: "cv-call-devices-chip", onClick: () => setPanel("devices"), children: [
|
|
8365
8845
|
/* @__PURE__ */ (0, import_jsx_runtime5.jsx)(Headphones, { size: 13, "aria-hidden": true }),
|
|
8366
8846
|
"Dispositivos"
|
|
8367
8847
|
] })
|
|
8368
|
-
] })
|
|
8848
|
+
] })
|
|
8369
8849
|
] }) }),
|
|
8370
8850
|
/* @__PURE__ */ (0, import_jsx_runtime5.jsx)("div", { className: "cv-controls", ref: controlsRef, children: isRinging ? /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("div", { className: "cv-controls-primary", children: [
|
|
8371
8851
|
/* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("div", { className: "cv-ctrl-item", children: [
|
|
@@ -8424,17 +8904,19 @@ function CallScreen({
|
|
|
8424
8904
|
label: "Transferir",
|
|
8425
8905
|
pressed: showTransfer,
|
|
8426
8906
|
tone: "accent",
|
|
8427
|
-
onClick: () =>
|
|
8907
|
+
onClick: () => togglePanel("transfer")
|
|
8428
8908
|
}
|
|
8429
8909
|
),
|
|
8430
8910
|
/* @__PURE__ */ (0, import_jsx_runtime5.jsx)(
|
|
8431
8911
|
Control,
|
|
8432
8912
|
{
|
|
8433
|
-
icon:
|
|
8434
|
-
label: "
|
|
8435
|
-
pressed:
|
|
8913
|
+
icon: Grid3x3,
|
|
8914
|
+
label: "Teclado",
|
|
8915
|
+
pressed: showKeypad,
|
|
8436
8916
|
tone: "accent",
|
|
8437
|
-
|
|
8917
|
+
disabled: s !== "active",
|
|
8918
|
+
title: s === "held" ? "Reanuda la llamada para usar el teclado" : s === "reconnecting" ? "No disponible mientras se reconecta" : void 0,
|
|
8919
|
+
onClick: () => togglePanel("keypad")
|
|
8438
8920
|
}
|
|
8439
8921
|
)
|
|
8440
8922
|
] }) : null,
|
|
@@ -8452,7 +8934,22 @@ function CallScreen({
|
|
|
8452
8934
|
),
|
|
8453
8935
|
/* @__PURE__ */ (0, import_jsx_runtime5.jsx)("span", { className: "cv-ctrl-label", "aria-hidden": true, children: "Colgar" })
|
|
8454
8936
|
] }) }),
|
|
8455
|
-
inCall &&
|
|
8937
|
+
inCall && showKeypad ? /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("div", { className: "cv-keypad-popover cv-keypad-popover--above", role: "dialog", "aria-label": "Teclado", children: KEYPAD_KEYS.map(([key, letters]) => /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)(
|
|
8938
|
+
"button",
|
|
8939
|
+
{
|
|
8940
|
+
type: "button",
|
|
8941
|
+
className: "cv-key",
|
|
8942
|
+
onClick: () => {
|
|
8943
|
+
call.sendDigits(key);
|
|
8944
|
+
if (keyTones) playKeyTone(key);
|
|
8945
|
+
},
|
|
8946
|
+
children: [
|
|
8947
|
+
/* @__PURE__ */ (0, import_jsx_runtime5.jsx)("b", { children: key }),
|
|
8948
|
+
letters ? /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("small", { children: letters }) : null
|
|
8949
|
+
]
|
|
8950
|
+
},
|
|
8951
|
+
key
|
|
8952
|
+
)) }) : null
|
|
8456
8953
|
] }) })
|
|
8457
8954
|
] });
|
|
8458
8955
|
}
|
|
@@ -9021,30 +9518,642 @@ function HistoryTab({ source }) {
|
|
|
9021
9518
|
selected ? /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(HistoryDetail, { entry: selected, onBack: () => setSelected(null) }) : null
|
|
9022
9519
|
] });
|
|
9023
9520
|
}
|
|
9024
|
-
|
|
9025
|
-
// src/ui/views/
|
|
9026
|
-
var
|
|
9027
|
-
|
|
9028
|
-
|
|
9029
|
-
|
|
9030
|
-
|
|
9031
|
-
|
|
9032
|
-
|
|
9033
|
-
|
|
9034
|
-
|
|
9521
|
+
|
|
9522
|
+
// src/ui/views/SettingsTab.tsx
|
|
9523
|
+
var import_react29 = __toESM(require_react(), 1);
|
|
9524
|
+
|
|
9525
|
+
// src/ui/audio/ringtone-store.ts
|
|
9526
|
+
var stores = /* @__PURE__ */ new WeakMap();
|
|
9527
|
+
function ringtoneStore(softphone) {
|
|
9528
|
+
const source = softphone.sources?.ringtones;
|
|
9529
|
+
if (!source) return null;
|
|
9530
|
+
let store = stores.get(softphone);
|
|
9531
|
+
if (!store) {
|
|
9532
|
+
store = createRingtoneStore(source);
|
|
9533
|
+
stores.set(softphone, store);
|
|
9534
|
+
}
|
|
9535
|
+
return store;
|
|
9536
|
+
}
|
|
9537
|
+
function createRingtoneStore(source, decodeImpl = decodeRingtone) {
|
|
9538
|
+
let settings = null;
|
|
9539
|
+
let audioWanted = false;
|
|
9540
|
+
let loaded = null;
|
|
9541
|
+
let loading2 = null;
|
|
9542
|
+
let inFlight = null;
|
|
9543
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
9544
|
+
const notify = () => {
|
|
9545
|
+
for (const l of listeners) l();
|
|
9546
|
+
};
|
|
9547
|
+
const effectiveUrl = () => {
|
|
9548
|
+
if (!settings) return null;
|
|
9549
|
+
return settings.catalogue.find((r) => r.id === settings.effective)?.url ?? null;
|
|
9550
|
+
};
|
|
9551
|
+
const loadEffective = async () => {
|
|
9552
|
+
const url = effectiveUrl();
|
|
9553
|
+
if (!audioWanted || !url || loaded?.url === url || loading2 === url) return;
|
|
9554
|
+
loading2 = url;
|
|
9555
|
+
try {
|
|
9556
|
+
const buffer = await decodeImpl(url);
|
|
9557
|
+
if (audioWanted && effectiveUrl() === url) {
|
|
9558
|
+
loaded = { url, buffer };
|
|
9559
|
+
notify();
|
|
9560
|
+
}
|
|
9561
|
+
} catch (error) {
|
|
9562
|
+
console.warn(`[softphone] ringtone could not be loaded, using the built-in tone: ${url}`, error);
|
|
9563
|
+
} finally {
|
|
9564
|
+
if (loading2 === url) loading2 = null;
|
|
9565
|
+
}
|
|
9566
|
+
};
|
|
9567
|
+
const apply = (next) => {
|
|
9568
|
+
settings = next;
|
|
9569
|
+
if (loaded && loaded.url !== effectiveUrl()) loaded = null;
|
|
9570
|
+
notify();
|
|
9571
|
+
};
|
|
9572
|
+
return {
|
|
9573
|
+
getSettings: () => settings,
|
|
9574
|
+
readyBuffer: () => loaded && loaded.url === effectiveUrl() ? loaded.buffer : null,
|
|
9575
|
+
readyLoop: () => {
|
|
9576
|
+
if (!loaded || loaded.url !== effectiveUrl() || !settings) return null;
|
|
9577
|
+
return settings.catalogue.find((r) => r.id === settings.effective)?.loop ?? null;
|
|
9578
|
+
},
|
|
9579
|
+
refresh() {
|
|
9580
|
+
inFlight ??= (async () => {
|
|
9581
|
+
if (!settings && source.cached) {
|
|
9582
|
+
const early = await source.cached().catch(() => null);
|
|
9583
|
+
if (early && !settings) {
|
|
9584
|
+
apply(early);
|
|
9585
|
+
void loadEffective();
|
|
9586
|
+
}
|
|
9587
|
+
}
|
|
9588
|
+
try {
|
|
9589
|
+
apply(await source.get());
|
|
9590
|
+
} catch (error) {
|
|
9591
|
+
console.warn("[softphone] ringtone settings could not be loaded; keeping the previous ones", error);
|
|
9592
|
+
} finally {
|
|
9593
|
+
inFlight = null;
|
|
9594
|
+
}
|
|
9595
|
+
await loadEffective();
|
|
9596
|
+
})();
|
|
9597
|
+
return inFlight;
|
|
9598
|
+
},
|
|
9599
|
+
async setMine(id) {
|
|
9600
|
+
apply(await source.setMine(id));
|
|
9601
|
+
void loadEffective();
|
|
9602
|
+
},
|
|
9603
|
+
wantAudio(on) {
|
|
9604
|
+
if (audioWanted === on) return;
|
|
9605
|
+
audioWanted = on;
|
|
9606
|
+
if (!on) {
|
|
9607
|
+
loaded = null;
|
|
9608
|
+
notify();
|
|
9609
|
+
} else {
|
|
9610
|
+
void loadEffective();
|
|
9611
|
+
}
|
|
9612
|
+
},
|
|
9613
|
+
decode: (url) => decodeImpl(url),
|
|
9614
|
+
subscribe(listener) {
|
|
9615
|
+
listeners.add(listener);
|
|
9616
|
+
return () => listeners.delete(listener);
|
|
9617
|
+
}
|
|
9618
|
+
};
|
|
9619
|
+
}
|
|
9620
|
+
async function decodeRingtone(url) {
|
|
9621
|
+
const Offline = typeof OfflineAudioContext !== "undefined" ? OfflineAudioContext : globalThis.webkitOfflineAudioContext;
|
|
9622
|
+
if (!Offline) throw new Error("this browser cannot decode audio files");
|
|
9623
|
+
const res = await fetch(url, { credentials: "omit" });
|
|
9624
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
9625
|
+
const bytes = await res.arrayBuffer();
|
|
9626
|
+
return new Offline(1, 1, 48e3).decodeAudioData(bytes);
|
|
9627
|
+
}
|
|
9628
|
+
|
|
9629
|
+
// src/ui/views/SettingsSection.tsx
|
|
9630
|
+
var import_jsx_runtime12 = __toESM(require_jsx_runtime(), 1);
|
|
9631
|
+
function SettingsSection({
|
|
9632
|
+
title,
|
|
9633
|
+
children
|
|
9634
|
+
}) {
|
|
9635
|
+
return /* @__PURE__ */ (0, import_jsx_runtime12.jsxs)("section", { className: "cv-settings-section", children: [
|
|
9636
|
+
/* @__PURE__ */ (0, import_jsx_runtime12.jsx)("h3", { className: "cv-settings-section-title", children: title }),
|
|
9637
|
+
/* @__PURE__ */ (0, import_jsx_runtime12.jsx)("div", { className: "cv-settings-section-body", children })
|
|
9638
|
+
] });
|
|
9639
|
+
}
|
|
9640
|
+
|
|
9641
|
+
// src/ui/views/settings/SettingsNavRow.tsx
|
|
9642
|
+
var import_jsx_runtime13 = __toESM(require_jsx_runtime(), 1);
|
|
9643
|
+
function SettingsNavRow({
|
|
9644
|
+
icon: Icon3,
|
|
9645
|
+
title,
|
|
9646
|
+
value,
|
|
9647
|
+
onOpen,
|
|
9648
|
+
rowRef
|
|
9649
|
+
}) {
|
|
9650
|
+
const inner = /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)(import_jsx_runtime13.Fragment, { children: [
|
|
9651
|
+
/* @__PURE__ */ (0, import_jsx_runtime13.jsx)("span", { className: "cv-settings-row-icon", "aria-hidden": true, children: /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(Icon3, { size: 16 }) }),
|
|
9652
|
+
/* @__PURE__ */ (0, import_jsx_runtime13.jsxs)("span", { className: "cv-settings-nav-text", children: [
|
|
9653
|
+
/* @__PURE__ */ (0, import_jsx_runtime13.jsx)("span", { className: "cv-settings-nav-title", children: title }),
|
|
9654
|
+
/* @__PURE__ */ (0, import_jsx_runtime13.jsx)("span", { className: "cv-settings-nav-value", children: value })
|
|
9655
|
+
] }),
|
|
9656
|
+
onOpen ? /* @__PURE__ */ (0, import_jsx_runtime13.jsx)(ChevronRight, { size: 16, className: "cv-settings-nav-chevron", "aria-hidden": true }) : null
|
|
9657
|
+
] });
|
|
9658
|
+
return onOpen ? /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("button", { type: "button", className: "cv-settings-nav", onClick: onOpen, ref: rowRef, children: inner }) : /* @__PURE__ */ (0, import_jsx_runtime13.jsx)("div", { className: "cv-settings-nav cv-settings-nav--static", children: inner });
|
|
9659
|
+
}
|
|
9660
|
+
|
|
9661
|
+
// src/ui/views/settings/DevicePage.tsx
|
|
9662
|
+
var import_react24 = __toESM(require_react(), 1);
|
|
9663
|
+
|
|
9664
|
+
// src/ui/views/settings/ChoiceList.tsx
|
|
9665
|
+
var import_react22 = __toESM(require_react(), 1);
|
|
9666
|
+
var import_jsx_runtime14 = __toESM(require_jsx_runtime(), 1);
|
|
9667
|
+
function ChoiceList({
|
|
9668
|
+
label,
|
|
9669
|
+
choices,
|
|
9670
|
+
value,
|
|
9671
|
+
onSelect,
|
|
9672
|
+
onReselect
|
|
9673
|
+
}) {
|
|
9674
|
+
const name = (0, import_react22.useId)();
|
|
9675
|
+
const pointer = (0, import_react22.useRef)(false);
|
|
9676
|
+
return /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("div", { className: "cv-choices", role: "radiogroup", "aria-label": label, children: choices.map((c) => {
|
|
9677
|
+
const checked = c.value === value;
|
|
9678
|
+
return /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(
|
|
9679
|
+
"label",
|
|
9680
|
+
{
|
|
9681
|
+
className: "cv-choice",
|
|
9682
|
+
"data-checked": checked || void 0,
|
|
9683
|
+
onPointerDown: () => {
|
|
9684
|
+
pointer.current = true;
|
|
9685
|
+
},
|
|
9686
|
+
children: [
|
|
9687
|
+
/* @__PURE__ */ (0, import_jsx_runtime14.jsx)(
|
|
9688
|
+
"input",
|
|
9689
|
+
{
|
|
9690
|
+
type: "radio",
|
|
9691
|
+
className: "cv-choice-input",
|
|
9692
|
+
name,
|
|
9693
|
+
value: c.value,
|
|
9694
|
+
checked,
|
|
9695
|
+
onChange: () => {
|
|
9696
|
+
const via = pointer.current ? "pointer" : "keyboard";
|
|
9697
|
+
pointer.current = false;
|
|
9698
|
+
onSelect(c.value, via);
|
|
9699
|
+
},
|
|
9700
|
+
onClick: () => {
|
|
9701
|
+
if (c.value === value) {
|
|
9702
|
+
pointer.current = false;
|
|
9703
|
+
onReselect?.(c.value);
|
|
9704
|
+
}
|
|
9705
|
+
}
|
|
9706
|
+
}
|
|
9707
|
+
),
|
|
9708
|
+
/* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("span", { className: "cv-choice-text", children: [
|
|
9709
|
+
/* @__PURE__ */ (0, import_jsx_runtime14.jsx)("span", { className: "cv-choice-label", children: c.label }),
|
|
9710
|
+
c.sub ? /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("span", { className: "cv-choice-sub", children: c.sub }) : null
|
|
9711
|
+
] }),
|
|
9712
|
+
c.trailing,
|
|
9713
|
+
/* @__PURE__ */ (0, import_jsx_runtime14.jsx)(Check, { size: 16, className: "cv-choice-check", "aria-hidden": true })
|
|
9714
|
+
]
|
|
9715
|
+
},
|
|
9716
|
+
c.value
|
|
9717
|
+
);
|
|
9718
|
+
}) });
|
|
9719
|
+
}
|
|
9720
|
+
|
|
9721
|
+
// src/ui/views/settings/SettingsPage.tsx
|
|
9722
|
+
var import_react23 = __toESM(require_react(), 1);
|
|
9723
|
+
var import_jsx_runtime15 = __toESM(require_jsx_runtime(), 1);
|
|
9724
|
+
function SettingsPage({
|
|
9725
|
+
title,
|
|
9726
|
+
intro,
|
|
9727
|
+
onBack,
|
|
9728
|
+
children
|
|
9729
|
+
}) {
|
|
9730
|
+
const titleId = (0, import_react23.useId)();
|
|
9731
|
+
const heading = (0, import_react23.useRef)(null);
|
|
9732
|
+
(0, import_react23.useEffect)(() => heading.current?.focus(), []);
|
|
9733
|
+
return /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)(
|
|
9734
|
+
"div",
|
|
9735
|
+
{
|
|
9736
|
+
className: "cv-detail cv-settings-page",
|
|
9737
|
+
role: "region",
|
|
9738
|
+
"aria-labelledby": titleId,
|
|
9739
|
+
onKeyDown: (e) => {
|
|
9740
|
+
if (e.key === "Escape") {
|
|
9741
|
+
e.stopPropagation();
|
|
9742
|
+
onBack();
|
|
9743
|
+
}
|
|
9744
|
+
},
|
|
9745
|
+
children: [
|
|
9746
|
+
/* @__PURE__ */ (0, import_jsx_runtime15.jsx)("div", { className: "cv-detail-head", children: /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("button", { type: "button", className: "cv-detail-back", onClick: onBack, children: [
|
|
9747
|
+
/* @__PURE__ */ (0, import_jsx_runtime15.jsx)(ArrowLeft, { size: 16, "aria-hidden": true }),
|
|
9748
|
+
"Ajustes"
|
|
9749
|
+
] }) }),
|
|
9750
|
+
/* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("div", { className: "cv-settings-page-body", children: [
|
|
9751
|
+
/* @__PURE__ */ (0, import_jsx_runtime15.jsx)("h2", { className: "cv-settings-page-title", id: titleId, tabIndex: -1, ref: heading, children: title }),
|
|
9752
|
+
intro ? /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("p", { className: "cv-settings-page-intro", children: intro }) : null,
|
|
9753
|
+
children
|
|
9754
|
+
] })
|
|
9755
|
+
]
|
|
9756
|
+
}
|
|
9757
|
+
);
|
|
9758
|
+
}
|
|
9759
|
+
|
|
9760
|
+
// src/ui/views/settings/DevicePage.tsx
|
|
9761
|
+
var import_jsx_runtime16 = __toESM(require_jsx_runtime(), 1);
|
|
9762
|
+
var SYSTEM_DEFAULT = "";
|
|
9763
|
+
var PAGES = {
|
|
9764
|
+
input: {
|
|
9765
|
+
title: "Micr\xF3fono",
|
|
9766
|
+
intro: "El micr\xF3fono que usa el tel\xE9fono en las llamadas.",
|
|
9767
|
+
empty: "No se encontr\xF3 ning\xFAn micr\xF3fono."
|
|
9768
|
+
},
|
|
9769
|
+
output: {
|
|
9770
|
+
title: "Altavoces",
|
|
9771
|
+
intro: "D\xF3nde oyes a la otra persona durante una llamada.",
|
|
9772
|
+
empty: "No se encontr\xF3 ning\xFAn altavoz."
|
|
9773
|
+
},
|
|
9774
|
+
ringtone: {
|
|
9775
|
+
title: "Salida del tono",
|
|
9776
|
+
intro: "D\xF3nde suena el tono cuando entra una llamada. La llamada se oye por \xABAltavoces\xBB.",
|
|
9777
|
+
empty: "No se encontr\xF3 ning\xFAn altavoz."
|
|
9778
|
+
}
|
|
9779
|
+
};
|
|
9780
|
+
function deviceSummary(kind, list, id) {
|
|
9781
|
+
const i = id ? list.findIndex((d) => d.deviceId === id) : -1;
|
|
9782
|
+
return i >= 0 ? deviceLabel(list[i], i, kind) : "Predeterminado del sistema";
|
|
9783
|
+
}
|
|
9784
|
+
function DevicePage({ kind, onBack }) {
|
|
9785
|
+
const devices = useDevices();
|
|
9786
|
+
const page = PAGES[kind];
|
|
9787
|
+
const list = kind === "input" ? devices.inputs : devices.outputs;
|
|
9788
|
+
const current = kind === "input" ? devices.currentInputId : kind === "output" ? devices.currentOutputId : devices.currentRingtoneOutputId;
|
|
9789
|
+
const apply = (id) => kind === "input" ? devices.setInput(id) : kind === "output" ? devices.setOutput(id) : devices.setRingtoneOutput(id);
|
|
9790
|
+
const choices = [
|
|
9791
|
+
{ value: SYSTEM_DEFAULT, label: "Predeterminado del sistema" },
|
|
9792
|
+
...list.map((d, i) => ({ value: d.deviceId, label: deviceLabel(d, i, kind) }))
|
|
9793
|
+
];
|
|
9794
|
+
return /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)(SettingsPage, { title: page.title, intro: page.intro, onBack, children: [
|
|
9795
|
+
list.length > 0 ? /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
|
|
9796
|
+
ChoiceList,
|
|
9797
|
+
{
|
|
9798
|
+
label: page.title,
|
|
9799
|
+
choices,
|
|
9800
|
+
value: current ?? SYSTEM_DEFAULT,
|
|
9801
|
+
onSelect: (v) => void apply(v === SYSTEM_DEFAULT ? null : v)
|
|
9802
|
+
}
|
|
9803
|
+
) : /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("p", { className: "cv-settings-page-empty", children: page.empty }),
|
|
9804
|
+
kind === "output" ? /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(SpeakerTest, { sinkId: devices.currentOutputId }) : null
|
|
9805
|
+
] });
|
|
9806
|
+
}
|
|
9807
|
+
function SpeakerTest({ sinkId }) {
|
|
9808
|
+
const [testing, setTesting] = (0, import_react24.useState)(false);
|
|
9809
|
+
const tone = (0, import_react24.useRef)(null);
|
|
9810
|
+
const timer = (0, import_react24.useRef)(null);
|
|
9811
|
+
(0, import_react24.useEffect)(
|
|
9812
|
+
() => () => {
|
|
9813
|
+
tone.current?.stop();
|
|
9814
|
+
if (timer.current) clearTimeout(timer.current);
|
|
9815
|
+
},
|
|
9816
|
+
[]
|
|
9817
|
+
);
|
|
9818
|
+
const test = () => {
|
|
9819
|
+
primeAudio();
|
|
9820
|
+
tone.current?.stop();
|
|
9821
|
+
tone.current = playTestTone(sinkId);
|
|
9822
|
+
setTesting(true);
|
|
9823
|
+
if (timer.current) clearTimeout(timer.current);
|
|
9824
|
+
timer.current = setTimeout(() => setTesting(false), TEST_TONE_MS);
|
|
9825
|
+
};
|
|
9826
|
+
return /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("button", { type: "button", className: "cv-btn cv-settings-test", onClick: test, disabled: testing, children: [
|
|
9827
|
+
/* @__PURE__ */ (0, import_jsx_runtime16.jsx)(Volume2, { size: 16, "aria-hidden": true }),
|
|
9828
|
+
testing ? "Sonando\u2026" : "Probar altavoz"
|
|
9829
|
+
] });
|
|
9830
|
+
}
|
|
9831
|
+
|
|
9832
|
+
// src/ui/views/settings/RingtonePage.tsx
|
|
9833
|
+
var import_react27 = __toESM(require_react(), 1);
|
|
9834
|
+
|
|
9835
|
+
// src/ui/views/settings/ringtone-state.ts
|
|
9836
|
+
var import_react26 = __toESM(require_react(), 1);
|
|
9837
|
+
|
|
9838
|
+
// src/ui/audio/preview.ts
|
|
9839
|
+
var PREVIEW_MAX_MS = 1e4;
|
|
9840
|
+
var BUILT_IN_PREVIEW_MS = 6e3;
|
|
9841
|
+
function previewDurationMs(buffer, loop) {
|
|
9842
|
+
if (!buffer) return BUILT_IN_PREVIEW_MS;
|
|
9843
|
+
const seconds = loop ? loop.end - loop.start : buffer.duration;
|
|
9844
|
+
return Math.min(seconds * 1e3, PREVIEW_MAX_MS);
|
|
9845
|
+
}
|
|
9846
|
+
|
|
9847
|
+
// src/ui/views/settings/ringtone-state.ts
|
|
9848
|
+
var noSubscribe = () => () => {
|
|
9849
|
+
};
|
|
9850
|
+
var noSettings = () => null;
|
|
9851
|
+
function useRingtoneSettings(store) {
|
|
9852
|
+
const settings = (0, import_react26.useSyncExternalStore)(store?.subscribe ?? noSubscribe, store?.getSettings ?? noSettings);
|
|
9853
|
+
const [failed, setFailed] = (0, import_react26.useState)(false);
|
|
9854
|
+
const load = (0, import_react26.useCallback)(() => {
|
|
9855
|
+
if (!store) return () => {
|
|
9856
|
+
};
|
|
9857
|
+
let live = true;
|
|
9858
|
+
setFailed(false);
|
|
9859
|
+
void store.refresh().then(() => {
|
|
9860
|
+
if (live) setFailed(store.getSettings() === null);
|
|
9861
|
+
});
|
|
9862
|
+
return () => {
|
|
9863
|
+
live = false;
|
|
9864
|
+
};
|
|
9865
|
+
}, [store]);
|
|
9866
|
+
(0, import_react26.useEffect)(load, [load]);
|
|
9867
|
+
return { settings, failed, retry: load };
|
|
9868
|
+
}
|
|
9869
|
+
function ringtoneSummary(settings, failed) {
|
|
9870
|
+
if (!settings) return failed ? "No disponible" : "Cargando\u2026";
|
|
9871
|
+
const byId = (id) => id ? settings.catalogue.find((r) => r.id === id) : void 0;
|
|
9872
|
+
const mine = byId(settings.mine);
|
|
9873
|
+
if (mine) return mine.name;
|
|
9874
|
+
const org = byId(settings.orgDefault);
|
|
9875
|
+
if (org) return `${org.name} \xB7 organizaci\xF3n`;
|
|
9876
|
+
return settings.catalogue.find((r) => r.url === null)?.name ?? "Predeterminado";
|
|
9877
|
+
}
|
|
9878
|
+
function useRingtonePreview(store, sinkId) {
|
|
9879
|
+
const [playing, setPlaying] = (0, import_react26.useState)(null);
|
|
9880
|
+
const [error, setError] = (0, import_react26.useState)(null);
|
|
9881
|
+
const current = (0, import_react26.useRef)(null);
|
|
9882
|
+
const generation = (0, import_react26.useRef)(0);
|
|
9883
|
+
const stop = (0, import_react26.useCallback)(() => {
|
|
9884
|
+
generation.current += 1;
|
|
9885
|
+
const c = current.current;
|
|
9886
|
+
current.current = null;
|
|
9887
|
+
if (c?.timer) clearTimeout(c.timer);
|
|
9888
|
+
c?.tone?.stop();
|
|
9889
|
+
setPlaying(null);
|
|
9890
|
+
}, []);
|
|
9891
|
+
(0, import_react26.useEffect)(() => stop, [stop]);
|
|
9892
|
+
const start = (settings, choice) => {
|
|
9893
|
+
stop();
|
|
9894
|
+
primeAudio();
|
|
9895
|
+
setError(null);
|
|
9896
|
+
const gen = generation.current;
|
|
9897
|
+
const orgDefault = settings.catalogue.some((r) => r.id === settings.orgDefault) ? settings.orgDefault : null;
|
|
9898
|
+
const id = choice || orgDefault;
|
|
9899
|
+
const option = settings.catalogue.find((r) => r.id === id) ?? settings.catalogue.find((r) => r.url === null);
|
|
9900
|
+
setPlaying(choice);
|
|
9901
|
+
current.current = { tone: null, timer: null };
|
|
9902
|
+
const play = (buffer) => {
|
|
9903
|
+
if (gen !== generation.current) return;
|
|
9904
|
+
const loop = buffer ? option?.loop ?? null : null;
|
|
9905
|
+
const tone = playRingtone({ buffer, loop, sinkId });
|
|
9906
|
+
current.current = { tone, timer: setTimeout(stop, previewDurationMs(buffer, loop)) };
|
|
9907
|
+
};
|
|
9908
|
+
if (!option?.url) {
|
|
9909
|
+
play(null);
|
|
9910
|
+
return;
|
|
9911
|
+
}
|
|
9912
|
+
store.decode(option.url).then(play, () => {
|
|
9913
|
+
if (gen !== generation.current) return;
|
|
9914
|
+
stop();
|
|
9915
|
+
setError("No se pudo reproducir el tono.");
|
|
9916
|
+
});
|
|
9917
|
+
};
|
|
9918
|
+
return { playing, error, start, stop };
|
|
9919
|
+
}
|
|
9920
|
+
|
|
9921
|
+
// src/ui/views/settings/RingtonePage.tsx
|
|
9922
|
+
var import_jsx_runtime17 = __toESM(require_jsx_runtime(), 1);
|
|
9923
|
+
var ORG_DEFAULT = "";
|
|
9924
|
+
var KEYBOARD_SETTLE_MS = 300;
|
|
9925
|
+
function RingtonePage({
|
|
9926
|
+
store,
|
|
9927
|
+
settings,
|
|
9928
|
+
failed,
|
|
9929
|
+
onRetry,
|
|
9930
|
+
onBack
|
|
9931
|
+
}) {
|
|
9932
|
+
const softphone = useSoftphone();
|
|
9933
|
+
const devices = useDevices();
|
|
9934
|
+
const preview = useRingtonePreview(store, devices.canSelectRingtoneOutput ? devices.currentRingtoneOutputId : null);
|
|
9935
|
+
const [pending, setPending] = (0, import_react27.useState)(void 0);
|
|
9936
|
+
const [error, setError] = (0, import_react27.useState)(null);
|
|
9937
|
+
const save = useSerialSave(store, {
|
|
9938
|
+
onSettled: () => setPending(void 0),
|
|
9939
|
+
onFailed: () => {
|
|
9940
|
+
setPending(void 0);
|
|
9941
|
+
setError("No se pudo guardar el tono. Int\xE9ntalo de nuevo.");
|
|
9942
|
+
}
|
|
9943
|
+
});
|
|
9944
|
+
const latest = (0, import_react27.useRef)(settings);
|
|
9945
|
+
latest.current = settings;
|
|
9946
|
+
const settle = (0, import_react27.useRef)(null);
|
|
9947
|
+
const { stop: stopPreview } = preview;
|
|
9948
|
+
(0, import_react27.useEffect)(() => softphone.on("incomingCall" /* IncomingCall */, stopPreview), [softphone, stopPreview]);
|
|
9949
|
+
(0, import_react27.useEffect)(
|
|
9950
|
+
() => () => {
|
|
9951
|
+
if (settle.current) {
|
|
9952
|
+
clearTimeout(settle.current.timer);
|
|
9953
|
+
save(settle.current.value || null);
|
|
9954
|
+
}
|
|
9955
|
+
},
|
|
9956
|
+
[save]
|
|
9957
|
+
);
|
|
9958
|
+
const commit = (value) => {
|
|
9959
|
+
save(value || null);
|
|
9960
|
+
if (latest.current) preview.start(latest.current, value);
|
|
9961
|
+
};
|
|
9962
|
+
const onSelect = (value, via) => {
|
|
9963
|
+
setError(null);
|
|
9964
|
+
setPending(value);
|
|
9965
|
+
if (settle.current) clearTimeout(settle.current.timer);
|
|
9966
|
+
settle.current = null;
|
|
9967
|
+
if (via === "pointer") {
|
|
9968
|
+
commit(value);
|
|
9969
|
+
return;
|
|
9970
|
+
}
|
|
9971
|
+
preview.stop();
|
|
9972
|
+
settle.current = {
|
|
9973
|
+
value,
|
|
9974
|
+
timer: setTimeout(() => {
|
|
9975
|
+
settle.current = null;
|
|
9976
|
+
commit(value);
|
|
9977
|
+
}, KEYBOARD_SETTLE_MS)
|
|
9978
|
+
};
|
|
9979
|
+
};
|
|
9980
|
+
const onReselect = (value) => {
|
|
9981
|
+
if (!settings) return;
|
|
9982
|
+
if (preview.playing === value) preview.stop();
|
|
9983
|
+
else preview.start(settings, value);
|
|
9984
|
+
};
|
|
9985
|
+
return /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(
|
|
9986
|
+
SettingsPage,
|
|
9987
|
+
{
|
|
9988
|
+
title: "Tono de llamada",
|
|
9989
|
+
intro: "Lo que suena cuando entra una llamada. Se guarda en tu cuenta, as\xED que es el mismo en todos tus dispositivos.",
|
|
9990
|
+
onBack,
|
|
9991
|
+
children: settings ? /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)(import_jsx_runtime17.Fragment, { children: [
|
|
9992
|
+
/* @__PURE__ */ (0, import_jsx_runtime17.jsx)(
|
|
9993
|
+
ChoiceList,
|
|
9994
|
+
{
|
|
9995
|
+
label: "Tono de llamada",
|
|
9996
|
+
choices: choicesFor(settings, preview.playing),
|
|
9997
|
+
value: pending !== void 0 ? pending : currentValue(settings),
|
|
9998
|
+
onSelect,
|
|
9999
|
+
onReselect
|
|
10000
|
+
}
|
|
10001
|
+
),
|
|
10002
|
+
error ?? preview.error ? /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("p", { className: "cv-settings-error", role: "alert", children: error ?? preview.error }) : null
|
|
10003
|
+
] }) : failed ? /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("div", { className: "cv-empty", children: [
|
|
10004
|
+
/* @__PURE__ */ (0, import_jsx_runtime17.jsx)("p", { className: "cv-empty-title", children: "No se pudieron cargar los tonos." }),
|
|
10005
|
+
/* @__PURE__ */ (0, import_jsx_runtime17.jsx)("button", { type: "button", className: "cv-btn cv-settings-retry", onClick: onRetry, children: "Reintentar" })
|
|
10006
|
+
] }) : /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("div", { className: "cv-empty", role: "status", children: [
|
|
10007
|
+
/* @__PURE__ */ (0, import_jsx_runtime17.jsx)(LoaderCircle, { className: "cv-empty-spinner", size: 18, "aria-hidden": true }),
|
|
10008
|
+
/* @__PURE__ */ (0, import_jsx_runtime17.jsx)("span", { className: "cv-empty-title", children: "Cargando\u2026" })
|
|
10009
|
+
] })
|
|
10010
|
+
}
|
|
10011
|
+
);
|
|
10012
|
+
}
|
|
10013
|
+
function currentValue(settings) {
|
|
10014
|
+
return settings.mine && settings.catalogue.some((r) => r.id === settings.mine) ? settings.mine : ORG_DEFAULT;
|
|
10015
|
+
}
|
|
10016
|
+
function choicesFor(settings, playing) {
|
|
10017
|
+
const org = settings.orgDefault ? settings.catalogue.find((r) => r.id === settings.orgDefault) : void 0;
|
|
10018
|
+
const builtIn = settings.catalogue.find((r) => r.url === null);
|
|
10019
|
+
const mark = (value) => playing === value ? /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("span", { className: "cv-choice-playing", children: [
|
|
10020
|
+
/* @__PURE__ */ (0, import_jsx_runtime17.jsx)(Volume2, { size: 15, "aria-hidden": true }),
|
|
10021
|
+
/* @__PURE__ */ (0, import_jsx_runtime17.jsx)("span", { className: "cv-sr-only", children: "Sonando" })
|
|
10022
|
+
] }) : void 0;
|
|
10023
|
+
const orgChoice = {
|
|
10024
|
+
value: ORG_DEFAULT,
|
|
10025
|
+
label: org ? "Predeterminado de la organizaci\xF3n" : "Predeterminado",
|
|
10026
|
+
sub: (org ?? builtIn)?.name ?? "",
|
|
10027
|
+
trailing: mark(ORG_DEFAULT)
|
|
10028
|
+
};
|
|
10029
|
+
return [orgChoice, ...settings.catalogue.map((r) => ({ value: r.id, label: r.name, trailing: mark(r.id) }))];
|
|
10030
|
+
}
|
|
10031
|
+
function useSerialSave(store, handlers) {
|
|
10032
|
+
const wanted = (0, import_react27.useRef)(null);
|
|
10033
|
+
const busy = (0, import_react27.useRef)(false);
|
|
10034
|
+
const on = (0, import_react27.useRef)(handlers);
|
|
10035
|
+
on.current = handlers;
|
|
10036
|
+
const [save] = (0, import_react27.useState)(() => (id) => {
|
|
10037
|
+
wanted.current = { id };
|
|
10038
|
+
if (busy.current) return;
|
|
10039
|
+
busy.current = true;
|
|
10040
|
+
void (async () => {
|
|
10041
|
+
while (wanted.current) {
|
|
10042
|
+
const { id: next } = wanted.current;
|
|
10043
|
+
wanted.current = null;
|
|
10044
|
+
try {
|
|
10045
|
+
await store.setMine(next);
|
|
10046
|
+
if (!wanted.current) on.current.onSettled();
|
|
10047
|
+
} catch {
|
|
10048
|
+
if (!wanted.current) on.current.onFailed();
|
|
10049
|
+
}
|
|
10050
|
+
}
|
|
10051
|
+
busy.current = false;
|
|
10052
|
+
})();
|
|
10053
|
+
});
|
|
10054
|
+
return save;
|
|
9035
10055
|
}
|
|
9036
10056
|
|
|
9037
10057
|
// src/ui/views/SettingsTab.tsx
|
|
9038
|
-
var
|
|
10058
|
+
var import_jsx_runtime18 = __toESM(require_jsx_runtime(), 1);
|
|
9039
10059
|
function SettingsTab() {
|
|
9040
|
-
|
|
9041
|
-
|
|
9042
|
-
|
|
9043
|
-
|
|
10060
|
+
const softphone = useSoftphone();
|
|
10061
|
+
const devices = useDevices();
|
|
10062
|
+
const store = ringtoneStore(softphone);
|
|
10063
|
+
const ringtone = useRingtoneSettings(store);
|
|
10064
|
+
const [page, setPage] = (0, import_react29.useState)(null);
|
|
10065
|
+
const list = (0, import_react29.useRef)(null);
|
|
10066
|
+
const rows = (0, import_react29.useRef)({});
|
|
10067
|
+
const returnTo = (0, import_react29.useRef)(null);
|
|
10068
|
+
(0, import_react29.useEffect)(() => {
|
|
10069
|
+
void softphone.audio.refresh();
|
|
10070
|
+
}, [softphone]);
|
|
10071
|
+
(0, import_react29.useEffect)(() => {
|
|
10072
|
+
list.current?.toggleAttribute("inert", page !== null);
|
|
10073
|
+
if (page === null && returnTo.current) {
|
|
10074
|
+
rows.current[returnTo.current]?.focus();
|
|
10075
|
+
returnTo.current = null;
|
|
10076
|
+
}
|
|
10077
|
+
}, [page]);
|
|
10078
|
+
const open = (p) => () => {
|
|
10079
|
+
returnTo.current = p;
|
|
10080
|
+
setPage(p);
|
|
10081
|
+
};
|
|
10082
|
+
const back = () => setPage(null);
|
|
10083
|
+
const rowRef = (p) => (el) => {
|
|
10084
|
+
rows.current[p] = el;
|
|
10085
|
+
};
|
|
10086
|
+
return /* @__PURE__ */ (0, import_jsx_runtime18.jsxs)("div", { className: "cv-view cv-settings", children: [
|
|
10087
|
+
/* @__PURE__ */ (0, import_jsx_runtime18.jsxs)("div", { className: "cv-settings-body", ref: list, children: [
|
|
10088
|
+
store ? /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(SettingsSection, { title: "Llamadas entrantes", children: /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(
|
|
10089
|
+
SettingsNavRow,
|
|
10090
|
+
{
|
|
10091
|
+
icon: Music,
|
|
10092
|
+
title: "Tono de llamada",
|
|
10093
|
+
value: ringtoneSummary(ringtone.settings, ringtone.failed),
|
|
10094
|
+
onOpen: open("tone"),
|
|
10095
|
+
rowRef: rowRef("tone")
|
|
10096
|
+
}
|
|
10097
|
+
) }) : null,
|
|
10098
|
+
/* @__PURE__ */ (0, import_jsx_runtime18.jsxs)(SettingsSection, { title: "Dispositivos", children: [
|
|
10099
|
+
/* @__PURE__ */ (0, import_jsx_runtime18.jsx)(
|
|
10100
|
+
SettingsNavRow,
|
|
10101
|
+
{
|
|
10102
|
+
icon: Mic,
|
|
10103
|
+
title: "Micr\xF3fono",
|
|
10104
|
+
value: deviceSummary("input", devices.inputs, devices.currentInputId),
|
|
10105
|
+
onOpen: open("input"),
|
|
10106
|
+
rowRef: rowRef("input")
|
|
10107
|
+
}
|
|
10108
|
+
),
|
|
10109
|
+
devices.canSelectOutput ? /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(
|
|
10110
|
+
SettingsNavRow,
|
|
10111
|
+
{
|
|
10112
|
+
icon: Headphones,
|
|
10113
|
+
title: "Altavoces",
|
|
10114
|
+
value: deviceSummary("output", devices.outputs, devices.currentOutputId),
|
|
10115
|
+
onOpen: open("output"),
|
|
10116
|
+
rowRef: rowRef("output")
|
|
10117
|
+
}
|
|
10118
|
+
) : (
|
|
10119
|
+
// Safari and most of Firefox have no `setSinkId`: offering a page whose choice silently does
|
|
10120
|
+
// nothing is worse than saying where the choice really lives.
|
|
10121
|
+
/* @__PURE__ */ (0, import_jsx_runtime18.jsx)(
|
|
10122
|
+
SettingsNavRow,
|
|
10123
|
+
{
|
|
10124
|
+
icon: Headphones,
|
|
10125
|
+
title: "Altavoces",
|
|
10126
|
+
value: "Tu navegador no permite elegirlos desde aqu\xED \u2014 c\xE1mbialos en los ajustes del sistema."
|
|
10127
|
+
}
|
|
10128
|
+
)
|
|
10129
|
+
),
|
|
10130
|
+
devices.canSelectRingtoneOutput ? /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(
|
|
10131
|
+
SettingsNavRow,
|
|
10132
|
+
{
|
|
10133
|
+
icon: BellRing,
|
|
10134
|
+
title: "Salida del tono",
|
|
10135
|
+
value: deviceSummary("ringtone", devices.outputs, devices.currentRingtoneOutputId),
|
|
10136
|
+
onOpen: open("ringtone"),
|
|
10137
|
+
rowRef: rowRef("ringtone")
|
|
10138
|
+
}
|
|
10139
|
+
) : null
|
|
10140
|
+
] })
|
|
10141
|
+
] }),
|
|
10142
|
+
page === "tone" && store ? /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(
|
|
10143
|
+
RingtonePage,
|
|
10144
|
+
{
|
|
10145
|
+
store,
|
|
10146
|
+
settings: ringtone.settings,
|
|
10147
|
+
failed: ringtone.failed,
|
|
10148
|
+
onRetry: ringtone.retry,
|
|
10149
|
+
onBack: back
|
|
10150
|
+
}
|
|
10151
|
+
) : page !== null && page !== "tone" ? /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(DevicePage, { kind: page, onBack: back }) : null
|
|
10152
|
+
] });
|
|
9044
10153
|
}
|
|
9045
10154
|
|
|
9046
10155
|
// src/ui/permission/useMicPermission.ts
|
|
9047
|
-
var
|
|
10156
|
+
var import_react31 = __toESM(require_react(), 1);
|
|
9048
10157
|
|
|
9049
10158
|
// src/core/env.ts
|
|
9050
10159
|
function isBrowser() {
|
|
@@ -9056,8 +10165,8 @@ function isWebRtcSupported() {
|
|
|
9056
10165
|
|
|
9057
10166
|
// src/ui/permission/useMicPermission.ts
|
|
9058
10167
|
function useMicPermission() {
|
|
9059
|
-
const [state, setState] = (0,
|
|
9060
|
-
const warm = (0,
|
|
10168
|
+
const [state, setState] = (0, import_react31.useState)("checking");
|
|
10169
|
+
const warm = (0, import_react31.useCallback)(async () => {
|
|
9061
10170
|
try {
|
|
9062
10171
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
9063
10172
|
for (const track of stream.getTracks()) track.stop();
|
|
@@ -9066,7 +10175,7 @@ function useMicPermission() {
|
|
|
9066
10175
|
setState("denied");
|
|
9067
10176
|
}
|
|
9068
10177
|
}, []);
|
|
9069
|
-
(0,
|
|
10178
|
+
(0, import_react31.useEffect)(() => {
|
|
9070
10179
|
if (!isWebRtcSupported()) {
|
|
9071
10180
|
setState("unsupported");
|
|
9072
10181
|
return;
|
|
@@ -9157,60 +10266,60 @@ function unblockGuide() {
|
|
|
9157
10266
|
}
|
|
9158
10267
|
|
|
9159
10268
|
// src/ui/permission/MicPermissionGate.tsx
|
|
9160
|
-
var
|
|
10269
|
+
var import_jsx_runtime19 = __toESM(require_jsx_runtime(), 1);
|
|
9161
10270
|
function MicPrompt({
|
|
9162
10271
|
state,
|
|
9163
10272
|
onRequest
|
|
9164
10273
|
}) {
|
|
9165
10274
|
if (state === "unsupported") {
|
|
9166
|
-
return /* @__PURE__ */ (0,
|
|
9167
|
-
/* @__PURE__ */ (0,
|
|
9168
|
-
/* @__PURE__ */ (0,
|
|
9169
|
-
/* @__PURE__ */ (0,
|
|
10275
|
+
return /* @__PURE__ */ (0, import_jsx_runtime19.jsxs)("div", { className: "cv-gate", "data-state": state, "data-tone": "danger", children: [
|
|
10276
|
+
/* @__PURE__ */ (0, import_jsx_runtime19.jsx)("span", { className: "cv-gate-mark", children: /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(MicOff, { size: 22 }) }),
|
|
10277
|
+
/* @__PURE__ */ (0, import_jsx_runtime19.jsx)("h3", { className: "cv-gate-title", children: "Llamadas no disponibles" }),
|
|
10278
|
+
/* @__PURE__ */ (0, import_jsx_runtime19.jsx)("p", { children: "Este navegador no admite llamadas. Prueba con Chrome, Edge o Firefox." })
|
|
9170
10279
|
] });
|
|
9171
10280
|
}
|
|
9172
10281
|
if (state === "denied") {
|
|
9173
10282
|
const guide = unblockGuide();
|
|
9174
|
-
return /* @__PURE__ */ (0,
|
|
9175
|
-
/* @__PURE__ */ (0,
|
|
9176
|
-
/* @__PURE__ */ (0,
|
|
9177
|
-
/* @__PURE__ */ (0,
|
|
9178
|
-
/* @__PURE__ */ (0,
|
|
9179
|
-
/* @__PURE__ */ (0,
|
|
9180
|
-
/* @__PURE__ */ (0,
|
|
10283
|
+
return /* @__PURE__ */ (0, import_jsx_runtime19.jsxs)("div", { className: "cv-gate", "data-state": state, "data-tone": "danger", children: [
|
|
10284
|
+
/* @__PURE__ */ (0, import_jsx_runtime19.jsx)("span", { className: "cv-gate-mark", children: /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(MicOff, { size: 22 }) }),
|
|
10285
|
+
/* @__PURE__ */ (0, import_jsx_runtime19.jsx)("h3", { className: "cv-gate-title", children: "Micr\xF3fono bloqueado" }),
|
|
10286
|
+
/* @__PURE__ */ (0, import_jsx_runtime19.jsx)("p", { children: "Perm\xEDtelo en tu navegador para poder llamar." }),
|
|
10287
|
+
/* @__PURE__ */ (0, import_jsx_runtime19.jsxs)("details", { className: "cv-help", children: [
|
|
10288
|
+
/* @__PURE__ */ (0, import_jsx_runtime19.jsxs)("summary", { children: [
|
|
10289
|
+
/* @__PURE__ */ (0, import_jsx_runtime19.jsx)(CircleHelp, { size: 13, "aria-hidden": true }),
|
|
9181
10290
|
"C\xF3mo permitirlo"
|
|
9182
10291
|
] }),
|
|
9183
|
-
/* @__PURE__ */ (0,
|
|
9184
|
-
guide.url ? /* @__PURE__ */ (0,
|
|
10292
|
+
/* @__PURE__ */ (0, import_jsx_runtime19.jsx)("ol", { children: guide.steps.map((step) => /* @__PURE__ */ (0, import_jsx_runtime19.jsx)("li", { children: step }, step)) }),
|
|
10293
|
+
guide.url ? /* @__PURE__ */ (0, import_jsx_runtime19.jsxs)("a", { href: guide.url, target: "_blank", rel: "noreferrer noopener", children: [
|
|
9185
10294
|
"Gu\xEDa del navegador",
|
|
9186
|
-
/* @__PURE__ */ (0,
|
|
10295
|
+
/* @__PURE__ */ (0, import_jsx_runtime19.jsx)(ExternalLink, { size: 11, "aria-hidden": true })
|
|
9187
10296
|
] }) : null
|
|
9188
10297
|
] })
|
|
9189
10298
|
] });
|
|
9190
10299
|
}
|
|
9191
10300
|
if (state === "warming") {
|
|
9192
|
-
return /* @__PURE__ */ (0,
|
|
9193
|
-
/* @__PURE__ */ (0,
|
|
9194
|
-
/* @__PURE__ */ (0,
|
|
9195
|
-
/* @__PURE__ */ (0,
|
|
10301
|
+
return /* @__PURE__ */ (0, import_jsx_runtime19.jsxs)("div", { className: "cv-gate", "data-state": state, children: [
|
|
10302
|
+
/* @__PURE__ */ (0, import_jsx_runtime19.jsx)("span", { className: "cv-gate-mark", children: /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(Mic, { size: 22 }) }),
|
|
10303
|
+
/* @__PURE__ */ (0, import_jsx_runtime19.jsx)("h3", { className: "cv-gate-title", children: "Permite el micr\xF3fono" }),
|
|
10304
|
+
/* @__PURE__ */ (0, import_jsx_runtime19.jsx)("p", { children: "Acepta el aviso que muestra el navegador para continuar." })
|
|
9196
10305
|
] });
|
|
9197
10306
|
}
|
|
9198
10307
|
if (state === "checking") {
|
|
9199
|
-
return /* @__PURE__ */ (0,
|
|
9200
|
-
/* @__PURE__ */ (0,
|
|
9201
|
-
/* @__PURE__ */ (0,
|
|
10308
|
+
return /* @__PURE__ */ (0, import_jsx_runtime19.jsxs)("div", { className: "cv-gate", "data-state": state, children: [
|
|
10309
|
+
/* @__PURE__ */ (0, import_jsx_runtime19.jsx)("span", { className: "cv-gate-mark", children: /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(Mic, { size: 22 }) }),
|
|
10310
|
+
/* @__PURE__ */ (0, import_jsx_runtime19.jsx)("p", { children: "Preparando el micr\xF3fono\u2026" })
|
|
9202
10311
|
] });
|
|
9203
10312
|
}
|
|
9204
|
-
return /* @__PURE__ */ (0,
|
|
9205
|
-
/* @__PURE__ */ (0,
|
|
9206
|
-
/* @__PURE__ */ (0,
|
|
9207
|
-
/* @__PURE__ */ (0,
|
|
9208
|
-
/* @__PURE__ */ (0,
|
|
10313
|
+
return /* @__PURE__ */ (0, import_jsx_runtime19.jsxs)("div", { className: "cv-gate", "data-state": state, children: [
|
|
10314
|
+
/* @__PURE__ */ (0, import_jsx_runtime19.jsx)("span", { className: "cv-gate-mark", children: /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(Mic, { size: 22 }) }),
|
|
10315
|
+
/* @__PURE__ */ (0, import_jsx_runtime19.jsx)("h3", { className: "cv-gate-title", children: "Permite el micr\xF3fono" }),
|
|
10316
|
+
/* @__PURE__ */ (0, import_jsx_runtime19.jsx)("p", { children: "Lo necesitamos para que puedas hablar durante las llamadas." }),
|
|
10317
|
+
/* @__PURE__ */ (0, import_jsx_runtime19.jsx)("button", { className: "cv-btn cv-btn--accent", onClick: onRequest, children: "Permitir micr\xF3fono" })
|
|
9209
10318
|
] });
|
|
9210
10319
|
}
|
|
9211
10320
|
|
|
9212
10321
|
// src/ui/permission/ErrorGate.tsx
|
|
9213
|
-
var
|
|
10322
|
+
var import_jsx_runtime20 = (
|
|
9214
10323
|
/*
|
|
9215
10324
|
`part` on all four, because this is the most intrusive thing the SDK draws — it replaces the
|
|
9216
10325
|
whole dialpad — and inside `<coventia-softphone>` a shadow root makes it otherwise unreachable
|
|
@@ -9226,36 +10335,36 @@ function ErrorGate({
|
|
|
9226
10335
|
onRetry
|
|
9227
10336
|
}) {
|
|
9228
10337
|
const text = ERROR_TEXT[error.code];
|
|
9229
|
-
return /* @__PURE__ */ (0,
|
|
9230
|
-
/* @__PURE__ */ (0,
|
|
9231
|
-
/* @__PURE__ */ (0,
|
|
9232
|
-
text.body === void 0 ? null : /* @__PURE__ */ (0,
|
|
9233
|
-
/* @__PURE__ */ (0,
|
|
10338
|
+
return /* @__PURE__ */ (0, import_jsx_runtime20.jsxs)("div", { className: "cv-gate", "data-tone": "danger", "data-code": error.code, part: "error-gate", children: [
|
|
10339
|
+
/* @__PURE__ */ (0, import_jsx_runtime20.jsx)("span", { className: "cv-gate-mark", children: /* @__PURE__ */ (0, import_jsx_runtime20.jsx)(TriangleAlert, { size: 22 }) }),
|
|
10340
|
+
/* @__PURE__ */ (0, import_jsx_runtime20.jsx)("h3", { className: "cv-gate-title", part: "error-title", children: text.title }),
|
|
10341
|
+
text.body === void 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime20.jsx)("p", { part: "error-body", children: text.body }),
|
|
10342
|
+
/* @__PURE__ */ (0, import_jsx_runtime20.jsx)("button", { className: "cv-btn cv-btn--accent", onClick: onRetry, part: "error-retry", children: "Reintentar" })
|
|
9234
10343
|
] });
|
|
9235
10344
|
}
|
|
9236
10345
|
|
|
9237
10346
|
// src/ui/permission/LeaderGate.tsx
|
|
9238
|
-
var
|
|
10347
|
+
var import_jsx_runtime21 = __toESM(require_jsx_runtime(), 1);
|
|
9239
10348
|
function LeaderGate() {
|
|
9240
|
-
return /* @__PURE__ */ (0,
|
|
9241
|
-
/* @__PURE__ */ (0,
|
|
9242
|
-
/* @__PURE__ */ (0,
|
|
9243
|
-
/* @__PURE__ */ (0,
|
|
10349
|
+
return /* @__PURE__ */ (0, import_jsx_runtime21.jsxs)("div", { className: "cv-gate", part: "leader-gate", children: [
|
|
10350
|
+
/* @__PURE__ */ (0, import_jsx_runtime21.jsx)("span", { className: "cv-gate-mark", children: /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(MonitorSmartphone, { size: 22 }) }),
|
|
10351
|
+
/* @__PURE__ */ (0, import_jsx_runtime21.jsx)("h3", { className: "cv-gate-title", part: "leader-title", children: "Tel\xE9fono activo en otra pesta\xF1a" }),
|
|
10352
|
+
/* @__PURE__ */ (0, import_jsx_runtime21.jsx)("p", { part: "leader-body", children: "Solo una pesta\xF1a puede usar el tel\xE9fono a la vez. Cierra la otra para activar esta." })
|
|
9244
10353
|
] });
|
|
9245
10354
|
}
|
|
9246
10355
|
|
|
9247
10356
|
// src/ui/lib/useCallOutcome.ts
|
|
9248
|
-
var
|
|
10357
|
+
var import_react32 = __toESM(require_react(), 1);
|
|
9249
10358
|
var BACKSTOP_MS = 6e3;
|
|
9250
10359
|
function useCallOutcome() {
|
|
9251
10360
|
const softphone = useSoftphone();
|
|
9252
|
-
const [code, setCode] = (0,
|
|
9253
|
-
const timer = (0,
|
|
9254
|
-
const clear = (0,
|
|
10361
|
+
const [code, setCode] = (0, import_react32.useState)(null);
|
|
10362
|
+
const timer = (0, import_react32.useRef)(void 0);
|
|
10363
|
+
const clear = (0, import_react32.useCallback)(() => {
|
|
9255
10364
|
clearTimeout(timer.current);
|
|
9256
10365
|
setCode(null);
|
|
9257
10366
|
}, []);
|
|
9258
|
-
(0,
|
|
10367
|
+
(0, import_react32.useEffect)(() => {
|
|
9259
10368
|
const off = softphone.on("error" /* Error */, (error) => {
|
|
9260
10369
|
if (error.fatal) return;
|
|
9261
10370
|
clearTimeout(timer.current);
|
|
@@ -9267,7 +10376,7 @@ function useCallOutcome() {
|
|
|
9267
10376
|
clearTimeout(timer.current);
|
|
9268
10377
|
};
|
|
9269
10378
|
}, [softphone]);
|
|
9270
|
-
(0,
|
|
10379
|
+
(0, import_react32.useEffect)(() => softphone.on("incomingCall" /* IncomingCall */, () => clear()), [softphone, clear]);
|
|
9271
10380
|
return {
|
|
9272
10381
|
outcome: code === null ? null : { code, text: ERROR_TEXT[code].title },
|
|
9273
10382
|
clear
|
|
@@ -9275,209 +10384,12 @@ function useCallOutcome() {
|
|
|
9275
10384
|
}
|
|
9276
10385
|
|
|
9277
10386
|
// src/ui/audio/useCallAudio.ts
|
|
9278
|
-
var
|
|
9279
|
-
|
|
9280
|
-
// src/ui/audio/audio.ts
|
|
9281
|
-
var TONE_HZ = 425;
|
|
9282
|
-
function createRemoteAudio() {
|
|
9283
|
-
const el = document.createElement("audio");
|
|
9284
|
-
el.autoplay = true;
|
|
9285
|
-
el.style.display = "none";
|
|
9286
|
-
document.body.appendChild(el);
|
|
9287
|
-
return {
|
|
9288
|
-
attach(stream) {
|
|
9289
|
-
el.srcObject = stream;
|
|
9290
|
-
void el.play().catch(() => {
|
|
9291
|
-
});
|
|
9292
|
-
},
|
|
9293
|
-
detach() {
|
|
9294
|
-
el.srcObject = null;
|
|
9295
|
-
},
|
|
9296
|
-
destroy() {
|
|
9297
|
-
el.srcObject = null;
|
|
9298
|
-
el.remove();
|
|
9299
|
-
},
|
|
9300
|
-
async setOutputDevice(deviceId) {
|
|
9301
|
-
if (typeof el.setSinkId !== "function") return;
|
|
9302
|
-
try {
|
|
9303
|
-
await el.setSinkId(deviceId ?? "");
|
|
9304
|
-
} catch {
|
|
9305
|
-
}
|
|
9306
|
-
}
|
|
9307
|
-
};
|
|
9308
|
-
}
|
|
9309
|
-
var shared = null;
|
|
9310
|
-
function sharedContext() {
|
|
9311
|
-
if (shared) return shared;
|
|
9312
|
-
const Ctor = typeof AudioContext !== "undefined" ? AudioContext : globalThis.webkitAudioContext;
|
|
9313
|
-
if (!Ctor) return null;
|
|
9314
|
-
shared = new Ctor();
|
|
9315
|
-
return shared;
|
|
9316
|
-
}
|
|
9317
|
-
function primeAudio() {
|
|
9318
|
-
const ctx = sharedContext();
|
|
9319
|
-
if (ctx && ctx.state === "suspended") void ctx.resume?.().catch(() => {
|
|
9320
|
-
});
|
|
9321
|
-
}
|
|
9322
|
-
function playRingtone() {
|
|
9323
|
-
const ctx = sharedContext();
|
|
9324
|
-
if (!ctx) return { stop() {
|
|
9325
|
-
} };
|
|
9326
|
-
const gain = ctx.createGain();
|
|
9327
|
-
gain.gain.value = 0;
|
|
9328
|
-
gain.connect(ctx.destination);
|
|
9329
|
-
const osc = ctx.createOscillator();
|
|
9330
|
-
osc.type = "sine";
|
|
9331
|
-
osc.frequency.value = TONE_HZ;
|
|
9332
|
-
osc.connect(gain);
|
|
9333
|
-
osc.start();
|
|
9334
|
-
const LEVEL = 0.8;
|
|
9335
|
-
const RAMP = 0.02;
|
|
9336
|
-
const burst = (at, dur) => {
|
|
9337
|
-
gain.gain.setValueAtTime(0, at);
|
|
9338
|
-
gain.gain.linearRampToValueAtTime(LEVEL, at + RAMP);
|
|
9339
|
-
gain.gain.setValueAtTime(LEVEL, at + dur - RAMP);
|
|
9340
|
-
gain.gain.linearRampToValueAtTime(0, at + dur);
|
|
9341
|
-
};
|
|
9342
|
-
const scheduleCycle = (at) => {
|
|
9343
|
-
burst(at, 0.4);
|
|
9344
|
-
burst(at + 0.6, 0.4);
|
|
9345
|
-
return 3;
|
|
9346
|
-
};
|
|
9347
|
-
const LOOKAHEAD = 0.5;
|
|
9348
|
-
const TICK_MS = 150;
|
|
9349
|
-
const LEAD = 0.08;
|
|
9350
|
-
let stopped = false;
|
|
9351
|
-
let timer = null;
|
|
9352
|
-
let nextAt = 0;
|
|
9353
|
-
const pump = () => {
|
|
9354
|
-
while (nextAt < ctx.currentTime + LOOKAHEAD) nextAt += scheduleCycle(nextAt);
|
|
9355
|
-
};
|
|
9356
|
-
void Promise.resolve(ctx.resume?.()).catch(() => {
|
|
9357
|
-
}).finally(() => {
|
|
9358
|
-
if (stopped) return;
|
|
9359
|
-
nextAt = ctx.currentTime + LEAD;
|
|
9360
|
-
pump();
|
|
9361
|
-
timer = setInterval(pump, TICK_MS);
|
|
9362
|
-
});
|
|
9363
|
-
return {
|
|
9364
|
-
stop() {
|
|
9365
|
-
stopped = true;
|
|
9366
|
-
if (timer) clearInterval(timer);
|
|
9367
|
-
try {
|
|
9368
|
-
osc.stop();
|
|
9369
|
-
} catch {
|
|
9370
|
-
}
|
|
9371
|
-
try {
|
|
9372
|
-
gain.disconnect();
|
|
9373
|
-
} catch {
|
|
9374
|
-
}
|
|
9375
|
-
}
|
|
9376
|
-
};
|
|
9377
|
-
}
|
|
9378
|
-
function playCallEndedTone() {
|
|
9379
|
-
const ctx = sharedContext();
|
|
9380
|
-
if (!ctx) return;
|
|
9381
|
-
const gain = ctx.createGain();
|
|
9382
|
-
gain.gain.value = 0;
|
|
9383
|
-
gain.connect(ctx.destination);
|
|
9384
|
-
const osc = ctx.createOscillator();
|
|
9385
|
-
osc.type = "sine";
|
|
9386
|
-
osc.frequency.value = TONE_HZ;
|
|
9387
|
-
osc.connect(gain);
|
|
9388
|
-
osc.start();
|
|
9389
|
-
const LEVEL = 0.55;
|
|
9390
|
-
const RAMP = 8e-3;
|
|
9391
|
-
const BEEP = 0.25;
|
|
9392
|
-
void Promise.resolve(ctx.resume?.()).catch(() => {
|
|
9393
|
-
}).finally(() => {
|
|
9394
|
-
const at = ctx.currentTime + 0.02;
|
|
9395
|
-
gain.gain.setValueAtTime(0, at);
|
|
9396
|
-
gain.gain.linearRampToValueAtTime(LEVEL, at + RAMP);
|
|
9397
|
-
gain.gain.setValueAtTime(LEVEL, at + BEEP - RAMP);
|
|
9398
|
-
gain.gain.linearRampToValueAtTime(0, at + BEEP);
|
|
9399
|
-
const totalMs = (at + BEEP - ctx.currentTime) * 1e3;
|
|
9400
|
-
setTimeout(() => {
|
|
9401
|
-
try {
|
|
9402
|
-
osc.stop();
|
|
9403
|
-
} catch {
|
|
9404
|
-
}
|
|
9405
|
-
try {
|
|
9406
|
-
gain.disconnect();
|
|
9407
|
-
} catch {
|
|
9408
|
-
}
|
|
9409
|
-
}, totalMs);
|
|
9410
|
-
});
|
|
9411
|
-
}
|
|
9412
|
-
var ringbackUrl = null;
|
|
9413
|
-
function ringbackSource() {
|
|
9414
|
-
if (ringbackUrl) return ringbackUrl;
|
|
9415
|
-
const sampleRate = 48e3;
|
|
9416
|
-
const channels = 2;
|
|
9417
|
-
const bytesPerSample = 2;
|
|
9418
|
-
const amp = 0.5;
|
|
9419
|
-
const toneSec = 1.5;
|
|
9420
|
-
const silenceSec = 3;
|
|
9421
|
-
const totalFrames = Math.round((toneSec + silenceSec) * sampleRate);
|
|
9422
|
-
const toneFrames = Math.round(toneSec * sampleRate);
|
|
9423
|
-
const ramp = Math.round(0.02 * sampleRate);
|
|
9424
|
-
const blockAlign = channels * bytesPerSample;
|
|
9425
|
-
const dataSize = totalFrames * blockAlign;
|
|
9426
|
-
const buffer = new ArrayBuffer(44 + dataSize);
|
|
9427
|
-
const view = new DataView(buffer);
|
|
9428
|
-
const str = (off, s) => {
|
|
9429
|
-
for (let i = 0; i < s.length; i++) view.setUint8(off + i, s.charCodeAt(i));
|
|
9430
|
-
};
|
|
9431
|
-
str(0, "RIFF");
|
|
9432
|
-
view.setUint32(4, 36 + dataSize, true);
|
|
9433
|
-
str(8, "WAVE");
|
|
9434
|
-
str(12, "fmt ");
|
|
9435
|
-
view.setUint32(16, 16, true);
|
|
9436
|
-
view.setUint16(20, 1, true);
|
|
9437
|
-
view.setUint16(22, channels, true);
|
|
9438
|
-
view.setUint32(24, sampleRate, true);
|
|
9439
|
-
view.setUint32(28, sampleRate * blockAlign, true);
|
|
9440
|
-
view.setUint16(32, blockAlign, true);
|
|
9441
|
-
view.setUint16(34, bytesPerSample * 8, true);
|
|
9442
|
-
str(36, "data");
|
|
9443
|
-
view.setUint32(40, dataSize, true);
|
|
9444
|
-
for (let i = 0; i < totalFrames; i++) {
|
|
9445
|
-
let sample = 0;
|
|
9446
|
-
if (i < toneFrames) {
|
|
9447
|
-
let env = 1;
|
|
9448
|
-
if (i < ramp) env = i / ramp;
|
|
9449
|
-
else if (i > toneFrames - ramp) env = Math.max(0, (toneFrames - i) / ramp);
|
|
9450
|
-
sample = Math.sin(2 * Math.PI * TONE_HZ * (i / sampleRate)) * amp * env;
|
|
9451
|
-
}
|
|
9452
|
-
const s16 = Math.round(sample * 32767);
|
|
9453
|
-
const off = 44 + i * blockAlign;
|
|
9454
|
-
for (let c = 0; c < channels; c++) view.setInt16(off + c * bytesPerSample, s16, true);
|
|
9455
|
-
}
|
|
9456
|
-
ringbackUrl = URL.createObjectURL(new Blob([buffer], { type: "audio/wav" }));
|
|
9457
|
-
return ringbackUrl;
|
|
9458
|
-
}
|
|
9459
|
-
function playRingback() {
|
|
9460
|
-
const el = document.createElement("audio");
|
|
9461
|
-
el.src = ringbackSource();
|
|
9462
|
-
el.loop = true;
|
|
9463
|
-
el.style.display = "none";
|
|
9464
|
-
document.body.appendChild(el);
|
|
9465
|
-
void el.play().catch(() => {
|
|
9466
|
-
});
|
|
9467
|
-
return {
|
|
9468
|
-
stop() {
|
|
9469
|
-
el.pause();
|
|
9470
|
-
el.remove();
|
|
9471
|
-
}
|
|
9472
|
-
};
|
|
9473
|
-
}
|
|
9474
|
-
|
|
9475
|
-
// src/ui/audio/useCallAudio.ts
|
|
10387
|
+
var import_react34 = __toESM(require_react(), 1);
|
|
9476
10388
|
function useCallAudio(enabled) {
|
|
9477
10389
|
const softphone = useSoftphone();
|
|
9478
|
-
const remote = (0,
|
|
9479
|
-
const tone = (0,
|
|
9480
|
-
(0,
|
|
10390
|
+
const remote = (0, import_react34.useRef)(null);
|
|
10391
|
+
const tone = (0, import_react34.useRef)(null);
|
|
10392
|
+
(0, import_react34.useEffect)(() => {
|
|
9481
10393
|
if (!enabled || typeof document === "undefined") return;
|
|
9482
10394
|
const prime = () => primeAudio();
|
|
9483
10395
|
const gestures = ["pointerdown", "keydown"];
|
|
@@ -9489,14 +10401,29 @@ function useCallAudio(enabled) {
|
|
|
9489
10401
|
const offDeviceChange = softphone.on("deviceChange" /* DeviceChange */, () => {
|
|
9490
10402
|
void remote.current?.setOutputDevice(softphone.audio.currentOutputId);
|
|
9491
10403
|
});
|
|
10404
|
+
const ringtones = ringtoneStore(softphone);
|
|
10405
|
+
ringtones?.wantAudio(true);
|
|
10406
|
+
const refreshRingtone = () => {
|
|
10407
|
+
if (softphone.isLeader) void ringtones?.refresh();
|
|
10408
|
+
};
|
|
10409
|
+
refreshRingtone();
|
|
10410
|
+
const offLeader = softphone.on("leaderChanged" /* LeaderChanged */, ({ isLeader }) => {
|
|
10411
|
+
if (isLeader) refreshRingtone();
|
|
10412
|
+
});
|
|
9492
10413
|
const stopTone = () => {
|
|
9493
10414
|
tone.current?.stop();
|
|
9494
10415
|
tone.current = null;
|
|
9495
10416
|
};
|
|
9496
10417
|
const setTone = (next) => {
|
|
9497
10418
|
stopTone();
|
|
9498
|
-
if (next === "ringtone")
|
|
9499
|
-
|
|
10419
|
+
if (next === "ringtone") {
|
|
10420
|
+
const audio = softphone.audio;
|
|
10421
|
+
tone.current = playRingtone({
|
|
10422
|
+
buffer: ringtones?.readyBuffer() ?? null,
|
|
10423
|
+
loop: ringtones?.readyLoop() ?? null,
|
|
10424
|
+
sinkId: audio.canSelectRingtoneOutput ? audio.currentRingtoneOutputId : null
|
|
10425
|
+
});
|
|
10426
|
+
} else if (next === "ringback") tone.current = playRingback();
|
|
9500
10427
|
};
|
|
9501
10428
|
const perCall = [];
|
|
9502
10429
|
const followedCalls = /* @__PURE__ */ new Set();
|
|
@@ -9527,6 +10454,7 @@ function useCallAudio(enabled) {
|
|
|
9527
10454
|
stopTone();
|
|
9528
10455
|
remote.current?.detach();
|
|
9529
10456
|
if (wasConnected && (reason === "local" || reason === "remote")) playCallEndedTone();
|
|
10457
|
+
refreshRingtone();
|
|
9530
10458
|
})
|
|
9531
10459
|
);
|
|
9532
10460
|
};
|
|
@@ -9544,6 +10472,8 @@ function useCallAudio(enabled) {
|
|
|
9544
10472
|
offIncoming();
|
|
9545
10473
|
offActive();
|
|
9546
10474
|
offDeviceChange();
|
|
10475
|
+
offLeader();
|
|
10476
|
+
ringtones?.wantAudio(false);
|
|
9547
10477
|
for (const off of perCall) off();
|
|
9548
10478
|
stopTone();
|
|
9549
10479
|
remote.current?.destroy();
|
|
@@ -9553,7 +10483,7 @@ function useCallAudio(enabled) {
|
|
|
9553
10483
|
}
|
|
9554
10484
|
|
|
9555
10485
|
// src/ui/notices/TransferNotices.tsx
|
|
9556
|
-
var
|
|
10486
|
+
var import_jsx_runtime22 = __toESM(require_jsx_runtime(), 1);
|
|
9557
10487
|
function TransferNotices({
|
|
9558
10488
|
notices,
|
|
9559
10489
|
dismiss
|
|
@@ -9565,7 +10495,7 @@ function TransferNotices({
|
|
|
9565
10495
|
if (notice.caller) void softphone.call(notice.caller).catch(() => {
|
|
9566
10496
|
});
|
|
9567
10497
|
};
|
|
9568
|
-
return /* @__PURE__ */ (0,
|
|
10498
|
+
return /* @__PURE__ */ (0, import_jsx_runtime22.jsxs)(
|
|
9569
10499
|
"div",
|
|
9570
10500
|
{
|
|
9571
10501
|
className: "cv-transfer-notices",
|
|
@@ -9573,20 +10503,20 @@ function TransferNotices({
|
|
|
9573
10503
|
part: "transfer-notices",
|
|
9574
10504
|
"data-has-notices": notices.length > 0,
|
|
9575
10505
|
children: [
|
|
9576
|
-
notices.length > 1 ? /* @__PURE__ */ (0,
|
|
10506
|
+
notices.length > 1 ? /* @__PURE__ */ (0, import_jsx_runtime22.jsxs)("p", { className: "cv-transfer-notices-count", children: [
|
|
9577
10507
|
notices.length,
|
|
9578
10508
|
" traspasos sin conectar"
|
|
9579
10509
|
] }) : null,
|
|
9580
|
-
notices.map((notice) => /* @__PURE__ */ (0,
|
|
9581
|
-
/* @__PURE__ */ (0,
|
|
9582
|
-
/* @__PURE__ */ (0,
|
|
9583
|
-
/* @__PURE__ */ (0,
|
|
9584
|
-
/* @__PURE__ */ (0,
|
|
10510
|
+
notices.map((notice) => /* @__PURE__ */ (0, import_jsx_runtime22.jsxs)("div", { className: "cv-transfer-notice", part: "transfer-notice", children: [
|
|
10511
|
+
/* @__PURE__ */ (0, import_jsx_runtime22.jsx)("span", { className: "cv-transfer-notice-mark", "aria-hidden": true, children: /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(PhoneMissed, { size: 16 }) }),
|
|
10512
|
+
/* @__PURE__ */ (0, import_jsx_runtime22.jsxs)("div", { className: "cv-transfer-notice-body", children: [
|
|
10513
|
+
/* @__PURE__ */ (0, import_jsx_runtime22.jsxs)("div", { className: "cv-transfer-notice-header", children: [
|
|
10514
|
+
/* @__PURE__ */ (0, import_jsx_runtime22.jsxs)("p", { className: "cv-transfer-notice-title", children: [
|
|
9585
10515
|
"El traspaso a ",
|
|
9586
10516
|
notice.target,
|
|
9587
10517
|
" no se conect\xF3"
|
|
9588
10518
|
] }),
|
|
9589
|
-
/* @__PURE__ */ (0,
|
|
10519
|
+
/* @__PURE__ */ (0, import_jsx_runtime22.jsx)(
|
|
9590
10520
|
"button",
|
|
9591
10521
|
{
|
|
9592
10522
|
type: "button",
|
|
@@ -9594,17 +10524,17 @@ function TransferNotices({
|
|
|
9594
10524
|
onClick: () => dismiss(notice.id),
|
|
9595
10525
|
"aria-label": "Descartar aviso",
|
|
9596
10526
|
part: "transfer-notice-dismiss",
|
|
9597
|
-
children: /* @__PURE__ */ (0,
|
|
10527
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(X, { size: 14 })
|
|
9598
10528
|
}
|
|
9599
10529
|
)
|
|
9600
10530
|
] }),
|
|
9601
|
-
notice.caller ? /* @__PURE__ */ (0,
|
|
10531
|
+
notice.caller ? /* @__PURE__ */ (0, import_jsx_runtime22.jsxs)("p", { className: "cv-transfer-notice-subtitle", children: [
|
|
9602
10532
|
notice.caller,
|
|
9603
10533
|
" se ha desconectado."
|
|
9604
10534
|
] }) : null,
|
|
9605
|
-
/* @__PURE__ */ (0,
|
|
9606
|
-
/* @__PURE__ */ (0,
|
|
9607
|
-
notice.caller ? /* @__PURE__ */ (0,
|
|
10535
|
+
/* @__PURE__ */ (0, import_jsx_runtime22.jsxs)("div", { className: "cv-transfer-notice-footer", children: [
|
|
10536
|
+
/* @__PURE__ */ (0, import_jsx_runtime22.jsx)("p", { className: "cv-transfer-notice-age", children: formatAge(elapsedSeconds(notice.at, now)) }),
|
|
10537
|
+
notice.caller ? /* @__PURE__ */ (0, import_jsx_runtime22.jsxs)(
|
|
9608
10538
|
"button",
|
|
9609
10539
|
{
|
|
9610
10540
|
type: "button",
|
|
@@ -9612,7 +10542,7 @@ function TransferNotices({
|
|
|
9612
10542
|
onClick: () => callBack(notice),
|
|
9613
10543
|
part: "transfer-notice-callback",
|
|
9614
10544
|
children: [
|
|
9615
|
-
/* @__PURE__ */ (0,
|
|
10545
|
+
/* @__PURE__ */ (0, import_jsx_runtime22.jsx)(Phone, { size: 14, "aria-hidden": true }),
|
|
9616
10546
|
"Llamar de vuelta"
|
|
9617
10547
|
]
|
|
9618
10548
|
}
|
|
@@ -9626,15 +10556,15 @@ function TransferNotices({
|
|
|
9626
10556
|
}
|
|
9627
10557
|
|
|
9628
10558
|
// src/ui/notices/useTransferNotices.ts
|
|
9629
|
-
var
|
|
10559
|
+
var import_react37 = __toESM(require_react(), 1);
|
|
9630
10560
|
function useTransferNotices() {
|
|
9631
10561
|
const softphone = useSoftphone();
|
|
9632
|
-
const [notices, setNotices] = (0,
|
|
9633
|
-
const nextId = (0,
|
|
9634
|
-
const dismiss = (0,
|
|
10562
|
+
const [notices, setNotices] = (0, import_react37.useState)([]);
|
|
10563
|
+
const nextId = (0, import_react37.useRef)(0);
|
|
10564
|
+
const dismiss = (0, import_react37.useCallback)((id) => {
|
|
9635
10565
|
setNotices((prev) => prev.filter((n) => n.id !== id));
|
|
9636
10566
|
}, []);
|
|
9637
|
-
(0,
|
|
10567
|
+
(0, import_react37.useEffect)(
|
|
9638
10568
|
() => softphone.on("transferOutcome" /* TransferOutcome */, (payload) => {
|
|
9639
10569
|
if (payload.status === "not-connected") {
|
|
9640
10570
|
const id = nextId.current++;
|
|
@@ -9647,7 +10577,7 @@ function useTransferNotices() {
|
|
|
9647
10577
|
}
|
|
9648
10578
|
|
|
9649
10579
|
// src/ui/SoftphoneWidget.tsx
|
|
9650
|
-
var
|
|
10580
|
+
var import_jsx_runtime23 = __toESM(require_jsx_runtime(), 1);
|
|
9651
10581
|
function SoftphoneWidget(props) {
|
|
9652
10582
|
const {
|
|
9653
10583
|
contacts,
|
|
@@ -9663,7 +10593,7 @@ function SoftphoneWidget(props) {
|
|
|
9663
10593
|
const fallback = softphone.sources;
|
|
9664
10594
|
const contactsSource = contacts ?? fallback?.contacts;
|
|
9665
10595
|
const historySource = history ?? fallback?.history;
|
|
9666
|
-
const [tab, setTab] = (0,
|
|
10596
|
+
const [tab, setTab] = (0, import_react39.useState)("dialer");
|
|
9667
10597
|
const call = useActiveCall();
|
|
9668
10598
|
const isLeader = useIsLeader();
|
|
9669
10599
|
const softphoneState = useSoftphoneState();
|
|
@@ -9692,15 +10622,15 @@ function SoftphoneWidget(props) {
|
|
|
9692
10622
|
// optional parts of a phone, so this is never conditionally hidden.
|
|
9693
10623
|
{ id: "settings", icon: Settings, label: "Ajustes", show: true }
|
|
9694
10624
|
].filter((t) => t.show);
|
|
9695
|
-
return /* @__PURE__ */ (0,
|
|
10625
|
+
return /* @__PURE__ */ (0, import_jsx_runtime23.jsxs)(
|
|
9696
10626
|
"div",
|
|
9697
10627
|
{
|
|
9698
10628
|
className: className ? `cv-softphone ${className}` : "cv-softphone",
|
|
9699
10629
|
...theme === "system" ? {} : { "data-cv-theme": theme },
|
|
9700
10630
|
children: [
|
|
9701
|
-
screenIsFree ? /* @__PURE__ */ (0,
|
|
9702
|
-
/* @__PURE__ */ (0,
|
|
9703
|
-
screenIsFree ? /* @__PURE__ */ (0,
|
|
10631
|
+
screenIsFree ? /* @__PURE__ */ (0, import_jsx_runtime23.jsx)(TransferNotices, { notices: transferNotices, dismiss: dismissTransferNotice }) : null,
|
|
10632
|
+
/* @__PURE__ */ (0, import_jsx_runtime23.jsx)("div", { className: "cv-body", children: isFollower ? /* @__PURE__ */ (0, import_jsx_runtime23.jsx)(LeaderGate, {}) : blocked ? /* @__PURE__ */ (0, import_jsx_runtime23.jsx)(MicPrompt, { state: mic.state, onRequest: () => void mic.request() }) : call ? /* @__PURE__ */ (0, import_jsx_runtime23.jsx)(CallScreen, { call, keyTones: sounds, ...incomingCallAction ? { incomingCallAction } : {} }) : fatal ? /* @__PURE__ */ (0, import_jsx_runtime23.jsx)(ErrorGate, { error: fatal, onRetry: retry }) : tab === "contacts" && contactsSource ? /* @__PURE__ */ (0, import_jsx_runtime23.jsx)(ContactsTab, { source: contactsSource, ...contactAction ? { contactAction } : {} }) : tab === "history" && historySource ? /* @__PURE__ */ (0, import_jsx_runtime23.jsx)(HistoryTab, { source: historySource }) : tab === "settings" ? /* @__PURE__ */ (0, import_jsx_runtime23.jsx)(SettingsTab, {}) : /* @__PURE__ */ (0, import_jsx_runtime23.jsx)(Dialpad, { outcome, onIntent: clearOutcome, keyTones: sounds }) }),
|
|
10633
|
+
screenIsFree ? /* @__PURE__ */ (0, import_jsx_runtime23.jsx)("div", { className: "cv-tabs", role: "tablist", children: tabs.map(({ id, icon: Icon3, label }) => /* @__PURE__ */ (0, import_jsx_runtime23.jsxs)(
|
|
9704
10634
|
"button",
|
|
9705
10635
|
{
|
|
9706
10636
|
role: "tab",
|
|
@@ -9708,8 +10638,8 @@ function SoftphoneWidget(props) {
|
|
|
9708
10638
|
className: "cv-tab",
|
|
9709
10639
|
onClick: () => setTab(id),
|
|
9710
10640
|
children: [
|
|
9711
|
-
/* @__PURE__ */ (0,
|
|
9712
|
-
/* @__PURE__ */ (0,
|
|
10641
|
+
/* @__PURE__ */ (0, import_jsx_runtime23.jsx)(Icon3, { size: 20 }),
|
|
10642
|
+
/* @__PURE__ */ (0, import_jsx_runtime23.jsx)("span", { children: label })
|
|
9713
10643
|
]
|
|
9714
10644
|
},
|
|
9715
10645
|
id
|
|
@@ -9720,7 +10650,7 @@ function SoftphoneWidget(props) {
|
|
|
9720
10650
|
}
|
|
9721
10651
|
|
|
9722
10652
|
// src/styles/softphone.css
|
|
9723
|
-
var softphone_default = "/**\r\n * @coventia/softphone/styles.css\r\n *\r\n * The theming contract (SPEC \xA72.6) + all component styles. A host restyles the softphone by\r\n * overriding the `--cv-*` custom properties \u2014 which are INHERITED, so they cross the shadow boundary\r\n * of `<coventia-softphone>` by design (the host's own CSS *selectors* do not, which is what stops a\r\n * stray `button { width:100% }` from destroying the dialpad).\r\n *\r\n * Defaults match the Coventia product: light surface, emerald accent, soft rounded key cards.\r\n *\r\n * Reseller: coventia-softphone { --cv-accent:#7c3aed; --cv-bg:#0b0b0f; }\r\n * CRM (shadcn bridge, ~15 lines, dark mode included):\r\n * .cv-softphone { --cv-bg:hsl(var(--background)); --cv-text:hsl(var(--foreground)); \u2026 }\r\n *\r\n * TYPOGRAPHY NOTE: `--cv-font` is a system stack on purpose. An embedded widget should inherit its\r\n * host's type, not force a webfont into someone else's app; hosts override the token if they want.\r\n *\r\n * Strings are Spanish-only (SPEC \xA72.4). No i18n.\r\n */\r\n\r\n/*\r\n * The custom element's own box.\r\n *\r\n * A custom element is `display: inline` by default and has NO size \u2014 and the widget inside now fills\r\n * its host rather than asserting 340\xD7520, so without this the element would render to ZERO. The\r\n * drop-in promise is \"one tag and it works\", which means the TAG must carry a box.\r\n *\r\n * These are defaults, not decisions: they sit on `:host`, so any host stylesheet outranks them \u2014\r\n *\r\n * coventia-softphone { width: 100%; height: 100%; } \u2190 a sidebar\r\n * coventia-softphone { height: 600px; } \u2190 taller\r\n *\r\n * React consumers never see this: they import `/ui` and their own wrapper states the box (the CRM's\r\n * PhoneDialer does exactly that).\r\n */\r\n:host {\r\n display: block;\r\n width: 340px;\r\n height: 520px;\r\n}\r\n\r\n/* React's container inside the shadow root \u2014 element path only; inert everywhere else.\r\n *\r\n * It must pass the host's height straight through. `.cv-softphone` below is `height: 100%`, and a\r\n * percentage height resolves against the PARENT: leave this div at its default `auto` and that 100%\r\n * silently becomes `auto` too, so the widget collapses to its content height and the host's `height`\r\n * is ignored. What you see is a short card stranded in a taller box \u2014 which reads as a broken widget,\r\n * not as a broken wrapper. */\r\n.cv-mount {\r\n height: 100%;\r\n}\r\n\r\n:host,\r\n.cv-softphone {\r\n /* Surface */\r\n --cv-bg: #ffffff;\r\n --cv-surface: #f9fafb;\r\n --cv-surface-2: #f3f4f6;\r\n --cv-border: #f3f4f6;\r\n --cv-border-strong: #e5e7eb;\r\n\r\n /* Text \u2014 three tiers, split by CONTRAST DUTY, not by taste:\r\n `text` informational primary 17.7:1\r\n `muted` informational secondary 4.8:1 (AA \u2014 phone numbers, timestamps, tab labels)\r\n `faint` DECORATIVE only 2.5:1 (keypad letters, placeholder \u2014 never information)\r\n The legacy used gray-400 for all secondary text, which fails AA at 2.54:1. Splitting the tier\r\n keeps the look while making the informational text legible. */\r\n --cv-text: #111827;\r\n --cv-muted: #6b7280;\r\n --cv-faint: #9ca3af;\r\n\r\n /* Intent \u2014 emerald. Split by CONTRAST DUTY, because one \"brand green\" cannot do all three jobs:\r\n `accent` FILL that carries a white glyph \u2192 emerald-600, white 3.77:1 \u2713\r\n (emerald-500 is the prettier brand green but white on it is 2.54:1 \u2014 the\r\n call button's phone icon would fail WCAG non-text contrast. Measured, not guessed.)\r\n `accent-strong` emerald AS TEXT on a surface \u2192 emerald-700, 5.48:1 \u2713 for the 10px tab label\r\n `accent-light` DECORATIVE only (the ring halo) \u2014 never carries text, so it stays vivid */\r\n --cv-accent: #059669;\r\n --cv-accent-strong: #047857;\r\n --cv-accent-light: #10b981;\r\n --cv-accent-soft: #a7f3d0;\r\n --cv-accent-contrast: #ffffff;\r\n --cv-danger: #ef4444;\r\n /* `--cv-danger` ITSELF fails 3:1 non-text contrast against `--cv-danger-soft` (2.6:1, measured) \u2014\r\n the same AS-TEXT/on-a-tinted-surface split `accent` already has, `danger` was just missing until\r\n the Historial tab's status badges needed it. Darker on light (mirrors `accent-strong`), LIGHTER on\r\n dark (red-400) \u2014 same inversion `accent-strong` documents above, same reason: emphasis on a dark\r\n surface means more light, not less.\r\n red-700 (#b91c1c) was the first pick here and overshot both minimums (6.47:1 text, 4.47:1 badge)\r\n enough to read as a dark, brownish red rather than red \u2014 red-600 clears both with real margin\r\n still (4.83:1 / 3.34:1, measured) and looks like red. */\r\n --cv-danger-strong: #dc2626;\r\n --cv-danger-soft: #fecaca;\r\n /* A third status hue, sky \u2014 for the Historial row status chip specifically: an outgoing call that\r\n WAS answered gets its own colour rather than sharing `accent`, matching the legacy CRM's own\r\n three-way split (emerald/sky/rose) that this file's earlier restrained accent-only-for-\"fine\"\r\n version had collapsed away. Same strong/soft split, same inversion, as `accent`/`danger` above \u2014\r\n 4.47:1 light / 4.42:1 dark against `-soft`, measured. */\r\n --cv-info: #0284c7;\r\n --cv-info-strong: #0369a1;\r\n --cv-info-soft: #bae6fd;\r\n --cv-hold: #6366f1;\r\n\r\n /* Shape & type */\r\n --cv-radius: 16px;\r\n /* Strong ease-out. The built-in CSS easings are too weak to read as intentional. */\r\n --cv-ease-out: cubic-bezier(0.23, 1, 0.32, 1);\r\n --cv-radius-key: 16px;\r\n --cv-font: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;\r\n --cv-font-size: 14px;\r\n --cv-shadow: 0 1px 2px rgb(0 0 0 / 0.04), 0 1px 3px rgb(0 0 0 / 0.06);\r\n\r\n color: var(--cv-text);\r\n font-family: var(--cv-font);\r\n font-size: var(--cv-font-size);\r\n}\r\n\r\n/**\r\n * Dark theme \u2014 matched to the Coventia product's own dark tokens, not invented.\r\n *\r\n * The CRM defines `--background: 240 10% 10%` (#17171c) with surfaces at 15.9% (#27272a) and borders\r\n * at 25.9% (#404044) \u2014 the zinc/240-hue family. An earlier draft here used a near-black\r\n * (#0f0f13, ~half the luminance of the product's background) with 2%-luminance borders that were\r\n * effectively invisible. That is the \"black tonality\" that read wrong: right hue, wrong lightness.\r\n *\r\n * Note the accent INVERTS: on light, `accent-strong` is darker than `accent` (emerald-700); on dark\r\n * it must be LIGHTER (emerald-400), because emphasis on a dark surface means more light, not less.\r\n * Same token, opposite direction \u2014 miss this and the timer/active tab sink into the background.\r\n *\r\n * \u2500\u2500 HOW THE THEME IS CHOSEN \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n * By default it follows the OS (`prefers-color-scheme`). But a host must be able to PIN it: the\r\n * Coventia CRM toggles dark with a class (next-themes), not the OS setting \u2014 so a user with a dark\r\n * OS but the CRM in light mode would otherwise get a dark widget inside a light app.\r\n *\r\n * <SoftphoneWidget theme=\"light\" /> \u2192 data-cv-theme=\"light\" \u2014 always light\r\n * <SoftphoneWidget theme=\"dark\" /> \u2192 data-cv-theme=\"dark\" \u2014 always dark\r\n * <SoftphoneWidget /> \u2192 follows the OS\r\n *\r\n * To follow the HOST's theme rather than the OS, pass the host's current theme through, e.g.\r\n * next-themes: `const { resolvedTheme } = useTheme(); <SoftphoneWidget theme={resolvedTheme} />`.\r\n *\r\n * The dark block is duplicated below (OS-driven vs. pinned) on purpose: plain CSS has no way to\r\n * share a declaration block across a media query and a selector, and the honest duplication beats a\r\n * clever custom-property toggle nobody can debug.\r\n */\r\n@media (prefers-color-scheme: dark) {\r\n :host(:not([data-cv-theme='light'])),\r\n .cv-softphone:not([data-cv-theme='light']) {\r\n --cv-bg: #17171c; /* CRM --background 240 10% 10% */\r\n --cv-surface: #202027; /* between bg and secondary */\r\n --cv-surface-2: #27272a; /* CRM --secondary/--muted 240 3.7% 15.9% */\r\n --cv-border: #2e2e33;\r\n --cv-border-strong: #404044; /* CRM --border 240 3.7% 25.9% */\r\n --cv-text: #fafafa; /* CRM --foreground 0 0% 98% */\r\n --cv-muted: #a1a1aa; /* CRM --muted-foreground 240 5% 64.9% \u2192 6.97:1 AA */\r\n --cv-faint: #71717a; /* decorative only */\r\n --cv-accent-strong: #34d399; /* emerald-400 \u2014 9.29:1 on #17171c */\r\n --cv-accent-light: #34d399; /* the halo reads brighter on dark */\r\n --cv-accent-soft: #065f46; /* glows must darken on dark, not glare */\r\n --cv-danger-strong: #f87171; /* red-400 \u2014 LIGHTER than --cv-danger, same inversion as accent-strong */\r\n --cv-danger-soft: #7f1d1d;\r\n --cv-info-strong: #38bdf8; /* sky-400 \u2014 same inversion, same reason */\r\n --cv-info-soft: #0c4a6e;\r\n --cv-shadow: 0 1px 2px rgb(0 0 0 / 0.4);\r\n }\r\n}\r\n\r\n/* Pinned dark \u2014 wins regardless of the OS setting. */\r\n:host([data-cv-theme='dark']),\r\n.cv-softphone[data-cv-theme='dark'] {\r\n --cv-bg: #17171c;\r\n --cv-surface: #202027;\r\n --cv-surface-2: #27272a;\r\n --cv-border: #2e2e33;\r\n --cv-border-strong: #404044;\r\n --cv-text: #fafafa;\r\n --cv-muted: #a1a1aa;\r\n --cv-faint: #71717a;\r\n --cv-accent-strong: #34d399;\r\n --cv-accent-light: #34d399;\r\n --cv-accent-soft: #065f46;\r\n --cv-danger-strong: #f87171;\r\n --cv-danger-soft: #7f1d1d;\r\n --cv-info-strong: #38bdf8;\r\n --cv-info-soft: #0c4a6e;\r\n --cv-shadow: 0 1px 2px rgb(0 0 0 / 0.4);\r\n}\r\n\r\n.cv-softphone,\r\n.cv-softphone *,\r\n.cv-softphone *::before,\r\n.cv-softphone *::after {\r\n box-sizing: border-box;\r\n}\r\n\r\n/* \u2500\u2500 Shell \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\r\n.cv-softphone {\r\n display: flex;\r\n flex-direction: column;\r\n\r\n /* FILL the box the host gives us; do not assert one.\r\n This was `width: 340px; height: 520px`, which is a component telling its host how big its\r\n layout is. The CRM's panel is 310 wide, so the third keypad column was simply cut off \u2014 and no\r\n amount of theming could have fixed it, because the widget was not asking. A component in\r\n someone else's layout accepts a size. */\r\n width: 100%;\r\n height: 100%;\r\n\r\n /* \u2026but a dialpad is not a spreadsheet. Given 900px it should not become a 900px dialpad with\r\n 300px keys; it should be itself, centred. Cap and centre rather than stretch. */\r\n max-width: var(--cv-max-width, 420px);\r\n margin-inline: auto;\r\n\r\n /* Adapt to THIS box, not to the viewport. The widget can be 310px wide inside a 1920px screen \u2014\r\n a media query would ask the wrong question and get a confident wrong answer. Container queries\r\n are the only ones that know the truth, and a shadow root is the ideal place for them. */\r\n container-type: inline-size;\r\n container-name: cv;\r\n\r\n background: var(--cv-bg);\r\n overflow: hidden;\r\n /* The containing block for `.cv-transfer-notices` \u2014 it overlays this whole shell, top edge to top\r\n edge, not just `.cv-body`, so it reads as one seamless panel rather than a strip that starts a few\r\n pixels short of the shell's own rounded corner. */\r\n position: relative;\r\n\r\n /* The frame is the HOST's call. The CRM's panel already draws `border rounded-xl shadow-2xl`, so\r\n drawing our own put a card inside a card \u2014 nested chrome, which is always wrong. Defaulting to\r\n a frame still suits a bare drop-in on a blank page; a host that has its own sets these to\r\n `none` / `0` and the double edge disappears. */\r\n border: var(--cv-shell-border, 1px solid var(--cv-border-strong));\r\n border-radius: var(--cv-shell-radius, var(--cv-radius));\r\n box-shadow: var(--cv-shell-shadow, 0 4px 24px rgb(0 0 0 / 0.06));\r\n}\r\n\r\n/* Narrow hosts \u2014 a sidebar, a phone-width column. Reclaim the horizontal padding first: at 260px\r\n the gutters cost more than they give, and a cramped keypad is worse than a tight one. */\r\n@container cv (max-width: 300px) {\r\n .cv-keypad { padding: 0 0.6rem; gap: 6px; }\r\n .cv-dial-prefix { padding: 0.7rem 0.6rem 0; }\r\n .cv-dial-head { padding: 0.3rem 0.6rem 0.6rem; }\r\n .cv-display { font-size: 1.5rem; }\r\n .cv-controls-row { gap: 1.1rem; }\r\n\r\n}\r\n\r\n/* Short hosts. The flex chain already lets the keypad absorb what is left, but below this the keys\r\n stop being tappable \u2014 so buy the height back from the chrome instead of from the keys. */\r\n@container cv (max-height: 440px) {\r\n .cv-dial-prefix { padding-top: 0.5rem; }\r\n .cv-dial-head { padding-top: 0.2rem; padding-bottom: 0.4rem; }\r\n .cv-dial-foot { padding-bottom: 0.5rem; }\r\n .cv-controls { gap: 0.4rem; padding-bottom: 0.6rem; }\r\n}\r\n\r\n/* The flex chain that makes the layout work: body flexes, views fill it, only the\r\n keypad/list grows. Without `min-height:0` a grid child refuses to shrink and overflows. */\r\n.cv-body {\r\n flex: 1;\r\n min-height: 0;\r\n display: flex;\r\n flex-direction: column;\r\n}\r\n.cv-view {\r\n flex: 1;\r\n min-height: 0;\r\n display: flex;\r\n flex-direction: column;\r\n /* Containing block for `.cv-detail` below \u2014 an absolute overlay must cover exactly THIS tab's\r\n content, not escape to whatever the next positioned ancestor up the tree happens to be. */\r\n position: relative;\r\n}\r\n\r\n/* \u2500\u2500 Tab bar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\r\n.cv-tabs {\r\n display: flex;\r\n border-top: 1px solid var(--cv-border);\r\n background: var(--cv-bg);\r\n flex-shrink: 0;\r\n}\r\n.cv-tab {\r\n flex: 1;\r\n display: flex;\r\n flex-direction: column;\r\n align-items: center;\r\n gap: 3px;\r\n padding: 9px 0 8px;\r\n background: none;\r\n border: none;\r\n color: var(--cv-muted);\r\n cursor: pointer;\r\n font: inherit;\r\n transition: color 0.15s ease, background 0.15s ease;\r\n}\r\n.cv-tab:hover:not(:disabled) { background: var(--cv-surface); }\r\n.cv-tab[aria-selected='true'] { color: var(--cv-accent-strong); }\r\n.cv-tab:disabled { opacity: 0.35; cursor: not-allowed; }\r\n.cv-tab span { font-size: 10px; font-weight: 600; line-height: 1; letter-spacing: 0.01em; }\r\n\r\n/* \u2500\u2500 Transfer outcome notices (Phase A \u2014 transfer-outcome-relay.md \xA75.5) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\r\n/*\r\n A persistent, dismissable banner OVER `.cv-body`, never a toast and never auto-dismissed \u2014 it carries\r\n an action, and WCAG 2.2.1 requires a time limit to be removable, adjustable or extended.\r\n\r\n Overlaps rather than reserving its own height, the same reasoning `.cv-dial-outcome`'s own comment\r\n already gives for why the widget never lets a line of text resize the keypad under a moving finger \u2014\r\n here that principle cuts the other way: appearing costs nothing but a repaint, not a reflow, so the\r\n widget's overall height stays identical whether a notice is showing or not, exactly like `.cv-detail`\r\n overlaying `.cv-body`'s own list rather than pushing it down. It sits over whatever the current tab\r\n happens to be showing (most often the dialer's country picker and number field \u2014 an acceptable loss:\r\n the agent is not expected to start a fresh manual dial before dealing with a transfer that just failed\r\n to connect), which is also why it outranks `.cv-detail` in `z-index`.\r\n\r\n `[data-has-notices='false']` collapses to zero footprint: the element stays mounted (so its\r\n `role=\"status\"` region exists before the FIRST notice ever arrives \u2014 an aria-live region inserted at\r\n the same moment as its own content is not reliably announced), but an idle widget, the common case,\r\n covers nothing and costs no paint either.\r\n*/\r\n.cv-transfer-notices {\r\n position: absolute;\r\n inset: 0 0 auto 0;\r\n z-index: 3; /* above `.cv-detail`'s 2 \u2014 this is the higher-priority thing on screen */\r\n}\r\n.cv-transfer-notices[data-has-notices='true'] {\r\n display: flex;\r\n flex-direction: column;\r\n gap: 0.5rem;\r\n padding: 0.6rem 0.75rem;\r\n background: var(--cv-bg);\r\n box-shadow: var(--cv-shadow);\r\n animation: cv-transfer-notices-in 200ms var(--cv-ease-out) both;\r\n}\r\n@keyframes cv-transfer-notices-in {\r\n from { opacity: 0; transform: translateY(-6px); }\r\n to { opacity: 1; transform: none; }\r\n}\r\n@media (prefers-reduced-motion: reduce) {\r\n .cv-transfer-notices[data-has-notices='true'] { animation: none; }\r\n}\r\n.cv-transfer-notices-count {\r\n margin: 0;\r\n font: 600 0.72rem var(--cv-font);\r\n color: var(--cv-muted);\r\n text-transform: uppercase;\r\n letter-spacing: 0.04em;\r\n}\r\n.cv-transfer-notice {\r\n display: flex;\r\n align-items: flex-start;\r\n gap: 0.6rem;\r\n padding: 0.65rem 0.7rem;\r\n border-radius: var(--cv-radius-key);\r\n border: 1px solid var(--cv-danger-soft);\r\n background: var(--cv-surface);\r\n}\r\n/* Same shape as `.cv-gate-mark`, smaller \u2014 one visual language for \"here is a status\", not two. */\r\n.cv-transfer-notice-mark {\r\n flex-shrink: 0;\r\n display: grid;\r\n place-items: center;\r\n width: 30px;\r\n height: 30px;\r\n border-radius: 999px;\r\n background: var(--cv-danger-soft);\r\n color: var(--cv-danger);\r\n}\r\n.cv-transfer-notice-body { flex: 1; min-width: 0; }\r\n/*\r\n Title shares its row with only the dismiss button (24px) \u2014 not the whole card's width, and not the\r\n \"Llamar de vuelta\" button, which gets its own row below (`.cv-transfer-notice-footer`). The original\r\n layout put mark + title + subtitle + a full button all in one flex row, leaving the title as little as\r\n ~80px to wrap into at the real ~340px embed width \u2014 nearly every word landed on its own line.\r\n `align-items: flex-start` keeps the dismiss pinned to the first line even when the title wraps to two.\r\n*/\r\n.cv-transfer-notice-header { display: flex; align-items: flex-start; gap: 0.4rem; }\r\n.cv-transfer-notice-title {\r\n flex: 1;\r\n min-width: 0;\r\n margin: 0;\r\n font: 600 0.82rem var(--cv-font);\r\n color: var(--cv-text);\r\n line-height: 1.35;\r\n}\r\n.cv-transfer-notice-subtitle {\r\n margin: 0.15rem 0 0;\r\n font-size: 0.78rem;\r\n color: var(--cv-muted);\r\n line-height: 1.35;\r\n}\r\n/* Age and the callback button share a row too, same reasoning as the header \u2014 a short fixed-width\r\n timestamp beside a content-sized button, neither one fighting the title for space above. */\r\n.cv-transfer-notice-footer {\r\n display: flex;\r\n align-items: center;\r\n justify-content: space-between;\r\n gap: 0.5rem;\r\n margin-top: 0.35rem;\r\n}\r\n.cv-transfer-notice-age { margin: 0; font-size: 0.7rem; color: var(--cv-faint); flex-shrink: 0; }\r\n/*\r\n `.cv-btn.cv-transfer-notice-callback` \u2014 TWO classes, not one. Equal-specificity selectors resolve by\r\n source order, and this rule used to lose its `padding`/`font-size` to the shared `.cv-btn` rule below\r\n simply because that one appears later in the file; the button rendered at full default CTA size\r\n instead of this smaller, secondary one, which is what actually forced the title into that ~80px\r\n column above. The extra class is what makes this override win regardless of source order.\r\n*/\r\n.cv-btn.cv-transfer-notice-callback {\r\n flex-shrink: 0;\r\n padding: 0.35rem 0.6rem;\r\n font-size: 0.75rem;\r\n display: inline-flex;\r\n align-items: center;\r\n gap: 0.3rem;\r\n white-space: nowrap;\r\n}\r\n/* Same round icon-only shape as `.cv-search-clear` \u2014 one dismiss affordance, not a new one. */\r\n.cv-transfer-notice-dismiss {\r\n flex-shrink: 0;\r\n width: 24px;\r\n height: 24px;\r\n display: inline-flex;\r\n align-items: center;\r\n justify-content: center;\r\n border: none;\r\n border-radius: 999px;\r\n background: none;\r\n color: var(--cv-muted);\r\n cursor: pointer;\r\n transition: color 0.12s ease, background 0.12s ease, transform 0.1s var(--cv-ease-out);\r\n}\r\n.cv-transfer-notice-dismiss:hover { color: var(--cv-text); background: var(--cv-surface-2); }\r\n.cv-transfer-notice-dismiss:active { transform: scale(0.88); }\r\n\r\n/* \u2500\u2500 Buttons \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\r\n.cv-btn {\r\n font: 600 0.875rem var(--cv-font);\r\n cursor: pointer;\r\n padding: 0.5rem 0.9rem;\r\n border-radius: 10px;\r\n border: 1px solid var(--cv-border-strong);\r\n background: var(--cv-bg);\r\n color: var(--cv-text);\r\n transition: background 0.15s ease, transform 0.06s ease;\r\n}\r\n.cv-btn:hover:not(:disabled) { background: var(--cv-surface); }\r\n.cv-btn:active:not(:disabled) { transform: scale(0.97); }\r\n.cv-btn:disabled { opacity: 0.45; cursor: not-allowed; }\r\n.cv-btn--accent {\r\n background: var(--cv-accent);\r\n border-color: var(--cv-accent);\r\n color: var(--cv-accent-contrast);\r\n}\r\n.cv-btn--accent:hover:not(:disabled) { background: var(--cv-accent-strong); }\r\n\r\n/* Round action buttons (answer / hangup / call) */\r\n.cv-round {\r\n width: 64px;\r\n height: 64px;\r\n border-radius: 999px;\r\n border: none;\r\n cursor: pointer;\r\n display: inline-flex;\r\n align-items: center;\r\n justify-content: center;\r\n color: #fff;\r\n transition: background 0.15s ease, transform 0.08s ease, box-shadow 0.15s ease;\r\n}\r\n.cv-round:active:not(:disabled) { transform: scale(0.94); }\r\n.cv-round--answer {\r\n background: var(--cv-accent);\r\n box-shadow: 0 8px 20px -4px var(--cv-accent-soft);\r\n}\r\n.cv-round--answer:hover:not(:disabled) { background: var(--cv-accent-strong); }\r\n.cv-round--answer:disabled {\r\n background: var(--cv-surface-2);\r\n color: var(--cv-faint);\r\n box-shadow: none;\r\n cursor: not-allowed;\r\n}\r\n.cv-round--hangup {\r\n background: var(--cv-danger);\r\n box-shadow: 0 8px 20px -4px var(--cv-danger-soft);\r\n}\r\n.cv-round--hangup:hover:not(:disabled) { filter: brightness(0.94); }\r\n\r\n/* \u2500\u2500 Dialpad \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\r\n/* The prefix chip, on its own row above the number.\r\n Sharing a line with the number starves it: chip + erase button eat the middle, and a formatted\r\n international number needs every pixel. The number is the content; the prefix is a setting. */\r\n.cv-dial-prefix {\r\n flex-shrink: 0;\r\n display: flex;\r\n justify-content: center;\r\n padding: 1rem 1rem 0;\r\n}\r\n.cv-dial-head {\r\n flex-shrink: 0;\r\n display: flex;\r\n align-items: center;\r\n padding: 0.35rem 1rem 0.75rem;\r\n gap: 0.25rem;\r\n}\r\n/*\r\n * The outcome of the last attempt, under the number.\r\n *\r\n * ALWAYS in the layout, visible or not. The keypad is `flex: 1` and absorbs whatever height is left,\r\n * so a line that appears and disappears would resize every key and shift the whole grid under the\r\n * user's finger \u2014 mid-dial, which is exactly when this text arrives. Reserving the row costs one line\r\n * of height and buys a keypad that never moves.\r\n */\r\n.cv-dial-outcome {\r\n flex-shrink: 0;\r\n margin: 0;\r\n min-height: 1.15rem;\r\n padding: 0 1rem;\r\n text-align: center;\r\n font-size: 0.78rem;\r\n line-height: 1.15rem;\r\n color: var(--cv-muted);\r\n /*\r\n One line, always. `error-text.test.ts` caps every outcome at 48 characters so nothing should ever\r\n reach this \u2014 but a cap depends on whoever writes the next string remembering it, and the failure\r\n it prevents is not cosmetic: the keypad below is `flex: 1`, so a second line resizes all twelve\r\n keys and shifts the grid under a finger that is already moving. Truncating is visibly wrong and\r\n harmless; reflowing the dialpad mid-dial is invisibly wrong and makes the user misdial.\r\n */\r\n white-space: nowrap;\r\n overflow: hidden;\r\n text-overflow: ellipsis;\r\n /* Muted, not danger: the phone still works. A red line here would read as a broken phone. */\r\n opacity: 0;\r\n transition: opacity 150ms ease-out;\r\n}\r\n.cv-dial-outcome[data-shown='true'] {\r\n opacity: 1;\r\n}\r\n@media (prefers-reduced-motion: reduce) {\r\n .cv-dial-outcome {\r\n transition: none;\r\n }\r\n}\r\n\r\n.cv-display {\r\n flex: 1;\r\n min-width: 0;\r\n font: 600 1.75rem var(--cv-font);\r\n font-variant-numeric: tabular-nums;\r\n letter-spacing: -0.02em;\r\n text-align: center;\r\n color: var(--cv-text);\r\n border: none;\r\n background: none;\r\n padding: 0;\r\n caret-color: var(--cv-accent);\r\n}\r\n.cv-display::placeholder { color: var(--cv-faint); font-weight: 400; }\r\n.cv-display:focus { outline: none; }\r\n\r\n/* The spacer/erase pair keeps the number optically centred. */\r\n.cv-dial-side {\r\n width: 36px;\r\n height: 36px;\r\n flex-shrink: 0;\r\n display: inline-flex;\r\n align-items: center;\r\n justify-content: center;\r\n border: none;\r\n background: none;\r\n color: var(--cv-muted);\r\n cursor: pointer;\r\n border-radius: 999px;\r\n transition: color 0.15s ease, opacity 0.15s ease, transform 0.06s ease;\r\n}\r\n.cv-dial-side:hover { color: var(--cv-text); }\r\n.cv-dial-side:active { transform: scale(0.9); }\r\n.cv-dial-side[data-hidden='true'] { opacity: 0; pointer-events: none; }\r\n\r\n/* THE layout fix: the keypad flexes to fill, and its rows share the space equally.\r\n Keys must NOT use aspect-ratio \u2014 that overflows the shell and shoves the call button\r\n under the tab bar. */\r\n.cv-keypad {\r\n flex: 1;\r\n min-height: 0;\r\n display: grid;\r\n grid-template-columns: repeat(3, 1fr);\r\n grid-template-rows: repeat(4, 1fr);\r\n gap: 8px;\r\n padding: 0 1rem;\r\n}\r\n.cv-key {\r\n display: flex;\r\n flex-direction: column;\r\n align-items: center;\r\n justify-content: center;\r\n gap: 1px;\r\n border-radius: var(--cv-radius-key);\r\n border: 1px solid var(--cv-border);\r\n background: var(--cv-bg);\r\n box-shadow: var(--cv-shadow);\r\n color: var(--cv-text);\r\n cursor: pointer;\r\n user-select: none;\r\n transition: background 0.12s ease, transform 0.06s ease;\r\n}\r\n.cv-key:hover { background: var(--cv-surface); }\r\n.cv-key:active { transform: scale(0.95); background: var(--cv-surface-2); }\r\n.cv-key b { font: 500 1.25rem/1 var(--cv-font); }\r\n.cv-key small {\r\n font-size: 9px;\r\n font-weight: 600;\r\n letter-spacing: 0.14em;\r\n /* Decorative: the letters carry no information the digit doesn't. `faint`, not `muted`. */\r\n color: var(--cv-faint);\r\n line-height: 1;\r\n}\r\n\r\n.cv-dial-foot {\r\n flex-shrink: 0;\r\n display: flex;\r\n justify-content: center;\r\n padding: 0.75rem 0 1rem;\r\n}\r\n\r\n/* \u2500\u2500 Active call \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\r\n.cv-call {\r\n flex: 1;\r\n min-height: 0;\r\n display: flex;\r\n flex-direction: column;\r\n align-items: center;\r\n justify-content: center;\r\n gap: 0.15rem;\r\n text-align: center;\r\n padding: 1rem;\r\n /*\r\n A REAL bug, found live (plan T7): the transfer picker's agent list can make this box's content\r\n taller than the space `flex: 1` actually allocates it \u2014 the avatar/name/timer/status ABOVE the\r\n picker are still shown while transferring, unlike `.cv-call-devices-inline`'s own \"swap the\r\n content\" fix for the pre-connect devices screen. Without this, the overflow painted straight past\r\n `.cv-call`'s own shrunk box and onto `.cv-controls` below it \u2014 not scroll, actual overlap, controls\r\n rendered ON TOP of list rows. `overflow-y: auto` keeps any excess inside THIS box, scrollable,\r\n exactly like `.cv-presence-list`'s own internal scroll already does for the list itself.\r\n */\r\n overflow-y: auto;\r\n}\r\n\r\n.cv-avatar-wrap { position: relative; margin-bottom: 0.85rem; }\r\n.cv-avatar {\r\n position: relative;\r\n width: 68px;\r\n height: 68px;\r\n border-radius: 999px;\r\n /* accent \u2192 accent-strong (emerald 600\u2192700): white initials clear 3.77:1 at EVERY stop. Starting\r\n the ramp at emerald-500 would look punchier but drop the light end to 2.54:1. */\r\n background: linear-gradient(135deg, var(--cv-accent) 0%, var(--cv-accent-strong) 100%);\r\n display: flex;\r\n align-items: center;\r\n justify-content: center;\r\n color: #fff;\r\n font: 700 1.3rem var(--cv-font);\r\n letter-spacing: -0.01em;\r\n box-shadow: 0 8px 20px -6px var(--cv-accent-soft);\r\n}\r\n/* A soft halo while ringing \u2014 the one moment worth animating. */\r\n.cv-avatar-ping {\r\n position: absolute;\r\n inset: 0;\r\n border-radius: 999px;\r\n /* Decorative \u2014 nothing sits on it, so it uses the vivid emerald. */\r\n background: var(--cv-accent-light);\r\n opacity: 0.16;\r\n animation: cv-ping 1.6s cubic-bezier(0, 0, 0.2, 1) infinite;\r\n pointer-events: none;\r\n}\r\n@keyframes cv-ping {\r\n 0% { transform: scale(1); opacity: 0.22; }\r\n 80%, 100% { transform: scale(1.7); opacity: 0; }\r\n}\r\n.cv-avatar-dot {\r\n position: absolute;\r\n bottom: 1px;\r\n right: 1px;\r\n width: 14px;\r\n height: 14px;\r\n border-radius: 999px;\r\n background: var(--cv-accent);\r\n border: 2px solid var(--cv-bg);\r\n}\r\n\r\n.cv-call-name { font-size: 1rem; font-weight: 600; line-height: 1.2; }\r\n.cv-call-number { font-size: 0.75rem; color: var(--cv-muted); margin-top: 2px; }\r\n\r\n.cv-timer {\r\n display: flex;\r\n align-items: baseline;\r\n justify-content: center;\r\n gap: 1px;\r\n margin-top: 0.75rem;\r\n color: var(--cv-accent-strong);\r\n}\r\n.cv-timer b {\r\n font: 700 2.25rem/1 var(--cv-font);\r\n font-variant-numeric: tabular-nums;\r\n letter-spacing: -0.03em;\r\n}\r\n.cv-timer i {\r\n font: 300 1.75rem/1 var(--cv-font);\r\n font-style: normal;\r\n color: var(--cv-accent);\r\n animation: cv-blink 1.6s ease-in-out infinite;\r\n margin: 0 1px;\r\n}\r\n@keyframes cv-blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }\r\n\r\n.cv-call-status {\r\n margin-top: 0.6rem;\r\n font-size: 0.78rem;\r\n color: var(--cv-muted);\r\n letter-spacing: 0.04em;\r\n}\r\n.cv-badge-hold { color: var(--cv-hold); font-weight: 600; }\r\n\r\n/* \u2500\u2500 Pre-connect device check (ringing / dialing) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n Before a call connects there is only ONE other control on screen (Colgar, or\r\n Rechazar/Contestar) \u2014 putting Dispositivos in that same neighbourhood, even\r\n spaced out, keeps it a mis-tap away from hanging up or answering. The entry\r\n chip lives beside the call status instead, removing it from that neighbourhood\r\n rather than just adding distance within it \u2014 same resting-pill SHAPE as `.cv-cp`\r\n (CountryPicker), reused rather than invented fresh.\r\n\r\n Opening it does NOT pop a panel into that same tight space, though \u2014 this\r\n SDK's real production embed (a 310\xD7520 host panel with its own header eating\r\n further into that) leaves too little free room around a trigger sitting in\r\n vertically-centred content for a floating popover to open into without either\r\n overlapping Colgar/Rechazar/Contestar or clamping itself down to something\r\n cramped and scrolly for just two rows. `.cv-call-devices-inline` REPLACES the\r\n avatar/name/status block in `.cv-call` instead (see that JSX's own comment) \u2014\r\n an inline swap, not an overlay, so it gets the full box, not a leftover sliver.\r\n \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\r\n.cv-call-devices-chip {\r\n display: inline-flex;\r\n align-items: center;\r\n gap: 0.35rem;\r\n height: 26px;\r\n padding: 0 0.65rem;\r\n border: 1px solid var(--cv-border-strong);\r\n border-radius: 999px;\r\n background: var(--cv-bg);\r\n color: var(--cv-muted);\r\n font: 600 0.7rem var(--cv-font);\r\n cursor: pointer;\r\n transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;\r\n}\r\n.cv-call-devices-chip:hover { background: var(--cv-surface); }\r\n.cv-call-devices-chip:active { transform: scale(0.95); }\r\n\r\n/*\r\n The entry-point row \u2014 ONE chip today (Dispositivos), up to two once a host configures\r\n `incomingCallAction` (that one first: who is calling outranks a phone-utility setting). `width:\r\n 100%` + `flex-wrap` on purpose: a host's action label is arbitrary text this component cannot\r\n assume is short, so two chips sit side by side when they fit and wrap onto their own centred line\r\n when they do not, rather than overflowing `.cv-call`'s fixed width.\r\n*/\r\n.cv-call-chips {\r\n width: 100%;\r\n display: flex;\r\n flex-wrap: wrap;\r\n justify-content: center;\r\n gap: 0.5rem;\r\n margin-top: 0.65rem;\r\n}\r\n\r\n.cv-call-devices-inline {\r\n width: 100%;\r\n display: flex;\r\n flex-direction: column;\r\n gap: 0.9rem;\r\n /* `.cv-call` centres and centre-aligns its normal (avatar) content \u2014 rows read left-aligned\r\n everywhere else in the widget (Ajustes, the in-call popover), so this opts back out of both. */\r\n text-align: left;\r\n animation: cv-call-devices-in 0.18s cubic-bezier(0.2, 0, 0.2, 1);\r\n}\r\n@keyframes cv-call-devices-in {\r\n from { opacity: 0; transform: translateY(6px); }\r\n to { opacity: 1; transform: none; }\r\n}\r\n@media (prefers-reduced-motion: reduce) {\r\n .cv-call-devices-inline { animation: none; }\r\n}\r\n.cv-call-devices-header { display: flex; align-items: center; gap: 0.5rem; }\r\n.cv-call-devices-title { font-size: 0.95rem; font-weight: 600; color: var(--cv-text); }\r\n/* `--cv-muted` on `--cv-bg` \u2014 the same already-verified pairing `.cv-settings-note` uses\r\n (4.83:1 light / 6.97:1 dark), not a new one. */\r\n.cv-call-devices-context {\r\n margin: 0;\r\n font-size: 0.78rem;\r\n color: var(--cv-muted);\r\n}\r\n\r\n/* \u2500\u2500 Call controls \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n TWO TIERS. Hang up is the one action that must never be hunted for, so it sits\r\n alone, centred, and largest \u2014 the anchor. The secondaries flank above it. They\r\n used to share a single row, where hang up was just the last button and carried\r\n the same weight as \"transfer\"; mid-call, that is the wrong hierarchy.\r\n \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\r\n.cv-controls {\r\n flex-shrink: 0;\r\n display: flex;\r\n flex-direction: column;\r\n align-items: center;\r\n gap: 0.7rem;\r\n padding: 0.35rem 1rem 1.1rem;\r\n /* The containing block for `.cv-device-popover` \u2014 see that rule's own comment for why it is\r\n anchored here rather than to the trigger button. */\r\n position: relative;\r\n}\r\n.cv-controls-row {\r\n display: flex;\r\n justify-content: center;\r\n gap: 1.5rem;\r\n}\r\n.cv-controls-primary {\r\n display: flex;\r\n justify-content: center;\r\n gap: 2.75rem;\r\n}\r\n/* Button + its word. The column keeps the label from widening the hit target. */\r\n.cv-ctrl-item {\r\n display: flex;\r\n flex-direction: column;\r\n align-items: center;\r\n gap: 0.3rem;\r\n}\r\n/* 10px, muted: present when you look for it, silent when you don't. An icon-only\r\n control is a quiz, and mid-call is the worst time to sit one. */\r\n.cv-ctrl-label {\r\n font-size: 0.625rem;\r\n line-height: 1;\r\n color: var(--cv-muted);\r\n white-space: nowrap;\r\n user-select: none;\r\n}\r\n.cv-ctrl {\r\n width: 48px;\r\n height: 48px;\r\n border-radius: 999px;\r\n border: 1px solid var(--cv-border-strong);\r\n background: var(--cv-bg);\r\n color: var(--cv-text);\r\n cursor: pointer;\r\n display: inline-flex;\r\n align-items: center;\r\n justify-content: center;\r\n transition: background 0.15s ease, color 0.15s ease, transform 0.06s ease;\r\n}\r\n.cv-ctrl:hover { background: var(--cv-surface); }\r\n.cv-ctrl:active { transform: scale(0.94); }\r\n\r\n/* The ON states say what they MEAN, in the widget's own tokens: muted is danger,\r\n held is hold, transfer-open is accent. Colour is the state, not decoration. */\r\n.cv-ctrl[data-tone='danger'] {\r\n background: var(--cv-danger);\r\n border-color: var(--cv-danger);\r\n color: #fff;\r\n}\r\n.cv-ctrl[data-tone='hold'] {\r\n background: var(--cv-hold);\r\n border-color: var(--cv-hold);\r\n color: #fff;\r\n}\r\n.cv-ctrl[data-tone='accent'] {\r\n background: var(--cv-accent-soft);\r\n border-color: transparent;\r\n color: var(--cv-accent-strong);\r\n}\r\n.cv-ctrl[data-tone]:hover { filter: brightness(0.95); }\r\n\r\n/* Muted is the one state worth animating: it is invisible, it is easy to forget,\r\n and the cost of forgetting is talking to nobody. Same halo the avatar rings\r\n with \u2014 one language, not two. */\r\n.cv-ctrl[data-tone='danger'] {\r\n position: relative;\r\n}\r\n.cv-ctrl[data-tone='danger']::after {\r\n content: '';\r\n position: absolute;\r\n inset: 0;\r\n border-radius: 999px;\r\n background: var(--cv-danger);\r\n animation: cv-ping 1.6s cubic-bezier(0, 0, 0.2, 1) infinite;\r\n pointer-events: none;\r\n z-index: -1;\r\n}\r\n@media (prefers-reduced-motion: reduce) {\r\n .cv-ctrl[data-tone='danger']::after { animation: none; opacity: 0; }\r\n}\r\n.cv-ctrl[aria-pressed='true'] {\r\n background: var(--cv-text);\r\n color: var(--cv-bg);\r\n border-color: var(--cv-text);\r\n}\r\n\r\n/*\r\n The attended-transfer target-entry panel (`docs/specs/transfer-availability.md` \xA75): an optional\r\n outcome line, then a row of whatever's actionable (input+button today). Once a consult exists,\r\n `ConsultScreen` takes over the whole screen instead \u2014 this panel is ONLY ever the pre-consult /\r\n retry-after-failure state now; it used to also cover the consulting/answered phases, which is why\r\n its rule is a column container built for more than one child even though there's only ever one row\r\n here today. (History: was two near-duplicate rules \u2014 `.cv-transfer` as a bare ROW,\r\n `.cv-transfer-consult` as a column \u2014 until the row version turned out to be a real,\r\n reproduced-in-browser bug: the optional `.cv-dial-outcome` message rendered as a THIRD item in the\r\n same horizontal row as the input and button, squeezing the input to its content-minimum. Merged\r\n rather than patched.)\r\n*/\r\n.cv-transfer {\r\n display: flex;\r\n flex-direction: column;\r\n gap: 0.3rem;\r\n width: 100%;\r\n padding: 0.2rem 0.25rem 0;\r\n /* `.cv-call` centres and centre-aligns its normal (avatar) content \u2014 same reasoning\r\n `.cv-call-devices-inline` already opts out of for the OTHER inline swap in this box (its own\r\n comment): rows read left-aligned everywhere else in the widget, and a centred context line\r\n sitting directly above a left-aligned input/toggle/list read as two layouts stacked on top of\r\n each other, found live (2026-09-29). */\r\n text-align: left;\r\n /* Found live (2026-09-29): `.cv-call`'s avatar/name/timer block used to sit ABOVE this,\r\n unconditionally, so this panel only ever got whatever was left over \u2014 see the JSX call site's own\r\n comment. Now that this IS the whole of `.cv-call`'s content while a transfer is in progress\r\n (a swap, not an addition), it should CLAIM `.cv-call`'s available height, not just sit in it \u2014\r\n `flex: 1` does that; `min-height: 0` is what lets a flex child actually shrink to fit instead of\r\n forcing its parent to grow past it (the same reason `.cv-call`/`.cv-view`/`.cv-body` all set it,\r\n see their own comments). This is what lets `.cv-presence-list` below flex-grow into real room\r\n instead of needing its own hand-tuned `max-height` guess. */\r\n flex: 1;\r\n min-height: 0;\r\n /* Same entrance `.cv-call-devices-inline` already uses for the analogous inline swap (its own\r\n comment) \u2014 reused, not a second animation invented for this widget's second inline-swap view. */\r\n animation: cv-call-devices-in 0.18s cubic-bezier(0.2, 0, 0.2, 1);\r\n}\r\n@media (prefers-reduced-motion: reduce) {\r\n .cv-transfer { animation: none; }\r\n}\r\n/* The compact \"who am I still on the phone with\" line while the picker is open \u2014 reuses\r\n `.cv-call-devices-inline`'s own two classes verbatim (`.cv-call-devices-title` for the name,\r\n `.cv-call-devices-context` for the number/timer line) rather than inventing a second \"quiet header\"\r\n style for the same widget; only the tiny stacking gap between them is new.\r\n\r\n Found live (2026-09-29): `.cv-transfer`'s own `gap` alone gave every section \u2014 context, input row,\r\n mode toggle, results list \u2014 the exact same spacing, so nothing read as its own group; the eye had\r\n no cue for where \"who I'm on the phone with\" ends and \"pick a transfer target\" begins. This extra\r\n `margin-bottom`, on top of the base `gap`, is that cue \u2014 the toggle stays close to the list it\r\n controls (unchanged), only the boundary above the interactive controls opened up. */\r\n.cv-transfer-context {\r\n display: flex;\r\n flex-direction: column;\r\n gap: 2px;\r\n flex-shrink: 0;\r\n margin-bottom: 0.35rem;\r\n}\r\n/* Plan T3: the mode toggle reuses `.cv-segmented` (HistoryTab's own filter control) as-is, but its\r\n `margin-bottom` (needed THERE, where the surrounding layout has no gap of its own) is redundant\r\n here \u2014 `.cv-transfer`'s own `gap` above already spaces every child, so the two would stack. Scoped\r\n to this context only; HistoryTab's own copy is untouched. */\r\n.cv-transfer .cv-segmented { margin-bottom: 0; flex-shrink: 0; }\r\n.cv-transfer .cv-presence-legend { flex-shrink: 0; }\r\n/* Same treatment as `.cv-call-status` (the avatar's own \"Llamando\u2026\"/\"En espera\" line) \u2014 one muted\r\n status-line vocabulary for the whole call screen, not a second one invented for this panel. */\r\n.cv-transfer-status {\r\n margin: 0;\r\n font-size: 0.78rem;\r\n color: var(--cv-muted);\r\n letter-spacing: 0.04em;\r\n}\r\n/* The target-entry/retry row: an input plus one button. */\r\n.cv-transfer-row {\r\n display: flex;\r\n gap: 0.5rem;\r\n}\r\n/*\r\n Flex items default to `min-width: auto`, which floors them at their CONTENT size rather than letting\r\n them shrink or grow to fill the row \u2014 `.cv-search`'s own `width: 100%` never actually took effect\r\n here, because `.cv-btn`'s text (\"Llamar\") set an unshrinkable floor and the input, having no\r\n `flex-grow`, never reclaimed the space left over. `flex: 1` + `min-width: 0` is what actually makes\r\n an input-plus-button row behave like one; `flex: none` keeps the button at its natural size.\r\n*/\r\n.cv-transfer-row .cv-search {\r\n flex: 1;\r\n min-width: 0;\r\n}\r\n/* `max-width` + ellipsis: the label now includes whatever was typed (\"Llamar a {typed}\", spec \xA74.5)\r\n and `truncateForButton` already bounds that in JS \u2014 this is the CSS backstop, so a value that\r\n slips past it (a very wide character set, a future copy change) still can't push `.cv-search` down\r\n to an unusable sliver the way the pre-truncation button did before this was noticed. */\r\n.cv-transfer-row .cv-btn {\r\n flex: none;\r\n max-width: 60%;\r\n overflow: hidden;\r\n text-overflow: ellipsis;\r\n white-space: nowrap;\r\n}\r\n/* The consult-answered row: one or two buttons, no input. */\r\n.cv-transfer-actions {\r\n display: flex;\r\n gap: 0.5rem;\r\n}\r\n.cv-transfer-actions .cv-btn {\r\n flex: 1;\r\n}\r\n/* The ringing phase has only \"Cancelar\" \u2014 a secondary, low-stakes action. Letting the row's own\r\n `flex: 1` stretch a LONE button edge to edge reads as oversized for what it is, and nothing else in\r\n this widget sizes a single button to the full row; every other `.cv-btn` sizes to its content. */\r\n.cv-transfer-actions .cv-btn:only-child {\r\n flex: none;\r\n}\r\n\r\n/*\r\n The agent picker (spec \xA74.5, plan T7) \u2014 an inline, always-visible filtered list under the\r\n input+button row, not a click-to-open popover like `.cv-person-filter-pop`: the whole point is that\r\n typing already filters it, so hiding it behind a second interaction would undo that.\r\n\r\n Redesigned live (2026-09-29): T7 (72px) and T3 (30px) both hand-tuned a `max-height` in PIXELS\r\n against whatever else happened to be on screen at the time, and both were broken by the very next\r\n addition above the list (T3's own toggle broke T7's number the same way this would keep breaking on\r\n the next change) \u2014 down to \"barely proves the list is scrollable\" at 30px, which is what the user\r\n was actually seeing: \"users cannot almost see any of the listed users.\" The real fix wasn't a bigger\r\n magic number, it was `.cv-transfer`'s own move to `flex: 1` (see its comment) freeing real height\r\n from the block that used to sit above this whole panel \u2014 `.cv-presence` and this list now flex-grow\r\n into whatever's ACTUALLY left after the context line, input row, toggle and legend take their own\r\n natural size, on whatever real height the host panel gives `.cv-call` (this SDK's fixed ~310\xD7520\r\n embed today, but also whatever a different host gives it tomorrow \u2014 no more re-tuning a constant\r\n every time something above the list changes). `.cv-call`'s own `overflow-y: auto` (its own comment)\r\n is still the last-resort net if content ever genuinely doesn't fit; this just means that net should\r\n now rarely, if ever, have to catch anything for a normal agent list.\r\n*/\r\n.cv-presence {\r\n display: flex;\r\n flex-direction: column;\r\n gap: 0.35rem;\r\n flex: 1;\r\n min-height: 0;\r\n}\r\n.cv-presence-list {\r\n display: flex;\r\n flex-direction: column;\r\n gap: 2px;\r\n flex: 1;\r\n min-height: 0;\r\n overflow-y: auto;\r\n padding: 0.25rem;\r\n border: 1px solid var(--cv-border);\r\n border-radius: var(--cv-radius-key);\r\n background: var(--cv-surface);\r\n}\r\n.cv-presence-empty {\r\n margin: 0;\r\n padding: 0.5rem;\r\n text-align: center;\r\n font-size: 0.78rem;\r\n color: var(--cv-muted);\r\n}\r\n.cv-presence-item {\r\n width: 100%;\r\n display: flex;\r\n align-items: center;\r\n gap: 0.5rem;\r\n padding: 0.4rem 0.5rem;\r\n border: none;\r\n border-radius: calc(var(--cv-radius) - 4px);\r\n background: none;\r\n color: var(--cv-text);\r\n font: 400 0.8rem var(--cv-font);\r\n text-align: left;\r\n cursor: pointer;\r\n}\r\n.cv-presence-item:hover:not(:disabled) { background: var(--cv-surface-2); }\r\n/* En llamada rows are a hard block, not just a hint (CallScreen.tsx's own comment on the row has why)\r\n \u2014 same `:disabled` treatment `.cv-btn`/`.cv-tab` already use elsewhere in this file, not a new pattern. */\r\n.cv-presence-item:disabled { opacity: 0.45; cursor: not-allowed; }\r\n.cv-presence-dot {\r\n flex-shrink: 0;\r\n width: 8px;\r\n height: 8px;\r\n border-radius: 999px;\r\n}\r\n/* Reuses existing intent tokens rather than inventing new ones \u2014 `--cv-accent` already means\r\n \"good, reachable\" (the answer button), `--cv-hold` already means \"occupied elsewhere\" (the hold\r\n toggle), and `--cv-faint` is this file's own DECORATIVE-only tier (see the `--cv-*` legend near the\r\n top) \u2014 exactly what \"no signal for this person\" is, no signal being the whole point of that tier. */\r\n.cv-presence-dot[data-state='disponible'] { background: var(--cv-accent); }\r\n.cv-presence-dot[data-state='en_llamada'] { background: var(--cv-hold); }\r\n.cv-presence-dot[data-state='desconectado'] { background: var(--cv-faint); }\r\n.cv-presence-name {\r\n flex: 1;\r\n min-width: 0;\r\n overflow: hidden;\r\n text-overflow: ellipsis;\r\n white-space: nowrap;\r\n}\r\n.cv-presence-meta {\r\n flex-shrink: 0;\r\n font-size: 0.72rem;\r\n color: var(--cv-muted);\r\n}\r\n/* Spec \xA74.6: Desconectado is a HINT, never \"can't transfer\" \u2014 this is what tells the agent so, right\r\n under the list it describes. `--cv-muted`, NOT `--cv-faint`: this text is informational (the reader\r\n needs it to not misread the state), and `--cv-faint` is reserved for decorative-only content that\r\n fails AA on purpose \u2014 using it here would repeat the exact tier mistake this file's own `--cv-*`\r\n comment block warns against. */\r\n.cv-presence-legend {\r\n margin: 0;\r\n font-size: 0.7rem;\r\n color: var(--cv-muted);\r\n line-height: 1.35;\r\n}\r\n\r\n/*\r\n `ConsultScreen`'s \"someone is still waiting\" banner (`docs/specs/transfer-availability.md` \xA75, the\r\n cross-talk fix) \u2014 full-width and tappable, not a passive status line, because the entire reason this\r\n screen exists is to make \"go back to the caller\" impossible to miss. Neutral surface + the same\r\n `--cv-hold` colour `.cv-badge-hold` already uses for the same fact elsewhere, not a new soft-fill\r\n token: a text/icon tint on a plain surface is already proven legible here, and inventing an\r\n unmeasured background-tint pairing risks a real contrast regression this file takes seriously\r\n elsewhere (see the `-soft`/`-strong` split's own comments).\r\n*/\r\n.cv-consult-hold-banner {\r\n display: flex;\r\n align-items: center;\r\n justify-content: center;\r\n gap: 0.4rem;\r\n width: 100%;\r\n padding: 0.5rem 0.75rem;\r\n border-radius: 999px;\r\n border: 1px solid var(--cv-border);\r\n background: var(--cv-surface);\r\n color: var(--cv-hold);\r\n font: 600 0.78rem var(--cv-font);\r\n cursor: pointer;\r\n transition: background 0.15s ease, border-color 0.15s ease;\r\n}\r\n.cv-consult-hold-banner:hover {\r\n background: var(--cv-surface-2);\r\n border-color: var(--cv-hold);\r\n}\r\n.cv-consult-hold-banner:active {\r\n transform: scale(0.98);\r\n}\r\n\r\n/*\r\n The in-call device quick-switch (SPEC \xA77 Phase 2 \u2014 device management). Same rows as the Ajustes tab\r\n (`.cv-settings-row`, via the shared `DevicePickers`), in a popover instead of a page \u2014 the call\r\n screen already takes over the whole body, so there is no tab bar to reach Ajustes from mid-call.\r\n\r\n Only the in-call state still uses a floating popover \u2014 before a call connects, Dispositivos is\r\n inline content instead (`.cv-call-devices-inline`, above), not an overlay. Opens UPWARD (`bottom:\r\n 100%`): the trigger sits at the bottom of the call screen, and there is no room to open downward.\r\n Anchored to `.cv-controls`'s own horizontal padding \u2014 a container whose width is bounded by ITS\r\n parent, never the narrow trigger itself \u2014 the same fix CountryPicker's own popover needed (see its\r\n comment).\r\n*/\r\n.cv-device-popover {\r\n position: absolute;\r\n z-index: 20;\r\n display: flex;\r\n flex-direction: column;\r\n gap: 0.6rem;\r\n padding: 0.7rem;\r\n background: var(--cv-surface);\r\n border: 1px solid var(--cv-border);\r\n border-radius: var(--cv-radius);\r\n box-shadow: var(--cv-shadow);\r\n}\r\n.cv-device-popover--above {\r\n bottom: 100%;\r\n left: 1rem;\r\n right: 1rem;\r\n margin-bottom: 0.6rem;\r\n transform-origin: bottom center;\r\n animation: cv-device-pop-in-up 0.14s cubic-bezier(0.2, 0, 0.2, 1);\r\n}\r\n@keyframes cv-device-pop-in-up {\r\n from { opacity: 0; transform: translateY(4px) scale(0.97); }\r\n to { opacity: 1; transform: none; }\r\n}\r\n@media (prefers-reduced-motion: reduce) {\r\n .cv-device-popover--above { animation: none; }\r\n}\r\n/* No per-row override needed here: `.cv-settings-row`/`.cv-settings-note` carry no border,\r\n background or padding of their own (see the Settings tab block) \u2014 this popover's own `gap` and\r\n `padding` are the only spacing in play, which is what keeps it lighter than the full Ajustes\r\n page's bordered section for what is meant to be a quick, transient panel. */\r\n\r\n/* \u2500\u2500 Lists (contacts / history) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\r\n.cv-list-head { flex-shrink: 0; padding: 0.85rem 1rem 0.5rem; }\r\n.cv-search {\r\n width: 100%;\r\n padding: 0.5rem 0.75rem;\r\n border-radius: 10px;\r\n border: 1px solid var(--cv-border-strong);\r\n background: var(--cv-surface);\r\n color: var(--cv-text);\r\n font: inherit;\r\n transition: border-color 0.15s ease, background 0.15s ease;\r\n /* Found live (2026-09-29): in the transfer row specifically, `.cv-btn`'s own `max-width: 60%`\r\n (needed for the dynamic \"Llamar a {typed}\" label) can leave the input narrower than its\r\n placeholder text \u2014 an `<input>` has no native ellipsis, so it hard-clipped mid-word\r\n (\"...o n\xFAm\") instead of trailing off cleanly. A real, if rare, risk for ANY `.cv-search` usage\r\n wherever it's squeezed, not just this one row \u2014 the fix belongs on the base rule. */\r\n overflow: hidden;\r\n text-overflow: ellipsis;\r\n}\r\n.cv-search::placeholder { color: var(--cv-muted); }\r\n.cv-search:focus { outline: none; border-color: var(--cv-accent); background: var(--cv-bg); }\r\n\r\n/*\r\n A wrapper around `.cv-search`, not a change to it \u2014 that class is also the transfer-target input\r\n in CallScreen, which wants neither an icon nor a clear button. `.cv-search--icon` is an ADDITIVE\r\n modifier on the input itself (padding only), so the base class is byte-for-byte what it was for\r\n every other consumer.\r\n*/\r\n.cv-search-field { position: relative; display: flex; align-items: center; }\r\n.cv-search--icon { padding-left: 2.15rem; padding-right: 1.85rem; }\r\n.cv-search-icon {\r\n position: absolute;\r\n left: 0.75rem;\r\n color: var(--cv-muted);\r\n pointer-events: none;\r\n}\r\n.cv-search-clear {\r\n position: absolute;\r\n right: 0.5rem;\r\n width: 22px;\r\n height: 22px;\r\n display: inline-flex;\r\n align-items: center;\r\n justify-content: center;\r\n border: none;\r\n border-radius: 999px;\r\n background: none;\r\n color: var(--cv-muted);\r\n cursor: pointer;\r\n transition: color 0.12s ease, transform 0.1s var(--cv-ease-out);\r\n}\r\n.cv-search-clear:hover { color: var(--cv-text); }\r\n.cv-search-clear:active { transform: scale(0.88); }\r\n\r\n/* \u2500\u2500 Historial filters: status (Todas/Perdidas) + person \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\r\n.cv-segmented {\r\n display: flex;\r\n gap: 2px;\r\n padding: 2px;\r\n border-radius: 10px;\r\n background: var(--cv-surface-2);\r\n margin-bottom: 0.4rem;\r\n}\r\n.cv-segmented-btn {\r\n flex: 1;\r\n padding: 0.35rem 0;\r\n border: none;\r\n border-radius: 8px;\r\n background: none;\r\n color: var(--cv-muted);\r\n font: 600 0.75rem var(--cv-font);\r\n cursor: pointer;\r\n transition: background 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;\r\n}\r\n/* The active segment is stated by its own fill + shadow, not colour alone \u2014 the same reasoning\r\n `.cv-item-action[data-tone='accent']`'s own comment gives for resting-state colour: a state that\r\n only differs by hue must still read on a screen that renders neither. */\r\n.cv-segmented-btn[aria-selected='true'] {\r\n background: var(--cv-bg);\r\n color: var(--cv-text);\r\n box-shadow: var(--cv-shadow);\r\n}\r\n@media (prefers-reduced-motion: reduce) {\r\n .cv-segmented-btn { transition: none; }\r\n}\r\n\r\n/* The person filter \u2014 same combobox SHAPE as `.cv-cp-*` (CountryPicker): a chip opening a positioned\r\n popover list with a checkmark on the active row. Not the same CLASSES: the two pickers have nothing\r\n to do with each other and sharing a namespace would make a future change to one silently reach the\r\n other. */\r\n.cv-person-filter { position: relative; width: 100%; }\r\n.cv-person-filter-chip {\r\n display: flex;\r\n align-items: center;\r\n gap: 0.4rem;\r\n width: 100%;\r\n padding: 0.4rem 0.65rem;\r\n border: 1px solid var(--cv-border-strong);\r\n border-radius: 10px;\r\n background: var(--cv-surface);\r\n color: var(--cv-muted);\r\n font: 500 0.78rem var(--cv-font);\r\n cursor: pointer;\r\n transition: background 0.15s ease, border-color 0.15s ease;\r\n}\r\n.cv-person-filter-chip:hover { background: var(--cv-surface-2); }\r\n/*\r\n Border only, no fill \u2014 the popover opening and the caret rotating already say \"this is open\";\r\n painting the whole width of a wide text pill solid green on top of that was the same over-strong\r\n green the selected row had (see its own comment below). A coloured border is the same restrained\r\n signal `.cv-search:focus` already uses for \"active\" elsewhere in this file.\r\n*/\r\n.cv-person-filter-chip[aria-expanded='true'] {\r\n border-color: var(--cv-accent);\r\n}\r\n.cv-person-filter-label {\r\n flex: 1;\r\n min-width: 0;\r\n overflow: hidden;\r\n text-overflow: ellipsis;\r\n white-space: nowrap;\r\n text-align: left;\r\n color: var(--cv-text);\r\n}\r\n.cv-person-filter-caret {\r\n flex-shrink: 0;\r\n transition: transform 0.18s cubic-bezier(0.2, 0, 0.2, 1);\r\n}\r\n.cv-person-filter-caret[data-open='true'] { transform: rotate(180deg); }\r\n.cv-person-filter-pop {\r\n position: absolute;\r\n top: calc(100% + 6px);\r\n left: 0;\r\n right: 0;\r\n z-index: 20;\r\n max-height: 220px;\r\n overflow-y: auto;\r\n padding: 0.25rem;\r\n background: var(--cv-surface);\r\n border: 1px solid var(--cv-border);\r\n border-radius: var(--cv-radius);\r\n box-shadow: var(--cv-shadow);\r\n transform-origin: top center;\r\n animation: cv-cp-in 0.14s cubic-bezier(0.2, 0, 0.2, 1);\r\n}\r\n@media (prefers-reduced-motion: reduce) {\r\n .cv-person-filter-pop { animation: none; }\r\n .cv-person-filter-caret { transition: none; }\r\n}\r\n.cv-person-filter-item {\r\n width: 100%;\r\n display: flex;\r\n align-items: center;\r\n gap: 0.5rem;\r\n padding: 0.45rem 0.5rem;\r\n border: none;\r\n border-radius: calc(var(--cv-radius) - 4px);\r\n background: none;\r\n color: var(--cv-text);\r\n font: 400 0.8rem var(--cv-font);\r\n text-align: left;\r\n cursor: pointer;\r\n}\r\n.cv-person-filter-item:hover { background: var(--cv-surface-2); }\r\n/*\r\n NOT a solid `--cv-accent-soft` fill \u2014 that read as a wall of saturated green across a wide row,\r\n much louder than the legacy CRM's own treatment for this exact list (`bg-emerald-50` \u2014 a barely-\r\n there wash \u2014 plus coloured text, never a strong fill). Coloured, bold text + the checkmark carry\r\n \"selected\" instead; `--cv-accent-strong` is already verified elsewhere in this file as AA against\r\n `--cv-surface`, which is what this popover sits on.\r\n*/\r\n.cv-person-filter-item[aria-selected='true'] .cv-person-filter-name {\r\n font-weight: 600;\r\n color: var(--cv-accent-strong);\r\n}\r\n.cv-person-filter-name {\r\n flex: 1;\r\n min-width: 0;\r\n overflow: hidden;\r\n text-overflow: ellipsis;\r\n white-space: nowrap;\r\n}\r\n.cv-person-filter-check { color: var(--cv-accent-strong); flex-shrink: 0; }\r\n.cv-person-filter-check[data-on='false'] { opacity: 0; }\r\n\r\n.cv-list {\r\n flex: 1;\r\n min-height: 0;\r\n overflow-y: auto;\r\n padding: 0 0.5rem 0.5rem;\r\n}\r\n/*\r\n Historial's day group header. Sticky within `.cv-list` (its nearest scrolling ancestor \u2014 a plain\r\n wrapper div between the two does not break that), OPAQUE `--cv-bg` rather than a translucent\r\n `/85 + backdrop-blur` treatment: it still needs to fully occlude rows scrolling underneath it, and\r\n a flat match to the list's own background does that with no new CSS feature this file doesn't\r\n already use elsewhere.\r\n*/\r\n.cv-history-day {\r\n position: sticky;\r\n top: 0;\r\n z-index: 1;\r\n padding: 0.5rem 0.6rem 0.35rem;\r\n background: var(--cv-bg);\r\n color: var(--cv-muted);\r\n font-size: 0.68rem;\r\n font-weight: 600;\r\n text-transform: uppercase;\r\n letter-spacing: 0.04em;\r\n}\r\n.cv-item {\r\n display: flex;\r\n align-items: center;\r\n gap: 0.7rem;\r\n padding: 0.5rem;\r\n border-radius: 12px;\r\n /*\r\n NOT `cursor: pointer` \u2014 this div has no click handler of its own; only its children\r\n (`.cv-item-main`, `.cv-item-action`) do, and each already sets its own pointer cursor. Putting\r\n it here too used to make the WHOLE row's box promise a click \u2014 its own padding, and the flex\r\n `gap` between avatar/name/button \u2014 none of which does anything when clicked. Most noticeable\r\n right where one row's padding meets the next, which reads as \"there's something clickable\r\n between these two contacts\" when there is not.\r\n */\r\n transition: background 0.12s ease;\r\n text-align: left;\r\n width: 100%;\r\n border: none;\r\n background: none;\r\n font: inherit;\r\n color: inherit;\r\n}\r\n.cv-item:hover { background: var(--cv-surface); }\r\n.cv-item-avatar {\r\n width: 40px;\r\n height: 40px;\r\n border-radius: 999px;\r\n /*\r\n RADIAL, not diagonal \u2014 deliberately. The initials sit centered, so a radial gradient puts them\r\n at its lightest point (pure `--cv-surface`) no matter what, while the edges still darken toward\r\n `--cv-surface-2` for the same cheap depth a diagonal gradient gave. A diagonal version measured\r\n 4.39:1 for `--cv-muted` text at its far corner \u2014 under the 4.5:1 text needs \u2014 because centered\r\n text's exact position against a DIAGONAL gradient depends on glyph width, not just circle size.\r\n Centering the gradient on the centered text removes that dependency entirely, structurally,\r\n rather than nudging a token and hoping the margin holds. Both stops stay tokens, so this is\r\n still theme-correct for free \u2014 a hardcoded gray gradient would not adapt to dark mode.\r\n */\r\n background: radial-gradient(circle, var(--cv-surface), var(--cv-surface-2));\r\n /*\r\n Without this, the circle's own boundary can vanish: `.cv-item:hover` sets the ROW background to\r\n `--cv-surface` \u2014 the exact color the gradient above STARTS at (1.00 contrast ratio, i.e.\r\n identical) in both themes. `--cv-border-strong` is the same token `.cv-search`'s border already\r\n uses against this same surface a few rules up \u2014 a pairing already proven visible, not a new one.\r\n */\r\n border: 1px solid var(--cv-border-strong);\r\n display: flex;\r\n align-items: center;\r\n justify-content: center;\r\n color: var(--cv-muted);\r\n font-size: 0.78rem;\r\n font-weight: 700;\r\n flex-shrink: 0;\r\n}\r\n/*\r\n The Historial row's status chip \u2014 WHY a call needs colour at all: direction is already the icon's\r\n shape (an arrow in/out), but \"did this connect\" is not shape-able, and a flat grey circle made every\r\n row look identical at a glance. Small badge, not a row-wide fill (that mistake already got made once\r\n on the person filter \u2014 see its own comment): a 40px circle reads as a status glyph, the same way a\r\n colored icon-badge does anywhere else; a wide fill reads as decoration competing with the text next\r\n to it. `--danger-strong`/`--accent-strong` (not the plain base tokens) because the base tokens\r\n measure BELOW 3:1 non-text contrast against their own `-soft` fill \u2014 `--cv-danger` on\r\n `--cv-danger-soft` is 2.6:1; `--cv-danger-strong` is 4.47:1 light / 3.62:1 dark, measured.\r\n*/\r\n.cv-item-avatar[data-tone='accent'] { background: var(--cv-accent-soft); color: var(--cv-accent-strong); }\r\n.cv-item-avatar[data-tone='info'] { background: var(--cv-info-soft); color: var(--cv-info-strong); }\r\n.cv-item-avatar[data-tone='danger'] { background: var(--cv-danger-soft); color: var(--cv-danger-strong); }\r\n/* A button, reset to look like plain content \u2014 the row is a container, not a control. */\r\n.cv-item-main {\r\n flex: 1;\r\n min-width: 0;\r\n display: block;\r\n border: none;\r\n background: none;\r\n padding: 0;\r\n font: inherit;\r\n color: inherit;\r\n text-align: left;\r\n cursor: pointer;\r\n transition: transform 0.1s var(--cv-ease-out);\r\n}\r\n.cv-item-main:disabled { cursor: default; }\r\n.cv-item-main:not(:disabled):active { transform: scale(0.985); }\r\n.cv-item-name {\r\n display: block;\r\n font-weight: 600;\r\n font-size: 0.875rem;\r\n white-space: nowrap;\r\n overflow: hidden;\r\n text-overflow: ellipsis;\r\n}\r\n/*\r\n 14px bold does not clear WCAG's large-text bar (18.66px bold) \u2014 this text needs the full 4.5:1, not\r\n the 3:1 large-text minimum. `--cv-danger-strong`: 6.47:1 light / 6.46:1 dark against `--cv-bg`,\r\n measured; the plain `--cv-danger` this used to be was only 3.76:1 light \u2014 a real (if marginal) fail.\r\n*/\r\n.cv-item-name[data-tone='danger'] { color: var(--cv-danger-strong); }\r\n.cv-item-sub {\r\n display: block;\r\n font-size: 0.72rem;\r\n color: var(--cv-muted);\r\n margin-top: 1px;\r\n font-variant-numeric: tabular-nums;\r\n}\r\n.cv-item-sub-outcome { color: var(--cv-danger-strong); }\r\n/*\r\n Decorative reinforcement, not the sole carrier \u2014 the row's own play button is what says \"this call\r\n has a recording\" (it only renders when one exists); this is a small hint IN the metadata line that\r\n the button is worth pressing, so `--cv-faint` (2.5:1, documented above as decorative-only) is the\r\n right tier here, same reasoning `.cv-empty-icon`/`.cv-gate-mark` already use for icons next to real\r\n text rather than standing alone.\r\n\r\n `display: inline-flex` is load-bearing, not decoration \u2014 every OTHER inline icon in this file gets\r\n an explicit `display` for the same reason: `/react` + `/ui` render straight into the host's light\r\n DOM (no shadow root), so a host reset like Tailwind Preflight's `svg { display: block }` reaches\r\n this element directly. Without an explicit override here, that reset knocks the icon onto its own\r\n line instead of trailing the duration text it is meant to sit beside.\r\n*/\r\n.cv-item-sub-rec {\r\n display: inline-flex;\r\n color: var(--cv-faint);\r\n margin-left: 0.3rem;\r\n vertical-align: -1px;\r\n}\r\n.cv-item-action {\r\n width: 34px;\r\n height: 34px;\r\n border-radius: 999px;\r\n border: none;\r\n background: none;\r\n color: var(--cv-muted);\r\n cursor: pointer;\r\n display: inline-flex;\r\n align-items: center;\r\n justify-content: center;\r\n flex-shrink: 0;\r\n transition: background 0.12s ease, color 0.12s ease, transform 0.1s var(--cv-ease-out);\r\n}\r\n.cv-item-action:hover { background: var(--cv-accent); color: #fff; }\r\n.cv-item-action:active { transform: scale(0.9); }\r\n/*\r\n The call action specifically (ContactsTab) \u2014 NOT History's \"play recording\", which shares this\r\n same class and must stay neutral; a play button has no reason to be accent-colored. Scoped with\r\n `data-tone`, the same modifier convention `.cv-ctrl[data-tone]` already uses elsewhere in this file.\r\n\r\n Coloured at REST, not just on hover: a hover-only reveal never fires on a touchscreen, so the one\r\n affordance that most needs to read as \"you can tap this\" would be invisible on exactly the devices\r\n a phone widget is likeliest to run on. `accent-strong` is already verified elsewhere in this file\r\n as AA on a surface; `accent-soft` is the same hover tint `.cv-ctrl[data-tone='accent']` uses, just\r\n transparent at rest so fifty rows of solid fill never turns the list into a wall of green.\r\n*/\r\n.cv-item-action[data-tone='accent'] { color: var(--cv-accent-strong); }\r\n.cv-item-action[data-tone='accent']:hover { background: var(--cv-accent-soft); color: var(--cv-accent-strong); }\r\n\r\n@media (prefers-reduced-motion: reduce) {\r\n .cv-item-main, .cv-item-action, .cv-search-clear { transition: none; }\r\n}\r\n\r\n.cv-empty {\r\n display: flex;\r\n flex-direction: column;\r\n align-items: center;\r\n justify-content: center;\r\n height: 100%;\r\n color: var(--cv-muted);\r\n text-align: center;\r\n padding: 2.5rem 1.25rem;\r\n font-size: 0.82rem;\r\n}\r\n.cv-empty-icon {\r\n display: flex;\r\n align-items: center;\r\n justify-content: center;\r\n width: 48px;\r\n height: 48px;\r\n border-radius: 999px;\r\n background: var(--cv-surface);\r\n color: var(--cv-faint);\r\n margin-bottom: 0.75rem;\r\n}\r\n.cv-empty-title { margin: 0; font-size: 0.85rem; font-weight: 600; color: var(--cv-muted); }\r\n/*\r\n `--cv-muted`, NOT `--cv-faint` \u2014 this file documents faint as \"decorative only... never\r\n information\" (2.5:1, below AA), and this line IS information: it is the reason the list is\r\n empty, sometimes echoing the user's own search back to them. `--cv-faint` here measured 2.54:1\r\n light / 3.70:1 dark against a 4.5:1 bar \u2014 a real failure, not a rounding difference.\r\n*/\r\n.cv-empty-sub { margin: 0.2rem 0 0; font-size: 0.75rem; color: var(--cv-muted); }\r\n.cv-empty-spinner {\r\n margin-bottom: 0.75rem;\r\n animation: cv-spin 0.8s linear infinite;\r\n /* A loading spinner's rotation IS the information, with no static equivalent \u2014 unlike this\r\n file's other motion, it stays exempt from reduced-motion rather than freezing into a shape\r\n that would just look stuck. Colour comes from `.cv-empty`'s own `color: var(--cv-muted)` \u2014\r\n the spinner no longer sets its own, now that it sits next to real text instead of standing\r\n alone: the icon is decorative reinforcement, same as `.cv-empty-icon`, not the sole carrier. */\r\n}\r\n@keyframes cv-spin {\r\n to { transform: rotate(360deg); }\r\n}\r\n\r\n/* The scroll-triggered \"load more\" marker \u2014 see `useInfiniteList`. Never visible itself; a small\r\n height so it has a real box for the `IntersectionObserver` to track. */\r\n.cv-list-sentinel {\r\n height: 1px;\r\n}\r\n.cv-list-loading-more {\r\n display: flex;\r\n align-items: center;\r\n justify-content: center;\r\n padding: 0.75rem 0 0.25rem;\r\n color: var(--cv-muted);\r\n}\r\n/* Exempt from reduced-motion for the same reason `.cv-empty-spinner` is \u2014 see its own comment. */\r\n.cv-list-loading-more-spinner {\r\n animation: cv-spin 0.8s linear infinite;\r\n}\r\n\r\n/* \u2500\u2500 Contact detail (overlays the list) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\r\n.cv-detail {\r\n position: absolute;\r\n inset: 0;\r\n /* Above the list's own sticky day-group header (`.cv-history-day`, z-index: 1) \u2014 otherwise\r\n that header's z-index wins the stacking fight wherever its unscrolled flow position happens\r\n to land on top of this overlay, and it paints through despite both having an opaque bg. */\r\n z-index: 2;\r\n display: flex;\r\n flex-direction: column;\r\n background: var(--cv-bg);\r\n animation: cv-detail-in 220ms var(--cv-ease-out) both;\r\n}\r\n@keyframes cv-detail-in {\r\n from { opacity: 0; transform: translateX(10px); }\r\n to { opacity: 1; transform: none; }\r\n}\r\n@media (prefers-reduced-motion: reduce) {\r\n .cv-detail { animation: none; }\r\n}\r\n\r\n.cv-detail-head { flex-shrink: 0; padding: 0.6rem 0.5rem 0; }\r\n.cv-detail-back {\r\n display: inline-flex;\r\n align-items: center;\r\n gap: 0.3rem;\r\n padding: 0.4rem 0.5rem;\r\n border: none;\r\n border-radius: 8px;\r\n background: none;\r\n color: var(--cv-muted);\r\n font: 600 0.82rem var(--cv-font);\r\n cursor: pointer;\r\n transition: color 0.12s ease, transform 0.1s var(--cv-ease-out);\r\n}\r\n.cv-detail-back:hover { color: var(--cv-text); }\r\n.cv-detail-back:active { transform: scale(0.97); }\r\n\r\n.cv-detail-body { flex: 1; min-height: 0; overflow-y: auto; padding: 0 1.25rem; }\r\n.cv-detail-identity {\r\n display: flex;\r\n flex-direction: column;\r\n align-items: center;\r\n text-align: center;\r\n padding: 0.75rem 0 1.5rem;\r\n}\r\n.cv-detail-avatar {\r\n width: 72px;\r\n height: 72px;\r\n border-radius: 999px;\r\n /* Same radial + border treatment as `.cv-item-avatar` (see its own comment), just larger. Neutral\r\n by default \u2014 a contact has no \"status\" to colour. `HistoryDetail` overrides both with `[data-tone]`\r\n below: a call DOES have one, the same one the list row already shows, so the hero avatar staying\r\n grey there would be information the list already gave up on repeating here. */\r\n background: radial-gradient(circle, var(--cv-surface), var(--cv-surface-2));\r\n border: 1px solid var(--cv-border-strong);\r\n display: flex;\r\n align-items: center;\r\n justify-content: center;\r\n color: var(--cv-muted);\r\n font-size: 1.35rem;\r\n font-weight: 700;\r\n margin-bottom: 0.85rem;\r\n}\r\n/* Same token pairs already verified for `.cv-item-avatar[data-tone]` \u2014 size doesn't change a contrast\r\n ratio, so no new numbers to measure here. */\r\n.cv-detail-avatar[data-tone='accent'] { background: var(--cv-accent-soft); color: var(--cv-accent-strong); border-color: transparent; }\r\n.cv-detail-avatar[data-tone='info'] { background: var(--cv-info-soft); color: var(--cv-info-strong); border-color: transparent; }\r\n.cv-detail-avatar[data-tone='danger'] { background: var(--cv-danger-soft); color: var(--cv-danger-strong); border-color: transparent; }\r\n.cv-detail-name {\r\n margin: 0;\r\n font-size: 1.1rem;\r\n font-weight: 700;\r\n color: var(--cv-text);\r\n letter-spacing: -0.01em;\r\n}\r\n.cv-detail-name[data-tone='danger'] { color: var(--cv-danger-strong); }\r\n.cv-detail-meta {\r\n margin: 0.2rem 0 0;\r\n font-size: 0.78rem;\r\n color: var(--cv-muted);\r\n}\r\n\r\n/* \u2500\u2500 Call detail: recording \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\r\n.cv-detail-recording { margin-bottom: 1rem; }\r\n.cv-detail-recording-label {\r\n margin: 0 0 0.4rem;\r\n padding: 0 0.15rem;\r\n font-size: 0.68rem;\r\n font-weight: 600;\r\n text-transform: uppercase;\r\n letter-spacing: 0.03em;\r\n color: var(--cv-muted);\r\n}\r\n.cv-detail-audio { width: 100%; display: block; }\r\n.cv-detail-audio + .cv-detail-audio { margin-top: 0.5rem; }\r\n.cv-detail-recording-status {\r\n display: flex;\r\n align-items: center;\r\n gap: 0.5rem;\r\n margin: 0 0 1rem;\r\n padding: 0.65rem 0.75rem;\r\n border-radius: 12px;\r\n background: var(--cv-surface);\r\n color: var(--cv-muted);\r\n font-size: 0.78rem;\r\n}\r\n/*\r\n Text colour only, same neutral `--cv-surface` background the other states use \u2014 NOT a\r\n `--cv-danger-soft` fill. Measured first, not assumed: `--cv-danger-strong` on `--cv-danger-soft`\r\n is 3.34:1 light / 3.62:1 dark, well under the 4.5:1 this 0.78rem text needs (it only ever cleared\r\n the SEPARATE 3:1 non-text bar this token was built for \u2014 an icon on that badge, not body text on\r\n it). Same lesson the person-filter fix already paid for: a badge-strength pairing that works for an\r\n icon is not automatically safe for text.\r\n*/\r\n.cv-detail-recording-status--warn { color: var(--cv-danger-strong); }\r\n.cv-detail-recording-spinner { animation: cv-spin 0.8s linear infinite; flex-shrink: 0; }\r\n\r\n/* \u2500\u2500 Call detail: who handled it \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\r\n.cv-detail-handlers { display: flex; flex-wrap: wrap; align-items: center; gap: 0.3rem; }\r\n.cv-detail-handler-hop { display: inline-flex; align-items: center; gap: 0.3rem; }\r\n.cv-detail-handler-arrow { color: var(--cv-faint); flex-shrink: 0; }\r\n.cv-detail-handler-chip {\r\n display: inline-flex;\r\n align-items: center;\r\n gap: 0.3rem;\r\n padding: 0.15rem 0.5rem;\r\n border-radius: 999px;\r\n background: var(--cv-surface-2);\r\n color: var(--cv-text);\r\n font-size: 0.75rem;\r\n font-weight: 600;\r\n}\r\n/* The LAST hop \u2014 the one the call actually ended with \u2014 is the one worth a glance first. */\r\n.cv-detail-handler-chip[data-tone='accent'] { background: var(--cv-accent-soft); color: var(--cv-accent-strong); }\r\n.cv-detail-handler-ext {\r\n font-weight: 400;\r\n font-variant-numeric: tabular-nums;\r\n opacity: 0.75;\r\n}\r\n\r\n.cv-detail-rows {\r\n border: 1px solid var(--cv-border);\r\n border-radius: 14px;\r\n overflow: hidden;\r\n background: var(--cv-bg);\r\n}\r\n.cv-detail-row {\r\n display: flex;\r\n align-items: center;\r\n gap: 0.7rem;\r\n width: 100%;\r\n padding: 0.7rem 0.85rem;\r\n border: none;\r\n border-top: 1px solid var(--cv-border);\r\n background: none;\r\n font: inherit;\r\n color: inherit;\r\n text-align: left;\r\n}\r\n.cv-detail-row:first-child { border-top: none; }\r\n.cv-detail-row--action {\r\n cursor: pointer;\r\n transition: background 0.12s ease;\r\n}\r\n.cv-detail-row--action:hover { background: var(--cv-surface); }\r\n.cv-detail-row-icon {\r\n display: flex;\r\n align-items: center;\r\n justify-content: center;\r\n width: 32px;\r\n height: 32px;\r\n border-radius: 999px;\r\n background: var(--cv-surface-2);\r\n /*\r\n Without this the circle nearly vanishes into its own row: `--cv-surface-2` measures only ~1.1:1\r\n against the row's rest-state background and ~1.05:1 against `--cv-surface`, the color\r\n `.cv-detail-row--action:hover` sets the ROW to \u2014 both far below a readable boundary, and worse\r\n than `.cv-item-avatar`/`.cv-detail-avatar` above even manage WITH their border (1.18:1 light /\r\n 1.57:1 dark). Same token, same fix, same reason.\r\n */\r\n border: 1px solid var(--cv-border-strong);\r\n color: var(--cv-muted);\r\n flex-shrink: 0;\r\n}\r\n.cv-detail-row-text { flex: 1; min-width: 0; display: flex; flex-direction: column; }\r\n.cv-detail-row-eyebrow {\r\n font-size: 0.68rem;\r\n font-weight: 600;\r\n text-transform: uppercase;\r\n letter-spacing: 0.03em;\r\n color: var(--cv-muted);\r\n}\r\n.cv-detail-row-value {\r\n font-size: 0.85rem;\r\n font-weight: 500;\r\n color: var(--cv-text);\r\n white-space: nowrap;\r\n overflow: hidden;\r\n text-overflow: ellipsis;\r\n}\r\n.cv-detail-row-chevron { color: var(--cv-faint); flex-shrink: 0; }\r\n\r\n.cv-detail-foot { flex-shrink: 0; padding: 0.85rem 1.25rem 1.1rem; }\r\n.cv-detail-call { width: 100%; display: flex; align-items: center; justify-content: center; gap: 0.4rem; }\r\n\r\n/* \u2500\u2500 Settings tab (device selection, SPEC \xA77 Phase 2) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\r\n.cv-settings-body {\r\n flex: 1;\r\n min-height: 0;\r\n overflow-y: auto;\r\n padding: 1rem;\r\n display: flex;\r\n flex-direction: column;\r\n /* Generous separation BETWEEN sections, deliberately larger than any gap inside one \u2014 grouping\r\n only reads as grouping if the space between groups is clearly bigger than the space within one\r\n (see impeccable's layout guidance: \"tight grouping, generous separation\"). */\r\n gap: 1.5rem;\r\n}\r\n\r\n/*\r\n One labeled group (`SettingsSection`): a title, and a bordered body holding its rows. Ajustes\r\n started as a flat list of two rows with nothing to say they were related \u2014 fine for exactly two\r\n rows, unreadable the moment an unrelated third setting lands with nothing to tell it apart from\r\n the device rows above it.\r\n\r\n The body is ONE card, not a card per row: `.cv-settings-row`/`.cv-settings-note` below carry no\r\n border or background of their own, only a divider between siblings \u2014 the same shape\r\n `.cv-detail-rows`/`.cv-detail-row` already use elsewhere in this file, and deliberately not a\r\n stack of individually-bordered cards (nested-card territory).\r\n*/\r\n.cv-settings-section { display: flex; flex-direction: column; gap: 0.5rem; }\r\n.cv-settings-section-title {\r\n margin: 0;\r\n padding: 0 0.2rem;\r\n font-size: 0.7rem;\r\n font-weight: 700;\r\n text-transform: uppercase;\r\n letter-spacing: 0.04em;\r\n color: var(--cv-muted);\r\n}\r\n.cv-settings-section-body {\r\n border: 1px solid var(--cv-border);\r\n border-radius: 14px;\r\n background: var(--cv-bg);\r\n overflow: hidden;\r\n}\r\n.cv-settings-section-body > * { padding: 0.75rem 0.85rem; }\r\n.cv-settings-section-body > *:not(:first-child) { border-top: 1px solid var(--cv-border); }\r\n\r\n.cv-settings-row { display: flex; align-items: center; gap: 0.7rem; }\r\n/* Same treatment as `.cv-detail-row-icon`, same reason \u2014 see its own comment on why the border is\r\n load-bearing: `--cv-surface-2` alone nearly vanishes into the section's own background. */\r\n.cv-settings-row-icon {\r\n display: flex;\r\n align-items: center;\r\n justify-content: center;\r\n width: 32px;\r\n height: 32px;\r\n border-radius: 999px;\r\n background: var(--cv-surface-2);\r\n border: 1px solid var(--cv-border-strong);\r\n color: var(--cv-muted);\r\n flex-shrink: 0;\r\n}\r\n.cv-settings-row-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0.35rem; }\r\n/* Same pairing as `.cv-detail-row-eyebrow` (`--cv-muted` on `--cv-bg`), verified there already. */\r\n.cv-settings-row-label {\r\n font-size: 0.68rem;\r\n font-weight: 600;\r\n text-transform: uppercase;\r\n letter-spacing: 0.03em;\r\n color: var(--cv-muted);\r\n}\r\n.cv-settings-select {\r\n width: 100%;\r\n padding: 0.45rem 0.6rem;\r\n border-radius: 8px;\r\n border: 1px solid var(--cv-border-strong);\r\n background: var(--cv-surface);\r\n color: var(--cv-text);\r\n font: inherit;\r\n font-size: 0.85rem;\r\n transition: border-color 0.15s ease, background 0.15s ease;\r\n}\r\n.cv-settings-select:focus { outline: none; border-color: var(--cv-accent); background: var(--cv-bg); }\r\n.cv-settings-empty { font-size: 0.85rem; color: var(--cv-muted); }\r\n/* `--cv-muted` on `--cv-bg` \u2014 4.83:1 light / 6.97:1 dark, computed fresh for THIS pairing (the note\r\n used to sit on `--cv-surface` with its own card; now it sits directly on the section's `--cv-bg`,\r\n a different background that earns its own check rather than inheriting the old one's number). */\r\n.cv-settings-note {\r\n margin: 0;\r\n color: var(--cv-muted);\r\n font-size: 0.8rem;\r\n line-height: 1.5;\r\n}\r\n/* Closing note for the tab as a whole, not any one section \u2014 sits directly on `.cv-settings-body`,\r\n which shares the same `--cv-bg` as `.cv-settings-section-body` above, so the `--cv-muted`-on-\r\n `--cv-bg` pairing already verified for `.cv-settings-note` carries over unchanged. Centred and\r\n quieter than a row: this is a footnote about the tab, not a row's worth of content. */\r\n.cv-settings-more {\r\n margin: 0;\r\n padding: 0 0.5rem;\r\n text-align: center;\r\n color: var(--cv-muted);\r\n font-size: 0.78rem;\r\n line-height: 1.5;\r\n}\r\n\r\n/* \u2500\u2500 Centred states (mic gate) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\r\n.cv-center {\r\n flex: 1;\r\n display: flex;\r\n flex-direction: column;\r\n align-items: center;\r\n justify-content: center;\r\n gap: 0.85rem;\r\n padding: 1.75rem;\r\n text-align: center;\r\n}\r\n.cv-center p { color: var(--cv-muted); margin: 0; font-size: 0.85rem; line-height: 1.5; }\r\n\r\n\r\n/* \"C\xF3mo permitirlo\" \u2014 a native <details> disclosure.\r\n Collapsed by default: the people who already know where the padlock is should not have to read\r\n past three steps to get there, and the people who don't get them without leaving the app. */\r\n.cv-help {\r\n width: 100%;\r\n margin-top: 0.15rem;\r\n text-align: left;\r\n}\r\n.cv-help summary {\r\n display: inline-flex;\r\n align-items: center;\r\n gap: 0.3rem;\r\n justify-content: center;\r\n width: 100%;\r\n padding: 0.3rem;\r\n border-radius: 8px;\r\n color: var(--cv-muted);\r\n font: 500 0.75rem var(--cv-font);\r\n cursor: pointer;\r\n list-style: none; /* hide the default triangle; the icon is the affordance */\r\n transition: color 0.15s ease, background 0.15s ease;\r\n}\r\n.cv-help summary::-webkit-details-marker { display: none; }\r\n.cv-help summary:hover { color: var(--cv-text); background: var(--cv-surface-2); }\r\n.cv-help[open] summary { color: var(--cv-text); margin-bottom: 0.35rem; }\r\n\r\n.cv-help ol {\r\n margin: 0;\r\n padding: 0.6rem 0.7rem 0.6rem 1.6rem;\r\n background: var(--cv-surface);\r\n border-radius: 10px;\r\n color: var(--cv-muted);\r\n font-size: 0.73rem;\r\n line-height: 1.55;\r\n}\r\n.cv-help li + li { margin-top: 0.3rem; }\r\n.cv-help li::marker { color: var(--cv-faint); font-variant-numeric: tabular-nums; }\r\n\r\n.cv-help a {\r\n display: inline-flex;\r\n align-items: center;\r\n gap: 0.25rem;\r\n margin-top: 0.45rem;\r\n padding-left: 0.1rem;\r\n color: var(--cv-accent-strong);\r\n font-size: 0.73rem;\r\n font-weight: 500;\r\n text-decoration: none;\r\n}\r\n.cv-help a:hover { text-decoration: underline; }\r\n\r\n/* The panel is the only thing that moves, and only when opened by hand \u2014 so it animates. */\r\n.cv-help[open] ol {\r\n animation: cv-gate-in 200ms var(--cv-ease-out) both;\r\n}\r\n@media (prefers-reduced-motion: reduce) {\r\n .cv-help[open] ol { animation: none; }\r\n}\r\n\r\n/* \u2500\u2500 The permission gate \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n Four states, and only ONE of them deserves a designed screen.\r\n\r\n `checking` resolves in ~100\u2013400 ms. Rendering it immediately means the user\r\n sees text appear and vanish inside a blink \u2014 a flash reads as jank, not as\r\n information. So it is delayed 450 ms with `both` fill: invisible during the\r\n delay, and if the check finishes first (the usual case, and always the case\r\n when permission is already granted) it is never painted at all.\r\n\r\n `prompting` is the one the user actually reads and acts on. It gets the\r\n hierarchy: mark, title, reason, action.\r\n \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\r\n.cv-gate {\r\n flex: 1;\r\n min-height: 0;\r\n display: flex;\r\n flex-direction: column;\r\n align-items: center;\r\n justify-content: center;\r\n gap: 0.6rem;\r\n padding: 1.5rem 1.75rem;\r\n text-align: center;\r\n}\r\n\r\n/* The disc echoes the call avatar \u2014 one visual language, not two. */\r\n.cv-gate-mark {\r\n display: grid;\r\n place-items: center;\r\n width: 52px;\r\n height: 52px;\r\n border-radius: 999px;\r\n background: var(--cv-accent-soft);\r\n color: var(--cv-accent-strong);\r\n margin-bottom: 0.15rem;\r\n}\r\n.cv-gate[data-tone='danger'] .cv-gate-mark {\r\n background: var(--cv-danger-soft);\r\n color: var(--cv-danger);\r\n}\r\n\r\n.cv-gate-title {\r\n margin: 0;\r\n font: 600 0.95rem var(--cv-font);\r\n color: var(--cv-text);\r\n letter-spacing: -0.01em;\r\n}\r\n.cv-gate p {\r\n margin: 0;\r\n color: var(--cv-muted);\r\n font-size: 0.8rem;\r\n line-height: 1.5;\r\n max-width: 24ch; /* two comfortable lines, not one long one */\r\n}\r\n.cv-gate .cv-btn { margin-top: 0.5rem; }\r\n\r\n/* Entrance. Staggered 45 ms apart: the eye follows mark \u2192 title \u2192 reason \u2192\r\n action, which is the order it needs to read them in anyway. */\r\n.cv-gate > * {\r\n animation: cv-gate-in 260ms var(--cv-ease-out) both;\r\n}\r\n.cv-gate > *:nth-child(2) { animation-delay: 45ms; }\r\n.cv-gate > *:nth-child(3) { animation-delay: 90ms; }\r\n.cv-gate > *:nth-child(4) { animation-delay: 135ms; }\r\n\r\n@keyframes cv-gate-in {\r\n from { opacity: 0; transform: translateY(6px); }\r\n to { opacity: 1; transform: none; }\r\n}\r\n\r\n/* The checking state: held back, then a quiet fade. No stagger, no movement \u2014\r\n it is a status, not a screen, and it should barely register. */\r\n.cv-gate[data-state='checking'] > *,\r\n.cv-gate[data-state='warming'] > * {\r\n animation: cv-gate-wait 200ms var(--cv-ease-out) 450ms both;\r\n}\r\n@keyframes cv-gate-wait {\r\n from { opacity: 0; }\r\n to { opacity: 1; }\r\n}\r\n/* A slow breath rather than a spinner: a spinner promises a long wait, and this\r\n is not one. */\r\n.cv-gate[data-state='checking'] .cv-gate-mark,\r\n.cv-gate[data-state='warming'] .cv-gate-mark {\r\n background: var(--cv-surface-2);\r\n color: var(--cv-muted);\r\n animation: cv-gate-wait 200ms var(--cv-ease-out) 450ms both,\r\n cv-breathe 1.8s ease-in-out 450ms infinite;\r\n}\r\n@keyframes cv-breathe {\r\n 0%, 100% { opacity: 1; }\r\n 50% { opacity: 0.45; }\r\n}\r\n\r\n/* Reduced motion: keep the fade and \u2014 critically \u2014 keep the DELAY, which is\r\n timing rather than motion and is the whole point. Drop the travel. */\r\n@media (prefers-reduced-motion: reduce) {\r\n .cv-gate > * {\r\n animation: cv-gate-wait 200ms var(--cv-ease-out) both;\r\n }\r\n .cv-gate[data-state='checking'] .cv-gate-mark,\r\n .cv-gate[data-state='warming'] .cv-gate-mark {\r\n animation: cv-gate-wait 200ms var(--cv-ease-out) 450ms both;\r\n }\r\n}\r\n\r\n@media (prefers-reduced-motion: reduce) {\r\n .cv-avatar-ping,\r\n .cv-timer i { animation: none; }\r\n .cv-softphone *,\r\n .cv-softphone *::before { transition-duration: 0.01ms !important; }\r\n}\r\n\r\n/* `*` and `#` stay in the grid \u2014 a keypad without them is not a keypad \u2014 but they are not part of a\r\n number (toE164 keeps digits only). Dimmed rather than `disabled` so the grid still reads as a\r\n keypad and the glyphs stay legible; they become live DTMF on the in-call screen. */\r\n.cv-key[data-dialable='false'] {\r\n cursor: default;\r\n}\r\n.cv-key[data-dialable='false'] b {\r\n color: var(--cv-faint);\r\n}\r\n.cv-key[data-dialable='false']:hover {\r\n background: none;\r\n}\r\n.cv-key[data-dialable='false']:active {\r\n transform: none;\r\n}\r\n\r\n/* \u2500\u2500 Country picker \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n The prefix chip + its list. Sits where the old left spacer was, so the number\r\n stays optically centred against the 36px erase button on the right.\r\n\r\n The popover is absolutely positioned inside the widget rather than portalled:\r\n there is nowhere to portal TO inside a shadow root, and the widget already owns\r\n its stacking context. That also means it cannot be clipped by a host's\r\n `overflow:hidden` \u2014 the failure SPEC \xA72.6 lists for the CRM's Radix version.\r\n \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\r\n/* The anchor spans the whole ROW, not just the chip.\r\n With the chip centred, anchoring to the chip put the popover's left edge at ~120px and its right\r\n edge past the widget \u2014 clipped by `.cv-softphone { overflow: hidden }`, which is the classic\r\n absolute-inside-overflow bug. Anchoring to the full row makes overflowing arithmetically\r\n impossible: `left:0; right:0` cannot be wider than the row that contains it. */\r\n.cv-cp {\r\n position: relative;\r\n display: flex;\r\n justify-content: center;\r\n width: 100%;\r\n}\r\n/* A PILL, with a resting surface and a border.\r\n This was `border: none; background: none` \u2014 invisible until hover, so it read as a label rather\r\n than a control. That was survivable when it sat inline beside the number; alone on its own row it\r\n is just floating text, and nothing says \"you can change this\". A control that changes state has to\r\n look like one at rest, not only under a cursor that may never arrive (touch has no hover at all). */\r\n.cv-cp-chip {\r\n display: inline-flex;\r\n align-items: center;\r\n gap: 0.3rem;\r\n height: 30px;\r\n padding: 0 0.5rem 0 0.4rem;\r\n border: 1px solid var(--cv-border-strong);\r\n border-radius: 999px;\r\n background: var(--cv-surface);\r\n color: var(--cv-text);\r\n font: 600 0.8rem var(--cv-font);\r\n cursor: pointer;\r\n transition: background 0.15s, border-color 0.15s;\r\n}\r\n.cv-cp-chip:hover { background: var(--cv-surface-2); }\r\n.cv-cp-chip:active { transform: scale(0.95); }\r\n/* Open: the chip is the thing the list belongs to, so it stays visibly engaged while the list is up. */\r\n.cv-cp-chip[aria-expanded='true'] {\r\n background: var(--cv-accent-soft);\r\n border-color: var(--cv-accent);\r\n}\r\n.cv-cp-dial {\r\n font-variant-numeric: tabular-nums;\r\n letter-spacing: -0.02em;\r\n}\r\n.cv-cp-caret {\r\n color: var(--cv-faint);\r\n flex-shrink: 0;\r\n transition: transform 0.18s cubic-bezier(0.2, 0, 0.2, 1);\r\n}\r\n.cv-cp-caret[data-open='true'] { transform: rotate(180deg); }\r\n\r\n/* The country mark. TWO designed states in ONE 20\xD714 box (a flag's own 3:2), so the chip and every\r\n row keep their rhythm whichever is showing \u2014 nothing reflows when the flags land. The plate is the\r\n LOADING state (flags are a lazy import) and the offline state, not a fallback: there is no flash of\r\n something broken, only a designed plate becoming a designed flag, once per session.\r\n `::part(country-mark)` lets a host restyle either. */\r\n.cv-mark {\r\n width: 20px;\r\n height: 14px;\r\n flex-shrink: 0;\r\n display: inline-flex;\r\n align-items: center;\r\n justify-content: center;\r\n font-size: 14px;\r\n line-height: 1;\r\n font-family: 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', var(--cv-font);\r\n}\r\n/* The SVG fills the slot exactly; 3x2 is the flag aspect, so nothing is letterboxed or cropped. */\r\n.cv-mark-svg {\r\n width: 100%;\r\n height: 100%;\r\n display: block;\r\n border-radius: 2px;\r\n object-fit: cover;\r\n}\r\n.cv-mark[data-plate='true'] {\r\n /* The ISO code set into the slot a flag will occupy. */\r\n font-family: var(--cv-font);\r\n font-size: 8.5px;\r\n font-weight: 700;\r\n letter-spacing: 0.04em;\r\n color: var(--cv-text);\r\n background: var(--cv-surface-2);\r\n border: 1px solid var(--cv-border);\r\n border-radius: 3px;\r\n /* Cap-height letters sit optically high in a 14px box; nudge them back to centre. */\r\n padding-top: 1px;\r\n}\r\n\r\n.cv-cp-pop {\r\n position: absolute;\r\n top: calc(100% + 6px);\r\n /* Pinned to BOTH row edges, so it is bounded by the container by construction \u2014 no width guess,\r\n no media query, nothing to clip. `max-width` keeps it from looking stretched on a wide host. */\r\n left: 0;\r\n right: 0;\r\n margin-inline: auto;\r\n max-width: 260px;\r\n z-index: 20;\r\n display: flex;\r\n flex-direction: column;\r\n background: var(--cv-surface);\r\n border: 1px solid var(--cv-border);\r\n border-radius: var(--cv-radius);\r\n box-shadow: var(--cv-shadow);\r\n overflow: hidden;\r\n /* Grows from the chip it belongs to, rather than appearing on top of it. */\r\n transform-origin: top center;\r\n animation: cv-cp-in 0.14s cubic-bezier(0.2, 0, 0.2, 1);\r\n}\r\n@keyframes cv-cp-in {\r\n from { opacity: 0; transform: translateY(-4px) scale(0.97); }\r\n to { opacity: 1; transform: none; }\r\n}\r\n@media (prefers-reduced-motion: reduce) {\r\n .cv-cp-pop { animation: none; }\r\n .cv-cp-caret { transition: none; }\r\n}\r\n.cv-cp-search {\r\n flex-shrink: 0;\r\n padding: 0.5rem 0.65rem;\r\n border: none;\r\n border-bottom: 1px solid var(--cv-border);\r\n background: none;\r\n color: var(--cv-text);\r\n font: 400 0.8rem var(--cv-font);\r\n outline: none;\r\n}\r\n.cv-cp-search::placeholder { color: var(--cv-faint); }\r\n/* Both states named explicitly. In the CRM the widget mounts via `/ui`, i.e. in the LIGHT DOM \u2014 none\r\n of the shadow-DOM isolation the web-component playground proves applies there, so a host's focus\r\n ring (or the browser's own) can land on our input. The accent underline IS the focus indicator. */\r\n.cv-cp-search:focus,\r\n.cv-cp-search:focus-visible {\r\n outline: none;\r\n border-bottom-color: var(--cv-accent);\r\n border-bottom-width: 2px;\r\n padding-bottom: calc(0.5rem - 1px);\r\n}\r\n.cv-cp-list {\r\n overflow-y: auto;\r\n max-height: 220px;\r\n padding: 0.25rem;\r\n}\r\n.cv-cp-item {\r\n width: 100%;\r\n display: flex;\r\n align-items: center;\r\n gap: 0.5rem;\r\n padding: 0.4rem 0.45rem;\r\n border: none;\r\n border-radius: calc(var(--cv-radius) - 4px);\r\n background: none;\r\n color: var(--cv-text);\r\n font: 400 0.8rem var(--cv-font);\r\n text-align: left;\r\n cursor: pointer;\r\n}\r\n.cv-cp-item:hover { background: var(--cv-surface-2); }\r\n/* The current country is stated by the row, not only by a tick at the far right. */\r\n.cv-cp-item[aria-selected='true'] { background: var(--cv-accent-soft); }\r\n.cv-cp-item[aria-selected='true'] .cv-cp-name { font-weight: 600; }\r\n.cv-cp-name {\r\n flex: 1;\r\n min-width: 0;\r\n overflow: hidden;\r\n text-overflow: ellipsis;\r\n white-space: nowrap;\r\n}\r\n.cv-cp-item .cv-cp-dial {\r\n color: var(--cv-muted);\r\n font-weight: 400;\r\n}\r\n.cv-cp-check { color: var(--cv-accent-strong); flex-shrink: 0; }\r\n.cv-cp-check[data-on='false'] { opacity: 0; }\r\n.cv-cp-empty {\r\n margin: 0;\r\n padding: 1rem 0.5rem;\r\n text-align: center;\r\n color: var(--cv-muted);\r\n font-size: 0.78rem;\r\n}\r\n";
|
|
10653
|
+
var softphone_default = "/**\r\n * @coventia/softphone/styles.css\r\n *\r\n * The theming contract (SPEC \xA72.6) + all component styles. A host restyles the softphone by\r\n * overriding the `--cv-*` custom properties \u2014 which are INHERITED, so they cross the shadow boundary\r\n * of `<coventia-softphone>` by design (the host's own CSS *selectors* do not, which is what stops a\r\n * stray `button { width:100% }` from destroying the dialpad).\r\n *\r\n * Defaults match the Coventia product: light surface, emerald accent, soft rounded key cards.\r\n *\r\n * Reseller: coventia-softphone { --cv-accent:#7c3aed; --cv-bg:#0b0b0f; }\r\n * CRM (shadcn bridge, ~15 lines, dark mode included):\r\n * .cv-softphone { --cv-bg:hsl(var(--background)); --cv-text:hsl(var(--foreground)); \u2026 }\r\n *\r\n * TYPOGRAPHY NOTE: `--cv-font` is a system stack on purpose. An embedded widget should inherit its\r\n * host's type, not force a webfont into someone else's app; hosts override the token if they want.\r\n *\r\n * Strings are Spanish-only (SPEC \xA72.4). No i18n.\r\n */\r\n\r\n/*\r\n * The custom element's own box.\r\n *\r\n * A custom element is `display: inline` by default and has NO size \u2014 and the widget inside now fills\r\n * its host rather than asserting 340\xD7520, so without this the element would render to ZERO. The\r\n * drop-in promise is \"one tag and it works\", which means the TAG must carry a box.\r\n *\r\n * These are defaults, not decisions: they sit on `:host`, so any host stylesheet outranks them \u2014\r\n *\r\n * coventia-softphone { width: 100%; height: 100%; } \u2190 a sidebar\r\n * coventia-softphone { height: 600px; } \u2190 taller\r\n *\r\n * React consumers never see this: they import `/ui` and their own wrapper states the box (the CRM's\r\n * PhoneDialer does exactly that).\r\n */\r\n:host {\r\n display: block;\r\n width: 340px;\r\n height: 520px;\r\n}\r\n\r\n/* React's container inside the shadow root \u2014 element path only; inert everywhere else.\r\n *\r\n * It must pass the host's height straight through. `.cv-softphone` below is `height: 100%`, and a\r\n * percentage height resolves against the PARENT: leave this div at its default `auto` and that 100%\r\n * silently becomes `auto` too, so the widget collapses to its content height and the host's `height`\r\n * is ignored. What you see is a short card stranded in a taller box \u2014 which reads as a broken widget,\r\n * not as a broken wrapper. */\r\n.cv-mount {\r\n height: 100%;\r\n}\r\n\r\n:host,\r\n.cv-softphone {\r\n /* Surface */\r\n --cv-bg: #ffffff;\r\n --cv-surface: #f9fafb;\r\n --cv-surface-2: #f3f4f6;\r\n --cv-border: #f3f4f6;\r\n --cv-border-strong: #e5e7eb;\r\n\r\n /* Text \u2014 three tiers, split by CONTRAST DUTY, not by taste:\r\n `text` informational primary 17.7:1\r\n `muted` informational secondary 4.8:1 (AA \u2014 phone numbers, timestamps, tab labels)\r\n `faint` DECORATIVE only 2.5:1 (keypad letters, placeholder \u2014 never information)\r\n The legacy used gray-400 for all secondary text, which fails AA at 2.54:1. Splitting the tier\r\n keeps the look while making the informational text legible. */\r\n --cv-text: #111827;\r\n --cv-muted: #6b7280;\r\n --cv-faint: #9ca3af;\r\n\r\n /* Intent \u2014 emerald. Split by CONTRAST DUTY, because one \"brand green\" cannot do all three jobs:\r\n `accent` FILL that carries a white glyph \u2192 emerald-600, white 3.77:1 \u2713\r\n (emerald-500 is the prettier brand green but white on it is 2.54:1 \u2014 the\r\n call button's phone icon would fail WCAG non-text contrast. Measured, not guessed.)\r\n `accent-strong` emerald AS TEXT on a surface \u2192 emerald-700, 5.48:1 \u2713 for the 10px tab label\r\n `accent-light` DECORATIVE only (the ring halo) \u2014 never carries text, so it stays vivid */\r\n --cv-accent: #059669;\r\n --cv-accent-strong: #047857;\r\n --cv-accent-light: #10b981;\r\n --cv-accent-soft: #a7f3d0;\r\n --cv-accent-contrast: #ffffff;\r\n --cv-danger: #ef4444;\r\n /* `--cv-danger` ITSELF fails 3:1 non-text contrast against `--cv-danger-soft` (2.6:1, measured) \u2014\r\n the same AS-TEXT/on-a-tinted-surface split `accent` already has, `danger` was just missing until\r\n the Historial tab's status badges needed it. Darker on light (mirrors `accent-strong`), LIGHTER on\r\n dark (red-400) \u2014 same inversion `accent-strong` documents above, same reason: emphasis on a dark\r\n surface means more light, not less.\r\n red-700 (#b91c1c) was the first pick here and overshot both minimums (6.47:1 text, 4.47:1 badge)\r\n enough to read as a dark, brownish red rather than red \u2014 red-600 clears both with real margin\r\n still (4.83:1 / 3.34:1, measured) and looks like red. */\r\n --cv-danger-strong: #dc2626;\r\n --cv-danger-soft: #fecaca;\r\n /* A third status hue, sky \u2014 for the Historial row status chip specifically: an outgoing call that\r\n WAS answered gets its own colour rather than sharing `accent`, matching the legacy CRM's own\r\n three-way split (emerald/sky/rose) that this file's earlier restrained accent-only-for-\"fine\"\r\n version had collapsed away. Same strong/soft split, same inversion, as `accent`/`danger` above \u2014\r\n 4.47:1 light / 4.42:1 dark against `-soft`, measured. */\r\n --cv-info: #0284c7;\r\n --cv-info-strong: #0369a1;\r\n --cv-info-soft: #bae6fd;\r\n --cv-hold: #6366f1;\r\n\r\n /* Shape & type */\r\n --cv-radius: 16px;\r\n /* Strong ease-out. The built-in CSS easings are too weak to read as intentional. */\r\n --cv-ease-out: cubic-bezier(0.23, 1, 0.32, 1);\r\n --cv-radius-key: 16px;\r\n --cv-font: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;\r\n --cv-font-size: 14px;\r\n --cv-shadow: 0 1px 2px rgb(0 0 0 / 0.04), 0 1px 3px rgb(0 0 0 / 0.06);\r\n\r\n color: var(--cv-text);\r\n font-family: var(--cv-font);\r\n font-size: var(--cv-font-size);\r\n}\r\n\r\n/**\r\n * Dark theme \u2014 matched to the Coventia product's own dark tokens, not invented.\r\n *\r\n * The CRM defines `--background: 240 10% 10%` (#17171c) with surfaces at 15.9% (#27272a) and borders\r\n * at 25.9% (#404044) \u2014 the zinc/240-hue family. An earlier draft here used a near-black\r\n * (#0f0f13, ~half the luminance of the product's background) with 2%-luminance borders that were\r\n * effectively invisible. That is the \"black tonality\" that read wrong: right hue, wrong lightness.\r\n *\r\n * Note the accent INVERTS: on light, `accent-strong` is darker than `accent` (emerald-700); on dark\r\n * it must be LIGHTER (emerald-400), because emphasis on a dark surface means more light, not less.\r\n * Same token, opposite direction \u2014 miss this and the timer/active tab sink into the background.\r\n *\r\n * \u2500\u2500 HOW THE THEME IS CHOSEN \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n * By default it follows the OS (`prefers-color-scheme`). But a host must be able to PIN it: the\r\n * Coventia CRM toggles dark with a class (next-themes), not the OS setting \u2014 so a user with a dark\r\n * OS but the CRM in light mode would otherwise get a dark widget inside a light app.\r\n *\r\n * <SoftphoneWidget theme=\"light\" /> \u2192 data-cv-theme=\"light\" \u2014 always light\r\n * <SoftphoneWidget theme=\"dark\" /> \u2192 data-cv-theme=\"dark\" \u2014 always dark\r\n * <SoftphoneWidget /> \u2192 follows the OS\r\n *\r\n * To follow the HOST's theme rather than the OS, pass the host's current theme through, e.g.\r\n * next-themes: `const { resolvedTheme } = useTheme(); <SoftphoneWidget theme={resolvedTheme} />`.\r\n *\r\n * The dark block is duplicated below (OS-driven vs. pinned) on purpose: plain CSS has no way to\r\n * share a declaration block across a media query and a selector, and the honest duplication beats a\r\n * clever custom-property toggle nobody can debug.\r\n */\r\n@media (prefers-color-scheme: dark) {\r\n :host(:not([data-cv-theme='light'])),\r\n .cv-softphone:not([data-cv-theme='light']) {\r\n --cv-bg: #17171c; /* CRM --background 240 10% 10% */\r\n --cv-surface: #202027; /* between bg and secondary */\r\n --cv-surface-2: #27272a; /* CRM --secondary/--muted 240 3.7% 15.9% */\r\n --cv-border: #2e2e33;\r\n --cv-border-strong: #404044; /* CRM --border 240 3.7% 25.9% */\r\n --cv-text: #fafafa; /* CRM --foreground 0 0% 98% */\r\n --cv-muted: #a1a1aa; /* CRM --muted-foreground 240 5% 64.9% \u2192 6.97:1 AA */\r\n --cv-faint: #71717a; /* decorative only */\r\n --cv-accent-strong: #34d399; /* emerald-400 \u2014 9.29:1 on #17171c */\r\n --cv-accent-light: #34d399; /* the halo reads brighter on dark */\r\n --cv-accent-soft: #065f46; /* glows must darken on dark, not glare */\r\n --cv-danger-strong: #f87171; /* red-400 \u2014 LIGHTER than --cv-danger, same inversion as accent-strong */\r\n --cv-danger-soft: #7f1d1d;\r\n --cv-info-strong: #38bdf8; /* sky-400 \u2014 same inversion, same reason */\r\n --cv-info-soft: #0c4a6e;\r\n --cv-shadow: 0 1px 2px rgb(0 0 0 / 0.4);\r\n }\r\n}\r\n\r\n/* Pinned dark \u2014 wins regardless of the OS setting. */\r\n:host([data-cv-theme='dark']),\r\n.cv-softphone[data-cv-theme='dark'] {\r\n --cv-bg: #17171c;\r\n --cv-surface: #202027;\r\n --cv-surface-2: #27272a;\r\n --cv-border: #2e2e33;\r\n --cv-border-strong: #404044;\r\n --cv-text: #fafafa;\r\n --cv-muted: #a1a1aa;\r\n --cv-faint: #71717a;\r\n --cv-accent-strong: #34d399;\r\n --cv-accent-light: #34d399;\r\n --cv-accent-soft: #065f46;\r\n --cv-danger-strong: #f87171;\r\n --cv-danger-soft: #7f1d1d;\r\n --cv-info-strong: #38bdf8;\r\n --cv-info-soft: #0c4a6e;\r\n --cv-shadow: 0 1px 2px rgb(0 0 0 / 0.4);\r\n}\r\n\r\n.cv-softphone,\r\n.cv-softphone *,\r\n.cv-softphone *::before,\r\n.cv-softphone *::after {\r\n box-sizing: border-box;\r\n}\r\n\r\n/* \u2500\u2500 Shell \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\r\n.cv-softphone {\r\n display: flex;\r\n flex-direction: column;\r\n\r\n /* FILL the box the host gives us; do not assert one.\r\n This was `width: 340px; height: 520px`, which is a component telling its host how big its\r\n layout is. The CRM's panel is 310 wide, so the third keypad column was simply cut off \u2014 and no\r\n amount of theming could have fixed it, because the widget was not asking. A component in\r\n someone else's layout accepts a size. */\r\n width: 100%;\r\n height: 100%;\r\n\r\n /* \u2026but a dialpad is not a spreadsheet. Given 900px it should not become a 900px dialpad with\r\n 300px keys; it should be itself, centred. Cap and centre rather than stretch. */\r\n max-width: var(--cv-max-width, 420px);\r\n margin-inline: auto;\r\n\r\n /* Adapt to THIS box, not to the viewport. The widget can be 310px wide inside a 1920px screen \u2014\r\n a media query would ask the wrong question and get a confident wrong answer. Container queries\r\n are the only ones that know the truth, and a shadow root is the ideal place for them. */\r\n container-type: inline-size;\r\n container-name: cv;\r\n\r\n background: var(--cv-bg);\r\n overflow: hidden;\r\n /* The containing block for `.cv-transfer-notices` \u2014 it overlays this whole shell, top edge to top\r\n edge, not just `.cv-body`, so it reads as one seamless panel rather than a strip that starts a few\r\n pixels short of the shell's own rounded corner. */\r\n position: relative;\r\n\r\n /* The frame is the HOST's call. The CRM's panel already draws `border rounded-xl shadow-2xl`, so\r\n drawing our own put a card inside a card \u2014 nested chrome, which is always wrong. Defaulting to\r\n a frame still suits a bare drop-in on a blank page; a host that has its own sets these to\r\n `none` / `0` and the double edge disappears. */\r\n border: var(--cv-shell-border, 1px solid var(--cv-border-strong));\r\n border-radius: var(--cv-shell-radius, var(--cv-radius));\r\n box-shadow: var(--cv-shell-shadow, 0 4px 24px rgb(0 0 0 / 0.06));\r\n}\r\n\r\n/* Narrow hosts \u2014 a sidebar, a phone-width column. Reclaim the horizontal padding first: at 260px\r\n the gutters cost more than they give, and a cramped keypad is worse than a tight one. */\r\n@container cv (max-width: 300px) {\r\n .cv-keypad { padding: 0 0.6rem; gap: 6px; }\r\n .cv-dial-prefix { padding: 0.7rem 0.6rem 0; }\r\n .cv-dial-head { padding: 0.3rem 0.6rem 0.6rem; }\r\n .cv-display { font-size: 1.5rem; }\r\n}\r\n\r\n/* Short hosts. The flex chain already lets the keypad absorb what is left, but below this the keys\r\n stop being tappable \u2014 so buy the height back from the chrome instead of from the keys. */\r\n@container cv (max-height: 440px) {\r\n .cv-dial-prefix { padding-top: 0.5rem; }\r\n .cv-dial-head { padding-top: 0.2rem; padding-bottom: 0.4rem; }\r\n .cv-dial-foot { padding-bottom: 0.5rem; }\r\n .cv-controls { gap: 0.4rem; padding-bottom: 0.6rem; }\r\n}\r\n\r\n/* The flex chain that makes the layout work: body flexes, views fill it, only the\r\n keypad/list grows. Without `min-height:0` a grid child refuses to shrink and overflows. */\r\n.cv-body {\r\n flex: 1;\r\n min-height: 0;\r\n display: flex;\r\n flex-direction: column;\r\n}\r\n.cv-view {\r\n flex: 1;\r\n min-height: 0;\r\n display: flex;\r\n flex-direction: column;\r\n /* Containing block for `.cv-detail` below \u2014 an absolute overlay must cover exactly THIS tab's\r\n content, not escape to whatever the next positioned ancestor up the tree happens to be. */\r\n position: relative;\r\n}\r\n\r\n/* \u2500\u2500 Tab bar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\r\n.cv-tabs {\r\n display: flex;\r\n border-top: 1px solid var(--cv-border);\r\n background: var(--cv-bg);\r\n flex-shrink: 0;\r\n}\r\n.cv-tab {\r\n flex: 1;\r\n display: flex;\r\n flex-direction: column;\r\n align-items: center;\r\n gap: 3px;\r\n padding: 9px 0 8px;\r\n background: none;\r\n border: none;\r\n color: var(--cv-muted);\r\n cursor: pointer;\r\n font: inherit;\r\n transition: color 0.15s ease, background 0.15s ease;\r\n}\r\n.cv-tab:hover:not(:disabled) { background: var(--cv-surface); }\r\n.cv-tab[aria-selected='true'] { color: var(--cv-accent-strong); }\r\n.cv-tab:disabled { opacity: 0.35; cursor: not-allowed; }\r\n.cv-tab span { font-size: 10px; font-weight: 600; line-height: 1; letter-spacing: 0.01em; }\r\n\r\n/* \u2500\u2500 Transfer outcome notices (Phase A \u2014 transfer-outcome-relay.md \xA75.5) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\r\n/*\r\n A persistent, dismissable banner OVER `.cv-body`, never a toast and never auto-dismissed \u2014 it carries\r\n an action, and WCAG 2.2.1 requires a time limit to be removable, adjustable or extended.\r\n\r\n Overlaps rather than reserving its own height, the same reasoning `.cv-dial-outcome`'s own comment\r\n already gives for why the widget never lets a line of text resize the keypad under a moving finger \u2014\r\n here that principle cuts the other way: appearing costs nothing but a repaint, not a reflow, so the\r\n widget's overall height stays identical whether a notice is showing or not, exactly like `.cv-detail`\r\n overlaying `.cv-body`'s own list rather than pushing it down. It sits over whatever the current tab\r\n happens to be showing (most often the dialer's country picker and number field \u2014 an acceptable loss:\r\n the agent is not expected to start a fresh manual dial before dealing with a transfer that just failed\r\n to connect), which is also why it outranks `.cv-detail` in `z-index`.\r\n\r\n `[data-has-notices='false']` collapses to zero footprint: the element stays mounted (so its\r\n `role=\"status\"` region exists before the FIRST notice ever arrives \u2014 an aria-live region inserted at\r\n the same moment as its own content is not reliably announced), but an idle widget, the common case,\r\n covers nothing and costs no paint either.\r\n*/\r\n.cv-transfer-notices {\r\n position: absolute;\r\n inset: 0 0 auto 0;\r\n z-index: 3; /* above `.cv-detail`'s 2 \u2014 this is the higher-priority thing on screen */\r\n}\r\n.cv-transfer-notices[data-has-notices='true'] {\r\n display: flex;\r\n flex-direction: column;\r\n gap: 0.5rem;\r\n padding: 0.6rem 0.75rem;\r\n background: var(--cv-bg);\r\n box-shadow: var(--cv-shadow);\r\n animation: cv-transfer-notices-in 200ms var(--cv-ease-out) both;\r\n}\r\n@keyframes cv-transfer-notices-in {\r\n from { opacity: 0; transform: translateY(-6px); }\r\n to { opacity: 1; transform: none; }\r\n}\r\n@media (prefers-reduced-motion: reduce) {\r\n .cv-transfer-notices[data-has-notices='true'] { animation: none; }\r\n}\r\n.cv-transfer-notices-count {\r\n margin: 0;\r\n font: 600 0.72rem var(--cv-font);\r\n color: var(--cv-muted);\r\n text-transform: uppercase;\r\n letter-spacing: 0.04em;\r\n}\r\n.cv-transfer-notice {\r\n display: flex;\r\n align-items: flex-start;\r\n gap: 0.6rem;\r\n padding: 0.65rem 0.7rem;\r\n border-radius: var(--cv-radius-key);\r\n border: 1px solid var(--cv-danger-soft);\r\n background: var(--cv-surface);\r\n}\r\n/* Same shape as `.cv-gate-mark`, smaller \u2014 one visual language for \"here is a status\", not two. */\r\n.cv-transfer-notice-mark {\r\n flex-shrink: 0;\r\n display: grid;\r\n place-items: center;\r\n width: 30px;\r\n height: 30px;\r\n border-radius: 999px;\r\n background: var(--cv-danger-soft);\r\n color: var(--cv-danger);\r\n}\r\n.cv-transfer-notice-body { flex: 1; min-width: 0; }\r\n/*\r\n Title shares its row with only the dismiss button (24px) \u2014 not the whole card's width, and not the\r\n \"Llamar de vuelta\" button, which gets its own row below (`.cv-transfer-notice-footer`). The original\r\n layout put mark + title + subtitle + a full button all in one flex row, leaving the title as little as\r\n ~80px to wrap into at the real ~340px embed width \u2014 nearly every word landed on its own line.\r\n `align-items: flex-start` keeps the dismiss pinned to the first line even when the title wraps to two.\r\n*/\r\n.cv-transfer-notice-header { display: flex; align-items: flex-start; gap: 0.4rem; }\r\n.cv-transfer-notice-title {\r\n flex: 1;\r\n min-width: 0;\r\n margin: 0;\r\n font: 600 0.82rem var(--cv-font);\r\n color: var(--cv-text);\r\n line-height: 1.35;\r\n}\r\n.cv-transfer-notice-subtitle {\r\n margin: 0.15rem 0 0;\r\n font-size: 0.78rem;\r\n color: var(--cv-muted);\r\n line-height: 1.35;\r\n}\r\n/* Age and the callback button share a row too, same reasoning as the header \u2014 a short fixed-width\r\n timestamp beside a content-sized button, neither one fighting the title for space above. */\r\n.cv-transfer-notice-footer {\r\n display: flex;\r\n align-items: center;\r\n justify-content: space-between;\r\n gap: 0.5rem;\r\n margin-top: 0.35rem;\r\n}\r\n.cv-transfer-notice-age { margin: 0; font-size: 0.7rem; color: var(--cv-faint); flex-shrink: 0; }\r\n/*\r\n `.cv-btn.cv-transfer-notice-callback` \u2014 TWO classes, not one. Equal-specificity selectors resolve by\r\n source order, and this rule used to lose its `padding`/`font-size` to the shared `.cv-btn` rule below\r\n simply because that one appears later in the file; the button rendered at full default CTA size\r\n instead of this smaller, secondary one, which is what actually forced the title into that ~80px\r\n column above. The extra class is what makes this override win regardless of source order.\r\n*/\r\n.cv-btn.cv-transfer-notice-callback {\r\n flex-shrink: 0;\r\n padding: 0.35rem 0.6rem;\r\n font-size: 0.75rem;\r\n display: inline-flex;\r\n align-items: center;\r\n gap: 0.3rem;\r\n white-space: nowrap;\r\n}\r\n/* Same round icon-only shape as `.cv-search-clear` \u2014 one dismiss affordance, not a new one. */\r\n.cv-transfer-notice-dismiss {\r\n flex-shrink: 0;\r\n width: 24px;\r\n height: 24px;\r\n display: inline-flex;\r\n align-items: center;\r\n justify-content: center;\r\n border: none;\r\n border-radius: 999px;\r\n background: none;\r\n color: var(--cv-muted);\r\n cursor: pointer;\r\n transition: color 0.12s ease, background 0.12s ease, transform 0.1s var(--cv-ease-out);\r\n}\r\n.cv-transfer-notice-dismiss:hover { color: var(--cv-text); background: var(--cv-surface-2); }\r\n.cv-transfer-notice-dismiss:active { transform: scale(0.88); }\r\n\r\n/* \u2500\u2500 Buttons \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\r\n.cv-btn {\r\n font: 600 0.875rem var(--cv-font);\r\n cursor: pointer;\r\n padding: 0.5rem 0.9rem;\r\n border-radius: 10px;\r\n border: 1px solid var(--cv-border-strong);\r\n background: var(--cv-bg);\r\n color: var(--cv-text);\r\n transition: background 0.15s ease, transform 0.06s ease;\r\n}\r\n.cv-btn:hover:not(:disabled) { background: var(--cv-surface); }\r\n.cv-btn:active:not(:disabled) { transform: scale(0.97); }\r\n.cv-btn:disabled { opacity: 0.45; cursor: not-allowed; }\r\n.cv-btn--accent {\r\n background: var(--cv-accent);\r\n border-color: var(--cv-accent);\r\n color: var(--cv-accent-contrast);\r\n}\r\n.cv-btn--accent:hover:not(:disabled) { background: var(--cv-accent-strong); }\r\n\r\n/* Round action buttons (answer / hangup / call) */\r\n.cv-round {\r\n width: 64px;\r\n height: 64px;\r\n border-radius: 999px;\r\n border: none;\r\n cursor: pointer;\r\n display: inline-flex;\r\n align-items: center;\r\n justify-content: center;\r\n color: #fff;\r\n transition: background 0.15s ease, transform 0.08s ease, box-shadow 0.15s ease;\r\n}\r\n.cv-round:active:not(:disabled) { transform: scale(0.94); }\r\n.cv-round--answer {\r\n background: var(--cv-accent);\r\n box-shadow: 0 8px 20px -4px var(--cv-accent-soft);\r\n}\r\n.cv-round--answer:hover:not(:disabled) { background: var(--cv-accent-strong); }\r\n.cv-round--answer:disabled {\r\n background: var(--cv-surface-2);\r\n color: var(--cv-faint);\r\n box-shadow: none;\r\n cursor: not-allowed;\r\n}\r\n.cv-round--hangup {\r\n background: var(--cv-danger);\r\n box-shadow: 0 8px 20px -4px var(--cv-danger-soft);\r\n}\r\n.cv-round--hangup:hover:not(:disabled) { filter: brightness(0.94); }\r\n\r\n/* \u2500\u2500 Dialpad \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\r\n/* The prefix chip, on its own row above the number.\r\n Sharing a line with the number starves it: chip + erase button eat the middle, and a formatted\r\n international number needs every pixel. The number is the content; the prefix is a setting. */\r\n.cv-dial-prefix {\r\n flex-shrink: 0;\r\n display: flex;\r\n justify-content: center;\r\n padding: 1rem 1rem 0;\r\n}\r\n.cv-dial-head {\r\n flex-shrink: 0;\r\n display: flex;\r\n align-items: center;\r\n padding: 0.35rem 1rem 0.75rem;\r\n gap: 0.25rem;\r\n}\r\n/*\r\n * The outcome of the last attempt, under the number.\r\n *\r\n * ALWAYS in the layout, visible or not. The keypad is `flex: 1` and absorbs whatever height is left,\r\n * so a line that appears and disappears would resize every key and shift the whole grid under the\r\n * user's finger \u2014 mid-dial, which is exactly when this text arrives. Reserving the row costs one line\r\n * of height and buys a keypad that never moves.\r\n */\r\n.cv-dial-outcome {\r\n flex-shrink: 0;\r\n margin: 0;\r\n min-height: 1.15rem;\r\n padding: 0 1rem;\r\n text-align: center;\r\n font-size: 0.78rem;\r\n line-height: 1.15rem;\r\n color: var(--cv-muted);\r\n /*\r\n One line, always. `error-text.test.ts` caps every outcome at 48 characters so nothing should ever\r\n reach this \u2014 but a cap depends on whoever writes the next string remembering it, and the failure\r\n it prevents is not cosmetic: the keypad below is `flex: 1`, so a second line resizes all twelve\r\n keys and shifts the grid under a finger that is already moving. Truncating is visibly wrong and\r\n harmless; reflowing the dialpad mid-dial is invisibly wrong and makes the user misdial.\r\n */\r\n white-space: nowrap;\r\n overflow: hidden;\r\n text-overflow: ellipsis;\r\n /* Muted, not danger: the phone still works. A red line here would read as a broken phone. */\r\n opacity: 0;\r\n transition: opacity 150ms ease-out;\r\n}\r\n.cv-dial-outcome[data-shown='true'] {\r\n opacity: 1;\r\n}\r\n@media (prefers-reduced-motion: reduce) {\r\n .cv-dial-outcome {\r\n transition: none;\r\n }\r\n}\r\n\r\n.cv-display {\r\n flex: 1;\r\n min-width: 0;\r\n font: 600 1.75rem var(--cv-font);\r\n font-variant-numeric: tabular-nums;\r\n letter-spacing: -0.02em;\r\n text-align: center;\r\n color: var(--cv-text);\r\n border: none;\r\n background: none;\r\n padding: 0;\r\n caret-color: var(--cv-accent);\r\n}\r\n.cv-display::placeholder { color: var(--cv-faint); font-weight: 400; }\r\n.cv-display:focus { outline: none; }\r\n\r\n/* The spacer/erase pair keeps the number optically centred. */\r\n.cv-dial-side {\r\n width: 36px;\r\n height: 36px;\r\n flex-shrink: 0;\r\n display: inline-flex;\r\n align-items: center;\r\n justify-content: center;\r\n border: none;\r\n background: none;\r\n color: var(--cv-muted);\r\n cursor: pointer;\r\n border-radius: 999px;\r\n transition: color 0.15s ease, opacity 0.15s ease, transform 0.06s ease;\r\n}\r\n.cv-dial-side:hover { color: var(--cv-text); }\r\n.cv-dial-side:active { transform: scale(0.9); }\r\n.cv-dial-side[data-hidden='true'] { opacity: 0; pointer-events: none; }\r\n\r\n/* THE layout fix: the keypad flexes to fill, and its rows share the space equally.\r\n Keys must NOT use aspect-ratio \u2014 that overflows the shell and shoves the call button\r\n under the tab bar. */\r\n.cv-keypad {\r\n flex: 1;\r\n min-height: 0;\r\n display: grid;\r\n grid-template-columns: repeat(3, 1fr);\r\n grid-template-rows: repeat(4, 1fr);\r\n gap: 8px;\r\n padding: 0 1rem;\r\n}\r\n.cv-key {\r\n display: flex;\r\n flex-direction: column;\r\n align-items: center;\r\n justify-content: center;\r\n gap: 1px;\r\n border-radius: var(--cv-radius-key);\r\n border: 1px solid var(--cv-border);\r\n background: var(--cv-bg);\r\n box-shadow: var(--cv-shadow);\r\n color: var(--cv-text);\r\n cursor: pointer;\r\n user-select: none;\r\n transition: background 0.12s ease, transform 0.06s ease;\r\n}\r\n.cv-key:hover { background: var(--cv-surface); }\r\n.cv-key:active { transform: scale(0.95); background: var(--cv-surface-2); }\r\n.cv-key b { font: 500 1.25rem/1 var(--cv-font); }\r\n.cv-key small {\r\n font-size: 9px;\r\n font-weight: 600;\r\n letter-spacing: 0.14em;\r\n /* Decorative: the letters carry no information the digit doesn't. `faint`, not `muted`. */\r\n color: var(--cv-faint);\r\n line-height: 1;\r\n}\r\n\r\n.cv-dial-foot {\r\n flex-shrink: 0;\r\n display: flex;\r\n justify-content: center;\r\n padding: 0.75rem 0 1rem;\r\n}\r\n\r\n/* \u2500\u2500 Active call \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\r\n.cv-call {\r\n flex: 1;\r\n min-height: 0;\r\n display: flex;\r\n flex-direction: column;\r\n align-items: center;\r\n justify-content: center;\r\n gap: 0.15rem;\r\n text-align: center;\r\n padding: 1rem;\r\n /*\r\n A REAL bug, found live (plan T7): the transfer picker's agent list can make this box's content\r\n taller than the space `flex: 1` actually allocates it \u2014 the avatar/name/timer/status ABOVE the\r\n picker are still shown while transferring, unlike `.cv-call-devices-inline`'s own \"swap the\r\n content\" fix for the pre-connect devices screen. Without this, the overflow painted straight past\r\n `.cv-call`'s own shrunk box and onto `.cv-controls` below it \u2014 not scroll, actual overlap, controls\r\n rendered ON TOP of list rows. `overflow-y: auto` keeps any excess inside THIS box, scrollable,\r\n exactly like `.cv-presence-list`'s own internal scroll already does for the list itself.\r\n */\r\n overflow-y: auto;\r\n}\r\n\r\n.cv-avatar-wrap { position: relative; margin-bottom: 0.85rem; }\r\n.cv-avatar {\r\n position: relative;\r\n width: 68px;\r\n height: 68px;\r\n border-radius: 999px;\r\n /* accent \u2192 accent-strong (emerald 600\u2192700): white initials clear 3.77:1 at EVERY stop. Starting\r\n the ramp at emerald-500 would look punchier but drop the light end to 2.54:1. */\r\n background: linear-gradient(135deg, var(--cv-accent) 0%, var(--cv-accent-strong) 100%);\r\n display: flex;\r\n align-items: center;\r\n justify-content: center;\r\n color: #fff;\r\n font: 700 1.3rem var(--cv-font);\r\n letter-spacing: -0.01em;\r\n box-shadow: 0 8px 20px -6px var(--cv-accent-soft);\r\n}\r\n/* A soft halo while ringing \u2014 the one moment worth animating. */\r\n.cv-avatar-ping {\r\n position: absolute;\r\n inset: 0;\r\n border-radius: 999px;\r\n /* Decorative \u2014 nothing sits on it, so it uses the vivid emerald. */\r\n background: var(--cv-accent-light);\r\n opacity: 0.16;\r\n animation: cv-ping 1.6s cubic-bezier(0, 0, 0.2, 1) infinite;\r\n pointer-events: none;\r\n}\r\n@keyframes cv-ping {\r\n 0% { transform: scale(1); opacity: 0.22; }\r\n 80%, 100% { transform: scale(1.7); opacity: 0; }\r\n}\r\n.cv-avatar-dot {\r\n position: absolute;\r\n bottom: 1px;\r\n right: 1px;\r\n width: 14px;\r\n height: 14px;\r\n border-radius: 999px;\r\n background: var(--cv-accent);\r\n border: 2px solid var(--cv-bg);\r\n}\r\n\r\n.cv-call-name { font-size: 1rem; font-weight: 600; line-height: 1.2; }\r\n.cv-call-number { font-size: 0.75rem; color: var(--cv-muted); margin-top: 2px; }\r\n\r\n.cv-timer {\r\n display: flex;\r\n align-items: baseline;\r\n justify-content: center;\r\n gap: 1px;\r\n margin-top: 0.75rem;\r\n color: var(--cv-accent-strong);\r\n}\r\n.cv-timer b {\r\n font: 700 2.25rem/1 var(--cv-font);\r\n font-variant-numeric: tabular-nums;\r\n letter-spacing: -0.03em;\r\n}\r\n.cv-timer i {\r\n font: 300 1.75rem/1 var(--cv-font);\r\n font-style: normal;\r\n color: var(--cv-accent);\r\n animation: cv-blink 1.6s ease-in-out infinite;\r\n margin: 0 1px;\r\n}\r\n@keyframes cv-blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }\r\n\r\n.cv-call-status {\r\n margin-top: 0.6rem;\r\n font-size: 0.78rem;\r\n color: var(--cv-muted);\r\n letter-spacing: 0.04em;\r\n}\r\n.cv-badge-hold { color: var(--cv-hold); font-weight: 600; }\r\n\r\n/* \u2500\u2500 Device check (ringing / dialing / in-call) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n Before a call connects there is only ONE other control on screen (Colgar, or\r\n Rechazar/Contestar) \u2014 putting Dispositivos in that same neighbourhood, even\r\n spaced out, keeps it a mis-tap away from hanging up or answering. The entry\r\n chip lives beside the call status instead, removing it from that neighbourhood\r\n rather than just adding distance within it \u2014 same resting-pill SHAPE as `.cv-cp`\r\n (CountryPicker), reused rather than invented fresh.\r\n\r\n Opening it does NOT pop a panel into that same tight space, though \u2014 this\r\n SDK's real production embed (a 310\xD7520 host panel with its own header eating\r\n further into that) leaves too little free room around a trigger sitting in\r\n vertically-centred content for a floating popover to open into without either\r\n overlapping Colgar/Rechazar/Contestar or clamping itself down to something\r\n cramped and scrolly for just two rows. `.cv-call-devices-inline` REPLACES the\r\n avatar/name/status block in `.cv-call` instead (see that JSX's own comment) \u2014\r\n an inline swap, not an overlay, so it gets the full box, not a leftover sliver.\r\n\r\n Once connected it is the SAME chip and the SAME swap. Dispositivos used to be a fifth control in\r\n the call row (with a floating popover), which is what forced that row down to 40px buttons and\r\n 9px labels in a 310px host. A set-once setting does not need a slot among the four things you do\r\n mid-call (mute, hold, transfer, keypad), and the control row stays live under the swap.\r\n \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\r\n.cv-call-devices-chip {\r\n display: inline-flex;\r\n align-items: center;\r\n gap: 0.35rem;\r\n height: 26px;\r\n padding: 0 0.65rem;\r\n border: 1px solid var(--cv-border-strong);\r\n border-radius: 999px;\r\n background: var(--cv-bg);\r\n color: var(--cv-muted);\r\n font: 600 0.7rem var(--cv-font);\r\n cursor: pointer;\r\n transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;\r\n}\r\n.cv-call-devices-chip:hover { background: var(--cv-surface); }\r\n.cv-call-devices-chip:active { transform: scale(0.95); }\r\n\r\n/*\r\n The entry-point row \u2014 ONE chip today (Dispositivos), up to two once a host configures\r\n `incomingCallAction` (that one first: who is calling outranks a phone-utility setting). `width:\r\n 100%` + `flex-wrap` on purpose: a host's action label is arbitrary text this component cannot\r\n assume is short, so two chips sit side by side when they fit and wrap onto their own centred line\r\n when they do not, rather than overflowing `.cv-call`'s fixed width.\r\n*/\r\n.cv-call-chips {\r\n width: 100%;\r\n display: flex;\r\n flex-wrap: wrap;\r\n justify-content: center;\r\n gap: 0.5rem;\r\n margin-top: 0.65rem;\r\n}\r\n\r\n.cv-call-devices-inline {\r\n width: 100%;\r\n display: flex;\r\n flex-direction: column;\r\n gap: 0.9rem;\r\n /* `.cv-call` centres and centre-aligns its normal (avatar) content \u2014 rows read left-aligned\r\n everywhere else in the widget (Ajustes, the in-call popover), so this opts back out of both. */\r\n text-align: left;\r\n animation: cv-call-devices-in 0.18s cubic-bezier(0.2, 0, 0.2, 1);\r\n}\r\n@keyframes cv-call-devices-in {\r\n from { opacity: 0; transform: translateY(6px); }\r\n to { opacity: 1; transform: none; }\r\n}\r\n@media (prefers-reduced-motion: reduce) {\r\n .cv-call-devices-inline { animation: none; }\r\n}\r\n.cv-call-devices-header { display: flex; align-items: center; gap: 0.5rem; }\r\n.cv-call-devices-title { font-size: 0.95rem; font-weight: 600; color: var(--cv-text); }\r\n/* `--cv-muted` on `--cv-bg` \u2014 the same already-verified pairing `.cv-settings-note` uses\r\n (4.83:1 light / 6.97:1 dark), not a new one. */\r\n.cv-call-devices-context {\r\n margin: 0;\r\n font-size: 0.78rem;\r\n color: var(--cv-muted);\r\n}\r\n\r\n/* \u2500\u2500 Call controls \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n TWO TIERS. Hang up is the one action that must never be hunted for, so it sits\r\n alone, centred, and largest \u2014 the anchor. The secondaries flank above it. They\r\n used to share a single row, where hang up was just the last button and carried\r\n the same weight as \"transfer\"; mid-call, that is the wrong hierarchy.\r\n \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\r\n.cv-controls {\r\n flex-shrink: 0;\r\n display: flex;\r\n flex-direction: column;\r\n align-items: center;\r\n gap: 0.9rem;\r\n padding: 0.35rem 1rem 1.1rem;\r\n /* The containing block for `.cv-keypad-popover` \u2014 see that rule's own comment for why it is\r\n anchored here rather than to the trigger button. */\r\n position: relative;\r\n}\r\n/* FOUR controls, sized so they never need to shrink to fit. This row held five (Dispositivos too) and\r\n the only way to fit five in the CRM's 310px host was a 40px button with a 9px label and ~6px gaps \u2014\r\n below the 44/48px a thumb needs, and below legible for a word read mid-call. The default 48px size\r\n below was never actually seen: it only applied above a 360px host, which no real embed is.\r\n Dispositivos is the `.cv-call-chips` chip up by the call info instead (the same entry point the\r\n ringing and dialing screens already use). 4 \xD7 52 + 3 \xD7 20 = 268px, inside 310 - 2 \xD7 16 padding. */\r\n.cv-controls-row {\r\n display: flex;\r\n justify-content: center;\r\n gap: 1.25rem;\r\n}\r\n.cv-controls-primary {\r\n display: flex;\r\n justify-content: center;\r\n gap: 2.75rem;\r\n}\r\n/* Button + its word. The column keeps the label from widening the hit target. */\r\n.cv-ctrl-item {\r\n display: flex;\r\n flex-direction: column;\r\n align-items: center;\r\n gap: 0.3rem;\r\n}\r\n/* 10px, muted: present when you look for it, silent when you don't. An icon-only\r\n control is a quiz, and mid-call is the worst time to sit one. */\r\n.cv-ctrl-label {\r\n font-size: 0.625rem;\r\n line-height: 1;\r\n color: var(--cv-muted);\r\n white-space: nowrap;\r\n user-select: none;\r\n}\r\n/* The four secondaries' words get a notch more than Colgar / Rechazar / Contestar's 10px \u2014 they are\r\n the ones read mid-call, and the five-up row used to shrink them to 9px (8px in a sidebar). */\r\n.cv-controls-row .cv-ctrl-label { font-size: 0.6875rem; }\r\n.cv-ctrl {\r\n width: 52px;\r\n height: 52px;\r\n border-radius: 999px;\r\n border: 1px solid var(--cv-border-strong);\r\n background: var(--cv-bg);\r\n color: var(--cv-text);\r\n cursor: pointer;\r\n display: inline-flex;\r\n align-items: center;\r\n justify-content: center;\r\n transition: background 0.15s ease, color 0.15s ease, transform 0.06s ease;\r\n}\r\n.cv-ctrl:hover { background: var(--cv-surface); }\r\n.cv-ctrl:active { transform: scale(0.94); }\r\n.cv-ctrl:disabled {\r\n opacity: 0.4;\r\n cursor: not-allowed;\r\n pointer-events: none;\r\n}\r\n\r\n/* The ON states say what they MEAN, in the widget's own tokens: muted is danger,\r\n held is hold, transfer-open is accent. Colour is the state, not decoration. */\r\n.cv-ctrl[data-tone='danger'] {\r\n background: var(--cv-danger);\r\n border-color: var(--cv-danger);\r\n color: #fff;\r\n}\r\n.cv-ctrl[data-tone='hold'] {\r\n background: var(--cv-hold);\r\n border-color: var(--cv-hold);\r\n color: #fff;\r\n}\r\n.cv-ctrl[data-tone='accent'] {\r\n background: var(--cv-accent-soft);\r\n border-color: transparent;\r\n color: var(--cv-accent-strong);\r\n}\r\n.cv-ctrl[data-tone]:hover { filter: brightness(0.95); }\r\n\r\n/* Muted is the one state worth animating: it is invisible, it is easy to forget,\r\n and the cost of forgetting is talking to nobody. Same halo the avatar rings\r\n with \u2014 one language, not two. */\r\n.cv-ctrl[data-tone='danger'] {\r\n position: relative;\r\n}\r\n.cv-ctrl[data-tone='danger']::after {\r\n content: '';\r\n position: absolute;\r\n inset: 0;\r\n border-radius: 999px;\r\n background: var(--cv-danger);\r\n animation: cv-ping 1.6s cubic-bezier(0, 0, 0.2, 1) infinite;\r\n pointer-events: none;\r\n z-index: -1;\r\n}\r\n@media (prefers-reduced-motion: reduce) {\r\n .cv-ctrl[data-tone='danger']::after { animation: none; opacity: 0; }\r\n}\r\n.cv-ctrl[aria-pressed='true'] {\r\n background: var(--cv-text);\r\n color: var(--cv-bg);\r\n border-color: var(--cv-text);\r\n}\r\n\r\n/*\r\n Sidebar-narrow (this file's own \u2264300px tier, up in \"Narrow hosts\"). The four 52px controls with\r\n 20px gaps need 268px, which a 260px host cannot give \u2014 so this tier steps down to 44px with 12px\r\n gaps (4 \xD7 44 + 3 \xD7 12 = 212px) and reclaims `.cv-controls`'s own horizontal padding, the first\r\n lever that block's own comment names. 44px is still the Apple HIG minimum tap target; the five-up\r\n row this replaced went to 36px here.\r\n\r\n Placed AFTER `.cv-controls`/`.cv-controls-row` above rather than up with this file's other\r\n `@container` blocks near the top: `gap` and `padding-left`/`-right` here have the same specificity as\r\n those base rules' own shorthand, so \u2014 unlike a compound selector such as `.cv-controls-row .cv-ctrl`,\r\n which wins on specificity regardless of position \u2014 source order is what decides, and a narrower-width\r\n override declared BEFORE the base rule it means to narrow loses to it. Confirmed live: moved up into\r\n the \"Narrow hosts\" section, the `.cv-ctrl` width and label font-size reductions took effect (compound\r\n selectors) but `gap` and `.cv-controls`'s padding silently did not (plain selectors, later base rule won).\r\n*/\r\n@container cv (max-width: 300px) {\r\n .cv-controls { padding-left: 0.5rem; padding-right: 0.5rem; }\r\n .cv-controls-row { gap: 0.75rem; }\r\n .cv-controls-row .cv-ctrl { width: 44px; height: 44px; }\r\n .cv-controls-row .cv-ctrl-label { font-size: 0.625rem; }\r\n}\r\n\r\n/*\r\n The attended-transfer target-entry panel (`docs/specs/transfer-availability.md` \xA75): an optional\r\n outcome line, then a row of whatever's actionable (input+button today). Once a consult exists,\r\n `ConsultScreen` takes over the whole screen instead \u2014 this panel is ONLY ever the pre-consult /\r\n retry-after-failure state now; it used to also cover the consulting/answered phases, which is why\r\n its rule is a column container built for more than one child even though there's only ever one row\r\n here today. (History: was two near-duplicate rules \u2014 `.cv-transfer` as a bare ROW,\r\n `.cv-transfer-consult` as a column \u2014 until the row version turned out to be a real,\r\n reproduced-in-browser bug: the optional `.cv-dial-outcome` message rendered as a THIRD item in the\r\n same horizontal row as the input and button, squeezing the input to its content-minimum. Merged\r\n rather than patched.)\r\n*/\r\n.cv-transfer {\r\n display: flex;\r\n flex-direction: column;\r\n gap: 0.3rem;\r\n width: 100%;\r\n padding: 0.2rem 0.25rem 0;\r\n /* `.cv-call` centres and centre-aligns its normal (avatar) content \u2014 same reasoning\r\n `.cv-call-devices-inline` already opts out of for the OTHER inline swap in this box (its own\r\n comment): rows read left-aligned everywhere else in the widget, and a centred context line\r\n sitting directly above a left-aligned input/toggle/list read as two layouts stacked on top of\r\n each other, found live (2026-09-29). */\r\n text-align: left;\r\n /* Found live (2026-09-29): `.cv-call`'s avatar/name/timer block used to sit ABOVE this,\r\n unconditionally, so this panel only ever got whatever was left over \u2014 see the JSX call site's own\r\n comment. Now that this IS the whole of `.cv-call`'s content while a transfer is in progress\r\n (a swap, not an addition), it should CLAIM `.cv-call`'s available height, not just sit in it \u2014\r\n `flex: 1` does that; `min-height: 0` is what lets a flex child actually shrink to fit instead of\r\n forcing its parent to grow past it (the same reason `.cv-call`/`.cv-view`/`.cv-body` all set it,\r\n see their own comments). This is what lets `.cv-presence-list` below flex-grow into real room\r\n instead of needing its own hand-tuned `max-height` guess. */\r\n flex: 1;\r\n min-height: 0;\r\n /* Same entrance `.cv-call-devices-inline` already uses for the analogous inline swap (its own\r\n comment) \u2014 reused, not a second animation invented for this widget's second inline-swap view. */\r\n animation: cv-call-devices-in 0.18s cubic-bezier(0.2, 0, 0.2, 1);\r\n}\r\n@media (prefers-reduced-motion: reduce) {\r\n .cv-transfer { animation: none; }\r\n}\r\n/* The compact \"who am I still on the phone with\" line while the picker is open \u2014 reuses\r\n `.cv-call-devices-inline`'s own two classes verbatim (`.cv-call-devices-title` for the name,\r\n `.cv-call-devices-context` for the number/timer line) rather than inventing a second \"quiet header\"\r\n style for the same widget; only the tiny stacking gap between them is new.\r\n\r\n Found live (2026-09-29): `.cv-transfer`'s own `gap` alone gave every section \u2014 context, input row,\r\n mode toggle, results list \u2014 the exact same spacing, so nothing read as its own group; the eye had\r\n no cue for where \"who I'm on the phone with\" ends and \"pick a transfer target\" begins. This extra\r\n `margin-bottom`, on top of the base `gap`, is that cue \u2014 the toggle stays close to the list it\r\n controls (unchanged), only the boundary above the interactive controls opened up. */\r\n.cv-transfer-context {\r\n display: flex;\r\n flex-direction: column;\r\n gap: 2px;\r\n flex-shrink: 0;\r\n margin-bottom: 0.35rem;\r\n}\r\n/* Plan T3: the mode toggle reuses `.cv-segmented` (HistoryTab's own filter control) as-is, but its\r\n `margin-bottom` (needed THERE, where the surrounding layout has no gap of its own) is redundant\r\n here \u2014 `.cv-transfer`'s own `gap` above already spaces every child, so the two would stack. Scoped\r\n to this context only; HistoryTab's own copy is untouched. */\r\n.cv-transfer .cv-segmented { margin-bottom: 0; flex-shrink: 0; }\r\n.cv-transfer .cv-presence-legend { flex-shrink: 0; }\r\n/* Same treatment as `.cv-call-status` (the avatar's own \"Llamando\u2026\"/\"En espera\" line) \u2014 one muted\r\n status-line vocabulary for the whole call screen, not a second one invented for this panel. */\r\n.cv-transfer-status {\r\n margin: 0;\r\n font-size: 0.78rem;\r\n color: var(--cv-muted);\r\n letter-spacing: 0.04em;\r\n}\r\n/* The target-entry/retry row: an input plus one button. */\r\n.cv-transfer-row {\r\n display: flex;\r\n gap: 0.5rem;\r\n}\r\n/*\r\n Flex items default to `min-width: auto`, which floors them at their CONTENT size rather than letting\r\n them shrink or grow to fill the row \u2014 `.cv-search`'s own `width: 100%` never actually took effect\r\n here, because `.cv-btn`'s text (\"Llamar\") set an unshrinkable floor and the input, having no\r\n `flex-grow`, never reclaimed the space left over. `flex: 1` + `min-width: 0` is what actually makes\r\n an input-plus-button row behave like one; `flex: none` keeps the button at its natural size.\r\n*/\r\n.cv-transfer-row .cv-search {\r\n flex: 1;\r\n min-width: 0;\r\n}\r\n/* `max-width` + ellipsis: the label now includes whatever was typed (\"Llamar a {typed}\", spec \xA74.5)\r\n and `truncateForButton` already bounds that in JS \u2014 this is the CSS backstop, so a value that\r\n slips past it (a very wide character set, a future copy change) still can't push `.cv-search` down\r\n to an unusable sliver the way the pre-truncation button did before this was noticed. */\r\n.cv-transfer-row .cv-btn {\r\n flex: none;\r\n max-width: 60%;\r\n overflow: hidden;\r\n text-overflow: ellipsis;\r\n white-space: nowrap;\r\n}\r\n/* The consult-answered row: one or two buttons, no input. */\r\n.cv-transfer-actions {\r\n display: flex;\r\n gap: 0.5rem;\r\n}\r\n.cv-transfer-actions .cv-btn {\r\n flex: 1;\r\n}\r\n/* The ringing phase has only \"Cancelar\" \u2014 a secondary, low-stakes action. Letting the row's own\r\n `flex: 1` stretch a LONE button edge to edge reads as oversized for what it is, and nothing else in\r\n this widget sizes a single button to the full row; every other `.cv-btn` sizes to its content. */\r\n.cv-transfer-actions .cv-btn:only-child {\r\n flex: none;\r\n}\r\n\r\n/*\r\n The agent picker (spec \xA74.5, plan T7) \u2014 an inline, always-visible filtered list under the\r\n input+button row, not a click-to-open popover like `.cv-person-filter-pop`: the whole point is that\r\n typing already filters it, so hiding it behind a second interaction would undo that.\r\n\r\n Redesigned live (2026-09-29): T7 (72px) and T3 (30px) both hand-tuned a `max-height` in PIXELS\r\n against whatever else happened to be on screen at the time, and both were broken by the very next\r\n addition above the list (T3's own toggle broke T7's number the same way this would keep breaking on\r\n the next change) \u2014 down to \"barely proves the list is scrollable\" at 30px, which is what the user\r\n was actually seeing: \"users cannot almost see any of the listed users.\" The real fix wasn't a bigger\r\n magic number, it was `.cv-transfer`'s own move to `flex: 1` (see its comment) freeing real height\r\n from the block that used to sit above this whole panel \u2014 `.cv-presence` and this list now flex-grow\r\n into whatever's ACTUALLY left after the context line, input row, toggle and legend take their own\r\n natural size, on whatever real height the host panel gives `.cv-call` (this SDK's fixed ~310\xD7520\r\n embed today, but also whatever a different host gives it tomorrow \u2014 no more re-tuning a constant\r\n every time something above the list changes). `.cv-call`'s own `overflow-y: auto` (its own comment)\r\n is still the last-resort net if content ever genuinely doesn't fit; this just means that net should\r\n now rarely, if ever, have to catch anything for a normal agent list.\r\n*/\r\n.cv-presence {\r\n display: flex;\r\n flex-direction: column;\r\n gap: 0.35rem;\r\n flex: 1;\r\n min-height: 0;\r\n}\r\n.cv-presence-list {\r\n display: flex;\r\n flex-direction: column;\r\n gap: 2px;\r\n flex: 1;\r\n min-height: 0;\r\n overflow-y: auto;\r\n padding: 0.25rem;\r\n border: 1px solid var(--cv-border);\r\n border-radius: var(--cv-radius-key);\r\n background: var(--cv-surface);\r\n}\r\n.cv-presence-empty {\r\n margin: 0;\r\n padding: 0.5rem;\r\n text-align: center;\r\n font-size: 0.78rem;\r\n color: var(--cv-muted);\r\n}\r\n.cv-presence-item {\r\n width: 100%;\r\n display: flex;\r\n align-items: center;\r\n gap: 0.5rem;\r\n padding: 0.4rem 0.5rem;\r\n border: none;\r\n border-radius: calc(var(--cv-radius) - 4px);\r\n background: none;\r\n color: var(--cv-text);\r\n font: 400 0.8rem var(--cv-font);\r\n text-align: left;\r\n cursor: pointer;\r\n}\r\n.cv-presence-item:hover:not(:disabled) { background: var(--cv-surface-2); }\r\n/* En llamada rows are a hard block, not just a hint (CallScreen.tsx's own comment on the row has why)\r\n \u2014 same `:disabled` treatment `.cv-btn`/`.cv-tab` already use elsewhere in this file, not a new pattern. */\r\n.cv-presence-item:disabled { opacity: 0.45; cursor: not-allowed; }\r\n.cv-presence-dot {\r\n flex-shrink: 0;\r\n width: 8px;\r\n height: 8px;\r\n border-radius: 999px;\r\n}\r\n/* Reuses existing intent tokens rather than inventing new ones \u2014 `--cv-accent` already means\r\n \"good, reachable\" (the answer button), `--cv-hold` already means \"occupied elsewhere\" (the hold\r\n toggle), and `--cv-faint` is this file's own DECORATIVE-only tier (see the `--cv-*` legend near the\r\n top) \u2014 exactly what \"no signal for this person\" is, no signal being the whole point of that tier. */\r\n.cv-presence-dot[data-state='disponible'] { background: var(--cv-accent); }\r\n.cv-presence-dot[data-state='en_llamada'] { background: var(--cv-hold); }\r\n.cv-presence-dot[data-state='desconectado'] { background: var(--cv-faint); }\r\n.cv-presence-name {\r\n flex: 1;\r\n min-width: 0;\r\n overflow: hidden;\r\n text-overflow: ellipsis;\r\n white-space: nowrap;\r\n}\r\n.cv-presence-meta {\r\n flex-shrink: 0;\r\n font-size: 0.72rem;\r\n color: var(--cv-muted);\r\n}\r\n/* Spec \xA74.6: Desconectado is a HINT, never \"can't transfer\" \u2014 this is what tells the agent so, right\r\n under the list it describes. `--cv-muted`, NOT `--cv-faint`: this text is informational (the reader\r\n needs it to not misread the state), and `--cv-faint` is reserved for decorative-only content that\r\n fails AA on purpose \u2014 using it here would repeat the exact tier mistake this file's own `--cv-*`\r\n comment block warns against. */\r\n.cv-presence-legend {\r\n margin: 0;\r\n font-size: 0.7rem;\r\n color: var(--cv-muted);\r\n line-height: 1.35;\r\n}\r\n\r\n/*\r\n `ConsultScreen`'s \"someone is still waiting\" banner (`docs/specs/transfer-availability.md` \xA75, the\r\n cross-talk fix) \u2014 full-width and tappable, not a passive status line, because the entire reason this\r\n screen exists is to make \"go back to the caller\" impossible to miss. Neutral surface + the same\r\n `--cv-hold` colour `.cv-badge-hold` already uses for the same fact elsewhere, not a new soft-fill\r\n token: a text/icon tint on a plain surface is already proven legible here, and inventing an\r\n unmeasured background-tint pairing risks a real contrast regression this file takes seriously\r\n elsewhere (see the `-soft`/`-strong` split's own comments).\r\n*/\r\n.cv-consult-hold-banner {\r\n display: flex;\r\n align-items: center;\r\n justify-content: center;\r\n gap: 0.4rem;\r\n width: 100%;\r\n padding: 0.5rem 0.75rem;\r\n border-radius: 999px;\r\n border: 1px solid var(--cv-border);\r\n background: var(--cv-surface);\r\n color: var(--cv-hold);\r\n font: 600 0.78rem var(--cv-font);\r\n cursor: pointer;\r\n transition: background 0.15s ease, border-color 0.15s ease;\r\n}\r\n.cv-consult-hold-banner:hover {\r\n background: var(--cv-surface-2);\r\n border-color: var(--cv-hold);\r\n}\r\n.cv-consult-hold-banner:active {\r\n transform: scale(0.98);\r\n}\r\n\r\n@keyframes cv-pop-in-up {\r\n from { opacity: 0; transform: translateY(4px) scale(0.97); }\r\n to { opacity: 1; transform: none; }\r\n}\r\n\r\n/*\r\n The in-call DTMF keypad \u2014 the one floating popover left on the call screen. (Dispositivos was the\r\n other; it is inline content now, `.cv-call-devices-inline` above, in every call state.)\r\n\r\n Opens UPWARD (`bottom: 100%`): the trigger sits at the bottom of the call screen, and there is no\r\n room to open downward. Anchored to `.cv-controls`'s own horizontal padding \u2014 a container whose width\r\n is bounded by ITS parent, never the narrow trigger itself \u2014 the same fix CountryPicker's own popover\r\n needed (see its comment): anchoring to a narrow, off-centre trigger let the popover's edge reach\r\n `.cv-softphone`'s `overflow: hidden` and get clipped.\r\n*/\r\n.cv-keypad-popover {\r\n position: absolute;\r\n z-index: 20;\r\n display: grid;\r\n grid-template-columns: repeat(3, 1fr);\r\n gap: 8px;\r\n padding: 0.85rem 1rem;\r\n background: var(--cv-surface);\r\n border: 1px solid var(--cv-border);\r\n border-radius: var(--cv-radius);\r\n box-shadow: var(--cv-shadow);\r\n}\r\n.cv-keypad-popover--above {\r\n bottom: 100%;\r\n left: 1rem;\r\n right: 1rem;\r\n margin-bottom: 0.6rem;\r\n transform-origin: bottom center;\r\n animation: cv-pop-in-up 0.14s cubic-bezier(0.2, 0, 0.2, 1);\r\n}\r\n/* `.cv-key` already carries the full key look (border, hover, active-scale, digit/letter type) from\r\n the pre-call dialer \u2014 only the size needs setting here, since this grid has no flex ancestor to\r\n stretch the rows the way `.cv-keypad` gets to.\r\n\r\n A fixed HEIGHT, not an `aspect-ratio`. This popover opens upward from `.cv-controls`, so it has\r\n (host height - controls block - margin) to work with, and a ratio ties its height to the host's\r\n WIDTH, which says nothing about how much height there is. Found live: the CRM's real body is 310\xD7473\r\n (a 520px panel minus its 45px header), and a 1.3 ratio made this popover 285px \u2014 with the 184px\r\n controls block that is 479px, so its top ~7px were clipped by `.cv-softphone`'s `overflow: hidden`.\r\n 48px keys give a 243px popover at ANY width, fit that host with room to spare, and stay at the\r\n 44-48px minimum tap target (the old ratio gave 45px keys at a 260px host, 58px at 310). */\r\n.cv-keypad-popover .cv-key {\r\n height: 48px;\r\n}\r\n@media (prefers-reduced-motion: reduce) {\r\n .cv-keypad-popover--above { animation: none; }\r\n}\r\n\r\n/* \u2500\u2500 Lists (contacts / history) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\r\n.cv-list-head { flex-shrink: 0; padding: 0.85rem 1rem 0.5rem; }\r\n.cv-search {\r\n width: 100%;\r\n padding: 0.5rem 0.75rem;\r\n border-radius: 10px;\r\n border: 1px solid var(--cv-border-strong);\r\n background: var(--cv-surface);\r\n color: var(--cv-text);\r\n font: inherit;\r\n transition: border-color 0.15s ease, background 0.15s ease;\r\n /* Found live (2026-09-29): in the transfer row specifically, `.cv-btn`'s own `max-width: 60%`\r\n (needed for the dynamic \"Llamar a {typed}\" label) can leave the input narrower than its\r\n placeholder text \u2014 an `<input>` has no native ellipsis, so it hard-clipped mid-word\r\n (\"...o n\xFAm\") instead of trailing off cleanly. A real, if rare, risk for ANY `.cv-search` usage\r\n wherever it's squeezed, not just this one row \u2014 the fix belongs on the base rule. */\r\n overflow: hidden;\r\n text-overflow: ellipsis;\r\n}\r\n.cv-search::placeholder { color: var(--cv-muted); }\r\n.cv-search:focus { outline: none; border-color: var(--cv-accent); background: var(--cv-bg); }\r\n\r\n/*\r\n A wrapper around `.cv-search`, not a change to it \u2014 that class is also the transfer-target input\r\n in CallScreen, which wants neither an icon nor a clear button. `.cv-search--icon` is an ADDITIVE\r\n modifier on the input itself (padding only), so the base class is byte-for-byte what it was for\r\n every other consumer.\r\n*/\r\n.cv-search-field { position: relative; display: flex; align-items: center; }\r\n.cv-search--icon { padding-left: 2.15rem; padding-right: 1.85rem; }\r\n.cv-search-icon {\r\n position: absolute;\r\n left: 0.75rem;\r\n color: var(--cv-muted);\r\n pointer-events: none;\r\n}\r\n.cv-search-clear {\r\n position: absolute;\r\n right: 0.5rem;\r\n width: 22px;\r\n height: 22px;\r\n display: inline-flex;\r\n align-items: center;\r\n justify-content: center;\r\n border: none;\r\n border-radius: 999px;\r\n background: none;\r\n color: var(--cv-muted);\r\n cursor: pointer;\r\n transition: color 0.12s ease, transform 0.1s var(--cv-ease-out);\r\n}\r\n.cv-search-clear:hover { color: var(--cv-text); }\r\n.cv-search-clear:active { transform: scale(0.88); }\r\n\r\n/* \u2500\u2500 Historial filters: status (Todas/Perdidas) + person \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\r\n.cv-segmented {\r\n display: flex;\r\n gap: 2px;\r\n padding: 2px;\r\n border-radius: 10px;\r\n background: var(--cv-surface-2);\r\n margin-bottom: 0.4rem;\r\n}\r\n.cv-segmented-btn {\r\n flex: 1;\r\n padding: 0.35rem 0;\r\n border: none;\r\n border-radius: 8px;\r\n background: none;\r\n color: var(--cv-muted);\r\n font: 600 0.75rem var(--cv-font);\r\n cursor: pointer;\r\n transition: background 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;\r\n}\r\n/* The active segment is stated by its own fill + shadow, not colour alone \u2014 the same reasoning\r\n `.cv-item-action[data-tone='accent']`'s own comment gives for resting-state colour: a state that\r\n only differs by hue must still read on a screen that renders neither. */\r\n.cv-segmented-btn[aria-selected='true'] {\r\n background: var(--cv-bg);\r\n color: var(--cv-text);\r\n box-shadow: var(--cv-shadow);\r\n}\r\n@media (prefers-reduced-motion: reduce) {\r\n .cv-segmented-btn { transition: none; }\r\n}\r\n\r\n/* The person filter \u2014 same combobox SHAPE as `.cv-cp-*` (CountryPicker): a chip opening a positioned\r\n popover list with a checkmark on the active row. Not the same CLASSES: the two pickers have nothing\r\n to do with each other and sharing a namespace would make a future change to one silently reach the\r\n other. */\r\n.cv-person-filter { position: relative; width: 100%; }\r\n.cv-person-filter-chip {\r\n display: flex;\r\n align-items: center;\r\n gap: 0.4rem;\r\n width: 100%;\r\n padding: 0.4rem 0.65rem;\r\n border: 1px solid var(--cv-border-strong);\r\n border-radius: 10px;\r\n background: var(--cv-surface);\r\n color: var(--cv-muted);\r\n font: 500 0.78rem var(--cv-font);\r\n cursor: pointer;\r\n transition: background 0.15s ease, border-color 0.15s ease;\r\n}\r\n.cv-person-filter-chip:hover { background: var(--cv-surface-2); }\r\n/*\r\n Border only, no fill \u2014 the popover opening and the caret rotating already say \"this is open\";\r\n painting the whole width of a wide text pill solid green on top of that was the same over-strong\r\n green the selected row had (see its own comment below). A coloured border is the same restrained\r\n signal `.cv-search:focus` already uses for \"active\" elsewhere in this file.\r\n*/\r\n.cv-person-filter-chip[aria-expanded='true'] {\r\n border-color: var(--cv-accent);\r\n}\r\n.cv-person-filter-label {\r\n flex: 1;\r\n min-width: 0;\r\n overflow: hidden;\r\n text-overflow: ellipsis;\r\n white-space: nowrap;\r\n text-align: left;\r\n color: var(--cv-text);\r\n}\r\n.cv-person-filter-caret {\r\n flex-shrink: 0;\r\n transition: transform 0.18s cubic-bezier(0.2, 0, 0.2, 1);\r\n}\r\n.cv-person-filter-caret[data-open='true'] { transform: rotate(180deg); }\r\n.cv-person-filter-pop {\r\n position: absolute;\r\n top: calc(100% + 6px);\r\n left: 0;\r\n right: 0;\r\n z-index: 20;\r\n max-height: 220px;\r\n overflow-y: auto;\r\n padding: 0.25rem;\r\n background: var(--cv-surface);\r\n border: 1px solid var(--cv-border);\r\n border-radius: var(--cv-radius);\r\n box-shadow: var(--cv-shadow);\r\n transform-origin: top center;\r\n animation: cv-cp-in 0.14s cubic-bezier(0.2, 0, 0.2, 1);\r\n}\r\n@media (prefers-reduced-motion: reduce) {\r\n .cv-person-filter-pop { animation: none; }\r\n .cv-person-filter-caret { transition: none; }\r\n}\r\n.cv-person-filter-item {\r\n width: 100%;\r\n display: flex;\r\n align-items: center;\r\n gap: 0.5rem;\r\n padding: 0.45rem 0.5rem;\r\n border: none;\r\n border-radius: calc(var(--cv-radius) - 4px);\r\n background: none;\r\n color: var(--cv-text);\r\n font: 400 0.8rem var(--cv-font);\r\n text-align: left;\r\n cursor: pointer;\r\n}\r\n.cv-person-filter-item:hover { background: var(--cv-surface-2); }\r\n/*\r\n NOT a solid `--cv-accent-soft` fill \u2014 that read as a wall of saturated green across a wide row,\r\n much louder than the legacy CRM's own treatment for this exact list (`bg-emerald-50` \u2014 a barely-\r\n there wash \u2014 plus coloured text, never a strong fill). Coloured, bold text + the checkmark carry\r\n \"selected\" instead; `--cv-accent-strong` is already verified elsewhere in this file as AA against\r\n `--cv-surface`, which is what this popover sits on.\r\n*/\r\n.cv-person-filter-item[aria-selected='true'] .cv-person-filter-name {\r\n font-weight: 600;\r\n color: var(--cv-accent-strong);\r\n}\r\n.cv-person-filter-name {\r\n flex: 1;\r\n min-width: 0;\r\n overflow: hidden;\r\n text-overflow: ellipsis;\r\n white-space: nowrap;\r\n}\r\n.cv-person-filter-check { color: var(--cv-accent-strong); flex-shrink: 0; }\r\n.cv-person-filter-check[data-on='false'] { opacity: 0; }\r\n\r\n.cv-list {\r\n flex: 1;\r\n min-height: 0;\r\n overflow-y: auto;\r\n padding: 0 0.5rem 0.5rem;\r\n}\r\n/*\r\n Historial's day group header. Sticky within `.cv-list` (its nearest scrolling ancestor \u2014 a plain\r\n wrapper div between the two does not break that), OPAQUE `--cv-bg` rather than a translucent\r\n `/85 + backdrop-blur` treatment: it still needs to fully occlude rows scrolling underneath it, and\r\n a flat match to the list's own background does that with no new CSS feature this file doesn't\r\n already use elsewhere.\r\n*/\r\n.cv-history-day {\r\n position: sticky;\r\n top: 0;\r\n z-index: 1;\r\n padding: 0.5rem 0.6rem 0.35rem;\r\n background: var(--cv-bg);\r\n color: var(--cv-muted);\r\n font-size: 0.68rem;\r\n font-weight: 600;\r\n text-transform: uppercase;\r\n letter-spacing: 0.04em;\r\n}\r\n.cv-item {\r\n display: flex;\r\n align-items: center;\r\n gap: 0.7rem;\r\n padding: 0.5rem;\r\n border-radius: 12px;\r\n /*\r\n NOT `cursor: pointer` \u2014 this div has no click handler of its own; only its children\r\n (`.cv-item-main`, `.cv-item-action`) do, and each already sets its own pointer cursor. Putting\r\n it here too used to make the WHOLE row's box promise a click \u2014 its own padding, and the flex\r\n `gap` between avatar/name/button \u2014 none of which does anything when clicked. Most noticeable\r\n right where one row's padding meets the next, which reads as \"there's something clickable\r\n between these two contacts\" when there is not.\r\n */\r\n transition: background 0.12s ease;\r\n text-align: left;\r\n width: 100%;\r\n border: none;\r\n background: none;\r\n font: inherit;\r\n color: inherit;\r\n}\r\n.cv-item:hover { background: var(--cv-surface); }\r\n.cv-item-avatar {\r\n width: 40px;\r\n height: 40px;\r\n border-radius: 999px;\r\n /*\r\n RADIAL, not diagonal \u2014 deliberately. The initials sit centered, so a radial gradient puts them\r\n at its lightest point (pure `--cv-surface`) no matter what, while the edges still darken toward\r\n `--cv-surface-2` for the same cheap depth a diagonal gradient gave. A diagonal version measured\r\n 4.39:1 for `--cv-muted` text at its far corner \u2014 under the 4.5:1 text needs \u2014 because centered\r\n text's exact position against a DIAGONAL gradient depends on glyph width, not just circle size.\r\n Centering the gradient on the centered text removes that dependency entirely, structurally,\r\n rather than nudging a token and hoping the margin holds. Both stops stay tokens, so this is\r\n still theme-correct for free \u2014 a hardcoded gray gradient would not adapt to dark mode.\r\n */\r\n background: radial-gradient(circle, var(--cv-surface), var(--cv-surface-2));\r\n /*\r\n Without this, the circle's own boundary can vanish: `.cv-item:hover` sets the ROW background to\r\n `--cv-surface` \u2014 the exact color the gradient above STARTS at (1.00 contrast ratio, i.e.\r\n identical) in both themes. `--cv-border-strong` is the same token `.cv-search`'s border already\r\n uses against this same surface a few rules up \u2014 a pairing already proven visible, not a new one.\r\n */\r\n border: 1px solid var(--cv-border-strong);\r\n display: flex;\r\n align-items: center;\r\n justify-content: center;\r\n color: var(--cv-muted);\r\n font-size: 0.78rem;\r\n font-weight: 700;\r\n flex-shrink: 0;\r\n}\r\n/*\r\n The Historial row's status chip \u2014 WHY a call needs colour at all: direction is already the icon's\r\n shape (an arrow in/out), but \"did this connect\" is not shape-able, and a flat grey circle made every\r\n row look identical at a glance. Small badge, not a row-wide fill (that mistake already got made once\r\n on the person filter \u2014 see its own comment): a 40px circle reads as a status glyph, the same way a\r\n colored icon-badge does anywhere else; a wide fill reads as decoration competing with the text next\r\n to it. `--danger-strong`/`--accent-strong` (not the plain base tokens) because the base tokens\r\n measure BELOW 3:1 non-text contrast against their own `-soft` fill \u2014 `--cv-danger` on\r\n `--cv-danger-soft` is 2.6:1; `--cv-danger-strong` is 4.47:1 light / 3.62:1 dark, measured.\r\n*/\r\n.cv-item-avatar[data-tone='accent'] { background: var(--cv-accent-soft); color: var(--cv-accent-strong); }\r\n.cv-item-avatar[data-tone='info'] { background: var(--cv-info-soft); color: var(--cv-info-strong); }\r\n.cv-item-avatar[data-tone='danger'] { background: var(--cv-danger-soft); color: var(--cv-danger-strong); }\r\n/* A button, reset to look like plain content \u2014 the row is a container, not a control. */\r\n.cv-item-main {\r\n flex: 1;\r\n min-width: 0;\r\n display: block;\r\n border: none;\r\n background: none;\r\n padding: 0;\r\n font: inherit;\r\n color: inherit;\r\n text-align: left;\r\n cursor: pointer;\r\n transition: transform 0.1s var(--cv-ease-out);\r\n}\r\n.cv-item-main:disabled { cursor: default; }\r\n.cv-item-main:not(:disabled):active { transform: scale(0.985); }\r\n.cv-item-name {\r\n display: block;\r\n font-weight: 600;\r\n font-size: 0.875rem;\r\n white-space: nowrap;\r\n overflow: hidden;\r\n text-overflow: ellipsis;\r\n}\r\n/*\r\n 14px bold does not clear WCAG's large-text bar (18.66px bold) \u2014 this text needs the full 4.5:1, not\r\n the 3:1 large-text minimum. `--cv-danger-strong`: 6.47:1 light / 6.46:1 dark against `--cv-bg`,\r\n measured; the plain `--cv-danger` this used to be was only 3.76:1 light \u2014 a real (if marginal) fail.\r\n*/\r\n.cv-item-name[data-tone='danger'] { color: var(--cv-danger-strong); }\r\n.cv-item-sub {\r\n display: block;\r\n font-size: 0.72rem;\r\n color: var(--cv-muted);\r\n margin-top: 1px;\r\n font-variant-numeric: tabular-nums;\r\n}\r\n.cv-item-sub-outcome { color: var(--cv-danger-strong); }\r\n/*\r\n Decorative reinforcement, not the sole carrier \u2014 the row's own play button is what says \"this call\r\n has a recording\" (it only renders when one exists); this is a small hint IN the metadata line that\r\n the button is worth pressing, so `--cv-faint` (2.5:1, documented above as decorative-only) is the\r\n right tier here, same reasoning `.cv-empty-icon`/`.cv-gate-mark` already use for icons next to real\r\n text rather than standing alone.\r\n\r\n `display: inline-flex` is load-bearing, not decoration \u2014 every OTHER inline icon in this file gets\r\n an explicit `display` for the same reason: `/react` + `/ui` render straight into the host's light\r\n DOM (no shadow root), so a host reset like Tailwind Preflight's `svg { display: block }` reaches\r\n this element directly. Without an explicit override here, that reset knocks the icon onto its own\r\n line instead of trailing the duration text it is meant to sit beside.\r\n*/\r\n.cv-item-sub-rec {\r\n display: inline-flex;\r\n color: var(--cv-faint);\r\n margin-left: 0.3rem;\r\n vertical-align: -1px;\r\n}\r\n.cv-item-action {\r\n width: 34px;\r\n height: 34px;\r\n border-radius: 999px;\r\n border: none;\r\n background: none;\r\n color: var(--cv-muted);\r\n cursor: pointer;\r\n display: inline-flex;\r\n align-items: center;\r\n justify-content: center;\r\n flex-shrink: 0;\r\n transition: background 0.12s ease, color 0.12s ease, transform 0.1s var(--cv-ease-out);\r\n}\r\n.cv-item-action:hover { background: var(--cv-accent); color: #fff; }\r\n.cv-item-action:active { transform: scale(0.9); }\r\n/*\r\n The call action specifically (ContactsTab) \u2014 NOT History's \"play recording\", which shares this\r\n same class and must stay neutral; a play button has no reason to be accent-colored. Scoped with\r\n `data-tone`, the same modifier convention `.cv-ctrl[data-tone]` already uses elsewhere in this file.\r\n\r\n Coloured at REST, not just on hover: a hover-only reveal never fires on a touchscreen, so the one\r\n affordance that most needs to read as \"you can tap this\" would be invisible on exactly the devices\r\n a phone widget is likeliest to run on. `accent-strong` is already verified elsewhere in this file\r\n as AA on a surface; `accent-soft` is the same hover tint `.cv-ctrl[data-tone='accent']` uses, just\r\n transparent at rest so fifty rows of solid fill never turns the list into a wall of green.\r\n*/\r\n.cv-item-action[data-tone='accent'] { color: var(--cv-accent-strong); }\r\n.cv-item-action[data-tone='accent']:hover { background: var(--cv-accent-soft); color: var(--cv-accent-strong); }\r\n\r\n@media (prefers-reduced-motion: reduce) {\r\n .cv-item-main, .cv-item-action, .cv-search-clear { transition: none; }\r\n}\r\n\r\n.cv-empty {\r\n display: flex;\r\n flex-direction: column;\r\n align-items: center;\r\n justify-content: center;\r\n height: 100%;\r\n color: var(--cv-muted);\r\n text-align: center;\r\n padding: 2.5rem 1.25rem;\r\n font-size: 0.82rem;\r\n}\r\n.cv-empty-icon {\r\n display: flex;\r\n align-items: center;\r\n justify-content: center;\r\n width: 48px;\r\n height: 48px;\r\n border-radius: 999px;\r\n background: var(--cv-surface);\r\n color: var(--cv-faint);\r\n margin-bottom: 0.75rem;\r\n}\r\n.cv-empty-title { margin: 0; font-size: 0.85rem; font-weight: 600; color: var(--cv-muted); }\r\n/*\r\n `--cv-muted`, NOT `--cv-faint` \u2014 this file documents faint as \"decorative only... never\r\n information\" (2.5:1, below AA), and this line IS information: it is the reason the list is\r\n empty, sometimes echoing the user's own search back to them. `--cv-faint` here measured 2.54:1\r\n light / 3.70:1 dark against a 4.5:1 bar \u2014 a real failure, not a rounding difference.\r\n*/\r\n.cv-empty-sub { margin: 0.2rem 0 0; font-size: 0.75rem; color: var(--cv-muted); }\r\n.cv-empty-spinner {\r\n margin-bottom: 0.75rem;\r\n animation: cv-spin 0.8s linear infinite;\r\n /* A loading spinner's rotation IS the information, with no static equivalent \u2014 unlike this\r\n file's other motion, it stays exempt from reduced-motion rather than freezing into a shape\r\n that would just look stuck. Colour comes from `.cv-empty`'s own `color: var(--cv-muted)` \u2014\r\n the spinner no longer sets its own, now that it sits next to real text instead of standing\r\n alone: the icon is decorative reinforcement, same as `.cv-empty-icon`, not the sole carrier. */\r\n}\r\n@keyframes cv-spin {\r\n to { transform: rotate(360deg); }\r\n}\r\n\r\n/* The scroll-triggered \"load more\" marker \u2014 see `useInfiniteList`. Never visible itself; a small\r\n height so it has a real box for the `IntersectionObserver` to track. */\r\n.cv-list-sentinel {\r\n height: 1px;\r\n}\r\n.cv-list-loading-more {\r\n display: flex;\r\n align-items: center;\r\n justify-content: center;\r\n padding: 0.75rem 0 0.25rem;\r\n color: var(--cv-muted);\r\n}\r\n/* Exempt from reduced-motion for the same reason `.cv-empty-spinner` is \u2014 see its own comment. */\r\n.cv-list-loading-more-spinner {\r\n animation: cv-spin 0.8s linear infinite;\r\n}\r\n\r\n/* \u2500\u2500 Contact detail (overlays the list) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\r\n.cv-detail {\r\n position: absolute;\r\n inset: 0;\r\n /* Above the list's own sticky day-group header (`.cv-history-day`, z-index: 1) \u2014 otherwise\r\n that header's z-index wins the stacking fight wherever its unscrolled flow position happens\r\n to land on top of this overlay, and it paints through despite both having an opaque bg. */\r\n z-index: 2;\r\n display: flex;\r\n flex-direction: column;\r\n background: var(--cv-bg);\r\n animation: cv-detail-in 220ms var(--cv-ease-out) both;\r\n}\r\n@keyframes cv-detail-in {\r\n from { opacity: 0; transform: translateX(10px); }\r\n to { opacity: 1; transform: none; }\r\n}\r\n@media (prefers-reduced-motion: reduce) {\r\n .cv-detail { animation: none; }\r\n}\r\n\r\n.cv-detail-head { flex-shrink: 0; padding: 0.6rem 0.5rem 0; }\r\n.cv-detail-back {\r\n display: inline-flex;\r\n align-items: center;\r\n gap: 0.3rem;\r\n padding: 0.4rem 0.5rem;\r\n border: none;\r\n border-radius: 8px;\r\n background: none;\r\n color: var(--cv-muted);\r\n font: 600 0.82rem var(--cv-font);\r\n cursor: pointer;\r\n transition: color 0.12s ease, transform 0.1s var(--cv-ease-out);\r\n}\r\n.cv-detail-back:hover { color: var(--cv-text); }\r\n.cv-detail-back:active { transform: scale(0.97); }\r\n\r\n.cv-detail-body { flex: 1; min-height: 0; overflow-y: auto; padding: 0 1.25rem; }\r\n.cv-detail-identity {\r\n display: flex;\r\n flex-direction: column;\r\n align-items: center;\r\n text-align: center;\r\n padding: 0.75rem 0 1.5rem;\r\n}\r\n.cv-detail-avatar {\r\n width: 72px;\r\n height: 72px;\r\n border-radius: 999px;\r\n /* Same radial + border treatment as `.cv-item-avatar` (see its own comment), just larger. Neutral\r\n by default \u2014 a contact has no \"status\" to colour. `HistoryDetail` overrides both with `[data-tone]`\r\n below: a call DOES have one, the same one the list row already shows, so the hero avatar staying\r\n grey there would be information the list already gave up on repeating here. */\r\n background: radial-gradient(circle, var(--cv-surface), var(--cv-surface-2));\r\n border: 1px solid var(--cv-border-strong);\r\n display: flex;\r\n align-items: center;\r\n justify-content: center;\r\n color: var(--cv-muted);\r\n font-size: 1.35rem;\r\n font-weight: 700;\r\n margin-bottom: 0.85rem;\r\n}\r\n/* Same token pairs already verified for `.cv-item-avatar[data-tone]` \u2014 size doesn't change a contrast\r\n ratio, so no new numbers to measure here. */\r\n.cv-detail-avatar[data-tone='accent'] { background: var(--cv-accent-soft); color: var(--cv-accent-strong); border-color: transparent; }\r\n.cv-detail-avatar[data-tone='info'] { background: var(--cv-info-soft); color: var(--cv-info-strong); border-color: transparent; }\r\n.cv-detail-avatar[data-tone='danger'] { background: var(--cv-danger-soft); color: var(--cv-danger-strong); border-color: transparent; }\r\n.cv-detail-name {\r\n margin: 0;\r\n font-size: 1.1rem;\r\n font-weight: 700;\r\n color: var(--cv-text);\r\n letter-spacing: -0.01em;\r\n}\r\n.cv-detail-name[data-tone='danger'] { color: var(--cv-danger-strong); }\r\n.cv-detail-meta {\r\n margin: 0.2rem 0 0;\r\n font-size: 0.78rem;\r\n color: var(--cv-muted);\r\n}\r\n\r\n/* \u2500\u2500 Call detail: recording \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\r\n.cv-detail-recording { margin-bottom: 1rem; }\r\n.cv-detail-recording-label {\r\n margin: 0 0 0.4rem;\r\n padding: 0 0.15rem;\r\n font-size: 0.68rem;\r\n font-weight: 600;\r\n text-transform: uppercase;\r\n letter-spacing: 0.03em;\r\n color: var(--cv-muted);\r\n}\r\n.cv-detail-audio { width: 100%; display: block; }\r\n.cv-detail-audio + .cv-detail-audio { margin-top: 0.5rem; }\r\n.cv-detail-recording-status {\r\n display: flex;\r\n align-items: center;\r\n gap: 0.5rem;\r\n margin: 0 0 1rem;\r\n padding: 0.65rem 0.75rem;\r\n border-radius: 12px;\r\n background: var(--cv-surface);\r\n color: var(--cv-muted);\r\n font-size: 0.78rem;\r\n}\r\n/*\r\n Text colour only, same neutral `--cv-surface` background the other states use \u2014 NOT a\r\n `--cv-danger-soft` fill. Measured first, not assumed: `--cv-danger-strong` on `--cv-danger-soft`\r\n is 3.34:1 light / 3.62:1 dark, well under the 4.5:1 this 0.78rem text needs (it only ever cleared\r\n the SEPARATE 3:1 non-text bar this token was built for \u2014 an icon on that badge, not body text on\r\n it). Same lesson the person-filter fix already paid for: a badge-strength pairing that works for an\r\n icon is not automatically safe for text.\r\n*/\r\n.cv-detail-recording-status--warn { color: var(--cv-danger-strong); }\r\n.cv-detail-recording-spinner { animation: cv-spin 0.8s linear infinite; flex-shrink: 0; }\r\n\r\n/* \u2500\u2500 Call detail: who handled it \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\r\n.cv-detail-handlers { display: flex; flex-wrap: wrap; align-items: center; gap: 0.3rem; }\r\n.cv-detail-handler-hop { display: inline-flex; align-items: center; gap: 0.3rem; }\r\n.cv-detail-handler-arrow { color: var(--cv-faint); flex-shrink: 0; }\r\n.cv-detail-handler-chip {\r\n display: inline-flex;\r\n align-items: center;\r\n gap: 0.3rem;\r\n padding: 0.15rem 0.5rem;\r\n border-radius: 999px;\r\n background: var(--cv-surface-2);\r\n color: var(--cv-text);\r\n font-size: 0.75rem;\r\n font-weight: 600;\r\n}\r\n/* The LAST hop \u2014 the one the call actually ended with \u2014 is the one worth a glance first. */\r\n.cv-detail-handler-chip[data-tone='accent'] { background: var(--cv-accent-soft); color: var(--cv-accent-strong); }\r\n.cv-detail-handler-ext {\r\n font-weight: 400;\r\n font-variant-numeric: tabular-nums;\r\n opacity: 0.75;\r\n}\r\n\r\n.cv-detail-rows {\r\n border: 1px solid var(--cv-border);\r\n border-radius: 14px;\r\n overflow: hidden;\r\n background: var(--cv-bg);\r\n}\r\n.cv-detail-row {\r\n display: flex;\r\n align-items: center;\r\n gap: 0.7rem;\r\n width: 100%;\r\n padding: 0.7rem 0.85rem;\r\n border: none;\r\n border-top: 1px solid var(--cv-border);\r\n background: none;\r\n font: inherit;\r\n color: inherit;\r\n text-align: left;\r\n}\r\n.cv-detail-row:first-child { border-top: none; }\r\n.cv-detail-row--action {\r\n cursor: pointer;\r\n transition: background 0.12s ease;\r\n}\r\n.cv-detail-row--action:hover { background: var(--cv-surface); }\r\n.cv-detail-row-icon {\r\n display: flex;\r\n align-items: center;\r\n justify-content: center;\r\n width: 32px;\r\n height: 32px;\r\n border-radius: 999px;\r\n background: var(--cv-surface-2);\r\n /*\r\n Without this the circle nearly vanishes into its own row: `--cv-surface-2` measures only ~1.1:1\r\n against the row's rest-state background and ~1.05:1 against `--cv-surface`, the color\r\n `.cv-detail-row--action:hover` sets the ROW to \u2014 both far below a readable boundary, and worse\r\n than `.cv-item-avatar`/`.cv-detail-avatar` above even manage WITH their border (1.18:1 light /\r\n 1.57:1 dark). Same token, same fix, same reason.\r\n */\r\n border: 1px solid var(--cv-border-strong);\r\n color: var(--cv-muted);\r\n flex-shrink: 0;\r\n}\r\n.cv-detail-row-text { flex: 1; min-width: 0; display: flex; flex-direction: column; }\r\n.cv-detail-row-eyebrow {\r\n font-size: 0.68rem;\r\n font-weight: 600;\r\n text-transform: uppercase;\r\n letter-spacing: 0.03em;\r\n color: var(--cv-muted);\r\n}\r\n.cv-detail-row-value {\r\n font-size: 0.85rem;\r\n font-weight: 500;\r\n color: var(--cv-text);\r\n white-space: nowrap;\r\n overflow: hidden;\r\n text-overflow: ellipsis;\r\n}\r\n.cv-detail-row-chevron { color: var(--cv-faint); flex-shrink: 0; }\r\n\r\n.cv-detail-foot { flex-shrink: 0; padding: 0.85rem 1.25rem 1.1rem; }\r\n.cv-detail-call { width: 100%; display: flex; align-items: center; justify-content: center; gap: 0.4rem; }\r\n\r\n/* \u2500\u2500 Settings tab (device selection, SPEC \xA77 Phase 2) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\r\n.cv-settings-body {\r\n flex: 1;\r\n min-height: 0;\r\n overflow-y: auto;\r\n padding: 1rem;\r\n display: flex;\r\n flex-direction: column;\r\n /* Generous separation BETWEEN sections, deliberately larger than any gap inside one \u2014 grouping\r\n only reads as grouping if the space between groups is clearly bigger than the space within one\r\n (see impeccable's layout guidance: \"tight grouping, generous separation\"). */\r\n gap: 1.5rem;\r\n}\r\n\r\n/*\r\n One labeled group (`SettingsSection`): a title, and a bordered body holding its rows. Ajustes\r\n started as a flat list of two rows with nothing to say they were related \u2014 fine for exactly two\r\n rows, unreadable the moment an unrelated third setting lands with nothing to tell it apart from\r\n the device rows above it.\r\n\r\n The body is ONE card, not a card per row: `.cv-settings-row`/`.cv-settings-note` below carry no\r\n border or background of their own, only a divider between siblings \u2014 the same shape\r\n `.cv-detail-rows`/`.cv-detail-row` already use elsewhere in this file, and deliberately not a\r\n stack of individually-bordered cards (nested-card territory).\r\n*/\r\n.cv-settings-section { display: flex; flex-direction: column; gap: 0.5rem; }\r\n.cv-settings-section-title {\r\n margin: 0;\r\n padding: 0 0.2rem;\r\n font-size: 0.7rem;\r\n font-weight: 700;\r\n text-transform: uppercase;\r\n letter-spacing: 0.04em;\r\n color: var(--cv-muted);\r\n}\r\n.cv-settings-section-body {\r\n border: 1px solid var(--cv-border);\r\n border-radius: 14px;\r\n background: var(--cv-bg);\r\n overflow: hidden;\r\n}\r\n.cv-settings-section-body > * { padding: 0.75rem 0.85rem; }\r\n.cv-settings-section-body > *:not(:first-child) { border-top: 1px solid var(--cv-border); }\r\n\r\n.cv-settings-row { display: flex; align-items: center; gap: 0.7rem; }\r\n/* Same treatment as `.cv-detail-row-icon`, same reason \u2014 see its own comment on why the border is\r\n load-bearing: `--cv-surface-2` alone nearly vanishes into the section's own background. */\r\n.cv-settings-row-icon {\r\n display: flex;\r\n align-items: center;\r\n justify-content: center;\r\n width: 32px;\r\n height: 32px;\r\n border-radius: 999px;\r\n background: var(--cv-surface-2);\r\n border: 1px solid var(--cv-border-strong);\r\n color: var(--cv-muted);\r\n flex-shrink: 0;\r\n}\r\n.cv-settings-row-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0.35rem; }\r\n/* Same pairing as `.cv-detail-row-eyebrow` (`--cv-muted` on `--cv-bg`), verified there already. */\r\n.cv-settings-row-label {\r\n font-size: 0.68rem;\r\n font-weight: 600;\r\n text-transform: uppercase;\r\n letter-spacing: 0.03em;\r\n color: var(--cv-muted);\r\n}\r\n.cv-settings-select {\r\n width: 100%;\r\n padding: 0.45rem 0.6rem;\r\n border-radius: 8px;\r\n border: 1px solid var(--cv-border-strong);\r\n background: var(--cv-surface);\r\n color: var(--cv-text);\r\n font: inherit;\r\n font-size: 0.85rem;\r\n transition: border-color 0.15s ease, background 0.15s ease;\r\n}\r\n.cv-settings-select:focus { outline: none; border-color: var(--cv-accent); background: var(--cv-bg); }\r\n.cv-settings-empty { font-size: 0.85rem; color: var(--cv-muted); }\r\n\r\n/* `--cv-danger-strong` is the AS-TEXT danger token (see its definition): red-600 on the light `--cv-bg`,\r\n red-400 on the dark one. */\r\n.cv-settings-error { margin: 0.6rem 0.2rem 0; font-size: 0.78rem; line-height: 1.4; color: var(--cv-danger-strong); }\r\n\r\n/*\r\n Ajustes as a list (custom-ringtone.md \xA74.6, revised 2026-10-14): each row names a setting, shows its CURRENT\r\n value under the name, and opens a page. Same icon circle and dividers as the device rows above (they live in\r\n the same `.cv-settings-section-body`, which supplies the padding and the dividers), same chevron as\r\n `.cv-detail-row--action` \u2014 one row vocabulary across the widget.\r\n*/\r\n.cv-settings-nav {\r\n display: flex;\r\n align-items: center;\r\n gap: 0.7rem;\r\n width: 100%;\r\n border: none;\r\n background: none;\r\n font: inherit;\r\n color: inherit;\r\n text-align: left;\r\n}\r\nbutton.cv-settings-nav { cursor: pointer; transition: background 0.12s ease; }\r\nbutton.cv-settings-nav:hover { background: var(--cv-surface); }\r\nbutton.cv-settings-nav:focus-visible { outline: 2px solid var(--cv-accent); outline-offset: -2px; }\r\n.cv-settings-nav-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0.1rem; }\r\n.cv-settings-nav-title { font-size: 0.88rem; font-weight: 600; color: var(--cv-text); }\r\n/* One line, cut with an ellipsis \u2014 the full device name is on its page. `--cv-muted` on `--cv-bg` is the\r\n pairing already verified for `.cv-settings-note`. The static row (a note, no page) may wrap instead. */\r\n.cv-settings-nav-value {\r\n font-size: 0.8rem;\r\n color: var(--cv-muted);\r\n overflow: hidden;\r\n text-overflow: ellipsis;\r\n white-space: nowrap;\r\n}\r\n.cv-settings-nav--static .cv-settings-nav-value { white-space: normal; line-height: 1.45; }\r\n.cv-settings-nav-chevron { flex-shrink: 0; color: var(--cv-muted); }\r\n\r\n/* A settings page: the `.cv-detail` overlay (entrance, back button, reduced motion all inherited) with a\r\n scrolling body of its own. */\r\n.cv-settings-page-body { flex: 1; min-height: 0; overflow-y: auto; padding: 0.25rem 1rem 1rem; }\r\n.cv-settings-page-title {\r\n margin: 0.15rem 0.2rem 0.35rem;\r\n font-size: 1.05rem;\r\n font-weight: 700;\r\n letter-spacing: -0.01em;\r\n color: var(--cv-text);\r\n}\r\n/* Focus lands here on open (for keyboard and screen-reader users); a ring on a heading would only look\r\n like a stray box to everyone else. */\r\n.cv-settings-page-title:focus { outline: none; }\r\n.cv-settings-page-intro { margin: 0 0.2rem 0.85rem; font-size: 0.8rem; line-height: 1.45; color: var(--cv-muted); }\r\n.cv-settings-page-empty { margin: 0; padding: 0 0.2rem; font-size: 0.85rem; color: var(--cv-muted); }\r\n\r\n/* The checkmark list: one card like `.cv-settings-section-body`, a row per choice. */\r\n.cv-choices {\r\n border: 1px solid var(--cv-border);\r\n border-radius: 14px;\r\n background: var(--cv-bg);\r\n overflow: hidden;\r\n}\r\n.cv-choice {\r\n position: relative;\r\n display: flex;\r\n align-items: center;\r\n gap: 0.6rem;\r\n min-height: 44px;\r\n padding: 0.6rem 0.85rem;\r\n cursor: pointer;\r\n transition: background 0.12s ease;\r\n}\r\n.cv-choice + .cv-choice { border-top: 1px solid var(--cv-border); }\r\n.cv-choice:hover { background: var(--cv-surface); }\r\n/* The real control: a native radio, kept in the accessibility tree and keyboard order, drawn by the row. */\r\n.cv-choice-input {\r\n position: absolute;\r\n opacity: 0;\r\n width: 1px;\r\n height: 1px;\r\n margin: 0;\r\n pointer-events: none;\r\n}\r\n.cv-choice:has(.cv-choice-input:focus-visible) { outline: 2px solid var(--cv-accent); outline-offset: -2px; }\r\n.cv-choice-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0.1rem; }\r\n.cv-choice-label { font-size: 0.88rem; color: var(--cv-text); overflow-wrap: anywhere; }\r\n.cv-choice-sub { font-size: 0.78rem; color: var(--cv-muted); }\r\n.cv-choice[data-checked] .cv-choice-label { font-weight: 600; }\r\n.cv-choice-check { flex-shrink: 0; color: var(--cv-accent-strong); visibility: hidden; }\r\n.cv-choice[data-checked] .cv-choice-check { visibility: visible; }\r\n.cv-choice-playing { display: inline-flex; flex-shrink: 0; color: var(--cv-accent-strong); }\r\n\r\n.cv-settings-test,\r\n.cv-settings-retry {\r\n display: inline-flex;\r\n align-items: center;\r\n gap: 0.4rem;\r\n}\r\n.cv-settings-test { margin-top: 0.85rem; }\r\n.cv-settings-retry { margin-top: 0.6rem; }\r\n\r\n/* Visually hidden, still read aloud. */\r\n.cv-sr-only {\r\n position: absolute;\r\n width: 1px;\r\n height: 1px;\r\n overflow: hidden;\r\n clip-path: inset(50%);\r\n white-space: nowrap;\r\n}\r\n/* `--cv-muted` on `--cv-bg` \u2014 4.83:1 light / 6.97:1 dark, computed fresh for THIS pairing (the note\r\n used to sit on `--cv-surface` with its own card; now it sits directly on the section's `--cv-bg`,\r\n a different background that earns its own check rather than inheriting the old one's number). */\r\n.cv-settings-note {\r\n margin: 0;\r\n color: var(--cv-muted);\r\n font-size: 0.8rem;\r\n line-height: 1.5;\r\n}\r\n\r\n/* \u2500\u2500 Centred states (mic gate) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\r\n.cv-center {\r\n flex: 1;\r\n display: flex;\r\n flex-direction: column;\r\n align-items: center;\r\n justify-content: center;\r\n gap: 0.85rem;\r\n padding: 1.75rem;\r\n text-align: center;\r\n}\r\n.cv-center p { color: var(--cv-muted); margin: 0; font-size: 0.85rem; line-height: 1.5; }\r\n\r\n\r\n/* \"C\xF3mo permitirlo\" \u2014 a native <details> disclosure.\r\n Collapsed by default: the people who already know where the padlock is should not have to read\r\n past three steps to get there, and the people who don't get them without leaving the app. */\r\n.cv-help {\r\n width: 100%;\r\n margin-top: 0.15rem;\r\n text-align: left;\r\n}\r\n.cv-help summary {\r\n display: inline-flex;\r\n align-items: center;\r\n gap: 0.3rem;\r\n justify-content: center;\r\n width: 100%;\r\n padding: 0.3rem;\r\n border-radius: 8px;\r\n color: var(--cv-muted);\r\n font: 500 0.75rem var(--cv-font);\r\n cursor: pointer;\r\n list-style: none; /* hide the default triangle; the icon is the affordance */\r\n transition: color 0.15s ease, background 0.15s ease;\r\n}\r\n.cv-help summary::-webkit-details-marker { display: none; }\r\n.cv-help summary:hover { color: var(--cv-text); background: var(--cv-surface-2); }\r\n.cv-help[open] summary { color: var(--cv-text); margin-bottom: 0.35rem; }\r\n\r\n.cv-help ol {\r\n margin: 0;\r\n padding: 0.6rem 0.7rem 0.6rem 1.6rem;\r\n background: var(--cv-surface);\r\n border-radius: 10px;\r\n color: var(--cv-muted);\r\n font-size: 0.73rem;\r\n line-height: 1.55;\r\n}\r\n.cv-help li + li { margin-top: 0.3rem; }\r\n.cv-help li::marker { color: var(--cv-faint); font-variant-numeric: tabular-nums; }\r\n\r\n.cv-help a {\r\n display: inline-flex;\r\n align-items: center;\r\n gap: 0.25rem;\r\n margin-top: 0.45rem;\r\n padding-left: 0.1rem;\r\n color: var(--cv-accent-strong);\r\n font-size: 0.73rem;\r\n font-weight: 500;\r\n text-decoration: none;\r\n}\r\n.cv-help a:hover { text-decoration: underline; }\r\n\r\n/* The panel is the only thing that moves, and only when opened by hand \u2014 so it animates. */\r\n.cv-help[open] ol {\r\n animation: cv-gate-in 200ms var(--cv-ease-out) both;\r\n}\r\n@media (prefers-reduced-motion: reduce) {\r\n .cv-help[open] ol { animation: none; }\r\n}\r\n\r\n/* \u2500\u2500 The permission gate \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n Four states, and only ONE of them deserves a designed screen.\r\n\r\n `checking` resolves in ~100\u2013400 ms. Rendering it immediately means the user\r\n sees text appear and vanish inside a blink \u2014 a flash reads as jank, not as\r\n information. So it is delayed 450 ms with `both` fill: invisible during the\r\n delay, and if the check finishes first (the usual case, and always the case\r\n when permission is already granted) it is never painted at all.\r\n\r\n `prompting` is the one the user actually reads and acts on. It gets the\r\n hierarchy: mark, title, reason, action.\r\n \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\r\n.cv-gate {\r\n flex: 1;\r\n min-height: 0;\r\n display: flex;\r\n flex-direction: column;\r\n align-items: center;\r\n justify-content: center;\r\n gap: 0.6rem;\r\n padding: 1.5rem 1.75rem;\r\n text-align: center;\r\n}\r\n\r\n/* The disc echoes the call avatar \u2014 one visual language, not two. */\r\n.cv-gate-mark {\r\n display: grid;\r\n place-items: center;\r\n width: 52px;\r\n height: 52px;\r\n border-radius: 999px;\r\n background: var(--cv-accent-soft);\r\n color: var(--cv-accent-strong);\r\n margin-bottom: 0.15rem;\r\n}\r\n.cv-gate[data-tone='danger'] .cv-gate-mark {\r\n background: var(--cv-danger-soft);\r\n color: var(--cv-danger);\r\n}\r\n\r\n.cv-gate-title {\r\n margin: 0;\r\n font: 600 0.95rem var(--cv-font);\r\n color: var(--cv-text);\r\n letter-spacing: -0.01em;\r\n}\r\n.cv-gate p {\r\n margin: 0;\r\n color: var(--cv-muted);\r\n font-size: 0.8rem;\r\n line-height: 1.5;\r\n max-width: 24ch; /* two comfortable lines, not one long one */\r\n}\r\n.cv-gate .cv-btn { margin-top: 0.5rem; }\r\n\r\n/* Entrance. Staggered 45 ms apart: the eye follows mark \u2192 title \u2192 reason \u2192\r\n action, which is the order it needs to read them in anyway. */\r\n.cv-gate > * {\r\n animation: cv-gate-in 260ms var(--cv-ease-out) both;\r\n}\r\n.cv-gate > *:nth-child(2) { animation-delay: 45ms; }\r\n.cv-gate > *:nth-child(3) { animation-delay: 90ms; }\r\n.cv-gate > *:nth-child(4) { animation-delay: 135ms; }\r\n\r\n@keyframes cv-gate-in {\r\n from { opacity: 0; transform: translateY(6px); }\r\n to { opacity: 1; transform: none; }\r\n}\r\n\r\n/* The checking state: held back, then a quiet fade. No stagger, no movement \u2014\r\n it is a status, not a screen, and it should barely register. */\r\n.cv-gate[data-state='checking'] > *,\r\n.cv-gate[data-state='warming'] > * {\r\n animation: cv-gate-wait 200ms var(--cv-ease-out) 450ms both;\r\n}\r\n@keyframes cv-gate-wait {\r\n from { opacity: 0; }\r\n to { opacity: 1; }\r\n}\r\n/* A slow breath rather than a spinner: a spinner promises a long wait, and this\r\n is not one. */\r\n.cv-gate[data-state='checking'] .cv-gate-mark,\r\n.cv-gate[data-state='warming'] .cv-gate-mark {\r\n background: var(--cv-surface-2);\r\n color: var(--cv-muted);\r\n animation: cv-gate-wait 200ms var(--cv-ease-out) 450ms both,\r\n cv-breathe 1.8s ease-in-out 450ms infinite;\r\n}\r\n@keyframes cv-breathe {\r\n 0%, 100% { opacity: 1; }\r\n 50% { opacity: 0.45; }\r\n}\r\n\r\n/* Reduced motion: keep the fade and \u2014 critically \u2014 keep the DELAY, which is\r\n timing rather than motion and is the whole point. Drop the travel. */\r\n@media (prefers-reduced-motion: reduce) {\r\n .cv-gate > * {\r\n animation: cv-gate-wait 200ms var(--cv-ease-out) both;\r\n }\r\n .cv-gate[data-state='checking'] .cv-gate-mark,\r\n .cv-gate[data-state='warming'] .cv-gate-mark {\r\n animation: cv-gate-wait 200ms var(--cv-ease-out) 450ms both;\r\n }\r\n}\r\n\r\n@media (prefers-reduced-motion: reduce) {\r\n .cv-avatar-ping,\r\n .cv-timer i { animation: none; }\r\n .cv-softphone *,\r\n .cv-softphone *::before { transition-duration: 0.01ms !important; }\r\n}\r\n\r\n/* `*` and `#` stay in the grid \u2014 a keypad without them is not a keypad \u2014 but they are not part of a\r\n number (toE164 keeps digits only). Dimmed rather than `disabled` so the grid still reads as a\r\n keypad and the glyphs stay legible; they become live DTMF on the in-call screen. */\r\n.cv-key[data-dialable='false'] {\r\n cursor: default;\r\n}\r\n.cv-key[data-dialable='false'] b {\r\n color: var(--cv-faint);\r\n}\r\n.cv-key[data-dialable='false']:hover {\r\n background: none;\r\n}\r\n.cv-key[data-dialable='false']:active {\r\n transform: none;\r\n}\r\n\r\n/* \u2500\u2500 Country picker \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n The prefix chip + its list. Sits where the old left spacer was, so the number\r\n stays optically centred against the 36px erase button on the right.\r\n\r\n The popover is absolutely positioned inside the widget rather than portalled:\r\n there is nowhere to portal TO inside a shadow root, and the widget already owns\r\n its stacking context. That also means it cannot be clipped by a host's\r\n `overflow:hidden` \u2014 the failure SPEC \xA72.6 lists for the CRM's Radix version.\r\n \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\r\n/* The anchor spans the whole ROW, not just the chip.\r\n With the chip centred, anchoring to the chip put the popover's left edge at ~120px and its right\r\n edge past the widget \u2014 clipped by `.cv-softphone { overflow: hidden }`, which is the classic\r\n absolute-inside-overflow bug. Anchoring to the full row makes overflowing arithmetically\r\n impossible: `left:0; right:0` cannot be wider than the row that contains it. */\r\n.cv-cp {\r\n position: relative;\r\n display: flex;\r\n justify-content: center;\r\n width: 100%;\r\n}\r\n/* A PILL, with a resting surface and a border.\r\n This was `border: none; background: none` \u2014 invisible until hover, so it read as a label rather\r\n than a control. That was survivable when it sat inline beside the number; alone on its own row it\r\n is just floating text, and nothing says \"you can change this\". A control that changes state has to\r\n look like one at rest, not only under a cursor that may never arrive (touch has no hover at all). */\r\n.cv-cp-chip {\r\n display: inline-flex;\r\n align-items: center;\r\n gap: 0.3rem;\r\n height: 30px;\r\n padding: 0 0.5rem 0 0.4rem;\r\n border: 1px solid var(--cv-border-strong);\r\n border-radius: 999px;\r\n background: var(--cv-surface);\r\n color: var(--cv-text);\r\n font: 600 0.8rem var(--cv-font);\r\n cursor: pointer;\r\n transition: background 0.15s, border-color 0.15s;\r\n}\r\n.cv-cp-chip:hover { background: var(--cv-surface-2); }\r\n.cv-cp-chip:active { transform: scale(0.95); }\r\n/* Open: the chip is the thing the list belongs to, so it stays visibly engaged while the list is up. */\r\n.cv-cp-chip[aria-expanded='true'] {\r\n background: var(--cv-accent-soft);\r\n border-color: var(--cv-accent);\r\n}\r\n.cv-cp-dial {\r\n font-variant-numeric: tabular-nums;\r\n letter-spacing: -0.02em;\r\n}\r\n.cv-cp-caret {\r\n color: var(--cv-faint);\r\n flex-shrink: 0;\r\n transition: transform 0.18s cubic-bezier(0.2, 0, 0.2, 1);\r\n}\r\n.cv-cp-caret[data-open='true'] { transform: rotate(180deg); }\r\n\r\n/* The country mark. TWO designed states in ONE 20\xD714 box (a flag's own 3:2), so the chip and every\r\n row keep their rhythm whichever is showing \u2014 nothing reflows when the flags land. The plate is the\r\n LOADING state (flags are a lazy import) and the offline state, not a fallback: there is no flash of\r\n something broken, only a designed plate becoming a designed flag, once per session.\r\n `::part(country-mark)` lets a host restyle either. */\r\n.cv-mark {\r\n width: 20px;\r\n height: 14px;\r\n flex-shrink: 0;\r\n display: inline-flex;\r\n align-items: center;\r\n justify-content: center;\r\n font-size: 14px;\r\n line-height: 1;\r\n font-family: 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', var(--cv-font);\r\n}\r\n/* The SVG fills the slot exactly; 3x2 is the flag aspect, so nothing is letterboxed or cropped. */\r\n.cv-mark-svg {\r\n width: 100%;\r\n height: 100%;\r\n display: block;\r\n border-radius: 2px;\r\n object-fit: cover;\r\n}\r\n.cv-mark[data-plate='true'] {\r\n /* The ISO code set into the slot a flag will occupy. */\r\n font-family: var(--cv-font);\r\n font-size: 8.5px;\r\n font-weight: 700;\r\n letter-spacing: 0.04em;\r\n color: var(--cv-text);\r\n background: var(--cv-surface-2);\r\n border: 1px solid var(--cv-border);\r\n border-radius: 3px;\r\n /* Cap-height letters sit optically high in a 14px box; nudge them back to centre. */\r\n padding-top: 1px;\r\n}\r\n\r\n.cv-cp-pop {\r\n position: absolute;\r\n top: calc(100% + 6px);\r\n /* Pinned to BOTH row edges, so it is bounded by the container by construction \u2014 no width guess,\r\n no media query, nothing to clip. `max-width` keeps it from looking stretched on a wide host. */\r\n left: 0;\r\n right: 0;\r\n margin-inline: auto;\r\n max-width: 260px;\r\n z-index: 20;\r\n display: flex;\r\n flex-direction: column;\r\n background: var(--cv-surface);\r\n border: 1px solid var(--cv-border);\r\n border-radius: var(--cv-radius);\r\n box-shadow: var(--cv-shadow);\r\n overflow: hidden;\r\n /* Grows from the chip it belongs to, rather than appearing on top of it. */\r\n transform-origin: top center;\r\n animation: cv-cp-in 0.14s cubic-bezier(0.2, 0, 0.2, 1);\r\n}\r\n@keyframes cv-cp-in {\r\n from { opacity: 0; transform: translateY(-4px) scale(0.97); }\r\n to { opacity: 1; transform: none; }\r\n}\r\n@media (prefers-reduced-motion: reduce) {\r\n .cv-cp-pop { animation: none; }\r\n .cv-cp-caret { transition: none; }\r\n}\r\n.cv-cp-search {\r\n flex-shrink: 0;\r\n padding: 0.5rem 0.65rem;\r\n border: none;\r\n border-bottom: 1px solid var(--cv-border);\r\n background: none;\r\n color: var(--cv-text);\r\n font: 400 0.8rem var(--cv-font);\r\n outline: none;\r\n}\r\n.cv-cp-search::placeholder { color: var(--cv-faint); }\r\n/* Both states named explicitly. In the CRM the widget mounts via `/ui`, i.e. in the LIGHT DOM \u2014 none\r\n of the shadow-DOM isolation the web-component playground proves applies there, so a host's focus\r\n ring (or the browser's own) can land on our input. The accent underline IS the focus indicator. */\r\n.cv-cp-search:focus,\r\n.cv-cp-search:focus-visible {\r\n outline: none;\r\n border-bottom-color: var(--cv-accent);\r\n border-bottom-width: 2px;\r\n padding-bottom: calc(0.5rem - 1px);\r\n}\r\n.cv-cp-list {\r\n overflow-y: auto;\r\n max-height: 220px;\r\n padding: 0.25rem;\r\n}\r\n.cv-cp-item {\r\n width: 100%;\r\n display: flex;\r\n align-items: center;\r\n gap: 0.5rem;\r\n padding: 0.4rem 0.45rem;\r\n border: none;\r\n border-radius: calc(var(--cv-radius) - 4px);\r\n background: none;\r\n color: var(--cv-text);\r\n font: 400 0.8rem var(--cv-font);\r\n text-align: left;\r\n cursor: pointer;\r\n}\r\n.cv-cp-item:hover { background: var(--cv-surface-2); }\r\n/* The current country is stated by the row, not only by a tick at the far right. */\r\n.cv-cp-item[aria-selected='true'] { background: var(--cv-accent-soft); }\r\n.cv-cp-item[aria-selected='true'] .cv-cp-name { font-weight: 600; }\r\n.cv-cp-name {\r\n flex: 1;\r\n min-width: 0;\r\n overflow: hidden;\r\n text-overflow: ellipsis;\r\n white-space: nowrap;\r\n}\r\n.cv-cp-item .cv-cp-dial {\r\n color: var(--cv-muted);\r\n font-weight: 400;\r\n}\r\n.cv-cp-check { color: var(--cv-accent-strong); flex-shrink: 0; }\r\n.cv-cp-check[data-on='false'] { opacity: 0; }\r\n.cv-cp-empty {\r\n margin: 0;\r\n padding: 1rem 0.5rem;\r\n text-align: center;\r\n color: var(--cv-muted);\r\n font-size: 0.78rem;\r\n}\r\n";
|
|
9724
10654
|
|
|
9725
10655
|
// src/element/index.ts
|
|
9726
10656
|
var EVENTS = {
|
|
@@ -9897,9 +10827,9 @@ var CoventiaSoftphoneElement = class extends HTMLElement {
|
|
|
9897
10827
|
...this.#config.incomingCallAction ? { incomingCallAction: this.#config.incomingCallAction } : {}
|
|
9898
10828
|
};
|
|
9899
10829
|
this.#root.render(
|
|
9900
|
-
(0,
|
|
10830
|
+
(0, import_react41.createElement)(
|
|
9901
10831
|
SoftphoneProvider,
|
|
9902
|
-
{ softphone: this.#softphone, children: (0,
|
|
10832
|
+
{ softphone: this.#softphone, children: (0, import_react41.createElement)(SoftphoneWidget, widgetProps) }
|
|
9903
10833
|
)
|
|
9904
10834
|
);
|
|
9905
10835
|
}
|
|
@@ -9952,6 +10882,7 @@ lucide-react/dist/esm/createLucideIcon.js:
|
|
|
9952
10882
|
lucide-react/dist/esm/icons/arrow-left-right.js:
|
|
9953
10883
|
lucide-react/dist/esm/icons/arrow-left.js:
|
|
9954
10884
|
lucide-react/dist/esm/icons/arrow-right.js:
|
|
10885
|
+
lucide-react/dist/esm/icons/bell-ring.js:
|
|
9955
10886
|
lucide-react/dist/esm/icons/calendar-clock.js:
|
|
9956
10887
|
lucide-react/dist/esm/icons/check.js:
|
|
9957
10888
|
lucide-react/dist/esm/icons/chevron-down.js:
|
|
@@ -9967,6 +10898,7 @@ lucide-react/dist/esm/icons/loader-circle.js:
|
|
|
9967
10898
|
lucide-react/dist/esm/icons/mic-off.js:
|
|
9968
10899
|
lucide-react/dist/esm/icons/mic.js:
|
|
9969
10900
|
lucide-react/dist/esm/icons/monitor-smartphone.js:
|
|
10901
|
+
lucide-react/dist/esm/icons/music.js:
|
|
9970
10902
|
lucide-react/dist/esm/icons/pause.js:
|
|
9971
10903
|
lucide-react/dist/esm/icons/phone-incoming.js:
|
|
9972
10904
|
lucide-react/dist/esm/icons/phone-missed.js:
|
|
@@ -9980,6 +10912,7 @@ lucide-react/dist/esm/icons/timer.js:
|
|
|
9980
10912
|
lucide-react/dist/esm/icons/triangle-alert.js:
|
|
9981
10913
|
lucide-react/dist/esm/icons/user.js:
|
|
9982
10914
|
lucide-react/dist/esm/icons/users.js:
|
|
10915
|
+
lucide-react/dist/esm/icons/volume-2.js:
|
|
9983
10916
|
lucide-react/dist/esm/icons/x.js:
|
|
9984
10917
|
lucide-react/dist/esm/lucide-react.js:
|
|
9985
10918
|
(**
|