free-coding-models 0.5.4 → 0.5.6

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 (33) hide show
  1. package/README.md +8 -5
  2. package/bin/free-coding-models.js +29 -8
  3. package/changelog/v0.5.5.md +16 -0
  4. package/changelog/v0.5.6.md +24 -0
  5. package/package.json +4 -4
  6. package/src/core/changelog-loader.js +5 -1
  7. package/src/core/router-daemon.js +11 -0
  8. package/src/core/updater.js +174 -10
  9. package/src/tui/app.js +11 -31
  10. package/src/tui/render-table.js +9 -3
  11. package/src/tui/tui-state.js +6 -0
  12. package/web/dist/assets/index-Blp9QJev.js +39 -0
  13. package/web/dist/assets/{index-BrpHevg4.css → index-Cz_aCLTR.css} +1 -1
  14. package/web/dist/index.html +2 -2
  15. package/web/server.js +158 -2
  16. package/web/src/App.jsx +107 -58
  17. package/web/src/components/changelog/ChangelogView.jsx +135 -0
  18. package/web/src/components/changelog/ChangelogView.module.css +160 -0
  19. package/web/src/components/help/HelpView.jsx +188 -0
  20. package/web/src/components/help/HelpView.module.css +157 -0
  21. package/web/src/components/layout/Header.jsx +8 -3
  22. package/web/src/components/palette/CommandPalette.jsx +228 -74
  23. package/web/src/components/settings/SettingsView.jsx +281 -8
  24. package/web/src/components/settings/SettingsView.module.css +174 -0
  25. package/web/src/components/update/UpdateChip.jsx +104 -0
  26. package/web/src/components/update/UpdateChip.module.css +146 -0
  27. package/web/src/global.css +15 -0
  28. package/web/src/hooks/urlState.constants.js +25 -0
  29. package/web/src/hooks/useChangelog.js +51 -0
  30. package/web/src/hooks/useSocket.js +3 -0
  31. package/web/src/hooks/useUpdateChecker.js +91 -0
  32. package/web/src/hooks/useUrlState.js +122 -62
  33. package/web/dist/assets/index-BoWmUveV.js +0 -39
package/web/src/App.jsx CHANGED
@@ -1,24 +1,30 @@
1
1
  /**
2
2
  * @file web/src/App.jsx
3
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).
4
+ * connection, toast notifications, and global state. M2 layout: no sidebar, header
5
+ * menu + ⌘K palette, full Settings parity, Help + Changelog modals, UpdateChip,
6
+ * URL write-back.
6
7
  *
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)
8
+ * 📖 M2 additions on top of M1:
9
+ * 📖 - Full command palette (TUI registry via `buildCommandPaletteEntries`)
10
+ * 📖 - HelpView modal (TUI parity help)
11
+ * 📖 - ChangelogView modal (2-phase: index + details)
12
+ * 📖 - UpdateChip in header (polls /api/version, popover with "Update now" + "What's new")
13
+ * 📖 - URL write-back (every filter / sort / view / palette / toolMode change
14
+ * 📖 updates the URL via history.replaceState, debounced at 80ms)
15
+ * 📖 - New Settings rows: theme dropdown, favorites mode toggle, startup AI scan
16
+ * 📖 toggle, shell-env toggle, legacy proxy cleanup button, open Changelog link,
17
+ * 📖 update status row, per-provider test key button
13
18
  *
14
19
  * @functions App → root component with all state and layout composition
15
20
  */
16
- import { useState, useCallback, useEffect, useRef } from 'react'
21
+ import { useState, useCallback, useEffect, useRef, useMemo } from 'react'
17
22
  import { useSocket } from './hooks/useSocket.js'
18
23
  import { useFilter } from './hooks/useFilter.js'
19
24
  import { useTheme } from './hooks/useTheme.js'
20
25
  import { useFavorites } from './hooks/useFavorites.js'
21
26
  import { useUrlState } from './hooks/useUrlState.js'
27
+ import { useUpdateChecker } from './hooks/useUpdateChecker.js'
22
28
  import Header from './components/layout/Header.jsx'
