@polderlabs/bizar 4.7.0 → 4.7.2
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/bizar-dash/dist/assets/main-DHZmbnxQ.js +361 -0
- package/bizar-dash/dist/assets/main-DHZmbnxQ.js.map +1 -0
- package/bizar-dash/dist/assets/main-DX_Jh8Wc.css +1 -0
- package/bizar-dash/dist/assets/{mobile-CWqPoGaT.js → mobile-BK8-ythT.js} +2 -2
- package/bizar-dash/dist/assets/mobile-BK8-ythT.js.map +1 -0
- package/bizar-dash/dist/assets/{mobile-i4Uv9eW8.js → mobile-Chvf9u_B.js} +1 -1
- package/bizar-dash/dist/assets/{mobile-i4Uv9eW8.js.map → mobile-Chvf9u_B.js.map} +1 -1
- package/bizar-dash/dist/index.html +3 -3
- package/bizar-dash/dist/mobile.html +2 -2
- package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
- package/bizar-dash/skills/publishing/SKILL.md +146 -0
- package/bizar-dash/src/server/api.mjs +8 -0
- package/bizar-dash/src/server/backup-store.mjs +525 -0
- package/bizar-dash/src/server/digest-store.mjs +558 -0
- package/bizar-dash/src/server/lib/rate-limit.mjs +122 -0
- package/bizar-dash/src/server/routes/backup.mjs +112 -0
- package/bizar-dash/src/server/routes/chat.mjs +14 -0
- package/bizar-dash/src/server/routes/digests.mjs +82 -0
- package/bizar-dash/src/server/routes-v2/events.mjs +14 -0
- package/bizar-dash/src/server/schedules-runner.mjs +126 -0
- package/bizar-dash/src/server/server.mjs +17 -0
- package/bizar-dash/src/web/App.tsx +8 -1
- package/bizar-dash/src/web/components/BackupRestore.tsx +330 -0
- package/bizar-dash/src/web/components/SearchModal.tsx +3 -1
- package/bizar-dash/src/web/components/chat/Composer.tsx +2 -0
- package/bizar-dash/src/web/styles/main.css +70 -8
- package/bizar-dash/src/web/views/Activity.tsx +11 -1
- package/bizar-dash/src/web/views/Agents.tsx +57 -42
- package/bizar-dash/src/web/views/Artifacts.tsx +38 -25
- package/bizar-dash/src/web/views/Chat.tsx +8 -0
- package/bizar-dash/src/web/views/History.tsx +4 -1
- package/bizar-dash/src/web/views/MiniMaxUsage.tsx +21 -4
- package/bizar-dash/src/web/views/Mods.tsx +30 -17
- package/bizar-dash/src/web/views/Overview.tsx +8 -1
- package/bizar-dash/src/web/views/Providers.tsx +16 -16
- package/bizar-dash/src/web/views/Schedules.tsx +33 -15
- package/bizar-dash/src/web/views/Settings.tsx +97 -1751
- package/bizar-dash/src/web/views/Skills.tsx +4 -1
- package/bizar-dash/src/web/views/Tasks.tsx +11 -2
- package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +8 -2
- package/bizar-dash/src/web/views/memory/LightragPanel.tsx +3 -0
- package/bizar-dash/src/web/views/memory/ObsidianPanel.tsx +12 -4
- package/bizar-dash/src/web/views/memory/SemanticSearchPanel.tsx +3 -0
- package/bizar-dash/src/web/views/settings/ActivitySection.tsx +205 -0
- package/bizar-dash/src/web/views/settings/AgentSection.tsx +294 -0
- package/bizar-dash/src/web/views/settings/AuthSection.tsx +159 -0
- package/bizar-dash/src/web/views/settings/BackupSection.tsx +16 -0
- package/bizar-dash/src/web/views/settings/EnvVarsSection.tsx +16 -0
- package/bizar-dash/src/web/views/settings/GeneralSection.tsx +105 -0
- package/bizar-dash/src/web/views/settings/HeadroomSection.tsx +39 -0
- package/bizar-dash/src/web/views/settings/MemorySection.tsx +16 -0
- package/bizar-dash/src/web/views/settings/NetworkSection.tsx +87 -0
- package/bizar-dash/src/web/views/settings/NotificationsSection.tsx +34 -0
- package/bizar-dash/src/web/views/settings/ProvidersSection.tsx +16 -0
- package/bizar-dash/src/web/views/settings/SkillsSection.tsx +16 -0
- package/bizar-dash/src/web/views/settings/SystemLlmSection.tsx +81 -0
- package/bizar-dash/src/web/views/settings/ThemeSection.tsx +168 -0
- package/bizar-dash/src/web/views/settings/UpdatesSection.tsx +256 -0
- package/bizar-dash/tests/a11y.test.tsx +206 -0
- package/bizar-dash/tests/backup-restore.test.mjs +217 -0
- package/bizar-dash/tests/backup-restore.test.tsx +123 -0
- package/bizar-dash/tests/backup-store.test.mjs +300 -0
- package/bizar-dash/tests/cli-error-visibility.test.mjs +153 -0
- package/bizar-dash/tests/digest-generation.test.mjs +191 -0
- package/bizar-dash/tests/digest-store.test.mjs +264 -0
- package/bizar-dash/tests/rate-limit.test.mjs +298 -0
- package/cli/bin.mjs +96 -2
- package/cli/commands/minimax.mjs +20 -0
- package/cli/commands/util.mjs +154 -1
- package/cli/digest.mjs +149 -0
- package/package.json +1 -1
- package/bizar-dash/dist/assets/main-DAlLdW8I.css +0 -1
- package/bizar-dash/dist/assets/main-DGGq-iZI.js +0 -361
- package/bizar-dash/dist/assets/main-DGGq-iZI.js.map +0 -1
- package/bizar-dash/dist/assets/mobile-CWqPoGaT.js.map +0 -1
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
// src/web/components/BackupRestore.tsx — v4.8.0 Backup/restore card for Settings.
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useState, useCallback } from 'react';
|
|
4
|
+
import { Download, Upload, Trash2, RefreshCw, CheckCircle, AlertCircle, Archive } from 'lucide-react';
|
|
5
|
+
import { Button } from './Button';
|
|
6
|
+
import { Card, CardTitle, CardMeta } from './Card';
|
|
7
|
+
import { useModal } from './Modal';
|
|
8
|
+
import { useToast } from './Toast';
|
|
9
|
+
import { api } from '../lib/api';
|
|
10
|
+
import { cn } from '../lib/utils';
|
|
11
|
+
|
|
12
|
+
type BackupEntry = {
|
|
13
|
+
path: string;
|
|
14
|
+
name: string;
|
|
15
|
+
createdAt: string;
|
|
16
|
+
sizeBytes: number;
|
|
17
|
+
sizeFormatted: string;
|
|
18
|
+
manifest: {
|
|
19
|
+
version?: string;
|
|
20
|
+
createdAt?: string;
|
|
21
|
+
label?: string;
|
|
22
|
+
paths?: string[];
|
|
23
|
+
} | null;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type RestoreResult = {
|
|
27
|
+
ok: boolean;
|
|
28
|
+
restored: string[];
|
|
29
|
+
skipped: string[];
|
|
30
|
+
errors: string[];
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function RestoreDialog({
|
|
34
|
+
backup,
|
|
35
|
+
onClose,
|
|
36
|
+
}: {
|
|
37
|
+
backup: BackupEntry;
|
|
38
|
+
onClose: () => void;
|
|
39
|
+
}) {
|
|
40
|
+
const toast = useToast();
|
|
41
|
+
const [dryRun, setDryRun] = useState(true);
|
|
42
|
+
const [strategy, setStrategy] = useState<'overwrite' | 'merge' | 'skip'>('merge');
|
|
43
|
+
const [loading, setLoading] = useState(false);
|
|
44
|
+
const [result, setResult] = useState<RestoreResult | null>(null);
|
|
45
|
+
|
|
46
|
+
const doRestore = async () => {
|
|
47
|
+
setLoading(true);
|
|
48
|
+
setResult(null);
|
|
49
|
+
try {
|
|
50
|
+
const r = await api.post<RestoreResult>('/backup/restore', {
|
|
51
|
+
backupPath: backup.path,
|
|
52
|
+
dryRun,
|
|
53
|
+
conflictStrategy: strategy,
|
|
54
|
+
});
|
|
55
|
+
setResult(r);
|
|
56
|
+
if (!r.ok && r.errors.length > 0) {
|
|
57
|
+
toast.error(`Restore completed with errors.`);
|
|
58
|
+
} else if (r.ok && !dryRun) {
|
|
59
|
+
toast.success('Restore complete.');
|
|
60
|
+
onClose();
|
|
61
|
+
}
|
|
62
|
+
} catch (err) {
|
|
63
|
+
toast.error(`Restore failed: ${(err as Error).message}`);
|
|
64
|
+
} finally {
|
|
65
|
+
setLoading(false);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
|
71
|
+
{backup.manifest && (
|
|
72
|
+
<div style={{ fontSize: 12, color: 'var(--text-muted, #8b949e)' }}>
|
|
73
|
+
<p><strong>Created:</strong> {new Date(backup.createdAt).toLocaleString()}</p>
|
|
74
|
+
<p><strong>Version:</strong> {backup.manifest.version || 'unknown'}</p>
|
|
75
|
+
<p><strong>Contents:</strong> {backup.manifest.paths?.join(', ') || 'unknown'}</p>
|
|
76
|
+
{backup.manifest.label && <p><strong>Label:</strong> {backup.manifest.label}</p>}
|
|
77
|
+
</div>
|
|
78
|
+
)}
|
|
79
|
+
|
|
80
|
+
<label className="checkbox-row">
|
|
81
|
+
<input
|
|
82
|
+
type="checkbox"
|
|
83
|
+
checked={dryRun}
|
|
84
|
+
onChange={(e) => setDryRun(e.target.checked)}
|
|
85
|
+
/>
|
|
86
|
+
<span>Dry-run (preview only — no files will be modified)</span>
|
|
87
|
+
</label>
|
|
88
|
+
|
|
89
|
+
<div className="field">
|
|
90
|
+
<label className="field-label">Conflict strategy</label>
|
|
91
|
+
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 6 }}>
|
|
92
|
+
{(['overwrite', 'merge', 'skip'] as const).map((s) => (
|
|
93
|
+
<button
|
|
94
|
+
key={s}
|
|
95
|
+
type="button"
|
|
96
|
+
className={cn('theme-card', strategy === s && 'theme-card-active')}
|
|
97
|
+
onClick={() => setStrategy(s)}
|
|
98
|
+
>
|
|
99
|
+
<span className="theme-card-label" style={{ fontSize: 12 }}>
|
|
100
|
+
{s === 'overwrite' ? 'Replace all' : s === 'merge' ? 'Merge (newer wins)' : 'Skip existing'}
|
|
101
|
+
</span>
|
|
102
|
+
</button>
|
|
103
|
+
))}
|
|
104
|
+
</div>
|
|
105
|
+
<p className="field-help">
|
|
106
|
+
{strategy === 'overwrite' && 'Replace every file in the backup — existing files will be overwritten.'}
|
|
107
|
+
{strategy === 'merge' && 'Copy newer files from backup over existing files. Existing files are kept if they are newer.'}
|
|
108
|
+
{strategy === 'skip' && 'Only create files that don\'t already exist. Existing files are never modified.'}
|
|
109
|
+
</p>
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
{result && (
|
|
113
|
+
<div style={{ fontSize: 12, padding: 12, background: 'var(--surface-2, #161b22)', borderRadius: 8 }}>
|
|
114
|
+
{result.restored.length > 0 && (
|
|
115
|
+
<p style={{ color: 'var(--success, #3fb950)' }}>
|
|
116
|
+
<CheckCircle size={12} style={{ display: 'inline', marginRight: 4 }} />
|
|
117
|
+
Restored: {result.restored.join(', ')}
|
|
118
|
+
</p>
|
|
119
|
+
)}
|
|
120
|
+
{result.skipped.length > 0 && (
|
|
121
|
+
<p style={{ color: 'var(--text-muted, #8b949e)' }}>
|
|
122
|
+
Skipped: {result.skipped.join(', ')}
|
|
123
|
+
</p>
|
|
124
|
+
)}
|
|
125
|
+
{result.errors.length > 0 && (
|
|
126
|
+
<p style={{ color: 'var(--error, #f85149)' }}>
|
|
127
|
+
<AlertCircle size={12} style={{ display: 'inline', marginRight: 4 }} />
|
|
128
|
+
Errors: {result.errors.join(', ')}
|
|
129
|
+
</p>
|
|
130
|
+
)}
|
|
131
|
+
</div>
|
|
132
|
+
)}
|
|
133
|
+
|
|
134
|
+
<div className="modal-footer-actions">
|
|
135
|
+
<Button variant="ghost" onClick={onClose}>Cancel</Button>
|
|
136
|
+
<Button
|
|
137
|
+
variant="primary"
|
|
138
|
+
disabled={loading}
|
|
139
|
+
onClick={doRestore}
|
|
140
|
+
>
|
|
141
|
+
{loading ? <span className="btn-spinner" /> : null}
|
|
142
|
+
{dryRun ? 'Preview restore' : 'Restore'}
|
|
143
|
+
</Button>
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function BackupRestoreCard() {
|
|
150
|
+
const toast = useToast();
|
|
151
|
+
const modal = useModal();
|
|
152
|
+
const [backups, setBackups] = useState<BackupEntry[]>([]);
|
|
153
|
+
const [loading, setLoading] = useState(true);
|
|
154
|
+
const [creating, setCreating] = useState(false);
|
|
155
|
+
const [label, setLabel] = useState('');
|
|
156
|
+
|
|
157
|
+
const loadBackups = useCallback(async () => {
|
|
158
|
+
setLoading(true);
|
|
159
|
+
try {
|
|
160
|
+
const r = await api.get<{ ok: boolean; backups: BackupEntry[] }>('/backup/list');
|
|
161
|
+
setBackups(r.backups || []);
|
|
162
|
+
} catch (err) {
|
|
163
|
+
toast.error(`Load failed: ${(err as Error).message}`);
|
|
164
|
+
} finally {
|
|
165
|
+
setLoading(false);
|
|
166
|
+
}
|
|
167
|
+
}, [toast]);
|
|
168
|
+
|
|
169
|
+
useEffect(() => { loadBackups(); }, [loadBackups]);
|
|
170
|
+
|
|
171
|
+
const onCreate = async () => {
|
|
172
|
+
setCreating(true);
|
|
173
|
+
try {
|
|
174
|
+
const r = await api.post<{ ok: boolean; path: string; sizeBytes: number; durationMs: number }>('/backup/create', {
|
|
175
|
+
label: label.trim() || null,
|
|
176
|
+
});
|
|
177
|
+
if (r.ok) {
|
|
178
|
+
toast.success('Backup created.');
|
|
179
|
+
setLabel('');
|
|
180
|
+
await loadBackups();
|
|
181
|
+
} else {
|
|
182
|
+
toast.error('Backup failed.');
|
|
183
|
+
}
|
|
184
|
+
} catch (err) {
|
|
185
|
+
toast.error(`Create failed: ${(err as Error).message}`);
|
|
186
|
+
} finally {
|
|
187
|
+
setCreating(false);
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const onVerify = async (backup: BackupEntry) => {
|
|
192
|
+
try {
|
|
193
|
+
const r = await api.post<{ ok: boolean; issues: string[] }>('/backup/verify', {
|
|
194
|
+
backupPath: backup.path,
|
|
195
|
+
});
|
|
196
|
+
if (r.ok) {
|
|
197
|
+
toast.success('Backup is valid.');
|
|
198
|
+
} else {
|
|
199
|
+
toast.error(`Backup has issues: ${r.issues.join(', ')}`);
|
|
200
|
+
}
|
|
201
|
+
} catch (err) {
|
|
202
|
+
toast.error(`Verify failed: ${(err as Error).message}`);
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const onDelete = async (backup: BackupEntry) => {
|
|
207
|
+
if (!confirm(`Delete backup?\n\n${backup.path}\n\nThis cannot be undone.`)) return;
|
|
208
|
+
try {
|
|
209
|
+
const encoded = encodeURIComponent(backup.path);
|
|
210
|
+
await api.del(`/backup/${encoded}`);
|
|
211
|
+
toast.success('Backup deleted.');
|
|
212
|
+
await loadBackups();
|
|
213
|
+
} catch (err) {
|
|
214
|
+
toast.error(`Delete failed: ${(err as Error).message}`);
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const onRestore = (backup: BackupEntry) => {
|
|
219
|
+
modal.open({
|
|
220
|
+
title: `Restore backup`,
|
|
221
|
+
children: <RestoreDialog backup={backup} onClose={() => modal.close()} />,
|
|
222
|
+
width: 520,
|
|
223
|
+
});
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const recent = backups.slice(0, 10);
|
|
227
|
+
|
|
228
|
+
return (
|
|
229
|
+
<Card id="settings-backup" data-section="backup">
|
|
230
|
+
<CardTitle><Archive size={14} /> Backup & Restore</CardTitle>
|
|
231
|
+
<CardMeta>
|
|
232
|
+
Snapshot your BizarHarness config, memory, and usage logs.
|
|
233
|
+
Backups are stored under <code>~/.local/share/bizar/backups/</code>.
|
|
234
|
+
</CardMeta>
|
|
235
|
+
|
|
236
|
+
{/* Create backup */}
|
|
237
|
+
<div style={{ display: 'flex', gap: 8, marginTop: 12, marginBottom: 16, flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
|
238
|
+
<div className="field" style={{ flex: '1 1 200px', margin: 0 }}>
|
|
239
|
+
<label className="field-label" htmlFor="backup-label" style={{ fontSize: 12 }}>
|
|
240
|
+
Label <span className="muted">(optional)</span>
|
|
241
|
+
</label>
|
|
242
|
+
<input
|
|
243
|
+
id="backup-label"
|
|
244
|
+
type="text"
|
|
245
|
+
className="input"
|
|
246
|
+
placeholder="e.g. before-upgrade"
|
|
247
|
+
value={label}
|
|
248
|
+
onChange={(e) => setLabel(e.target.value)}
|
|
249
|
+
onKeyDown={(e) => { if (e.key === 'Enter') onCreate(); }}
|
|
250
|
+
style={{ fontSize: 13 }}
|
|
251
|
+
/>
|
|
252
|
+
</div>
|
|
253
|
+
<Button variant="primary" onClick={onCreate} disabled={creating}>
|
|
254
|
+
{creating ? <span className="btn-spinner" /> : <Download size={14} />}
|
|
255
|
+
{creating ? 'Creating…' : 'Create backup'}
|
|
256
|
+
</Button>
|
|
257
|
+
</div>
|
|
258
|
+
|
|
259
|
+
{/* Backup list */}
|
|
260
|
+
{loading ? (
|
|
261
|
+
<div className="muted" style={{ fontSize: 12, padding: '12px 0' }}>Loading backups…</div>
|
|
262
|
+
) : recent.length === 0 ? (
|
|
263
|
+
<div className="muted" style={{ fontSize: 12, padding: '12px 0' }}>
|
|
264
|
+
No backups yet. Create one to protect your configuration.
|
|
265
|
+
</div>
|
|
266
|
+
) : (
|
|
267
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
268
|
+
{recent.map((b) => (
|
|
269
|
+
<div
|
|
270
|
+
key={b.path}
|
|
271
|
+
style={{
|
|
272
|
+
display: 'flex',
|
|
273
|
+
alignItems: 'center',
|
|
274
|
+
gap: 8,
|
|
275
|
+
padding: '8px 10px',
|
|
276
|
+
borderRadius: 6,
|
|
277
|
+
border: '1px solid var(--border, #30363d)',
|
|
278
|
+
background: 'var(--surface-2, #161b22)',
|
|
279
|
+
flexWrap: 'wrap',
|
|
280
|
+
}}
|
|
281
|
+
>
|
|
282
|
+
<div style={{ flex: '1 1 200px', minWidth: 0 }}>
|
|
283
|
+
<div style={{ fontSize: 12, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
284
|
+
{b.name}
|
|
285
|
+
</div>
|
|
286
|
+
<div style={{ fontSize: 11, color: 'var(--text-muted, #8b949e)' }}>
|
|
287
|
+
{new Date(b.createdAt).toLocaleString()} · {b.sizeFormatted}
|
|
288
|
+
{b.manifest?.label && <> · <em>{b.manifest.label}</em></>}
|
|
289
|
+
{b.manifest?.version && <> · v{b.manifest.version}</>}
|
|
290
|
+
</div>
|
|
291
|
+
</div>
|
|
292
|
+
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
|
|
293
|
+
<Button
|
|
294
|
+
variant="secondary"
|
|
295
|
+
size="sm"
|
|
296
|
+
onClick={() => onRestore(b)}
|
|
297
|
+
title="Restore from this backup"
|
|
298
|
+
>
|
|
299
|
+
<Upload size={12} /> Restore
|
|
300
|
+
</Button>
|
|
301
|
+
<Button
|
|
302
|
+
variant="ghost"
|
|
303
|
+
size="sm"
|
|
304
|
+
onClick={() => onVerify(b)}
|
|
305
|
+
title="Verify backup integrity"
|
|
306
|
+
>
|
|
307
|
+
<RefreshCw size={12} />
|
|
308
|
+
</Button>
|
|
309
|
+
<Button
|
|
310
|
+
variant="ghost"
|
|
311
|
+
size="sm"
|
|
312
|
+
onClick={() => onDelete(b)}
|
|
313
|
+
title="Delete this backup"
|
|
314
|
+
style={{ color: 'var(--error, #f85149)' }}
|
|
315
|
+
>
|
|
316
|
+
<Trash2 size={12} />
|
|
317
|
+
</Button>
|
|
318
|
+
</div>
|
|
319
|
+
</div>
|
|
320
|
+
))}
|
|
321
|
+
{backups.length > 10 && (
|
|
322
|
+
<div style={{ fontSize: 11, color: 'var(--text-muted, #8b949e)', textAlign: 'center', padding: '4px 0' }}>
|
|
323
|
+
+ {backups.length - 10} older backup{backups.length - 10 === 1 ? '' : 's'}
|
|
324
|
+
</div>
|
|
325
|
+
)}
|
|
326
|
+
</div>
|
|
327
|
+
)}
|
|
328
|
+
</Card>
|
|
329
|
+
);
|
|
330
|
+
}
|
|
@@ -149,11 +149,13 @@ export function SearchModal({ open, onClose, onSelect }: Props) {
|
|
|
149
149
|
<X size={14} />
|
|
150
150
|
</button>
|
|
151
151
|
</div>
|
|
152
|
-
<div className="search-modal-scopes">
|
|
152
|
+
<div className="search-modal-scopes" role="tablist" aria-label="Search scope">
|
|
153
153
|
{SCOPES.map((s) => (
|
|
154
154
|
<button
|
|
155
155
|
key={s.id}
|
|
156
156
|
type="button"
|
|
157
|
+
role="tab"
|
|
158
|
+
aria-selected={scope === s.id}
|
|
157
159
|
className={cn('search-scope', scope === s.id && 'search-scope-active')}
|
|
158
160
|
onClick={() => setScope(s.id)}
|
|
159
161
|
>
|
|
@@ -101,6 +101,41 @@ pre,
|
|
|
101
101
|
border: 0;
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
/* v4.8.0 — WCAG 2.2 AA: skip-to-main-content link. Visually hidden
|
|
105
|
+
until focused (keyboard tab), then pops in at the top-left so
|
|
106
|
+
users can jump past the topbar + sidebar into the active view.
|
|
107
|
+
Anchors must be focusable by default; we just hide them visually. */
|
|
108
|
+
.skip-to-main {
|
|
109
|
+
position: absolute;
|
|
110
|
+
top: -100px;
|
|
111
|
+
left: var(--space-2);
|
|
112
|
+
z-index: 9999;
|
|
113
|
+
padding: 10px 14px;
|
|
114
|
+
background: var(--accent);
|
|
115
|
+
color: #ffffff;
|
|
116
|
+
border-radius: var(--radius-sm);
|
|
117
|
+
font-size: 13px;
|
|
118
|
+
font-weight: 600;
|
|
119
|
+
text-decoration: none;
|
|
120
|
+
box-shadow: var(--shadow-2);
|
|
121
|
+
transition: top 120ms var(--ease);
|
|
122
|
+
}
|
|
123
|
+
.skip-to-main:focus,
|
|
124
|
+
.skip-to-main:focus-visible {
|
|
125
|
+
top: var(--space-2);
|
|
126
|
+
outline: 2px solid var(--accent-2);
|
|
127
|
+
outline-offset: 2px;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/* v4.8.0 — Make <main> programmatically focusable for the skip link
|
|
131
|
+
without a visible focus ring (it has no interactive content). */
|
|
132
|
+
#main-content:focus {
|
|
133
|
+
outline: none;
|
|
134
|
+
}
|
|
135
|
+
#main-content:focus-visible {
|
|
136
|
+
outline: none;
|
|
137
|
+
}
|
|
138
|
+
|
|
104
139
|
/* ─── Theme tokens ─────────────────────────────────────────────────── */
|
|
105
140
|
|
|
106
141
|
:root {
|
|
@@ -258,10 +293,10 @@ pre,
|
|
|
258
293
|
--accent-glow: rgba(124, 58, 237, 0.18);
|
|
259
294
|
--accent-soft: rgba(124, 58, 237, 0.06);
|
|
260
295
|
|
|
261
|
-
--success: #
|
|
262
|
-
--warning: #
|
|
263
|
-
--error: #
|
|
264
|
-
--info: #
|
|
296
|
+
--success: #047857;
|
|
297
|
+
--warning: #b45309;
|
|
298
|
+
--error: #b91c1c;
|
|
299
|
+
--info: #1d4ed8;
|
|
265
300
|
--success-soft: rgba(5, 150, 105, 0.12);
|
|
266
301
|
--error-soft: rgba(220, 38, 38, 0.08);
|
|
267
302
|
--warning-soft: rgba(217, 119, 6, 0.12);
|
|
@@ -281,10 +316,12 @@ pre,
|
|
|
281
316
|
/* v3.21.x — Surface and semantic tokens (light theme overrides). */
|
|
282
317
|
--surface-1: color-mix(in oklab, var(--bg) 92%, var(--text) 8%);
|
|
283
318
|
--surface-2: color-mix(in oklab, var(--bg) 84%, var(--text) 16%);
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
--
|
|
287
|
-
--
|
|
319
|
+
/* v4.8.0 — Darken light-theme status colors so they pass WCAG AA (4.5:1)
|
|
320
|
+
against the light surface (#f7f8fa). */
|
|
321
|
+
--success: oklch(0.48 0.14 145);
|
|
322
|
+
--warning: oklch(0.55 0.15 70);
|
|
323
|
+
--error: oklch(0.48 0.20 25);
|
|
324
|
+
--info: oklch(0.48 0.16 235);
|
|
288
325
|
--bg-1: #f4f4f5;
|
|
289
326
|
--bg-2: #e4e4e7;
|
|
290
327
|
|
|
@@ -945,6 +982,17 @@ pre,
|
|
|
945
982
|
box-shadow: 0 0 0 3px var(--accent-bg);
|
|
946
983
|
}
|
|
947
984
|
|
|
985
|
+
/* v4.8.0 — Placeholder color must pass AA (4.5:1) against input
|
|
986
|
+
backgrounds on both themes. Default browser placeholder is too
|
|
987
|
+
light on dark and too dark on light, so we pin to --text-dim
|
|
988
|
+
which is computed to ~9:1 (dark) / ~6:1 (light). */
|
|
989
|
+
.input::placeholder,
|
|
990
|
+
.textarea::placeholder,
|
|
991
|
+
.select::placeholder {
|
|
992
|
+
color: var(--text-dim);
|
|
993
|
+
opacity: 1;
|
|
994
|
+
}
|
|
995
|
+
|
|
948
996
|
.textarea.invalid {
|
|
949
997
|
border-color: var(--error);
|
|
950
998
|
}
|
|
@@ -5004,6 +5052,12 @@ kbd {
|
|
|
5004
5052
|
cursor: pointer;
|
|
5005
5053
|
user-select: none;
|
|
5006
5054
|
transition: background var(--motion-fast) var(--ease);
|
|
5055
|
+
background: transparent;
|
|
5056
|
+
border: 0;
|
|
5057
|
+
color: inherit;
|
|
5058
|
+
text-align: left;
|
|
5059
|
+
width: 100%;
|
|
5060
|
+
font: inherit;
|
|
5007
5061
|
}
|
|
5008
5062
|
|
|
5009
5063
|
.mods-registry-head:hover {
|
|
@@ -5214,6 +5268,14 @@ kbd {
|
|
|
5214
5268
|
cursor: pointer;
|
|
5215
5269
|
user-select: none;
|
|
5216
5270
|
padding: 4px 0;
|
|
5271
|
+
background: transparent;
|
|
5272
|
+
border: 0;
|
|
5273
|
+
color: inherit;
|
|
5274
|
+
text-align: left;
|
|
5275
|
+
font: inherit;
|
|
5276
|
+
}
|
|
5277
|
+
button.mod-instructions-head {
|
|
5278
|
+
width: 100%;
|
|
5217
5279
|
}
|
|
5218
5280
|
.mod-instructions-head:hover { color: var(--accent); }
|
|
5219
5281
|
.mod-instructions-spacer { flex: 1; }
|
|
@@ -1109,7 +1109,9 @@ export function Activity({ snapshot, refreshSnapshot }: Props) {
|
|
|
1109
1109
|
</Button>
|
|
1110
1110
|
</div>
|
|
1111
1111
|
<div className="tl-form-row">
|
|
1112
|
+
<label htmlFor="bg-message-input" className="sr-only">Send a message to this bg session</label>
|
|
1112
1113
|
<input
|
|
1114
|
+
id="bg-message-input"
|
|
1113
1115
|
className="input"
|
|
1114
1116
|
placeholder="Send a message to this bg session…"
|
|
1115
1117
|
value={bgMessage}
|
|
@@ -1117,6 +1119,7 @@ export function Activity({ snapshot, refreshSnapshot }: Props) {
|
|
|
1117
1119
|
onKeyDown={(e) => {
|
|
1118
1120
|
if (e.key === 'Enter') onSendBgMessage();
|
|
1119
1121
|
}}
|
|
1122
|
+
aria-label="Send a message to this bg session"
|
|
1120
1123
|
/>
|
|
1121
1124
|
<Button variant="primary" size="sm" disabled={!bgMessage.trim()} onClick={onSendBgMessage}>
|
|
1122
1125
|
<Send size={12} /> Send
|
|
@@ -1127,18 +1130,22 @@ export function Activity({ snapshot, refreshSnapshot }: Props) {
|
|
|
1127
1130
|
|
|
1128
1131
|
{selectedItem.kind !== 'bg' && (
|
|
1129
1132
|
<div className="tl-detail-create">
|
|
1130
|
-
<
|
|
1133
|
+
<label className="field-label" htmlFor="followup-task-title">Create follow-up task</label>
|
|
1131
1134
|
<input
|
|
1135
|
+
id="followup-task-title"
|
|
1132
1136
|
className="input"
|
|
1133
1137
|
placeholder="Task title"
|
|
1134
1138
|
value={taskTitle}
|
|
1135
1139
|
onChange={(e) => setTaskTitle(e.target.value)}
|
|
1136
1140
|
/>
|
|
1137
1141
|
<div className="tl-form-row">
|
|
1142
|
+
<label htmlFor="followup-task-priority" className="sr-only">Task priority</label>
|
|
1138
1143
|
<select
|
|
1144
|
+
id="followup-task-priority"
|
|
1139
1145
|
className="select"
|
|
1140
1146
|
value={taskPriority}
|
|
1141
1147
|
onChange={(e) => setTaskPriority(e.target.value)}
|
|
1148
|
+
aria-label="Task priority"
|
|
1142
1149
|
>
|
|
1143
1150
|
<option value="low">Low</option>
|
|
1144
1151
|
<option value="normal">Normal</option>
|
|
@@ -1180,7 +1187,9 @@ export function Activity({ snapshot, refreshSnapshot }: Props) {
|
|
|
1180
1187
|
))}
|
|
1181
1188
|
</ul>
|
|
1182
1189
|
<div className="comment-input-row">
|
|
1190
|
+
<label htmlFor="comment-input" className="sr-only">Add a comment</label>
|
|
1183
1191
|
<input
|
|
1192
|
+
id="comment-input"
|
|
1184
1193
|
className="input"
|
|
1185
1194
|
placeholder="Add a comment…"
|
|
1186
1195
|
value={commentText}
|
|
@@ -1188,6 +1197,7 @@ export function Activity({ snapshot, refreshSnapshot }: Props) {
|
|
|
1188
1197
|
onKeyDown={(e) => {
|
|
1189
1198
|
if (e.key === 'Enter' && !e.shiftKey) onAddComment();
|
|
1190
1199
|
}}
|
|
1200
|
+
aria-label="Add a comment"
|
|
1191
1201
|
/>
|
|
1192
1202
|
<Button
|
|
1193
1203
|
variant="secondary"
|