free-coding-models 0.5.1 → 0.5.4

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.
Files changed (122) hide show
  1. package/README.md +31 -5
  2. package/changelog/v0.5.4.md +24 -0
  3. package/package.json +5 -2
  4. package/sources.js +2 -0
  5. package/src/tui/key-handler.js +1 -1
  6. package/web/assets/providers/aistudio/aistudio-text.svg +1 -0
  7. package/web/assets/providers/aistudio/aistudio.svg +1 -0
  8. package/web/assets/providers/cerebras/cerebras-brand-color.svg +1 -0
  9. package/web/assets/providers/cerebras/cerebras-brand.svg +1 -0
  10. package/web/assets/providers/cerebras/cerebras-color.svg +1 -0
  11. package/web/assets/providers/cerebras/cerebras-text.svg +1 -0
  12. package/web/assets/providers/cerebras/cerebras.svg +1 -0
  13. package/web/assets/providers/cloudflare/cloudflare-color.svg +1 -0
  14. package/web/assets/providers/cloudflare/cloudflare-text.svg +1 -0
  15. package/web/assets/providers/cloudflare/cloudflare.svg +1 -0
  16. package/web/assets/providers/gemini/gemini-color.svg +1 -0
  17. package/web/assets/providers/gemini/gemini-text.svg +1 -0
  18. package/web/assets/providers/gemini/gemini.svg +1 -0
  19. package/web/assets/providers/github/github-text.svg +1 -0
  20. package/web/assets/providers/github/github.svg +1 -0
  21. package/web/assets/providers/groq/groq-text.svg +1 -0
  22. package/web/assets/providers/groq/groq.svg +1 -0
  23. package/web/assets/providers/mistral/mistral-color.svg +1 -0
  24. package/web/assets/providers/mistral/mistral-text.svg +1 -0
  25. package/web/assets/providers/mistral/mistral.svg +1 -0
  26. package/web/assets/providers/nvidia/nvidia-color.svg +1 -0
  27. package/web/assets/providers/nvidia/nvidia-text.svg +1 -0
  28. package/web/assets/providers/nvidia/nvidia.svg +1 -0
  29. package/web/assets/providers/opencode/opencode-text.svg +1 -0
  30. package/web/assets/providers/opencode/opencode.svg +1 -0
  31. package/web/assets/providers/openrouter/openrouter (1).svg +1 -0
  32. package/web/assets/providers/openrouter/openrouter-text.svg +1 -0
  33. package/web/assets/providers/openrouter/openrouter.svg +1 -0
  34. package/web/assets/providers/ovhcloud/ovhcloud.svg +12 -0
  35. package/web/assets/providers/qwen/qwen-color.svg +1 -0
  36. package/web/assets/providers/qwen/qwen-text.svg +1 -0
  37. package/web/assets/providers/qwen/qwen.svg +1 -0
  38. package/web/assets/providers/sambanova/sambanova-color.svg +1 -0
  39. package/web/assets/providers/sambanova/sambanova-text.svg +1 -0
  40. package/web/assets/providers/sambanova/sambanova.svg +1 -0
  41. package/web/assets/providers/scalewaylogo/ScalewayLogo.svg +13 -0
  42. package/web/assets/providers/zai/zai-text.svg +1 -0
  43. package/web/assets/providers/zai/zai.svg +1 -0
  44. package/web/dist/assets/index-BoWmUveV.js +39 -0
  45. package/web/dist/assets/index-BrpHevg4.css +1 -0
  46. package/web/dist/favicon.ico +0 -0
  47. package/web/dist/favicons/apple-touch-icon.png +0 -0
  48. package/web/dist/favicons/browserconfig.xml +12 -0
  49. package/web/dist/favicons/favicon-16x16.png +0 -0
  50. package/web/dist/favicons/favicon-192x192.png +0 -0
  51. package/web/dist/favicons/favicon-32x32.png +0 -0
  52. package/web/dist/favicons/favicon-48x48.png +0 -0
  53. package/web/dist/favicons/favicon-512x512.png +0 -0
  54. package/web/dist/favicons/favicon-96x96.png +0 -0
  55. package/web/dist/favicons/favicon.ico +0 -0
  56. package/web/dist/favicons/mstile-150x150.png +0 -0
  57. package/web/dist/favicons/mstile-310x150.png +0 -0
  58. package/web/dist/favicons/mstile-310x310.png +0 -0
  59. package/web/dist/favicons/mstile-512x512.png +0 -0
  60. package/web/dist/favicons/mstile-70x70.png +0 -0
  61. package/web/dist/favicons/site.webmanifest +25 -0
  62. package/web/dist/index.html +42 -3
  63. package/web/index.html +40 -1
  64. package/web/public/favicon.ico +0 -0
  65. package/web/public/favicons/apple-touch-icon.png +0 -0
  66. package/web/public/favicons/browserconfig.xml +12 -0
  67. package/web/public/favicons/favicon-16x16.png +0 -0
  68. package/web/public/favicons/favicon-192x192.png +0 -0
  69. package/web/public/favicons/favicon-32x32.png +0 -0
  70. package/web/public/favicons/favicon-48x48.png +0 -0
  71. package/web/public/favicons/favicon-512x512.png +0 -0
  72. package/web/public/favicons/favicon-96x96.png +0 -0
  73. package/web/public/favicons/favicon.ico +0 -0
  74. package/web/public/favicons/mstile-150x150.png +0 -0
  75. package/web/public/favicons/mstile-310x150.png +0 -0
  76. package/web/public/favicons/mstile-310x310.png +0 -0
  77. package/web/public/favicons/mstile-512x512.png +0 -0
  78. package/web/public/favicons/mstile-70x70.png +0 -0
  79. package/web/public/favicons/site.webmanifest +25 -0
  80. package/web/server.js +71 -1
  81. package/web/src/App.jsx +209 -89
  82. package/web/src/components/analytics/AnalyticsView.jsx +30 -2
  83. package/web/src/components/analytics/AnalyticsView.module.css +4 -0
  84. package/web/src/components/atoms/AILatencyCell.module.css +9 -1
  85. package/web/src/components/atoms/HealthCell.jsx +113 -28
  86. package/web/src/components/atoms/HealthCell.module.css +43 -2
  87. package/web/src/components/atoms/LastPingCell.module.css +10 -0
  88. package/web/src/components/atoms/NoKeyIcon.jsx +65 -0
  89. package/web/src/components/atoms/NoKeyIcon.module.css +30 -0
  90. package/web/src/components/atoms/ProviderLogo.jsx +188 -0
  91. package/web/src/components/atoms/ProviderLogo.module.css +103 -0
  92. package/web/src/components/atoms/Sparkline.jsx +21 -2
  93. package/web/src/components/atoms/StabilityCell.module.css +5 -0
  94. package/web/src/components/atoms/StatusDot.module.css +7 -0
  95. package/web/src/components/atoms/TPSCell.module.css +7 -1
  96. package/web/src/components/atoms/TierBadge.module.css +11 -0
  97. package/web/src/components/atoms/Toast.module.css +6 -0
  98. package/web/src/components/atoms/VerdictBadge.module.css +12 -0
  99. package/web/src/components/dashboard/DetailPanel.jsx +63 -2
  100. package/web/src/components/dashboard/DetailPanel.module.css +130 -0
  101. package/web/src/components/dashboard/FilterBar.jsx +146 -53
  102. package/web/src/components/dashboard/FilterBar.module.css +138 -58
  103. package/web/src/components/dashboard/ModelTable.jsx +235 -30
  104. package/web/src/components/dashboard/ModelTable.module.css +205 -16
  105. package/web/src/components/layout/Header.jsx +140 -12
  106. package/web/src/components/layout/Header.module.css +191 -27
  107. package/web/src/components/palette/CommandPalette.jsx +146 -0
  108. package/web/src/components/palette/CommandPalette.module.css +121 -0
  109. package/web/src/global.css +52 -15
  110. package/web/src/hooks/useColumnSizing.js +211 -0
  111. package/web/src/hooks/useFavorites.js +150 -0
  112. package/web/src/hooks/useFilter.js +157 -24
  113. package/web/src/hooks/useTheme.js +72 -16
  114. package/web/src/hooks/useUrlState.js +93 -0
  115. package/web/dist/assets/index-ByGf4Kq-.js +0 -14
  116. package/web/dist/assets/index-Ds7wmHBv.css +0 -1
  117. package/web/src/components/dashboard/StatsBar.jsx +0 -42
  118. package/web/src/components/dashboard/StatsBar.module.css +0 -28
  119. package/web/src/components/layout/Sidebar.jsx +0 -50
  120. package/web/src/components/layout/Sidebar.module.css +0 -86
  121. package/web/src/components/map/MapView.jsx +0 -17
  122. package/web/src/components/map/MapView.module.css +0 -25