23
29
  import Footer from './components/layout/Footer.jsx'
24
30
  import FilterBar from './components/dashboard/FilterBar.jsx'
@@ -28,23 +34,19 @@ import ExportModal from './components/dashboard/ExportModal.jsx'
28
34
  import SettingsView from './components/settings/SettingsView.jsx'
29
35
  import AnalyticsView from './components/analytics/AnalyticsView.jsx'
30
36
  import CommandPalette from './components/palette/CommandPalette.jsx'
37
+ import HelpView from './components/help/HelpView.jsx'
38
+ import ChangelogView from './components/changelog/ChangelogView.jsx'
39
+ import UpdateChip from './components/update/UpdateChip.jsx'
31
40
  import ToastContainer from './components/atoms/ToastContainer.jsx'
32
41
 
33
42
  let toastIdCounter = 0
34
43
 
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
44
  const VIEW_TO_NAV = {
39
45
  dashboard: 'dashboard',
40
46
  settings: 'settings',
41
47
  analytics: 'analytics',
42
48
  recommend: 'recommend',
43
49
  router: 'router',
44
- help: 'help',
45
- changelog: 'changelog',
46
- 'install-endpoints': 'install-endpoints',
47
- 'installed-models': 'installed-models',
48
50
  }
49
51
 
50
52
  export default function App() {
@@ -54,43 +56,63 @@ export default function App() {
54
56
  const [selectedModel, setSelectedModel] = useState(null)
55
57
  const [exportOpen, setExportOpen] = useState(false)
56
58
  const [paletteOpen, setPaletteOpen] = useState(false)
59
+ const [helpOpen, setHelpOpen] = useState(false)
60
+ const [changelogOpen, setChangelogOpen] = useState(false)
61
+ const [changelogDefaultVersion, setChangelogDefaultVersion] = useState(null)
62
+ const [toolMode, setToolMode] = useState(null) // 📖 M3 will use this; the row highlighter already keys off it
57
63
  const [toasts, setToasts] = useState([])
58
64
  const lastActivityRef = useRef(Date.now())
59
65
 
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
- })
67
-
68
66
  const {
69
67
  filtered,
70
68
  filterTier, setFilterTier,
71
69
  filterStatus, setFilterStatus,
72
70
  filterProvider, setFilterProvider,
73
- searchQuery, setSearchQuery,
74
- sortColumn, sortDirection, toggleSort,
75
71
  filterVerdict, setFilterVerdict,
76
72
  filterHealth, setFilterHealth,
77
73
  visibilityMode, setVisibilityMode,
74
+ searchQuery, setSearchQuery,
78
75
  customTextFilter, setCustomTextFilter,
76
+ sortColumn, sortDirection, setSortColumn, setSortDirection, toggleSort,
79
77
  resetView,
80
78
  } = useFilter(models)
81
79
 
82
- // 📖 Favorites single source of truth shared with the TUI via ~/.free-coding-models.json.
80
+ // 📖 URL deep-linking (M2 = read + write). Hydrates on mount, then pushes
81
+ // 📖 every change back via history.replaceState (debounced 80ms).
82
+ useUrlState({
83
+ currentView, setCurrentView,
84
+ filterState: {
85
+ filterTier, setFilterTier,
86
+ filterStatus, setFilterStatus,
87
+ filterProvider, setFilterProvider,
88
+ filterVerdict, setFilterVerdict,
89
+ filterHealth, setFilterHealth,
90
+ sortColumn, sortDirection, setSortColumn, setSortDirection, toggleSort,
91
+ setSearchQuery,
92
+ filterState: null, // sentinel; useFilter doesn't expose this name
93
+ searchQuery,
94
+ },
95
+ paletteOpen, setPaletteOpen,
96
+ toolMode, setToolMode,
97
+ })
98
+
99
+ // 📖 Favorites — single source of truth shared with the TUI.
83
100
  const favorites = useFavorites({ models })
84
101
 
