free-coding-models 0.5.5 → 0.5.8

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 (63) hide show
  1. package/README.md +19 -11
  2. package/bin/free-coding-models.js +6 -0
  3. package/changelog/v0.5.5.md +1 -0
  4. package/changelog/v0.5.6.md +24 -0
  5. package/changelog/v0.5.7.md +20 -0
  6. package/package.json +4 -4
  7. package/src/core/changelog-loader.js +5 -1
  8. package/src/core/endpoint-installer.js +3 -3
  9. package/src/core/router-daemon.js +11 -0
  10. package/src/core/tool-launchers.js +2 -2
  11. package/web/dist/assets/index-DKmmiDip.css +1 -0
  12. package/web/dist/assets/index-DzVt42Nf.js +39 -0
  13. package/web/dist/index.html +2 -2
  14. package/web/server.js +532 -2
  15. package/web/src/App.jsx +235 -73
  16. package/web/src/components/analytics/AnalyticsView.jsx +4 -0
  17. package/web/src/components/analytics/TokenUsagePanel.jsx +105 -0
  18. package/web/src/components/analytics/TokenUsagePanel.module.css +126 -0
  19. package/web/src/components/atoms/TierBadge.module.css +3 -3
  20. package/web/src/components/changelog/ChangelogView.jsx +135 -0
  21. package/web/src/components/changelog/ChangelogView.module.css +160 -0
  22. package/web/src/components/dashboard/DetailPanel.jsx +29 -4
  23. package/web/src/components/dashboard/DetailPanel.module.css +26 -0
  24. package/web/src/components/dashboard/FilterBar.jsx +17 -2
  25. package/web/src/components/dashboard/FilterBar.module.css +17 -0
  26. package/web/src/components/dashboard/ModelTable.jsx +17 -5
  27. package/web/src/components/dashboard/ModelTable.module.css +14 -1
  28. package/web/src/components/help/HelpView.jsx +188 -0
  29. package/web/src/components/help/HelpView.module.css +157 -0
  30. package/web/src/components/install/InstallEndpointsView.jsx +278 -0
  31. package/web/src/components/install/InstallEndpointsView.module.css +351 -0
  32. package/web/src/components/installed/InstalledModelsView.jsx +89 -0
  33. package/web/src/components/installed/InstalledModelsView.module.css +200 -0
  34. package/web/src/components/launch/IncompatibleFallbackModal.jsx +69 -0
  35. package/web/src/components/launch/LaunchButton.jsx +30 -0
  36. package/web/src/components/launch/LaunchButton.module.css +35 -0
  37. package/web/src/components/launch/LaunchModal.module.css +125 -0
  38. package/web/src/components/layout/Header.jsx +78 -14
  39. package/web/src/components/layout/Header.module.css +62 -2
  40. package/web/src/components/palette/CommandPalette.jsx +228 -74
  41. package/web/src/components/recommend/RecommendView.jsx +121 -0
  42. package/web/src/components/recommend/RecommendView.module.css +55 -0
  43. package/web/src/components/router/RouterView.jsx +286 -0
  44. package/web/src/components/router/RouterView.module.css +357 -0
  45. package/web/src/components/settings/SettingsView.jsx +281 -8
  46. package/web/src/components/settings/SettingsView.module.css +174 -0
  47. package/web/src/components/tools/ToolPicker.jsx +86 -0
  48. package/web/src/components/tools/ToolPicker.module.css +84 -0
  49. package/web/src/components/update/UpdateChip.jsx +104 -0
  50. package/web/src/components/update/UpdateChip.module.css +146 -0
  51. package/web/src/global.css +23 -0
  52. package/web/src/hooks/urlState.constants.js +28 -0
  53. package/web/src/hooks/useChangelog.js +51 -0
  54. package/web/src/hooks/useInstalledModels.js +42 -0
  55. package/web/src/hooks/useRecommend.js +67 -0
  56. package/web/src/hooks/useRouterDashboard.js +133 -0
  57. package/web/src/hooks/useTokenUsage.js +103 -0
  58. package/web/src/hooks/useToolMode.js +77 -0
  59. package/web/src/hooks/useUpdateChecker.js +91 -0
  60. package/web/src/hooks/useUrlState.js +123 -62
  61. package/web/src/utils/m3.js +55 -0
  62. package/web/dist/assets/index-uD3faN3G.js +0 -39
  63. package/web/dist/assets/index-uTifVKX1.css +0 -1