package/web/src/App.jsx CHANGED
@@ -1,17 +1,25 @@
1
1
  /**
2
2
  * @file web/src/App.jsx
3
- * @description Root application component — orchestrates all views, layout, Socket.IO connection, and global state.
4
- * 📖 Manages current view (dashboard/settings/analytics), theme toggle, search, filters,
5
- * selected model for detail panel, export modal, toast notifications, ping mode, and benchmark.
6
- * Uses useSocket for live data, useFilter for model filtering/sorting, useTheme for dark/light.
3
+ * @description Root application component — orchestrates all views, header nav, Socket.IO
4
+ * connection, toast notifications, and global state. M1 layout refactor: no more left
5
+ * sidebar. Navigation lives in the Header (always-visible nav + overflow menu).
6
+ *
7
+ * 📖 M1 also wires in:
8
+ * - StatsBar above the ModelTable (was orphaned)
9
+ * - URL deep-linking (read-only — ?tier=…&sort=…&origin=…)
10
+ * - ⌘K command palette (placeholder modal for M1, real palette in M2)
11
+ * - Favorites hook (per-row star + reorder + display mode toggle)
12
+ * - Per-model benchmark button (uses existing /api/benchmark)
13
+ *
7
14
  * @functions App → root component with all state and layout composition
8
15
  */
