free-coding-models 0.5.12 → 0.5.15

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.
@@ -48,8 +48,8 @@
48
48
  <link rel="preconnect" href="https://fonts.googleapis.com">
49
49
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
50
50
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
51
- <script type="module" crossorigin src="/assets/index-Ce_pr2YF.js"></script>
52
- <link rel="stylesheet" crossorigin href="/assets/index-H9JWDRIh.css">
51
+ <script type="module" crossorigin src="/assets/index-CUYQh5_t.js"></script>
52
+ <link rel="stylesheet" crossorigin href="/assets/index-BRqzsHVw.css">
53
53
  </head>
54
54
  <body>
55
55
  <div id="root"></div>
package/web/server.js CHANGED
@@ -1360,32 +1360,101 @@ async function handleRequest(req, res) {
1360
1360
  }
1361
1361
 
1362
1362
  case '/api/playground/chat': {
1363
- // 📖 Playground proxy: forwards a chat-completions request to the
1364
- // 📖 local router daemon and returns the response. Streams SSE
1365
- // 📖 events when stream: true is set. The web playground UI is just
1366
- // 📖 a thin client over this endpoint so the browser never talks to
1367
- // 📖 an external domain (no CORS, no exposed provider keys).
1363
+ // 📖 Playground proxy: routes chat-completions requests either to
1364
+ // 📖 the local router daemon (for "fcm" auto-router) or directly
1365
+ // 📖 to the provider (for specific models). This way the playground
1366
+ // 📖 works immediately with any "up" model, even without the daemon.
1368
1367
  if (req.method !== 'POST') { res.writeHead(405); res.end('Method Not Allowed'); return }
1369
- const port = await readDaemonPort()
1370
- if (!port) {
1368
+ const body = await readJsonBody(req)
1369
+ const wantsStream = body?.stream === true
1370
+ const requestedModel = body?.model || 'fcm'
1371
+ const isFcm = requestedModel === 'fcm'
1372
+
1373
+ // 📖 Resolve direct provider routing for non-fcm models.
1374
+ // 📖 Format: "providerKey/modelId" → look up source URL + API key.
1375
+ let directUrl = null
1376
+ let directApiKey = null
1377
+ let directModelId = null
1378
+ if (!isFcm) {
1379
+ const slashIdx = requestedModel.indexOf('/')
1380
+ if (slashIdx > 0) {
1381
+ const providerKey = requestedModel.slice(0, slashIdx)
1382
+ const modelId = requestedModel.slice(slashIdx + 1)
1383
+ const source = sources[providerKey]
1384
+ if (source?.url) {
1385
+ directApiKey = getApiKey(config, providerKey)
1386
+ // 📖 Always resolve the URL so we can differentiate "no key"
1387
+ // 📖 from "unknown model" in the error handler below.
1388
+ let baseUrl = source.url
1389
+ if (!baseUrl.includes('/chat/completions')) {
1390
+ baseUrl = baseUrl.replace(/\/+$/, '') + '/v1/chat/completions'
1391
+ }
1392
+ directUrl = baseUrl
1393
+ directModelId = modelId
1394
+ }
1395
+ }
1396
+ }
1397
+
1398
+ // 📖 Decide routing: direct to provider (non-fcm, has key) or via daemon.
1399
+ const useDirectRoute = !isFcm && directUrl && directApiKey
1400
+
1401
+ // 📖 If user picked a specific model but has no API key for it,
1402
+ // 📖 tell them instead of falling through to the daemon 503.
1403
+ if (!isFcm && directUrl && !directApiKey) {
1404
+ const providerKey = requestedModel.slice(0, requestedModel.indexOf('/'))
1405
+ sendJson(res, 401, { ok: false, error: `No API key configured for ${sources[providerKey]?.name || providerKey}. Add one in Settings to chat directly with this model.` })
1406
+ return
1407
+ }
1408
+
1409
+ // 📖 If user picked a specific model but we couldn't resolve it,
1410
+ // 📖 tell them instead of falling through to the daemon 503.
1411
+ if (!isFcm && !directUrl) {
1412
+ sendJson(res, 404, { ok: false, error: `Could not resolve provider for model "${requestedModel}". Try selecting a different model or use the auto-router (fcm).` })
1413
+ return
1414
+ }
1415
+ const upstreamUrl = useDirectRoute
1416
+ ? directUrl
1417
+ : `http://127.0.0.1:${await readDaemonPort()}/v1/chat/completions`
1418
+
1419
+ if (!useDirectRoute && !await readDaemonPort()) {
1371
1420
  sendJson(res, 503, { ok: false, error: 'Router daemon is not running. Start it from the Router card or with `free-coding-models --daemon-bg`.' })
1372
1421
  return
1373
1422
  }
1374
- const body = await readJsonBody(req)
1375
- const wantsStream = body?.stream === true
1376
- const upstreamUrl = `http://127.0.0.1:${port}/v1/chat/completions`
1423
+
1424
+ // 📖 Build the upstream payload. For direct routing, extract the
1425
+ // 📖 actual model ID from the providerKey/modelId format.
1426
+ const upstreamPayload = {
1427
+ ...body,
1428
+ stream: wantsStream,
1429
+ model: useDirectRoute ? directModelId : requestedModel,
1430
+ }
1431
+
1432
+ // 📖 Build headers: direct routing needs auth + any provider-specific
1433
+ // 📖 headers. Daemon routing is just JSON content-type.
1434
+ const upstreamHeaders = { 'Content-Type': 'application/json' }
1435
+ if (useDirectRoute) {
1436
+ // 📖 Most providers use standard Bearer auth. A few need extra
1437
+ // 📖 headers (OpenRouter wants Referer + X-Title for ex).
1438
+ upstreamHeaders['Authorization'] = `Bearer ${directApiKey}`
1439
+ const providerKey = requestedModel.slice(0, requestedModel.indexOf('/'))
1440
+ if (providerKey === 'openrouter') {
1441
+ upstreamHeaders['HTTP-Referer'] = 'https://github.com/vava-nessa/free-coding-models'
1442
+ upstreamHeaders['X-Title'] = 'free-coding-models'
1443
+ }
1444
+ }
1445
+
1377
1446
  const controller = new AbortController()
1378
1447
  const timeout = setTimeout(() => controller.abort(), 120000)
1379
1448
  req.on('close', () => controller.abort())
1380
1449
  try {
1381
1450
  const upstreamResp = await fetch(upstreamUrl, {
1382
1451
  method: 'POST',
1383
- headers: { 'Content-Type': 'application/json' },
1384
- body: JSON.stringify({ ...body, stream: wantsStream, model: body?.model || 'fcm' }),
1452
+ headers: upstreamHeaders,
1453
+ body: JSON.stringify(upstreamPayload),
1385
1454
  signal: controller.signal,
1386
1455
  })
1387
1456
  if (wantsStream) {
1388
- // 📖 Pipe SSE events straight from the daemon to the browser.
1457
+ // 📖 Pipe SSE events straight from the upstream to the browser.
1389
1458
  res.writeHead(upstreamResp.status, {
1390
1459
  'Content-Type': 'text/event-stream',
1391
1460
  'Cache-Control': 'no-cache',
@@ -1407,7 +1476,7 @@ async function handleRequest(req, res) {
1407
1476
  return
1408
1477
  }
1409
1478
  const json = await upstreamResp.json().catch(() => null)
1410
- sendJson(res, upstreamResp.status, json || { ok: false, error: 'Empty response from router' })
1479
+ sendJson(res, upstreamResp.status, json || { ok: false, error: 'Empty response from upstream' })
1411
1480
  return
1412
1481
  } catch (err) {
1413
1482
  sendJson(res, 502, { ok: false, error: `Playground proxy failed: ${err.message || String(err)}` })
package/web/src/App.jsx CHANGED
@@ -171,11 +171,25 @@ export default function App() {
171
171
  } = useUpdateChecker({ onToast: addToast })
172
172
 
173
173
  // 📖 Build the provider list for the FilterBar dropdown.
174
+ // 📖 Build the provider list for the FilterBar dropdown with aggregated health.
175
+ // 📖 `hasKey` = at least one model has an API key configured.
176
+ // 📖 `anyUp` = at least one model with status 'up' (key works).
177
+ // 📖 These drive the colored indicator dot in the custom provider dropdown.
174
178
  const providers = useMemo(() => {
175
179
  const map = {}
176
180
  models.forEach((m) => {
177
- if (!map[m.providerKey]) map[m.providerKey] = { key: m.providerKey, name: m.origin, count: 0 }
181
+ if (!map[m.providerKey]) {
182
+ map[m.providerKey] = {
183
+ key: m.providerKey,
184
+ name: m.origin,
185
+ count: 0,
186
+ hasKey: false,
187
+ anyUp: false,
188
+ }
189
+ }
178
190
  map[m.providerKey].count++
191
+ if (m.hasApiKey) map[m.providerKey].hasKey = true
192
+ if (m.status === 'up') map[m.providerKey].anyUp = true
179
193
  })
180
194
  return Object.values(map).sort((a, b) => a.name.localeCompare(b.name))
181
195
  }, [models])
@@ -87,7 +87,7 @@ export default function DetailPanel({
87
87
  const compatible = isModelCompatibleWithTool(model.providerKey, toolMode)
88
88
 
89
89
  return (
90
- <div className={styles.panel}>
90
+ <div className={`${styles.panel} ${styles.panelOpen}`}>
91
91
  <div className={styles.header}>
92
92
  <h3 className={styles.title}>{model.label}</h3>
93
93
  <button className={styles.closeBtn} onClick={onClose}>&times;</button>
@@ -13,6 +13,11 @@
13
13
  overflow-y: auto;
14
14
  }
15
15
 
16
+ /* 📖 Slide-in when model is selected (parent passes a non-null model) */
17
+ .panelOpen {
18
+ transform: translateX(0);
19
+ }
20
+
16
21
  .header {
17
22
  display: flex;
18
23
  align-items: center;
@@ -5,10 +5,13 @@
5
5
  * 📖 + custom text filter chip with "X" clear + reset view button (N).
6
6
  * 📖 Each chip is a cycling button matching the TUI single-key behavior.
7
7
  * 📖 The "Next ping in Xs" countdown still shows the live status.
8
+ * 📖 Filter groups (Tier, Status, Verdict, Health) are collapsible by default
9
+ * — click the trigger to expand the chip row, click again or outside to close.
8
10
  */
9
- import { useState, useEffect, useMemo } from 'react'
10
- import { IconRefresh, IconX, IconFilter, IconCircleCheck, IconAlertTriangle, IconActivity, IconEye, IconStar } from '@tabler/icons-react'
11
+ import { useState, useEffect, useMemo, useRef, useCallback } from 'react'
12
+ import { IconRefresh, IconX, IconFilter, IconChevronDown } from '@tabler/icons-react'
11
13
  import { getToolMeta } from '../../../../src/core/tool-metadata.js'
14
+ import ProviderDropdown from './ProviderDropdown.jsx'
12
15
  import styles from './FilterBar.module.css'
13
16
 
14
17
  // 📖 Chip sets match the TUI cycles 1:1 (see useFilter.js). Keep these in sync.
@@ -65,30 +68,84 @@ const PING_MODES = [
65
68
 
66
69
  function formatCountdown(ms) {
67
70
  if (ms == null) return null
68
- const s = Math.max(0, Math.ceil(ms / 1000))
69
- if (s === 1) return '1s'
70
- if (s < 60) return `${s}s`
71
- const m = Math.floor(s / 60)
72
- const rem = s % 60
73
- return `${m}m${rem > 0 ? rem + 's' : ''}`
71
+ const totalSec = Math.max(0, ms / 1000)
72
+ if (totalSec < 60) return `${totalSec.toFixed(2)}s`
73
+ const m = Math.floor(totalSec / 60)
74
+ const s = totalSec % 60
75
+ return `${m}m ${s.toFixed(2)}s`
74
76
  }
75
77
 
76
- function ChipRow({ items, value, onChange, label }) {
78
+ /**
79
+ * 📖 FilterGroup — collapsible chip selector. Shows label + active value as a compact
80
+ * trigger. Click to expand a dropdown with all options grouped as a segmented control.
81
+ * Click outside or pick a value to collapse. Designed to keep the filter bar minimal
82
+ * by default while exposing the full chip set on demand.
83
+ */
84
+ function FilterGroup({ label, items, value, onChange, colorMap }) {
85
+ const [open, setOpen] = useState(false)
86
+ const ref = useRef(null)
87
+
88
+ const close = useCallback(() => setOpen(false), [])
89
+
90
+ useEffect(() => {
91
+ if (!open) return
92
+ const handler = (e) => {
93
+ if (ref.current && !ref.current.contains(e.target)) close()
94
+ }
95
+ const keyHandler = (e) => { if (e.key === 'Escape') close() }
96
+ document.addEventListener('mousedown', handler)
97
+ document.addEventListener('keydown', keyHandler)
98
+ return () => {
99
+ document.removeEventListener('mousedown', handler)
100
+ document.removeEventListener('keydown', keyHandler)
101
+ }
102
+ }, [open, close])
103
+
104
+ const activeItem = items.find(i => i.key === value)
105
+ const activeLabel = activeItem?.label ?? value ?? 'All'
106
+ const isFiltered = value !== 'all'
107
+
77
108
  return (
78
- <div className={styles.group}>
79
- {label && <label className={styles.filterLabel}>{label}</label>}
80
- <div className={styles.chipRow}>
81
- {items.map((item) => (
82
- <button
83
- key={item.key}
84
- className={`${styles.chip} ${value === item.key ? styles.chipActive : ''}`}
85
- onClick={() => onChange(item.key)}
86
- title={item.hint || item.label}
87
- >
88
- {item.label}
89
- </button>
90
- ))}
91
- </div>
109
+ <div className={styles.filterGroup} ref={ref}>
110
+ <button
111
+ className={`${styles.filterTrigger} ${isFiltered ? styles.filterTriggerActive : ''} ${open ? styles.filterTriggerExpanded : ''}`}
112
+ onClick={() => setOpen(!open)}
113
+ title={`${label}: ${activeLabel}`}
114
+ aria-expanded={open}
115
+ aria-haspopup="listbox"
116
+ >
117
+ <span className={styles.filterTriggerLabel}>{label}</span>
118
+ <span className={styles.filterTriggerSep}>:</span>
119
+ <span className={styles.filterTriggerValue}>{activeLabel}</span>
120
+ <IconChevronDown
121
+ size={12}
122
+ stroke={2}
123
+ className={`${styles.filterTriggerChevron} ${open ? styles.chevronOpen : ''}`}
124
+ />
125
+ </button>
126
+ {open && (
127
+ <div className={styles.filterDropdown} role="listbox">
128
+ <div className={styles.filterChipRow}>
129
+ {items.map((item, i) => {
130
+ const active = value === item.key
131
+ const customColor = colorMap?.[item.key]
132
+ return (
133
+ <button
134
+ key={item.key}
135
+ role="option"
136
+ aria-selected={active}
137
+ className={`${styles.filterChip} ${active ? styles.filterChipActive : ''}`}
138
+ style={active && customColor ? { '--chip-active-color': customColor } : {}}
139
+ onClick={() => { onChange(item.key); close() }}
140
+ title={item.hint || item.label}
141
+ >
142
+ {item.label}
143
+ </button>
144
+ )
145
+ })}
146
+ </div>
147
+ </div>
148
+ )}
92
149
  </div>
93
150
  )
94
151
  }
@@ -121,7 +178,7 @@ export default function FilterBar({
121
178
  setCountdown(rem > 0 ? rem : 0)
122
179
  }
123
180
  tick()
124
- const id = setInterval(tick, 1000)
181
+ const id = setInterval(tick, 100)
125
182
  return () => clearInterval(id)
126
183
  }, [nextPingAt])
127
184
 
@@ -167,10 +224,10 @@ export default function FilterBar({
167
224
  </div>
168
225
  )}
169
226
 
170
- <ChipRow label="Tier" items={TIERS} value={filterTier} onChange={setFilterTier} />
171
- <ChipRow label="Status" items={STATUSES} value={filterStatus} onChange={setFilterStatus} />
172
- <ChipRow label="Verdict" items={VERDICTS} value={filterVerdict} onChange={setFilterVerdict} />
173
- <ChipRow label="Health" items={HEALTHS} value={filterHealth} onChange={setFilterHealth} />
227
+ <FilterGroup label="Tier" items={TIERS} value={filterTier} onChange={setFilterTier} />
228
+ <FilterGroup label="Status" items={STATUSES} value={filterStatus} onChange={setFilterStatus} />
229
+ <FilterGroup label="Verdict" items={VERDICTS} value={filterVerdict} onChange={setFilterVerdict} />
230
+ <FilterGroup label="Health" items={HEALTHS} value={filterHealth} onChange={setFilterHealth} />
174
231
 
175
232
  <div className={styles.group}>
176
233
  <label className={styles.filterLabel} htmlFor="visibility-select">Visibility</label>
@@ -189,19 +246,12 @@ export default function FilterBar({
189
246
  </div>
190
247
 
191
248
  <div className={styles.group}>
192
- <label className={styles.filterLabel} htmlFor="provider-select">Provider</label>
193
- <select
194
- id="provider-select"
195
- className={styles.providerSelect}
249
+ <label className={styles.filterLabel}>Provider</label>
250
+ <ProviderDropdown
251
+ providers={providers}
196
252
  value={filterProvider}
197
- onChange={(e) => setFilterProvider(e.target.value)}
198
- aria-label="Filter by provider"
199
- >
200
- <option value="all">All Providers</option>
201
- {providers.map((p) => (
202
- <option key={p.key} value={p.key}>{p.name} ({p.count})</option>
203
- ))}
204
- </select>
253
+ onChange={setFilterProvider}
254
+ />
205
255
  </div>
206
256
 
207
257
  <div className={styles.group}>
@@ -246,23 +296,14 @@ export default function FilterBar({
246
296
  </button>
247
297
  )}
248
298
 
249
- {/* ── Ping interval selector ── */}
250
- <div className={styles.group}>
251
- <label className={styles.filterLabel}>Ping</label>
252
- <div className={styles.pingRow}>
253
- {PING_MODES.map((m) => (
254
- <button
255
- key={m.key}
256
- className={`${styles.pingBtn} ${pingMode === m.key ? styles.pingBtnActive : ''}`}
257
- style={pingMode === m.key ? { '--ping-active-color': m.color } : {}}
258
- onClick={() => setPingMode(m.key)}
259
- title={`${m.interval} interval`}
260
- >
261
- {m.label}
262
- </button>
263
- ))}
264
- </div>
265
- </div>
299
+ {/* ── Ping interval selector (collapsible group) ── */}
300
+ <FilterGroup
301
+ label="Ping"
302
+ items={PING_MODES}
303
+ value={pingMode}
304
+ onChange={setPingMode}
305
+ colorMap={Object.fromEntries(PING_MODES.map(m => [m.key, m.color]))}
306
+ />
266
307
 
267
308
  {/* ── Next ping countdown (TUI parity: always show the delay) ──
268
309
  📖 The TUI footer always renders `next : Xs` regardless of whether
@@ -275,7 +316,6 @@ export default function FilterBar({
275
316
  <div className={styles.nextPing} title="Next ping countdown">
276
317
  <span className={styles.nextPingLabel}>next ping in</span>
277
318
  <span className={styles.nextPingTime}>{countdownDisplay ?? '—'}</span>
278
- {isPinging && <span className={styles.pingingDot} aria-hidden="true" />}
279
319
  </div>
280
320
  </div>
281
321
  </section>
@@ -24,19 +24,112 @@
24
24
  white-space: nowrap;
25
25
  }
26
26
 
27
- /* ── Chip rows (TUI cycle behavior) ── */
28
- .chipRow { display: flex; gap: 3px; }
29
- .chip {
30
- font-size: 11px; font-weight: 600; font-family: var(--font-mono);
27
+ /* ── Collapsible filter groups (Tier, Status, Verdict, Health) ── */
28
+ .filterGroup {
29
+ position: relative;
30
+ }
31
+
32
+ .filterTrigger {
33
+ display: inline-flex; align-items: center; gap: 4px;
34
+ font-size: 11px; font-family: var(--font-mono);
31
35
  padding: 4px 8px; border-radius: 5px;
32
- border: 1px solid var(--color-border); background: var(--color-surface);
33
- color: var(--color-text-muted); cursor: pointer; transition: all 150ms;
36
+ border: 1px solid var(--color-border);
37
+ background: var(--color-surface);
38
+ color: var(--color-text-muted);
39
+ cursor: pointer; transition: all 150ms;
40
+ white-space: nowrap;
41
+ user-select: none;
42
+ }
43
+ .filterTrigger:hover {
44
+ background: var(--color-bg-hover);
45
+ color: var(--color-text);
46
+ border-color: var(--color-text-muted);
47
+ }
48
+ .filterTriggerActive {
49
+ border-color: var(--color-accent);
50
+ color: var(--color-text);
51
+ }
52
+ .filterTriggerExpanded {
53
+ border-color: var(--color-accent);
54
+ background: var(--color-bg-hover);
55
+ color: var(--color-text);
56
+ border-bottom-left-radius: 0;
57
+ border-bottom-right-radius: 0;
58
+ z-index: 101;
59
+ }
60
+
61
+ .filterTriggerLabel {
62
+ font-size: 10px; font-weight: 700;
63
+ text-transform: uppercase; letter-spacing: 0.5px;
64
+ color: var(--color-text-muted);
65
+ }
66
+ .filterTriggerSep {
67
+ color: var(--color-border);
68
+ margin: 0 -2px;
69
+ }
70
+ .filterTriggerValue {
71
+ font-weight: 700;
72
+ color: var(--color-text);
73
+ }
74
+ .filterTriggerActive .filterTriggerValue {
75
+ color: var(--color-accent);
76
+ }
77
+
78
+ .filterTriggerChevron {
79
+ flex-shrink: 0;
80
+ transition: transform 200ms ease;
81
+ opacity: 0.6;
82
+ }
83
+ .chevronOpen {
84
+ transform: rotate(180deg);
85
+ opacity: 1;
86
+ }
87
+
88
+ .filterDropdown {
89
+ position: absolute; top: 100%; left: 0;
90
+ z-index: 100;
91
+ padding: 6px 8px 7px;
92
+ background: var(--color-surface);
93
+ border: 1px solid var(--color-accent);
94
+ border-top: none;
95
+ border-radius: 0 0 6px 6px;
96
+ box-shadow: 0 8px 24px rgba(0,0,0,0.3);
97
+ min-width: max-content;
98
+ }
99
+
100
+ .filterChipRow {
101
+ display: flex;
102
+ gap: 0;
103
+ }
104
+
105
+ .filterChip {
106
+ font-size: 11px; font-weight: 600; font-family: var(--font-mono);
107
+ padding: 4px 9px;
108
+ border: 1px solid var(--color-border);
109
+ background: var(--color-bg-card);
110
+ color: var(--color-text-muted);
111
+ cursor: pointer; transition: all 120ms;
34
112
  white-space: nowrap;
113
+ margin-left: -1px;
35
114
  }
36
- .chip:hover { background: var(--color-bg-hover); color: var(--color-text); }
37
- .chipActive {
38
- background: var(--color-accent) !important; color: var(--color-bg) !important;
39
- border-color: var(--color-accent) !important; font-weight: 700;
115
+ .filterChip:first-child {
116
+ border-radius: 4px 0 0 4px;
117
+ margin-left: 0;
118
+ }
119
+ .filterChip:last-child {
120
+ border-radius: 0 4px 4px 0;
121
+ }
122
+ .filterChip:hover {
123
+ background: var(--color-bg-hover);
124
+ color: var(--color-text);
125
+ z-index: 1;
126
+ }
127
+ .filterChipActive {
128
+ background: var(--chip-active-color, var(--color-accent)) !important;
129
+ color: #fff !important;
130
+ border-color: var(--chip-active-color, var(--color-accent)) !important;
131
+ z-index: 2;
132
+ font-weight: 700;
40
133
  }
41
134
 
42
135
  /* ── Select inputs (provider, visibility) ── */
@@ -121,23 +214,6 @@
121
214
  }
122
215
  .customFilterClear:hover { background: var(--color-accent); color: var(--color-bg); }
123
216
 
124
- /* ── Ping interval selector ── */
125
- .pingRow { display: flex; gap: 3px; }
126
- .pingBtn {
127
- font-size: 11px; font-weight: 600; font-family: var(--font-mono);
128
- padding: 4px 8px; border-radius: 5px;
129
- border: 1px solid var(--color-border); background: var(--color-surface);
130
- color: var(--color-text-muted); cursor: pointer; transition: all 150ms;
131
- white-space: nowrap;
132
- }
133
- .pingBtn:hover { background: var(--color-bg-hover); color: var(--color-text); border-color: var(--color-text-muted); }
134
- .pingBtnActive {
135
- background: var(--color-bg-hover) !important;
136
- color: var(--ping-active-color, var(--color-accent)) !important;
137
- border-color: var(--ping-active-color, var(--color-accent)) !important;
138
- font-weight: 700;
139
- }
140
-
141
217
  /* ── Next ping countdown (TUI parity) ── */
142
218
  .nextPing {
143
219
  display: flex; align-items: center; gap: 6px;
@@ -157,12 +233,6 @@
157
233
  text-align: right;
158
234
  font-variant-numeric: tabular-nums;
159
235
  }
160
- .pingingDot {
161
- width: 7px; height: 7px; border-radius: 50%;
162
- background: #ff4466; animation: pulseDot 0.6s ease-in-out infinite;
163
- flex-shrink: 0;
164
- }
165
-
166
236
  @keyframes pulseDot {
167
237
  0%, 100% { box-shadow: 0 0 0 0 var(--color-accent-glow); }
168
238
  50% { box-shadow: 0 0 0 6px transparent; }