@coventia/softphone 0.3.0 → 0.5.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-CaPDWVk3.d.cts → call-FqcvDg1t.d.cts} +168 -12
- package/dist/{call-CaPDWVk3.d.ts → call-FqcvDg1t.d.ts} +168 -12
- package/dist/chunk-5E5OEXS2.js +14 -0
- package/dist/chunk-5E5OEXS2.js.map +1 -0
- package/dist/{chunk-EYEDPD7Y.js → chunk-6RR6G35E.js} +92 -17
- package/dist/chunk-6RR6G35E.js.map +1 -0
- package/dist/{chunk-2ZSOGGPO.js → chunk-FRXHEHQA.js} +15 -2
- package/dist/chunk-FRXHEHQA.js.map +1 -0
- package/dist/{chunk-UHNLEBBO.js → chunk-I3RSC52R.js} +28 -3
- package/dist/chunk-I3RSC52R.js.map +1 -0
- package/dist/chunk-M76SAXXM.js +13 -0
- package/dist/chunk-M76SAXXM.js.map +1 -0
- package/dist/{chunk-7R6LOOAI.js → chunk-N4PLEYFJ.js} +4 -2
- package/dist/chunk-N4PLEYFJ.js.map +1 -0
- package/dist/element/index.d.ts +198 -6
- package/dist/element/index.js +1160 -489
- package/dist/element/index.js.map +1 -1
- package/dist/{engine-BgBsIcEa.d.ts → engine-CsNRD7B7.d.ts} +1 -1
- package/dist/{engine-CU2rEo3g.d.cts → engine-CzOCrfY9.d.cts} +1 -1
- package/dist/index.cjs +675 -53
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +16 -6
- package/dist/index.d.ts +16 -6
- package/dist/index.js +453 -37
- package/dist/index.js.map +1 -1
- package/dist/react/index.cjs +26 -0
- package/dist/react/index.cjs.map +1 -1
- package/dist/react/index.d.cts +24 -5
- package/dist/react/index.d.ts +24 -5
- package/dist/react/index.js +2 -2
- package/dist/{softphone-DEt0TtQw.d.ts → softphone-By9sizlV.d.ts} +44 -3
- package/dist/{softphone-dZMkW8V1.d.cts → softphone-JZvJdzU3.d.cts} +44 -3
- package/dist/softphone.css +254 -23
- package/dist/{sources-M3GYMS6V.js → sources-QL2LRZVH.js} +93 -8
- package/dist/sources-QL2LRZVH.js.map +1 -0
- package/dist/testing/index.cjs +13 -0
- package/dist/testing/index.cjs.map +1 -1
- package/dist/testing/index.d.cts +2 -2
- package/dist/testing/index.d.ts +2 -2
- package/dist/testing/index.js +1 -1
- package/dist/ui/index.cjs +670 -20
- package/dist/ui/index.cjs.map +1 -1
- package/dist/ui/index.d.cts +2 -2
- package/dist/ui/index.d.ts +2 -2
- package/dist/ui/index.js +642 -22
- package/dist/ui/index.js.map +1 -1
- package/dist/zadarma/index.cjs +111 -14
- package/dist/zadarma/index.cjs.map +1 -1
- package/dist/zadarma/index.d.cts +5 -6
- package/dist/zadarma/index.d.ts +5 -6
- package/dist/zadarma/index.js +3 -2
- package/dist/zadarma-engine-TIUE7PZ7.js +6 -0
- package/dist/{zadarma-engine-L5DEFXUS.js.map → zadarma-engine-TIUE7PZ7.js.map} +1 -1
- package/package.json +1 -1
- package/dist/chunk-2ZSOGGPO.js.map +0 -1
- package/dist/chunk-7R6LOOAI.js.map +0 -1
- package/dist/chunk-EYEDPD7Y.js.map +0 -1
- package/dist/chunk-UHNLEBBO.js.map +0 -1
- package/dist/sources-M3GYMS6V.js.map +0 -1
- package/dist/zadarma-engine-L5DEFXUS.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_react47 = __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_react45 = __toESM(require_react(), 1);
|
|
6954
6954
|
|
|
6955
6955
|
// ../../node_modules/lucide-react/dist/esm/createLucideIcon.js
|
|
6956
6956
|
var import_react3 = __toESM(require_react());
|
|
@@ -7178,6 +7178,19 @@ var Pause = createLucideIcon("Pause", [
|
|
|
7178
7178
|
["rect", { x: "6", y: "4", width: "4", height: "16", rx: "1", key: "1okwgv" }]
|
|
7179
7179
|
]);
|
|
7180
7180
|
|
|
7181
|
+
// ../../node_modules/lucide-react/dist/esm/icons/phone-forwarded.js
|
|
7182
|
+
var PhoneForwarded = createLucideIcon("PhoneForwarded", [
|
|
7183
|
+
["polyline", { points: "18 2 22 6 18 10", key: "6vjanh" }],
|
|
7184
|
+
["line", { x1: "14", x2: "22", y1: "6", y2: "6", key: "1jsywh" }],
|
|
7185
|
+
[
|
|
7186
|
+
"path",
|
|
7187
|
+
{
|
|
7188
|
+
d: "M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z",
|
|
7189
|
+
key: "foiqr5"
|
|
7190
|
+
}
|
|
7191
|
+
]
|
|
7192
|
+
]);
|
|
7193
|
+
|
|
7181
7194
|
// ../../node_modules/lucide-react/dist/esm/icons/phone-incoming.js
|
|
7182
7195
|
var PhoneIncoming = createLucideIcon("PhoneIncoming", [
|
|
7183
7196
|
["polyline", { points: "16 2 16 8 22 8", key: "1ygljm" }],
|
|
@@ -7349,6 +7362,31 @@ function useIsLeader() {
|
|
|
7349
7362
|
(0, import_react4.useCallback)(() => phone.isLeader, [phone])
|
|
7350
7363
|
);
|
|
7351
7364
|
}
|
|
7365
|
+
function useLine() {
|
|
7366
|
+
const phone = useSoftphone();
|
|
7367
|
+
const cache = (0, import_react4.useRef)(null);
|
|
7368
|
+
return (0, import_react4.useSyncExternalStore)(
|
|
7369
|
+
(0, import_react4.useCallback)(
|
|
7370
|
+
(cb) => {
|
|
7371
|
+
const offLine = phone.on("lineChanged" /* LineChanged */, () => cb());
|
|
7372
|
+
const offClaim = phone.on("lineClaimChanged" /* LineClaimChanged */, () => cb());
|
|
7373
|
+
return () => {
|
|
7374
|
+
offLine();
|
|
7375
|
+
offClaim();
|
|
7376
|
+
};
|
|
7377
|
+
},
|
|
7378
|
+
[phone]
|
|
7379
|
+
),
|
|
7380
|
+
(0, import_react4.useCallback)(() => {
|
|
7381
|
+
const { line, lineWhere: where, lineClaim: claim } = phone;
|
|
7382
|
+
const cached = cache.current;
|
|
7383
|
+
if (cached?.line === line && cached.where === where && cached.claim === claim) return cached;
|
|
7384
|
+
const view = { line, where, claim };
|
|
7385
|
+
cache.current = view;
|
|
7386
|
+
return view;
|
|
7387
|
+
}, [phone])
|
|
7388
|
+
);
|
|
7389
|
+
}
|
|
7352
7390
|
function useActiveCall() {
|
|
7353
7391
|
const phone = useSoftphone();
|
|
7354
7392
|
return (0, import_react4.useSyncExternalStore)(
|
|
@@ -7446,7 +7484,7 @@ function useDevices() {
|
|
|
7446
7484
|
}
|
|
7447
7485
|
|
|
7448
7486
|
// src/ui/views/Dialpad.tsx
|
|
7449
|
-
var
|
|
7487
|
+
var import_react11 = __toESM(require_react(), 1);
|
|
7450
7488
|
|
|
7451
7489
|
// src/ui/dialing/CountryPicker.tsx
|
|
7452
7490
|
var import_react5 = __toESM(require_react(), 1);
|
|
@@ -7456,6 +7494,36 @@ var PACKED = "AC247 AD376 AE971 AF93 AG1 AI1 AL355 AM374 AO244 AR54 AS1 AT43 AU6
|
|
|
7456
7494
|
var DIAL_CODES = new Map(
|
|
7457
7495
|
PACKED.split(" ").map((token) => [token.slice(0, 2), Number(token.slice(2))])
|
|
7458
7496
|
);
|
|
7497
|
+
var REGIONS_BY_CODE = (() => {
|
|
7498
|
+
const map = /* @__PURE__ */ new Map();
|
|
7499
|
+
for (const [region, code] of DIAL_CODES) map.set(code, [...map.get(code) ?? [], region]);
|
|
7500
|
+
return map;
|
|
7501
|
+
})();
|
|
7502
|
+
var MAIN_REGION = {
|
|
7503
|
+
1: "US",
|
|
7504
|
+
7: "RU",
|
|
7505
|
+
39: "IT",
|
|
7506
|
+
44: "GB",
|
|
7507
|
+
47: "NO",
|
|
7508
|
+
61: "AU",
|
|
7509
|
+
212: "MA",
|
|
7510
|
+
262: "RE",
|
|
7511
|
+
290: "SH",
|
|
7512
|
+
358: "FI",
|
|
7513
|
+
590: "GP",
|
|
7514
|
+
599: "CW"
|
|
7515
|
+
};
|
|
7516
|
+
function regionOfInternational(typed) {
|
|
7517
|
+
const text = String(typed ?? "").trim();
|
|
7518
|
+
if (!text.startsWith("+")) return null;
|
|
7519
|
+
const digits = text.slice(1).replace(/\D/g, "");
|
|
7520
|
+
for (let length = 1; length <= Math.min(3, digits.length); length++) {
|
|
7521
|
+
const code = Number(digits.slice(0, length));
|
|
7522
|
+
const regions = REGIONS_BY_CODE.get(code);
|
|
7523
|
+
if (regions) return MAIN_REGION[code] ?? regions[0] ?? null;
|
|
7524
|
+
}
|
|
7525
|
+
return null;
|
|
7526
|
+
}
|
|
7459
7527
|
var DEFAULT_COUNTRY = "ES";
|
|
7460
7528
|
var REGION_NAMES = (() => {
|
|
7461
7529
|
try {
|
|
@@ -7510,12 +7578,16 @@ function CountryMark({ code }) {
|
|
|
7510
7578
|
}
|
|
7511
7579
|
function CountryPicker({
|
|
7512
7580
|
value,
|
|
7513
|
-
onChange
|
|
7581
|
+
onChange,
|
|
7582
|
+
fromNumber = false
|
|
7514
7583
|
}) {
|
|
7515
7584
|
const [open, setOpen] = (0, import_react5.useState)(false);
|
|
7516
7585
|
const [query, setQuery] = (0, import_react5.useState)("");
|
|
7517
7586
|
const rootRef = (0, import_react5.useRef)(null);
|
|
7518
7587
|
const inputRef = (0, import_react5.useRef)(null);
|
|
7588
|
+
(0, import_react5.useEffect)(() => {
|
|
7589
|
+
if (fromNumber) setOpen(false);
|
|
7590
|
+
}, [fromNumber]);
|
|
7519
7591
|
const results = (0, import_react5.useMemo)(() => {
|
|
7520
7592
|
const q = query.trim().toLowerCase().replace(/^\+/, "");
|
|
7521
7593
|
if (!q) return COUNTRIES;
|
|
@@ -7544,6 +7616,12 @@ function CountryPicker({
|
|
|
7544
7616
|
else setQuery("");
|
|
7545
7617
|
}, [open]);
|
|
7546
7618
|
const dial = DIAL_CODES.get(value);
|
|
7619
|
+
if (fromNumber) {
|
|
7620
|
+
return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "cv-cp", ref: rootRef, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("span", { className: "cv-cp-chip", "data-from-number": true, title: "El prefijo viene del n\xFAmero marcado", part: "country-chip", children: [
|
|
7621
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(CountryMark, { code: value || "+" }),
|
|
7622
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "cv-sr-only", children: dial ? `Prefijo del n\xFAmero marcado: ${countryName(value)} (+${dial})` : "Prefijo del n\xFAmero marcado" })
|
|
7623
|
+
] }) });
|
|
7624
|
+
}
|
|
7547
7625
|
return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "cv-cp", ref: rootRef, children: [
|
|
7548
7626
|
/* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
|
|
7549
7627
|
"button",
|
|
@@ -8114,27 +8192,413 @@ function playRingback() {
|
|
|
8114
8192
|
};
|
|
8115
8193
|
}
|
|
8116
8194
|
|
|
8117
|
-
// src/ui/
|
|
8195
|
+
// src/ui/outbound/OutboundNumberPicker.tsx
|
|
8196
|
+
var import_react9 = __toESM(require_react(), 1);
|
|
8197
|
+
|
|
8198
|
+
// src/ui/outbound/outbound-change.ts
|
|
8199
|
+
var import_react8 = __toESM(require_react(), 1);
|
|
8200
|
+
|
|
8201
|
+
// src/core/outbound-numbers.ts
|
|
8202
|
+
function isOutboundNumberRefused(value) {
|
|
8203
|
+
const refusal = value?.refusal;
|
|
8204
|
+
return value instanceof Error && !!refusal && (refusal.reason === "platform" || refusal.reason === "user") && typeof refusal.retryAfterSeconds === "number";
|
|
8205
|
+
}
|
|
8206
|
+
|
|
8207
|
+
// src/ui/outbound/outbound-number-store.ts
|
|
8208
|
+
var import_react7 = __toESM(require_react(), 1);
|
|
8209
|
+
var stores = /* @__PURE__ */ new WeakMap();
|
|
8210
|
+
function outboundNumberStore(softphone) {
|
|
8211
|
+
const source = softphone.sources?.outboundNumbers;
|
|
8212
|
+
if (!source) return null;
|
|
8213
|
+
let store = stores.get(softphone);
|
|
8214
|
+
if (!store) {
|
|
8215
|
+
store = createOutboundNumberStore(source);
|
|
8216
|
+
stores.set(softphone, store);
|
|
8217
|
+
}
|
|
8218
|
+
return store;
|
|
8219
|
+
}
|
|
8220
|
+
function createOutboundNumberStore(source) {
|
|
8221
|
+
let settings = null;
|
|
8222
|
+
let inFlight = null;
|
|
8223
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
8224
|
+
const apply = (next) => {
|
|
8225
|
+
settings = next;
|
|
8226
|
+
for (const l of listeners) l();
|
|
8227
|
+
};
|
|
8228
|
+
return {
|
|
8229
|
+
getSettings: () => settings,
|
|
8230
|
+
refresh() {
|
|
8231
|
+
inFlight ??= (async () => {
|
|
8232
|
+
if (!settings && source.cached) {
|
|
8233
|
+
const early = await source.cached().catch(() => null);
|
|
8234
|
+
if (early && !settings) apply(early);
|
|
8235
|
+
}
|
|
8236
|
+
try {
|
|
8237
|
+
apply(await source.get());
|
|
8238
|
+
} catch (error) {
|
|
8239
|
+
console.warn("[softphone] outbound numbers could not be loaded; keeping the previous ones", error);
|
|
8240
|
+
} finally {
|
|
8241
|
+
inFlight = null;
|
|
8242
|
+
}
|
|
8243
|
+
})();
|
|
8244
|
+
return inFlight;
|
|
8245
|
+
},
|
|
8246
|
+
async setMine(id) {
|
|
8247
|
+
apply(await source.setMine(id));
|
|
8248
|
+
},
|
|
8249
|
+
subscribe(listener) {
|
|
8250
|
+
listeners.add(listener);
|
|
8251
|
+
return () => listeners.delete(listener);
|
|
8252
|
+
}
|
|
8253
|
+
};
|
|
8254
|
+
}
|
|
8255
|
+
var noSubscribe = () => () => {
|
|
8256
|
+
};
|
|
8257
|
+
var noSettings = () => null;
|
|
8258
|
+
function useOutboundNumbers(store) {
|
|
8259
|
+
const settings = (0, import_react7.useSyncExternalStore)(store?.subscribe ?? noSubscribe, store?.getSettings ?? noSettings);
|
|
8260
|
+
const [failed, setFailed] = (0, import_react7.useState)(false);
|
|
8261
|
+
const load = (0, import_react7.useCallback)(() => {
|
|
8262
|
+
if (!store) return () => {
|
|
8263
|
+
};
|
|
8264
|
+
let live = true;
|
|
8265
|
+
setFailed(false);
|
|
8266
|
+
void store.refresh().then(() => {
|
|
8267
|
+
if (live) setFailed(store.getSettings() === null);
|
|
8268
|
+
});
|
|
8269
|
+
return () => {
|
|
8270
|
+
live = false;
|
|
8271
|
+
};
|
|
8272
|
+
}, [store]);
|
|
8273
|
+
(0, import_react7.useEffect)(load, [load]);
|
|
8274
|
+
return { settings, failed, retry: load };
|
|
8275
|
+
}
|
|
8276
|
+
function useKnownOutboundNumbers(store) {
|
|
8277
|
+
return (0, import_react7.useSyncExternalStore)(store?.subscribe ?? noSubscribe, store?.getSettings ?? noSettings);
|
|
8278
|
+
}
|
|
8279
|
+
function useCountdown(until) {
|
|
8280
|
+
const [, setTick] = (0, import_react7.useState)(0);
|
|
8281
|
+
(0, import_react7.useEffect)(() => {
|
|
8282
|
+
if (!until) return void 0;
|
|
8283
|
+
const timer = setInterval(() => {
|
|
8284
|
+
setTick((n) => n + 1);
|
|
8285
|
+
if (secondsLeft(until) === 0) clearInterval(timer);
|
|
8286
|
+
}, 1e3);
|
|
8287
|
+
return () => clearInterval(timer);
|
|
8288
|
+
}, [until]);
|
|
8289
|
+
return secondsLeft(until);
|
|
8290
|
+
}
|
|
8291
|
+
function secondsLeft(until) {
|
|
8292
|
+
return until ? Math.max(0, Math.ceil((until - Date.now()) / 1e3)) : 0;
|
|
8293
|
+
}
|
|
8294
|
+
function numberName(n, format = formatIntl) {
|
|
8295
|
+
if (!n) return "N\xFAmero de la organizaci\xF3n";
|
|
8296
|
+
return n.label || format(`+${n.number}`);
|
|
8297
|
+
}
|
|
8298
|
+
function effectiveNumber(settings) {
|
|
8299
|
+
return settings?.numbers.find((n) => n.id === settings.effective);
|
|
8300
|
+
}
|
|
8301
|
+
|
|
8302
|
+
// src/ui/outbound/outbound-change.ts
|
|
8303
|
+
var KIND_TAG = {
|
|
8304
|
+
mobile: { tag: "M\xF3vil", hint: "No usar para atenci\xF3n al cliente ni para llamadas comerciales no solicitadas." },
|
|
8305
|
+
commercial: { tag: "Comercial (400)", hint: "Solo para llamadas comerciales. No recibe llamadas." }
|
|
8306
|
+
};
|
|
8307
|
+
function noticeText(situation, seconds, current) {
|
|
8308
|
+
switch (situation) {
|
|
8309
|
+
case "platform":
|
|
8310
|
+
return `El cambio de n\xFAmero de salida no est\xE1 disponible temporalmente. Vuelve a intentarlo en ${seconds} s. Hasta entonces, tus llamadas seguir\xE1n saliendo desde ${current}.`;
|
|
8311
|
+
case "user":
|
|
8312
|
+
return `Acabas de cambiar el n\xFAmero de salida. Podr\xE1s cambiarlo de nuevo en ${seconds} s. Hasta entonces, tus llamadas saldr\xE1n desde ${current}.`;
|
|
8313
|
+
case "wait":
|
|
8314
|
+
return `Podr\xE1s cambiar el n\xFAmero de salida en ${seconds} s.`;
|
|
8315
|
+
case "pending":
|
|
8316
|
+
return seconds > 0 ? `Tu n\xFAmero de salida todav\xEDa no se ha aplicado. Podr\xE1s volver a elegirlo en ${seconds} s.` : "Tu n\xFAmero de salida todav\xEDa no se ha aplicado. Vuelve a elegirlo para aplicarlo.";
|
|
8317
|
+
}
|
|
8318
|
+
}
|
|
8319
|
+
function useOutboundNumberChange(store, settings, current) {
|
|
8320
|
+
const [busy, setBusy] = (0, import_react8.useState)(false);
|
|
8321
|
+
const [notice, setNotice] = (0, import_react8.useState)(null);
|
|
8322
|
+
const [waitUntil, setWaitUntil] = (0, import_react8.useState)(null);
|
|
8323
|
+
(0, import_react8.useEffect)(() => {
|
|
8324
|
+
const s = settings?.changeAvailableInSeconds ?? 0;
|
|
8325
|
+
setWaitUntil(s > 0 ? Date.now() + s * 1e3 : null);
|
|
8326
|
+
}, [settings]);
|
|
8327
|
+
const refusedUntil = notice?.kind === "refused" ? notice.until : null;
|
|
8328
|
+
const blockedFor = useCountdown(refusedUntil ?? waitUntil);
|
|
8329
|
+
(0, import_react8.useEffect)(() => {
|
|
8330
|
+
if (notice?.kind === "refused" && blockedFor === 0) setNotice(null);
|
|
8331
|
+
}, [notice, blockedFor]);
|
|
8332
|
+
const locked = busy || blockedFor > 0;
|
|
8333
|
+
const choose = async (id) => {
|
|
8334
|
+
if (!store || !settings || locked) return false;
|
|
8335
|
+
if (id === settings.mine) return true;
|
|
8336
|
+
setBusy(true);
|
|
8337
|
+
setNotice(null);
|
|
8338
|
+
try {
|
|
8339
|
+
await store.setMine(id);
|
|
8340
|
+
return true;
|
|
8341
|
+
} catch (error) {
|
|
8342
|
+
setNotice(
|
|
8343
|
+
isOutboundNumberRefused(error) ? { kind: "refused", reason: error.refusal.reason, until: Date.now() + error.refusal.retryAfterSeconds * 1e3 } : { kind: "error", text: errorText(error) }
|
|
8344
|
+
);
|
|
8345
|
+
return false;
|
|
8346
|
+
} finally {
|
|
8347
|
+
setBusy(false);
|
|
8348
|
+
}
|
|
8349
|
+
};
|
|
8350
|
+
return {
|
|
8351
|
+
busy,
|
|
8352
|
+
locked,
|
|
8353
|
+
choose,
|
|
8354
|
+
/** What to say about the choice right now, if anything. */
|
|
8355
|
+
message: settings ? currentMessage(notice, settings.state, blockedFor, current) : null,
|
|
8356
|
+
/** The message is an error (the change failed), not information (wait, pending). */
|
|
8357
|
+
isError: notice?.kind === "error"
|
|
8358
|
+
};
|
|
8359
|
+
}
|
|
8360
|
+
function currentMessage(notice, state, blockedFor, current) {
|
|
8361
|
+
if (notice?.kind === "refused") return blockedFor > 0 ? noticeText(notice.reason, blockedFor, current) : null;
|
|
8362
|
+
if (notice?.kind === "error") return notice.text;
|
|
8363
|
+
if (state === "pending" || state === "failed") return noticeText("pending", blockedFor, current);
|
|
8364
|
+
return blockedFor > 0 ? noticeText("wait", blockedFor, current) : null;
|
|
8365
|
+
}
|
|
8366
|
+
function errorText(error) {
|
|
8367
|
+
const message = error instanceof Error ? error.message : "";
|
|
8368
|
+
return message && !/HTTP \d+/.test(message) ? message : "No se ha podido cambiar el n\xFAmero de salida. Int\xE9ntalo de nuevo.";
|
|
8369
|
+
}
|
|
8370
|
+
|
|
8371
|
+
// src/ui/outbound/OutboundNumberPicker.tsx
|
|
8118
8372
|
var import_jsx_runtime3 = __toESM(require_jsx_runtime(), 1);
|
|
8373
|
+
var CALLOUT_MS = 6e3;
|
|
8374
|
+
function OutboundNumberPicker({ onBusyChange }) {
|
|
8375
|
+
const softphone = useSoftphone();
|
|
8376
|
+
const store = outboundNumberStore(softphone);
|
|
8377
|
+
const { settings } = useOutboundNumbers(store);
|
|
8378
|
+
const formatIntl2 = useIntlFormatter();
|
|
8379
|
+
const current = settings ? numberName(effectiveNumber(settings), formatIntl2) : "";
|
|
8380
|
+
const { busy, locked, choose: apply, message, isError } = useOutboundNumberChange(store, settings, current);
|
|
8381
|
+
const [open, setOpen] = (0, import_react9.useState)(false);
|
|
8382
|
+
const [applying, setApplying] = (0, import_react9.useState)(void 0);
|
|
8383
|
+
const [callout, setCallout] = (0, import_react9.useState)(false);
|
|
8384
|
+
const rootRef = (0, import_react9.useRef)(null);
|
|
8385
|
+
const chipRef = (0, import_react9.useRef)(null);
|
|
8386
|
+
const keys = useListKeyboard(open);
|
|
8387
|
+
(0, import_react9.useEffect)(() => onBusyChange?.(busy), [busy, onBusyChange]);
|
|
8388
|
+
(0, import_react9.useEffect)(() => {
|
|
8389
|
+
if (!message) setCallout(false);
|
|
8390
|
+
}, [message]);
|
|
8391
|
+
(0, import_react9.useEffect)(() => {
|
|
8392
|
+
if (!callout) return void 0;
|
|
8393
|
+
const timer = setTimeout(() => setCallout(false), CALLOUT_MS);
|
|
8394
|
+
return () => clearTimeout(timer);
|
|
8395
|
+
}, [callout]);
|
|
8396
|
+
const floating = open || callout;
|
|
8397
|
+
(0, import_react9.useEffect)(() => {
|
|
8398
|
+
if (!floating) return;
|
|
8399
|
+
const close = () => {
|
|
8400
|
+
setOpen(false);
|
|
8401
|
+
setCallout(false);
|
|
8402
|
+
};
|
|
8403
|
+
const root = rootRef.current?.getRootNode() ?? document;
|
|
8404
|
+
const onDown = (e) => {
|
|
8405
|
+
if (!rootRef.current?.contains(e.target)) close();
|
|
8406
|
+
};
|
|
8407
|
+
const onKey = (e) => {
|
|
8408
|
+
if (e.key !== "Escape") return;
|
|
8409
|
+
if (rootRef.current?.contains(e.target)) chipRef.current?.focus();
|
|
8410
|
+
close();
|
|
8411
|
+
};
|
|
8412
|
+
root.addEventListener("pointerdown", onDown);
|
|
8413
|
+
document.addEventListener("keydown", onKey);
|
|
8414
|
+
return () => {
|
|
8415
|
+
root.removeEventListener("pointerdown", onDown);
|
|
8416
|
+
document.removeEventListener("keydown", onKey);
|
|
8417
|
+
};
|
|
8418
|
+
}, [floating]);
|
|
8419
|
+
if (!store || !settings || settings.numbers.length < 2) return null;
|
|
8420
|
+
const orgDefault = settings.numbers.find((n) => n.id === settings.orgDefault);
|
|
8421
|
+
const applyingNumber = applying === null ? orgDefault : settings.numbers.find((n) => n.id === applying);
|
|
8422
|
+
const shown = applying === void 0 ? current : numberName(applyingNumber, formatIntl2);
|
|
8423
|
+
const choose = async (id) => {
|
|
8424
|
+
if (locked) return;
|
|
8425
|
+
setOpen(false);
|
|
8426
|
+
chipRef.current?.focus();
|
|
8427
|
+
if (id === settings.mine) return;
|
|
8428
|
+
setCallout(false);
|
|
8429
|
+
setApplying(id);
|
|
8430
|
+
const applied = await apply(id);
|
|
8431
|
+
setApplying(void 0);
|
|
8432
|
+
if (!applied) setCallout(true);
|
|
8433
|
+
};
|
|
8434
|
+
return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "cv-on", ref: rootRef, children: [
|
|
8435
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(
|
|
8436
|
+
"button",
|
|
8437
|
+
{
|
|
8438
|
+
ref: chipRef,
|
|
8439
|
+
type: "button",
|
|
8440
|
+
className: "cv-on-chip",
|
|
8441
|
+
onClick: () => {
|
|
8442
|
+
setCallout(false);
|
|
8443
|
+
setOpen((o) => !o);
|
|
8444
|
+
},
|
|
8445
|
+
"aria-haspopup": "listbox",
|
|
8446
|
+
"aria-expanded": open,
|
|
8447
|
+
"aria-label": `Llamar desde: ${shown}${busy ? " (aplicando)" : ""}. Cambiar n\xFAmero de salida`,
|
|
8448
|
+
"aria-busy": busy,
|
|
8449
|
+
part: "outbound-number-chip",
|
|
8450
|
+
children: [
|
|
8451
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { className: "cv-on-label", children: "Llamar desde:" }),
|
|
8452
|
+
busy ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(LoaderCircle, { size: 13, className: "cv-on-icon cv-on-spin", "aria-hidden": true }) : null,
|
|
8453
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { className: "cv-on-current", children: shown }),
|
|
8454
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(ChevronDown, { size: 13, className: "cv-cp-caret", "data-open": open, "aria-hidden": true })
|
|
8455
|
+
]
|
|
8456
|
+
}
|
|
8457
|
+
),
|
|
8458
|
+
open ? /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(
|
|
8459
|
+
"div",
|
|
8460
|
+
{
|
|
8461
|
+
className: "cv-cp-pop cv-on-pop",
|
|
8462
|
+
role: "dialog",
|
|
8463
|
+
"aria-label": "Llamar desde",
|
|
8464
|
+
part: "outbound-number-list",
|
|
8465
|
+
onBlur: (e) => {
|
|
8466
|
+
if (!e.currentTarget.contains(e.relatedTarget)) setOpen(false);
|
|
8467
|
+
},
|
|
8468
|
+
children: [
|
|
8469
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { className: "cv-on-title", children: "Llamar desde" }),
|
|
8470
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "cv-cp-list", role: "listbox", "aria-disabled": locked, ref: keys.listRef, onKeyDown: keys.onKeyDown, children: [
|
|
8471
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
|
|
8472
|
+
OptionRow,
|
|
8473
|
+
{
|
|
8474
|
+
selected: settings.mine === null,
|
|
8475
|
+
disabled: locked,
|
|
8476
|
+
title: "Por defecto de la organizaci\xF3n",
|
|
8477
|
+
sub: orgDefault ? numberName(orgDefault, formatIntl2) : "Sin n\xFAmero por defecto",
|
|
8478
|
+
onSelect: () => void choose(null)
|
|
8479
|
+
}
|
|
8480
|
+
),
|
|
8481
|
+
settings.numbers.map((n) => /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
|
|
8482
|
+
OptionRow,
|
|
8483
|
+
{
|
|
8484
|
+
selected: settings.mine === n.id,
|
|
8485
|
+
disabled: locked,
|
|
8486
|
+
title: numberName(n, formatIntl2),
|
|
8487
|
+
sub: n.label ? formatIntl2(`+${n.number}`) : null,
|
|
8488
|
+
tag: KIND_TAG[n.kind],
|
|
8489
|
+
onSelect: () => void choose(n.id)
|
|
8490
|
+
},
|
|
8491
|
+
n.id
|
|
8492
|
+
))
|
|
8493
|
+
] }),
|
|
8494
|
+
message ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { className: "cv-on-notice", role: "status", "data-kind": isError ? "error" : "info", children: message }) : null
|
|
8495
|
+
]
|
|
8496
|
+
}
|
|
8497
|
+
) : null,
|
|
8498
|
+
callout && !open && message ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { className: "cv-on-callout", role: "status", "data-kind": isError ? "error" : "info", part: "outbound-number-notice", children: message }) : null
|
|
8499
|
+
] });
|
|
8500
|
+
}
|
|
8501
|
+
function OptionRow({
|
|
8502
|
+
selected,
|
|
8503
|
+
disabled,
|
|
8504
|
+
title,
|
|
8505
|
+
sub,
|
|
8506
|
+
tag,
|
|
8507
|
+
onSelect
|
|
8508
|
+
}) {
|
|
8509
|
+
return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(
|
|
8510
|
+
"button",
|
|
8511
|
+
{
|
|
8512
|
+
type: "button",
|
|
8513
|
+
role: "option",
|
|
8514
|
+
"aria-selected": selected,
|
|
8515
|
+
"aria-disabled": disabled,
|
|
8516
|
+
className: "cv-cp-item cv-on-item",
|
|
8517
|
+
tabIndex: -1,
|
|
8518
|
+
onClick: onSelect,
|
|
8519
|
+
children: [
|
|
8520
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("span", { className: "cv-on-text", children: [
|
|
8521
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("span", { className: "cv-on-line", children: [
|
|
8522
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { className: "cv-cp-name", children: title }),
|
|
8523
|
+
tag ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { className: "cv-on-tag", children: tag.tag }) : null
|
|
8524
|
+
] }),
|
|
8525
|
+
sub ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { className: "cv-on-sub", children: sub }) : null,
|
|
8526
|
+
tag ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { className: "cv-on-hint", children: tag.hint }) : null
|
|
8527
|
+
] }),
|
|
8528
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(Check, { size: 14, className: "cv-cp-check", "data-on": selected, "aria-hidden": true })
|
|
8529
|
+
]
|
|
8530
|
+
}
|
|
8531
|
+
);
|
|
8532
|
+
}
|
|
8533
|
+
var optionsOf = (list) => list ? [...list.querySelectorAll('[role="option"]')] : [];
|
|
8534
|
+
var fold = (text) => text.normalize("NFD").replace(/\p{Diacritic}/gu, "").toLowerCase();
|
|
8535
|
+
var TYPEAHEAD_MS = 700;
|
|
8536
|
+
function useListKeyboard(open) {
|
|
8537
|
+
const listRef = (0, import_react9.useRef)(null);
|
|
8538
|
+
const typed = (0, import_react9.useRef)({ text: "", at: 0 });
|
|
8539
|
+
(0, import_react9.useEffect)(() => {
|
|
8540
|
+
if (!open) return;
|
|
8541
|
+
const options = optionsOf(listRef.current);
|
|
8542
|
+
(options.find((o) => o.getAttribute("aria-selected") === "true") ?? options[0])?.focus();
|
|
8543
|
+
}, [open]);
|
|
8544
|
+
const onKeyDown = (e) => {
|
|
8545
|
+
const options = optionsOf(listRef.current);
|
|
8546
|
+
const at = options.indexOf(e.target);
|
|
8547
|
+
const move = {
|
|
8548
|
+
ArrowDown: Math.min(at + 1, options.length - 1),
|
|
8549
|
+
ArrowUp: Math.max(at - 1, 0),
|
|
8550
|
+
Home: 0,
|
|
8551
|
+
End: options.length - 1
|
|
8552
|
+
};
|
|
8553
|
+
let next = move[e.key];
|
|
8554
|
+
if (next === void 0 && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
|
8555
|
+
const now = Date.now();
|
|
8556
|
+
const text = now - typed.current.at < TYPEAHEAD_MS ? typed.current.text + e.key : e.key;
|
|
8557
|
+
typed.current = { text, at: now };
|
|
8558
|
+
const wanted = fold(text);
|
|
8559
|
+
const match = options.findIndex((o) => fold(o.querySelector(".cv-cp-name")?.textContent ?? "").startsWith(wanted));
|
|
8560
|
+
next = match >= 0 ? match : void 0;
|
|
8561
|
+
}
|
|
8562
|
+
if (next === void 0) return;
|
|
8563
|
+
e.preventDefault();
|
|
8564
|
+
options[next]?.focus();
|
|
8565
|
+
};
|
|
8566
|
+
return { listRef, onKeyDown };
|
|
8567
|
+
}
|
|
8568
|
+
|
|
8569
|
+
// src/ui/views/Dialpad.tsx
|
|
8570
|
+
var import_jsx_runtime4 = __toESM(require_jsx_runtime(), 1);
|
|
8119
8571
|
var NOT_DIALABLE = /* @__PURE__ */ new Set(["*", "#"]);
|
|
8572
|
+
var DISPLAY_SIZES = ["1.375rem", "1.25rem", "1.125rem", "1rem"];
|
|
8120
8573
|
function Dialpad({ outcome = null, onIntent, keyTones = true } = {}) {
|
|
8121
8574
|
const softphone = useSoftphone();
|
|
8122
8575
|
const state = useSoftphoneState();
|
|
8123
|
-
const [value, setValue] = (0,
|
|
8124
|
-
const [country, setCountry] = (0,
|
|
8576
|
+
const [value, setValue] = (0, import_react11.useState)("");
|
|
8577
|
+
const [country, setCountry] = (0, import_react11.useState)(DEFAULT_COUNTRY);
|
|
8578
|
+
const [changingNumber, setChangingNumber] = (0, import_react11.useState)(false);
|
|
8125
8579
|
const format = useNumberFormatter();
|
|
8126
|
-
const inputRef = (0,
|
|
8127
|
-
const caretRef = (0,
|
|
8580
|
+
const inputRef = (0, import_react11.useRef)(null);
|
|
8581
|
+
const caretRef = (0, import_react11.useRef)(null);
|
|
8128
8582
|
const display = format(country, value);
|
|
8129
8583
|
const target = toE164(country, value);
|
|
8130
|
-
const canCall = state === "online" && target.length > 1;
|
|
8131
|
-
|
|
8584
|
+
const canCall = state === "online" && target.length > 1 && !changingNumber;
|
|
8585
|
+
const typedInternational = value.trim().startsWith("+");
|
|
8586
|
+
const numberRegion = regionOfInternational(value);
|
|
8587
|
+
(0, import_react11.useLayoutEffect)(() => {
|
|
8132
8588
|
const caret = caretRef.current;
|
|
8133
8589
|
if (caret === null || !inputRef.current) return;
|
|
8134
8590
|
caretRef.current = null;
|
|
8135
8591
|
const pos = caretAfterDigits(display, caret);
|
|
8136
8592
|
inputRef.current.setSelectionRange(pos, pos);
|
|
8137
8593
|
}, [display]);
|
|
8594
|
+
(0, import_react11.useLayoutEffect)(() => {
|
|
8595
|
+
const input = inputRef.current;
|
|
8596
|
+
if (!input) return;
|
|
8597
|
+
for (const size of DISPLAY_SIZES) {
|
|
8598
|
+
input.style.fontSize = size;
|
|
8599
|
+
if (input.scrollWidth <= input.clientWidth) break;
|
|
8600
|
+
}
|
|
8601
|
+
}, [display]);
|
|
8138
8602
|
const append = (key) => {
|
|
8139
8603
|
onIntent?.();
|
|
8140
8604
|
setValue((v) => v + key);
|
|
@@ -8149,11 +8613,18 @@ function Dialpad({ outcome = null, onIntent, keyTones = true } = {}) {
|
|
|
8149
8613
|
onIntent?.();
|
|
8150
8614
|
void softphone.call(target);
|
|
8151
8615
|
};
|
|
8152
|
-
return /* @__PURE__ */ (0,
|
|
8153
|
-
/* @__PURE__ */ (0,
|
|
8154
|
-
/* @__PURE__ */ (0,
|
|
8155
|
-
/* @__PURE__ */ (0,
|
|
8156
|
-
|
|
8616
|
+
return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("div", { className: "cv-view", children: [
|
|
8617
|
+
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)(OutboundNumberPicker, { onBusyChange: setChangingNumber }),
|
|
8618
|
+
/* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("div", { className: "cv-dial-head", children: [
|
|
8619
|
+
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
|
|
8620
|
+
CountryPicker,
|
|
8621
|
+
{
|
|
8622
|
+
value: numberRegion ?? country,
|
|
8623
|
+
onChange: setCountry,
|
|
8624
|
+
fromNumber: typedInternational
|
|
8625
|
+
}
|
|
8626
|
+
),
|
|
8627
|
+
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
|
|
8157
8628
|
"input",
|
|
8158
8629
|
{
|
|
8159
8630
|
ref: inputRef,
|
|
@@ -8170,22 +8641,24 @@ function Dialpad({ outcome = null, onIntent, keyTones = true } = {}) {
|
|
|
8170
8641
|
inputMode: "tel"
|
|
8171
8642
|
}
|
|
8172
8643
|
),
|
|
8173
|
-
/* @__PURE__ */ (0,
|
|
8644
|
+
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
|
|
8174
8645
|
"button",
|
|
8175
8646
|
{
|
|
8176
8647
|
type: "button",
|
|
8177
8648
|
className: "cv-dial-side",
|
|
8178
8649
|
"data-hidden": value.length === 0,
|
|
8650
|
+
tabIndex: value.length === 0 ? -1 : void 0,
|
|
8651
|
+
"aria-hidden": value.length === 0 || void 0,
|
|
8179
8652
|
onClick: backspace,
|
|
8180
8653
|
"aria-label": "Borrar",
|
|
8181
|
-
children: /* @__PURE__ */ (0,
|
|
8654
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(Delete, { size: 19 })
|
|
8182
8655
|
}
|
|
8183
8656
|
)
|
|
8184
8657
|
] }),
|
|
8185
|
-
/* @__PURE__ */ (0,
|
|
8186
|
-
/* @__PURE__ */ (0,
|
|
8658
|
+
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)("p", { className: "cv-dial-outcome", role: "status", part: "error", "data-shown": outcome !== null, children: outcome?.text ?? "" }),
|
|
8659
|
+
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)("div", { className: "cv-keypad", children: KEYPAD_KEYS.map(([key, letters]) => {
|
|
8187
8660
|
const dialable = !NOT_DIALABLE.has(key);
|
|
8188
|
-
return /* @__PURE__ */ (0,
|
|
8661
|
+
return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(
|
|
8189
8662
|
"button",
|
|
8190
8663
|
{
|
|
8191
8664
|
type: "button",
|
|
@@ -8195,14 +8668,14 @@ function Dialpad({ outcome = null, onIntent, keyTones = true } = {}) {
|
|
|
8195
8668
|
"aria-disabled": !dialable,
|
|
8196
8669
|
title: dialable ? void 0 : "Solo disponible durante una llamada",
|
|
8197
8670
|
children: [
|
|
8198
|
-
/* @__PURE__ */ (0,
|
|
8199
|
-
letters ? /* @__PURE__ */ (0,
|
|
8671
|
+
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)("b", { children: key }),
|
|
8672
|
+
letters ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("small", { children: letters }) : null
|
|
8200
8673
|
]
|
|
8201
8674
|
},
|
|
8202
8675
|
key
|
|
8203
8676
|
);
|
|
8204
8677
|
}) }),
|
|
8205
|
-
/* @__PURE__ */ (0,
|
|
8678
|
+
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)("div", { className: "cv-dial-foot", children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
|
|
8206
8679
|
"button",
|
|
8207
8680
|
{
|
|
8208
8681
|
type: "button",
|
|
@@ -8211,14 +8684,14 @@ function Dialpad({ outcome = null, onIntent, keyTones = true } = {}) {
|
|
|
8211
8684
|
disabled: !canCall,
|
|
8212
8685
|
"aria-label": "Llamar",
|
|
8213
8686
|
part: "call-button",
|
|
8214
|
-
children: /* @__PURE__ */ (0,
|
|
8687
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(Phone, { size: 26 })
|
|
8215
8688
|
}
|
|
8216
8689
|
) })
|
|
8217
8690
|
] });
|
|
8218
8691
|
}
|
|
8219
8692
|
|
|
8220
8693
|
// src/ui/views/CallScreen.tsx
|
|
8221
|
-
var
|
|
8694
|
+
var import_react16 = __toESM(require_react(), 1);
|
|
8222
8695
|
|
|
8223
8696
|
// src/ui/lib/format.ts
|
|
8224
8697
|
function formatDuration(totalSeconds) {
|
|
@@ -8352,6 +8825,22 @@ var ERROR_TEXT = {
|
|
|
8352
8825
|
// thinks they dropped it will hang up on a client to "fix" it.
|
|
8353
8826
|
"call/transfer-failed": { title: "No se pudo transferir. Sigues en la llamada" },
|
|
8354
8827
|
"call/unsupported": { title: "Esta funci\xF3n no est\xE1 disponible" },
|
|
8828
|
+
// ── Line owner ──────────────────────────────────────────────────────────────
|
|
8829
|
+
/*
|
|
8830
|
+
Not call outcomes: they answer "Recibir aquí" on `LineGate`, which has ONE line for them where the
|
|
8831
|
+
explanation was, so it reads the pair as two sentences — `${title}. ${body}`. Split anyway, so the
|
|
8832
|
+
title stands alone wherever there is only room for a headline (a host's own toast), under the same
|
|
8833
|
+
one-line rules as every other non-fatal title. "Allí" because the gate has just said where: the
|
|
8834
|
+
other softphone, the one that has the calls.
|
|
8835
|
+
*/
|
|
8836
|
+
"line/claim-refused": {
|
|
8837
|
+
title: "Est\xE1s en una llamada all\xED",
|
|
8838
|
+
body: "Podr\xE1s recibirlas aqu\xED cuando cuelgues."
|
|
8839
|
+
},
|
|
8840
|
+
"line/unavailable": {
|
|
8841
|
+
title: "No se ha podido cambiar",
|
|
8842
|
+
body: "Int\xE9ntalo de nuevo."
|
|
8843
|
+
},
|
|
8355
8844
|
// ── Provider ────────────────────────────────────────────────────────────────
|
|
8356
8845
|
"provider/provision-failed": {
|
|
8357
8846
|
title: "No se pudo activar el tel\xE9fono",
|
|
@@ -8360,10 +8849,10 @@ var ERROR_TEXT = {
|
|
|
8360
8849
|
};
|
|
8361
8850
|
|
|
8362
8851
|
// src/ui/lib/useTicker.ts
|
|
8363
|
-
var
|
|
8852
|
+
var import_react13 = __toESM(require_react(), 1);
|
|
8364
8853
|
function useTicker(enabled, intervalMs = 1e3) {
|
|
8365
|
-
const [now, setNow] = (0,
|
|
8366
|
-
(0,
|
|
8854
|
+
const [now, setNow] = (0, import_react13.useState)(() => Date.now());
|
|
8855
|
+
(0, import_react13.useEffect)(() => {
|
|
8367
8856
|
if (!enabled) return;
|
|
8368
8857
|
setNow(Date.now());
|
|
8369
8858
|
const id = setInterval(() => setNow(Date.now()), intervalMs);
|
|
@@ -8373,8 +8862,8 @@ function useTicker(enabled, intervalMs = 1e3) {
|
|
|
8373
8862
|
}
|
|
8374
8863
|
|
|
8375
8864
|
// src/ui/views/DevicePicker.tsx
|
|
8376
|
-
var
|
|
8377
|
-
var
|
|
8865
|
+
var import_react14 = __toESM(require_react(), 1);
|
|
8866
|
+
var import_jsx_runtime5 = __toESM(require_jsx_runtime(), 1);
|
|
8378
8867
|
function deviceLabel(device, index, kind) {
|
|
8379
8868
|
if (device.label) return device.label;
|
|
8380
8869
|
return kind === "input" ? `Micr\xF3fono ${index + 1}` : `Altavoz ${index + 1}`;
|
|
@@ -8389,11 +8878,11 @@ function DevicePicker({
|
|
|
8389
8878
|
onChange,
|
|
8390
8879
|
emptyText
|
|
8391
8880
|
}) {
|
|
8392
|
-
return /* @__PURE__ */ (0,
|
|
8393
|
-
/* @__PURE__ */ (0,
|
|
8394
|
-
/* @__PURE__ */ (0,
|
|
8395
|
-
/* @__PURE__ */ (0,
|
|
8396
|
-
options.length > 0 ? /* @__PURE__ */ (0,
|
|
8881
|
+
return /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("div", { className: "cv-settings-row", children: [
|
|
8882
|
+
/* @__PURE__ */ (0, import_jsx_runtime5.jsx)("span", { className: "cv-settings-row-icon", "aria-hidden": true, children: /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(Icon3, { size: 16 }) }),
|
|
8883
|
+
/* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("span", { className: "cv-settings-row-text", children: [
|
|
8884
|
+
/* @__PURE__ */ (0, import_jsx_runtime5.jsx)("span", { className: "cv-settings-row-label", id: `cv-settings-label-${kind}`, children: label }),
|
|
8885
|
+
options.length > 0 ? /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)(
|
|
8397
8886
|
"select",
|
|
8398
8887
|
{
|
|
8399
8888
|
className: "cv-settings-select",
|
|
@@ -8401,22 +8890,22 @@ function DevicePicker({
|
|
|
8401
8890
|
value: value ?? DEFAULT_OPTION,
|
|
8402
8891
|
onChange: (e) => onChange(e.target.value === DEFAULT_OPTION ? null : e.target.value),
|
|
8403
8892
|
children: [
|
|
8404
|
-
/* @__PURE__ */ (0,
|
|
8405
|
-
options.map((d, i) => /* @__PURE__ */ (0,
|
|
8893
|
+
/* @__PURE__ */ (0, import_jsx_runtime5.jsx)("option", { value: DEFAULT_OPTION, children: "Predeterminado del sistema" }),
|
|
8894
|
+
options.map((d, i) => /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("option", { value: d.deviceId, children: deviceLabel(d, i, kind) }, d.deviceId))
|
|
8406
8895
|
]
|
|
8407
8896
|
}
|
|
8408
|
-
) : /* @__PURE__ */ (0,
|
|
8897
|
+
) : /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("span", { className: "cv-settings-empty", children: emptyText })
|
|
8409
8898
|
] })
|
|
8410
8899
|
] });
|
|
8411
8900
|
}
|
|
8412
8901
|
function DevicePickers() {
|
|
8413
8902
|
const softphone = useSoftphone();
|
|
8414
8903
|
const devices = useDevices();
|
|
8415
|
-
(0,
|
|
8904
|
+
(0, import_react14.useEffect)(() => {
|
|
8416
8905
|
void softphone.audio.refresh();
|
|
8417
8906
|
}, [softphone]);
|
|
8418
|
-
return /* @__PURE__ */ (0,
|
|
8419
|
-
/* @__PURE__ */ (0,
|
|
8907
|
+
return /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)(import_jsx_runtime5.Fragment, { children: [
|
|
8908
|
+
/* @__PURE__ */ (0, import_jsx_runtime5.jsx)(
|
|
8420
8909
|
DevicePicker,
|
|
8421
8910
|
{
|
|
8422
8911
|
icon: Mic,
|
|
@@ -8428,7 +8917,7 @@ function DevicePickers() {
|
|
|
8428
8917
|
emptyText: "No se encontr\xF3 ning\xFAn micr\xF3fono"
|
|
8429
8918
|
}
|
|
8430
8919
|
),
|
|
8431
|
-
devices.canSelectOutput ? /* @__PURE__ */ (0,
|
|
8920
|
+
devices.canSelectOutput ? /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(
|
|
8432
8921
|
DevicePicker,
|
|
8433
8922
|
{
|
|
8434
8923
|
icon: Headphones,
|
|
@@ -8442,13 +8931,13 @@ function DevicePickers() {
|
|
|
8442
8931
|
) : (
|
|
8443
8932
|
// Safari and most of Firefox have no `setSinkId` at all — nothing here would do anything,
|
|
8444
8933
|
// and offering a picker that silently fails is worse than not offering one.
|
|
8445
|
-
/* @__PURE__ */ (0,
|
|
8934
|
+
/* @__PURE__ */ (0, import_jsx_runtime5.jsx)("p", { className: "cv-settings-note", children: "Tu navegador no permite elegir el altavoz desde aqu\xED \u2014 c\xE1mbialo en los ajustes del sistema." })
|
|
8446
8935
|
)
|
|
8447
8936
|
] });
|
|
8448
8937
|
}
|
|
8449
8938
|
|
|
8450
8939
|
// src/ui/views/CallScreen.tsx
|
|
8451
|
-
var
|
|
8940
|
+
var import_jsx_runtime6 = __toESM(require_jsx_runtime(), 1);
|
|
8452
8941
|
function Control({
|
|
8453
8942
|
icon: Icon3,
|
|
8454
8943
|
label,
|
|
@@ -8458,8 +8947,8 @@ function Control({
|
|
|
8458
8947
|
disabled = false,
|
|
8459
8948
|
title
|
|
8460
8949
|
}) {
|
|
8461
|
-
return /* @__PURE__ */ (0,
|
|
8462
|
-
/* @__PURE__ */ (0,
|
|
8950
|
+
return /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("div", { className: "cv-ctrl-item", children: [
|
|
8951
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
|
|
8463
8952
|
"button",
|
|
8464
8953
|
{
|
|
8465
8954
|
type: "button",
|
|
@@ -8470,10 +8959,10 @@ function Control({
|
|
|
8470
8959
|
onClick,
|
|
8471
8960
|
disabled,
|
|
8472
8961
|
title,
|
|
8473
|
-
children: /* @__PURE__ */ (0,
|
|
8962
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(Icon3, { size: 19 })
|
|
8474
8963
|
}
|
|
8475
8964
|
),
|
|
8476
|
-
/* @__PURE__ */ (0,
|
|
8965
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)("span", { className: "cv-ctrl-label", "aria-hidden": true, children: label })
|
|
8477
8966
|
] });
|
|
8478
8967
|
}
|
|
8479
8968
|
var STATUS_LABEL = {
|
|
@@ -8511,42 +9000,42 @@ function ConsultScreen({
|
|
|
8511
9000
|
const [rawM = "0", ss = "00"] = formatDuration(heldSeconds).split(":");
|
|
8512
9001
|
const mm = rawM.padStart(2, "0");
|
|
8513
9002
|
const canComplete = heldSeconds >= MIN_CONSULT_HOLD_SECONDS;
|
|
8514
|
-
return /* @__PURE__ */ (0,
|
|
8515
|
-
/* @__PURE__ */ (0,
|
|
8516
|
-
/* @__PURE__ */ (0,
|
|
8517
|
-
/* @__PURE__ */ (0,
|
|
9003
|
+
return /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("div", { className: "cv-view", children: [
|
|
9004
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("div", { className: "cv-call", children: [
|
|
9005
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("button", { type: "button", className: "cv-consult-hold-banner", onClick: onReturn, children: [
|
|
9006
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)(Pause, { size: 13, "aria-hidden": true }),
|
|
8518
9007
|
mainName,
|
|
8519
9008
|
" en espera \xB7 Volver"
|
|
8520
9009
|
] }),
|
|
8521
|
-
/* @__PURE__ */ (0,
|
|
8522
|
-
/* @__PURE__ */ (0,
|
|
8523
|
-
answered ? /* @__PURE__ */ (0,
|
|
9010
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("div", { className: "cv-avatar-wrap", children: [
|
|
9011
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)("div", { className: "cv-avatar", "aria-hidden": true, children: initials(name) }),
|
|
9012
|
+
answered ? /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("span", { className: "cv-avatar-dot", "aria-hidden": true }) : null
|
|
8524
9013
|
] }),
|
|
8525
|
-
/* @__PURE__ */ (0,
|
|
8526
|
-
showNumber ? /* @__PURE__ */ (0,
|
|
8527
|
-
answered ? /* @__PURE__ */ (0,
|
|
8528
|
-
/* @__PURE__ */ (0,
|
|
8529
|
-
/* @__PURE__ */ (0,
|
|
8530
|
-
/* @__PURE__ */ (0,
|
|
8531
|
-
] }) : /* @__PURE__ */ (0,
|
|
9014
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)("div", { className: "cv-call-name", children: name }),
|
|
9015
|
+
showNumber ? /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("div", { className: "cv-call-number", children: number }) : null,
|
|
9016
|
+
answered ? /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("div", { className: "cv-timer", "aria-label": "Duraci\xF3n de la llamada", children: [
|
|
9017
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)("b", { children: mm }),
|
|
9018
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)("i", { children: ":" }),
|
|
9019
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)("b", { children: ss })
|
|
9020
|
+
] }) : /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("div", { className: "cv-call-status", children: STATUS_LABEL[s] ?? "" })
|
|
8532
9021
|
] }),
|
|
8533
|
-
/* @__PURE__ */ (0,
|
|
8534
|
-
/* @__PURE__ */ (0,
|
|
8535
|
-
answered ? /* @__PURE__ */ (0,
|
|
8536
|
-
/* @__PURE__ */ (0,
|
|
9022
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("div", { className: "cv-controls", children: [
|
|
9023
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("div", { className: "cv-transfer-actions", children: [
|
|
9024
|
+
answered ? /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("button", { className: "cv-btn cv-btn--accent", onClick: onComplete, disabled: !canComplete, children: "Completar" }) : null,
|
|
9025
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)("button", { className: "cv-btn", onClick: onReturn, children: "Cancelar" })
|
|
8537
9026
|
] }),
|
|
8538
|
-
/* @__PURE__ */ (0,
|
|
8539
|
-
/* @__PURE__ */ (0,
|
|
9027
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)("div", { className: "cv-controls-primary", children: /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("div", { className: "cv-ctrl-item", children: [
|
|
9028
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
|
|
8540
9029
|
"button",
|
|
8541
9030
|
{
|
|
8542
9031
|
type: "button",
|
|
8543
9032
|
className: "cv-round cv-round--hangup",
|
|
8544
9033
|
onClick: onHangupAll,
|
|
8545
9034
|
"aria-label": "Colgar todo",
|
|
8546
|
-
children: /* @__PURE__ */ (0,
|
|
9035
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(PhoneOff, { size: 26 })
|
|
8547
9036
|
}
|
|
8548
9037
|
),
|
|
8549
|
-
/* @__PURE__ */ (0,
|
|
9038
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)("span", { className: "cv-ctrl-label", "aria-hidden": true, children: "Colgar todo" })
|
|
8550
9039
|
] }) })
|
|
8551
9040
|
] })
|
|
8552
9041
|
] });
|
|
@@ -8579,10 +9068,10 @@ function TransferPicker({
|
|
|
8579
9068
|
const others = agents.filter((a) => a.userId !== myUserId);
|
|
8580
9069
|
if (others.length === 0) return null;
|
|
8581
9070
|
const filtered = filterAgents(others, query);
|
|
8582
|
-
return /* @__PURE__ */ (0,
|
|
8583
|
-
/* @__PURE__ */ (0,
|
|
9071
|
+
return /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("div", { className: "cv-presence", children: [
|
|
9072
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)("div", { className: "cv-presence-list", role: "listbox", "aria-label": "Compa\xF1eros", children: filtered.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("p", { className: "cv-presence-empty", children: "Sin coincidencias" }) : filtered.map((agent) => {
|
|
8584
9073
|
const isBusy = agent.state === "en_llamada";
|
|
8585
|
-
return /* @__PURE__ */ (0,
|
|
9074
|
+
return /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)(
|
|
8586
9075
|
"button",
|
|
8587
9076
|
{
|
|
8588
9077
|
type: "button",
|
|
@@ -8591,9 +9080,9 @@ function TransferPicker({
|
|
|
8591
9080
|
disabled: isBusy,
|
|
8592
9081
|
onClick: () => onSelect(agent.extension),
|
|
8593
9082
|
children: [
|
|
8594
|
-
/* @__PURE__ */ (0,
|
|
8595
|
-
/* @__PURE__ */ (0,
|
|
8596
|
-
/* @__PURE__ */ (0,
|
|
9083
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)("span", { className: "cv-presence-dot", "data-state": agent.state, "aria-hidden": true }),
|
|
9084
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)("span", { className: "cv-presence-name", children: agent.name ?? agent.extension }),
|
|
9085
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("span", { className: "cv-presence-meta", children: [
|
|
8597
9086
|
agent.extension,
|
|
8598
9087
|
" \xB7 ",
|
|
8599
9088
|
PRESENCE_LABEL[agent.state]
|
|
@@ -8603,7 +9092,7 @@ function TransferPicker({
|
|
|
8603
9092
|
agent.userId
|
|
8604
9093
|
);
|
|
8605
9094
|
}) }),
|
|
8606
|
-
/* @__PURE__ */ (0,
|
|
9095
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)("p", { className: "cv-presence-legend", children: "Desconectado: sin conexi\xF3n en el softphone web. Puede atender desde otro dispositivo." })
|
|
8607
9096
|
] });
|
|
8608
9097
|
}
|
|
8609
9098
|
function CallScreen({
|
|
@@ -8613,24 +9102,27 @@ function CallScreen({
|
|
|
8613
9102
|
}) {
|
|
8614
9103
|
const view = useCall(call);
|
|
8615
9104
|
const softphone = useSoftphone();
|
|
8616
|
-
const
|
|
9105
|
+
const outbound = useOutboundNumbers(call.direction === "outbound" ? outboundNumberStore(softphone) : null).settings;
|
|
9106
|
+
const formatIntl2 = useIntlFormatter();
|
|
9107
|
+
const fromName = outbound && outbound.numbers.length > 1 ? numberName(effectiveNumber(outbound), formatIntl2) : null;
|
|
9108
|
+
const [panel, setPanel] = (0, import_react16.useState)(null);
|
|
8617
9109
|
const showTransfer = panel === "transfer";
|
|
8618
9110
|
const showKeypad = panel === "keypad";
|
|
8619
9111
|
const showDevices = panel === "devices";
|
|
8620
9112
|
const togglePanel = (p) => setPanel((cur) => cur === p ? null : p);
|
|
8621
|
-
const [transferTo, setTransferTo] = (0,
|
|
8622
|
-
const [consultCall, setConsultCall] = (0,
|
|
8623
|
-
const [consultOutcome, setConsultOutcome] = (0,
|
|
8624
|
-
const [myUserId, setMyUserId] = (0,
|
|
9113
|
+
const [transferTo, setTransferTo] = (0, import_react16.useState)("");
|
|
9114
|
+
const [consultCall, setConsultCall] = (0, import_react16.useState)(null);
|
|
9115
|
+
const [consultOutcome, setConsultOutcome] = (0, import_react16.useState)(null);
|
|
9116
|
+
const [myUserId, setMyUserId] = (0, import_react16.useState)(null);
|
|
8625
9117
|
const consultView = useCall(consultCall);
|
|
8626
|
-
const consultIntentRef = (0,
|
|
8627
|
-
const controlsRef = (0,
|
|
9118
|
+
const consultIntentRef = (0, import_react16.useRef)(null);
|
|
9119
|
+
const controlsRef = (0, import_react16.useRef)(null);
|
|
8628
9120
|
const now = useTicker(view?.state === "active");
|
|
8629
9121
|
const consultNow = useTicker(consultView?.state === "active");
|
|
8630
9122
|
const isConnected = view ? view.state === "active" || view.state === "held" || view.state === "reconnecting" : false;
|
|
8631
9123
|
const transferPickerActive = isConnected && showTransfer && !consultCall;
|
|
8632
9124
|
const presence = usePresence(transferPickerActive);
|
|
8633
|
-
(0,
|
|
9125
|
+
(0, import_react16.useEffect)(() => {
|
|
8634
9126
|
const listAgents = softphone.sources?.listAgents;
|
|
8635
9127
|
if (!listAgents) return;
|
|
8636
9128
|
let cancelled = false;
|
|
@@ -8640,7 +9132,7 @@ function CallScreen({
|
|
|
8640
9132
|
cancelled = true;
|
|
8641
9133
|
};
|
|
8642
9134
|
}, [softphone]);
|
|
8643
|
-
(0,
|
|
9135
|
+
(0, import_react16.useEffect)(() => {
|
|
8644
9136
|
if (!consultCall) return;
|
|
8645
9137
|
return consultCall.on("ended" /* Ended */, ({ reason }) => {
|
|
8646
9138
|
const intent = consultIntentRef.current;
|
|
@@ -8662,7 +9154,7 @@ function CallScreen({
|
|
|
8662
9154
|
setTransferTo(extension);
|
|
8663
9155
|
void startConsult(extension);
|
|
8664
9156
|
};
|
|
8665
|
-
const [quickTransfer, setQuickTransfer] = (0,
|
|
9157
|
+
const [quickTransfer, setQuickTransfer] = (0, import_react16.useState)(false);
|
|
8666
9158
|
const selectPresenceAgentAuto = (extension) => {
|
|
8667
9159
|
void call.transfer(extension);
|
|
8668
9160
|
};
|
|
@@ -8677,7 +9169,7 @@ function CallScreen({
|
|
|
8677
9169
|
consultIntentRef.current = "cancel";
|
|
8678
9170
|
await call.cancelConsult();
|
|
8679
9171
|
};
|
|
8680
|
-
(0,
|
|
9172
|
+
(0, import_react16.useEffect)(() => {
|
|
8681
9173
|
if (!showDevices) return;
|
|
8682
9174
|
const onKey = (e) => {
|
|
8683
9175
|
if (e.key === "Escape") setPanel(null);
|
|
@@ -8685,7 +9177,7 @@ function CallScreen({
|
|
|
8685
9177
|
document.addEventListener("keydown", onKey);
|
|
8686
9178
|
return () => document.removeEventListener("keydown", onKey);
|
|
8687
9179
|
}, [showDevices]);
|
|
8688
|
-
(0,
|
|
9180
|
+
(0, import_react16.useEffect)(() => {
|
|
8689
9181
|
if (!showKeypad) return;
|
|
8690
9182
|
const onKey = (e) => {
|
|
8691
9183
|
if (e.key === "Escape") setPanel(null);
|
|
@@ -8702,7 +9194,7 @@ function CallScreen({
|
|
|
8702
9194
|
root.removeEventListener("pointerdown", onDown);
|
|
8703
9195
|
};
|
|
8704
9196
|
}, [showKeypad, isConnected]);
|
|
8705
|
-
(0,
|
|
9197
|
+
(0, import_react16.useEffect)(() => {
|
|
8706
9198
|
if (view && view.state !== "active") setPanel((cur) => cur === "keypad" ? null : cur);
|
|
8707
9199
|
}, [view?.state]);
|
|
8708
9200
|
if (!view || view.state === "ended") return null;
|
|
@@ -8713,7 +9205,7 @@ function CallScreen({
|
|
|
8713
9205
|
const number = view.remote.number;
|
|
8714
9206
|
const showNumber = number !== name;
|
|
8715
9207
|
if (consultCall && consultView) {
|
|
8716
|
-
return /* @__PURE__ */ (0,
|
|
9208
|
+
return /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
|
|
8717
9209
|
ConsultScreen,
|
|
8718
9210
|
{
|
|
8719
9211
|
mainName: name,
|
|
@@ -8729,20 +9221,20 @@ function CallScreen({
|
|
|
8729
9221
|
const IncomingActionIcon = incomingAction?.icon ?? ExternalLink;
|
|
8730
9222
|
const [rawM = "0", ss = "00"] = formatDuration(elapsedSeconds(view.answeredAt, now)).split(":");
|
|
8731
9223
|
const mm = rawM.padStart(2, "0");
|
|
8732
|
-
return /* @__PURE__ */ (0,
|
|
8733
|
-
/* @__PURE__ */ (0,
|
|
8734
|
-
/* @__PURE__ */ (0,
|
|
8735
|
-
/* @__PURE__ */ (0,
|
|
8736
|
-
/* @__PURE__ */ (0,
|
|
9224
|
+
return /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("div", { className: "cv-view", children: [
|
|
9225
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)("div", { className: "cv-call", children: showDevices ? /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("div", { className: "cv-call-devices-inline", children: [
|
|
9226
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("div", { className: "cv-call-devices-header", children: [
|
|
9227
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("button", { type: "button", className: "cv-call-devices-chip", onClick: () => setPanel(null), children: [
|
|
9228
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)(ArrowLeft, { size: 13, "aria-hidden": true }),
|
|
8737
9229
|
"Volver"
|
|
8738
9230
|
] }),
|
|
8739
|
-
/* @__PURE__ */ (0,
|
|
9231
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)("span", { className: "cv-call-devices-title", children: "Dispositivos" })
|
|
8740
9232
|
] }),
|
|
8741
|
-
/* @__PURE__ */ (0,
|
|
9233
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("p", { className: "cv-call-devices-context", children: [
|
|
8742
9234
|
showNumber ? `${number} \xB7 ` : "",
|
|
8743
9235
|
s === "active" ? `${mm}:${ss}` : STATUS_LABEL[s] ?? ""
|
|
8744
9236
|
] }),
|
|
8745
|
-
/* @__PURE__ */ (0,
|
|
9237
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)("div", { className: "cv-settings-section-body", children: /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(DevicePickers, {}) })
|
|
8746
9238
|
] }) : transferPickerActive ? (
|
|
8747
9239
|
/*
|
|
8748
9240
|
Found live (2026-09-29): the agent list a moment ago picked between showing ONE agent row
|
|
@@ -8759,17 +9251,17 @@ function CallScreen({
|
|
|
8759
9251
|
calling doesn't disappear" compact context line (below) instead of the full block gives
|
|
8760
9252
|
that ~150px back to the one thing this screen is actually for right now: the list.
|
|
8761
9253
|
*/
|
|
8762
|
-
/* @__PURE__ */ (0,
|
|
8763
|
-
/* @__PURE__ */ (0,
|
|
8764
|
-
/* @__PURE__ */ (0,
|
|
8765
|
-
/* @__PURE__ */ (0,
|
|
9254
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("div", { className: "cv-transfer", children: [
|
|
9255
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("div", { className: "cv-transfer-context", children: [
|
|
9256
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)("span", { className: "cv-call-devices-title", children: name }),
|
|
9257
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("p", { className: "cv-call-devices-context", children: [
|
|
8766
9258
|
showNumber ? `${number} \xB7 ` : "",
|
|
8767
9259
|
s === "active" ? `${mm}:${ss}` : STATUS_LABEL[s] ?? ""
|
|
8768
9260
|
] })
|
|
8769
9261
|
] }),
|
|
8770
|
-
consultOutcome ? /* @__PURE__ */ (0,
|
|
8771
|
-
/* @__PURE__ */ (0,
|
|
8772
|
-
/* @__PURE__ */ (0,
|
|
9262
|
+
consultOutcome ? /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("p", { className: "cv-dial-outcome", role: "status", "data-shown": "true", children: consultFailText(consultOutcome) }) : null,
|
|
9263
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("div", { className: "cv-transfer-row", children: [
|
|
9264
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
|
|
8773
9265
|
"input",
|
|
8774
9266
|
{
|
|
8775
9267
|
className: "cv-search",
|
|
@@ -8780,7 +9272,7 @@ function CallScreen({
|
|
|
8780
9272
|
autoFocus: true
|
|
8781
9273
|
}
|
|
8782
9274
|
),
|
|
8783
|
-
/* @__PURE__ */ (0,
|
|
9275
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
|
|
8784
9276
|
"button",
|
|
8785
9277
|
{
|
|
8786
9278
|
className: "cv-btn cv-btn--accent",
|
|
@@ -8790,10 +9282,10 @@ function CallScreen({
|
|
|
8790
9282
|
}
|
|
8791
9283
|
)
|
|
8792
9284
|
] }),
|
|
8793
|
-
/* @__PURE__ */ (0,
|
|
9285
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)("div", { className: "cv-segmented", role: "tablist", "aria-label": "Modo de transferencia", children: [
|
|
8794
9286
|
{ key: "consult", label: "Hablar primero" },
|
|
8795
9287
|
{ key: "auto", label: "Transferir directo" }
|
|
8796
|
-
].map(({ key, label }) => /* @__PURE__ */ (0,
|
|
9288
|
+
].map(({ key, label }) => /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
|
|
8797
9289
|
"button",
|
|
8798
9290
|
{
|
|
8799
9291
|
type: "button",
|
|
@@ -8805,7 +9297,7 @@ function CallScreen({
|
|
|
8805
9297
|
},
|
|
8806
9298
|
key
|
|
8807
9299
|
)) }),
|
|
8808
|
-
/* @__PURE__ */ (0,
|
|
9300
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
|
|
8809
9301
|
TransferPicker,
|
|
8810
9302
|
{
|
|
8811
9303
|
agents: presence,
|
|
@@ -8815,54 +9307,61 @@ function CallScreen({
|
|
|
8815
9307
|
}
|
|
8816
9308
|
)
|
|
8817
9309
|
] })
|
|
8818
|
-
) : /* @__PURE__ */ (0,
|
|
8819
|
-
/* @__PURE__ */ (0,
|
|
8820
|
-
isRinging ? /* @__PURE__ */ (0,
|
|
8821
|
-
/* @__PURE__ */ (0,
|
|
8822
|
-
s === "active" ? /* @__PURE__ */ (0,
|
|
9310
|
+
) : /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)(import_jsx_runtime6.Fragment, { children: [
|
|
9311
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("div", { className: "cv-avatar-wrap", children: [
|
|
9312
|
+
isRinging ? /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("span", { className: "cv-avatar-ping", "aria-hidden": true }) : null,
|
|
9313
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)("div", { className: "cv-avatar", "aria-hidden": true, children: initials(name) }),
|
|
9314
|
+
s === "active" ? /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("span", { className: "cv-avatar-dot", "aria-hidden": true }) : null
|
|
8823
9315
|
] }),
|
|
8824
|
-
/* @__PURE__ */ (0,
|
|
8825
|
-
showNumber ? /* @__PURE__ */ (0,
|
|
8826
|
-
|
|
8827
|
-
/* @__PURE__ */ (0,
|
|
8828
|
-
/* @__PURE__ */ (0,
|
|
8829
|
-
|
|
8830
|
-
|
|
8831
|
-
|
|
8832
|
-
|
|
9316
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)("div", { className: "cv-call-name", children: name }),
|
|
9317
|
+
showNumber ? /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("div", { className: "cv-call-number", children: number }) : null,
|
|
9318
|
+
fromName ? /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("div", { className: "cv-call-from", children: [
|
|
9319
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)(PhoneOutgoing, { size: 11, "aria-hidden": true }),
|
|
9320
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("span", { children: [
|
|
9321
|
+
"Desde ",
|
|
9322
|
+
fromName
|
|
9323
|
+
] })
|
|
9324
|
+
] }) : null,
|
|
9325
|
+
s === "active" ? /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("div", { className: "cv-timer", "aria-label": "Duraci\xF3n de la llamada", children: [
|
|
9326
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)("b", { children: mm }),
|
|
9327
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)("i", { children: ":" }),
|
|
9328
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)("b", { children: ss })
|
|
9329
|
+
] }) : /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("div", { className: "cv-call-status", children: s === "held" ? /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("span", { className: "cv-badge-hold", children: STATUS_LABEL[s] }) : STATUS_LABEL[s] ?? "" }),
|
|
9330
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("div", { className: "cv-call-chips", children: [
|
|
9331
|
+
incomingAction ? /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)(
|
|
8833
9332
|
"button",
|
|
8834
9333
|
{
|
|
8835
9334
|
type: "button",
|
|
8836
9335
|
className: "cv-call-devices-chip",
|
|
8837
9336
|
onClick: () => incomingAction.onClick(view.remote.contact),
|
|
8838
9337
|
children: [
|
|
8839
|
-
/* @__PURE__ */ (0,
|
|
9338
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)(IncomingActionIcon, { size: 13, "aria-hidden": true }),
|
|
8840
9339
|
incomingAction.label
|
|
8841
9340
|
]
|
|
8842
9341
|
}
|
|
8843
9342
|
) : null,
|
|
8844
|
-
/* @__PURE__ */ (0,
|
|
8845
|
-
/* @__PURE__ */ (0,
|
|
9343
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("button", { type: "button", className: "cv-call-devices-chip", onClick: () => setPanel("devices"), children: [
|
|
9344
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)(Headphones, { size: 13, "aria-hidden": true }),
|
|
8846
9345
|
"Dispositivos"
|
|
8847
9346
|
] })
|
|
8848
9347
|
] })
|
|
8849
9348
|
] }) }),
|
|
8850
|
-
/* @__PURE__ */ (0,
|
|
8851
|
-
/* @__PURE__ */ (0,
|
|
8852
|
-
/* @__PURE__ */ (0,
|
|
9349
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)("div", { className: "cv-controls", ref: controlsRef, children: isRinging ? /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("div", { className: "cv-controls-primary", children: [
|
|
9350
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("div", { className: "cv-ctrl-item", children: [
|
|
9351
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
|
|
8853
9352
|
"button",
|
|
8854
9353
|
{
|
|
8855
9354
|
type: "button",
|
|
8856
9355
|
className: "cv-round cv-round--hangup",
|
|
8857
9356
|
onClick: () => void call.reject(),
|
|
8858
9357
|
"aria-label": "Rechazar",
|
|
8859
|
-
children: /* @__PURE__ */ (0,
|
|
9358
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(PhoneOff, { size: 26 })
|
|
8860
9359
|
}
|
|
8861
9360
|
),
|
|
8862
|
-
/* @__PURE__ */ (0,
|
|
9361
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)("span", { className: "cv-ctrl-label", "aria-hidden": true, children: "Rechazar" })
|
|
8863
9362
|
] }),
|
|
8864
|
-
/* @__PURE__ */ (0,
|
|
8865
|
-
/* @__PURE__ */ (0,
|
|
9363
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("div", { className: "cv-ctrl-item", children: [
|
|
9364
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
|
|
8866
9365
|
"button",
|
|
8867
9366
|
{
|
|
8868
9367
|
type: "button",
|
|
@@ -8870,14 +9369,14 @@ function CallScreen({
|
|
|
8870
9369
|
onClick: () => void call.answer(),
|
|
8871
9370
|
"aria-label": "Contestar",
|
|
8872
9371
|
part: "answer-button",
|
|
8873
|
-
children: /* @__PURE__ */ (0,
|
|
9372
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(Phone, { size: 26 })
|
|
8874
9373
|
}
|
|
8875
9374
|
),
|
|
8876
|
-
/* @__PURE__ */ (0,
|
|
9375
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)("span", { className: "cv-ctrl-label", "aria-hidden": true, children: "Contestar" })
|
|
8877
9376
|
] })
|
|
8878
|
-
] }) : /* @__PURE__ */ (0,
|
|
8879
|
-
inCall ? /* @__PURE__ */ (0,
|
|
8880
|
-
/* @__PURE__ */ (0,
|
|
9377
|
+
] }) : /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)(import_jsx_runtime6.Fragment, { children: [
|
|
9378
|
+
inCall ? /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("div", { className: "cv-controls-row", children: [
|
|
9379
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
|
|
8881
9380
|
Control,
|
|
8882
9381
|
{
|
|
8883
9382
|
icon: view.isMuted ? MicOff : Mic,
|
|
@@ -8887,7 +9386,7 @@ function CallScreen({
|
|
|
8887
9386
|
onClick: () => call.mute(!view.isMuted)
|
|
8888
9387
|
}
|
|
8889
9388
|
),
|
|
8890
|
-
/* @__PURE__ */ (0,
|
|
9389
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
|
|
8891
9390
|
Control,
|
|
8892
9391
|
{
|
|
8893
9392
|
icon: view.isOnHold ? Play : Pause,
|
|
@@ -8897,7 +9396,7 @@ function CallScreen({
|
|
|
8897
9396
|
onClick: () => view.isOnHold ? void call.unhold() : void call.hold()
|
|
8898
9397
|
}
|
|
8899
9398
|
),
|
|
8900
|
-
/* @__PURE__ */ (0,
|
|
9399
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
|
|
8901
9400
|
Control,
|
|
8902
9401
|
{
|
|
8903
9402
|
icon: ArrowLeftRight,
|
|
@@ -8907,7 +9406,7 @@ function CallScreen({
|
|
|
8907
9406
|
onClick: () => togglePanel("transfer")
|
|
8908
9407
|
}
|
|
8909
9408
|
),
|
|
8910
|
-
/* @__PURE__ */ (0,
|
|
9409
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
|
|
8911
9410
|
Control,
|
|
8912
9411
|
{
|
|
8913
9412
|
icon: Grid3x3,
|
|
@@ -8920,8 +9419,8 @@ function CallScreen({
|
|
|
8920
9419
|
}
|
|
8921
9420
|
)
|
|
8922
9421
|
] }) : null,
|
|
8923
|
-
/* @__PURE__ */ (0,
|
|
8924
|
-
/* @__PURE__ */ (0,
|
|
9422
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)("div", { className: "cv-controls-primary", children: /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)("div", { className: "cv-ctrl-item", children: [
|
|
9423
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
|
|
8925
9424
|
"button",
|
|
8926
9425
|
{
|
|
8927
9426
|
type: "button",
|
|
@@ -8929,12 +9428,12 @@ function CallScreen({
|
|
|
8929
9428
|
onClick: () => void call.hangup(),
|
|
8930
9429
|
"aria-label": "Colgar",
|
|
8931
9430
|
part: "hangup-button",
|
|
8932
|
-
children: /* @__PURE__ */ (0,
|
|
9431
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(PhoneOff, { size: 26 })
|
|
8933
9432
|
}
|
|
8934
9433
|
),
|
|
8935
|
-
/* @__PURE__ */ (0,
|
|
9434
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)("span", { className: "cv-ctrl-label", "aria-hidden": true, children: "Colgar" })
|
|
8936
9435
|
] }) }),
|
|
8937
|
-
inCall && showKeypad ? /* @__PURE__ */ (0,
|
|
9436
|
+
inCall && showKeypad ? /* @__PURE__ */ (0, import_jsx_runtime6.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_runtime6.jsxs)(
|
|
8938
9437
|
"button",
|
|
8939
9438
|
{
|
|
8940
9439
|
type: "button",
|
|
@@ -8944,8 +9443,8 @@ function CallScreen({
|
|
|
8944
9443
|
if (keyTones) playKeyTone(key);
|
|
8945
9444
|
},
|
|
8946
9445
|
children: [
|
|
8947
|
-
/* @__PURE__ */ (0,
|
|
8948
|
-
letters ? /* @__PURE__ */ (0,
|
|
9446
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)("b", { children: key }),
|
|
9447
|
+
letters ? /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("small", { children: letters }) : null
|
|
8949
9448
|
]
|
|
8950
9449
|
},
|
|
8951
9450
|
key
|
|
@@ -8955,29 +9454,29 @@ function CallScreen({
|
|
|
8955
9454
|
}
|
|
8956
9455
|
|
|
8957
9456
|
// src/ui/views/ContactsTab.tsx
|
|
8958
|
-
var
|
|
9457
|
+
var import_react20 = __toESM(require_react(), 1);
|
|
8959
9458
|
|
|
8960
9459
|
// src/ui/views/DetailRow.tsx
|
|
8961
|
-
var
|
|
9460
|
+
var import_jsx_runtime7 = __toESM(require_jsx_runtime(), 1);
|
|
8962
9461
|
function DetailRow({
|
|
8963
9462
|
icon: Icon3,
|
|
8964
9463
|
eyebrow,
|
|
8965
9464
|
text,
|
|
8966
9465
|
onClick
|
|
8967
9466
|
}) {
|
|
8968
|
-
const inner = /* @__PURE__ */ (0,
|
|
8969
|
-
/* @__PURE__ */ (0,
|
|
8970
|
-
/* @__PURE__ */ (0,
|
|
8971
|
-
eyebrow ? /* @__PURE__ */ (0,
|
|
8972
|
-
/* @__PURE__ */ (0,
|
|
9467
|
+
const inner = /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)(import_jsx_runtime7.Fragment, { children: [
|
|
9468
|
+
/* @__PURE__ */ (0, import_jsx_runtime7.jsx)("span", { className: "cv-detail-row-icon", "aria-hidden": true, children: /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(Icon3, { size: 16 }) }),
|
|
9469
|
+
/* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("span", { className: "cv-detail-row-text", children: [
|
|
9470
|
+
eyebrow ? /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("span", { className: "cv-detail-row-eyebrow", children: eyebrow }) : null,
|
|
9471
|
+
/* @__PURE__ */ (0, import_jsx_runtime7.jsx)("span", { className: "cv-detail-row-value", children: text })
|
|
8973
9472
|
] }),
|
|
8974
|
-
onClick ? /* @__PURE__ */ (0,
|
|
9473
|
+
onClick ? /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(ChevronRight, { size: 16, className: "cv-detail-row-chevron", "aria-hidden": true }) : null
|
|
8975
9474
|
] });
|
|
8976
|
-
return onClick ? /* @__PURE__ */ (0,
|
|
9475
|
+
return onClick ? /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("button", { type: "button", className: "cv-detail-row cv-detail-row--action", onClick, children: inner }) : /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("div", { className: "cv-detail-row", children: inner });
|
|
8977
9476
|
}
|
|
8978
9477
|
|
|
8979
9478
|
// src/ui/views/ContactDetail.tsx
|
|
8980
|
-
var
|
|
9479
|
+
var import_jsx_runtime8 = __toESM(require_jsx_runtime(), 1);
|
|
8981
9480
|
function ContactDetail({
|
|
8982
9481
|
contact,
|
|
8983
9482
|
onBack,
|
|
@@ -8987,19 +9486,19 @@ function ContactDetail({
|
|
|
8987
9486
|
const formatIntl2 = useIntlFormatter();
|
|
8988
9487
|
const formatted = formatIntl2(contact.phone);
|
|
8989
9488
|
const name = contact.name ?? formatted;
|
|
8990
|
-
return /* @__PURE__ */ (0,
|
|
8991
|
-
/* @__PURE__ */ (0,
|
|
8992
|
-
/* @__PURE__ */ (0,
|
|
9489
|
+
return /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("div", { className: "cv-detail", role: "region", "aria-label": name, children: [
|
|
9490
|
+
/* @__PURE__ */ (0, import_jsx_runtime8.jsx)("div", { className: "cv-detail-head", children: /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("button", { type: "button", className: "cv-detail-back", onClick: onBack, children: [
|
|
9491
|
+
/* @__PURE__ */ (0, import_jsx_runtime8.jsx)(ArrowLeft, { size: 16, "aria-hidden": true }),
|
|
8993
9492
|
"Contactos"
|
|
8994
9493
|
] }) }),
|
|
8995
|
-
/* @__PURE__ */ (0,
|
|
8996
|
-
/* @__PURE__ */ (0,
|
|
8997
|
-
/* @__PURE__ */ (0,
|
|
8998
|
-
/* @__PURE__ */ (0,
|
|
9494
|
+
/* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("div", { className: "cv-detail-body", children: [
|
|
9495
|
+
/* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("div", { className: "cv-detail-identity", children: [
|
|
9496
|
+
/* @__PURE__ */ (0, import_jsx_runtime8.jsx)("span", { className: "cv-detail-avatar", "aria-hidden": true, children: initials(contact.name) }),
|
|
9497
|
+
/* @__PURE__ */ (0, import_jsx_runtime8.jsx)("h2", { className: "cv-detail-name", children: name })
|
|
8999
9498
|
] }),
|
|
9000
|
-
/* @__PURE__ */ (0,
|
|
9001
|
-
/* @__PURE__ */ (0,
|
|
9002
|
-
contactAction ? /* @__PURE__ */ (0,
|
|
9499
|
+
/* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("div", { className: "cv-detail-rows", children: [
|
|
9500
|
+
/* @__PURE__ */ (0, import_jsx_runtime8.jsx)(DetailRow, { icon: Phone, eyebrow: "Tel\xE9fono", text: formatted }),
|
|
9501
|
+
contactAction ? /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
|
|
9003
9502
|
DetailRow,
|
|
9004
9503
|
{
|
|
9005
9504
|
icon: contactAction.icon ?? ExternalLink,
|
|
@@ -9009,14 +9508,14 @@ function ContactDetail({
|
|
|
9009
9508
|
) : null
|
|
9010
9509
|
] })
|
|
9011
9510
|
] }),
|
|
9012
|
-
/* @__PURE__ */ (0,
|
|
9511
|
+
/* @__PURE__ */ (0, import_jsx_runtime8.jsx)("div", { className: "cv-detail-foot", children: /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)(
|
|
9013
9512
|
"button",
|
|
9014
9513
|
{
|
|
9015
9514
|
type: "button",
|
|
9016
9515
|
className: "cv-btn cv-btn--accent cv-detail-call",
|
|
9017
9516
|
onClick: () => void softphone.call(contact.phone),
|
|
9018
9517
|
children: [
|
|
9019
|
-
/* @__PURE__ */ (0,
|
|
9518
|
+
/* @__PURE__ */ (0, import_jsx_runtime8.jsx)(Phone, { size: 16, "aria-hidden": true }),
|
|
9020
9519
|
"Llamar"
|
|
9021
9520
|
]
|
|
9022
9521
|
}
|
|
@@ -9025,19 +9524,19 @@ function ContactDetail({
|
|
|
9025
9524
|
}
|
|
9026
9525
|
|
|
9027
9526
|
// src/ui/lib/useInfiniteList.ts
|
|
9028
|
-
var
|
|
9527
|
+
var import_react19 = __toESM(require_react(), 1);
|
|
9029
9528
|
function useInfiniteList(loadPage) {
|
|
9030
|
-
const [items, setItems] = (0,
|
|
9031
|
-
const [loading2, setLoading] = (0,
|
|
9032
|
-
const [loadingMore, setLoadingMore] = (0,
|
|
9033
|
-
const [hasMore, setHasMore] = (0,
|
|
9034
|
-
const [error, setError] = (0,
|
|
9035
|
-
const seqRef = (0,
|
|
9036
|
-
const itemsRef = (0,
|
|
9529
|
+
const [items, setItems] = (0, import_react19.useState)([]);
|
|
9530
|
+
const [loading2, setLoading] = (0, import_react19.useState)(true);
|
|
9531
|
+
const [loadingMore, setLoadingMore] = (0, import_react19.useState)(false);
|
|
9532
|
+
const [hasMore, setHasMore] = (0, import_react19.useState)(true);
|
|
9533
|
+
const [error, setError] = (0, import_react19.useState)(false);
|
|
9534
|
+
const seqRef = (0, import_react19.useRef)(0);
|
|
9535
|
+
const itemsRef = (0, import_react19.useRef)(items);
|
|
9037
9536
|
itemsRef.current = items;
|
|
9038
|
-
const loadingMoreRef = (0,
|
|
9039
|
-
const hasMoreRef = (0,
|
|
9040
|
-
(0,
|
|
9537
|
+
const loadingMoreRef = (0, import_react19.useRef)(false);
|
|
9538
|
+
const hasMoreRef = (0, import_react19.useRef)(true);
|
|
9539
|
+
(0, import_react19.useEffect)(() => {
|
|
9041
9540
|
const seq = ++seqRef.current;
|
|
9042
9541
|
loadingMoreRef.current = false;
|
|
9043
9542
|
hasMoreRef.current = true;
|
|
@@ -9060,7 +9559,7 @@ function useInfiniteList(loadPage) {
|
|
|
9060
9559
|
if (seq === seqRef.current) setLoading(false);
|
|
9061
9560
|
});
|
|
9062
9561
|
}, [loadPage]);
|
|
9063
|
-
const loadMore = (0,
|
|
9562
|
+
const loadMore = (0, import_react19.useCallback)(() => {
|
|
9064
9563
|
if (loadingMoreRef.current || !hasMoreRef.current) return;
|
|
9065
9564
|
const seq = seqRef.current;
|
|
9066
9565
|
loadingMoreRef.current = true;
|
|
@@ -9076,11 +9575,11 @@ function useInfiniteList(loadPage) {
|
|
|
9076
9575
|
if (seq === seqRef.current) setLoadingMore(false);
|
|
9077
9576
|
});
|
|
9078
9577
|
}, [loadPage]);
|
|
9079
|
-
const [containerEl, setContainerEl] = (0,
|
|
9080
|
-
const [sentinelEl, setSentinelEl] = (0,
|
|
9081
|
-
const loadMoreRef = (0,
|
|
9578
|
+
const [containerEl, setContainerEl] = (0, import_react19.useState)(null);
|
|
9579
|
+
const [sentinelEl, setSentinelEl] = (0, import_react19.useState)(null);
|
|
9580
|
+
const loadMoreRef = (0, import_react19.useRef)(loadMore);
|
|
9082
9581
|
loadMoreRef.current = loadMore;
|
|
9083
|
-
(0,
|
|
9582
|
+
(0, import_react19.useEffect)(() => {
|
|
9084
9583
|
if (!containerEl || !sentinelEl) return;
|
|
9085
9584
|
const observer = new IntersectionObserver(
|
|
9086
9585
|
([entry]) => {
|
|
@@ -9103,21 +9602,21 @@ function useInfiniteList(loadPage) {
|
|
|
9103
9602
|
}
|
|
9104
9603
|
|
|
9105
9604
|
// src/ui/views/ContactsTab.tsx
|
|
9106
|
-
var
|
|
9605
|
+
var import_jsx_runtime9 = __toESM(require_jsx_runtime(), 1);
|
|
9107
9606
|
var PAGE = 50;
|
|
9108
9607
|
function ContactsTab({
|
|
9109
9608
|
source,
|
|
9110
9609
|
contactAction
|
|
9111
9610
|
}) {
|
|
9112
9611
|
const softphone = useSoftphone();
|
|
9113
|
-
const [search, setSearch] = (0,
|
|
9114
|
-
const [debouncedSearch, setDebouncedSearch] = (0,
|
|
9115
|
-
const [selected, setSelected] = (0,
|
|
9116
|
-
(0,
|
|
9612
|
+
const [search, setSearch] = (0, import_react20.useState)("");
|
|
9613
|
+
const [debouncedSearch, setDebouncedSearch] = (0, import_react20.useState)("");
|
|
9614
|
+
const [selected, setSelected] = (0, import_react20.useState)(null);
|
|
9615
|
+
(0, import_react20.useEffect)(() => {
|
|
9117
9616
|
const t = setTimeout(() => setDebouncedSearch(search), 200);
|
|
9118
9617
|
return () => clearTimeout(t);
|
|
9119
9618
|
}, [search]);
|
|
9120
|
-
const loadPage = (0,
|
|
9619
|
+
const loadPage = (0, import_react20.useCallback)(
|
|
9121
9620
|
(skip) => source({ search: debouncedSearch, skip, limit: PAGE }).then((page) => ({
|
|
9122
9621
|
items: page.items,
|
|
9123
9622
|
hasMore: skip + page.items.length < page.total
|
|
@@ -9133,10 +9632,10 @@ function ContactsTab({
|
|
|
9133
9632
|
sentinelRef
|
|
9134
9633
|
} = useInfiniteList(loadPage);
|
|
9135
9634
|
const loading2 = loadingPage || search !== debouncedSearch;
|
|
9136
|
-
return /* @__PURE__ */ (0,
|
|
9137
|
-
/* @__PURE__ */ (0,
|
|
9138
|
-
/* @__PURE__ */ (0,
|
|
9139
|
-
/* @__PURE__ */ (0,
|
|
9635
|
+
return /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)("div", { className: "cv-view", children: [
|
|
9636
|
+
/* @__PURE__ */ (0, import_jsx_runtime9.jsx)("div", { className: "cv-list-head", children: /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)("div", { className: "cv-search-field", children: [
|
|
9637
|
+
/* @__PURE__ */ (0, import_jsx_runtime9.jsx)(Search, { className: "cv-search-icon", size: 15, "aria-hidden": true }),
|
|
9638
|
+
/* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
|
|
9140
9639
|
"input",
|
|
9141
9640
|
{
|
|
9142
9641
|
className: "cv-search cv-search--icon",
|
|
@@ -9146,41 +9645,41 @@ function ContactsTab({
|
|
|
9146
9645
|
"aria-label": "Buscar contactos"
|
|
9147
9646
|
}
|
|
9148
9647
|
),
|
|
9149
|
-
search ? /* @__PURE__ */ (0,
|
|
9648
|
+
search ? /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
|
|
9150
9649
|
"button",
|
|
9151
9650
|
{
|
|
9152
9651
|
type: "button",
|
|
9153
9652
|
className: "cv-search-clear",
|
|
9154
9653
|
onClick: () => setSearch(""),
|
|
9155
9654
|
"aria-label": "Limpiar b\xFAsqueda",
|
|
9156
|
-
children: /* @__PURE__ */ (0,
|
|
9655
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(X, { size: 14 })
|
|
9157
9656
|
}
|
|
9158
9657
|
) : null
|
|
9159
9658
|
] }) }),
|
|
9160
|
-
/* @__PURE__ */ (0,
|
|
9659
|
+
/* @__PURE__ */ (0, import_jsx_runtime9.jsx)("div", { className: "cv-list", ref: containerRef, children: loading2 ? (
|
|
9161
9660
|
// The spinner alone was silent to a screen reader — nothing else on this state carries
|
|
9162
9661
|
// the word "loading" for it to announce. `role="status"` + real text fixes both the
|
|
9163
9662
|
// accessibility gap and, incidentally, gives the icon something to be decorative NEXT TO
|
|
9164
9663
|
// rather than being the sole carrier of the information itself.
|
|
9165
|
-
/* @__PURE__ */ (0,
|
|
9166
|
-
/* @__PURE__ */ (0,
|
|
9167
|
-
/* @__PURE__ */ (0,
|
|
9664
|
+
/* @__PURE__ */ (0, import_jsx_runtime9.jsxs)("div", { className: "cv-empty", role: "status", children: [
|
|
9665
|
+
/* @__PURE__ */ (0, import_jsx_runtime9.jsx)(LoaderCircle, { className: "cv-empty-spinner", size: 18, "aria-hidden": true }),
|
|
9666
|
+
/* @__PURE__ */ (0, import_jsx_runtime9.jsx)("span", { className: "cv-empty-title", children: "Cargando\u2026" })
|
|
9168
9667
|
] })
|
|
9169
|
-
) : items.length === 0 ? /* @__PURE__ */ (0,
|
|
9170
|
-
/* @__PURE__ */ (0,
|
|
9171
|
-
/* @__PURE__ */ (0,
|
|
9172
|
-
/* @__PURE__ */ (0,
|
|
9173
|
-
] }) : /* @__PURE__ */ (0,
|
|
9668
|
+
) : items.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)("div", { className: "cv-empty", children: [
|
|
9669
|
+
/* @__PURE__ */ (0, import_jsx_runtime9.jsx)("span", { className: "cv-empty-icon", "aria-hidden": true, children: /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(Users, { size: 20 }) }),
|
|
9670
|
+
/* @__PURE__ */ (0, import_jsx_runtime9.jsx)("p", { className: "cv-empty-title", children: search ? "Sin resultados" : "No tienes contactos" }),
|
|
9671
|
+
/* @__PURE__ */ (0, import_jsx_runtime9.jsx)("p", { className: "cv-empty-sub", children: search ? `Nada coincide con \xAB${search}\xBB.` : "Tus contactos con tel\xE9fono aparecer\xE1n aqu\xED." })
|
|
9672
|
+
] }) : /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)(import_jsx_runtime9.Fragment, { children: [
|
|
9174
9673
|
items.map((c) => (
|
|
9175
9674
|
// A row, not a button: the name area and the call action are separate controls, so
|
|
9176
9675
|
// neither is nested inside the other (nested interactives are invalid + inaccessible).
|
|
9177
|
-
/* @__PURE__ */ (0,
|
|
9178
|
-
/* @__PURE__ */ (0,
|
|
9179
|
-
/* @__PURE__ */ (0,
|
|
9180
|
-
/* @__PURE__ */ (0,
|
|
9181
|
-
/* @__PURE__ */ (0,
|
|
9676
|
+
/* @__PURE__ */ (0, import_jsx_runtime9.jsxs)("div", { className: "cv-item", children: [
|
|
9677
|
+
/* @__PURE__ */ (0, import_jsx_runtime9.jsx)("span", { className: "cv-item-avatar", "aria-hidden": true, children: initials(c.name) === "#" ? /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(User, { size: 17 }) : initials(c.name) }),
|
|
9678
|
+
/* @__PURE__ */ (0, import_jsx_runtime9.jsxs)("button", { type: "button", className: "cv-item-main", onClick: () => setSelected(c), children: [
|
|
9679
|
+
/* @__PURE__ */ (0, import_jsx_runtime9.jsx)("span", { className: "cv-item-name", children: c.name ?? c.phone }),
|
|
9680
|
+
/* @__PURE__ */ (0, import_jsx_runtime9.jsx)("span", { className: "cv-item-sub", children: c.phone })
|
|
9182
9681
|
] }),
|
|
9183
|
-
/* @__PURE__ */ (0,
|
|
9682
|
+
/* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
|
|
9184
9683
|
"button",
|
|
9185
9684
|
{
|
|
9186
9685
|
type: "button",
|
|
@@ -9188,15 +9687,15 @@ function ContactsTab({
|
|
|
9188
9687
|
"data-tone": "accent",
|
|
9189
9688
|
"aria-label": `Llamar a ${c.name ?? c.phone}`,
|
|
9190
9689
|
onClick: () => void softphone.call(c.phone),
|
|
9191
|
-
children: /* @__PURE__ */ (0,
|
|
9690
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(Phone, { size: 16 })
|
|
9192
9691
|
}
|
|
9193
9692
|
)
|
|
9194
9693
|
] }, c.id)
|
|
9195
9694
|
)),
|
|
9196
|
-
hasMore ? /* @__PURE__ */ (0,
|
|
9197
|
-
loadingMore ? /* @__PURE__ */ (0,
|
|
9695
|
+
hasMore ? /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("div", { ref: sentinelRef, className: "cv-list-sentinel", "aria-hidden": true }) : null,
|
|
9696
|
+
loadingMore ? /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("div", { className: "cv-list-loading-more", role: "status", "aria-label": "Cargando m\xE1s contactos", children: /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(LoaderCircle, { className: "cv-list-loading-more-spinner", size: 14, "aria-hidden": true }) }) : null
|
|
9198
9697
|
] }) }),
|
|
9199
|
-
selected ? /* @__PURE__ */ (0,
|
|
9698
|
+
selected ? /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
|
|
9200
9699
|
ContactDetail,
|
|
9201
9700
|
{
|
|
9202
9701
|
contact: selected,
|
|
@@ -9208,13 +9707,13 @@ function ContactsTab({
|
|
|
9208
9707
|
}
|
|
9209
9708
|
|
|
9210
9709
|
// src/ui/views/HistoryTab.tsx
|
|
9211
|
-
var
|
|
9710
|
+
var import_react24 = __toESM(require_react(), 1);
|
|
9212
9711
|
|
|
9213
9712
|
// src/ui/views/HistoryDetail.tsx
|
|
9214
|
-
var
|
|
9713
|
+
var import_react22 = __toESM(require_react(), 1);
|
|
9215
9714
|
|
|
9216
9715
|
// src/ui/views/history-format.tsx
|
|
9217
|
-
var
|
|
9716
|
+
var import_jsx_runtime10 = __toESM(require_jsx_runtime(), 1);
|
|
9218
9717
|
var OUTCOME_LABELS = {
|
|
9219
9718
|
"no-answer": "No contestada",
|
|
9220
9719
|
busy: "Ocupada",
|
|
@@ -9235,8 +9734,8 @@ function fullDateLabel(at) {
|
|
|
9235
9734
|
});
|
|
9236
9735
|
}
|
|
9237
9736
|
function Icon2({ entry }) {
|
|
9238
|
-
if (entry.missed) return /* @__PURE__ */ (0,
|
|
9239
|
-
return entry.direction === "inbound" ? /* @__PURE__ */ (0,
|
|
9737
|
+
if (entry.missed) return /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(PhoneMissed, { size: 16 });
|
|
9738
|
+
return entry.direction === "inbound" ? /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(PhoneIncoming, { size: 16 }) : /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(PhoneOutgoing, { size: 16 });
|
|
9240
9739
|
}
|
|
9241
9740
|
function statusIcon(entry) {
|
|
9242
9741
|
if (entry.missed) return PhoneMissed;
|
|
@@ -9248,16 +9747,16 @@ function statusTone(entry) {
|
|
|
9248
9747
|
}
|
|
9249
9748
|
|
|
9250
9749
|
// src/ui/views/HistoryDetail.tsx
|
|
9251
|
-
var
|
|
9750
|
+
var import_jsx_runtime11 = __toESM(require_jsx_runtime(), 1);
|
|
9252
9751
|
function HandlerChain({ handlers }) {
|
|
9253
|
-
return /* @__PURE__ */ (0,
|
|
9752
|
+
return /* @__PURE__ */ (0, import_jsx_runtime11.jsx)("span", { className: "cv-detail-handlers", children: handlers.map((h, i) => {
|
|
9254
9753
|
const isLast = i === handlers.length - 1;
|
|
9255
9754
|
const label = h.name ?? `Ext. ${h.extension}`;
|
|
9256
|
-
return /* @__PURE__ */ (0,
|
|
9257
|
-
i > 0 ? /* @__PURE__ */ (0,
|
|
9258
|
-
/* @__PURE__ */ (0,
|
|
9755
|
+
return /* @__PURE__ */ (0, import_jsx_runtime11.jsxs)("span", { className: "cv-detail-handler-hop", children: [
|
|
9756
|
+
i > 0 ? /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(ArrowRight, { size: 12, className: "cv-detail-handler-arrow", "aria-hidden": true }) : null,
|
|
9757
|
+
/* @__PURE__ */ (0, import_jsx_runtime11.jsxs)("span", { className: "cv-detail-handler-chip", "data-tone": isLast ? "accent" : void 0, children: [
|
|
9259
9758
|
label,
|
|
9260
|
-
h.name ? /* @__PURE__ */ (0,
|
|
9759
|
+
h.name ? /* @__PURE__ */ (0, import_jsx_runtime11.jsxs)("span", { className: "cv-detail-handler-ext", children: [
|
|
9261
9760
|
"\xB7 ",
|
|
9262
9761
|
h.extension
|
|
9263
9762
|
] }) : null
|
|
@@ -9265,15 +9764,26 @@ function HandlerChain({ handlers }) {
|
|
|
9265
9764
|
] }, `${h.extension}-${i}`);
|
|
9266
9765
|
}) });
|
|
9267
9766
|
}
|
|
9767
|
+
function outboundFrom(entry, known, formatIntl2) {
|
|
9768
|
+
if (entry.direction !== "outbound" || !entry.from) return null;
|
|
9769
|
+
const number = formatIntl2(`+${entry.from}`);
|
|
9770
|
+
const label = known?.numbers.find((n) => n.number === entry.from)?.label;
|
|
9771
|
+
if (!label) return number;
|
|
9772
|
+
return /* @__PURE__ */ (0, import_jsx_runtime11.jsxs)(import_jsx_runtime11.Fragment, { children: [
|
|
9773
|
+
label,
|
|
9774
|
+
/* @__PURE__ */ (0, import_jsx_runtime11.jsx)("span", { className: "cv-detail-row-sub", children: number })
|
|
9775
|
+
] });
|
|
9776
|
+
}
|
|
9268
9777
|
function HistoryDetail({
|
|
9269
9778
|
entry,
|
|
9270
9779
|
onBack
|
|
9271
9780
|
}) {
|
|
9272
9781
|
const softphone = useSoftphone();
|
|
9273
9782
|
const formatIntl2 = useIntlFormatter();
|
|
9274
|
-
const
|
|
9275
|
-
const [
|
|
9276
|
-
(0,
|
|
9783
|
+
const knownNumbers = useKnownOutboundNumbers(outboundNumberStore(softphone));
|
|
9784
|
+
const [detail, setDetail] = (0, import_react22.useState)(null);
|
|
9785
|
+
const [loadingDetail, setLoadingDetail] = (0, import_react22.useState)(false);
|
|
9786
|
+
(0, import_react22.useEffect)(() => {
|
|
9277
9787
|
const load = entry.loadDetail;
|
|
9278
9788
|
if (!load) return;
|
|
9279
9789
|
let cancelled = false;
|
|
@@ -9286,53 +9796,55 @@ function HistoryDetail({
|
|
|
9286
9796
|
}, [entry]);
|
|
9287
9797
|
const formatted = formatIntl2(entry.number);
|
|
9288
9798
|
const name = entry.name ?? formatted;
|
|
9799
|
+
const from = outboundFrom(entry, knownNumbers, formatIntl2);
|
|
9289
9800
|
const handledBy = detail?.handledBy ?? entry.handledBy;
|
|
9290
9801
|
const recordingUrls = detail?.recordingUrls ?? [];
|
|
9291
9802
|
const loadingRecording = entry.recorded && loadingDetail && recordingUrls.length === 0;
|
|
9292
9803
|
const statusText = entry.missed ? entry.outcome ? OUTCOME_LABELS[entry.outcome] : "Perdida" : "Contestada";
|
|
9293
9804
|
const handlerLabel = handledBy.length > 1 ? "Ruta de la llamada" : entry.direction === "outbound" ? "Realizada por" : "Atendida por";
|
|
9294
|
-
return /* @__PURE__ */ (0,
|
|
9295
|
-
/* @__PURE__ */ (0,
|
|
9296
|
-
/* @__PURE__ */ (0,
|
|
9805
|
+
return /* @__PURE__ */ (0, import_jsx_runtime11.jsxs)("div", { className: "cv-detail", role: "region", "aria-label": name, children: [
|
|
9806
|
+
/* @__PURE__ */ (0, import_jsx_runtime11.jsx)("div", { className: "cv-detail-head", children: /* @__PURE__ */ (0, import_jsx_runtime11.jsxs)("button", { type: "button", className: "cv-detail-back", onClick: onBack, children: [
|
|
9807
|
+
/* @__PURE__ */ (0, import_jsx_runtime11.jsx)(ArrowLeft, { size: 16, "aria-hidden": true }),
|
|
9297
9808
|
"Historial"
|
|
9298
9809
|
] }) }),
|
|
9299
|
-
/* @__PURE__ */ (0,
|
|
9300
|
-
/* @__PURE__ */ (0,
|
|
9301
|
-
/* @__PURE__ */ (0,
|
|
9302
|
-
/* @__PURE__ */ (0,
|
|
9303
|
-
/* @__PURE__ */ (0,
|
|
9810
|
+
/* @__PURE__ */ (0, import_jsx_runtime11.jsxs)("div", { className: "cv-detail-body", children: [
|
|
9811
|
+
/* @__PURE__ */ (0, import_jsx_runtime11.jsxs)("div", { className: "cv-detail-identity", children: [
|
|
9812
|
+
/* @__PURE__ */ (0, import_jsx_runtime11.jsx)("span", { className: "cv-detail-avatar", "data-tone": statusTone(entry), "aria-hidden": true, children: /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(Icon2, { entry }) }),
|
|
9813
|
+
/* @__PURE__ */ (0, import_jsx_runtime11.jsx)("h2", { className: "cv-detail-name", "data-tone": entry.missed ? "danger" : void 0, children: name }),
|
|
9814
|
+
/* @__PURE__ */ (0, import_jsx_runtime11.jsxs)("p", { className: "cv-detail-meta", children: [
|
|
9304
9815
|
statusText,
|
|
9305
9816
|
" \xB7 ",
|
|
9306
9817
|
timeLabel(entry.at)
|
|
9307
9818
|
] })
|
|
9308
9819
|
] }),
|
|
9309
|
-
entry.recorded ? /* @__PURE__ */ (0,
|
|
9310
|
-
/* @__PURE__ */ (0,
|
|
9820
|
+
entry.recorded ? /* @__PURE__ */ (0, import_jsx_runtime11.jsx)("div", { className: "cv-detail-recording", children: loadingRecording ? /* @__PURE__ */ (0, import_jsx_runtime11.jsxs)("div", { className: "cv-detail-recording-status", children: [
|
|
9821
|
+
/* @__PURE__ */ (0, import_jsx_runtime11.jsx)(LoaderCircle, { size: 14, className: "cv-detail-recording-spinner", "aria-hidden": true }),
|
|
9311
9822
|
"Cargando grabaci\xF3n\u2026"
|
|
9312
|
-
] }) : recordingUrls.length > 0 ? /* @__PURE__ */ (0,
|
|
9313
|
-
/* @__PURE__ */ (0,
|
|
9314
|
-
recordingUrls.map((url) => /* @__PURE__ */ (0,
|
|
9315
|
-
] }) : /* @__PURE__ */ (0,
|
|
9316
|
-
/* @__PURE__ */ (0,
|
|
9823
|
+
] }) : recordingUrls.length > 0 ? /* @__PURE__ */ (0, import_jsx_runtime11.jsxs)(import_jsx_runtime11.Fragment, { children: [
|
|
9824
|
+
/* @__PURE__ */ (0, import_jsx_runtime11.jsx)("p", { className: "cv-detail-recording-label", children: recordingUrls.length === 1 ? "Grabaci\xF3n" : "Grabaciones" }),
|
|
9825
|
+
recordingUrls.map((url) => /* @__PURE__ */ (0, import_jsx_runtime11.jsx)("audio", { controls: true, src: url, className: "cv-detail-audio", preload: "metadata" }, url))
|
|
9826
|
+
] }) : /* @__PURE__ */ (0, import_jsx_runtime11.jsx)("p", { className: "cv-detail-recording-status cv-detail-recording-status--warn", children: "No se pudo cargar la grabaci\xF3n ahora. Int\xE9ntalo de nuevo m\xE1s tarde." }) }) : /* @__PURE__ */ (0, import_jsx_runtime11.jsxs)("p", { className: "cv-detail-recording-status", children: [
|
|
9827
|
+
/* @__PURE__ */ (0, import_jsx_runtime11.jsx)(Mic, { size: 14, "aria-hidden": true }),
|
|
9317
9828
|
"Esta llamada no se grab\xF3."
|
|
9318
9829
|
] }),
|
|
9319
|
-
/* @__PURE__ */ (0,
|
|
9320
|
-
/* @__PURE__ */ (0,
|
|
9321
|
-
/* @__PURE__ */ (0,
|
|
9322
|
-
entry.durationSec > 0 ? /* @__PURE__ */ (0,
|
|
9323
|
-
/* @__PURE__ */ (0,
|
|
9324
|
-
handledBy.length > 0 ? /* @__PURE__ */ (0,
|
|
9325
|
-
entry.direction === "inbound" && entry.did ? /* @__PURE__ */ (0,
|
|
9830
|
+
/* @__PURE__ */ (0, import_jsx_runtime11.jsxs)("div", { className: "cv-detail-rows", children: [
|
|
9831
|
+
/* @__PURE__ */ (0, import_jsx_runtime11.jsx)(DetailRow, { icon: statusIcon(entry), eyebrow: "Estado", text: statusText }),
|
|
9832
|
+
/* @__PURE__ */ (0, import_jsx_runtime11.jsx)(DetailRow, { icon: CalendarClock, eyebrow: "Fecha y hora", text: fullDateLabel(entry.at) }),
|
|
9833
|
+
entry.durationSec > 0 ? /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(DetailRow, { icon: Timer, eyebrow: "Duraci\xF3n", text: formatDuration(entry.durationSec) }) : null,
|
|
9834
|
+
/* @__PURE__ */ (0, import_jsx_runtime11.jsx)(DetailRow, { icon: Phone, eyebrow: "N\xFAmero", text: formatted }),
|
|
9835
|
+
handledBy.length > 0 ? /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(DetailRow, { icon: User, eyebrow: handlerLabel, text: /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(HandlerChain, { handlers: handledBy }) }) : null,
|
|
9836
|
+
entry.direction === "inbound" && entry.did ? /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(DetailRow, { icon: Hash, eyebrow: "N\xFAmero marcado (DID)", text: formatIntl2(entry.did) }) : null,
|
|
9837
|
+
from ? /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(DetailRow, { icon: PhoneOutgoing, eyebrow: "Llamada desde", text: from }) : null
|
|
9326
9838
|
] })
|
|
9327
9839
|
] }),
|
|
9328
|
-
/* @__PURE__ */ (0,
|
|
9840
|
+
/* @__PURE__ */ (0, import_jsx_runtime11.jsx)("div", { className: "cv-detail-foot", children: /* @__PURE__ */ (0, import_jsx_runtime11.jsxs)(
|
|
9329
9841
|
"button",
|
|
9330
9842
|
{
|
|
9331
9843
|
type: "button",
|
|
9332
9844
|
className: "cv-btn cv-btn--accent cv-detail-call",
|
|
9333
9845
|
onClick: () => void softphone.call(entry.number),
|
|
9334
9846
|
children: [
|
|
9335
|
-
/* @__PURE__ */ (0,
|
|
9847
|
+
/* @__PURE__ */ (0, import_jsx_runtime11.jsx)(Phone, { size: 16, "aria-hidden": true }),
|
|
9336
9848
|
"Llamar"
|
|
9337
9849
|
]
|
|
9338
9850
|
}
|
|
@@ -9341,7 +9853,7 @@ function HistoryDetail({
|
|
|
9341
9853
|
}
|
|
9342
9854
|
|
|
9343
9855
|
// src/ui/views/HistoryTab.tsx
|
|
9344
|
-
var
|
|
9856
|
+
var import_jsx_runtime12 = __toESM(require_jsx_runtime(), 1);
|
|
9345
9857
|
var PAGE2 = 30;
|
|
9346
9858
|
function startOfDay(at) {
|
|
9347
9859
|
return new Date(at.getFullYear(), at.getMonth(), at.getDate()).getTime();
|
|
@@ -9367,9 +9879,9 @@ function PersonFilter({
|
|
|
9367
9879
|
directory,
|
|
9368
9880
|
onChange
|
|
9369
9881
|
}) {
|
|
9370
|
-
const [open, setOpen] = (0,
|
|
9371
|
-
const rootRef = (0,
|
|
9372
|
-
(0,
|
|
9882
|
+
const [open, setOpen] = (0, import_react24.useState)(false);
|
|
9883
|
+
const rootRef = (0, import_react24.useRef)(null);
|
|
9884
|
+
(0, import_react24.useEffect)(() => {
|
|
9373
9885
|
if (!open) return;
|
|
9374
9886
|
const root = rootRef.current?.getRootNode() ?? document;
|
|
9375
9887
|
const onDown = (e) => {
|
|
@@ -9391,8 +9903,8 @@ function PersonFilter({
|
|
|
9391
9903
|
...directory.people.filter((p) => !directory.me || p.userId !== directory.me.userId).map((p) => ({ v: p.userId, label: p.name ?? "Sin nombre" }))
|
|
9392
9904
|
];
|
|
9393
9905
|
const label = options.find((o) => o.v === value)?.label ?? "Todas las personas";
|
|
9394
|
-
return /* @__PURE__ */ (0,
|
|
9395
|
-
/* @__PURE__ */ (0,
|
|
9906
|
+
return /* @__PURE__ */ (0, import_jsx_runtime12.jsxs)("div", { className: "cv-person-filter", ref: rootRef, children: [
|
|
9907
|
+
/* @__PURE__ */ (0, import_jsx_runtime12.jsxs)(
|
|
9396
9908
|
"button",
|
|
9397
9909
|
{
|
|
9398
9910
|
type: "button",
|
|
@@ -9401,13 +9913,13 @@ function PersonFilter({
|
|
|
9401
9913
|
"aria-haspopup": "listbox",
|
|
9402
9914
|
"aria-expanded": open,
|
|
9403
9915
|
children: [
|
|
9404
|
-
/* @__PURE__ */ (0,
|
|
9405
|
-
/* @__PURE__ */ (0,
|
|
9406
|
-
/* @__PURE__ */ (0,
|
|
9916
|
+
/* @__PURE__ */ (0, import_jsx_runtime12.jsx)(Users, { size: 13, "aria-hidden": true }),
|
|
9917
|
+
/* @__PURE__ */ (0, import_jsx_runtime12.jsx)("span", { className: "cv-person-filter-label", children: label }),
|
|
9918
|
+
/* @__PURE__ */ (0, import_jsx_runtime12.jsx)(ChevronDown, { size: 13, className: "cv-person-filter-caret", "data-open": open, "aria-hidden": true })
|
|
9407
9919
|
]
|
|
9408
9920
|
}
|
|
9409
9921
|
),
|
|
9410
|
-
open ? /* @__PURE__ */ (0,
|
|
9922
|
+
open ? /* @__PURE__ */ (0, import_jsx_runtime12.jsx)("div", { className: "cv-person-filter-pop", role: "listbox", children: options.map((opt) => /* @__PURE__ */ (0, import_jsx_runtime12.jsxs)(
|
|
9411
9923
|
"button",
|
|
9412
9924
|
{
|
|
9413
9925
|
type: "button",
|
|
@@ -9419,8 +9931,8 @@ function PersonFilter({
|
|
|
9419
9931
|
setOpen(false);
|
|
9420
9932
|
},
|
|
9421
9933
|
children: [
|
|
9422
|
-
/* @__PURE__ */ (0,
|
|
9423
|
-
/* @__PURE__ */ (0,
|
|
9934
|
+
/* @__PURE__ */ (0, import_jsx_runtime12.jsx)("span", { className: "cv-person-filter-name", children: opt.label }),
|
|
9935
|
+
/* @__PURE__ */ (0, import_jsx_runtime12.jsx)(Check, { size: 14, className: "cv-person-filter-check", "data-on": opt.v === value, "aria-hidden": true })
|
|
9424
9936
|
]
|
|
9425
9937
|
},
|
|
9426
9938
|
opt.v || "all"
|
|
@@ -9431,11 +9943,11 @@ function HistoryTab({ source }) {
|
|
|
9431
9943
|
const softphone = useSoftphone();
|
|
9432
9944
|
const formatIntl2 = useIntlFormatter();
|
|
9433
9945
|
const now = Date.now();
|
|
9434
|
-
const [status, setStatus] = (0,
|
|
9435
|
-
const [agentId, setAgentId] = (0,
|
|
9436
|
-
const [directory, setDirectory] = (0,
|
|
9437
|
-
const [selected, setSelected] = (0,
|
|
9438
|
-
(0,
|
|
9946
|
+
const [status, setStatus] = (0, import_react24.useState)("all");
|
|
9947
|
+
const [agentId, setAgentId] = (0, import_react24.useState)("");
|
|
9948
|
+
const [directory, setDirectory] = (0, import_react24.useState)(null);
|
|
9949
|
+
const [selected, setSelected] = (0, import_react24.useState)(null);
|
|
9950
|
+
(0, import_react24.useEffect)(() => {
|
|
9439
9951
|
const listAgents = softphone.sources?.listAgents;
|
|
9440
9952
|
if (!listAgents) return;
|
|
9441
9953
|
let cancelled = false;
|
|
@@ -9445,7 +9957,7 @@ function HistoryTab({ source }) {
|
|
|
9445
9957
|
cancelled = true;
|
|
9446
9958
|
};
|
|
9447
9959
|
}, [softphone]);
|
|
9448
|
-
const loadPage = (0,
|
|
9960
|
+
const loadPage = (0, import_react24.useCallback)(
|
|
9449
9961
|
(skip) => source({
|
|
9450
9962
|
skip,
|
|
9451
9963
|
limit: PAGE2,
|
|
@@ -9456,9 +9968,9 @@ function HistoryTab({ source }) {
|
|
|
9456
9968
|
);
|
|
9457
9969
|
const { items: entries, loading: loading2, loadingMore, hasMore, containerRef, sentinelRef } = useInfiniteList(loadPage);
|
|
9458
9970
|
const groups = groupByDay(entries, now);
|
|
9459
|
-
return /* @__PURE__ */ (0,
|
|
9460
|
-
/* @__PURE__ */ (0,
|
|
9461
|
-
/* @__PURE__ */ (0,
|
|
9971
|
+
return /* @__PURE__ */ (0, import_jsx_runtime12.jsxs)("div", { className: "cv-view", children: [
|
|
9972
|
+
/* @__PURE__ */ (0, import_jsx_runtime12.jsxs)("div", { className: "cv-list-head", children: [
|
|
9973
|
+
/* @__PURE__ */ (0, import_jsx_runtime12.jsx)("div", { className: "cv-segmented", role: "tablist", "aria-label": "Filtrar por estado", children: ["all", "missed"].map((key) => /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(
|
|
9462
9974
|
"button",
|
|
9463
9975
|
{
|
|
9464
9976
|
type: "button",
|
|
@@ -9470,36 +9982,36 @@ function HistoryTab({ source }) {
|
|
|
9470
9982
|
},
|
|
9471
9983
|
key
|
|
9472
9984
|
)) }),
|
|
9473
|
-
directory ? /* @__PURE__ */ (0,
|
|
9985
|
+
directory ? /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(PersonFilter, { value: agentId, directory, onChange: setAgentId }) : null
|
|
9474
9986
|
] }),
|
|
9475
|
-
/* @__PURE__ */ (0,
|
|
9476
|
-
loading2 && /* @__PURE__ */ (0,
|
|
9477
|
-
!loading2 && entries.length === 0 && /* @__PURE__ */ (0,
|
|
9478
|
-
!loading2 && groups.map((group) => /* @__PURE__ */ (0,
|
|
9479
|
-
/* @__PURE__ */ (0,
|
|
9480
|
-
group.rows.map((e) => /* @__PURE__ */ (0,
|
|
9481
|
-
/* @__PURE__ */ (0,
|
|
9482
|
-
/* @__PURE__ */ (0,
|
|
9987
|
+
/* @__PURE__ */ (0, import_jsx_runtime12.jsxs)("div", { className: "cv-list", ref: containerRef, children: [
|
|
9988
|
+
loading2 && /* @__PURE__ */ (0, import_jsx_runtime12.jsx)("div", { className: "cv-empty", children: "Cargando\u2026" }),
|
|
9989
|
+
!loading2 && entries.length === 0 && /* @__PURE__ */ (0, import_jsx_runtime12.jsx)("div", { className: "cv-empty", children: status === "missed" ? "Sin llamadas perdidas" : "Sin llamadas recientes" }),
|
|
9990
|
+
!loading2 && groups.map((group) => /* @__PURE__ */ (0, import_jsx_runtime12.jsxs)("div", { children: [
|
|
9991
|
+
/* @__PURE__ */ (0, import_jsx_runtime12.jsx)("div", { className: "cv-history-day", children: group.label }),
|
|
9992
|
+
group.rows.map((e) => /* @__PURE__ */ (0, import_jsx_runtime12.jsxs)("div", { className: "cv-item", children: [
|
|
9993
|
+
/* @__PURE__ */ (0, import_jsx_runtime12.jsx)("span", { className: "cv-item-avatar", "data-tone": statusTone(e), "aria-hidden": true, children: /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(Icon2, { entry: e }) }),
|
|
9994
|
+
/* @__PURE__ */ (0, import_jsx_runtime12.jsxs)(
|
|
9483
9995
|
"button",
|
|
9484
9996
|
{
|
|
9485
9997
|
type: "button",
|
|
9486
9998
|
className: "cv-item-main",
|
|
9487
9999
|
onClick: () => setSelected(e),
|
|
9488
10000
|
children: [
|
|
9489
|
-
/* @__PURE__ */ (0,
|
|
9490
|
-
/* @__PURE__ */ (0,
|
|
9491
|
-
/* @__PURE__ */ (0,
|
|
9492
|
-
e.missed ? /* @__PURE__ */ (0,
|
|
10001
|
+
/* @__PURE__ */ (0, import_jsx_runtime12.jsx)("span", { className: "cv-item-name", "data-tone": e.missed ? "danger" : void 0, children: e.name ?? formatIntl2(e.number) }),
|
|
10002
|
+
/* @__PURE__ */ (0, import_jsx_runtime12.jsxs)("span", { className: "cv-item-sub", children: [
|
|
10003
|
+
/* @__PURE__ */ (0, import_jsx_runtime12.jsx)("span", { className: "tabular-nums", children: timeLabel(e.at) }),
|
|
10004
|
+
e.missed ? /* @__PURE__ */ (0, import_jsx_runtime12.jsxs)("span", { className: "cv-item-sub-outcome", children: [
|
|
9493
10005
|
" ",
|
|
9494
10006
|
"\xB7 ",
|
|
9495
10007
|
e.outcome ? OUTCOME_LABELS[e.outcome] : "Perdida"
|
|
9496
10008
|
] }) : e.durationSec > 0 && ` \xB7 ${formatDuration(e.durationSec)}`,
|
|
9497
|
-
e.recorded ? /* @__PURE__ */ (0,
|
|
10009
|
+
e.recorded ? /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(Mic, { size: 11, className: "cv-item-sub-rec", "aria-label": "Grabada" }) : null
|
|
9498
10010
|
] })
|
|
9499
10011
|
]
|
|
9500
10012
|
}
|
|
9501
10013
|
),
|
|
9502
|
-
/* @__PURE__ */ (0,
|
|
10014
|
+
/* @__PURE__ */ (0, import_jsx_runtime12.jsx)(
|
|
9503
10015
|
"button",
|
|
9504
10016
|
{
|
|
9505
10017
|
type: "button",
|
|
@@ -9507,30 +10019,30 @@ function HistoryTab({ source }) {
|
|
|
9507
10019
|
"data-tone": "accent",
|
|
9508
10020
|
"aria-label": `Llamar a ${e.name ?? e.number}`,
|
|
9509
10021
|
onClick: () => void softphone.call(e.number),
|
|
9510
|
-
children: /* @__PURE__ */ (0,
|
|
10022
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(Phone, { size: 16 })
|
|
9511
10023
|
}
|
|
9512
10024
|
)
|
|
9513
10025
|
] }, e.id))
|
|
9514
10026
|
] }, group.label)),
|
|
9515
|
-
!loading2 && hasMore ? /* @__PURE__ */ (0,
|
|
9516
|
-
loadingMore ? /* @__PURE__ */ (0,
|
|
10027
|
+
!loading2 && hasMore ? /* @__PURE__ */ (0, import_jsx_runtime12.jsx)("div", { ref: sentinelRef, className: "cv-list-sentinel", "aria-hidden": true }) : null,
|
|
10028
|
+
loadingMore ? /* @__PURE__ */ (0, import_jsx_runtime12.jsx)("div", { className: "cv-list-loading-more", role: "status", "aria-label": "Cargando m\xE1s llamadas", children: /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(LoaderCircle, { className: "cv-list-loading-more-spinner", size: 14, "aria-hidden": true }) }) : null
|
|
9517
10029
|
] }),
|
|
9518
|
-
selected ? /* @__PURE__ */ (0,
|
|
10030
|
+
selected ? /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(HistoryDetail, { entry: selected, onBack: () => setSelected(null) }) : null
|
|
9519
10031
|
] });
|
|
9520
10032
|
}
|
|
9521
10033
|
|
|
9522
10034
|
// src/ui/views/SettingsTab.tsx
|
|
9523
|
-
var
|
|
10035
|
+
var import_react34 = __toESM(require_react(), 1);
|
|
9524
10036
|
|
|
9525
10037
|
// src/ui/audio/ringtone-store.ts
|
|
9526
|
-
var
|
|
10038
|
+
var stores2 = /* @__PURE__ */ new WeakMap();
|
|
9527
10039
|
function ringtoneStore(softphone) {
|
|
9528
10040
|
const source = softphone.sources?.ringtones;
|
|
9529
10041
|
if (!source) return null;
|
|
9530
|
-
let store =
|
|
10042
|
+
let store = stores2.get(softphone);
|
|
9531
10043
|
if (!store) {
|
|
9532
10044
|
store = createRingtoneStore(source);
|
|
9533
|
-
|
|
10045
|
+
stores2.set(softphone, store);
|
|
9534
10046
|
}
|
|
9535
10047
|
return store;
|
|
9536
10048
|
}
|
|
@@ -9627,19 +10139,19 @@ async function decodeRingtone(url) {
|
|
|
9627
10139
|
}
|
|
9628
10140
|
|
|
9629
10141
|
// src/ui/views/SettingsSection.tsx
|
|
9630
|
-
var
|
|
10142
|
+
var import_jsx_runtime13 = __toESM(require_jsx_runtime(), 1);
|
|
9631
10143
|
function SettingsSection({
|
|
9632
10144
|
title,
|
|
9633
10145
|
children
|
|
9634
10146
|
}) {
|
|
9635
|
-
return /* @__PURE__ */ (0,
|
|
9636
|
-
/* @__PURE__ */ (0,
|
|
9637
|
-
/* @__PURE__ */ (0,
|
|
10147
|
+
return /* @__PURE__ */ (0, import_jsx_runtime13.jsxs)("section", { className: "cv-settings-section", children: [
|
|
10148
|
+
/* @__PURE__ */ (0, import_jsx_runtime13.jsx)("h3", { className: "cv-settings-section-title", children: title }),
|
|
10149
|
+
/* @__PURE__ */ (0, import_jsx_runtime13.jsx)("div", { className: "cv-settings-section-body", children })
|
|
9638
10150
|
] });
|
|
9639
10151
|
}
|
|
9640
10152
|
|
|
9641
10153
|
// src/ui/views/settings/SettingsNavRow.tsx
|
|
9642
|
-
var
|
|
10154
|
+
var import_jsx_runtime14 = __toESM(require_jsx_runtime(), 1);
|
|
9643
10155
|
function SettingsNavRow({
|
|
9644
10156
|
icon: Icon3,
|
|
9645
10157
|
title,
|
|
@@ -9647,44 +10159,46 @@ function SettingsNavRow({
|
|
|
9647
10159
|
onOpen,
|
|
9648
10160
|
rowRef
|
|
9649
10161
|
}) {
|
|
9650
|
-
const inner = /* @__PURE__ */ (0,
|
|
9651
|
-
/* @__PURE__ */ (0,
|
|
9652
|
-
/* @__PURE__ */ (0,
|
|
9653
|
-
/* @__PURE__ */ (0,
|
|
9654
|
-
/* @__PURE__ */ (0,
|
|
10162
|
+
const inner = /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_jsx_runtime14.Fragment, { children: [
|
|
10163
|
+
/* @__PURE__ */ (0, import_jsx_runtime14.jsx)("span", { className: "cv-settings-row-icon", "aria-hidden": true, children: /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(Icon3, { size: 16 }) }),
|
|
10164
|
+
/* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("span", { className: "cv-settings-nav-text", children: [
|
|
10165
|
+
/* @__PURE__ */ (0, import_jsx_runtime14.jsx)("span", { className: "cv-settings-nav-title", children: title }),
|
|
10166
|
+
/* @__PURE__ */ (0, import_jsx_runtime14.jsx)("span", { className: "cv-settings-nav-value", children: value })
|
|
9655
10167
|
] }),
|
|
9656
|
-
onOpen ? /* @__PURE__ */ (0,
|
|
10168
|
+
onOpen ? /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(ChevronRight, { size: 16, className: "cv-settings-nav-chevron", "aria-hidden": true }) : null
|
|
9657
10169
|
] });
|
|
9658
|
-
return onOpen ? /* @__PURE__ */ (0,
|
|
10170
|
+
return onOpen ? /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("button", { type: "button", className: "cv-settings-nav", onClick: onOpen, ref: rowRef, children: inner }) : /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("div", { className: "cv-settings-nav cv-settings-nav--static", children: inner });
|
|
9659
10171
|
}
|
|
9660
10172
|
|
|
9661
10173
|
// src/ui/views/settings/DevicePage.tsx
|
|
9662
|
-
var
|
|
10174
|
+
var import_react28 = __toESM(require_react(), 1);
|
|
9663
10175
|
|
|
9664
10176
|
// src/ui/views/settings/ChoiceList.tsx
|
|
9665
|
-
var
|
|
9666
|
-
var
|
|
10177
|
+
var import_react26 = __toESM(require_react(), 1);
|
|
10178
|
+
var import_jsx_runtime15 = __toESM(require_jsx_runtime(), 1);
|
|
9667
10179
|
function ChoiceList({
|
|
9668
10180
|
label,
|
|
9669
10181
|
choices,
|
|
9670
10182
|
value,
|
|
9671
10183
|
onSelect,
|
|
9672
|
-
onReselect
|
|
10184
|
+
onReselect,
|
|
10185
|
+
disabled = false
|
|
9673
10186
|
}) {
|
|
9674
|
-
const name = (0,
|
|
9675
|
-
const pointer = (0,
|
|
9676
|
-
return /* @__PURE__ */ (0,
|
|
10187
|
+
const name = (0, import_react26.useId)();
|
|
10188
|
+
const pointer = (0, import_react26.useRef)(false);
|
|
10189
|
+
return /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("div", { className: "cv-choices", role: "radiogroup", "aria-label": label, "aria-disabled": disabled || void 0, children: choices.map((c) => {
|
|
9677
10190
|
const checked = c.value === value;
|
|
9678
|
-
return /* @__PURE__ */ (0,
|
|
10191
|
+
return /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)(
|
|
9679
10192
|
"label",
|
|
9680
10193
|
{
|
|
9681
10194
|
className: "cv-choice",
|
|
9682
10195
|
"data-checked": checked || void 0,
|
|
10196
|
+
"data-disabled": disabled || void 0,
|
|
9683
10197
|
onPointerDown: () => {
|
|
9684
10198
|
pointer.current = true;
|
|
9685
10199
|
},
|
|
9686
10200
|
children: [
|
|
9687
|
-
/* @__PURE__ */ (0,
|
|
10201
|
+
/* @__PURE__ */ (0, import_jsx_runtime15.jsx)(
|
|
9688
10202
|
"input",
|
|
9689
10203
|
{
|
|
9690
10204
|
type: "radio",
|
|
@@ -9695,22 +10209,26 @@ function ChoiceList({
|
|
|
9695
10209
|
onChange: () => {
|
|
9696
10210
|
const via = pointer.current ? "pointer" : "keyboard";
|
|
9697
10211
|
pointer.current = false;
|
|
9698
|
-
onSelect(c.value, via);
|
|
10212
|
+
if (!disabled) onSelect(c.value, via);
|
|
9699
10213
|
},
|
|
9700
10214
|
onClick: () => {
|
|
9701
10215
|
if (c.value === value) {
|
|
9702
10216
|
pointer.current = false;
|
|
9703
|
-
onReselect?.(c.value);
|
|
10217
|
+
if (!disabled) onReselect?.(c.value);
|
|
9704
10218
|
}
|
|
9705
10219
|
}
|
|
9706
10220
|
}
|
|
9707
10221
|
),
|
|
9708
|
-
/* @__PURE__ */ (0,
|
|
9709
|
-
/* @__PURE__ */ (0,
|
|
9710
|
-
|
|
10222
|
+
/* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("span", { className: "cv-choice-text", children: [
|
|
10223
|
+
c.tag ? /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)("span", { className: "cv-choice-line", children: [
|
|
10224
|
+
/* @__PURE__ */ (0, import_jsx_runtime15.jsx)("span", { className: "cv-choice-label", children: c.label }),
|
|
10225
|
+
/* @__PURE__ */ (0, import_jsx_runtime15.jsx)("span", { className: "cv-choice-tag", children: c.tag })
|
|
10226
|
+
] }) : /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("span", { className: "cv-choice-label", children: c.label }),
|
|
10227
|
+
c.sub ? /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("span", { className: "cv-choice-sub", children: c.sub }) : null,
|
|
10228
|
+
c.hint ? /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("span", { className: "cv-choice-hint", children: c.hint }) : null
|
|
9711
10229
|
] }),
|
|
9712
10230
|
c.trailing,
|
|
9713
|
-
/* @__PURE__ */ (0,
|
|
10231
|
+
/* @__PURE__ */ (0, import_jsx_runtime15.jsx)(Check, { size: 16, className: "cv-choice-check", "aria-hidden": true })
|
|
9714
10232
|
]
|
|
9715
10233
|
},
|
|
9716
10234
|
c.value
|
|
@@ -9719,18 +10237,18 @@ function ChoiceList({
|
|
|
9719
10237
|
}
|
|
9720
10238
|
|
|
9721
10239
|
// src/ui/views/settings/SettingsPage.tsx
|
|
9722
|
-
var
|
|
9723
|
-
var
|
|
10240
|
+
var import_react27 = __toESM(require_react(), 1);
|
|
10241
|
+
var import_jsx_runtime16 = __toESM(require_jsx_runtime(), 1);
|
|
9724
10242
|
function SettingsPage({
|
|
9725
10243
|
title,
|
|
9726
10244
|
intro,
|
|
9727
10245
|
onBack,
|
|
9728
10246
|
children
|
|
9729
10247
|
}) {
|
|
9730
|
-
const titleId = (0,
|
|
9731
|
-
const heading = (0,
|
|
9732
|
-
(0,
|
|
9733
|
-
return /* @__PURE__ */ (0,
|
|
10248
|
+
const titleId = (0, import_react27.useId)();
|
|
10249
|
+
const heading = (0, import_react27.useRef)(null);
|
|
10250
|
+
(0, import_react27.useEffect)(() => heading.current?.focus(), []);
|
|
10251
|
+
return /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)(
|
|
9734
10252
|
"div",
|
|
9735
10253
|
{
|
|
9736
10254
|
className: "cv-detail cv-settings-page",
|
|
@@ -9743,13 +10261,13 @@ function SettingsPage({
|
|
|
9743
10261
|
}
|
|
9744
10262
|
},
|
|
9745
10263
|
children: [
|
|
9746
|
-
/* @__PURE__ */ (0,
|
|
9747
|
-
/* @__PURE__ */ (0,
|
|
10264
|
+
/* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { className: "cv-detail-head", children: /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("button", { type: "button", className: "cv-detail-back", onClick: onBack, children: [
|
|
10265
|
+
/* @__PURE__ */ (0, import_jsx_runtime16.jsx)(ArrowLeft, { size: 16, "aria-hidden": true }),
|
|
9748
10266
|
"Ajustes"
|
|
9749
10267
|
] }) }),
|
|
9750
|
-
/* @__PURE__ */ (0,
|
|
9751
|
-
/* @__PURE__ */ (0,
|
|
9752
|
-
intro ? /* @__PURE__ */ (0,
|
|
10268
|
+
/* @__PURE__ */ (0, import_jsx_runtime16.jsxs)("div", { className: "cv-settings-page-body", children: [
|
|
10269
|
+
/* @__PURE__ */ (0, import_jsx_runtime16.jsx)("h2", { className: "cv-settings-page-title", id: titleId, tabIndex: -1, ref: heading, children: title }),
|
|
10270
|
+
intro ? /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("p", { className: "cv-settings-page-intro", children: intro }) : null,
|
|
9753
10271
|
children
|
|
9754
10272
|
] })
|
|
9755
10273
|
]
|
|
@@ -9758,7 +10276,7 @@ function SettingsPage({
|
|
|
9758
10276
|
}
|
|
9759
10277
|
|
|
9760
10278
|
// src/ui/views/settings/DevicePage.tsx
|
|
9761
|
-
var
|
|
10279
|
+
var import_jsx_runtime17 = __toESM(require_jsx_runtime(), 1);
|
|
9762
10280
|
var SYSTEM_DEFAULT = "";
|
|
9763
10281
|
var PAGES = {
|
|
9764
10282
|
input: {
|
|
@@ -9791,8 +10309,8 @@ function DevicePage({ kind, onBack }) {
|
|
|
9791
10309
|
{ value: SYSTEM_DEFAULT, label: "Predeterminado del sistema" },
|
|
9792
10310
|
...list.map((d, i) => ({ value: d.deviceId, label: deviceLabel(d, i, kind) }))
|
|
9793
10311
|
];
|
|
9794
|
-
return /* @__PURE__ */ (0,
|
|
9795
|
-
list.length > 0 ? /* @__PURE__ */ (0,
|
|
10312
|
+
return /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)(SettingsPage, { title: page.title, intro: page.intro, onBack, children: [
|
|
10313
|
+
list.length > 0 ? /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(
|
|
9796
10314
|
ChoiceList,
|
|
9797
10315
|
{
|
|
9798
10316
|
label: page.title,
|
|
@@ -9800,15 +10318,15 @@ function DevicePage({ kind, onBack }) {
|
|
|
9800
10318
|
value: current ?? SYSTEM_DEFAULT,
|
|
9801
10319
|
onSelect: (v) => void apply(v === SYSTEM_DEFAULT ? null : v)
|
|
9802
10320
|
}
|
|
9803
|
-
) : /* @__PURE__ */ (0,
|
|
9804
|
-
kind === "output" ? /* @__PURE__ */ (0,
|
|
10321
|
+
) : /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("p", { className: "cv-settings-page-empty", children: page.empty }),
|
|
10322
|
+
kind === "output" ? /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(SpeakerTest, { sinkId: devices.currentOutputId }) : null
|
|
9805
10323
|
] });
|
|
9806
10324
|
}
|
|
9807
10325
|
function SpeakerTest({ sinkId }) {
|
|
9808
|
-
const [testing, setTesting] = (0,
|
|
9809
|
-
const tone = (0,
|
|
9810
|
-
const timer = (0,
|
|
9811
|
-
(0,
|
|
10326
|
+
const [testing, setTesting] = (0, import_react28.useState)(false);
|
|
10327
|
+
const tone = (0, import_react28.useRef)(null);
|
|
10328
|
+
const timer = (0, import_react28.useRef)(null);
|
|
10329
|
+
(0, import_react28.useEffect)(
|
|
9812
10330
|
() => () => {
|
|
9813
10331
|
tone.current?.stop();
|
|
9814
10332
|
if (timer.current) clearTimeout(timer.current);
|
|
@@ -9823,17 +10341,17 @@ function SpeakerTest({ sinkId }) {
|
|
|
9823
10341
|
if (timer.current) clearTimeout(timer.current);
|
|
9824
10342
|
timer.current = setTimeout(() => setTesting(false), TEST_TONE_MS);
|
|
9825
10343
|
};
|
|
9826
|
-
return /* @__PURE__ */ (0,
|
|
9827
|
-
/* @__PURE__ */ (0,
|
|
10344
|
+
return /* @__PURE__ */ (0, import_jsx_runtime17.jsxs)("button", { type: "button", className: "cv-btn cv-settings-test", onClick: test, disabled: testing, children: [
|
|
10345
|
+
/* @__PURE__ */ (0, import_jsx_runtime17.jsx)(Volume2, { size: 16, "aria-hidden": true }),
|
|
9828
10346
|
testing ? "Sonando\u2026" : "Probar altavoz"
|
|
9829
10347
|
] });
|
|
9830
10348
|
}
|
|
9831
10349
|
|
|
9832
10350
|
// src/ui/views/settings/RingtonePage.tsx
|
|
9833
|
-
var
|
|
10351
|
+
var import_react31 = __toESM(require_react(), 1);
|
|
9834
10352
|
|
|
9835
10353
|
// src/ui/views/settings/ringtone-state.ts
|
|
9836
|
-
var
|
|
10354
|
+
var import_react30 = __toESM(require_react(), 1);
|
|
9837
10355
|
|
|
9838
10356
|
// src/ui/audio/preview.ts
|
|
9839
10357
|
var PREVIEW_MAX_MS = 1e4;
|
|
@@ -9845,13 +10363,13 @@ function previewDurationMs(buffer, loop) {
|
|
|
9845
10363
|
}
|
|
9846
10364
|
|
|
9847
10365
|
// src/ui/views/settings/ringtone-state.ts
|
|
9848
|
-
var
|
|
10366
|
+
var noSubscribe2 = () => () => {
|
|
9849
10367
|
};
|
|
9850
|
-
var
|
|
10368
|
+
var noSettings2 = () => null;
|
|
9851
10369
|
function useRingtoneSettings(store) {
|
|
9852
|
-
const settings = (0,
|
|
9853
|
-
const [failed, setFailed] = (0,
|
|
9854
|
-
const load = (0,
|
|
10370
|
+
const settings = (0, import_react30.useSyncExternalStore)(store?.subscribe ?? noSubscribe2, store?.getSettings ?? noSettings2);
|
|
10371
|
+
const [failed, setFailed] = (0, import_react30.useState)(false);
|
|
10372
|
+
const load = (0, import_react30.useCallback)(() => {
|
|
9855
10373
|
if (!store) return () => {
|
|
9856
10374
|
};
|
|
9857
10375
|
let live = true;
|
|
@@ -9863,7 +10381,7 @@ function useRingtoneSettings(store) {
|
|
|
9863
10381
|
live = false;
|
|
9864
10382
|
};
|
|
9865
10383
|
}, [store]);
|
|
9866
|
-
(0,
|
|
10384
|
+
(0, import_react30.useEffect)(load, [load]);
|
|
9867
10385
|
return { settings, failed, retry: load };
|
|
9868
10386
|
}
|
|
9869
10387
|
function ringtoneSummary(settings, failed) {
|
|
@@ -9876,11 +10394,11 @@ function ringtoneSummary(settings, failed) {
|
|
|
9876
10394
|
return settings.catalogue.find((r) => r.url === null)?.name ?? "Predeterminado";
|
|
9877
10395
|
}
|
|
9878
10396
|
function useRingtonePreview(store, sinkId) {
|
|
9879
|
-
const [playing, setPlaying] = (0,
|
|
9880
|
-
const [error, setError] = (0,
|
|
9881
|
-
const current = (0,
|
|
9882
|
-
const generation = (0,
|
|
9883
|
-
const stop = (0,
|
|
10397
|
+
const [playing, setPlaying] = (0, import_react30.useState)(null);
|
|
10398
|
+
const [error, setError] = (0, import_react30.useState)(null);
|
|
10399
|
+
const current = (0, import_react30.useRef)(null);
|
|
10400
|
+
const generation = (0, import_react30.useRef)(0);
|
|
10401
|
+
const stop = (0, import_react30.useCallback)(() => {
|
|
9884
10402
|
generation.current += 1;
|
|
9885
10403
|
const c = current.current;
|
|
9886
10404
|
current.current = null;
|
|
@@ -9888,7 +10406,7 @@ function useRingtonePreview(store, sinkId) {
|
|
|
9888
10406
|
c?.tone?.stop();
|
|
9889
10407
|
setPlaying(null);
|
|
9890
10408
|
}, []);
|
|
9891
|
-
(0,
|
|
10409
|
+
(0, import_react30.useEffect)(() => stop, [stop]);
|
|
9892
10410
|
const start = (settings, choice) => {
|
|
9893
10411
|
stop();
|
|
9894
10412
|
primeAudio();
|
|
@@ -9919,7 +10437,7 @@ function useRingtonePreview(store, sinkId) {
|
|
|
9919
10437
|
}
|
|
9920
10438
|
|
|
9921
10439
|
// src/ui/views/settings/RingtonePage.tsx
|
|
9922
|
-
var
|
|
10440
|
+
var import_jsx_runtime18 = __toESM(require_jsx_runtime(), 1);
|
|
9923
10441
|
var ORG_DEFAULT = "";
|
|
9924
10442
|
var KEYBOARD_SETTLE_MS = 300;
|
|
9925
10443
|
function RingtonePage({
|
|
@@ -9932,8 +10450,8 @@ function RingtonePage({
|
|
|
9932
10450
|
const softphone = useSoftphone();
|
|
9933
10451
|
const devices = useDevices();
|
|
9934
10452
|
const preview = useRingtonePreview(store, devices.canSelectRingtoneOutput ? devices.currentRingtoneOutputId : null);
|
|
9935
|
-
const [pending, setPending] = (0,
|
|
9936
|
-
const [error, setError] = (0,
|
|
10453
|
+
const [pending, setPending] = (0, import_react31.useState)(void 0);
|
|
10454
|
+
const [error, setError] = (0, import_react31.useState)(null);
|
|
9937
10455
|
const save = useSerialSave(store, {
|
|
9938
10456
|
onSettled: () => setPending(void 0),
|
|
9939
10457
|
onFailed: () => {
|
|
@@ -9941,12 +10459,12 @@ function RingtonePage({
|
|
|
9941
10459
|
setError("No se pudo guardar el tono. Int\xE9ntalo de nuevo.");
|
|
9942
10460
|
}
|
|
9943
10461
|
});
|
|
9944
|
-
const latest = (0,
|
|
10462
|
+
const latest = (0, import_react31.useRef)(settings);
|
|
9945
10463
|
latest.current = settings;
|
|
9946
|
-
const settle = (0,
|
|
10464
|
+
const settle = (0, import_react31.useRef)(null);
|
|
9947
10465
|
const { stop: stopPreview } = preview;
|
|
9948
|
-
(0,
|
|
9949
|
-
(0,
|
|
10466
|
+
(0, import_react31.useEffect)(() => softphone.on("incomingCall" /* IncomingCall */, stopPreview), [softphone, stopPreview]);
|
|
10467
|
+
(0, import_react31.useEffect)(
|
|
9950
10468
|
() => () => {
|
|
9951
10469
|
if (settle.current) {
|
|
9952
10470
|
clearTimeout(settle.current.timer);
|
|
@@ -9982,14 +10500,14 @@ function RingtonePage({
|
|
|
9982
10500
|
if (preview.playing === value) preview.stop();
|
|
9983
10501
|
else preview.start(settings, value);
|
|
9984
10502
|
};
|
|
9985
|
-
return /* @__PURE__ */ (0,
|
|
10503
|
+
return /* @__PURE__ */ (0, import_jsx_runtime18.jsx)(
|
|
9986
10504
|
SettingsPage,
|
|
9987
10505
|
{
|
|
9988
10506
|
title: "Tono de llamada",
|
|
9989
10507
|
intro: "Lo que suena cuando entra una llamada. Se guarda en tu cuenta, as\xED que es el mismo en todos tus dispositivos.",
|
|
9990
10508
|
onBack,
|
|
9991
|
-
children: settings ? /* @__PURE__ */ (0,
|
|
9992
|
-
/* @__PURE__ */ (0,
|
|
10509
|
+
children: settings ? /* @__PURE__ */ (0, import_jsx_runtime18.jsxs)(import_jsx_runtime18.Fragment, { children: [
|
|
10510
|
+
/* @__PURE__ */ (0, import_jsx_runtime18.jsx)(
|
|
9993
10511
|
ChoiceList,
|
|
9994
10512
|
{
|
|
9995
10513
|
label: "Tono de llamada",
|
|
@@ -9999,13 +10517,13 @@ function RingtonePage({
|
|
|
9999
10517
|
onReselect
|
|
10000
10518
|
}
|
|
10001
10519
|
),
|
|
10002
|
-
error ?? preview.error ? /* @__PURE__ */ (0,
|
|
10003
|
-
] }) : failed ? /* @__PURE__ */ (0,
|
|
10004
|
-
/* @__PURE__ */ (0,
|
|
10005
|
-
/* @__PURE__ */ (0,
|
|
10006
|
-
] }) : /* @__PURE__ */ (0,
|
|
10007
|
-
/* @__PURE__ */ (0,
|
|
10008
|
-
/* @__PURE__ */ (0,
|
|
10520
|
+
error ?? preview.error ? /* @__PURE__ */ (0, import_jsx_runtime18.jsx)("p", { className: "cv-settings-error", role: "alert", children: error ?? preview.error }) : null
|
|
10521
|
+
] }) : failed ? /* @__PURE__ */ (0, import_jsx_runtime18.jsxs)("div", { className: "cv-empty", children: [
|
|
10522
|
+
/* @__PURE__ */ (0, import_jsx_runtime18.jsx)("p", { className: "cv-empty-title", children: "No se pudieron cargar los tonos." }),
|
|
10523
|
+
/* @__PURE__ */ (0, import_jsx_runtime18.jsx)("button", { type: "button", className: "cv-btn cv-settings-retry", onClick: onRetry, children: "Reintentar" })
|
|
10524
|
+
] }) : /* @__PURE__ */ (0, import_jsx_runtime18.jsxs)("div", { className: "cv-empty", role: "status", children: [
|
|
10525
|
+
/* @__PURE__ */ (0, import_jsx_runtime18.jsx)(LoaderCircle, { className: "cv-empty-spinner", size: 18, "aria-hidden": true }),
|
|
10526
|
+
/* @__PURE__ */ (0, import_jsx_runtime18.jsx)("span", { className: "cv-empty-title", children: "Cargando\u2026" })
|
|
10009
10527
|
] })
|
|
10010
10528
|
}
|
|
10011
10529
|
);
|
|
@@ -10016,9 +10534,9 @@ function currentValue(settings) {
|
|
|
10016
10534
|
function choicesFor(settings, playing) {
|
|
10017
10535
|
const org = settings.orgDefault ? settings.catalogue.find((r) => r.id === settings.orgDefault) : void 0;
|
|
10018
10536
|
const builtIn = settings.catalogue.find((r) => r.url === null);
|
|
10019
|
-
const mark = (value) => playing === value ? /* @__PURE__ */ (0,
|
|
10020
|
-
/* @__PURE__ */ (0,
|
|
10021
|
-
/* @__PURE__ */ (0,
|
|
10537
|
+
const mark = (value) => playing === value ? /* @__PURE__ */ (0, import_jsx_runtime18.jsxs)("span", { className: "cv-choice-playing", children: [
|
|
10538
|
+
/* @__PURE__ */ (0, import_jsx_runtime18.jsx)(Volume2, { size: 15, "aria-hidden": true }),
|
|
10539
|
+
/* @__PURE__ */ (0, import_jsx_runtime18.jsx)("span", { className: "cv-sr-only", children: "Sonando" })
|
|
10022
10540
|
] }) : void 0;
|
|
10023
10541
|
const orgChoice = {
|
|
10024
10542
|
value: ORG_DEFAULT,
|
|
@@ -10029,11 +10547,11 @@ function choicesFor(settings, playing) {
|
|
|
10029
10547
|
return [orgChoice, ...settings.catalogue.map((r) => ({ value: r.id, label: r.name, trailing: mark(r.id) }))];
|
|
10030
10548
|
}
|
|
10031
10549
|
function useSerialSave(store, handlers) {
|
|
10032
|
-
const wanted = (0,
|
|
10033
|
-
const busy = (0,
|
|
10034
|
-
const on = (0,
|
|
10550
|
+
const wanted = (0, import_react31.useRef)(null);
|
|
10551
|
+
const busy = (0, import_react31.useRef)(false);
|
|
10552
|
+
const on = (0, import_react31.useRef)(handlers);
|
|
10035
10553
|
on.current = handlers;
|
|
10036
|
-
const [save] = (0,
|
|
10554
|
+
const [save] = (0, import_react31.useState)(() => (id) => {
|
|
10037
10555
|
wanted.current = { id };
|
|
10038
10556
|
if (busy.current) return;
|
|
10039
10557
|
busy.current = true;
|
|
@@ -10054,21 +10572,123 @@ function useSerialSave(store, handlers) {
|
|
|
10054
10572
|
return save;
|
|
10055
10573
|
}
|
|
10056
10574
|
|
|
10575
|
+
// src/ui/views/settings/OutboundNumberPage.tsx
|
|
10576
|
+
var import_react33 = __toESM(require_react(), 1);
|
|
10577
|
+
var import_jsx_runtime19 = __toESM(require_jsx_runtime(), 1);
|
|
10578
|
+
var ORG_DEFAULT2 = "";
|
|
10579
|
+
var KEYBOARD_SETTLE_MS2 = 800;
|
|
10580
|
+
function OutboundNumberPage({
|
|
10581
|
+
store,
|
|
10582
|
+
settings,
|
|
10583
|
+
onBack
|
|
10584
|
+
}) {
|
|
10585
|
+
const formatIntl2 = useIntlFormatter();
|
|
10586
|
+
const current = numberName(effectiveNumber(settings), formatIntl2);
|
|
10587
|
+
const change = useOutboundNumberChange(store, settings, current);
|
|
10588
|
+
const [pending, setPending] = (0, import_react33.useState)(void 0);
|
|
10589
|
+
const settle = (0, import_react33.useRef)(null);
|
|
10590
|
+
const choose = (0, import_react33.useRef)(change.choose);
|
|
10591
|
+
choose.current = change.choose;
|
|
10592
|
+
(0, import_react33.useEffect)(
|
|
10593
|
+
() => () => {
|
|
10594
|
+
if (settle.current) clearTimeout(settle.current);
|
|
10595
|
+
},
|
|
10596
|
+
[]
|
|
10597
|
+
);
|
|
10598
|
+
const notice = (0, import_react33.useRef)(null);
|
|
10599
|
+
const apply = async (value) => {
|
|
10600
|
+
setPending(value);
|
|
10601
|
+
const applied = await choose.current(value || null);
|
|
10602
|
+
setPending(void 0);
|
|
10603
|
+
if (!applied) requestAnimationFrame(() => notice.current?.scrollIntoView?.({ block: "nearest" }));
|
|
10604
|
+
};
|
|
10605
|
+
const onSelect = (value, via) => {
|
|
10606
|
+
if (settle.current) clearTimeout(settle.current);
|
|
10607
|
+
settle.current = null;
|
|
10608
|
+
if (via === "pointer") {
|
|
10609
|
+
void apply(value);
|
|
10610
|
+
return;
|
|
10611
|
+
}
|
|
10612
|
+
setPending(value);
|
|
10613
|
+
settle.current = setTimeout(() => {
|
|
10614
|
+
settle.current = null;
|
|
10615
|
+
void apply(value);
|
|
10616
|
+
}, KEYBOARD_SETTLE_MS2);
|
|
10617
|
+
};
|
|
10618
|
+
return /* @__PURE__ */ (0, import_jsx_runtime19.jsxs)(
|
|
10619
|
+
SettingsPage,
|
|
10620
|
+
{
|
|
10621
|
+
title: "N\xFAmero de salida",
|
|
10622
|
+
intro: "El n\xFAmero que ve la persona a la que llamas. Es el mismo en todos tus dispositivos y tambi\xE9n puedes cambiarlo desde el teclado.",
|
|
10623
|
+
onBack,
|
|
10624
|
+
children: [
|
|
10625
|
+
change.message ? /* @__PURE__ */ (0, import_jsx_runtime19.jsx)("p", { className: "cv-settings-notice", role: "status", "data-kind": change.isError ? "error" : "info", ref: notice, children: change.message }) : null,
|
|
10626
|
+
/* @__PURE__ */ (0, import_jsx_runtime19.jsx)(
|
|
10627
|
+
ChoiceList,
|
|
10628
|
+
{
|
|
10629
|
+
label: "N\xFAmero de salida",
|
|
10630
|
+
choices: choicesFor2(settings, formatIntl2, change.busy ? pending : void 0),
|
|
10631
|
+
value: pending ?? currentValue2(settings),
|
|
10632
|
+
onSelect,
|
|
10633
|
+
disabled: change.locked
|
|
10634
|
+
}
|
|
10635
|
+
)
|
|
10636
|
+
]
|
|
10637
|
+
}
|
|
10638
|
+
);
|
|
10639
|
+
}
|
|
10640
|
+
function currentValue2(settings) {
|
|
10641
|
+
return settings.mine && settings.numbers.some((n) => n.id === settings.mine) ? settings.mine : ORG_DEFAULT2;
|
|
10642
|
+
}
|
|
10643
|
+
function choicesFor2(settings, formatIntl2, applying) {
|
|
10644
|
+
const orgDefault = settings.numbers.find((n) => n.id === settings.orgDefault);
|
|
10645
|
+
const spinner = (value) => applying === value ? /* @__PURE__ */ (0, import_jsx_runtime19.jsxs)("span", { className: "cv-choice-applying", children: [
|
|
10646
|
+
/* @__PURE__ */ (0, import_jsx_runtime19.jsx)(LoaderCircle, { size: 15, className: "cv-choice-applying-icon", "aria-hidden": true }),
|
|
10647
|
+
/* @__PURE__ */ (0, import_jsx_runtime19.jsx)("span", { className: "cv-sr-only", children: "Aplicando" })
|
|
10648
|
+
] }) : void 0;
|
|
10649
|
+
return [
|
|
10650
|
+
{
|
|
10651
|
+
value: ORG_DEFAULT2,
|
|
10652
|
+
label: "Por defecto de la organizaci\xF3n",
|
|
10653
|
+
sub: orgDefault ? numberName(orgDefault, formatIntl2) : "Sin n\xFAmero por defecto",
|
|
10654
|
+
trailing: spinner(ORG_DEFAULT2)
|
|
10655
|
+
},
|
|
10656
|
+
...settings.numbers.map((n) => {
|
|
10657
|
+
const kind = KIND_TAG[n.kind];
|
|
10658
|
+
return {
|
|
10659
|
+
value: n.id,
|
|
10660
|
+
label: numberName(n, formatIntl2),
|
|
10661
|
+
...n.label ? { sub: formatIntl2(`+${n.number}`) } : {},
|
|
10662
|
+
...kind ? { tag: kind.tag, hint: kind.hint } : {},
|
|
10663
|
+
trailing: spinner(n.id)
|
|
10664
|
+
};
|
|
10665
|
+
})
|
|
10666
|
+
];
|
|
10667
|
+
}
|
|
10668
|
+
function outboundSummary(settings, formatIntl2) {
|
|
10669
|
+
const effective = effectiveNumber(settings);
|
|
10670
|
+
const name = numberName(effective, formatIntl2);
|
|
10671
|
+
return settings.mine || !effective ? name : `${name} \xB7 organizaci\xF3n`;
|
|
10672
|
+
}
|
|
10673
|
+
|
|
10057
10674
|
// src/ui/views/SettingsTab.tsx
|
|
10058
|
-
var
|
|
10675
|
+
var import_jsx_runtime20 = __toESM(require_jsx_runtime(), 1);
|
|
10059
10676
|
function SettingsTab() {
|
|
10060
10677
|
const softphone = useSoftphone();
|
|
10061
10678
|
const devices = useDevices();
|
|
10062
10679
|
const store = ringtoneStore(softphone);
|
|
10063
10680
|
const ringtone = useRingtoneSettings(store);
|
|
10064
|
-
const
|
|
10065
|
-
const
|
|
10066
|
-
const
|
|
10067
|
-
const
|
|
10068
|
-
(0,
|
|
10681
|
+
const outStore = outboundNumberStore(softphone);
|
|
10682
|
+
const outbound = useOutboundNumbers(outStore).settings;
|
|
10683
|
+
const formatIntl2 = useIntlFormatter();
|
|
10684
|
+
const [page, setPage] = (0, import_react34.useState)(null);
|
|
10685
|
+
const list = (0, import_react34.useRef)(null);
|
|
10686
|
+
const rows = (0, import_react34.useRef)({});
|
|
10687
|
+
const returnTo = (0, import_react34.useRef)(null);
|
|
10688
|
+
(0, import_react34.useEffect)(() => {
|
|
10069
10689
|
void softphone.audio.refresh();
|
|
10070
10690
|
}, [softphone]);
|
|
10071
|
-
(0,
|
|
10691
|
+
(0, import_react34.useEffect)(() => {
|
|
10072
10692
|
list.current?.toggleAttribute("inert", page !== null);
|
|
10073
10693
|
if (page === null && returnTo.current) {
|
|
10074
10694
|
rows.current[returnTo.current]?.focus();
|
|
@@ -10083,9 +10703,9 @@ function SettingsTab() {
|
|
|
10083
10703
|
const rowRef = (p) => (el) => {
|
|
10084
10704
|
rows.current[p] = el;
|
|
10085
10705
|
};
|
|
10086
|
-
return /* @__PURE__ */ (0,
|
|
10087
|
-
/* @__PURE__ */ (0,
|
|
10088
|
-
store ? /* @__PURE__ */ (0,
|
|
10706
|
+
return /* @__PURE__ */ (0, import_jsx_runtime20.jsxs)("div", { className: "cv-view cv-settings", children: [
|
|
10707
|
+
/* @__PURE__ */ (0, import_jsx_runtime20.jsxs)("div", { className: "cv-settings-body", ref: list, children: [
|
|
10708
|
+
store ? /* @__PURE__ */ (0, import_jsx_runtime20.jsx)(SettingsSection, { title: "Llamadas entrantes", children: /* @__PURE__ */ (0, import_jsx_runtime20.jsx)(
|
|
10089
10709
|
SettingsNavRow,
|
|
10090
10710
|
{
|
|
10091
10711
|
icon: Music,
|
|
@@ -10095,8 +10715,18 @@ function SettingsTab() {
|
|
|
10095
10715
|
rowRef: rowRef("tone")
|
|
10096
10716
|
}
|
|
10097
10717
|
) }) : null,
|
|
10098
|
-
/* @__PURE__ */ (0,
|
|
10099
|
-
|
|
10718
|
+
outStore && outbound && outbound.numbers.length > 1 ? /* @__PURE__ */ (0, import_jsx_runtime20.jsx)(SettingsSection, { title: "Llamadas salientes", children: /* @__PURE__ */ (0, import_jsx_runtime20.jsx)(
|
|
10719
|
+
SettingsNavRow,
|
|
10720
|
+
{
|
|
10721
|
+
icon: PhoneOutgoing,
|
|
10722
|
+
title: "N\xFAmero de salida",
|
|
10723
|
+
value: outboundSummary(outbound, formatIntl2),
|
|
10724
|
+
onOpen: open("outbound"),
|
|
10725
|
+
rowRef: rowRef("outbound")
|
|
10726
|
+
}
|
|
10727
|
+
) }) : null,
|
|
10728
|
+
/* @__PURE__ */ (0, import_jsx_runtime20.jsxs)(SettingsSection, { title: "Dispositivos", children: [
|
|
10729
|
+
/* @__PURE__ */ (0, import_jsx_runtime20.jsx)(
|
|
10100
10730
|
SettingsNavRow,
|
|
10101
10731
|
{
|
|
10102
10732
|
icon: Mic,
|
|
@@ -10106,7 +10736,7 @@ function SettingsTab() {
|
|
|
10106
10736
|
rowRef: rowRef("input")
|
|
10107
10737
|
}
|
|
10108
10738
|
),
|
|
10109
|
-
devices.canSelectOutput ? /* @__PURE__ */ (0,
|
|
10739
|
+
devices.canSelectOutput ? /* @__PURE__ */ (0, import_jsx_runtime20.jsx)(
|
|
10110
10740
|
SettingsNavRow,
|
|
10111
10741
|
{
|
|
10112
10742
|
icon: Headphones,
|
|
@@ -10118,7 +10748,7 @@ function SettingsTab() {
|
|
|
10118
10748
|
) : (
|
|
10119
10749
|
// Safari and most of Firefox have no `setSinkId`: offering a page whose choice silently does
|
|
10120
10750
|
// nothing is worse than saying where the choice really lives.
|
|
10121
|
-
/* @__PURE__ */ (0,
|
|
10751
|
+
/* @__PURE__ */ (0, import_jsx_runtime20.jsx)(
|
|
10122
10752
|
SettingsNavRow,
|
|
10123
10753
|
{
|
|
10124
10754
|
icon: Headphones,
|
|
@@ -10127,7 +10757,7 @@ function SettingsTab() {
|
|
|
10127
10757
|
}
|
|
10128
10758
|
)
|
|
10129
10759
|
),
|
|
10130
|
-
devices.canSelectRingtoneOutput ? /* @__PURE__ */ (0,
|
|
10760
|
+
devices.canSelectRingtoneOutput ? /* @__PURE__ */ (0, import_jsx_runtime20.jsx)(
|
|
10131
10761
|
SettingsNavRow,
|
|
10132
10762
|
{
|
|
10133
10763
|
icon: BellRing,
|
|
@@ -10139,7 +10769,7 @@ function SettingsTab() {
|
|
|
10139
10769
|
) : null
|
|
10140
10770
|
] })
|
|
10141
10771
|
] }),
|
|
10142
|
-
page === "tone" && store ? /* @__PURE__ */ (0,
|
|
10772
|
+
page === "tone" && store ? /* @__PURE__ */ (0, import_jsx_runtime20.jsx)(
|
|
10143
10773
|
RingtonePage,
|
|
10144
10774
|
{
|
|
10145
10775
|
store,
|
|
@@ -10148,12 +10778,12 @@ function SettingsTab() {
|
|
|
10148
10778
|
onRetry: ringtone.retry,
|
|
10149
10779
|
onBack: back
|
|
10150
10780
|
}
|
|
10151
|
-
) : page !== null && page !== "tone" ? /* @__PURE__ */ (0,
|
|
10781
|
+
) : page === "outbound" && outStore && outbound ? /* @__PURE__ */ (0, import_jsx_runtime20.jsx)(OutboundNumberPage, { store: outStore, settings: outbound, onBack: back }) : page !== null && page !== "tone" && page !== "outbound" ? /* @__PURE__ */ (0, import_jsx_runtime20.jsx)(DevicePage, { kind: page, onBack: back }) : null
|
|
10152
10782
|
] });
|
|
10153
10783
|
}
|
|
10154
10784
|
|
|
10155
10785
|
// src/ui/permission/useMicPermission.ts
|
|
10156
|
-
var
|
|
10786
|
+
var import_react36 = __toESM(require_react(), 1);
|
|
10157
10787
|
|
|
10158
10788
|
// src/core/env.ts
|
|
10159
10789
|
function isBrowser() {
|
|
@@ -10165,8 +10795,8 @@ function isWebRtcSupported() {
|
|
|
10165
10795
|
|
|
10166
10796
|
// src/ui/permission/useMicPermission.ts
|
|
10167
10797
|
function useMicPermission() {
|
|
10168
|
-
const [state, setState] = (0,
|
|
10169
|
-
const warm = (0,
|
|
10798
|
+
const [state, setState] = (0, import_react36.useState)("checking");
|
|
10799
|
+
const warm = (0, import_react36.useCallback)(async () => {
|
|
10170
10800
|
try {
|
|
10171
10801
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
10172
10802
|
for (const track of stream.getTracks()) track.stop();
|
|
@@ -10175,7 +10805,7 @@ function useMicPermission() {
|
|
|
10175
10805
|
setState("denied");
|
|
10176
10806
|
}
|
|
10177
10807
|
}, []);
|
|
10178
|
-
(0,
|
|
10808
|
+
(0, import_react36.useEffect)(() => {
|
|
10179
10809
|
if (!isWebRtcSupported()) {
|
|
10180
10810
|
setState("unsupported");
|
|
10181
10811
|
return;
|
|
@@ -10266,60 +10896,60 @@ function unblockGuide() {
|
|
|
10266
10896
|
}
|
|
10267
10897
|
|
|
10268
10898
|
// src/ui/permission/MicPermissionGate.tsx
|
|
10269
|
-
var
|
|
10899
|
+
var import_jsx_runtime21 = __toESM(require_jsx_runtime(), 1);
|
|
10270
10900
|
function MicPrompt({
|
|
10271
10901
|
state,
|
|
10272
10902
|
onRequest
|
|
10273
10903
|
}) {
|
|
10274
10904
|
if (state === "unsupported") {
|
|
10275
|
-
return /* @__PURE__ */ (0,
|
|
10276
|
-
/* @__PURE__ */ (0,
|
|
10277
|
-
/* @__PURE__ */ (0,
|
|
10278
|
-
/* @__PURE__ */ (0,
|
|
10905
|
+
return /* @__PURE__ */ (0, import_jsx_runtime21.jsxs)("div", { className: "cv-gate", "data-state": state, "data-tone": "danger", children: [
|
|
10906
|
+
/* @__PURE__ */ (0, import_jsx_runtime21.jsx)("span", { className: "cv-gate-mark", children: /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(MicOff, { size: 22 }) }),
|
|
10907
|
+
/* @__PURE__ */ (0, import_jsx_runtime21.jsx)("h3", { className: "cv-gate-title", children: "Llamadas no disponibles" }),
|
|
10908
|
+
/* @__PURE__ */ (0, import_jsx_runtime21.jsx)("p", { children: "Este navegador no admite llamadas. Prueba con Chrome, Edge o Firefox." })
|
|
10279
10909
|
] });
|
|
10280
10910
|
}
|
|
10281
10911
|
if (state === "denied") {
|
|
10282
10912
|
const guide = unblockGuide();
|
|
10283
|
-
return /* @__PURE__ */ (0,
|
|
10284
|
-
/* @__PURE__ */ (0,
|
|
10285
|
-
/* @__PURE__ */ (0,
|
|
10286
|
-
/* @__PURE__ */ (0,
|
|
10287
|
-
/* @__PURE__ */ (0,
|
|
10288
|
-
/* @__PURE__ */ (0,
|
|
10289
|
-
/* @__PURE__ */ (0,
|
|
10913
|
+
return /* @__PURE__ */ (0, import_jsx_runtime21.jsxs)("div", { className: "cv-gate", "data-state": state, "data-tone": "danger", children: [
|
|
10914
|
+
/* @__PURE__ */ (0, import_jsx_runtime21.jsx)("span", { className: "cv-gate-mark", children: /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(MicOff, { size: 22 }) }),
|
|
10915
|
+
/* @__PURE__ */ (0, import_jsx_runtime21.jsx)("h3", { className: "cv-gate-title", children: "Micr\xF3fono bloqueado" }),
|
|
10916
|
+
/* @__PURE__ */ (0, import_jsx_runtime21.jsx)("p", { children: "Perm\xEDtelo en tu navegador para poder llamar." }),
|
|
10917
|
+
/* @__PURE__ */ (0, import_jsx_runtime21.jsxs)("details", { className: "cv-help", children: [
|
|
10918
|
+
/* @__PURE__ */ (0, import_jsx_runtime21.jsxs)("summary", { children: [
|
|
10919
|
+
/* @__PURE__ */ (0, import_jsx_runtime21.jsx)(CircleHelp, { size: 13, "aria-hidden": true }),
|
|
10290
10920
|
"C\xF3mo permitirlo"
|
|
10291
10921
|
] }),
|
|
10292
|
-
/* @__PURE__ */ (0,
|
|
10293
|
-
guide.url ? /* @__PURE__ */ (0,
|
|
10922
|
+
/* @__PURE__ */ (0, import_jsx_runtime21.jsx)("ol", { children: guide.steps.map((step) => /* @__PURE__ */ (0, import_jsx_runtime21.jsx)("li", { children: step }, step)) }),
|
|
10923
|
+
guide.url ? /* @__PURE__ */ (0, import_jsx_runtime21.jsxs)("a", { href: guide.url, target: "_blank", rel: "noreferrer noopener", children: [
|
|
10294
10924
|
"Gu\xEDa del navegador",
|
|
10295
|
-
/* @__PURE__ */ (0,
|
|
10925
|
+
/* @__PURE__ */ (0, import_jsx_runtime21.jsx)(ExternalLink, { size: 11, "aria-hidden": true })
|
|
10296
10926
|
] }) : null
|
|
10297
10927
|
] })
|
|
10298
10928
|
] });
|
|
10299
10929
|
}
|
|
10300
10930
|
if (state === "warming") {
|
|
10301
|
-
return /* @__PURE__ */ (0,
|
|
10302
|
-
/* @__PURE__ */ (0,
|
|
10303
|
-
/* @__PURE__ */ (0,
|
|
10304
|
-
/* @__PURE__ */ (0,
|
|
10931
|
+
return /* @__PURE__ */ (0, import_jsx_runtime21.jsxs)("div", { className: "cv-gate", "data-state": state, children: [
|
|
10932
|
+
/* @__PURE__ */ (0, import_jsx_runtime21.jsx)("span", { className: "cv-gate-mark", children: /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(Mic, { size: 22 }) }),
|
|
10933
|
+
/* @__PURE__ */ (0, import_jsx_runtime21.jsx)("h3", { className: "cv-gate-title", children: "Permite el micr\xF3fono" }),
|
|
10934
|
+
/* @__PURE__ */ (0, import_jsx_runtime21.jsx)("p", { children: "Acepta el aviso que muestra el navegador para continuar." })
|
|
10305
10935
|
] });
|
|
10306
10936
|
}
|
|
10307
10937
|
if (state === "checking") {
|
|
10308
|
-
return /* @__PURE__ */ (0,
|
|
10309
|
-
/* @__PURE__ */ (0,
|
|
10310
|
-
/* @__PURE__ */ (0,
|
|
10938
|
+
return /* @__PURE__ */ (0, import_jsx_runtime21.jsxs)("div", { className: "cv-gate", "data-state": state, children: [
|
|
10939
|
+
/* @__PURE__ */ (0, import_jsx_runtime21.jsx)("span", { className: "cv-gate-mark", children: /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(Mic, { size: 22 }) }),
|
|
10940
|
+
/* @__PURE__ */ (0, import_jsx_runtime21.jsx)("p", { children: "Preparando el micr\xF3fono\u2026" })
|
|
10311
10941
|
] });
|
|
10312
10942
|
}
|
|
10313
|
-
return /* @__PURE__ */ (0,
|
|
10314
|
-
/* @__PURE__ */ (0,
|
|
10315
|
-
/* @__PURE__ */ (0,
|
|
10316
|
-
/* @__PURE__ */ (0,
|
|
10317
|
-
/* @__PURE__ */ (0,
|
|
10943
|
+
return /* @__PURE__ */ (0, import_jsx_runtime21.jsxs)("div", { className: "cv-gate", "data-state": state, children: [
|
|
10944
|
+
/* @__PURE__ */ (0, import_jsx_runtime21.jsx)("span", { className: "cv-gate-mark", children: /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(Mic, { size: 22 }) }),
|
|
10945
|
+
/* @__PURE__ */ (0, import_jsx_runtime21.jsx)("h3", { className: "cv-gate-title", children: "Permite el micr\xF3fono" }),
|
|
10946
|
+
/* @__PURE__ */ (0, import_jsx_runtime21.jsx)("p", { children: "Lo necesitamos para que puedas hablar durante las llamadas." }),
|
|
10947
|
+
/* @__PURE__ */ (0, import_jsx_runtime21.jsx)("button", { className: "cv-btn cv-btn--accent", onClick: onRequest, children: "Permitir micr\xF3fono" })
|
|
10318
10948
|
] });
|
|
10319
10949
|
}
|
|
10320
10950
|
|
|
10321
10951
|
// src/ui/permission/ErrorGate.tsx
|
|
10322
|
-
var
|
|
10952
|
+
var import_jsx_runtime22 = (
|
|
10323
10953
|
/*
|
|
10324
10954
|
`part` on all four, because this is the most intrusive thing the SDK draws — it replaces the
|
|
10325
10955
|
whole dialpad — and inside `<coventia-softphone>` a shadow root makes it otherwise unreachable
|
|
@@ -10335,36 +10965,73 @@ function ErrorGate({
|
|
|
10335
10965
|
onRetry
|
|
10336
10966
|
}) {
|
|
10337
10967
|
const text = ERROR_TEXT[error.code];
|
|
10338
|
-
return /* @__PURE__ */ (0,
|
|
10339
|
-
/* @__PURE__ */ (0,
|
|
10340
|
-
/* @__PURE__ */ (0,
|
|
10341
|
-
text.body === void 0 ? null : /* @__PURE__ */ (0,
|
|
10342
|
-
/* @__PURE__ */ (0,
|
|
10968
|
+
return /* @__PURE__ */ (0, import_jsx_runtime22.jsxs)("div", { className: "cv-gate", "data-tone": "danger", "data-code": error.code, part: "error-gate", children: [
|
|
10969
|
+
/* @__PURE__ */ (0, import_jsx_runtime22.jsx)("span", { className: "cv-gate-mark", children: /* @__PURE__ */ (0, import_jsx_runtime22.jsx)(TriangleAlert, { size: 22 }) }),
|
|
10970
|
+
/* @__PURE__ */ (0, import_jsx_runtime22.jsx)("h3", { className: "cv-gate-title", part: "error-title", children: text.title }),
|
|
10971
|
+
text.body === void 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime22.jsx)("p", { part: "error-body", children: text.body }),
|
|
10972
|
+
/* @__PURE__ */ (0, import_jsx_runtime22.jsx)("button", { className: "cv-btn cv-btn--accent", onClick: onRetry, part: "error-retry", children: "Reintentar" })
|
|
10343
10973
|
] });
|
|
10344
10974
|
}
|
|
10345
10975
|
|
|
10346
10976
|
// src/ui/permission/LeaderGate.tsx
|
|
10347
|
-
var
|
|
10977
|
+
var import_jsx_runtime23 = __toESM(require_jsx_runtime(), 1);
|
|
10348
10978
|
function LeaderGate() {
|
|
10349
|
-
return /* @__PURE__ */ (0,
|
|
10350
|
-
/* @__PURE__ */ (0,
|
|
10351
|
-
/* @__PURE__ */ (0,
|
|
10352
|
-
/* @__PURE__ */ (0,
|
|
10979
|
+
return /* @__PURE__ */ (0, import_jsx_runtime23.jsxs)("div", { className: "cv-gate", part: "leader-gate", children: [
|
|
10980
|
+
/* @__PURE__ */ (0, import_jsx_runtime23.jsx)("span", { className: "cv-gate-mark", children: /* @__PURE__ */ (0, import_jsx_runtime23.jsx)(MonitorSmartphone, { size: 22 }) }),
|
|
10981
|
+
/* @__PURE__ */ (0, import_jsx_runtime23.jsx)("h3", { className: "cv-gate-title", part: "leader-title", children: "Tel\xE9fono activo en otra pesta\xF1a" }),
|
|
10982
|
+
/* @__PURE__ */ (0, import_jsx_runtime23.jsx)("p", { part: "leader-body", children: "Solo una pesta\xF1a puede usar el tel\xE9fono a la vez. Cierra la otra para activar esta." })
|
|
10983
|
+
] });
|
|
10984
|
+
}
|
|
10985
|
+
|
|
10986
|
+
// src/ui/permission/LineGate.tsx
|
|
10987
|
+
var import_jsx_runtime24 = (
|
|
10988
|
+
/*
|
|
10989
|
+
`part` on all four, like `ErrorGate`: it replaces the whole dialpad, and inside `<coventia-softphone>`
|
|
10990
|
+
a shadow root makes it otherwise unreachable from the host's CSS. `data-claim` is for the light-DOM
|
|
10991
|
+
`/ui` path and for debugging — it cannot cross the shadow boundary either.
|
|
10992
|
+
*/
|
|
10993
|
+
__toESM(require_jsx_runtime(), 1)
|
|
10994
|
+
);
|
|
10995
|
+
var BODY = "Las llamadas entran all\xED. Si prefieres recibirlas aqu\xED, pulsa \xABRecibir aqu\xED\xBB.";
|
|
10996
|
+
var sentence = (code) => `${ERROR_TEXT[code].title}. ${ERROR_TEXT[code].body ?? ""}`.trim();
|
|
10997
|
+
function LineGate() {
|
|
10998
|
+
const softphone = useSoftphone();
|
|
10999
|
+
const { where, claim } = useLine();
|
|
11000
|
+
const take = () => {
|
|
11001
|
+
softphone.takeLine().catch(() => {
|
|
11002
|
+
});
|
|
11003
|
+
};
|
|
11004
|
+
const body = claim === "refused" ? sentence("line/claim-refused") : claim === "failed" ? sentence("line/unavailable") : BODY;
|
|
11005
|
+
return /* @__PURE__ */ (0, import_jsx_runtime24.jsxs)("div", { className: "cv-gate", "data-claim": claim, part: "line-gate", children: [
|
|
11006
|
+
/* @__PURE__ */ (0, import_jsx_runtime24.jsx)("span", { className: "cv-gate-mark", children: /* @__PURE__ */ (0, import_jsx_runtime24.jsx)(PhoneForwarded, { size: 22 }) }),
|
|
11007
|
+
/* @__PURE__ */ (0, import_jsx_runtime24.jsx)("h3", { className: "cv-gate-title", part: "line-title", children: where === "desktop" ? "Tu tel\xE9fono est\xE1 activo en la app de escritorio" : "Tu tel\xE9fono est\xE1 activo en otro sitio" }),
|
|
11008
|
+
/* @__PURE__ */ (0, import_jsx_runtime24.jsx)("p", { role: "status", part: "line-body", children: body }),
|
|
11009
|
+
/* @__PURE__ */ (0, import_jsx_runtime24.jsx)(
|
|
11010
|
+
"button",
|
|
11011
|
+
{
|
|
11012
|
+
className: "cv-btn cv-btn--accent",
|
|
11013
|
+
onClick: take,
|
|
11014
|
+
disabled: claim === "claiming",
|
|
11015
|
+
"aria-busy": claim === "claiming",
|
|
11016
|
+
part: "line-claim",
|
|
11017
|
+
children: claim === "claiming" ? "Cambiando\u2026" : "Recibir aqu\xED"
|
|
11018
|
+
}
|
|
11019
|
+
)
|
|
10353
11020
|
] });
|
|
10354
11021
|
}
|
|
10355
11022
|
|
|
10356
11023
|
// src/ui/lib/useCallOutcome.ts
|
|
10357
|
-
var
|
|
11024
|
+
var import_react38 = __toESM(require_react(), 1);
|
|
10358
11025
|
var BACKSTOP_MS = 6e3;
|
|
10359
11026
|
function useCallOutcome() {
|
|
10360
11027
|
const softphone = useSoftphone();
|
|
10361
|
-
const [code, setCode] = (0,
|
|
10362
|
-
const timer = (0,
|
|
10363
|
-
const clear = (0,
|
|
11028
|
+
const [code, setCode] = (0, import_react38.useState)(null);
|
|
11029
|
+
const timer = (0, import_react38.useRef)(void 0);
|
|
11030
|
+
const clear = (0, import_react38.useCallback)(() => {
|
|
10364
11031
|
clearTimeout(timer.current);
|
|
10365
11032
|
setCode(null);
|
|
10366
11033
|
}, []);
|
|
10367
|
-
(0,
|
|
11034
|
+
(0, import_react38.useEffect)(() => {
|
|
10368
11035
|
const off = softphone.on("error" /* Error */, (error) => {
|
|
10369
11036
|
if (error.fatal) return;
|
|
10370
11037
|
clearTimeout(timer.current);
|
|
@@ -10376,7 +11043,7 @@ function useCallOutcome() {
|
|
|
10376
11043
|
clearTimeout(timer.current);
|
|
10377
11044
|
};
|
|
10378
11045
|
}, [softphone]);
|
|
10379
|
-
(0,
|
|
11046
|
+
(0, import_react38.useEffect)(() => softphone.on("incomingCall" /* IncomingCall */, () => clear()), [softphone, clear]);
|
|
10380
11047
|
return {
|
|
10381
11048
|
outcome: code === null ? null : { code, text: ERROR_TEXT[code].title },
|
|
10382
11049
|
clear
|
|
@@ -10384,12 +11051,12 @@ function useCallOutcome() {
|
|
|
10384
11051
|
}
|
|
10385
11052
|
|
|
10386
11053
|
// src/ui/audio/useCallAudio.ts
|
|
10387
|
-
var
|
|
11054
|
+
var import_react40 = __toESM(require_react(), 1);
|
|
10388
11055
|
function useCallAudio(enabled) {
|
|
10389
11056
|
const softphone = useSoftphone();
|
|
10390
|
-
const remote = (0,
|
|
10391
|
-
const tone = (0,
|
|
10392
|
-
(0,
|
|
11057
|
+
const remote = (0, import_react40.useRef)(null);
|
|
11058
|
+
const tone = (0, import_react40.useRef)(null);
|
|
11059
|
+
(0, import_react40.useEffect)(() => {
|
|
10393
11060
|
if (!enabled || typeof document === "undefined") return;
|
|
10394
11061
|
const prime = () => primeAudio();
|
|
10395
11062
|
const gestures = ["pointerdown", "keydown"];
|
|
@@ -10483,7 +11150,7 @@ function useCallAudio(enabled) {
|
|
|
10483
11150
|
}
|
|
10484
11151
|
|
|
10485
11152
|
// src/ui/notices/TransferNotices.tsx
|
|
10486
|
-
var
|
|
11153
|
+
var import_jsx_runtime25 = __toESM(require_jsx_runtime(), 1);
|
|
10487
11154
|
function TransferNotices({
|
|
10488
11155
|
notices,
|
|
10489
11156
|
dismiss
|
|
@@ -10495,7 +11162,7 @@ function TransferNotices({
|
|
|
10495
11162
|
if (notice.caller) void softphone.call(notice.caller).catch(() => {
|
|
10496
11163
|
});
|
|
10497
11164
|
};
|
|
10498
|
-
return /* @__PURE__ */ (0,
|
|
11165
|
+
return /* @__PURE__ */ (0, import_jsx_runtime25.jsxs)(
|
|
10499
11166
|
"div",
|
|
10500
11167
|
{
|
|
10501
11168
|
className: "cv-transfer-notices",
|
|
@@ -10503,20 +11170,20 @@ function TransferNotices({
|
|
|
10503
11170
|
part: "transfer-notices",
|
|
10504
11171
|
"data-has-notices": notices.length > 0,
|
|
10505
11172
|
children: [
|
|
10506
|
-
notices.length > 1 ? /* @__PURE__ */ (0,
|
|
11173
|
+
notices.length > 1 ? /* @__PURE__ */ (0, import_jsx_runtime25.jsxs)("p", { className: "cv-transfer-notices-count", children: [
|
|
10507
11174
|
notices.length,
|
|
10508
11175
|
" traspasos sin conectar"
|
|
10509
11176
|
] }) : null,
|
|
10510
|
-
notices.map((notice) => /* @__PURE__ */ (0,
|
|
10511
|
-
/* @__PURE__ */ (0,
|
|
10512
|
-
/* @__PURE__ */ (0,
|
|
10513
|
-
/* @__PURE__ */ (0,
|
|
10514
|
-
/* @__PURE__ */ (0,
|
|
11177
|
+
notices.map((notice) => /* @__PURE__ */ (0, import_jsx_runtime25.jsxs)("div", { className: "cv-transfer-notice", part: "transfer-notice", children: [
|
|
11178
|
+
/* @__PURE__ */ (0, import_jsx_runtime25.jsx)("span", { className: "cv-transfer-notice-mark", "aria-hidden": true, children: /* @__PURE__ */ (0, import_jsx_runtime25.jsx)(PhoneMissed, { size: 16 }) }),
|
|
11179
|
+
/* @__PURE__ */ (0, import_jsx_runtime25.jsxs)("div", { className: "cv-transfer-notice-body", children: [
|
|
11180
|
+
/* @__PURE__ */ (0, import_jsx_runtime25.jsxs)("div", { className: "cv-transfer-notice-header", children: [
|
|
11181
|
+
/* @__PURE__ */ (0, import_jsx_runtime25.jsxs)("p", { className: "cv-transfer-notice-title", children: [
|
|
10515
11182
|
"El traspaso a ",
|
|
10516
11183
|
notice.target,
|
|
10517
11184
|
" no se conect\xF3"
|
|
10518
11185
|
] }),
|
|
10519
|
-
/* @__PURE__ */ (0,
|
|
11186
|
+
/* @__PURE__ */ (0, import_jsx_runtime25.jsx)(
|
|
10520
11187
|
"button",
|
|
10521
11188
|
{
|
|
10522
11189
|
type: "button",
|
|
@@ -10524,17 +11191,17 @@ function TransferNotices({
|
|
|
10524
11191
|
onClick: () => dismiss(notice.id),
|
|
10525
11192
|
"aria-label": "Descartar aviso",
|
|
10526
11193
|
part: "transfer-notice-dismiss",
|
|
10527
|
-
children: /* @__PURE__ */ (0,
|
|
11194
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime25.jsx)(X, { size: 14 })
|
|
10528
11195
|
}
|
|
10529
11196
|
)
|
|
10530
11197
|
] }),
|
|
10531
|
-
notice.caller ? /* @__PURE__ */ (0,
|
|
11198
|
+
notice.caller ? /* @__PURE__ */ (0, import_jsx_runtime25.jsxs)("p", { className: "cv-transfer-notice-subtitle", children: [
|
|
10532
11199
|
notice.caller,
|
|
10533
11200
|
" se ha desconectado."
|
|
10534
11201
|
] }) : null,
|
|
10535
|
-
/* @__PURE__ */ (0,
|
|
10536
|
-
/* @__PURE__ */ (0,
|
|
10537
|
-
notice.caller ? /* @__PURE__ */ (0,
|
|
11202
|
+
/* @__PURE__ */ (0, import_jsx_runtime25.jsxs)("div", { className: "cv-transfer-notice-footer", children: [
|
|
11203
|
+
/* @__PURE__ */ (0, import_jsx_runtime25.jsx)("p", { className: "cv-transfer-notice-age", children: formatAge(elapsedSeconds(notice.at, now)) }),
|
|
11204
|
+
notice.caller ? /* @__PURE__ */ (0, import_jsx_runtime25.jsxs)(
|
|
10538
11205
|
"button",
|
|
10539
11206
|
{
|
|
10540
11207
|
type: "button",
|
|
@@ -10542,7 +11209,7 @@ function TransferNotices({
|
|
|
10542
11209
|
onClick: () => callBack(notice),
|
|
10543
11210
|
part: "transfer-notice-callback",
|
|
10544
11211
|
children: [
|
|
10545
|
-
/* @__PURE__ */ (0,
|
|
11212
|
+
/* @__PURE__ */ (0, import_jsx_runtime25.jsx)(Phone, { size: 14, "aria-hidden": true }),
|
|
10546
11213
|
"Llamar de vuelta"
|
|
10547
11214
|
]
|
|
10548
11215
|
}
|
|
@@ -10556,15 +11223,15 @@ function TransferNotices({
|
|
|
10556
11223
|
}
|
|
10557
11224
|
|
|
10558
11225
|
// src/ui/notices/useTransferNotices.ts
|
|
10559
|
-
var
|
|
11226
|
+
var import_react43 = __toESM(require_react(), 1);
|
|
10560
11227
|
function useTransferNotices() {
|
|
10561
11228
|
const softphone = useSoftphone();
|
|
10562
|
-
const [notices, setNotices] = (0,
|
|
10563
|
-
const nextId = (0,
|
|
10564
|
-
const dismiss = (0,
|
|
11229
|
+
const [notices, setNotices] = (0, import_react43.useState)([]);
|
|
11230
|
+
const nextId = (0, import_react43.useRef)(0);
|
|
11231
|
+
const dismiss = (0, import_react43.useCallback)((id) => {
|
|
10565
11232
|
setNotices((prev) => prev.filter((n) => n.id !== id));
|
|
10566
11233
|
}, []);
|
|
10567
|
-
(0,
|
|
11234
|
+
(0, import_react43.useEffect)(
|
|
10568
11235
|
() => softphone.on("transferOutcome" /* TransferOutcome */, (payload) => {
|
|
10569
11236
|
if (payload.status === "not-connected") {
|
|
10570
11237
|
const id = nextId.current++;
|
|
@@ -10577,7 +11244,7 @@ function useTransferNotices() {
|
|
|
10577
11244
|
}
|
|
10578
11245
|
|
|
10579
11246
|
// src/ui/SoftphoneWidget.tsx
|
|
10580
|
-
var
|
|
11247
|
+
var import_jsx_runtime26 = __toESM(require_jsx_runtime(), 1);
|
|
10581
11248
|
function SoftphoneWidget(props) {
|
|
10582
11249
|
const {
|
|
10583
11250
|
contacts,
|
|
@@ -10593,16 +11260,17 @@ function SoftphoneWidget(props) {
|
|
|
10593
11260
|
const fallback = softphone.sources;
|
|
10594
11261
|
const contactsSource = contacts ?? fallback?.contacts;
|
|
10595
11262
|
const historySource = history ?? fallback?.history;
|
|
10596
|
-
const [tab, setTab] = (0,
|
|
11263
|
+
const [tab, setTab] = (0, import_react45.useState)("dialer");
|
|
10597
11264
|
const call = useActiveCall();
|
|
10598
11265
|
const isLeader = useIsLeader();
|
|
10599
11266
|
const softphoneState = useSoftphoneState();
|
|
10600
11267
|
const isFollower = !isLeader && softphoneState === "starting";
|
|
11268
|
+
const isStandby = softphoneState === "standby";
|
|
10601
11269
|
const mic = useMicPermission();
|
|
10602
11270
|
useCallAudio(sounds);
|
|
10603
11271
|
const blocked = requireMic && mic.state !== "ready";
|
|
10604
11272
|
const fatal = useLastError();
|
|
10605
|
-
const screenIsFree = !call && !blocked && !fatal && !isFollower;
|
|
11273
|
+
const screenIsFree = !call && !blocked && !fatal && !isFollower && !isStandby;
|
|
10606
11274
|
const { outcome, clear: clearOutcome } = useCallOutcome();
|
|
10607
11275
|
const { notices: transferNotices, dismiss: dismissTransferNotice } = useTransferNotices();
|
|
10608
11276
|
const retry = () => {
|
|
@@ -10622,15 +11290,15 @@ function SoftphoneWidget(props) {
|
|
|
10622
11290
|
// optional parts of a phone, so this is never conditionally hidden.
|
|
10623
11291
|
{ id: "settings", icon: Settings, label: "Ajustes", show: true }
|
|
10624
11292
|
].filter((t) => t.show);
|
|
10625
|
-
return /* @__PURE__ */ (0,
|
|
11293
|
+
return /* @__PURE__ */ (0, import_jsx_runtime26.jsxs)(
|
|
10626
11294
|
"div",
|
|
10627
11295
|
{
|
|
10628
11296
|
className: className ? `cv-softphone ${className}` : "cv-softphone",
|
|
10629
11297
|
...theme === "system" ? {} : { "data-cv-theme": theme },
|
|
10630
11298
|
children: [
|
|
10631
|
-
screenIsFree ? /* @__PURE__ */ (0,
|
|
10632
|
-
/* @__PURE__ */ (0,
|
|
10633
|
-
screenIsFree ? /* @__PURE__ */ (0,
|
|
11299
|
+
screenIsFree ? /* @__PURE__ */ (0, import_jsx_runtime26.jsx)(TransferNotices, { notices: transferNotices, dismiss: dismissTransferNotice }) : null,
|
|
11300
|
+
/* @__PURE__ */ (0, import_jsx_runtime26.jsx)("div", { className: "cv-body", children: isFollower ? /* @__PURE__ */ (0, import_jsx_runtime26.jsx)(LeaderGate, {}) : isStandby && !call ? /* @__PURE__ */ (0, import_jsx_runtime26.jsx)(LineGate, {}) : blocked ? /* @__PURE__ */ (0, import_jsx_runtime26.jsx)(MicPrompt, { state: mic.state, onRequest: () => void mic.request() }) : call ? /* @__PURE__ */ (0, import_jsx_runtime26.jsx)(CallScreen, { call, keyTones: sounds, ...incomingCallAction ? { incomingCallAction } : {} }) : fatal ? /* @__PURE__ */ (0, import_jsx_runtime26.jsx)(ErrorGate, { error: fatal, onRetry: retry }) : tab === "contacts" && contactsSource ? /* @__PURE__ */ (0, import_jsx_runtime26.jsx)(ContactsTab, { source: contactsSource, ...contactAction ? { contactAction } : {} }) : tab === "history" && historySource ? /* @__PURE__ */ (0, import_jsx_runtime26.jsx)(HistoryTab, { source: historySource }) : tab === "settings" ? /* @__PURE__ */ (0, import_jsx_runtime26.jsx)(SettingsTab, {}) : /* @__PURE__ */ (0, import_jsx_runtime26.jsx)(Dialpad, { outcome, onIntent: clearOutcome, keyTones: sounds }) }),
|
|
11301
|
+
screenIsFree ? /* @__PURE__ */ (0, import_jsx_runtime26.jsx)("div", { className: "cv-tabs", role: "tablist", children: tabs.map(({ id, icon: Icon3, label }) => /* @__PURE__ */ (0, import_jsx_runtime26.jsxs)(
|
|
10634
11302
|
"button",
|
|
10635
11303
|
{
|
|
10636
11304
|
role: "tab",
|
|
@@ -10638,8 +11306,8 @@ function SoftphoneWidget(props) {
|
|
|
10638
11306
|
className: "cv-tab",
|
|
10639
11307
|
onClick: () => setTab(id),
|
|
10640
11308
|
children: [
|
|
10641
|
-
/* @__PURE__ */ (0,
|
|
10642
|
-
/* @__PURE__ */ (0,
|
|
11309
|
+
/* @__PURE__ */ (0, import_jsx_runtime26.jsx)(Icon3, { size: 20 }),
|
|
11310
|
+
/* @__PURE__ */ (0, import_jsx_runtime26.jsx)("span", { children: label })
|
|
10643
11311
|
]
|
|
10644
11312
|
},
|
|
10645
11313
|
id
|
|
@@ -10650,7 +11318,7 @@ function SoftphoneWidget(props) {
|
|
|
10650
11318
|
}
|
|
10651
11319
|
|
|
10652
11320
|
// src/styles/softphone.css
|
|
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";
|
|
11321
|
+
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-head { margin: 0 0.6rem 0.6rem; }\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-head { margin-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 number: ONE bordered field, the prefix inside it on the left and erase on the right \u2014 the pattern of the\r\n providers' dialers (Talkdesk \"[\u{1F1FA}\u{1F1F8} +1 \u25BE | Type or paste a number]\", Zendesk, Aircall; outbound-number-per-agent.md\r\n \xA75.5, 2026-10-26). The prefix is part of the number being dialled, so it sits inside the same box. The number\r\n shrinks to fit (`DISPLAY_SIZES` in Dialpad.tsx). The field is the positioned ancestor of the country list, so the\r\n list spans its width (`left: 0; right: 0`). */\r\n.cv-dial-head {\r\n flex-shrink: 0;\r\n position: relative;\r\n display: flex;\r\n align-items: center;\r\n height: 46px;\r\n margin: 0 1rem 0.75rem;\r\n padding: 0 0.2rem 0 0;\r\n border: 1px solid var(--cv-border-strong);\r\n border-radius: 10px;\r\n background: var(--cv-bg);\r\n transition: border-color 0.15s ease, box-shadow 0.15s ease;\r\n}\r\n/* With no \"Llamar desde\" line above it (an org with one number), the field opens the view. */\r\n.cv-view > .cv-dial-head:first-child { margin-top: 1rem; }\r\n/* Focus shows on the field, as on any input (WCAG 2.4.7): the accent border and a soft ring. */\r\n.cv-dial-head:focus-within {\r\n border-color: var(--cv-accent);\r\n box-shadow: 0 0 0 3px color-mix(in srgb, var(--cv-accent) 18%, transparent);\r\n}\r\n.cv-dial-head > .cv-cp {\r\n position: static;\r\n width: auto;\r\n flex-shrink: 0;\r\n align-self: stretch;\r\n}\r\n/* The prefix as the field's leading segment: the same chip, flush with the field, divided from the number. */\r\n.cv-dial-head .cv-cp-chip {\r\n height: 100%;\r\n padding: 0 0.45rem 0 0.65rem;\r\n border: 0;\r\n border-right: 1px solid var(--cv-border);\r\n border-radius: 9px 0 0 9px;\r\n background: var(--cv-surface);\r\n}\r\n.cv-dial-head .cv-cp-chip:focus-visible { outline: 2px solid var(--cv-accent); outline-offset: -2px; }\r\n/* Following a number typed in full (\"+44 \u2026\"): read-only \u2014 the flag alone, no caret, no hover. */\r\n.cv-cp-chip[data-from-number] {\r\n cursor: default;\r\n padding: 0 0.6rem;\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/* Left-aligned after the prefix segment, as a phone field reads (\"+34 | 600 11 12 22\"). 1.375rem is the start of\r\n `DISPLAY_SIZES`, which steps it down until a long number fits the field. */\r\n.cv-display {\r\n flex: 1;\r\n min-width: 0;\r\n height: 100%;\r\n font: 500 1.375rem var(--cv-font);\r\n font-variant-numeric: tabular-nums;\r\n letter-spacing: -0.01em;\r\n text-align: left;\r\n color: var(--cv-text);\r\n border: none;\r\n background: none;\r\n padding: 0 0.6rem;\r\n caret-color: var(--cv-accent);\r\n}\r\n/* `--cv-muted`, not `--cv-faint`: a placeholder is read like any text (4.5:1). */\r\n.cv-display::placeholder { color: var(--cv-muted); font-weight: 400; }\r\n/* The field around it shows focus (`.cv-dial-head:focus-within`). */\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 /* `muted` (was `faint`, 2.5:1): the letters are read when dialling a number written in letters (900-FLORES),\r\n so they are text, held to 4.5:1 like any other (design critique, 2026-10-26). */\r\n color: var(--cv-muted);\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/* Which of the org's numbers this outbound call shows. Smaller than the callee: context, not identity \u2014 but muted,\r\n not faint: --cv-faint is under 4.5:1 at this size in both themes, and the agent may need to read it mid-call. */\r\n.cv-call-from {\r\n display: inline-flex;\r\n align-items: center;\r\n gap: 0.25rem;\r\n max-width: 100%;\r\n margin-top: 4px;\r\n font-size: 0.72rem;\r\n color: var(--cv-muted);\r\n}\r\n.cv-call-from span {\r\n min-width: 0;\r\n overflow: hidden;\r\n text-overflow: ellipsis;\r\n white-space: nowrap;\r\n}\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/* A second line under a row's value, for two facts that would each be cut off sharing one line (\"Llamada desde\":\r\n the name the org gave the number, then the number). */\r\n.cv-detail-row-sub {\r\n display: block;\r\n font-size: 0.75rem;\r\n font-weight: 400;\r\n color: var(--cv-muted);\r\n overflow: hidden;\r\n text-overflow: ellipsis;\r\n}\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/* Why the choice cannot change right now, and until when (N\xFAmero de salida). Calm, like the dialpad's notice: the\r\n phone keeps working. Above the list, so it is in sight of the row just tapped. */\r\n.cv-settings-notice {\r\n margin: 0 0 0.75rem;\r\n padding: 0.6rem 0.75rem;\r\n border-radius: 10px;\r\n background: var(--cv-surface-2);\r\n color: var(--cv-text);\r\n font-size: 0.78rem;\r\n line-height: 1.4;\r\n}\r\n.cv-settings-notice[data-kind='error'] { 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/* A tagged choice (N\xFAmero de salida): the label and its tag on one line, like the dialpad's list. */\r\n.cv-choice-line { display: flex; align-items: center; gap: 0.35rem; min-width: 0; }\r\n/* Locked while a change is in flight or must wait: the chosen row stays clear, the others step back. */\r\n.cv-choice[data-disabled] { cursor: default; }\r\n.cv-choice[data-disabled]:not([data-checked]) { opacity: 0.55; }\r\n.cv-choice[data-disabled]:hover { background: none; }\r\n.cv-choice-applying { display: inline-flex; flex-shrink: 0; color: var(--cv-muted); }\r\n/* Exempt from reduced-motion like `.cv-empty-spinner`: the rotation is the information. */\r\n.cv-choice-applying-icon { animation: cv-spin 0.8s linear infinite; }\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\r\n/* \u2500\u2500 \"Llamar desde\" (outbound-number-per-agent.md \xA75.5) \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/* \"Llamar desde: Oficina \u25BE\" \u2014 a light line right above the number field, as the providers' dialers label their\r\n caller ID (\"Caller ID: \u2026 \u25BE\", \"Outbound on: \u2026 \u25BE\", \"From: \u2026 \u25BE\"; outbound-number-per-agent.md \xA75.5, 2026-10-26). Not a\r\n strip with a divider: under the host's own header that read as a second title bar. The line is the positioned\r\n ancestor, so its list and callout drop under it. */\r\n.cv-on {\r\n position: relative;\r\n flex-shrink: 0;\r\n display: flex;\r\n padding: 0.9rem 1rem 0.4rem;\r\n}\r\n.cv-on-chip {\r\n display: inline-flex;\r\n align-items: center;\r\n gap: 0.3rem;\r\n max-width: 100%;\r\n min-width: 0;\r\n height: 26px;\r\n margin-left: -0.3rem;\r\n padding: 0 0.3rem;\r\n border: 0;\r\n border-radius: 6px;\r\n background: none;\r\n color: var(--cv-text);\r\n font: 0.8rem var(--cv-font);\r\n text-align: left;\r\n cursor: pointer;\r\n transition: background 0.15s;\r\n}\r\n.cv-on-chip:hover { background: var(--cv-surface-2); }\r\n.cv-on-chip:focus-visible { outline: 2px solid var(--cv-accent); outline-offset: 0; }\r\n.cv-on-label { flex-shrink: 0; color: var(--cv-muted); }\r\n.cv-on-chip .cv-on-current { font-weight: 600; }\r\n.cv-on-icon { color: var(--cv-accent-strong); flex-shrink: 0; }\r\n.cv-on-spin { animation: cv-spin 0.9s linear infinite; }\r\n@media (prefers-reduced-motion: reduce) {\r\n .cv-on-spin { animation: none; }\r\n}\r\n.cv-on-current {\r\n min-width: 0;\r\n overflow: hidden;\r\n text-overflow: ellipsis;\r\n white-space: nowrap;\r\n}\r\n/* Drops under the strip, over the number and the keypad, inset to the strip's edges. Capped so that in the CRM's\r\n 473px panel it never reaches the tab bar: the LIST gives way (it scrolls), never the title or a refusal message. */\r\n.cv-on-pop {\r\n top: calc(100% + 4px);\r\n left: 0.5rem;\r\n right: 0.5rem;\r\n max-width: none;\r\n max-height: 330px;\r\n}\r\n.cv-on-pop .cv-cp-list {\r\n flex: 1 1 auto;\r\n min-height: 0;\r\n max-height: none;\r\n}\r\n.cv-on-title {\r\n flex-shrink: 0;\r\n margin: 0;\r\n padding: 0.5rem 0.75rem 0.4rem;\r\n border-bottom: 1px solid var(--cv-border);\r\n font: 600 0.7rem var(--cv-font);\r\n letter-spacing: 0.06em;\r\n text-transform: uppercase;\r\n color: var(--cv-muted);\r\n}\r\n.cv-on-item { align-items: flex-start; }\r\n/* Locked while a change is applying or the limit says wait: still legible (the choice is visible), not usable. */\r\n.cv-on-item[aria-disabled='true'] { cursor: default; }\r\n.cv-on-item[aria-disabled='true']:not([aria-selected='true']) { opacity: 0.55; }\r\n.cv-on-item[aria-disabled='true']:not([aria-selected='true']):hover { background: none; }\r\n.cv-on-text {\r\n flex: 1;\r\n min-width: 0;\r\n display: flex;\r\n flex-direction: column;\r\n gap: 1px;\r\n}\r\n.cv-on-line {\r\n display: flex;\r\n align-items: center;\r\n gap: 0.35rem;\r\n min-width: 0;\r\n}\r\n.cv-on-sub {\r\n color: var(--cv-muted);\r\n font-size: 0.74rem;\r\n font-variant-numeric: tabular-nums;\r\n}\r\n/* A quiet, neutral pill: it tells the agent what the number is for, it is not a warning. Shared with Ajustes. */\r\n.cv-on-tag,\r\n.cv-choice-tag {\r\n flex-shrink: 0;\r\n padding: 0 0.35rem;\r\n border: 1px solid var(--cv-border-strong);\r\n border-radius: 999px;\r\n color: var(--cv-muted);\r\n font-size: 0.66rem;\r\n font-weight: 600;\r\n line-height: 1.4;\r\n}\r\n/* The usage rule IS information (when not to call from this number): --cv-muted, not --cv-faint, which is under\r\n 4.5:1 at this size in both themes (see `.cv-empty-sub`). */\r\n.cv-on-hint,\r\n.cv-choice-hint {\r\n color: var(--cv-muted);\r\n font-size: 0.7rem;\r\n line-height: 1.3;\r\n}\r\n.cv-on-item .cv-cp-check { margin-top: 2px; }\r\n/* Why a change cannot happen right now, and until when. Calm, not alarming: the phone keeps working. */\r\n.cv-on-notice {\r\n flex-shrink: 0;\r\n margin: 0;\r\n padding: 0.55rem 0.75rem;\r\n border-top: 1px solid var(--cv-border);\r\n background: var(--cv-surface-2);\r\n color: var(--cv-text);\r\n font-size: 0.74rem;\r\n line-height: 1.4;\r\n}\r\n.cv-on-notice[data-kind='error'] { color: var(--cv-danger-strong); }\r\n/* A change that did not go through, after the (optimistic) list already closed: the same words, where the list\r\n was (under the strip). Same place and surface as the list; dismissed by a tap elsewhere, when the wait is over,\r\n or after a few seconds. */\r\n.cv-on-callout {\r\n position: absolute;\r\n top: calc(100% + 4px);\r\n left: 0.5rem;\r\n right: 0.5rem;\r\n margin: 0;\r\n z-index: 20;\r\n padding: 0.6rem 0.75rem;\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 color: var(--cv-text);\r\n font-size: 0.74rem;\r\n line-height: 1.4;\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.cv-on-callout[data-kind='error'] { color: var(--cv-danger-strong); }\r\n@media (prefers-reduced-motion: reduce) {\r\n .cv-on-callout { animation: none; }\r\n}\r\n";
|
|
10654
11322
|
|
|
10655
11323
|
// src/element/index.ts
|
|
10656
11324
|
var EVENTS = {
|
|
@@ -10660,6 +11328,7 @@ var EVENTS = {
|
|
|
10660
11328
|
callEnded: "cv-call-ended",
|
|
10661
11329
|
error: "cv-error",
|
|
10662
11330
|
leaderChanged: "cv-leadership-changed",
|
|
11331
|
+
lineChanged: "cv-line-changed",
|
|
10663
11332
|
transferOutcome: "cv-transfer-outcome"
|
|
10664
11333
|
};
|
|
10665
11334
|
var CoventiaSoftphoneElement = class extends HTMLElement {
|
|
@@ -10779,6 +11448,7 @@ var CoventiaSoftphoneElement = class extends HTMLElement {
|
|
|
10779
11448
|
"leaderChanged" /* LeaderChanged */,
|
|
10780
11449
|
({ isLeader }) => this.#emit(EVENTS.leaderChanged, { isLeader })
|
|
10781
11450
|
),
|
|
11451
|
+
phone.on("lineChanged" /* LineChanged */, ({ line, where }) => this.#emit(EVENTS.lineChanged, { line, where })),
|
|
10782
11452
|
// Payload is already exactly `TransferOutcomeEvent` — no reshaping needed, unlike the events above.
|
|
10783
11453
|
phone.on("transferOutcome" /* TransferOutcome */, (payload) => this.#emit(EVENTS.transferOutcome, payload))
|
|
10784
11454
|
];
|
|
@@ -10827,9 +11497,9 @@ var CoventiaSoftphoneElement = class extends HTMLElement {
|
|
|
10827
11497
|
...this.#config.incomingCallAction ? { incomingCallAction: this.#config.incomingCallAction } : {}
|
|
10828
11498
|
};
|
|
10829
11499
|
this.#root.render(
|
|
10830
|
-
(0,
|
|
11500
|
+
(0, import_react47.createElement)(
|
|
10831
11501
|
SoftphoneProvider,
|
|
10832
|
-
{ softphone: this.#softphone, children: (0,
|
|
11502
|
+
{ softphone: this.#softphone, children: (0, import_react47.createElement)(SoftphoneWidget, widgetProps) }
|
|
10833
11503
|
)
|
|
10834
11504
|
);
|
|
10835
11505
|
}
|
|
@@ -10900,6 +11570,7 @@ lucide-react/dist/esm/icons/mic.js:
|
|
|
10900
11570
|
lucide-react/dist/esm/icons/monitor-smartphone.js:
|
|
10901
11571
|
lucide-react/dist/esm/icons/music.js:
|
|
10902
11572
|
lucide-react/dist/esm/icons/pause.js:
|
|
11573
|
+
lucide-react/dist/esm/icons/phone-forwarded.js:
|
|
10903
11574
|
lucide-react/dist/esm/icons/phone-incoming.js:
|
|
10904
11575
|
lucide-react/dist/esm/icons/phone-missed.js:
|
|
10905
11576
|
lucide-react/dist/esm/icons/phone-off.js:
|