diff --git a/docs/index.html b/docs/index.html index 5867e9d4122..fae736041af 100644 --- a/docs/index.html +++ b/docs/index.html @@ -85,6 +85,7 @@

Contact Center Labs + Calling Labs
diff --git a/docs/labs/calling/README.md b/docs/labs/calling/README.md new file mode 100644 index 00000000000..bfc6e4cc4f5 --- /dev/null +++ b/docs/labs/calling/README.md @@ -0,0 +1,89 @@ +# Calling SDK Lab + +This lab mirrors the Contact Center lab structure and demonstrates Webex Calling WebRTC SDK basics: + +- Initialize Calling with a Personal Access Token +- Register and obtain a `line` +- Acquire microphone and wire media +- Make and answer calls +- Control calls (mute/hold/end) +- Send DTMF digits using a dial pad + +## Files + +``` +docs/labs/calling/ +├── index.html # Lab UI +├── index.js # Orchestration +├── styles.css # Styles +└── modules/ + ├── auth.js # Initialization + ├── registration.js # Register/deregister/line helpers + └── call-controls.js# Call control helpers (placeholder) +``` + +## Notes + +- DTMF: Use `call.sendDigit('123#')` or per-key press from the dial pad. This calls through to the SDK which inserts DTMF on the media connection. +- The lab expects the calling UMD from `docs/samples/calling.min.js` already built by the repo. + +## DTMF Dialer and IVR Use Case + +Interactive Voice Response (IVR) systems commonly prompt callers to navigate menus (e.g., "Press 1 for Sales") or enter information (e.g., account numbers). During an established call, you can send DTMF tones either one-by-one or as a sequence: + +```javascript +// Single selection (e.g., Press 1 for Sales) +call.sendDigit('1'); + +// Enter an account number followed by # +const accountNumber = '123456'; +call.sendDigit(accountNumber + '#'); + +// If using the lab's dialpad: per-key presses call sendDigit(key) +// automatically; the Send button submits the current input field. +``` + +Tips: +- Only call `sendDigit` once the call is established and you can hear the IVR prompts. +- The dialpad in this lab sends each key immediately, but also lets you batch send when needed. + +## OAuth (Implicit) Flow — Calling + +For production, use OAuth instead of a PAT. Create an Integration at the Webex Developer Portal and configure: + +- Client ID (public) +- Redirect URI (e.g., `http://localhost:/` while testing) +- Scopes (minimum for Calling): + - `spark:webrtc_calling` + - `spark:calls_read` + - `spark:calls_write` + - `spark:kms` + - `spark:xsi` + +Example (also shown in `index.html`): + +```javascript +const webex = Webex.init({ + config: { + credentials: { + client_id: 'YOUR_PUBLIC_CLIENT_ID', + redirect_uri: window.location.origin + window.location.pathname, + scope: [ + 'spark:webrtc_calling', + 'spark:calls_read', + 'spark:calls_write', + 'spark:kms', + 'spark:xsi' + ].join(' ') + } + } +}); + +await webex.authorization.initiateLogin(); + +// After redirect back, the access_token is in the URL hash. The lab auto-detects +// it and initializes Calling using that token. +``` + + + diff --git a/docs/labs/calling/index.html b/docs/labs/calling/index.html new file mode 100644 index 00000000000..7bebe6f2f7f --- /dev/null +++ b/docs/labs/calling/index.html @@ -0,0 +1,276 @@ + + + + + Calling SDK Lab + + + + + + + + + + + + +

Webex Calling SDK Lab

+ +
+

+ This lab demonstrates how to build a basic Calling experience using the + Webex Calling WebRTC SDK. You'll authenticate, register, acquire media, + place/answer calls, and send DTMF digits using a dial pad. +

+
+ + +
+

Step 1: Authentication

+

+ Initialize the Calling SDK with a Personal Access Token (PAT). The + initialized Calling instance is used for all subsequent + operations. +

+
+ Credentials + +
+ + +
+
+ +
+
Not initialized
+
+
// Initialize SDK with a PAT
+const calling = await Calling.init({
+  webexConfig: {
+    credentials: { access_token: 'YOUR_TOKEN' },
+    config: { fedramp: false }
+  },
+  callingConfig: {
+    logger: { level: 'info' }
+  }
+});
+
+calling.on('ready', () => console.log('Calling ready'));
+
+ +
+ +