102
+ // 📖 Update checker (5-minute poll). Returns `updateAvailable` for the chip.
103
+ const {
104
+ localVersion, latestVersion, updateAvailable, runUpdate, checkNow, error: updateError,
105
+ } = useUpdateChecker({ onToast: addToastInternal })
106
+
85
107
  // 📖 Build the provider list for the FilterBar dropdown.
86
- const providers = (() => {
108
+ const providers = useMemo(() => {
87
109
  const map = {}
88
110
  models.forEach((m) => {
89
111
  if (!map[m.providerKey]) map[m.providerKey] = { key: m.providerKey, name: m.origin, count: 0 }
90
112
  map[m.providerKey].count++
91
113
  })
92
114
  return Object.values(map).sort((a, b) => a.name.localeCompare(b.name))
93
- })()
115
+ }, [models])
94
116
 
95
117
  // ── Global benchmark (AI Speed Test) ─────────────────────────────────────
96
118
  const handleBenchmark = useCallback(async () => {
@@ -118,7 +140,7 @@ export default function App() {
118
140
  })
119
141
  if (!resp.ok && resp.status !== 202) {
120
142
  const err = await resp.json().catch(() => ({}))
121
- addToast?.(`Benchmark failed: ${err?.error || resp.statusText}`, 'error')
143
+ addToastInternal?.(`Benchmark failed: ${err?.error || resp.statusText}`, 'error')
122
144
  }
123
145
  } catch (err) {
124
146
  console.error('[Benchmark] per-row failed:', err.message)
@@ -126,14 +148,14 @@ export default function App() {
126
148
  }, [])
127
149
 
128
150
  // ── Toast helpers ────────────────────────────────────────────────────────
129
- const addToast = useCallback((message, type = 'info') => {
151
+ function addToastInternal(message, type = 'info') {
130
152
  const id = ++toastIdCounter
131
153
  setToasts((prev) => [...prev, { id, message, type }])
132
154
  setTimeout(() => {
133
155
  setToasts((prev) => prev.filter((t) => t.id !== id))
134
156
  }, 4000)
135
- }, [])
136
-
157
+ }
158
+ const addToast = useCallback(addToastInternal, [])
137
159
  const dismissToast = useCallback((id) => {
138
160
  setToasts((prev) => prev.filter((t) => t.id !== id))
139
161
  }, [])
@@ -143,10 +165,9 @@ export default function App() {
143
165
  setSelectedModel(model)
144
166
  lastActivityRef.current = Date.now()
145
167
  }, [])
146
-
147
168
  const handleCloseDetail = useCallback(() => setSelectedModel(null), [])
148
169
 
149
- // ── Ping mode change → server → broadcast ───────────────────────────────
170
+ // ── Ping mode change → server → broadcast ─────────────────────────────
150
171
  const handlePingModeChange = useCallback(async (mode) => {
151
172
  try {
152
173
  await fetch(`/api/ping-mode?action=${mode}`, { method: 'POST' })
@@ -155,55 +176,56 @@ export default function App() {
155
176
 
156
177
  // ── Navigation handler (Header nav + overflow menu) ──────────────────────
157
178
  const handleNavigate = useCallback((viewId) => {
179
+ // 📖 'help' / 'changelog' / 'recommend' / 'router' open modals (M2) or
180
+ // 📖 toasts (M3/M4) — they don't switch the currentView.
181
+ if (viewId === 'help') { setHelpOpen(true); return }
182
+ if (viewId === 'changelog') { setChangelogOpen(true); setChangelogDefaultVersion(null); return }
183
+ if (viewId === 'recommend') { addToast?.('Smart Recommend arrives in M3', 'info'); return }
184
+ if (viewId === 'router') { addToast?.('Router dashboard arrives in M4', 'info'); return }
185
+ if (viewId === 'install-endpoints') { addToast?.('Install Endpoints arrives in M4', 'info'); return }
186
+ if (viewId === 'installed-models') { addToast?.('Installed Models arrives in M4', 'info'); return }
158
187
  setCurrentView(VIEW_TO_NAV[viewId] || viewId)
159
188
  lastActivityRef.current = Date.now()
160
- }, [])
189
+ }, [addToast])
161
190
 
162
191
  // ── Reset view (N key equivalent) ────────────────────────────────────────
163
192
  const handleResetView = useCallback(() => {
164
193
  resetView()
165
194
  setSearchQuery('')
166
- addToast('View reset to defaults.', 'info')
167
- }, [resetView, setSearchQuery, addToast])
195
+ addToastInternal('View reset to defaults.', 'info')
196
+ }, [resetView, setSearchQuery])
197
+
198
+ // ── Changelog open with optional version (e.g. from UpdateChip "What's new") ─
199
+ const openChangelogAt = useCallback((version) => {
200
+ setChangelogDefaultVersion(version)
201
+ setChangelogOpen(true)
202
+ }, [])
168
203
 