9
- import { useState, useCallback, useEffect } from 'react'
16
+ import { useState, useCallback, useEffect, useRef } from 'react'
10
17
  import { useSocket } from './hooks/useSocket.js'
11
18
  import { useFilter } from './hooks/useFilter.js'
12
19
  import { useTheme } from './hooks/useTheme.js'
20
+ import { useFavorites } from './hooks/useFavorites.js'
21
+ import { useUrlState } from './hooks/useUrlState.js'
13
22
  import Header from './components/layout/Header.jsx'
14
- import Sidebar from './components/layout/Sidebar.jsx'
15
23
  import Footer from './components/layout/Footer.jsx'
16
24
  import FilterBar from './components/dashboard/FilterBar.jsx'
17
25
  import ModelTable from './components/dashboard/ModelTable.jsx'
@@ -19,18 +27,43 @@ import DetailPanel from './components/dashboard/DetailPanel.jsx'
19
27
  import ExportModal from './components/dashboard/ExportModal.jsx'
20
28
  import SettingsView from './components/settings/SettingsView.jsx'
21
29
  import AnalyticsView from './components/analytics/AnalyticsView.jsx'
22
- import MapView from './components/map/MapView.jsx'
30
+ import CommandPalette from './components/palette/CommandPalette.jsx'
23
31
  import ToastContainer from './components/atoms/ToastContainer.jsx'
24
32
 
25
33
  let toastIdCounter = 0
26
34
 