Using OAuth (for production):

+

+ To use OAuth, first create an Integration in the + Webex Developer Portal. + You will receive a Client ID and configure one or more + Redirect URIs. For local testing, set your redirect to + http://localhost:<PORT>/ (matching the port you serve this page on). +

+

+ This lab uses OAuth (implicit) to obtain an access token that is then + used to initialize the Calling SDK. The required scopes for Calling are: + spark:webrtc_calling spark:calls_read spark:calls_write spark:kms spark:xsi. +

+
// OAuth (Implicit) flow (Calling scopes)
+const webex = Webex.init({
+  config: {
+    credentials: {
+      client_id: 'YOUR_PUBLIC_CLIENT_ID', // From your Integration
+      redirect_uri: window.location.origin + window.location.pathname, // Must match Integration
+      scope: [
+        'spark:webrtc_calling',
+        'spark:calls_read',
+        'spark:calls_write',
+        'spark:kms',
+        'spark:xsi'
+      ].join(' ')
+    }
+  }
+});
+
+// 1) Initiate OAuth; user is redirected to Webex for login/consent
+await webex.authorization.initiateLogin();
+
+// 2) After redirect back to this page, Webex places the access_token in the URL hash
+//    This lab automatically detects it and initializes Calling with that token.
+//    See: auto-init logic in index.js (autoInitFromHash()).
+
+ + +
+

Step 2: Registration

+

+ Register and create a line to receive incoming calls and + place outgoing calls. +

+
+ + +
+
Not registered
+
// Register and get first line
+await calling.register();
+const callingClient = calling.callingClient;
+const line = Object.values(callingClient.getLines())[0];
+// Next step: explicitly register the line (see Step 3)
+
+ + +
+

Step 3: Line Registration

+

+ Register the line to enable placing and receiving calls. This + must be done after Calling registration. +

+
+ +
+
Line not registered
+
// Register the primary line
+line.register();
+line.on('registered', (deviceInfo) => {
+  console.log('Line registered', deviceInfo);
+});
+
+ + +
+

Step 4: Media

+

Get microphone access and prepare local media for calls.

+ +
+
+ Local Audio + +
+
+ Remote Audio + +
+
+
// Acquire microphone stream
+const mic = await Calling.createMicrophoneStream({ audio: true });
+document.getElementById('local-audio').srcObject = mic.outputStream;
+
+ + +
+

Step 5: Calling

+
+ Outgoing Call +
+ + + +
+
+ +
+ Incoming Call +
No incoming calls
+
+ + +
+
+ +
+ Call Controls +
+ + +
+
// Mute / Unmute
+// 'call' is the active call, 'localAudioStream' is the microphone stream
+call.mute(localAudioStream, 'user_mute');
+
+// Hold / Resume (toggles)
+call.doHoldResume();
+
+// Optional: listen for state
+call.on('established', (id) => console.log('Established', id));
+call.on('disconnect', () => console.log('Disconnected'));
+
+ +
+ Transfer +
+ + + + +
+

Consult transfer flow: place the active call on hold, call the target, then click Commit to complete transfer.

+
Transfer idle
+
+ +
+ DTMF Dialer +
+ +
+ + + + + + + + + + + + +
+
+ + +
+
+