169
- // ── Keyboard shortcuts: only ⌘K / Ctrl+P for the palette ────────────────
204
+ // ── Keyboard shortcuts: only ⌘K / Ctrl+P for the palette, Esc for any modal ─
170
205
  useEffect(() => {
171
206
  const handler = (e) => {
172
207
  const cmdOrCtrl = e.metaKey || e.ctrlKey
173
- // ⌘K / Ctrl+K → toggle command palette (the Web's only global shortcut)
174
208
  if (cmdOrCtrl && (e.key === 'k' || e.key === 'K')) {
175
209
  e.preventDefault()
176
210
  setPaletteOpen((o) => !o)
177
211
  return
178
212
  }
179
- // Ctrl+P is also accepted as a TUI-style alias for the same palette.
180
213
  if (cmdOrCtrl && (e.key === 'p' || e.key === 'P') && !e.shiftKey) {
181
214
  e.preventDefault()
182
215
  setPaletteOpen((o) => !o)
183
216
  return
184
217
  }
185
- // Esc closes whatever is open.
186
218
  if (e.key === 'Escape') {
187
219
  if (paletteOpen) { setPaletteOpen(false); return }
220
+ if (helpOpen) { setHelpOpen(false); return }
221
+ if (changelogOpen) { setChangelogOpen(false); return }
188
222
  if (selectedModel) { setSelectedModel(null); return }
189
223
  if (exportOpen) { setExportOpen(false); return }
190
224
  }
191
225
  }
192
226
  window.addEventListener('keydown', handler)
193
227
  return () => window.removeEventListener('keydown', handler)
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
- }, [])
228
+ }, [paletteOpen, helpOpen, changelogOpen, selectedModel, exportOpen])
207
229
 
208
230
  return (
209
231
  <>
@@ -223,6 +245,14 @@ export default function App() {
223
245
  modelsCount={filtered.length}
224
246
  theme={theme}
225
247
  onToast={addToast}
248
+ updateSlot={
249
+ <UpdateChip
250
+ updateAvailable={updateAvailable}
251
+ latestVersion={latestVersion}
252
+ onRunUpdate={runUpdate}
253
+ onOpenChangelog={openChangelogAt}
254
+ />
255
+ }
226
256
  />
227
257
 
228
258
  <div className="app-content">
@@ -262,13 +292,18 @@ export default function App() {
262
292
  sortColumn={sortColumn}
263
293
  sortDirection={sortDirection}
264
294
  onSort={toggleSort}
295
+ toolMode={toolMode}
265
296
  />
266
297
  </div>
267
298
  )}
268
299
 
269
300
  {currentView === 'settings' && (
270
301
  <div className="view">
271
- <SettingsView onToast={addToast} />
302
+ <SettingsView
303
+ onToast={addToast}
304
+ onOpenChangelog={(version) => { setChangelogDefaultVersion(version); setChangelogOpen(true) }}
305
+ onCheckForUpdate={() => { checkNow(); addToast?.('Checking for updates…', 'info') }}
306
+ />
272
307
  </div>
273
308
  )}
274
309
 
@@ -304,12 +339,26 @@ export default function App() {
304
339
  onNavigate={handleNavigate}
305
340
  onCycleTheme={cycleTheme}
306
341
  onResetView={handleResetView}
342
+ onSetPingMode={handlePingModeChange}
343
+ onOpenHelp={() => setHelpOpen(true)}
344
+ onOpenChangelog={() => { setChangelogDefaultVersion(null); setChangelogOpen(true) }}
345
+ onExport={() => setExportOpen(true)}
346
+ onRunUpdate={runUpdate}
307
347
  currentView={currentView}
308
348
  theme={theme}
309
349
  pingMode={pingMode}
310
- onSetPingMode={handlePingModeChange}
350
+ models={models}
351
+ updateAvailable={updateAvailable}
352
+ latestVersion={latestVersion}
311
353
  onToast={addToast}
312
- onExport={() => setExportOpen(true)}
354
+ />
355
+ )}
356
+
357
+ {helpOpen && <HelpView onClose={() => setHelpOpen(false)} />}
358
+ {changelogOpen && (
359
+ <ChangelogView
360
+ onClose={() => setChangelogOpen(false)}
361
+ defaultVersion={changelogDefaultVersion}
313
362
  />
314
363
  )}
