@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.
Files changed (75) hide show
  1. package/bizar-dash/dist/assets/main-DHZmbnxQ.js +361 -0
  2. package/bizar-dash/dist/assets/main-DHZmbnxQ.js.map +1 -0
  3. package/bizar-dash/dist/assets/main-DX_Jh8Wc.css +1 -0
  4. package/bizar-dash/dist/assets/{mobile-CWqPoGaT.js → mobile-BK8-ythT.js} +2 -2
  5. package/bizar-dash/dist/assets/mobile-BK8-ythT.js.map +1 -0
  6. package/bizar-dash/dist/assets/{mobile-i4Uv9eW8.js → mobile-Chvf9u_B.js} +1 -1
  7. package/bizar-dash/dist/assets/{mobile-i4Uv9eW8.js.map → mobile-Chvf9u_B.js.map} +1 -1
  8. package/bizar-dash/dist/index.html +3 -3
  9. package/bizar-dash/dist/mobile.html +2 -2
  10. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  11. package/bizar-dash/skills/publishing/SKILL.md +146 -0
  12. package/bizar-dash/src/server/api.mjs +8 -0
  13. package/bizar-dash/src/server/backup-store.mjs +525 -0
  14. package/bizar-dash/src/server/digest-store.mjs +558 -0
  15. package/bizar-dash/src/server/lib/rate-limit.mjs +122 -0
  16. package/bizar-dash/src/server/routes/backup.mjs +112 -0
  17. package/bizar-dash/src/server/routes/chat.mjs +14 -0
  18. package/bizar-dash/src/server/routes/digests.mjs +82 -0
  19. package/bizar-dash/src/server/routes-v2/events.mjs +14 -0
  20. package/bizar-dash/src/server/schedules-runner.mjs +126 -0
  21. package/bizar-dash/src/server/server.mjs +17 -0
  22. package/bizar-dash/src/web/App.tsx +8 -1
  23. package/bizar-dash/src/web/components/BackupRestore.tsx +330 -0
  24. package/bizar-dash/src/web/components/SearchModal.tsx +3 -1
  25. package/bizar-dash/src/web/components/chat/Composer.tsx +2 -0
  26. package/bizar-dash/src/web/styles/main.css +70 -8
  27. package/bizar-dash/src/web/views/Activity.tsx +11 -1
  28. package/bizar-dash/src/web/views/Agents.tsx +57 -42
  29. package/bizar-dash/src/web/views/Artifacts.tsx +38 -25
  30. package/bizar-dash/src/web/views/Chat.tsx +8 -0
  31. package/bizar-dash/src/web/views/History.tsx +4 -1
  32. package/bizar-dash/src/web/views/MiniMaxUsage.tsx +21 -4
  33. package/bizar-dash/src/web/views/Mods.tsx +30 -17
  34. package/bizar-dash/src/web/views/Overview.tsx +8 -1
  35. package/bizar-dash/src/web/views/Providers.tsx +16 -16
  36. package/bizar-dash/src/web/views/Schedules.tsx +33 -15
  37. package/bizar-dash/src/web/views/Settings.tsx +97 -1751
  38. package/bizar-dash/src/web/views/Skills.tsx +4 -1
  39. package/bizar-dash/src/web/views/Tasks.tsx +11 -2
  40. package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +8 -2
  41. package/bizar-dash/src/web/views/memory/LightragPanel.tsx +3 -0
  42. package/bizar-dash/src/web/views/memory/ObsidianPanel.tsx +12 -4
  43. package/bizar-dash/src/web/views/memory/SemanticSearchPanel.tsx +3 -0
  44. package/bizar-dash/src/web/views/settings/ActivitySection.tsx +205 -0
  45. package/bizar-dash/src/web/views/settings/AgentSection.tsx +294 -0
  46. package/bizar-dash/src/web/views/settings/AuthSection.tsx +159 -0
  47. package/bizar-dash/src/web/views/settings/BackupSection.tsx +16 -0
  48. package/bizar-dash/src/web/views/settings/EnvVarsSection.tsx +16 -0
  49. package/bizar-dash/src/web/views/settings/GeneralSection.tsx +105 -0
  50. package/bizar-dash/src/web/views/settings/HeadroomSection.tsx +39 -0
  51. package/bizar-dash/src/web/views/settings/MemorySection.tsx +16 -0
  52. package/bizar-dash/src/web/views/settings/NetworkSection.tsx +87 -0
  53. package/bizar-dash/src/web/views/settings/NotificationsSection.tsx +34 -0
  54. package/bizar-dash/src/web/views/settings/ProvidersSection.tsx +16 -0
  55. package/bizar-dash/src/web/views/settings/SkillsSection.tsx +16 -0
  56. package/bizar-dash/src/web/views/settings/SystemLlmSection.tsx +81 -0
  57. package/bizar-dash/src/web/views/settings/ThemeSection.tsx +168 -0
  58. package/bizar-dash/src/web/views/settings/UpdatesSection.tsx +256 -0
  59. package/bizar-dash/tests/a11y.test.tsx +206 -0
  60. package/bizar-dash/tests/backup-restore.test.mjs +217 -0
  61. package/bizar-dash/tests/backup-restore.test.tsx +123 -0
  62. package/bizar-dash/tests/backup-store.test.mjs +300 -0
  63. package/bizar-dash/tests/cli-error-visibility.test.mjs +153 -0
  64. package/bizar-dash/tests/digest-generation.test.mjs +191 -0
  65. package/bizar-dash/tests/digest-store.test.mjs +264 -0
  66. package/bizar-dash/tests/rate-limit.test.mjs +298 -0
  67. package/cli/bin.mjs +96 -2
  68. package/cli/commands/minimax.mjs +20 -0
  69. package/cli/commands/util.mjs +154 -1
  70. package/cli/digest.mjs +149 -0
  71. package/package.json +1 -1
  72. package/bizar-dash/dist/assets/main-DAlLdW8I.css +0 -1
  73. package/bizar-dash/dist/assets/main-DGGq-iZI.js +0 -361
  74. package/bizar-dash/dist/assets/main-DGGq-iZI.js.map +0 -1
  75. 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 &amp; 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
  >
@@ -125,6 +125,8 @@ export function Composer({
125
125
  multiple
126
126
  style={{ display: 'none' }}
127
127
  onChange={onFiles}
128
+ aria-label="Attach files"
129
+ tabIndex={-1}
128
130
  />
129
131
  <button
130
132
  type="button"
@@ -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: #059669;
262
- --warning: #d97706;
263
- --error: #dc2626;
264
- --info: #2563eb;
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
- --success: oklch(0.62 0.16 145);
285
- --warning: oklch(0.72 0.14 70);
286
- --error: oklch(0.60 0.20 25);
287
- --info: oklch(0.62 0.13 235);
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
- <div className="field-label">Create follow-up task</div>
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"