35
+ // 📖 Map current view to the header nav id. M1 only ships dashboard/settings/analytics;
36
+ // 📖 recommend/router/help/changelog/install-endpoints/installed-models are wired but
37
+ // 📖 still show a "Coming in M2/M3/M4" toast from the header for now.
38
+ const VIEW_TO_NAV = {
39
+ dashboard: 'dashboard',
40
+ settings: 'settings',
41
+ analytics: 'analytics',
42
+ recommend: 'recommend',
43
+ router: 'router',
44
+ help: 'help',
45
+ changelog: 'changelog',
46
+ 'install-endpoints': 'install-endpoints',
47
+ 'installed-models': 'installed-models',
48
+ }
49
+
27
50
  export default function App() {
28
51
  const { models, connected, nextPingAt, isPinging, pingMode, globalBenchmarkRunning, globalBenchmarkTotal, globalBenchmarkCompleted } = useSocket()
29
- const { theme, toggle: toggleTheme } = useTheme()
52
+ const { theme, cycle: cycleTheme } = useTheme()
30
53
  const [currentView, setCurrentView] = useState('dashboard')
31
54
  const [selectedModel, setSelectedModel] = useState(null)
32
55
  const [exportOpen, setExportOpen] = useState(false)
56
+ const [paletteOpen, setPaletteOpen] = useState(false)
33
57
  const [toasts, setToasts] = useState([])
58
+ const lastActivityRef = useRef(Date.now())
59
+
60
+ // 📖 URL deep-linking (M1 = read-only hydration on mount; write-back lands in M2).
61
+ // 📖 Syncs `currentView`, filter state, and selection from query params so users
62
+ // 📖 can share pre-configured dashboard URLs.
63
+ useUrlState({
64
+ currentView, setCurrentView,
65
+ filterState: null, // wired in M2
66
+ })
34
67
 
35
68
  const {
36
69
  filtered,
@@ -39,8 +72,17 @@ export default function App() {
39
72
  filterProvider, setFilterProvider,
40
73
  searchQuery, setSearchQuery,
41
74
  sortColumn, sortDirection, toggleSort,
75
+ filterVerdict, setFilterVerdict,
76
+ filterHealth, setFilterHealth,
77
+ visibilityMode, setVisibilityMode,
78
+ customTextFilter, setCustomTextFilter,
79
+ resetView,
42
80
  } = useFilter(models)
43
81
 
82
+ // 📖 Favorites — single source of truth shared with the TUI via ~/.free-coding-models.json.
83
+ const favorites = useFavorites({ models })
84
+
85
+ // 📖 Build the provider list for the FilterBar dropdown.
44
86
  const providers = (() => {
45
87
  const map = {}
46
88
  models.forEach((m) => {
@@ -50,18 +92,13 @@ export default function App() {
50
92
  return Object.values(map).sort((a, b) => a.name.localeCompare(b.name))
51
93
  })()
52
94
 
53
- // ── Global AI Speed Benchmark (Ctrl+U equivalent) ──
95
+ // ── Global benchmark (AI Speed Test) ─────────────────────────────────────
54
96
  const handleBenchmark = useCallback(async () => {
55
- if (globalBenchmarkRunning) {
56
- console.warn('[Benchmark] Global benchmark already in progress.')
57
- return
58
- }
97
+ if (globalBenchmarkRunning) return
59
98
  try {
60
99
  await fetch('/api/global-benchmark', {
61
100
  method: 'POST',
62
101
  headers: { 'Content-Type': 'application/json' },
63
- // 📖 Match the user's current table, not the hidden catalog. If filters/search
64
- // 📖 hide a model, the benchmark intentionally skips it.
65
102
  body: JSON.stringify({
66
103
  models: filtered.map((model) => ({ providerKey: model.providerKey, modelId: model.modelId })),
67
104
  }),
@@ -71,118 +108,186 @@ export default function App() {
71
108
  }
72
109
  }, [filtered, globalBenchmarkRunning])
73
110
 
111
+ // ── Per-model benchmark (M1 parity with TUI Ctrl+A) ──────────────────────
112
+ const handleBenchmarkRow = useCallback(async (model) => {
113
+ try {
114
+ const resp = await fetch('/api/benchmark', {
115
+ method: 'POST',
116
+ headers: { 'Content-Type': 'application/json' },
117
+ body: JSON.stringify({ providerKey: model.providerKey, modelId: model.modelId }),
118
+ })
119
+ if (!resp.ok && resp.status !== 202) {
120
+ const err = await resp.json().catch(() => ({}))
121
+ addToast?.(`Benchmark failed: ${err?.error || resp.statusText}`, 'error')
122
+ }
123
+ } catch (err) {
124
+ console.error('[Benchmark] per-row failed:', err.message)
125
+ }
126
+ }, [])
127
+
128
+ // ── Toast helpers ────────────────────────────────────────────────────────
74
129
  const addToast = useCallback((message, type = 'info') => {
75
130
  const id = ++toastIdCounter
76
131
  setToasts((prev) => [...prev, { id, message, type }])
132
+ setTimeout(() => {
133
+ setToasts((prev) => prev.filter((t) => t.id !== id))
134
+ }, 4000)
77
135
  }, [])
78
136
 
79
137
  const dismissToast = useCallback((id) => {
80
138
  setToasts((prev) => prev.filter((t) => t.id !== id))
81
139
  }, [])
82
140
 
141
+ // ── Selection / detail panel ─────────────────────────────────────────────
83
142
  const handleSelectModel = useCallback((model) => {
84
143
  setSelectedModel(model)
144
+ lastActivityRef.current = Date.now()
85
145
  }, [])
86
146
 
87
147
  const handleCloseDetail = useCallback(() => setSelectedModel(null), [])
88
148
 
89
- // ── Ping mode: sync with backend ──
149
+ // ── Ping mode change server → broadcast ───────────────────────────────
90
150
  const handlePingModeChange = useCallback(async (mode) => {
91
151
  try {
92
152
  await fetch(`/api/ping-mode?action=${mode}`, { method: 'POST' })
93
153
  } catch {}
94
154
  }, [])
95
155
 
156
+ // ── Navigation handler (Header nav + overflow menu) ──────────────────────
157
+ const handleNavigate = useCallback((viewId) => {
158
+ setCurrentView(VIEW_TO_NAV[viewId] || viewId)
159
+ lastActivityRef.current = Date.now()
160
+ }, [])
161
+
162
+ // ── Reset view (N key equivalent) ────────────────────────────────────────
163
+ const handleResetView = useCallback(() => {
164
+ resetView()
165
+ setSearchQuery('')
166
+ addToast('View reset to defaults.', 'info')
167
+ }, [resetView, setSearchQuery, addToast])
168
+
169
+ // ── Keyboard shortcuts: only ⌘K / Ctrl+P for the palette ────────────────
96
170
  useEffect(() => {
97
171
  const handler = (e) => {
98
- if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
172
+ const cmdOrCtrl = e.metaKey || e.ctrlKey
173
+ // ⌘K / Ctrl+K → toggle command palette (the Web's only global shortcut)
174
+ if (cmdOrCtrl && (e.key === 'k' || e.key === 'K')) {
99
175
  e.preventDefault()
100
- if (currentView !== 'dashboard') setCurrentView('dashboard')
176
+ setPaletteOpen((o) => !o)
177
+ return
101
178
  }
179
+ // Ctrl+P is also accepted as a TUI-style alias for the same palette.
180
+ if (cmdOrCtrl && (e.key === 'p' || e.key === 'P') && !e.shiftKey) {
181
+ e.preventDefault()
182
+ setPaletteOpen((o) => !o)
183
+ return
184
+ }
185
+ // Esc closes whatever is open.
102
186
  if (e.key === 'Escape') {
103
- setSelectedModel(null)
104
- setExportOpen(false)
187
+ if (paletteOpen) { setPaletteOpen(false); return }
188
+ if (selectedModel) { setSelectedModel(null); return }
189
+ if (exportOpen) { setExportOpen(false); return }
105
190
  }
106
191
  }
107
192
  window.addEventListener('keydown', handler)
108
193
  return () => window.removeEventListener('keydown', handler)
109
- }, [currentView])
194
+ }, [paletteOpen, selectedModel, exportOpen])
195
+
196
+ // 📖 Reset view if URL contains the reset flag.
197
+ useEffect(() => {
198
+ const params = new URLSearchParams(window.location.search)
199
+ if (params.get('reset') === '1') {
200
+ handleResetView()
201
+ const url = new URL(window.location.href)
202
+ url.searchParams.delete('reset')
203
+ window.history.replaceState({}, '', url.toString())
204
+ }
205
+ // eslint-disable-next-line react-hooks/exhaustive-deps
206
+ }, [])
110
207
 
111
208
  return (
112
209
  <>
113
- <Sidebar
114
- currentView={currentView}
115
- onNavigate={setCurrentView}
116
- onToggleTheme={toggleTheme}
117
- theme={theme}
118
- />
210
+ <div className="app-shell">
211
+ <Header
212
+ searchQuery={searchQuery}
213
+ onSearchChange={setSearchQuery}
214
+ currentView={currentView}
215
+ onNavigate={handleNavigate}
216
+ onToggleTheme={cycleTheme}
217
+ onOpenExport={() => setExportOpen(true)}
218
+ onOpenCommandPalette={() => setPaletteOpen(true)}
219
+ onBenchmark={handleBenchmark}
220
+ benchmarkRunning={globalBenchmarkRunning}
221
+ benchmarkTotal={globalBenchmarkTotal}
222
+ benchmarkCompleted={globalBenchmarkCompleted}
223
+ modelsCount={filtered.length}
224
+ theme={theme}
225
+ onToast={addToast}
226
+ />
227
+
228
+ <div className="app-content">
229
+ {currentView === 'dashboard' && (
230
+ <div className="view">
231
+ <FilterBar
232
+ filterTier={filterTier}
233
+ setFilterTier={setFilterTier}
234
+ filterStatus={filterStatus}
235
+ setFilterStatus={setFilterStatus}
236
+ filterProvider={filterProvider}
237
+ setFilterProvider={setFilterProvider}
238
+ filterVerdict={filterVerdict}
239
+ setFilterVerdict={setFilterVerdict}
240
+ filterHealth={filterHealth}
241
+ setFilterHealth={setFilterHealth}
242
+ visibilityMode={visibilityMode}
243
+ setVisibilityMode={setVisibilityMode}
244
+ customTextFilter={customTextFilter}
245
+ setCustomTextFilter={setCustomTextFilter}
246
+ searchQuery={searchQuery}
247
+ onResetView={handleResetView}
248
+ providers={providers}
249
+ pingMode={pingMode}
250
+ setPingMode={handlePingModeChange}
251
+ nextPingAt={nextPingAt}
252
+ isPinging={isPinging}
253
+ globalBenchmarkRunning={globalBenchmarkRunning}
254
+ globalBenchmarkTotal={globalBenchmarkTotal}
255
+ globalBenchmarkCompleted={globalBenchmarkCompleted}
256
+ />
257
+ <ModelTable
258
+ filtered={filtered}
259
+ onSelectModel={handleSelectModel}
260
+ onBenchmarkRow={handleBenchmarkRow}
261
+ favorites={favorites}
262
+ sortColumn={sortColumn}
263
+ sortDirection={sortDirection}
264
+ onSort={toggleSort}
265
+ />
266
+ </div>
267
+ )}
268
+
269
+ {currentView === 'settings' && (
270
+ <div className="view">
271
+ <SettingsView onToast={addToast} />
272
+ </div>
273
+ )}
274
+
275
+ {currentView === 'analytics' && (
276
+ <div className="view">
277
+ <AnalyticsView models={models} />
278
+ </div>
279
+ )}
119
280
 
120
- <div className="app-content">
121
- {currentView === 'dashboard' && (
122
- <div className="view">
123
- <Header
124
- searchQuery={searchQuery}
125
- onSearchChange={setSearchQuery}
126
- onToggleTheme={toggleTheme}
127
- onOpenSettings={() => setCurrentView('settings')}
128
- onOpenExport={() => setExportOpen(true)}
129
- onBenchmark={handleBenchmark}
130
- benchmarkRunning={globalBenchmarkRunning}
131
- benchmarkTotal={globalBenchmarkTotal}
132
- benchmarkCompleted={globalBenchmarkCompleted}
133
- modelsCount={filtered.length}
134
- theme={theme}
135
- />
136
- <FilterBar
137
- filterTier={filterTier}
138
- setFilterTier={setFilterTier}
139
- filterStatus={filterStatus}
140
- setFilterStatus={setFilterStatus}
141
- filterProvider={filterProvider}
142
- setFilterProvider={setFilterProvider}
143
- providers={providers}
144
- pingMode={pingMode}
145
- setPingMode={handlePingModeChange}
146
- nextPingAt={nextPingAt}
147
- isPinging={isPinging}
148
- globalBenchmarkRunning={globalBenchmarkRunning}
149
- globalBenchmarkTotal={globalBenchmarkTotal}
150
- globalBenchmarkCompleted={globalBenchmarkCompleted}
151
- />
152
- <ModelTable
153
- filtered={filtered}
154
- onSelectModel={handleSelectModel}
155
- sortColumn={sortColumn}
156
- sortDirection={sortDirection}
157
- onSort={toggleSort}
158
- />
159
- </div>
160
- )}
161
-
162
- {currentView === 'settings' && (
163
- <div className="view">
164
- <SettingsView onToast={addToast} />
165
- </div>
166
- )}
167
-
168
- {currentView === 'analytics' && (
169
- <div className="view">
170
- <AnalyticsView models={models} />
171
- </div>
172
- )}
173
-
174
- {currentView === 'map' && (
175
- <div className="view">
176
- <MapView />
177
- </div>
178
- )}
179
-
180
- <Footer />
281
+ <Footer />
282
+ </div>
181
283
  </div>
182
284
 
183
285
  <DetailPanel
184
286
  model={selectedModel}
185
287
  onClose={handleCloseDetail}
288
+ favorites={favorites}
289
+ onBenchmark={handleBenchmarkRow}
290
+ onToast={addToast}
186
291
  />
187
292
 
188
293
  {exportOpen && (
@@ -193,7 +298,22 @@ export default function App() {
193
298
  />
194
299
  )}
195
300
 
301
+ {paletteOpen && (
302
+ <CommandPalette
303
+ onClose={() => setPaletteOpen(false)}
304
+ onNavigate={handleNavigate}
305
+ onCycleTheme={cycleTheme}
306
+ onResetView={handleResetView}
307
+ currentView={currentView}
308
+ theme={theme}
309
+ pingMode={pingMode}
310
+ onSetPingMode={handlePingModeChange}
311
+ onToast={addToast}
312
+ onExport={() => setExportOpen(true)}
313
+ />
314
+ )}
315
+
196
316
  <ToastContainer toasts={toasts} dismissToast={dismissToast} />
197
317
  </>
198
318
  )
199
- }
319
+ }
@@ -9,10 +9,38 @@ import { IconActivity, IconTrophy } from '@tabler/icons-react'
9
9
  import TierBadge from '../atoms/TierBadge.jsx'
10
10
  import styles from './AnalyticsView.module.css'
11
11
 
12
- const TIER_COLORS = {
12
+ // 📖 TIER_COLORS is now derived from CSS custom properties so the chart fills
13
+ // 📖 swap to AA-contrast shades in light mode automatically. The keys stay
14
+ // 📖 the same; the values come from --tier-splus / --tier-s / etc.
15
+ function readTierColor(name, fallback) {
16
+ if (typeof window === 'undefined' || !window.document?.documentElement) return fallback
17
+ const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
18
+ return v || fallback
19
+ }
20
+
21
+ const TIER_COLOR_KEYS = {
22
+ 'S+': '--tier-splus',
23
+ 'S': '--tier-s',
24
+ 'A+': '--tier-aplus',
25
+ 'A': '--tier-a',
26
+ 'A-': '--tier-aminus',
27
+ 'B+': '--tier-bplus',
28
+ 'B': '--tier-b',
29
+ 'C': '--tier-c',
30
+ }
31
+
32
+ const TIER_COLOR_FALLBACKS = {
13
33
  'S+': '#ffd700', S: '#ff8c00', 'A+': '#00c8ff', A: '#3ddc84',
14
34
  'A-': '#7ecf7e', 'B+': '#a8a8c8', B: '#808098', C: '#606078',
15
35
  }
36
+
37
+ // 📖 Resolve the tier color at render time. Memoizing the map per theme switch
38
+ // 📖 would be more efficient, but the cost is one getComputedStyle per tier and
39
+ // 📖 the chart only re-renders when `models` changes, so the extra work is
40
+ // 📖 imperceptible.
41
+ function tierColor(key) {
42
+ return readTierColor(TIER_COLOR_KEYS[key], TIER_COLOR_FALLBACKS[key])
43
+ }
16
44
  const TIERS = ['S+', 'S', 'A+', 'A', 'A-', 'B+', 'B', 'C']
17
45
 
18
46
  export default function AnalyticsView({ models }) {
@@ -102,7 +130,7 @@ export default function AnalyticsView({ models }) {
102
130
  <div key={tier} className={styles.tierItem}>
103
131
  <div className={styles.tierBadge}><TierBadge tier={tier} /></div>
104
132
  <div className={styles.tierBar}>
105
- <div className={styles.tierFill} style={{ width: `${pct}%`, background: TIER_COLORS[tier] }} />
133
+ <div className={styles.tierFill} style={{ width: `${pct}%`, background: tierColor(tier) }} />
106
134
  </div>
107
135
  <span className={styles.tierCount}>{count}</span>
108
136
  </div>
@@ -98,6 +98,10 @@
98
98
  .pctMedium { color: #ffaa00; }
99
99
  .pctSlow { color: #ff4444; }
100
100
 
101
+ :global([data-theme="light"]) .pctFast { color: #008f4d; }
102
+ :global([data-theme="light"]) .pctMedium { color: #b86b00; }
103
+ :global([data-theme="light"]) .pctSlow { color: #c8143a; }
104
+
101
105
  .leaderItem {
102
106
  display: flex;
103
107
  align-items: center;
@@ -40,4 +40,12 @@
40
40
  border-radius: 50%;
41
41
  animation: benchSpin 0.6s linear infinite;
42
42
  flex-shrink: 0;
43
- }
43
+ }
44
+
45
+ /* 📖 Light theme: swap the neon palette for darker, AA-contrast shades. The
46
+ 📖 purple running color stays vibrant since it works on both backgrounds. */
47
+ :global([data-theme="light"]) .fast { color: #008f4d; }
48
+ :global([data-theme="light"]) .medium { color: #b86b00; }
49
+ :global([data-theme="light"]) .slow { color: #c8143a; }
50
+ :global([data-theme="light"]) .badge { color: #0e7490; }
51
+ :global([data-theme="light"]) .runningCell { background: rgba(125, 0, 180, 0.08); }
@@ -1,11 +1,53 @@
1
1
  /**
2
2
  * @file web/src/components/atoms/HealthCell.jsx
3
3
  * @description Detailed health/status column — matches CLI Health column.
4
- * 📖 Shows: UP, 🔑 NO KEY, 🔐 AUTH FAIL, wait, TIMEOUT,
5
- * 🔥 429 TRY LATER, 🚫 404, 💥 500, 🔌 502, 🔒 503, 504, ❌ ERROR.
4
+ * 📖 Shows the current provider status next to a Tabler icon that matches the
5
+ * 📖 state. The icon is color-coded so a quick glance is enough to know if
6
+ * 📖 the row is healthy, slow, down, blocked, etc.
7
+ * 📖
8
+ * 📖 Icon → state mapping:
9
+ * 📖 ✅ up → IconChecks (green)
10
+ * 📖 ⏳ timeout → IconHourglassOff (yellow)
11
+ * 📖 ⏳ pending → IconHourglass (yellow)
12
+ * 📖 🔐 AUTH FAIL → IconLock (red, bold)
13
+ * 📖 🔑 NO KEY → NoKeyIcon (yellow key + red slash)
14
+ * 📖 🔥 429 → IconFlame (red)
15
+ * 📖 🚫 404 → IconError404 (red)
16
+ * 📖 💥 500 → IconAlertOctagon (red)
17
+ * 📖 🔌 502 → IconPlug (red)
18
+ * 📖 🔒 503 → IconCloudOff (red)
19
+ * 📖 ⏰ 504 → IconClock (red)
20
+ * 📖 ❌ unknown → IconBan (red)
6
21
  */
22
+ import {
23
+ IconChecks,
24
+ IconHourglass,
25
+ IconHourglassOff,
26
+ IconLock,
27
+ IconFlame,
28
+ IconError404,
29
+ IconAlertOctagon,
30
+ IconPlug,
31
+ IconCloudOff,
32
+ IconClock,
33
+ IconBan,
34
+ } from '@tabler/icons-react'
35
+ import NoKeyIcon from './NoKeyIcon.jsx'
7
36
  import styles from './HealthCell.module.css'
8
37
 
38
+ const ICON_SIZE = 12
39
+ const ICON_STROKE = 1.8
40
+
41
+ // 📖 Themed icon helper: same color palette as the rest of the Health cell text,
42
+ // 📖 so the icon + label always match. Reading the CSS var at render time keeps
43
+ // 📖 the dark/light palettes aligned with the rest of the dashboard.
44
+ const HEALTH_ICON_COLOR = {
45
+ success: 'var(--color-success, #00ff88)',
46
+ warning: 'var(--color-warning, #ffaa00)',
47
+ danger: 'var(--color-danger, #ff4444)',
48
+ dim: 'var(--color-text-dim, #444)',
49
+ }
50
+
9
51
  const ERROR_LABELS = {
10
52
  '404': '404 NOT FOUND',
11
53
  '410': '410 GONE',
@@ -16,38 +58,81 @@ const ERROR_LABELS = {
16
58
  '504': '504 TIMEOUT',
17
59
  }
18
60
 
19
- const STATUS_EMOJI = {
20
- up: '✅',
21
- timeout: '⏳',
22
- down: '',
23
- pending: '',
24
- noauth: '🔑',
25
- auth_error: '🔐',
61
+ // 📖 Per-HTTP-code icon + color picker. Anything not in this map falls back to
62
+ // 📖 the generic down-state rendering below.
63
+ const ERROR_ICON = {
64
+ '429': { Icon: IconFlame, color: HEALTH_ICON_COLOR.danger },
65
+ '404': { Icon: IconError404, color: HEALTH_ICON_COLOR.danger },
66
+ '500': { Icon: IconAlertOctagon, color: HEALTH_ICON_COLOR.danger },
67
+ '502': { Icon: IconPlug, color: HEALTH_ICON_COLOR.danger },
68
+ '503': { Icon: IconCloudOff, color: HEALTH_ICON_COLOR.danger },
69
+ '504': { Icon: IconClock, color: HEALTH_ICON_COLOR.danger },
26
70
  }
27
71
 
28
72
  function statusLabel(status, httpCode) {
29
- const emoji = STATUS_EMOJI[status] || '?'
30
- if (status === 'noauth') return `${emoji} NO KEY`
31
- if (status === 'auth_error') return `${emoji} AUTH FAIL`
32
- if (status === 'pending') return `${emoji} wait`
33
- if (status === 'timeout') return `${emoji} TIMEOUT`
73
+ if (status === 'noauth') return 'NO KEY'
74
+ if (status === 'auth_error') return 'AUTH FAIL'
75
+ if (status === 'pending') return 'wait'
76
+ if (status === 'timeout') return 'TIMEOUT'
34
77
  if (status === 'down') {
35
- const label = ERROR_LABELS[httpCode] || (httpCode || 'ERR')
36
- const errEmoji = { '429': '🔥', '404': '🚫', '500': '💥', '502': '🔌', '503': '🔒', '504': '⏰' }[httpCode] || '❌'
37
- return `${errEmoji} ${label}`
78
+ return ERROR_LABELS[httpCode] || (httpCode || 'ERROR')
38
79
  }
39
- if (status === 'up') return `${emoji} UP`
40
- return `${emoji} ?`
80
+ if (status === 'up') return 'UP'
81
+ return '?'
82
+ }
83
+
84
+ function statusClass(status) {
85
+ if (status === 'noauth') return styles.noKey
86
+ if (status === 'up') return styles.up
87
+ if (status === 'down') return styles.error
88
+ if (status === 'timeout') return styles.warning
89
+ if (status === 'auth_error') return styles.errorBold
90
+ if (status === 'pending') return styles.warning
91
+ return styles.dim
41
92
  }
42
93
 
43
94
  export default function HealthCell({ status, httpCode }) {
44
95
  const text = statusLabel(status, httpCode)
45
- const cls = status === 'up' ? styles.up
46
- : status === 'down' ? styles.error
47
- : status === 'timeout' ? styles.warning
48
- : status === 'noauth' ? styles.dim
49
- : status === 'auth_error' ? styles.errorBold
50
- : status === 'pending' ? styles.warning
51
- : styles.dim
52
- return <span className={`${styles.cell} ${cls}`}>{text}</span>
53
- }
96
+ const cls = statusClass(status)
97
+ const isNoKey = status === 'noauth'
98
+
99
+ // 📖 Pick the right icon + color combo for this state.
100
+ let Icon = null
101
+ let iconColor = null
102
+ if (status === 'up') {
103
+ Icon = IconChecks
104
+ iconColor = HEALTH_ICON_COLOR.success
105
+ } else if (status === 'timeout') {
106
+ Icon = IconHourglassOff
107
+ iconColor = HEALTH_ICON_COLOR.warning
108
+ } else if (status === 'pending') {
109
+ Icon = IconHourglass
110
+ iconColor = HEALTH_ICON_COLOR.warning
111
+ } else if (status === 'auth_error') {
112
+ Icon = IconLock
113
+ iconColor = HEALTH_ICON_COLOR.danger
114
+ } else if (status === 'down' && ERROR_ICON[httpCode]) {
115
+ Icon = ERROR_ICON[httpCode].Icon
116
+ iconColor = ERROR_ICON[httpCode].color
117
+ } else if (status === 'down') {
118
+ // 📖 Generic "down" fallback (no recognized HTTP code).
119
+ Icon = IconBan
120
+ iconColor = HEALTH_ICON_COLOR.danger
121
+ }
122
+
123
+ return (
124
+ <span className={`${styles.cell} ${cls}`}>
125
+ {isNoKey ? (
126
+ <NoKeyIcon size={15} title="No API key configured for this provider" />
127
+ ) : Icon ? (
128
+ <Icon
129
+ size={ICON_SIZE}
130
+ stroke={ICON_STROKE}
131
+ style={{ color: iconColor, flexShrink: 0 }}
132
+ aria-hidden="true"
133
+ />
134
+ ) : null}
135
+ <span className={isNoKey ? styles.noKeyText : undefined}>{text}</span>
136
+ </span>
137
+ )
138
+ }