315
364
 
@@ -0,0 +1,135 @@
1
+ /**
2
+ * @file web/src/components/changelog/ChangelogView.jsx
3
+ * @description Changelog modal — M2 parity with the TUI's Changelog overlay (N key / Settings link).
4
+ * 📖 Two-phase UI: index of versions on the left, details on the right. Same
5
+ * 📖 content as the TUI's renderChangelog (which uses src/core/changelog-loader.js
6
+ * 📖 — we hit the same data through `/api/changelog`).
7
+ *
8
+ * @functions
9
+ * → ChangelogView → main modal component
10
+ */
11
+ import { useState } from 'react'
12
+ import { IconArrowLeft, IconX, IconCalendar } from '@tabler/icons-react'
13
+ import { useChangelog } from '../../hooks/useChangelog.js'
14
+ import styles from './ChangelogView.module.css'
15
+
16
+ // 📖 Section order matches the changelog files (`### Added` / `### Fixed` /
17
+ // 📖 `### Changed` / `### Updated`). The TUI uses the same order in
18
+ // 📖 formatChangelogForDisplay.
19
+ const SECTION_LABELS = [
20
+ { key: 'added', label: '✨ Added', icon: '✨' },
21
+ { key: 'fixed', label: '🐛 Fixed', icon: '🐛' },
22
+ { key: 'changed', label: '🔄 Changed', icon: '🔄' },
23
+ { key: 'updated', label: '📝 Updated', icon: '📝' },
24
+ ]
25
+
26
+ export default function ChangelogView({ onClose, defaultVersion = null }) {
27
+ const { sortedVersions, getVersion, loading, error } = useChangelog()
28
+ // 📖 Two-phase navigation: 'index' (version list) or 'details' (one version).
29
+ // 📖 `selectedVersion` is null on the index and a version string on details.
30
+ const [phase, setPhase] = useState(defaultVersion ? 'details' : 'index')
31
+ const [selectedVersion, setSelectedVersion] = useState(defaultVersion)
32
+
33
+ const openDetails = (version) => {
34
+ setSelectedVersion(version)
35
+ setPhase('details')
36
+ }
37
+ const backToIndex = () => {
38
+ setSelectedVersion(null)
39
+ setPhase('index')
40
+ }
41
+
42
+ const details = selectedVersion ? getVersion(selectedVersion) : null
43
+
44
+ return (
45
+ <div className={styles.backdrop} onClick={onClose}>
46
+ <div className={styles.modal} onClick={(e) => e.stopPropagation()}>
47
+ <div className={styles.header}>
48
+ <div className={styles.titleRow}>
49
+ <h2 className={styles.title}>
50
+ {phase === 'index' ? '📋 Changelog' : `📋 v${selectedVersion}`}
51
+ </h2>
52
+ {phase === 'details' && (
53
+ <button className={styles.backBtn} onClick={backToIndex} title="Back to index (TUI: B)">
54
+ <IconArrowLeft size={14} stroke={1.5} /> Index
55
+ </button>
56
+ )}
57
+ <button className={styles.closeBtn} onClick={onClose} aria-label="Close changelog">
58
+ <IconX size={18} stroke={1.5} />
59
+ </button>
60
+ </div>
61
+ </div>
62
+
63
+ <div className={styles.body}>
64
+ {loading && <div className={styles.empty}>Loading changelog…</div>}
65
+ {error && !loading && <div className={styles.empty}>Failed to load changelog: {error}</div>}
66
+
67
+ {!loading && !error && phase === 'index' && (
68
+ <div className={styles.indexWrap}>
69
+ <p className={styles.indexHint}>
70
+ {sortedVersions.length} versions. Click any to read the release notes.
71
+ </p>
72
+ <ul className={styles.versionList}>
73
+ {sortedVersions.map((version) => {
74
+ const changes = getVersion(version)
75
+ const summary = []
76
+ if (changes?.added?.length) summary.push(`${changes.added.length} added`)
77
+ if (changes?.fixed?.length) summary.push(`${changes.fixed.length} fixed`)
78
+ if (changes?.changed?.length) summary.push(`${changes.changed.length} changed`)
79
+ return (
80
+ <li key={version}>
81
+ <button
82
+ className={styles.versionBtn}
83
+ onClick={() => openDetails(version)}
84
+ >
85
+ <span className={styles.versionLabel}>v{version}</span>
86
+ <span className={styles.versionSummary}>
87
+ {summary.length > 0 ? summary.join(' · ') : '—'}
88
+ </span>
89
+ </button>
90
+ </li>
91
+ )
92
+ })}
93
+ </ul>
94
+ </div>
95
+ )}
96
+
97
+ {!loading && !error && phase === 'details' && details && (
98
+ <div className={styles.details}>
99
+ {SECTION_LABELS.map(({ key, label }) => {
100
+ const items = details[key]
101
+ if (!items || items.length === 0) return null
102
+ return (
103
+ <section key={key} className={styles.section}>
104
+ <h3 className={styles.sectionTitle}>{label}</h3>
105
+ <ul className={styles.itemList}>
106
+ {items.map((item, idx) => (
107
+ <li key={idx} className={styles.item}>{formatItem(item)}</li>
108
+ ))}
109
+ </ul>
110
+ </section>
111
+ )
112
+ })}
113
+ {SECTION_LABELS.every(({ key }) => !details[key]?.length) && (
114
+ <div className={styles.empty}>No release notes for v{selectedVersion}.</div>
115
+ )}
116
+ </div>
117
+ )}
118
+
119
+ {!loading && !error && phase === 'details' && !details && (
120
+ <div className={styles.empty}>No notes for v{selectedVersion}.</div>
121
+ )}
122
+ </div>
123
+ </div>
124
+ </div>
125
+ )
126
+ }
127
+
128
+ // 📖 Strip **bold** and `code` markdown markers for the Web view. The TUI does
129
+ // 📖 the same in formatChangelogForDisplay, so the two surfaces stay in sync.
130
+ function formatItem(text) {
131
+ return text
132
+ .replace(/\*\*([^*]+)\*\*/g, '$1')
133
+ .replace(/`([^`]+)`/g, '$1')
134
+ .trim()
135
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * @file web/src/components/changelog/ChangelogView.module.css
3
+ * @description Two-phase changelog modal styles (TUI parity).
4
+ */
5
+
6
+ .backdrop {
7
+ position: fixed;
8
+ inset: 0;
9
+ background: rgba(0, 0, 0, 0.55);
10
+ backdrop-filter: blur(4px);
11
+ -webkit-backdrop-filter: blur(4px);
12
+ z-index: 500;
13
+ display: flex;
14
+ align-items: center;
15
+ justify-content: center;
16
+ padding: 5vh 4vw;
17
+ }
18
+
19
+ .modal {
20
+ width: min(720px, 95vw);
21
+ max-height: 88vh;
22
+ background: var(--color-bg-elevated);
23
+ border: 1px solid var(--color-border-hover);
24
+ border-radius: 14px;
25
+ box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5);
26
+ display: flex;
27
+ flex-direction: column;
28
+ overflow: hidden;
29
+ }
30
+
31
+ .header {
32
+ padding: 16px 20px;
33
+ border-bottom: 1px solid var(--color-border);
34
+ }
35
+
36
+ .titleRow {
37
+ display: flex;
38
+ align-items: center;
39
+ gap: 12px;
40
+ }
41
+
42
+ .title {
43
+ font-size: 16px;
44
+ font-weight: 700;
45
+ margin: 0;
46
+ color: var(--color-text);
47
+ flex: 1;
48
+ min-width: 0;
49
+ }
50
+
51
+ .backBtn {
52
+ display: inline-flex;
53
+ align-items: center;
54
+ gap: 4px;
55
+ padding: 4px 10px;
56
+ background: var(--color-surface);
57
+ color: var(--color-text);
58
+ border: 1px solid var(--color-border);
59
+ border-radius: 6px;
60
+ font-size: 11px;
61
+ font-weight: 600;
62
+ cursor: pointer;
63
+ font-family: var(--font-sans);
64
+ white-space: nowrap;
65
+ }
66
+ .backBtn:hover { background: var(--color-bg-hover); border-color: var(--color-text-muted); }
67
+
68
+ .closeBtn {
69
+ background: transparent;
70
+ border: none;
71
+ color: var(--color-text-muted);
72
+ cursor: pointer;
73
+ padding: 4px;
74
+ border-radius: 4px;
75
+ display: inline-flex;
76
+ align-items: center;
77
+ justify-content: center;
78
+ flex-shrink: 0;
79
+ }
80
+ .closeBtn:hover { color: var(--color-text); background: var(--color-bg-hover); }
81
+
82
+ .body {
83
+ flex: 1;
84
+ overflow-y: auto;
85
+ padding: 16px 20px 20px;
86
+ }
87
+
88
+ .indexWrap {}
89
+ .indexHint {
90
+ margin: 0 0 12px;
91
+ font-size: 11px;
92
+ color: var(--color-text-muted);
93
+ }
94
+
95
+ .versionList {
96
+ list-style: none;
97
+ margin: 0;
98
+ padding: 0;
99
+ display: flex;
100
+ flex-direction: column;
101
+ gap: 4px;
102
+ }
103
+ .versionBtn {
104
+ display: flex;
105
+ align-items: center;
106
+ justify-content: space-between;
107
+ gap: 12px;
108
+ width: 100%;
109
+ padding: 8px 12px;
110
+ background: var(--color-surface);
111
+ border: 1px solid var(--color-border);
112
+ border-radius: 6px;
113
+ cursor: pointer;
114
+ text-align: left;
115
+ transition: all 100ms;
116
+ font-family: var(--font-sans);
117
+ }
118
+ .versionBtn:hover { background: var(--color-bg-hover); border-color: var(--color-text-muted); }
119
+ .versionLabel {
120
+ font-family: var(--font-mono);
121
+ font-size: 13px;
122
+ font-weight: 700;
123
+ color: var(--color-text);
124
+ }
125
+ .versionSummary {
126
+ font-size: 11px;
127
+ color: var(--color-text-muted);
128
+ }
129
+
130
+ .details { display: flex; flex-direction: column; gap: 14px; }
131
+ .section {}
132
+ .sectionTitle {
133
+ font-size: 12px;
134
+ font-weight: 700;
135
+ text-transform: uppercase;
136
+ letter-spacing: 0.6px;
137
+ color: var(--color-text-muted);
138
+ margin: 0 0 6px;
139
+ }
140
+ .itemList {
141
+ list-style: disc;
142
+ padding-left: 20px;
143
+ margin: 0;
144
+ display: flex;
145
+ flex-direction: column;
146
+ gap: 4px;
147
+ }
148
+ .item {
149
+ font-size: 12px;
150
+ color: var(--color-text);
151
+ line-height: 1.55;
152
+ word-break: break-word;
153
+ }
154
+
155
+ .empty {
156
+ text-align: center;
157
+ padding: 40px 20px;
158
+ color: var(--color-text-dim);
159
+ font-size: 13px;
160
+ }