@goodandready/dsh-voice 0.8.19 → 0.8.21

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
@@ -495,6 +509,9 @@ window.__ModuleLoader__.load({
495
509
  // Fetch the mode chain from the host once. Only needed to decide whether
496
510
  // to use browser recognition or record a file.
497
511
  let chainsPromise = null
512
+ if (typeof window !== 'undefined') {
513
+ window.addEventListener('dsh-voice:settings-saved', () => { chainsPromise = null })
514
+ }
498
515
  function modeChain(mode) {
499
516
  if (!chainsPromise) {
500
517
  chainsPromise = fetch('/dsh-voice/status', { cache: 'no-store' })
@@ -1345,19 +1362,40 @@ window.__ModuleLoader__.load({
1345
1362
  const LANGS = ['auto', 'ru', 'en', 'uk', 'de']
1346
1363
 
1347
1364
  const SET_CSS =
1348
- '.dvs-wrap{display:flex;flex-direction:column;gap:22px;padding:4px 0;max-width:720px}' +
1365
+ '.cb-page{display:flex;flex-direction:column;gap:18px;padding:4px 0 24px;max-width:960px}' +
1366
+ '.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}' +
1367
+ '.cb-section-title{font-size:15px;font-weight:600;color:var(--dsw-alias-label-primary);display:flex;align-items:center;justify-content:space-between}' +
1368
+ '.cb-section-desc{font-size:13px;color:var(--dsw-alias-label-secondary);margin-top:-4px;line-height:1.4}' +
1369
+ '.cb-row{display:flex;flex-wrap:wrap;gap:10px;align-items:center}' +
1370
+ '.cb-grid-2{display:grid;grid-template-columns:repeat(auto-fit, minmax(260px, 1fr));gap:12px}' +
1371
+ '.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}' +
1372
+ '.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)}' +
1373
+ '.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)}' +
1374
+ '.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)}' +
1375
+ '.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}' +
1376
+ '.cb-input:focus{outline:none;border-color:var(--dsw-alias-state-brand-primary)}' +
1377
+ '.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}' +
1378
+ '.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))}' +
1379
+ '.cb-btn-primary{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);border-color:transparent}' +
1380
+ '.cb-btn-primary:hover:not(:disabled){background:var(--dsw-alias-label-primary) !important;color:var(--dsw-alias-bg-layer-3) !important;opacity:0.88}' +
1381
+ '.cb-btn-danger{color:var(--dsw-alias-state-error-primary);border-color:rgba(239,68,68,0.3)}' +
1382
+ '.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}' +
1383
+ '.cb-btn-mini:hover:not(:disabled){background:var(--dsw-alias-bg-layer-2)}' +
1384
+ '.cb-field{display:flex;flex-direction:column;gap:6px;padding:6px 0;font-size:13px;color:var(--dsw-alias-label-primary)}' +
1385
+ '.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}' +
1386
+ '.cb-field textarea{height:auto;padding:8px 10px;font-family:inherit}' +
1387
+ '.cb-check-label{display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:var(--dsw-alias-label-primary);user-select:none}' +
1388
+ '.cb-check-label input[type="checkbox"]{width:16px;height:16px;cursor:pointer}' +
1389
+ '.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}' +
1390
+ '.dvs-wrap{display:flex;flex-direction:column;gap:18px;padding:4px 0;max-width:960px}' +
1349
1391
  '.dvs-block{display:flex;flex-direction:column;gap:10px}' +
1350
1392
  '.dvs-h{font-size:14px;font-weight:600;color:var(--dsw-alias-label-primary)}' +
1351
1393
  '.dvs-sub{font-size:12px;color:var(--dsw-alias-label-secondary)}' +
1352
1394
  '.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}' +
1395
+ '.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
1396
  '.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}' +
1397
+ '.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}' +
1398
+ '.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
1399
  '.dvs-wait{font-size:13px;color:var(--dsw-alias-label-secondary);line-height:1.5;max-width:520px}' +
1362
1400
  '.dvs-ok{font-size:12px;color:var(--dsw-alias-state-success-primary)}' +
1363
1401
  '.dvs-bad{font-size:12px;color:var(--dsw-alias-state-error-primary)}' +
@@ -1368,7 +1406,7 @@ window.__ModuleLoader__.load({
1368
1406
  '.dvo-pdesc{color:var(--dsw-alias-label-secondary);font-size:13px}' +
1369
1407
  '.dvo-pchev{flex:none;display:flex;color:var(--dsw-alias-label-secondary);transition:transform .15s ease}' +
1370
1408
  '.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}'
1409
+ '.dvo-pbody{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:12px}'
1372
1410
  const setCssId = 'dsh-voice/settings.module.css'
1373
1411
  if (typeof document !== 'undefined' && !document.querySelector('style[data-dsh-plugin="dsh-voice"][data-plugin-css="' + setCssId + '"]')) {
1374
1412
  const tag = document.createElement('style')
@@ -1467,6 +1505,46 @@ window.__ModuleLoader__.load({
1467
1505
  )
1468
1506
  }
1469
1507
 
1508
+ function createErrorBoundary() {
1509
+ if (!React || typeof React.Component !== 'function') {
1510
+ return function NoopBoundary(props) { return props?.children || null }
1511
+ }
1512
+ return class ErrorBoundary extends React.Component {
1513
+ constructor(props) {
1514
+ super(props)
1515
+ this.state = { hasError: false, error: null }
1516
+ }
1517
+ static getDerivedStateFromError(error) {
1518
+ return { hasError: true, error }
1519
+ }
1520
+ componentDidCatch(error, errorInfo) {
1521
+ console.error('[dsh-voice] React Error:', error, errorInfo)
1522
+ }
1523
+ render() {
1524
+ if (this.state.hasError) {
1525
+ return React.createElement(
1526
+ 'div',
1527
+ { className: 'cb-alert-err', style: { margin: '12px 0', padding: '14px', borderRadius: '8px' } },
1528
+ React.createElement('div', { style: { fontWeight: 600, marginBottom: '6px' } }, '⚠️ ' + t('uiError')),
1529
+ React.createElement('div', { style: { fontSize: '12px', wordBreak: 'break-all' } }, String(this.state.error?.message || this.state.error)),
1530
+ React.createElement(
1531
+ 'button',
1532
+ {
1533
+ type: 'button',
1534
+ className: 'cb-btn',
1535
+ style: { marginTop: '10px', fontSize: '12px', padding: '4px 10px' },
1536
+ onClick: () => this.setState({ hasError: false, error: null }),
1537
+ },
1538
+ t('retry'),
1539
+ ),
1540
+ )
1541
+ }
1542
+ return this.props?.children || null
1543
+ }
1544
+ }
1545
+ }
1546
+ const ErrorBoundary = createErrorBoundary()
1547
+
1470
1548
  function VoiceSection(props) {
1471
1549
  const t = (props && props.t) || moduleT
1472
1550
  const ctx = props.ctx
@@ -1481,12 +1559,7 @@ window.__ModuleLoader__.load({
1481
1559
  const [saved, setSaved] = React.useState(false)
1482
1560
  const [err, setErr] = React.useState('')
1483
1561
 
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.
1562
+ // Hotkey picker: not a name field — press the key you want.
1490
1563
  const [catching, setCatching] = React.useState(false)
1491
1564
  React.useEffect(() => {
1492
1565
  if (!catching) return undefined
@@ -1518,22 +1591,10 @@ window.__ModuleLoader__.load({
1518
1591
  .catch(() => {})
1519
1592
  }, [])
1520
1593
 
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
1594
  const ready = !!snap && snap.status === 'ready'
1530
1595
  const value = ready && snap.value ? snap.value : {}
1531
1596
  const writable = ready && snap.writable !== false
1532
1597
 
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
1598
  React.useEffect(() => {
1538
1599
  if (ready) return undefined
1539
1600
  let tries = 0
@@ -1545,11 +1606,8 @@ window.__ModuleLoader__.load({
1545
1606
  return () => clearInterval(timer)
1546
1607
  }, [ready])
1547
1608
 
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
1609
  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.
1610
+
1553
1611
  React.useEffect(() => {
1554
1612
  if (!ready) return
1555
1613
  voice.settings = Object.assign({}, voice.settings, {
@@ -1573,23 +1631,34 @@ window.__ModuleLoader__.load({
1573
1631
  })
1574
1632
  }, [ready, value, snap])
1575
1633
 
1576
- const [statsData, setStatsData] = React.useState({})
1577
- React.useEffect(() => {
1578
- let alive = true
1579
- fetch('/dsh-voice/status', { cache: 'no-store' })
1634
+ const [statusData, setStatusData] = React.useState(null)
1635
+ const [statusLatency, setStatusLatency] = React.useState(null)
1636
+ const fetchStatus = React.useCallback(() => {
1637
+ const start = performance.now()
1638
+ const ctrl = new AbortController()
1639
+ const tId = setTimeout(() => ctrl.abort(), 5000)
1640
+ fetch('/dsh-voice/status', { cache: 'no-store', signal: ctrl.signal })
1580
1641
  .then((r) => r.json())
1581
1642
  .then((data) => {
1582
- if (alive && data && data.providerStats) {
1583
- setStatsData(data.providerStats)
1643
+ clearTimeout(tId)
1644
+ setStatusLatency(Math.round(performance.now() - start))
1645
+ if (data && data.ok) {
1646
+ setStatusData(data)
1584
1647
  }
1585
1648
  })
1586
- .catch(() => {})
1587
- return () => { alive = false }
1649
+ .catch(() => {
1650
+ clearTimeout(tId)
1651
+ setStatusLatency(null)
1652
+ })
1588
1653
  }, [])
1589
1654
 
1655
+ React.useEffect(() => {
1656
+ fetchStatus()
1657
+ }, [fetchStatus])
1658
+
1590
1659
  if (!ready) {
1591
1660
  const waiting = !snap || snap.status === 'loading'
1592
- return React.createElement('div', { className: 'dvs-wrap' },
1661
+ return React.createElement('div', { className: 'cb-page' },
1593
1662
  React.createElement('div', { className: 'dvs-wait' }, waiting
1594
1663
  ? t('loadingSettings')
1595
1664
  : t('notReady1')
@@ -1609,10 +1678,6 @@ window.__ModuleLoader__.load({
1609
1678
  setErr(''); setSaved(false)
1610
1679
  if (!draft) return
1611
1680
 
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
1681
  const failed = []
1617
1682
  for (const k of Object.keys(draft)) {
1618
1683
  try {
@@ -1637,12 +1702,11 @@ window.__ModuleLoader__.load({
1637
1702
  contextGlossary: draft.contextGlossary !== false,
1638
1703
  visualizerStyle: draft.visualizerStyle || 'liquid-wave',
1639
1704
  })
1640
- // The composer owns the hotkey handler — tell it to reload, otherwise
1641
- // a new key only works after a full page reload.
1642
1705
  try { window.dispatchEvent(new CustomEvent('dsh-voice:settings-saved')) } catch (noEvents) { /* nobody */ }
1643
1706
 
1644
1707
  if (failed.length) { setErr(t('saveFailed') + ' ' + failed.join('; ')); return }
1645
1708
  setSaved(true); setTimeout(() => setSaved(false), 2000)
1709
+ fetchStatus()
1646
1710
  }
1647
1711
 
1648
1712
  const modeVal = (mode, key, fallback) => {
@@ -1650,174 +1714,270 @@ window.__ModuleLoader__.load({
1650
1714
  return m && m[key] !== undefined ? m[key] : fallback
1651
1715
  }
1652
1716
 
1653
- // Custom provider names come from the draft so a just-added entry
1654
- // appears in chain selects immediately.
1655
1717
  const chainOptions = BUILTIN.concat(
1656
1718
  (draft && Array.isArray(draft.customProviders) ? draft.customProviders : [])
1657
1719
  .map((c) => String(c && c.key || '').trim())
1658
1720
  .filter((k) => k && BUILTIN.indexOf(k) < 0),
1659
1721
  )
1660
1722
 
1661
- const hotkeyField = () => React.createElement('label', { className: 'dvs-field' },
1662
- t('hotkey'),
1663
- React.createElement('div', { className: 'dvs-row' },
1723
+ const hotkeyField = () => React.createElement('div', { className: 'cb-field' },
1724
+ React.createElement('label', null, t('hotkey')),
1725
+ React.createElement('div', { className: 'cb-row' },
1664
1726
  React.createElement('button', {
1665
- type: 'button', className: 'dvs-save', disabled: !writable,
1727
+ type: 'button', className: 'cb-btn cb-btn-primary', disabled: !writable,
1666
1728
  onClick: () => setCatching(true),
1667
1729
  }, catching ? t('pressKey') : keyLabel(draft && draft.hotkey)),
1668
1730
  React.createElement('button', {
1669
- type: 'button', className: 'dvs-mini', title: t('clearKey'),
1731
+ type: 'button', className: 'cb-btn cb-btn-mini', title: t('clearKey'),
1670
1732
  disabled: !writable, onClick: () => setDraft((d) => Object.assign({}, d || {}, { hotkey: '' })),
1671
1733
  }, '×'),
1672
1734
  ),
1673
- React.createElement('span', { className: 'dvs-sub' },
1674
- t('hotkeyHint1')
1675
- + t('hotkeyHint2')))
1735
+ React.createElement('span', { className: 'dvs-sub' }, t('hotkeyHint1') + t('hotkeyHint2')),
1736
+ )
1676
1737
 
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'),
1738
+ const micField = () => React.createElement('div', { className: 'cb-field' },
1739
+ React.createElement('label', null, t('mic')),
1740
+ React.createElement('select', {
1741
+ value: String((snap && snap.micDeviceId) || ''), disabled: !writable,
1742
+ onChange: (e) => setTop('micDeviceId', e.target.value),
1743
+ },
1744
+ React.createElement('option', { value: '' }, t('micDefault')),
1745
+ devices.map((d) => React.createElement('option', { key: d.deviceId, value: d.deviceId },
1746
+ d.label || d.deviceId.slice(0, 12))),
1747
+ ),
1748
+ )
1749
+
1750
+ const langField = (mode) => React.createElement('div', { className: 'cb-field' },
1751
+ React.createElement('label', null, t('language')),
1686
1752
  React.createElement('select', {
1687
1753
  value: modeVal(mode, 'language', 'ru'), disabled: !writable,
1688
1754
  onChange: (e) => setIn(mode, 'language', e.target.value),
1689
- }, LANGS.map((o) => React.createElement('option', { key: o, value: o }, o))))
1755
+ }, LANGS.map((o) => React.createElement('option', { key: o, value: o }, o))),
1756
+ )
1690
1757
 
1691
- const numField = (mode, key, label, hint) => React.createElement('label', { className: 'dvs-field' }, label,
1758
+ const numField = (mode, key, label, hint) => React.createElement('div', { className: 'cb-field' },
1759
+ React.createElement('label', null, label),
1692
1760
  React.createElement('input', {
1693
1761
  type: 'number', value: modeVal(mode, key, ''), disabled: !writable,
1694
1762
  onChange: (e) => setIn(mode, key, Number(e.target.value)),
1695
1763
  }),
1696
- React.createElement('span', { className: 'dvs-sub' }, hint))
1764
+ React.createElement('span', { className: 'dvs-sub' }, hint),
1765
+ )
1697
1766
 
1698
- const textField = (key, label, hint) => React.createElement('label', { className: 'dvs-field' }, label,
1767
+ const textField = (key, label, hint) => React.createElement('div', { className: 'cb-field' },
1768
+ React.createElement('label', null, label),
1699
1769
  React.createElement('input', {
1700
- value: draft && draft[key] !== undefined ? draft[key] : '', disabled: !writable,
1770
+ type: 'text', value: draft && draft[key] !== undefined ? draft[key] : '', disabled: !writable,
1701
1771
  onChange: (e) => setTop(key, e.target.value),
1702
1772
  }),
1703
- React.createElement('span', { className: 'dvs-sub' }, hint))
1773
+ React.createElement('span', { className: 'dvs-sub' }, hint),
1774
+ )
1775
+
1776
+ const statsData = (statusData && statusData.providerStats) || {}
1777
+ const hostOnline = statusLatency !== null
1778
+ const whisperRunning = !!(statusData && statusData.whisperRunning)
1779
+ const providerCount = (statusData && Array.isArray(statusData.providers)) ? statusData.providers.length : chainOptions.length
1780
+
1781
+ return React.createElement('div', { className: 'cb-page' },
1782
+ // 1. Connection & Engine Status Card
1783
+ React.createElement('div', { className: 'cb-section-card' },
1784
+ React.createElement('div', { className: 'cb-section-title' },
1785
+ React.createElement('span', null, t('statusTitle')),
1786
+ React.createElement('button', {
1787
+ type: 'button', className: 'cb-btn', style: { fontSize: '12px', padding: '4px 8px' },
1788
+ onClick: fetchStatus,
1789
+ }, '🔄 ' + t('refreshStats')),
1790
+ ),
1791
+ React.createElement('div', { className: 'cb-section-desc' }, t('statusDesc')),
1792
+ React.createElement('div', { className: 'cb-row' },
1793
+ hostOnline
1794
+ ? React.createElement('span', { className: 'cb-badge cb-badge-ok' }, '● ' + t('badgeHostOnline').replace('{ms}', statusLatency))
1795
+ : React.createElement('span', { className: 'cb-badge cb-badge-bad' }, '● ' + t('badgeHostOffline')),
1796
+ whisperRunning
1797
+ ? React.createElement('span', { className: 'cb-badge cb-badge-ok' }, '● ' + t('badgeWhisperActive'))
1798
+ : React.createElement('span', { className: 'cb-badge cb-badge-warn' }, '○ ' + t('badgeWhisperInactive')),
1799
+ React.createElement('span', { className: 'cb-badge cb-badge-ok' }, '✓ ' + t('badgeProviders').replace('{count}', providerCount)),
1800
+ ),
1801
+ ),
1704
1802
 
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')),
1803
+ // 2. Dictation Mode Card
1804
+ React.createElement('div', { className: 'cb-section-card' },
1805
+ React.createElement('div', { className: 'cb-section-title' },
1806
+ React.createElement('span', null, '🎙️ ' + t('dictationBtn')),
1807
+ ),
1808
+ React.createElement('div', { className: 'cb-section-desc' }, t('dictationHint')),
1709
1809
  React.createElement(ChainEditor, {
1710
1810
  value: draft && draft.dictation ? draft.dictation.chain : [], writable: writable,
1711
1811
  options: chainOptions,
1712
1812
  onChange: (v) => setIn('dictation', 'chain', v),
1713
1813
  }),
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'),
1814
+ React.createElement('div', { className: 'cb-grid-2' },
1815
+ langField('dictation'),
1816
+ numField('dictation', 'vadSilenceMs', t('pauseMs'), t('pauseHint')),
1817
+ numField('dictation', 'sendDelayMs', t('sendDelay'), t('sendDelayHint')),
1818
+ numField('dictation', 'streamChunkMs', t('streamChunkMs'), t('streamHint')),
1819
+ ),
1820
+ React.createElement('div', { className: 'cb-row' },
1821
+ React.createElement('label', { className: 'cb-check-label' },
1822
+ React.createElement('input', {
1823
+ type: 'checkbox', checked: !!(draft && draft.dictation && draft.dictation.polish), disabled: !writable,
1824
+ onChange: (e) => setIn('dictation', 'polish', e.target.checked),
1825
+ }),
1826
+ t('polish'),
1827
+ ),
1828
+ React.createElement('label', { className: 'cb-check-label' },
1829
+ React.createElement('input', {
1830
+ type: 'checkbox', checked: !!(draft && draft.dictation && draft.dictation.stream), disabled: !writable,
1831
+ onChange: (e) => setIn('dictation', 'stream', e.target.checked),
1832
+ }),
1833
+ t('stream'),
1834
+ ),
1835
+ ),
1836
+ React.createElement('div', { className: 'cb-field' },
1837
+ React.createElement('label', null, t('vadAdapt')),
1735
1838
  React.createElement('input', {
1736
1839
  type: 'range', min: 0, max: 1, step: 0.1,
1737
1840
  value: Number(draft && draft.dictation && draft.dictation.vadAdapt) || 0, disabled: !writable,
1738
1841
  onChange: (e) => setIn('dictation', 'vadAdapt', Number(e.target.value)),
1739
- })),
1842
+ }),
1843
+ React.createElement('span', { className: 'dvs-sub' }, t('vadAdaptHint')),
1844
+ ),
1845
+ textField('wakeWord', t('wakeWord'), t('wakeWordHint')),
1740
1846
  ),
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')),
1847
+
1848
+ // 3. Voice Message Card
1849
+ React.createElement('div', { className: 'cb-section-card' },
1850
+ React.createElement('div', { className: 'cb-section-title' },
1851
+ React.createElement('span', null, '💬 ' + t('messageTitle')),
1852
+ ),
1853
+ React.createElement('div', { className: 'cb-section-desc' }, t('messageHint')),
1744
1854
  React.createElement(ChainEditor, {
1745
1855
  value: draft && draft.message ? draft.message.chain : [], writable: writable,
1746
1856
  options: chainOptions,
1747
1857
  onChange: (v) => setIn('message', 'chain', v),
1748
1858
  }),
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
- })),
1859
+ React.createElement('div', { className: 'cb-grid-2' },
1860
+ langField('message'),
1861
+ numField('message', 'autoSendMs', t('undoMs'), t('undoHint')),
1862
+ ),
1863
+ React.createElement('div', { className: 'cb-row' },
1864
+ React.createElement('label', { className: 'cb-check-label' },
1865
+ React.createElement('input', {
1866
+ type: 'checkbox', checked: !!(draft && draft.message && draft.message.polish), disabled: !writable,
1867
+ onChange: (e) => setIn('message', 'polish', e.target.checked),
1868
+ }),
1869
+ t('polish'),
1870
+ ),
1871
+ React.createElement('label', { className: 'cb-check-label' },
1872
+ React.createElement('input', {
1873
+ type: 'checkbox', checked: !!(draft && draft.message && draft.message.polishSend), disabled: !writable,
1874
+ onChange: (e) => setIn('message', 'polishSend', e.target.checked),
1875
+ }),
1876
+ t('polishSend'),
1877
+ ),
1878
+ React.createElement('label', { className: 'cb-check-label' },
1879
+ React.createElement('input', {
1880
+ type: 'checkbox', checked: !!(draft && draft.message && draft.message.sessionCommands), disabled: !writable,
1881
+ onChange: (e) => setIn('message', 'sessionCommands', e.target.checked),
1882
+ }),
1883
+ t('sessionCommands'),
1884
+ ),
1885
+ ),
1766
1886
  ),
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')),
1887
+
1888
+ // 4. Custom Providers Card
1889
+ React.createElement('div', { className: 'cb-section-card' },
1890
+ React.createElement('div', { className: 'cb-section-title' },
1891
+ React.createElement('span', null, '⚙️ ' + t('customTitle')),
1892
+ ),
1893
+ React.createElement('div', { className: 'cb-section-desc' }, t('customHint')),
1771
1894
  React.createElement(CustomEditor, {
1772
1895
  value: draft && draft.customProviders ? draft.customProviders : [], writable: writable,
1896
+ options: chainOptions,
1773
1897
  onChange: (v) => setTop('customProviders', v),
1774
1898
  }),
1775
1899
  ),
1776
- React.createElement('div', { className: 'dvs-block' },
1777
- React.createElement('div', { className: 'dvs-h' }, t('general')),
1900
+
1901
+ // 5. Hardware & Local Engines Card
1902
+ React.createElement('div', { className: 'cb-section-card' },
1903
+ React.createElement('div', { className: 'cb-section-title' },
1904
+ React.createElement('span', null, '🔧 ' + t('hardwareTitle')),
1905
+ ),
1906
+ React.createElement('div', { className: 'cb-section-desc' }, t('hardwareDesc')),
1778
1907
  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
1908
  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'),
1909
+ React.createElement('div', { className: 'cb-grid-2' },
1910
+ textField('whisperUrl', t('whisperEndpoint'), t('whisperEndpointHint')),
1911
+ textField('whisperBin', t('whisperBin'), t('whisperBinHint')),
1912
+ textField('whisperModel', t('whisperModel'), t('whisperModelHint')),
1913
+ textField('deepgramBaseUrl', t('deepgramEndpoint'), t('deepgramEndpointHint')),
1914
+ textField('sensevoiceUrl', t('sensevoiceEndpoint'), t('sensevoiceEndpointHint')),
1915
+ textField('sensevoiceBin', t('sensevoiceBin'), t('sensevoiceBinHint')),
1916
+ textField('sensevoiceModel', t('sensevoiceModel'), t('sensevoiceModelHint')),
1917
+ textField('polishBaseUrl', t('polishBaseUrl'), t('polishBaseUrlHint')),
1918
+ textField('polishModel', t('polishModel'), ''),
1919
+ textField('polishKeyEnv', t('polishKeyEnv'), ''),
1920
+ ),
1921
+ React.createElement('div', { className: 'cb-row' },
1922
+ React.createElement('label', { className: 'cb-check-label' },
1923
+ React.createElement('input', {
1924
+ type: 'checkbox', checked: !!(draft && draft.autoStart), disabled: !writable,
1925
+ onChange: (e) => setTop('autoStart', e.target.checked),
1926
+ }),
1927
+ t('whisperAutostart'),
1928
+ ),
1929
+ React.createElement('label', { className: 'cb-check-label' },
1930
+ React.createElement('input', {
1931
+ type: 'checkbox', checked: !!(draft && draft.sensevoiceAutostart), disabled: !writable,
1932
+ onChange: (e) => setTop('sensevoiceAutostart', e.target.checked),
1933
+ }),
1934
+ t('sensevoiceAutostart'),
1935
+ ),
1936
+ React.createElement('label', { className: 'cb-check-label' },
1937
+ React.createElement('input', {
1938
+ type: 'checkbox', checked: !!(draft && draft.localOnly), disabled: !writable,
1939
+ onChange: (e) => setTop('localOnly', e.target.checked),
1940
+ }),
1941
+ t('localOnly'),
1942
+ ),
1943
+ React.createElement('label', { className: 'cb-check-label' },
1944
+ React.createElement('input', {
1945
+ type: 'checkbox', checked: !!(draft && draft.beep), disabled: !writable,
1946
+ onChange: (e) => setTop('beep', e.target.checked),
1947
+ }),
1948
+ t('beep'),
1949
+ ),
1950
+ React.createElement('label', { className: 'cb-check-label' },
1951
+ React.createElement('input', {
1952
+ type: 'checkbox', checked: draft ? draft.noiseSuppression !== false : true, disabled: !writable,
1953
+ onChange: (e) => setTop('noiseSuppression', e.target.checked),
1954
+ }),
1955
+ t('noiseSuppression'),
1956
+ ),
1957
+ React.createElement('label', { className: 'cb-check-label' },
1958
+ React.createElement('input', {
1959
+ type: 'checkbox', checked: draft ? draft.contextGlossary !== false : true, disabled: !writable,
1960
+ onChange: (e) => setTop('contextGlossary', e.target.checked),
1961
+ }),
1962
+ t('contextGlossary'),
1963
+ ),
1964
+ React.createElement('label', { className: 'cb-check-label' },
1965
+ React.createElement('input', {
1966
+ type: 'checkbox', checked: !!(draft && draft.voiceCommands), disabled: !writable,
1967
+ onChange: (e) => setTop('voiceCommands', e.target.checked),
1968
+ }),
1969
+ t('voiceCommandsLabel'),
1970
+ ),
1971
+ React.createElement('label', { className: 'cb-check-label' },
1972
+ React.createElement('input', {
1973
+ type: 'checkbox', checked: !!(draft && draft.normalizeTranscript), disabled: !writable,
1974
+ onChange: (e) => setTop('normalizeTranscript', e.target.checked),
1975
+ }),
1976
+ t('normalizeTranscript'),
1977
+ ),
1978
+ ),
1979
+ React.createElement('div', { className: 'cb-field' },
1980
+ React.createElement('label', null, t('visualizerStyle')),
1821
1981
  React.createElement('select', {
1822
1982
  value: String((draft && draft.visualizerStyle) || 'liquid-wave'), disabled: !writable,
1823
1983
  onChange: (e) => setTop('visualizerStyle', e.target.value),
@@ -1826,43 +1986,27 @@ window.__ModuleLoader__.load({
1826
1986
  React.createElement('option', { value: 'dynamic-orb' }, t('visDynamicOrb')),
1827
1987
  React.createElement('option', { value: 'bars' }, t('visBars')),
1828
1988
  React.createElement('option', { value: 'off' }, t('visOff')),
1829
- )),
1830
- React.createElement('label', { className: 'dvs-field' }, t('vocabulary'),
1989
+ ),
1990
+ ),
1991
+ React.createElement('div', { className: 'cb-field' },
1992
+ React.createElement('label', null, t('vocabulary')),
1831
1993
  React.createElement('textarea', {
1832
1994
  rows: 3, disabled: !writable,
1833
1995
  value: Array.isArray(draft && draft.vocabulary) ? draft.vocabulary.join('\n') : '',
1834
1996
  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
- })),
1997
+ }),
1998
+ ),
1863
1999
  ),
1864
- React.createElement('div', { className: 'dvs-block' },
1865
- React.createElement('div', { className: 'dvs-h' }, t('providerDashboard')),
2000
+
2001
+ // 6. Provider Telemetry & Latency Dashboard
2002
+ React.createElement('div', { className: 'cb-section-card' },
2003
+ React.createElement('div', { className: 'cb-section-title' },
2004
+ React.createElement('span', null, '📊 ' + t('providerDashboard')),
2005
+ React.createElement('button', {
2006
+ type: 'button', className: 'cb-btn', style: { fontSize: '12px', padding: '4px 8px' },
2007
+ onClick: fetchStatus,
2008
+ }, '🔄 ' + t('refreshStats')),
2009
+ ),
1866
2010
  React.createElement('div', { className: 'dvo-dash' },
1867
2011
  React.createElement('div', { className: 'dvo-dash-grid' },
1868
2012
  Object.keys(statsData).length === 0
@@ -1899,53 +2043,51 @@ window.__ModuleLoader__.load({
1899
2043
  React.createElement('span', null, t('successRate')),
1900
2044
  ),
1901
2045
  )
1902
- })
1903
- )
1904
- )
2046
+ }),
2047
+ ),
2048
+ ),
1905
2049
  ),
1906
- React.createElement('div', { className: 'dvs-row' },
1907
- React.createElement('button', { type: 'button', className: 'dvs-save', disabled: !writable, onClick: save }, t('save')),
2050
+
2051
+ // Action bar (Save)
2052
+ React.createElement('div', { className: 'cb-row' },
2053
+ React.createElement('button', { type: 'button', className: 'cb-btn cb-btn-primary', disabled: !writable, onClick: save }, t('save')),
1908
2054
  saved ? React.createElement('span', { className: 'dvs-ok' }, t('saved')) : null,
1909
2055
  err ? React.createElement('span', { className: 'dvs-bad' }, err) : null,
1910
2056
  ),
1911
2057
  )
1912
2058
  }
1913
2059
 
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.
2060
+ // Card in Settings → Plugins → Plugin settings (#18)
1917
2061
  function PluginCard(props) {
1918
2062
  const [open, setOpen] = React.useState(false)
1919
2063
  const tt = (props && props.t) || t
1920
2064
  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')),
2065
+ return React.createElement(ErrorBoundary, null,
2066
+ React.createElement('li',
2067
+ { className: open ? 'dvo-pcard dvo-pcardOpen' : 'dvo-pcard' },
2068
+ React.createElement('button',
2069
+ {
2070
+ type: 'button', className: 'dvo-phead',
2071
+ 'aria-expanded': open ? 'true' : 'false',
2072
+ 'aria-label': (open ? tt('collapse') : tt('expand')) + ': ' + title,
2073
+ onClick: () => setOpen((v) => !v),
2074
+ },
2075
+ React.createElement('span', { className: 'dvo-pheadtext' },
2076
+ React.createElement('span', { className: 'dvo-ptitle' }, title),
2077
+ React.createElement('span', { className: 'dvo-pdesc' }, tt('cardHint')),
2078
+ ),
2079
+ React.createElement('span', { className: 'dvo-pchev' }, chevronIcon()),
1933
2080
  ),
1934
- React.createElement('span', { className: 'dvo-pchev' }, chevronIcon()),
2081
+ open ? React.createElement('div', { className: 'dvo-pbody' }, React.createElement(VoiceSection, props)) : null,
1935
2082
  ),
1936
- open ? React.createElement('div', { className: 'dvo-pbody' }, React.createElement(VoiceSection, props)) : null,
1937
2083
  )
1938
2084
  }
1939
2085
 
1940
2086
  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
2087
  ctx.slots.inject('settings.plugin.item', () => ctx.slots.register(
1945
2088
  {
1946
2089
  name: 'settings.plugin.item',
1947
2090
  key: NS,
1948
- // locale on the slot record is what makes the component receive props.t.
1949
2091
  locale: NS,
1950
2092
  inject: () => ({ ctx: ctx }),
1951
2093
  },