@@ -33,6 +33,180 @@
33
33
  color: var(--color-text-muted);
34
34
  }
35
35
 
36
+ /* ─── M2: feature toggles section ─── */
37
+ .featureSection {
38
+ margin-bottom: 28px;
39
+ padding: 18px 20px;
40
+ background: var(--color-bg-card);
41
+ border: 1px solid var(--color-border);
42
+ border-radius: 12px;
43
+ }
44
+ .sectionHeading {
45
+ font-size: 11px;
46
+ font-weight: 700;
47
+ text-transform: uppercase;
48
+ letter-spacing: 0.6px;
49
+ color: var(--color-text-muted);
50
+ margin: 0 0 14px;
51
+ }
52
+ .featureGrid {
53
+ display: flex;
54
+ flex-direction: column;
55
+ gap: 4px;
56
+ }
57
+ .featureRow {
58
+ display: flex;
59
+ align-items: center;
60
+ gap: 14px;
61
+ padding: 10px 12px;
62
+ border: 1px solid transparent;
63
+ border-radius: 8px;
64
+ transition: background 120ms;
65
+ }
66
+ .featureRow:hover { background: var(--color-surface); }
67
+ .featureLabel {
68
+ display: flex;
69
+ align-items: flex-start;
70
+ gap: 10px;
71
+ flex: 1;
72
+ min-width: 0;
73
+ }
74
+ .featureLabel > svg {
75
+ flex-shrink: 0;
76
+ color: var(--color-text-muted);
77
+ margin-top: 2px;
78
+ }
79
+ .featureTitle {
80
+ font-size: 13px;
81
+ font-weight: 600;
82
+ color: var(--color-text);
83
+ line-height: 1.4;
84
+ }
85
+ .featureDesc {
86
+ font-size: 11px;
87
+ color: var(--color-text-muted);
88
+ line-height: 1.5;
89
+ margin-top: 2px;
90
+ }
91
+ .featureActions {
92
+ display: flex;
93
+ gap: 6px;
94
+ flex-shrink: 0;
95
+ }
96
+ .select {
97
+ background: var(--color-surface);
98
+ color: var(--color-text);
99
+ border: 1px solid var(--color-border);
100
+ border-radius: 5px;
101
+ padding: 5px 8px;
102
+ font-size: 12px;
103
+ font-family: var(--font-sans);
104
+ cursor: pointer;
105
+ outline: none;
106
+ min-width: 130px;
107
+ }
108
+ .select:focus { border-color: var(--color-accent); }
109
+
110
+ .toggleSwitch {
111
+ position: relative;
112
+ display: inline-block;
113
+ width: 36px;
114
+ height: 20px;
115
+ flex-shrink: 0;
116
+ }
117
+ .toggleSwitch input {
118
+ opacity: 0;
119
+ width: 0;
120
+ height: 0;
121
+ }
122
+ .toggleSlider {
123
+ position: absolute;
124
+ inset: 0;
125
+ background: var(--color-border);
126
+ border-radius: 20px;
127
+ cursor: pointer;
128
+ transition: background 150ms;
129
+ }
130
+ .toggleSlider::before {
131
+ content: '';
132
+ position: absolute;
133
+ left: 2px;
134
+ top: 2px;
135
+ width: 16px;
136
+ height: 16px;
137
+ background: var(--color-text);
138
+ border-radius: 50%;
139
+ transition: transform 150ms;
140
+ }
141
+ .toggleSwitch input:checked + .toggleSlider {
142
+ background: var(--color-accent);
143
+ }
144
+ .toggleSwitch input:checked + .toggleSlider::before {
145
+ transform: translateX(16px);
146
+ }
147
+
148
+ .smallBtn {
149
+ display: inline-flex;
150
+ align-items: center;
151
+ gap: 4px;
152
+ padding: 5px 10px;
153
+ font-size: 11px;
154
+ font-weight: 600;
155
+ font-family: var(--font-sans);
156
+ background: var(--color-surface);
157
+ color: var(--color-text);
158
+ border: 1px solid var(--color-border);
159
+ border-radius: 5px;
160
+ cursor: pointer;
161
+ white-space: nowrap;
162
+ }
163
+ .smallBtn:hover { background: var(--color-bg-hover); border-color: var(--color-text-muted); }
164
+ .smallBtn:disabled { opacity: 0.5; cursor: not-allowed; }
165
+
166
+ .notice {
167
+ margin-top: 12px;
168
+ padding: 8px 12px;
169
+ background: var(--color-info-dim);
170
+ color: var(--color-info);
171
+ border: 1px solid var(--color-info);
172
+ border-radius: 6px;
173
+ font-size: 12px;
174
+ line-height: 1.5;
175
+ }
176
+
177
+ /* ─── M2: per-provider test badge ─── */
178
+ .testBadge {
179
+ display: inline-flex;
180
+ align-items: center;
181
+ gap: 5px;
182
+ padding: 4px 8px;
183
+ border-radius: 5px;
184
+ font-size: 10px;
185
+ font-weight: 700;
186
+ font-family: var(--font-mono);
187
+ margin-bottom: 8px;
188
+ align-self: flex-start;
189
+ width: fit-content;
190
+ }
191
+ .testBadge.test_ok { background: var(--color-success-dim); color: var(--color-success); border: 1px solid var(--color-success); }
192
+ .testBadge.test_auth_error { background: var(--color-danger-dim); color: var(--color-danger); border: 1px solid var(--color-danger); }
193
+ .testBadge.test_rate_limited { background: var(--color-warning-dim); color: var(--color-warning); border: 1px solid var(--color-warning); }
194
+ .testBadge.test_no_callable_model { background: var(--color-warning-dim); color: var(--color-warning); border: 1px solid var(--color-warning); }
195
+ .testBadge.test_fail { background: var(--color-danger-dim); color: var(--color-danger); border: 1px solid var(--color-danger); }
196
+ .testBadge.test_missing_key { background: var(--color-surface); color: var(--color-text-muted); border: 1px solid var(--color-border); }
197
+ .testBadge.test_pending { background: var(--color-surface); color: var(--color-text-muted); border: 1px solid var(--color-border); }
198
+
199
+ .testSpinner {
200
+ display: inline-block;
201
+ width: 10px;
202
+ height: 10px;
203
+ border: 2px solid rgba(180, 0, 255, 0.3);
204
+ border-top-color: #b400ff;
205
+ border-radius: 50%;
206
+ animation: spin 0.8s linear infinite;
207
+ }
208
+ @keyframes spin { to { transform: rotate(360deg); } }
209
+
36
210
  .toolbar {
37
211
  display: flex;
38
212
  align-items: center;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * @file web/src/components/tools/ToolPicker.jsx
3
+ * @description Header/dropdown control for the active Web endpoint-install target.
4
+ * Mirrors the TUI install target flow with a mouse-first dropdown and cycle button.
5
+ *
6
+ * @functions
7
+ * → ToolPicker — renders active tool, cycle control, and selectable tool list
8
+ * @exports ToolPicker
9
+ */
10
+ import { useEffect, useRef, useState } from 'react'
11
+ import { IconChevronDown, IconRefresh } from '@tabler/icons-react'
12
+ import { TOOL_METADATA, getToolMeta } from '../../../../src/core/tool-metadata.js'
13
+ import { INSTALL_ENDPOINT_TOOL_MODES } from '../../utils/m3.js'
14
+ import styles from './ToolPicker.module.css'
15
+
16
+ export default function ToolPicker({ toolMode = 'opencode', onSetToolMode, onCycleToolMode, compact = false, tools = INSTALL_ENDPOINT_TOOL_MODES }) {
17
+ const [open, setOpen] = useState(false)
18
+ const wrapRef = useRef(null)
19
+ const active = getToolMeta(toolMode)
20
+
21
+ useEffect(() => {
22
+ if (!open) return
23
+ const onPointer = (event) => {
24
+ if (wrapRef.current && !wrapRef.current.contains(event.target)) setOpen(false)
25
+ }
26
+ const onKey = (event) => { if (event.key === 'Escape') setOpen(false) }
27
+ document.addEventListener('mousedown', onPointer)
28
+ document.addEventListener('keydown', onKey)
29
+ return () => {
30
+ document.removeEventListener('mousedown', onPointer)
31
+ document.removeEventListener('keydown', onKey)
32
+ }
33
+ }, [open])
34
+
35
+ return (
36
+ <div className={`${styles.wrap} ${compact ? styles.compact : ''}`} ref={wrapRef}>
37
+ <button
38
+ type="button"
39
+ className={styles.trigger}
40
+ onClick={() => setOpen((value) => !value)}
41
+ aria-haspopup="listbox"
42
+ aria-expanded={open}
43
+ title={`Install endpoint into: ${active.label}`}
44
+ >
45
+ <span className={styles.emoji}>{active.emoji}</span>
46
+ <span className={styles.label}>{compact ? active.label.replace(/ CLI$/, '') : active.label}</span>
47
+ <IconChevronDown size={13} stroke={1.7} />
48
+ </button>
49
+ <button
50
+ type="button"
51
+ className={styles.cycle}
52
+ onClick={onCycleToolMode}
53
+ title="Cycle endpoint target"
54
+ aria-label="Cycle endpoint target"
55
+ >
56
+ <IconRefresh size={13} stroke={1.7} />
57
+ </button>
58
+
59
+ {open && (
60
+ <div className={styles.menu} role="listbox" aria-label="Endpoint install target">
61
+ {tools.map((mode) => {
62
+ const meta = TOOL_METADATA[mode]
63
+ const activeMode = mode === toolMode
64
+ return (
65
+ <button
66
+ type="button"
67
+ key={mode}
68
+ className={`${styles.item} ${activeMode ? styles.itemActive : ''}`}
69
+ onClick={() => { onSetToolMode?.(mode); setOpen(false) }}
70
+ role="option"
71
+ aria-selected={activeMode}
72
+ >
73
+ <span className={styles.itemEmoji}>{meta.emoji}</span>
74
+ <span className={styles.itemText}>
75
+ <strong>{meta.label}</strong>
76
+ <small>{meta.flag}</small>
77
+ </span>
78
+ {activeMode && <span className={styles.activeDot}>●</span>}
79
+ </button>
80
+ )
81
+ })}
82
+ </div>
83
+ )}
84
+ </div>
85
+ )
86
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * @file web/src/components/tools/ToolPicker.module.css
3
+ * @description Compact header dropdown styles for the M3 tool-mode picker.
4
+ */
5
+ .wrap {
6
+ position: relative;
7
+ display: inline-flex;
8
+ align-items: center;
9
+ gap: 4px;
10
+ }
11
+ .trigger,
12
+ .cycle {
13
+ height: 30px;
14
+ display: inline-flex;
15
+ align-items: center;
16
+ justify-content: center;
17
+ border: 1px solid var(--color-border);
18
+ background: var(--color-surface);
19
+ color: var(--color-text);
20
+ cursor: pointer;
21
+ transition: background 150ms, border-color 150ms, color 150ms;
22
+ }
23
+ .trigger {
24
+ gap: 6px;
25
+ padding: 0 9px;
26
+ border-radius: 6px;
27
+ font-size: 12px;
28
+ font-weight: 700;
29
+ }
30
+ .cycle {
31
+ width: 30px;
32
+ border-radius: 6px;
33
+ }
34
+ .trigger:hover,
35
+ .cycle:hover {
36
+ background: var(--color-bg-hover);
37
+ border-color: var(--color-accent);
38
+ color: var(--color-accent);
39
+ }
40
+ .emoji { font-size: 14px; line-height: 1; }
41
+ .label { white-space: nowrap; }
42
+ .menu {
43
+ position: absolute;
44
+ right: 0;
45
+ top: calc(100% + 7px);
46
+ width: 280px;
47
+ max-height: min(520px, calc(100vh - 90px));
48
+ overflow: auto;
49
+ padding: 5px;
50
+ border: 1px solid var(--color-border);
51
+ border-radius: 10px;
52
+ background: var(--color-bg-elevated);
53
+ box-shadow: 0 14px 36px rgba(0, 0, 0, 0.38);
54
+ z-index: 250;
55
+ }
56
+ .item {
57
+ width: 100%;
58
+ display: flex;
59
+ align-items: center;
60
+ gap: 9px;
61
+ padding: 8px 9px;
62
+ border: 0;
63
+ border-radius: 7px;
64
+ color: var(--color-text);
65
+ background: transparent;
66
+ cursor: pointer;
67
+ text-align: left;
68
+ }
69
+ .item:hover { background: var(--color-bg-hover); }
70
+ .itemActive {
71
+ background: var(--color-accent-dim);
72
+ color: var(--color-accent);
73
+ }
74
+ .itemEmoji { width: 22px; text-align: center; font-size: 16px; }
75
+ .itemText { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
76
+ .itemText strong { font-size: 12px; font-weight: 800; }
77
+ .itemText small { color: var(--color-text-dim); font-family: var(--font-mono); font-size: 10px; }
78
+ .activeDot { color: var(--color-accent); font-size: 10px; }
79
+ .compact .trigger { width: 100%; justify-content: flex-start; }
80
+ .compact .menu { left: 0; right: auto; }
81
+ @media (max-width: 1024px) {
82
+ .label { display: none; }
83
+ .trigger { padding: 0 8px; }
84
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * @file web/src/components/update/UpdateChip.jsx
3
+ * @description Header update chip + popover — M2 parity with TUI's auto-update banner.
4
+ * 📖 Sits in the right side of the header (next to AI Latency, theme, export).
5
+ * 📖 Hidden when no update is available; shows "⬆ vX.Y.Z" when one is.
6
+ * 📖 Click → popover with "Update now" + "What's new" (which opens the
7
+ * 📖 Changelog modal pre-focused on the new version).
8
+ *
9
+ * @functions
10
+ * → UpdateChip — small badge with popover
11
+ */
12
+ import { useState, useRef, useEffect } from 'react'
13
+ import { IconDownload, IconHistory, IconX, IconExternalLink } from '@tabler/icons-react'
14
+ import styles from './UpdateChip.module.css'
15
+
16
+ export default function UpdateChip({ updateAvailable, latestVersion, onRunUpdate, onOpenChangelog, checking }) {
17
+ const [open, setOpen] = useState(false)
18
+ const ref = useRef(null)
19
+
20
+ // 📖 Close on outside click / Esc — same pattern as the header kebab menu.
21
+ useEffect(() => {
22
+ if (!open) return
23
+ const onClick = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false) }
24
+ const onKey = (e) => { if (e.key === 'Escape') setOpen(false) }
25
+ document.addEventListener('mousedown', onClick)
26
+ document.addEventListener('keydown', onKey)
27
+ return () => {
28
+ document.removeEventListener('mousedown', onClick)
29
+ document.removeEventListener('keydown', onKey)
30
+ }
31
+ }, [open])
32
+
33
+ // 📖 No chip when no update. The M1 plan keeps the chip honest — a fresh
34
+ // 📖 install should look clean, not noisy.
35
+ if (!updateAvailable) {
36
+ if (checking) {
37
+ return (
38
+ <span className={styles.checking} title="Checking for updates…">
39
+ <span className={styles.dot} />
40
+ </span>
41
+ )
42
+ }
43
+ return null
44
+ }
45
+
46
+ return (
47
+ <div className={styles.wrap} ref={ref}>
48
+ <button
49
+ className={styles.chip}
50
+ onClick={() => setOpen((o) => !o)}
51
+ title={`Update available: v${latestVersion}. Click to install.`}
52
+ aria-expanded={open}
53
+ aria-haspopup="dialog"
54
+ >
55
+ <IconDownload size={13} stroke={1.5} />
56
+ <span>v{latestVersion}</span>
57
+ </button>
58
+
59
+ {open && (
60
+ <div className={styles.popover} role="dialog" aria-label="Update available">
61
+ <div className={styles.popoverHeader}>
62
+ <div className={styles.popoverTitle}>
63
+ <IconDownload size={14} stroke={1.5} />
64
+ <span>Update available</span>
65
+ </div>
66
+ <button
67
+ className={styles.popoverClose}
68
+ onClick={() => setOpen(false)}
69
+ aria-label="Close"
70
+ >
71
+ <IconX size={14} stroke={1.5} />
72
+ </button>
73
+ </div>
74
+ <p className={styles.popoverBody}>
75
+ A newer version (<strong>v{latestVersion}</strong>) is available on npm.
76
+ After updating, restart the dashboard to pick up the changes.
77
+ </p>
78
+ <div className={styles.popoverActions}>
79
+ <button
80
+ className={styles.primaryAction}
81
+ onClick={() => {
82
+ setOpen(false)
83
+ onRunUpdate?.()
84
+ }}
85
+ >
86
+ <IconDownload size={13} stroke={1.5} />
87
+ <span>Update now</span>
88
+ </button>
89
+ <button
90
+ className={styles.secondaryAction}
91
+ onClick={() => {
92
+ setOpen(false)
93
+ onOpenChangelog?.(latestVersion)
94
+ }}
95
+ >
96
+ <IconHistory size={13} stroke={1.5} />
97
+ <span>What's new</span>
98
+ </button>
99
+ </div>
100
+ </div>
101
+ )}
102
+ </div>
103
+ )
104
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * @file web/src/components/update/UpdateChip.module.css
3
+ * @description Styles for the header update chip + popover.
4
+ */
5
+
6
+ .wrap {
7
+ position: relative;
8
+ display: inline-flex;
9
+ }
10
+
11
+ .chip {
12
+ display: inline-flex;
13
+ align-items: center;
14
+ gap: 5px;
15
+ height: 28px;
16
+ padding: 0 10px;
17
+ font-size: 11px;
18
+ font-weight: 700;
19
+ font-family: var(--font-mono);
20
+ background: var(--color-success-dim);
21
+ color: var(--color-success);
22
+ border: 1px solid var(--color-success);
23
+ border-radius: 6px;
24
+ cursor: pointer;
25
+ font-family: var(--font-mono);
26
+ transition: all 150ms;
27
+ white-space: nowrap;
28
+ }
29
+ .chip:hover {
30
+ background: var(--color-success);
31
+ color: var(--color-bg);
32
+ }
33
+
34
+ .checking {
35
+ display: inline-flex;
36
+ align-items: center;
37
+ justify-content: center;
38
+ width: 22px;
39
+ height: 22px;
40
+ opacity: 0.5;
41
+ }
42
+ .dot {
43
+ width: 6px;
44
+ height: 6px;
45
+ border-radius: 50%;
46
+ background: var(--color-text-muted);
47
+ animation: pulse 1.5s ease-in-out infinite;
48
+ }
49
+ @keyframes pulse {
50
+ 0%, 100% { opacity: 0.3; }
51
+ 50% { opacity: 1; }
52
+ }
53
+
54
+ .popover {
55
+ position: absolute;
56
+ top: calc(100% + 6px);
57
+ right: 0;
58
+ width: 320px;
59
+ background: var(--color-bg-elevated);
60
+ border: 1px solid var(--color-border-hover);
61
+ border-radius: 10px;
62
+ box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4);
63
+ z-index: 200;
64
+ overflow: hidden;
65
+ }
66
+
67
+ .popoverHeader {
68
+ display: flex;
69
+ align-items: center;
70
+ justify-content: space-between;
71
+ padding: 10px 12px;
72
+ border-bottom: 1px solid var(--color-border);
73
+ }
74
+ .popoverTitle {
75
+ display: inline-flex;
76
+ align-items: center;
77
+ gap: 6px;
78
+ font-size: 12px;
79
+ font-weight: 700;
80
+ color: var(--color-success);
81
+ }
82
+ .popoverClose {
83
+ background: transparent;
84
+ border: none;
85
+ color: var(--color-text-muted);
86
+ cursor: pointer;
87
+ display: inline-flex;
88
+ align-items: center;
89
+ justify-content: center;
90
+ padding: 2px;
91
+ border-radius: 4px;
92
+ }
93
+ .popoverClose:hover { color: var(--color-text); background: var(--color-bg-hover); }
94
+
95
+ .popoverBody {
96
+ padding: 10px 12px;
97
+ margin: 0;
98
+ font-size: 12px;
99
+ line-height: 1.5;
100
+ color: var(--color-text);
101
+ }
102
+ .popoverBody strong {
103
+ color: var(--color-success);
104
+ font-family: var(--font-mono);
105
+ }
106
+
107
+ .popoverActions {
108
+ display: flex;
109
+ gap: 6px;
110
+ padding: 8px 12px 12px;
111
+ }
112
+
113
+ .primaryAction,
114
+ .secondaryAction {
115
+ display: inline-flex;
116
+ align-items: center;
117
+ gap: 5px;
118
+ flex: 1;
119
+ height: 32px;
120
+ padding: 0 10px;
121
+ font-size: 11px;
122
+ font-weight: 600;
123
+ border-radius: 6px;
124
+ cursor: pointer;
125
+ justify-content: center;
126
+ font-family: var(--font-sans);
127
+ transition: all 150ms;
128
+ }
129
+ .primaryAction {
130
+ background: var(--color-success);
131
+ color: var(--color-bg);
132
+ border: 1px solid var(--color-success);
133
+ }
134
+ .primaryAction:hover {
135
+ background: var(--color-success);
136
+ filter: brightness(0.9);
137
+ }
138
+ .secondaryAction {
139
+ background: var(--color-surface);
140
+ color: var(--color-text);
141
+ border: 1px solid var(--color-border);
142
+ }
143
+ .secondaryAction:hover {
144
+ background: var(--color-bg-hover);
145
+ border-color: var(--color-text-muted);
146
+ }
@@ -136,6 +136,29 @@
136
136
  /* ─── Reset & Base ─── */
137
137
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
138
138
 
139
+ /* 📖 M5: Global :focus-visible outline for keyboard navigation (a11y).
140
+ 📖 Only shows for keyboard users, not mouse clicks. Uses the accent color
141
+ 📖 with a subtle glow ring so focus is always visible but not jarring. */
142
+ :focus-visible {
143
+ outline: 2px solid var(--color-accent);
144
+ outline-offset: 2px;
145
+ border-radius: var(--radius-sm);
146
+ }
147
+
148
+ /* 📖 Remove default browser :focus ring — we only want :focus-visible */
149
+ :focus:not(:focus-visible) {
150
+ outline: none;
151
+ }
152
+
153
+ /* 📖 Inputs keep their own focus styling via component CSS, but keyboard
154
+ 📖 users still see the global ring as a fallback safety net. */
155
+ input:focus-visible,
156
+ select:focus-visible,
157
+ textarea:focus-visible {
158
+ outline: 2px solid var(--color-accent);
159
+ outline-offset: 1px;
160
+ }
161
+
139
162
  html {
140
163
  font-size: 14px;
141
164
  scroll-behavior: smooth;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * @file web/src/hooks/urlState.constants.js
3
+ * @description URL param validation tables — extracted from useUrlState for testability.
4
+ * 📖 Keep these in sync with src/tui/tui-state.js TIER_CYCLE / VERDICT_CYCLE /
5
+ * 📖 HEALTH_CYCLE constants so the URL reflects the same filter universe
6
+ * 📖 the TUI uses.
7
+ */
8
+ import { TOOL_MODE_ORDER } from '../../../src/core/tool-metadata.js'
9
+
10
+ const TIER_VALUES = new Set(['S+', 'S', 'A+', 'A', 'A-', 'B+', 'B', 'C', 'all'])
11
+ const STATUS_VALUES = new Set(['up', 'down', 'pending', 'all'])
12
+ const SORT_VALUES = new Set([
13
+ 'mood', 'idx', 'tier', 'sweScore', 'ctx', 'label', 'origin',
14
+ 'latestPing', 'avg', 'condition', 'verdict', 'stability', 'uptime',
15
+ 'aiLatency', 'tps', 'trend',
16
+ ])
17
+ const VIEW_VALUES = new Set(['dashboard', 'settings', 'analytics', 'recommend', 'router', 'help', 'changelog'])
18
+ const DIR_VALUES = new Set(['asc', 'desc'])
19
+ const TOOL_MODE_VALUES = new Set(TOOL_MODE_ORDER)
20
+
21
+ export {
22
+ TIER_VALUES as VALID_TIERS,
23
+ STATUS_VALUES as VALID_STATUS,
24
+ SORT_VALUES as VALID_SORTS,
25
+ VIEW_VALUES as VALID_VIEWS,
26
+ DIR_VALUES as VALID_DIRS,
27
+ TOOL_MODE_VALUES as VALID_TOOL_MODES,
28
+ }