+ Use DTMF when interacting with IVR systems (e.g., “Press 1 for Sales, + then enter your account number followed by #”). You can send each key + as you press it, or submit a sequence when prompted. +

+
// Send DTMF during an active call (typical IVR)
+// 1) User hears the IVR prompt: "Press 1 for Sales" →
+call.sendDigit('1');
+
+// 2) IVR asks: "Enter your account number followed by #" →
+const account = '123456';
+call.sendDigit(account + '#');
+
+// Or use the dialpad: per-key presses call sendDigit(key) automatically,
+// and the Send button submits the full input field.
+call.sendDigit('123#'); // or per-key press
+
+
Call status
+
+ + + + + + + diff --git a/docs/labs/calling/index.js b/docs/labs/calling/index.js new file mode 100644 index 00000000000..45ce144d56e --- /dev/null +++ b/docs/labs/calling/index.js @@ -0,0 +1,356 @@ +import { initCalling, initOauth } from './modules/auth.js'; +import { register, deregister, getPrimaryLine } from './modules/registration.js'; +import { setupCallControls } from './modules/call-controls.js'; + +let calling; // Calling root +let callingClient; // CallingClient +let line; // Primary line +let localAudioStream; // Calling MicrophoneStream +let activeCall; // Current Call +let consultCall; // Second call during consult transfer + +const els = { + token: document.getElementById('access-token'), + fedramp: document.getElementById('fedramp'), + intEnv: document.getElementById('integration-env'), + init: document.getElementById('btn-init'), + oauth: document.getElementById('btn-oauth'), + authStatus: document.getElementById('auth-status'), + btnRegister: document.getElementById('btn-register'), + btnDeregister: document.getElementById('btn-deregister'), + regStatus: document.getElementById('registration-status'), + btnMedia: document.getElementById('btn-media'), + btnLineRegister: document.getElementById('btn-line-register'), + localAudio: document.getElementById('local-audio'), + remoteAudio: document.getElementById('remote-audio'), + dest: document.getElementById('destination'), + call: document.getElementById('btn-call'), + end: document.getElementById('btn-end'), + answer: document.getElementById('btn-answer'), + endIncoming: document.getElementById('btn-end-incoming'), + incomingInfo: document.getElementById('incoming-info'), + mute: document.getElementById('btn-mute'), + hold: document.getElementById('btn-hold'), + dtmfDisplay: document.getElementById('dtmf-display'), + sendDigits: document.getElementById('btn-send-digits'), + clearDigits: document.getElementById('btn-clear-digits'), + callStatus: document.getElementById('call-status'), + transferTarget: document.getElementById('transfer-target'), + transferType: document.getElementById('transfer-type'), + transferBtn: document.getElementById('btn-transfer'), + endSecondBtn: document.getElementById('btn-end-second'), + transferStatus: document.getElementById('transfer-status') +}; + +function setAuthUI(enabled) { + els.btnRegister.disabled = !enabled; + els.authStatus.textContent = enabled ? 'Initialized' : 'Not initialized'; +} + +function setRegistrationUI(registered) { + els.btnRegister.disabled = registered; + els.btnDeregister.disabled = !registered; + els.regStatus.textContent = registered ? 'Registered' : 'Not registered'; + els.btnLineRegister.disabled = !registered; + els.btnMedia.disabled = true; + els.dest.disabled = !registered; +} + +function setCallUI(inCall) { + els.call.disabled = inCall; + els.end.disabled = !inCall; + els.mute.disabled = !inCall; + els.hold.disabled = !inCall; + els.sendDigits.disabled = !inCall; + els.transferBtn.disabled = !inCall; + els.transferTarget.disabled = !inCall; + els.transferType.disabled = !inCall; +} + +// Dialpad wiring moved to call-controls.js + +async function handleInit() { + els.init.disabled = true; + els.authStatus.textContent = 'Initializing...'; + const token = els.token.value.trim(); + try { + const { callingInstance, client } = await initCalling({ + token, + fedramp: !!els.fedramp.checked, + useIntegration: !!els.intEnv.checked + }); + calling = callingInstance; + callingClient = client; + setAuthUI(true); + } catch (e) { + console.error(e); + els.authStatus.textContent = 'Init failed'; + } finally { + els.init.disabled = false; + } +} + +async function handleOAuth() { + els.oauth.disabled = true; + try { + await initOauth({ + // Client ID placeholder; replace with your Integration's client ID + clientId: 'YOUR_PUBLIC_CLIENT_ID' + }); + } catch (e) { + console.error('OAuth start failed', e); + } finally { + els.oauth.disabled = false; + } +} + +async function handleRegister() { + try { + await register(calling); + // Ensure CallingClient is ready; wait for `ready` if needed + if (!calling?.callingClient) { + await new Promise((resolve) => calling.on('ready', resolve)); + } + callingClient = calling.callingClient; + line = getPrimaryLine(callingClient); + wireLineEvents(line); + setRegistrationUI(true); + } catch (e) { + console.error('Registration failed', e); + } +} + +function handleLineRegister() { + if (!line) return; + try { + line.register(); + els.callStatus.textContent = 'Registering line...'; + } catch (e) { + console.warn('Line register failed', e); + } +} + +async function handleDeregister() { + try { + await deregister(calling); + setRegistrationUI(false); + setCallUI(false); + activeCall = undefined; + } catch (e) { + console.error('Deregister failed', e); + } +} + +async function handleGetMedia() { + try { + localAudioStream = await Calling.createMicrophoneStream({ audio: true }); + els.localAudio.srcObject = localAudioStream.outputStream; + els.call.disabled = false; + } catch (e) { + console.error('Mic error', e); + } +} + +function wireActiveCall(call) { + activeCall = call; + setCallUI(true); + + call.on('remote_media', (track) => { + els.remoteAudio.srcObject = new MediaStream([track]); + }); + call.on('progress', (id) => els.callStatus.textContent = `${id}: Progress`); + call.on('connect', (id) => els.callStatus.textContent = `${id}: Connect`); + call.on('established', (id) => els.callStatus.textContent = `${id}: Established`); + call.on('disconnect', () => { + els.callStatus.textContent = 'Call Disconnected'; + setCallUI(false); + activeCall = undefined; + resetTransferUI(); + }); +} + +function wireLineEvents(theLine) { + if (!theLine) return; + theLine.on('registered', () => { + els.regStatus.textContent = 'Registered (Calling)'; + const lineStatus = document.getElementById('line-status'); + if (lineStatus) lineStatus.textContent = 'Line registered'; + els.btnMedia.disabled = false; + }); + theLine.on('line:incoming_call', (incomingCall) => { + activeCall = incomingCall; + els.incomingInfo.textContent = 'Incoming call...'; + els.answer.disabled = false; + els.endIncoming.disabled = false; + + incomingCall.on('disconnect', () => { + els.incomingInfo.textContent = 'Call ended'; + els.answer.disabled = true; + els.endIncoming.disabled = true; + setCallUI(false); + activeCall = undefined; + }); + + incomingCall.on('remote_media', (track) => { + els.remoteAudio.srcObject = new MediaStream([track]); + }); + }); +} + +async function handlePlaceCall() { + const dest = els.dest.value.trim(); + if (!line) { + els.callStatus.textContent = 'Line not ready. Please register first.'; + return; + } + if (!dest) { + els.callStatus.textContent = 'Please enter a destination address/number.'; + return; + } + if (!localAudioStream) { + els.callStatus.textContent = 'Getting microphone...'; + await handleGetMedia(); + if (!localAudioStream) { + els.callStatus.textContent = 'Microphone unavailable.'; + return; + } + } + + try { + const call = line.makeCall({ type: 'uri', address: dest }); + wireActiveCall(call); + els.callStatus.textContent = 'Dialing...'; + call.dial(localAudioStream); + } catch (e) { + console.error('Call failed', e); + els.callStatus.textContent = `Call failed: ${e?.message || 'Unknown error'}`; + } +} + +function resetTransferUI() { + els.transferStatus.textContent = 'Transfer idle'; + els.endSecondBtn.disabled = true; + consultCall = undefined; +} + +function wireConsultCall(c) { + consultCall = c; + els.endSecondBtn.disabled = false; + c.on('remote_media', (track) => { + els.remoteAudio.srcObject = new MediaStream([track]); + }); + c.on('established', (id) => { + els.transferStatus.textContent = `${id}: Transfer target connected`; + els.transferBtn.textContent = 'Commit'; + els.transferBtn.disabled = false; + }); + c.on('disconnect', () => { + els.endSecondBtn.disabled = true; + consultCall = undefined; + els.transferBtn.textContent = 'Transfer'; + }); +} + +function handleTransfer() { + if (!activeCall || !line) return; + const target = els.transferTarget.value.trim(); + const type = els.transferType.value; + if (!target) { + els.transferStatus.textContent = 'Enter a transfer target'; + return; + } + + // If we are in commit state for consult transfer + if (type === 'CONSULT' && consultCall) { + try { + activeCall.completeTransfer('CONSULT', consultCall.getCallId(), undefined); + els.transferStatus.textContent = 'Consult transfer completed'; + els.transferBtn.textContent = 'Transfer'; + return; + } catch (e) { + els.transferStatus.textContent = `Commit failed: ${e?.message || ''}`; + return; + } + } + + if (type === 'BLIND') { + try { + activeCall.completeTransfer('BLIND', undefined, target); + els.transferStatus.textContent = 'Blind transfer initiated'; + els.transferBtn.disabled = true; + } catch (e) { + els.transferStatus.textContent = `Blind transfer failed: ${e?.message || ''}`; + } + } else { + // Consult transfer: hold current call and call target + try { + activeCall.doHoldResume(); + els.transferStatus.textContent = `Holding current call and dialing ${target}`; + els.transferBtn.disabled = true; + const second = line.makeCall({ type: 'uri', address: target }); + wireConsultCall(second); + second.dial(localAudioStream); + } catch (e) { + els.transferStatus.textContent = `Consult transfer failed: ${e?.message || ''}`; + } + } +} + +function handleEndSecond() { + if (consultCall) { + try { consultCall.end(); } catch {} + consultCall = undefined; + els.endSecondBtn.disabled = true; + els.transferBtn.textContent = 'Transfer'; + } +} + +function handleEndCall() { + if (activeCall) activeCall.end(); +} + +function handleAnswer() { + if (!activeCall || !localAudioStream) return; + wireActiveCall(activeCall); + activeCall.answer(localAudioStream); + els.answer.disabled = true; +} + +function autoInitFromHash() { + const hash = window.location.hash?.substring(1) || ''; + const params = new URLSearchParams(hash); + const token = params.get('access_token'); + if (token) { + els.token.value = token; + handleInit(); + } +} + +function bindUI() { + console.log('bindUI'); + els.init.addEventListener('click', handleInit); + els.oauth.addEventListener('click', handleOAuth); + els.btnRegister.addEventListener('click', handleRegister); + els.btnLineRegister.addEventListener('click', handleLineRegister); + els.btnDeregister.addEventListener('click', handleDeregister); + els.btnMedia.addEventListener('click', handleGetMedia); + els.call.addEventListener('click', handlePlaceCall); + els.end.addEventListener('click', handleEndCall); + els.answer.addEventListener('click', handleAnswer); + els.endIncoming.addEventListener('click', handleEndCall); + // Controls are wired by setupCallControls + els.transferBtn.addEventListener('click', handleTransfer); + els.endSecondBtn.addEventListener('click', handleEndSecond); +} + +// Initialize +setupCallControls({ + els, + getActiveCall: () => activeCall, + getLocalStream: () => localAudioStream, +}); +bindUI(); +autoInitFromHash(); + + + diff --git a/docs/labs/calling/modules/auth.js b/docs/labs/calling/modules/auth.js new file mode 100644 index 00000000000..caf1c3d7c4f --- /dev/null +++ b/docs/labs/calling/modules/auth.js @@ -0,0 +1,75 @@ +export async function initCalling({ token, fedramp = false, useIntegration = false }) { + if (!token) throw new Error('Access token is required'); + + const webexConfig = { + fedramp, + config: { fedramp }, + credentials: { access_token: token } + }; + + if (useIntegration) { + webexConfig.config.services = { + discovery: { + u2c: 'https://u2c-intb.ciscospark.com/u2c/api/v1', + hydra: 'https://hydra-intb.ciscospark.com/v1/' + } + }; + } + + const callingConfig = { + clientConfig: { + calling: !fedramp, + callHistory: true, + voicemail: true, + callSettings: !fedramp, + contact: !fedramp + }, + callingClientConfig: { + discovery: { region: '', country: '' }, + serviceData: { indicator: 'calling', domain: '' } + }, + logger: { level: 'info' } + }; + + const callingInstance = await Calling.init({ webexConfig, callingConfig }); + + await new Promise((resolve) => { + callingInstance.on('ready', resolve); + }); + + return { callingInstance, client: callingInstance.callingClient }; +} + +// OAuth scopes aligned to Calling/BNR +const callingScopes = [ + 'spark:webrtc_calling', + 'spark:calls_read', + 'spark:calls_write', + 'spark:kms', + 'spark:xsi' +].join(' '); + +/** + * Start OAuth flow using Webex UMD + * @param {Object} cfg + * @param {string} cfg.clientId - Public client id from Webex Developer Portal + * @param {string} [cfg.redirectUri] - Optional redirect override + * @param {string} [cfg.scope] - Optional scopes override + */ +export async function initOauth({ clientId, redirectUri, scope } = {}) { + const webex = Webex.init({ + config: { + credentials: { + client_id: clientId || 'YOUR_PUBLIC_CLIENT_ID', + redirect_uri: redirectUri || (window.location.origin + window.location.pathname), + scope: scope || callingScopes + } + } + }); + + await webex.authorization.initiateLogin({ state: {} }); + return webex; +} + + + diff --git a/docs/labs/calling/modules/call-controls.js b/docs/labs/calling/modules/call-controls.js new file mode 100644 index 00000000000..c96ba149325 --- /dev/null +++ b/docs/labs/calling/modules/call-controls.js @@ -0,0 +1,66 @@ +/** + * Wires up call control UI: mute, hold, and the DTMF dialer + * + * @param {Object} cfg + * @param {Object} cfg.els - Elements map used in the lab + * @param {() => any} cfg.getActiveCall - Returns the active call object + * @param {() => any} cfg.getLocalStream - Returns the local microphone stream + */ +export function setupCallControls({ els, getActiveCall, getLocalStream }) { + if (!els) return; + + // Mute/Unmute + if (els.mute) { + els.mute.addEventListener('click', () => { + const call = getActiveCall?.(); + const stream = getLocalStream?.(); + if (!call || !stream) return; + call.mute(stream, 'user_mute'); + }); + } + + // Hold/Resume + if (els.hold) { + els.hold.addEventListener('click', () => { + const call = getActiveCall?.(); + if (!call) return; + call.doHoldResume(); + }); + } + + // Dialpad per-key sending + document.querySelectorAll('.dialpad button[data-tone]') + .forEach((btn) => { + btn.addEventListener('click', () => { + const tone = btn.getAttribute('data-tone'); + if (els.dtmfDisplay) { + els.dtmfDisplay.value += tone; + } + const call = getActiveCall?.(); + if (call) { + try { call.sendDigit(tone); } catch (e) { /* ignore */ } + } + }); + }); + + // Send all digits in input + if (els.sendDigits) { + els.sendDigits.addEventListener('click', () => { + const call = getActiveCall?.(); + const digits = els.dtmfDisplay?.value?.trim(); + if (call && digits) { + call.sendDigit(digits); + } + }); + } + + // Clear display + if (els.clearDigits) { + els.clearDigits.addEventListener('click', () => { + if (els.dtmfDisplay) els.dtmfDisplay.value = ''; + }); + } +} + + + diff --git a/docs/labs/calling/modules/registration.js b/docs/labs/calling/modules/registration.js new file mode 100644 index 00000000000..878da8752f3 --- /dev/null +++ b/docs/labs/calling/modules/registration.js @@ -0,0 +1,21 @@ +export async function register(calling) { + if (!calling) throw new Error('Calling not initialized'); + await calling.register(); +} + +export async function deregister(calling) { + if (!calling) return; + await calling.deregister(); +} + +export function getPrimaryLine(callingClient) { + if (!callingClient) return undefined; + console.log('callingClient', callingClient); + const lines = callingClient.getLines(); + const first = Object.values(lines)[0]; + console.log('first', first); + return first; +} + + + diff --git a/docs/labs/calling/styles.css b/docs/labs/calling/styles.css new file mode 100644 index 00000000000..6a3ceabb7fc --- /dev/null +++ b/docs/labs/calling/styles.css @@ -0,0 +1,45 @@ +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif; + line-height: 1.6; + max-width: 1200px; + margin: 0 auto; + padding: 20px; + color: #333; +} + +h1 { + color: #0052bf; + border-bottom: 2px solid #0052bf; + padding-bottom: 0.5rem; +} + +section { + background: #fff; + margin: 20px 0; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + padding: 16px; +} + +fieldset { border: 1px solid #ddd; border-radius: 6px; margin: 12px 0; padding: 12px; } +legend { color: #1a73e8; font-weight: 600; } + +.s-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } +button { background:#00a6d6; color:#fff; border:none; border-radius:4px; padding:8px 12px; cursor:pointer; } +button:disabled { background:#ccc; cursor:not-allowed; } +input[type="text"] { padding:8px; border:1px solid #ddd; border-radius:4px; min-width:260px; } + +.media-grid { display:grid; grid-template-columns: repeat(auto-fit, minmax(240px,1fr)); gap: 12px; } + +.dialpad { display:grid; grid-template-columns: repeat(3, 56px); gap:8px; margin-top:10px; } +.dialpad button { width:56px; height:40px; font-weight:600; } +.dtmf { max-width: 360px; } + +pre { background:#f8f9fa; padding:12px; border-radius:6px; overflow:auto; } + +.call-controls { display:flex; gap:8px; } + +.switch { display:flex; align-items:center; gap:6px; } + + +