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.
- package/README.md +19 -11
- package/bin/free-coding-models.js +6 -0
- package/changelog/v0.5.5.md +1 -0
- package/changelog/v0.5.6.md +24 -0
- package/changelog/v0.5.7.md +20 -0
- package/package.json +4 -4
- package/src/core/changelog-loader.js +5 -1
- package/src/core/endpoint-installer.js +3 -3
- package/src/core/router-daemon.js +11 -0
- package/src/core/tool-launchers.js +2 -2
- package/web/dist/assets/index-DKmmiDip.css +1 -0
- package/web/dist/assets/index-DzVt42Nf.js +39 -0
- package/web/dist/index.html +2 -2
- package/web/server.js +532 -2
- package/web/src/App.jsx +235 -73
- package/web/src/components/analytics/AnalyticsView.jsx +4 -0
- package/web/src/components/analytics/TokenUsagePanel.jsx +105 -0
- package/web/src/components/analytics/TokenUsagePanel.module.css +126 -0
- package/web/src/components/atoms/TierBadge.module.css +3 -3
- package/web/src/components/changelog/ChangelogView.jsx +135 -0
- package/web/src/components/changelog/ChangelogView.module.css +160 -0
- package/web/src/components/dashboard/DetailPanel.jsx +29 -4
- package/web/src/components/dashboard/DetailPanel.module.css +26 -0
- package/web/src/components/dashboard/FilterBar.jsx +17 -2
- package/web/src/components/dashboard/FilterBar.module.css +17 -0
- package/web/src/components/dashboard/ModelTable.jsx +17 -5
- package/web/src/components/dashboard/ModelTable.module.css +14 -1
- package/web/src/components/help/HelpView.jsx +188 -0
- package/web/src/components/help/HelpView.module.css +157 -0
- package/web/src/components/install/InstallEndpointsView.jsx +278 -0
- package/web/src/components/install/InstallEndpointsView.module.css +351 -0
- package/web/src/components/installed/InstalledModelsView.jsx +89 -0
- package/web/src/components/installed/InstalledModelsView.module.css +200 -0
- package/web/src/components/launch/IncompatibleFallbackModal.jsx +69 -0
- package/web/src/components/launch/LaunchButton.jsx +30 -0
- package/web/src/components/launch/LaunchButton.module.css +35 -0
- package/web/src/components/launch/LaunchModal.module.css +125 -0
- package/web/src/components/layout/Header.jsx +78 -14
- package/web/src/components/layout/Header.module.css +62 -2
- package/web/src/components/palette/CommandPalette.jsx +228 -74
- package/web/src/components/recommend/RecommendView.jsx +121 -0
- package/web/src/components/recommend/RecommendView.module.css +55 -0
- package/web/src/components/router/RouterView.jsx +286 -0
- package/web/src/components/router/RouterView.module.css +357 -0
- package/web/src/components/settings/SettingsView.jsx +281 -8
- package/web/src/components/settings/SettingsView.module.css +174 -0
- package/web/src/components/tools/ToolPicker.jsx +86 -0
- package/web/src/components/tools/ToolPicker.module.css +84 -0
- package/web/src/components/update/UpdateChip.jsx +104 -0
- package/web/src/components/update/UpdateChip.module.css +146 -0
- package/web/src/global.css +23 -0
- package/web/src/hooks/urlState.constants.js +28 -0
- package/web/src/hooks/useChangelog.js +51 -0
- package/web/src/hooks/useInstalledModels.js +42 -0
- package/web/src/hooks/useRecommend.js +67 -0
- package/web/src/hooks/useRouterDashboard.js +133 -0
- package/web/src/hooks/useTokenUsage.js +103 -0
- package/web/src/hooks/useToolMode.js +77 -0
- package/web/src/hooks/useUpdateChecker.js +91 -0
- package/web/src/hooks/useUrlState.js +123 -62
- package/web/src/utils/m3.js +55 -0
- package/web/dist/assets/index-uD3faN3G.js +0 -39
- 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
|
+
}
|
package/web/src/global.css
CHANGED
|
@@ -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
|
+
}
|