@goodandready/dsh-voice 0.8.19 → 0.8.20

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/lib/client.js CHANGED
@@ -163,6 +163,20 @@ window.__ModuleLoader__.load({
163
163
  'visDynamicOrb': 'Dynamic Orb',
164
164
  'visBars': 'Classic Bars',
165
165
  'visOff': 'Off',
166
+ 'statusTitle': 'Connection & Engine Status',
167
+ 'statusDesc': 'Current runtime connectivity to voice backends and recognition services.',
168
+ 'badgeHostOnline': 'Host online ({ms} ms)',
169
+ 'badgeHostOffline': 'Host unreachable',
170
+ 'badgeWhisperActive': 'Whisper active',
171
+ 'badgeWhisperInactive': 'Whisper offline',
172
+ 'badgeSenseVoiceActive': 'SenseVoice active',
173
+ 'badgeSenseVoiceInactive': 'SenseVoice offline',
174
+ 'badgeProviders': '{count} providers ready',
175
+ 'refreshStats': 'Refresh Stats',
176
+ 'hardwareTitle': 'Hardware & Local Engines',
177
+ 'hardwareDesc': 'Microphone device, local whisper.cpp and SenseVoice runtime settings.',
178
+ 'uiError': 'Voice UI Error:',
179
+ 'retry': 'Retry',
166
180
  }
167
181
 
168
182
  // Strings are also needed outside components — in recording handlers and
@@ -1345,19 +1359,40 @@ window.__ModuleLoader__.load({
1345
1359
  const LANGS = ['auto', 'ru', 'en', 'uk', 'de']
1346
1360
 
1347
1361
  const SET_CSS =
1348
- '.dvs-wrap{display:flex;flex-direction:column;gap:22px;padding:4px 0;max-width:720px}' +
1362
+ '.cb-page{display:flex;flex-direction:column;gap:18px;padding:4px 0 24px;max-width:960px}' +
1363
+ '.cb-section-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;padding:16px 18px;display:flex;flex-direction:column;gap:12px}' +
1364
+ '.cb-section-title{font-size:15px;font-weight:600;color:var(--dsw-alias-label-primary);display:flex;align-items:center;justify-content:space-between}' +
1365
+ '.cb-section-desc{font-size:13px;color:var(--dsw-alias-label-secondary);margin-top:-4px;line-height:1.4}' +
1366
+ '.cb-row{display:flex;flex-wrap:wrap;gap:10px;align-items:center}' +
1367
+ '.cb-grid-2{display:grid;grid-template-columns:repeat(auto-fit, minmax(260px, 1fr));gap:12px}' +
1368
+ '.cb-badge{font-size:11px;padding:2px 8px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);display:inline-flex;align-items:center;gap:4px;font-weight:500}' +
1369
+ '.cb-badge-ok{border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary);background:rgba(16,185,129,0.08)}' +
1370
+ '.cb-badge-warn{border-color:var(--dsw-alias-state-warning-primary);color:var(--dsw-alias-state-warning-primary);background:rgba(245,158,11,0.08)}' +
1371
+ '.cb-badge-bad{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);background:rgba(239,68,68,0.08)}' +
1372
+ '.cb-input{height:34px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 10px;font-size:13px;box-sizing:border-box}' +
1373
+ '.cb-input:focus{outline:none;border-color:var(--dsw-alias-state-brand-primary)}' +
1374
+ '.cb-btn{appearance:none;font:inherit;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:6px 12px;font-size:13px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-weight:500;display:inline-flex;align-items:center;justify-content:center;gap:6px;transition:all .15s ease}' +
1375
+ '.cb-btn:hover:not(:disabled){background:var(--dsw-alias-bg-layer-4, var(--dsw-alias-bg-layer-2));border-color:var(--dsw-alias-label-dimmed, var(--dsw-alias-border-l2))}' +
1376
+ '.cb-btn-primary{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);border-color:transparent}' +
1377
+ '.cb-btn-primary:hover:not(:disabled){background:var(--dsw-alias-label-primary) !important;color:var(--dsw-alias-bg-layer-3) !important;opacity:0.88}' +
1378
+ '.cb-btn-danger{color:var(--dsw-alias-state-error-primary);border-color:rgba(239,68,68,0.3)}' +
1379
+ '.cb-btn-mini{border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);border-radius:6px;width:28px;height:28px;cursor:pointer;flex:none;display:inline-flex;align-items:center;justify-content:center;padding:0;font-size:13px}' +
1380
+ '.cb-btn-mini:hover:not(:disabled){background:var(--dsw-alias-bg-layer-2)}' +
1381
+ '.cb-field{display:flex;flex-direction:column;gap:6px;padding:6px 0;font-size:13px;color:var(--dsw-alias-label-primary)}' +
1382
+ '.cb-field select,.cb-field input[type="text"],.cb-field input[type="number"],.cb-field textarea{height:34px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 10px;font-size:13px;box-sizing:border-box}' +
1383
+ '.cb-field textarea{height:auto;padding:8px 10px;font-family:inherit}' +
1384
+ '.cb-check-label{display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:var(--dsw-alias-label-primary);user-select:none}' +
1385
+ '.cb-check-label input[type="checkbox"]{width:16px;height:16px;cursor:pointer}' +
1386
+ '.cb-alert-err{padding:10px 14px;border-radius:8px;background:rgba(239,68,68,0.1);color:var(--dsw-alias-state-error-primary);font-size:13px}' +
1387
+ '.dvs-wrap{display:flex;flex-direction:column;gap:18px;padding:4px 0;max-width:960px}' +
1349
1388
  '.dvs-block{display:flex;flex-direction:column;gap:10px}' +
1350
1389
  '.dvs-h{font-size:14px;font-weight:600;color:var(--dsw-alias-label-primary)}' +
1351
1390
  '.dvs-sub{font-size:12px;color:var(--dsw-alias-label-secondary)}' +
1352
1391
  '.dvs-row{display:flex;gap:8px;align-items:center}' +
1353
- '.dvs-row select,.dvs-row input{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-primary);border-radius:6px;padding:6px 8px;font-size:13px}' +
1392
+ '.dvs-row select,.dvs-row input{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary);border-radius:8px;padding:6px 10px;font-size:13px}' +
1354
1393
  '.dvs-row .dvs-model{flex:1}' +
1355
- '.dvs-field{display:flex;flex-direction:column;gap:6px;padding:12px 0;font-size:12px;color:var(--dsw-alias-label-secondary)}' +
1356
- '.dvs-field input,.dvs-field select{height:34px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px}' +
1357
- '.dvs-mini{border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);border-radius:6px;width:28px;height:28px;cursor:pointer;flex:none}' +
1358
- '.dvs-save{appearance:none;font:inherit;cursor:pointer;border:1px solid transparent;border-radius:8px;padding:5px 14px;font-size:13px;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}' +
1359
- '.dvs-card{display:flex;flex-direction:column;gap:6px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:10px}' +
1360
- '.dvs-card input,.dvs-card select{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-primary);border-radius:6px;padding:6px 8px;font-size:13px}' +
1394
+ '.dvs-card{display:flex;flex-direction:column;gap:8px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);border-radius:10px;padding:12px}' +
1395
+ '.dvs-card input,.dvs-card select{background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-primary);border-radius:6px;padding:6px 8px;font-size:13px}' +
1361
1396
  '.dvs-wait{font-size:13px;color:var(--dsw-alias-label-secondary);line-height:1.5;max-width:520px}' +
1362
1397
  '.dvs-ok{font-size:12px;color:var(--dsw-alias-state-success-primary)}' +
1363
1398
  '.dvs-bad{font-size:12px;color:var(--dsw-alias-state-error-primary)}' +
@@ -1368,7 +1403,7 @@ window.__ModuleLoader__.load({
1368
1403
  '.dvo-pdesc{color:var(--dsw-alias-label-secondary);font-size:13px}' +
1369
1404
  '.dvo-pchev{flex:none;display:flex;color:var(--dsw-alias-label-secondary);transition:transform .15s ease}' +
1370
1405
  '.dvo-pcardOpen .dvo-pchev{transform:rotate(180deg)}' +
1371
- '.dvo-pbody{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}'
1406
+ '.dvo-pbody{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:12px}'
1372
1407
  const setCssId = 'dsh-voice/settings.module.css'
1373
1408
  if (typeof document !== 'undefined' && !document.querySelector('style[data-dsh-plugin="dsh-voice"][data-plugin-css="' + setCssId + '"]')) {
1374
1409
  const tag = document.createElement('style')
@@ -1467,6 +1502,46 @@ window.__ModuleLoader__.load({
1467
1502
  )
1468
1503
  }
1469
1504
 
1505
+ function createErrorBoundary() {
1506
+ if (!React || typeof React.Component !== 'function') {
1507
+ return function NoopBoundary(props) { return props?.children || null }
1508
+ }
1509
+ return class ErrorBoundary extends React.Component {
1510
+ constructor(props) {
1511
+ super(props)
1512
+ this.state = { hasError: false, error: null }
1513
+ }
1514
+ static getDerivedStateFromError(error) {
1515
+ return { hasError: true, error }
1516
+ }
1517
+ componentDidCatch(error, errorInfo) {
1518
+ console.error('[dsh-voice] React Error:', error, errorInfo)
1519
+ }
1520
+ render() {
1521
+ if (this.state.hasError) {
1522
+ return React.createElement(
1523
+ 'div',
1524
+ { className: 'cb-alert-err', style: { margin: '12px 0', padding: '14px', borderRadius: '8px' } },
1525
+ React.createElement('div', { style: { fontWeight: 600, marginBottom: '6px' } }, '⚠️ ' + t('uiError')),
1526
+ React.createElement('div', { style: { fontSize: '12px', wordBreak: 'break-all' } }, String(this.state.error?.message || this.state.error)),
1527
+ React.createElement(
1528
+ 'button',
1529
+ {
1530
+ type: 'button',
1531
+ className: 'cb-btn',
1532
+ style: { marginTop: '10px', fontSize: '12px', padding: '4px 10px' },
1533
+ onClick: () => this.setState({ hasError: false, error: null }),
1534
+ },
1535
+ t('retry'),
1536
+ ),
1537
+ )
1538
+ }
1539
+ return this.props?.children || null
1540
+ }
1541
+ }
1542
+ }
1543
+ const ErrorBoundary = createErrorBoundary()
1544
+
1470
1545
  function VoiceSection(props) {
1471
1546
  const t = (props && props.t) || moduleT
1472
1547
  const ctx = props.ctx
@@ -1481,12 +1556,7 @@ window.__ModuleLoader__.load({
1481
1556
  const [saved, setSaved] = React.useState(false)
1482
1557
  const [err, setErr] = React.useState('')
1483
1558
 
1484
- // Hotkey picker: not a name field — press the key you want. Users should
1485
- // not need to know codes like KeyR.
1486
- //
1487
- // Hooks are declared here, above every return: declaring them later
1488
- // would leave fewer hooks on the not-ready branch and React would
1489
- // unmount the section with an error.
1559
+ // Hotkey picker: not a name field — press the key you want.
1490
1560
  const [catching, setCatching] = React.useState(false)
1491
1561
  React.useEffect(() => {
1492
1562
  if (!catching) return undefined
@@ -1518,22 +1588,10 @@ window.__ModuleLoader__.load({
1518
1588
  .catch(() => {})
1519
1589
  }, [])
1520
1590
 
1521
- // The snapshot carries a status, and status matters more than value.
1522
- // loading — no host answer yet;
1523
- // unavailable — host answered but does not know this namespace yet
1524
- // (page opened in the first seconds while plugins register);
1525
- // ready — values are present.
1526
- // At unavailable, writable comes from the document and stays true, so
1527
- // without a status check the card draws an empty but seemingly working
1528
- // form — that is exactly how this bug looked.
1529
1591
  const ready = !!snap && snap.status === 'ready'
1530
1592
  const value = ready && snap.value ? snap.value : {}
1531
1593
  const writable = ready && snap.writable !== false
1532
1594
 
1533
- // The browser settings mirror reloads only on value writes and
1534
- // reconnect; a new namespace is not such a signal. It will not fix
1535
- // itself — ask it to reload until ready. The mirror is shared, so this
1536
- // also repairs other settings sections.
1537
1595
  React.useEffect(() => {
1538
1596
  if (ready) return undefined
1539
1597
  let tries = 0
@@ -1545,11 +1603,8 @@ window.__ModuleLoader__.load({
1545
1603
  return () => clearInterval(timer)
1546
1604
  }, [ready])
1547
1605
 
1548
- // Seed the draft only from a ready snapshot. Previously it was filled
1549
- // from the first snapshot and an empty draft froze forever: later
1550
- // snapshots did not reseed it and the card stayed empty until reload.
1551
1606
  React.useEffect(() => { if (ready && draft === null) setDraft(JSON.parse(JSON.stringify(value))) }, [ready, draft, value])
1552
- // The client needs the VAD threshold and cancel window from the same settings.
1607
+
1553
1608
  React.useEffect(() => {
1554
1609
  if (!ready) return
1555
1610
  voice.settings = Object.assign({}, voice.settings, {
@@ -1573,23 +1628,34 @@ window.__ModuleLoader__.load({
1573
1628
  })
1574
1629
  }, [ready, value, snap])
1575
1630
 
1576
- const [statsData, setStatsData] = React.useState({})
1577
- React.useEffect(() => {
1578
- let alive = true
1579
- fetch('/dsh-voice/status', { cache: 'no-store' })
1631
+ const [statusData, setStatusData] = React.useState(null)
1632
+ const [statusLatency, setStatusLatency] = React.useState(null)
1633
+ const fetchStatus = React.useCallback(() => {
1634
+ const start = performance.now()
1635
+ const ctrl = new AbortController()
1636
+ const tId = setTimeout(() => ctrl.abort(), 5000)
1637
+ fetch('/dsh-voice/status', { cache: 'no-store', signal: ctrl.signal })
1580
1638
  .then((r) => r.json())
1581
1639
  .then((data) => {
1582
- if (alive && data && data.providerStats) {
1583
- setStatsData(data.providerStats)
1640
+ clearTimeout(tId)
1641
+ setStatusLatency(Math.round(performance.now() - start))
1642
+ if (data && data.ok) {
1643
+ setStatusData(data)
1584
1644
  }
1585
1645
  })
1586
- .catch(() => {})
1587
- return () => { alive = false }
1646
+ .catch(() => {
1647
+ clearTimeout(tId)
1648
+ setStatusLatency(null)
1649
+ })
1588
1650
  }, [])
1589
1651
 
1652
+ React.useEffect(() => {
1653
+ fetchStatus()
1654
+ }, [fetchStatus])
1655
+
1590
1656
  if (!ready) {
1591
1657
  const waiting = !snap || snap.status === 'loading'
1592
- return React.createElement('div', { className: 'dvs-wrap' },
1658
+ return React.createElement('div', { className: 'cb-page' },
1593
1659
  React.createElement('div', { className: 'dvs-wait' }, waiting
1594
1660
  ? t('loadingSettings')
1595
1661
  : t('notReady1')
@@ -1609,10 +1675,6 @@ window.__ModuleLoader__.load({
1609
1675
  setErr(''); setSaved(false)
1610
1676
  if (!draft) return
1611
1677
 
1612
- // Fields are written one by one. Previously the first failure broke
1613
- // the loop: later fields were never saved, the composer never got the
1614
- // hotkey reload signal, and the button looked like "nothing happens".
1615
- // Now every field is attempted and failures are collected by name.
1616
1678
  const failed = []
1617
1679
  for (const k of Object.keys(draft)) {
1618
1680
  try {
@@ -1637,12 +1699,11 @@ window.__ModuleLoader__.load({
1637
1699
  contextGlossary: draft.contextGlossary !== false,
1638
1700
  visualizerStyle: draft.visualizerStyle || 'liquid-wave',
1639
1701
  })
1640
- // The composer owns the hotkey handler — tell it to reload, otherwise
1641
- // a new key only works after a full page reload.
1642
1702
  try { window.dispatchEvent(new CustomEvent('dsh-voice:settings-saved')) } catch (noEvents) { /* nobody */ }
1643
1703
 
1644
1704
  if (failed.length) { setErr(t('saveFailed') + ' ' + failed.join('; ')); return }
1645
1705
  setSaved(true); setTimeout(() => setSaved(false), 2000)
1706
+ fetchStatus()
1646
1707
  }
1647
1708
 
1648
1709
  const modeVal = (mode, key, fallback) => {
@@ -1650,174 +1711,270 @@ window.__ModuleLoader__.load({
1650
1711
  return m && m[key] !== undefined ? m[key] : fallback
1651
1712
  }
1652
1713
 
1653
- // Custom provider names come from the draft so a just-added entry
1654
- // appears in chain selects immediately.
1655
1714
  const chainOptions = BUILTIN.concat(
1656
1715
  (draft && Array.isArray(draft.customProviders) ? draft.customProviders : [])
1657
1716
  .map((c) => String(c && c.key || '').trim())
1658
1717
  .filter((k) => k && BUILTIN.indexOf(k) < 0),
1659
1718
  )
1660
1719
 
1661
- const hotkeyField = () => React.createElement('label', { className: 'dvs-field' },
1662
- t('hotkey'),
1663
- React.createElement('div', { className: 'dvs-row' },
1720
+ const hotkeyField = () => React.createElement('div', { className: 'cb-field' },
1721
+ React.createElement('label', null, t('hotkey')),
1722
+ React.createElement('div', { className: 'cb-row' },
1664
1723
  React.createElement('button', {
1665
- type: 'button', className: 'dvs-save', disabled: !writable,
1724
+ type: 'button', className: 'cb-btn cb-btn-primary', disabled: !writable,
1666
1725
  onClick: () => setCatching(true),
1667
1726
  }, catching ? t('pressKey') : keyLabel(draft && draft.hotkey)),
1668
1727
  React.createElement('button', {
1669
- type: 'button', className: 'dvs-mini', title: t('clearKey'),
1728
+ type: 'button', className: 'cb-btn cb-btn-mini', title: t('clearKey'),
1670
1729
  disabled: !writable, onClick: () => setDraft((d) => Object.assign({}, d || {}, { hotkey: '' })),
1671
1730
  }, '×'),
1672
1731
  ),
1673
- React.createElement('span', { className: 'dvs-sub' },
1674
- t('hotkeyHint1')
1675
- + t('hotkeyHint2')))
1732
+ React.createElement('span', { className: 'dvs-sub' }, t('hotkeyHint1') + t('hotkeyHint2')),
1733
+ )
1676
1734
 
1677
- const micField = () => React.createElement('label', { className: 'dvs-field' }, t('mic'),
1678
- React.createElement('select', {
1679
- value: String((snap && snap.micDeviceId) || ''), disabled: !writable,
1680
- onChange: (e) => setTop('micDeviceId', e.target.value),
1681
- },
1682
- React.createElement('option', { value: '' }, t('micDefault')),
1683
- devices.map((d) => React.createElement('option', { key: d.deviceId, value: d.deviceId },
1684
- d.label || d.deviceId.slice(0, 12)))))
1685
- const langField = (mode) => React.createElement('label', { className: 'dvs-field' }, t('language'),
1735
+ const micField = () => React.createElement('div', { className: 'cb-field' },
1736
+ React.createElement('label', null, t('mic')),
1737
+ React.createElement('select', {
1738
+ value: String((snap && snap.micDeviceId) || ''), disabled: !writable,
1739
+ onChange: (e) => setTop('micDeviceId', e.target.value),
1740
+ },
1741
+ React.createElement('option', { value: '' }, t('micDefault')),
1742
+ devices.map((d) => React.createElement('option', { key: d.deviceId, value: d.deviceId },
1743
+ d.label || d.deviceId.slice(0, 12))),
1744
+ ),
1745
+ )
1746
+
1747
+ const langField = (mode) => React.createElement('div', { className: 'cb-field' },
1748
+ React.createElement('label', null, t('language')),
1686
1749
  React.createElement('select', {
1687
1750
  value: modeVal(mode, 'language', 'ru'), disabled: !writable,
1688
1751
  onChange: (e) => setIn(mode, 'language', e.target.value),
1689
- }, LANGS.map((o) => React.createElement('option', { key: o, value: o }, o))))
1752
+ }, LANGS.map((o) => React.createElement('option', { key: o, value: o }, o))),
1753
+ )
1690
1754
 
1691
- const numField = (mode, key, label, hint) => React.createElement('label', { className: 'dvs-field' }, label,
1755
+ const numField = (mode, key, label, hint) => React.createElement('div', { className: 'cb-field' },
1756
+ React.createElement('label', null, label),
1692
1757
  React.createElement('input', {
1693
1758
  type: 'number', value: modeVal(mode, key, ''), disabled: !writable,
1694
1759
  onChange: (e) => setIn(mode, key, Number(e.target.value)),
1695
1760
  }),
1696
- React.createElement('span', { className: 'dvs-sub' }, hint))
1761
+ React.createElement('span', { className: 'dvs-sub' }, hint),
1762
+ )
1697
1763
 
1698
- const textField = (key, label, hint) => React.createElement('label', { className: 'dvs-field' }, label,
1764
+ const textField = (key, label, hint) => React.createElement('div', { className: 'cb-field' },
1765
+ React.createElement('label', null, label),
1699
1766
  React.createElement('input', {
1700
- value: draft && draft[key] !== undefined ? draft[key] : '', disabled: !writable,
1767
+ type: 'text', value: draft && draft[key] !== undefined ? draft[key] : '', disabled: !writable,
1701
1768
  onChange: (e) => setTop(key, e.target.value),
1702
1769
  }),
1703
- React.createElement('span', { className: 'dvs-sub' }, hint))
1770
+ React.createElement('span', { className: 'dvs-sub' }, hint),
1771
+ )
1772
+
1773
+ const statsData = (statusData && statusData.providerStats) || {}
1774
+ const hostOnline = statusLatency !== null
1775
+ const whisperRunning = !!(statusData && statusData.whisperRunning)
1776
+ const providerCount = (statusData && Array.isArray(statusData.providers)) ? statusData.providers.length : chainOptions.length
1777
+
1778
+ return React.createElement('div', { className: 'cb-page' },
1779
+ // 1. Connection & Engine Status Card
1780
+ React.createElement('div', { className: 'cb-section-card' },
1781
+ React.createElement('div', { className: 'cb-section-title' },
1782
+ React.createElement('span', null, t('statusTitle')),
1783
+ React.createElement('button', {
1784
+ type: 'button', className: 'cb-btn', style: { fontSize: '12px', padding: '4px 8px' },
1785
+ onClick: fetchStatus,
1786
+ }, '🔄 ' + t('refreshStats')),
1787
+ ),
1788
+ React.createElement('div', { className: 'cb-section-desc' }, t('statusDesc')),
1789
+ React.createElement('div', { className: 'cb-row' },
1790
+ hostOnline
1791
+ ? React.createElement('span', { className: 'cb-badge cb-badge-ok' }, '● ' + t('badgeHostOnline').replace('{ms}', statusLatency))
1792
+ : React.createElement('span', { className: 'cb-badge cb-badge-bad' }, '● ' + t('badgeHostOffline')),
1793
+ whisperRunning
1794
+ ? React.createElement('span', { className: 'cb-badge cb-badge-ok' }, '● ' + t('badgeWhisperActive'))
1795
+ : React.createElement('span', { className: 'cb-badge cb-badge-warn' }, '○ ' + t('badgeWhisperInactive')),
1796
+ React.createElement('span', { className: 'cb-badge cb-badge-ok' }, '✓ ' + t('badgeProviders').replace('{count}', providerCount)),
1797
+ ),
1798
+ ),
1704
1799
 
1705
- return React.createElement('div', { className: 'dvs-wrap' },
1706
- React.createElement('div', { className: 'dvs-block' },
1707
- React.createElement('div', { className: 'dvs-h' }, t('dictationBtn')),
1708
- React.createElement('div', { className: 'dvs-sub' }, t('dictationHint')),
1800
+ // 2. Dictation Mode Card
1801
+ React.createElement('div', { className: 'cb-section-card' },
1802
+ React.createElement('div', { className: 'cb-section-title' },
1803
+ React.createElement('span', null, '🎙️ ' + t('dictationBtn')),
1804
+ ),
1805
+ React.createElement('div', { className: 'cb-section-desc' }, t('dictationHint')),
1709
1806
  React.createElement(ChainEditor, {
1710
1807
  value: draft && draft.dictation ? draft.dictation.chain : [], writable: writable,
1711
1808
  options: chainOptions,
1712
1809
  onChange: (v) => setIn('dictation', 'chain', v),
1713
1810
  }),
1714
- langField('dictation'),
1715
- numField('dictation', 'vadSilenceMs', t('pauseMs'), t('pauseHint')),
1716
- numField('dictation', 'sendDelayMs', t('sendDelay'), t('sendDelayHint')),
1717
- React.createElement('label', { className: 'dvs-field' }, t('polish'),
1718
- React.createElement('input', {
1719
- type: 'checkbox', checked: !!(draft && draft.dictation && draft.dictation.polish), disabled: !writable,
1720
- onChange: (e) => setIn('dictation', 'polish', e.target.checked),
1721
- })),
1722
- React.createElement('div', { className: 'dvs-sub' }, t('polishHint')),
1723
- React.createElement('label', { className: 'dvs-field', title: t('wakeWordHint') }, t('wakeWord'),
1724
- React.createElement('input', {
1725
- type: 'text', value: String((snap && snap.wakeWord) || ''), disabled: !writable,
1726
- onChange: (e) => setTop('wakeWord', e.target.value),
1727
- })),
1728
- React.createElement('label', { className: 'dvs-field', title: t('streamHint') }, t('stream'),
1729
- React.createElement('input', {
1730
- type: 'checkbox', checked: !!(draft && draft.dictation && draft.dictation.stream), disabled: !writable,
1731
- onChange: (e) => setIn('dictation', 'stream', e.target.checked),
1732
- })),
1733
- numField('dictation', 'streamChunkMs', t('streamChunkMs'), t('streamHint')),
1734
- React.createElement('label', { className: 'dvs-field', title: t('vadAdaptHint') }, t('vadAdapt'),
1811
+ React.createElement('div', { className: 'cb-grid-2' },
1812
+ langField('dictation'),
1813
+ numField('dictation', 'vadSilenceMs', t('pauseMs'), t('pauseHint')),
1814
+ numField('dictation', 'sendDelayMs', t('sendDelay'), t('sendDelayHint')),
1815
+ numField('dictation', 'streamChunkMs', t('streamChunkMs'), t('streamHint')),
1816
+ ),
1817
+ React.createElement('div', { className: 'cb-row' },
1818
+ React.createElement('label', { className: 'cb-check-label' },
1819
+ React.createElement('input', {
1820
+ type: 'checkbox', checked: !!(draft && draft.dictation && draft.dictation.polish), disabled: !writable,
1821
+ onChange: (e) => setIn('dictation', 'polish', e.target.checked),
1822
+ }),
1823
+ t('polish'),
1824
+ ),
1825
+ React.createElement('label', { className: 'cb-check-label' },
1826
+ React.createElement('input', {
1827
+ type: 'checkbox', checked: !!(draft && draft.dictation && draft.dictation.stream), disabled: !writable,
1828
+ onChange: (e) => setIn('dictation', 'stream', e.target.checked),
1829
+ }),
1830
+ t('stream'),
1831
+ ),
1832
+ ),
1833
+ React.createElement('div', { className: 'cb-field' },
1834
+ React.createElement('label', null, t('vadAdapt')),
1735
1835
  React.createElement('input', {
1736
1836
  type: 'range', min: 0, max: 1, step: 0.1,
1737
1837
  value: Number(draft && draft.dictation && draft.dictation.vadAdapt) || 0, disabled: !writable,
1738
1838
  onChange: (e) => setIn('dictation', 'vadAdapt', Number(e.target.value)),
1739
- })),
1839
+ }),
1840
+ React.createElement('span', { className: 'dvs-sub' }, t('vadAdaptHint')),
1841
+ ),
1842
+ textField('wakeWord', t('wakeWord'), t('wakeWordHint')),
1740
1843
  ),
1741
- React.createElement('div', { className: 'dvs-block' },
1742
- React.createElement('div', { className: 'dvs-h' }, t('messageTitle')),
1743
- React.createElement('div', { className: 'dvs-sub' }, t('messageHint')),
1844
+
1845
+ // 3. Voice Message Card
1846
+ React.createElement('div', { className: 'cb-section-card' },
1847
+ React.createElement('div', { className: 'cb-section-title' },
1848
+ React.createElement('span', null, '💬 ' + t('messageTitle')),
1849
+ ),
1850
+ React.createElement('div', { className: 'cb-section-desc' }, t('messageHint')),
1744
1851
  React.createElement(ChainEditor, {
1745
1852
  value: draft && draft.message ? draft.message.chain : [], writable: writable,
1746
1853
  options: chainOptions,
1747
1854
  onChange: (v) => setIn('message', 'chain', v),
1748
1855
  }),
1749
- langField('message'),
1750
- numField('message', 'autoSendMs', t('undoMs'), t('undoHint')),
1751
- React.createElement('label', { className: 'dvs-field' }, t('polish'),
1752
- React.createElement('input', {
1753
- type: 'checkbox', checked: !!(draft && draft.message && draft.message.polish), disabled: !writable,
1754
- onChange: (e) => setIn('message', 'polish', e.target.checked),
1755
- })),
1756
- React.createElement('label', { className: 'dvs-field', title: t('polishSendHint') }, t('polishSend'),
1757
- React.createElement('input', {
1758
- type: 'checkbox', checked: !!(draft && draft.message && draft.message.polishSend), disabled: !writable,
1759
- onChange: (e) => setIn('message', 'polishSend', e.target.checked),
1760
- })),
1761
- React.createElement('label', { className: 'dvs-field', title: t('sessionCommandsHint') }, t('sessionCommands'),
1762
- React.createElement('input', {
1763
- type: 'checkbox', checked: !!(draft && draft.message && draft.message.sessionCommands), disabled: !writable,
1764
- onChange: (e) => setIn('message', 'sessionCommands', e.target.checked),
1765
- })),
1856
+ React.createElement('div', { className: 'cb-grid-2' },
1857
+ langField('message'),
1858
+ numField('message', 'autoSendMs', t('undoMs'), t('undoHint')),
1859
+ ),
1860
+ React.createElement('div', { className: 'cb-row' },
1861
+ React.createElement('label', { className: 'cb-check-label' },
1862
+ React.createElement('input', {
1863
+ type: 'checkbox', checked: !!(draft && draft.message && draft.message.polish), disabled: !writable,
1864
+ onChange: (e) => setIn('message', 'polish', e.target.checked),
1865
+ }),
1866
+ t('polish'),
1867
+ ),
1868
+ React.createElement('label', { className: 'cb-check-label' },
1869
+ React.createElement('input', {
1870
+ type: 'checkbox', checked: !!(draft && draft.message && draft.message.polishSend), disabled: !writable,
1871
+ onChange: (e) => setIn('message', 'polishSend', e.target.checked),
1872
+ }),
1873
+ t('polishSend'),
1874
+ ),
1875
+ React.createElement('label', { className: 'cb-check-label' },
1876
+ React.createElement('input', {
1877
+ type: 'checkbox', checked: !!(draft && draft.message && draft.message.sessionCommands), disabled: !writable,
1878
+ onChange: (e) => setIn('message', 'sessionCommands', e.target.checked),
1879
+ }),
1880
+ t('sessionCommands'),
1881
+ ),
1882
+ ),
1766
1883
  ),
1767
- React.createElement('div', { className: 'dvs-block' },
1768
- React.createElement('div', { className: 'dvs-h' }, t('customTitle')),
1769
- React.createElement('div', { className: 'dvs-sub' },
1770
- t('customHint')),
1884
+
1885
+ // 4. Custom Providers Card
1886
+ React.createElement('div', { className: 'cb-section-card' },
1887
+ React.createElement('div', { className: 'cb-section-title' },
1888
+ React.createElement('span', null, '⚙️ ' + t('customTitle')),
1889
+ ),
1890
+ React.createElement('div', { className: 'cb-section-desc' }, t('customHint')),
1771
1891
  React.createElement(CustomEditor, {
1772
1892
  value: draft && draft.customProviders ? draft.customProviders : [], writable: writable,
1893
+ options: chainOptions,
1773
1894
  onChange: (v) => setTop('customProviders', v),
1774
1895
  }),
1775
1896
  ),
1776
- React.createElement('div', { className: 'dvs-block' },
1777
- React.createElement('div', { className: 'dvs-h' }, t('general')),
1897
+
1898
+ // 5. Hardware & Local Engines Card
1899
+ React.createElement('div', { className: 'cb-section-card' },
1900
+ React.createElement('div', { className: 'cb-section-title' },
1901
+ React.createElement('span', null, '🔧 ' + t('hardwareTitle')),
1902
+ ),
1903
+ React.createElement('div', { className: 'cb-section-desc' }, t('hardwareDesc')),
1778
1904
  hotkeyField(),
1779
- textField('whisperUrl', t('whisperEndpoint'), t('whisperEndpointHint')),
1780
- textField('whisperBin', t('whisperBin'), t('whisperBinHint')),
1781
- textField('whisperModel', t('whisperModel'), t('whisperModelHint')),
1782
- textField('deepgramBaseUrl', t('deepgramEndpoint'), t('deepgramEndpointHint')),
1783
- React.createElement('label', { className: 'dvs-field' }, t('whisperAutostart'),
1784
- React.createElement('input', {
1785
- type: 'checkbox', checked: !!(draft && draft.autoStart), disabled: !writable,
1786
- onChange: (e) => setTop('autoStart', e.target.checked),
1787
- })),
1788
- React.createElement('label', { className: 'dvs-field', title: t('normalizeTranscriptHint') },
1789
- t('normalizeTranscript'),
1790
- React.createElement('input', {
1791
- type: 'checkbox', checked: !!(draft && draft.normalizeTranscript), disabled: !writable,
1792
- onChange: (e) => setTop('normalizeTranscript', e.target.checked),
1793
- })),
1794
- React.createElement('label', { className: 'dvs-field' }, t('beep'),
1795
- React.createElement('input', {
1796
- type: 'checkbox', checked: !!(draft && draft.beep), disabled: !writable,
1797
- onChange: (e) => setTop('beep', e.target.checked),
1798
- })),
1799
- React.createElement('label', { className: 'dvs-field', title: t('localOnlyHint') }, t('localOnly'),
1800
- React.createElement('input', {
1801
- type: 'checkbox', checked: !!(draft && draft.localOnly), disabled: !writable,
1802
- onChange: (e) => setTop('localOnly', e.target.checked),
1803
- })),
1804
- React.createElement('label', { className: 'dvs-field' }, t('voiceCommandsLabel'),
1805
- React.createElement('input', {
1806
- type: 'checkbox', checked: !!(draft && draft.voiceCommands), disabled: !writable,
1807
- onChange: (e) => setTop('voiceCommands', e.target.checked),
1808
- })),
1809
1905
  micField(),
1810
- React.createElement('label', { className: 'dvs-field', title: t('noiseSuppressionHint') }, t('noiseSuppression'),
1811
- React.createElement('input', {
1812
- type: 'checkbox', checked: draft ? draft.noiseSuppression !== false : true, disabled: !writable,
1813
- onChange: (e) => setTop('noiseSuppression', e.target.checked),
1814
- })),
1815
- React.createElement('label', { className: 'dvs-field', title: t('contextGlossaryHint') }, t('contextGlossary'),
1816
- React.createElement('input', {
1817
- type: 'checkbox', checked: draft ? draft.contextGlossary !== false : true, disabled: !writable,
1818
- onChange: (e) => setTop('contextGlossary', e.target.checked),
1819
- })),
1820
- React.createElement('label', { className: 'dvs-field', title: t('visualizerStyleHint') }, t('visualizerStyle'),
1906
+ React.createElement('div', { className: 'cb-grid-2' },
1907
+ textField('whisperUrl', t('whisperEndpoint'), t('whisperEndpointHint')),
1908
+ textField('whisperBin', t('whisperBin'), t('whisperBinHint')),
1909
+ textField('whisperModel', t('whisperModel'), t('whisperModelHint')),
1910
+ textField('deepgramBaseUrl', t('deepgramEndpoint'), t('deepgramEndpointHint')),
1911
+ textField('sensevoiceUrl', t('sensevoiceEndpoint'), t('sensevoiceEndpointHint')),
1912
+ textField('sensevoiceBin', t('sensevoiceBin'), t('sensevoiceBinHint')),
1913
+ textField('sensevoiceModel', t('sensevoiceModel'), t('sensevoiceModelHint')),
1914
+ textField('polishBaseUrl', t('polishBaseUrl'), t('polishBaseUrlHint')),
1915
+ textField('polishModel', t('polishModel'), ''),
1916
+ textField('polishKeyEnv', t('polishKeyEnv'), ''),
1917
+ ),
1918
+ React.createElement('div', { className: 'cb-row' },
1919
+ React.createElement('label', { className: 'cb-check-label' },
1920
+ React.createElement('input', {
1921
+ type: 'checkbox', checked: !!(draft && draft.autoStart), disabled: !writable,
1922
+ onChange: (e) => setTop('autoStart', e.target.checked),
1923
+ }),
1924
+ t('whisperAutostart'),
1925
+ ),
1926
+ React.createElement('label', { className: 'cb-check-label' },
1927
+ React.createElement('input', {
1928
+ type: 'checkbox', checked: !!(draft && draft.sensevoiceAutostart), disabled: !writable,
1929
+ onChange: (e) => setTop('sensevoiceAutostart', e.target.checked),
1930
+ }),
1931
+ t('sensevoiceAutostart'),
1932
+ ),
1933
+ React.createElement('label', { className: 'cb-check-label' },
1934
+ React.createElement('input', {
1935
+ type: 'checkbox', checked: !!(draft && draft.localOnly), disabled: !writable,
1936
+ onChange: (e) => setTop('localOnly', e.target.checked),
1937
+ }),
1938
+ t('localOnly'),
1939
+ ),
1940
+ React.createElement('label', { className: 'cb-check-label' },
1941
+ React.createElement('input', {
1942
+ type: 'checkbox', checked: !!(draft && draft.beep), disabled: !writable,
1943
+ onChange: (e) => setTop('beep', e.target.checked),
1944
+ }),
1945
+ t('beep'),
1946
+ ),
1947
+ React.createElement('label', { className: 'cb-check-label' },
1948
+ React.createElement('input', {
1949
+ type: 'checkbox', checked: draft ? draft.noiseSuppression !== false : true, disabled: !writable,
1950
+ onChange: (e) => setTop('noiseSuppression', e.target.checked),
1951
+ }),
1952
+ t('noiseSuppression'),
1953
+ ),
1954
+ React.createElement('label', { className: 'cb-check-label' },
1955
+ React.createElement('input', {
1956
+ type: 'checkbox', checked: draft ? draft.contextGlossary !== false : true, disabled: !writable,
1957
+ onChange: (e) => setTop('contextGlossary', e.target.checked),
1958
+ }),
1959
+ t('contextGlossary'),
1960
+ ),
1961
+ React.createElement('label', { className: 'cb-check-label' },
1962
+ React.createElement('input', {
1963
+ type: 'checkbox', checked: !!(draft && draft.voiceCommands), disabled: !writable,
1964
+ onChange: (e) => setTop('voiceCommands', e.target.checked),
1965
+ }),
1966
+ t('voiceCommandsLabel'),
1967
+ ),
1968
+ React.createElement('label', { className: 'cb-check-label' },
1969
+ React.createElement('input', {
1970
+ type: 'checkbox', checked: !!(draft && draft.normalizeTranscript), disabled: !writable,
1971
+ onChange: (e) => setTop('normalizeTranscript', e.target.checked),
1972
+ }),
1973
+ t('normalizeTranscript'),
1974
+ ),
1975
+ ),
1976
+ React.createElement('div', { className: 'cb-field' },
1977
+ React.createElement('label', null, t('visualizerStyle')),
1821
1978
  React.createElement('select', {
1822
1979
  value: String((draft && draft.visualizerStyle) || 'liquid-wave'), disabled: !writable,
1823
1980
  onChange: (e) => setTop('visualizerStyle', e.target.value),
@@ -1826,43 +1983,27 @@ window.__ModuleLoader__.load({
1826
1983
  React.createElement('option', { value: 'dynamic-orb' }, t('visDynamicOrb')),
1827
1984
  React.createElement('option', { value: 'bars' }, t('visBars')),
1828
1985
  React.createElement('option', { value: 'off' }, t('visOff')),
1829
- )),
1830
- React.createElement('label', { className: 'dvs-field' }, t('vocabulary'),
1986
+ ),
1987
+ ),
1988
+ React.createElement('div', { className: 'cb-field' },
1989
+ React.createElement('label', null, t('vocabulary')),
1831
1990
  React.createElement('textarea', {
1832
1991
  rows: 3, disabled: !writable,
1833
1992
  value: Array.isArray(draft && draft.vocabulary) ? draft.vocabulary.join('\n') : '',
1834
1993
  onChange: (e) => setTop('vocabulary', e.target.value.split('\n').map((x) => x.trim()).filter(Boolean)),
1835
- })),
1836
- React.createElement('label', { className: 'dvs-field', title: t('polishBaseUrlHint') }, t('polishBaseUrl'),
1837
- React.createElement('input', {
1838
- type: 'text', value: String((snap && snap.polishBaseUrl) || ''), disabled: !writable,
1839
- onChange: (e) => setTop('polishBaseUrl', e.target.value),
1840
- })),
1841
- React.createElement('label', { className: 'dvs-field' }, t('polishModel'),
1842
- React.createElement('input', {
1843
- type: 'text', value: String((draft && draft.polishModel) || ''), disabled: !writable,
1844
- onChange: (e) => setTop('polishModel', e.target.value),
1845
- })),
1846
- React.createElement('label', { className: 'dvs-field' }, t('polishKeyEnv'),
1847
- React.createElement('input', {
1848
- type: 'text', value: String((draft && draft.polishKeyEnv) || ''), disabled: !writable,
1849
- onChange: (e) => setTop('polishKeyEnv', e.target.value),
1850
- })),
1851
- ),
1852
- React.createElement('div', { className: 'dvs-block' },
1853
- React.createElement('div', { className: 'dvs-h' }, 'SenseVoice-ONNX / Sherpa-ONNX'),
1854
- React.createElement('div', { className: 'dvs-sub' }, t('sensevoiceHint')),
1855
- textField('sensevoiceUrl', t('sensevoiceEndpoint'), t('sensevoiceEndpointHint')),
1856
- textField('sensevoiceBin', t('sensevoiceBin'), t('sensevoiceBinHint')),
1857
- textField('sensevoiceModel', t('sensevoiceModel'), t('sensevoiceModelHint')),
1858
- React.createElement('label', { className: 'dvs-field' }, t('sensevoiceAutostart'),
1859
- React.createElement('input', {
1860
- type: 'checkbox', checked: !!(draft && draft.sensevoiceAutostart), disabled: !writable,
1861
- onChange: (e) => setTop('sensevoiceAutostart', e.target.checked),
1862
- })),
1994
+ }),
1995
+ ),
1863
1996
  ),
1864
- React.createElement('div', { className: 'dvs-block' },
1865
- React.createElement('div', { className: 'dvs-h' }, t('providerDashboard')),
1997
+
1998
+ // 6. Provider Telemetry & Latency Dashboard
1999
+ React.createElement('div', { className: 'cb-section-card' },
2000
+ React.createElement('div', { className: 'cb-section-title' },
2001
+ React.createElement('span', null, '📊 ' + t('providerDashboard')),
2002
+ React.createElement('button', {
2003
+ type: 'button', className: 'cb-btn', style: { fontSize: '12px', padding: '4px 8px' },
2004
+ onClick: fetchStatus,
2005
+ }, '🔄 ' + t('refreshStats')),
2006
+ ),
1866
2007
  React.createElement('div', { className: 'dvo-dash' },
1867
2008
  React.createElement('div', { className: 'dvo-dash-grid' },
1868
2009
  Object.keys(statsData).length === 0
@@ -1899,53 +2040,51 @@ window.__ModuleLoader__.load({
1899
2040
  React.createElement('span', null, t('successRate')),
1900
2041
  ),
1901
2042
  )
1902
- })
1903
- )
1904
- )
2043
+ }),
2044
+ ),
2045
+ ),
1905
2046
  ),
1906
- React.createElement('div', { className: 'dvs-row' },
1907
- React.createElement('button', { type: 'button', className: 'dvs-save', disabled: !writable, onClick: save }, t('save')),
2047
+
2048
+ // Action bar (Save)
2049
+ React.createElement('div', { className: 'cb-row' },
2050
+ React.createElement('button', { type: 'button', className: 'cb-btn cb-btn-primary', disabled: !writable, onClick: save }, t('save')),
1908
2051
  saved ? React.createElement('span', { className: 'dvs-ok' }, t('saved')) : null,
1909
2052
  err ? React.createElement('span', { className: 'dvs-bad' }, err) : null,
1910
2053
  ),
1911
2054
  )
1912
2055
  }
1913
2056
 
1914
- // Card in Settings → Plugins → Plugin settings (#18): the kernel only
1915
- // draws the list shell, so title, hint and collapse are ours.
1916
- // Body mounts on first expand; the settings snapshot arrives then.
2057
+ // Card in Settings → Plugins → Plugin settings (#18)
1917
2058
  function PluginCard(props) {
1918
2059
  const [open, setOpen] = React.useState(false)
1919
2060
  const tt = (props && props.t) || t
1920
2061
  const title = tt('title')
1921
- return React.createElement('li',
1922
- { className: open ? 'dvo-pcard dvo-pcardOpen' : 'dvo-pcard' },
1923
- React.createElement('button',
1924
- {
1925
- type: 'button', className: 'dvo-phead',
1926
- 'aria-expanded': open ? 'true' : 'false',
1927
- 'aria-label': (open ? tt('collapse') : tt('expand')) + ': ' + title,
1928
- onClick: () => setOpen((v) => !v),
1929
- },
1930
- React.createElement('span', { className: 'dvo-pheadtext' },
1931
- React.createElement('span', { className: 'dvo-ptitle' }, title),
1932
- React.createElement('span', { className: 'dvo-pdesc' }, tt('cardHint')),
2062
+ return React.createElement(ErrorBoundary, null,
2063
+ React.createElement('li',
2064
+ { className: open ? 'dvo-pcard dvo-pcardOpen' : 'dvo-pcard' },
2065
+ React.createElement('button',
2066
+ {
2067
+ type: 'button', className: 'dvo-phead',
2068
+ 'aria-expanded': open ? 'true' : 'false',
2069
+ 'aria-label': (open ? tt('collapse') : tt('expand')) + ': ' + title,
2070
+ onClick: () => setOpen((v) => !v),
2071
+ },
2072
+ React.createElement('span', { className: 'dvo-pheadtext' },
2073
+ React.createElement('span', { className: 'dvo-ptitle' }, title),
2074
+ React.createElement('span', { className: 'dvo-pdesc' }, tt('cardHint')),
2075
+ ),
2076
+ React.createElement('span', { className: 'dvo-pchev' }, chevronIcon()),
1933
2077
  ),
1934
- React.createElement('span', { className: 'dvo-pchev' }, chevronIcon()),
2078
+ open ? React.createElement('div', { className: 'dvo-pbody' }, React.createElement(VoiceSection, props)) : null,
1935
2079
  ),
1936
- open ? React.createElement('div', { className: 'dvo-pbody' }, React.createElement(VoiceSection, props)) : null,
1937
2080
  )
1938
2081
  }
1939
2082
 
1940
2083
  function registerSettings(ctx) {
1941
- // Settings card under Settings → Plugins → Plugin settings (#18).
1942
- // The registration key must equal NS (the settings namespace), otherwise
1943
- // the tab silently skips the slot.
1944
2084
  ctx.slots.inject('settings.plugin.item', () => ctx.slots.register(
1945
2085
  {
1946
2086
  name: 'settings.plugin.item',
1947
2087
  key: NS,
1948
- // locale on the slot record is what makes the component receive props.t.
1949
2088
  locale: NS,
1950
2089
  inject: () => ({ ctx: ctx }),
1951
2090
  },