@polderlabs/bizar 4.5.2 → 4.7.1

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 (123) 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-lbH6szyX.js → mobile-BK8-ythT.js} +18 -18
  5. package/bizar-dash/dist/assets/mobile-BK8-ythT.js.map +1 -0
  6. package/bizar-dash/dist/assets/{mobile-BRhoDOUz.js → mobile-Chvf9u_B.js} +1 -1
  7. package/bizar-dash/dist/assets/{mobile-BRhoDOUz.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 -0
  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/logger.mjs +71 -0
  17. package/bizar-dash/src/server/memory-lightrag.mjs +45 -6
  18. package/bizar-dash/src/server/metrics.mjs +193 -0
  19. package/bizar-dash/src/server/routes/backup.mjs +112 -0
  20. package/bizar-dash/src/server/routes/chat.mjs +20 -3
  21. package/bizar-dash/src/server/routes/digests.mjs +82 -0
  22. package/bizar-dash/src/server/routes/lightrag.mjs +3 -2
  23. package/bizar-dash/src/server/routes/memory.mjs +5 -4
  24. package/bizar-dash/src/server/routes/misc.mjs +2 -1
  25. package/bizar-dash/src/server/routes/overview.mjs +2 -1
  26. package/bizar-dash/src/server/routes-v2/events.mjs +14 -0
  27. package/bizar-dash/src/server/schedules-runner.mjs +126 -0
  28. package/bizar-dash/src/server/server.mjs +79 -0
  29. package/bizar-dash/src/web/App.tsx +8 -1
  30. package/bizar-dash/src/web/components/BackupRestore.tsx +330 -0
  31. package/bizar-dash/src/web/components/SearchModal.tsx +6 -3
  32. package/bizar-dash/src/web/components/VirtualList.tsx +53 -0
  33. package/bizar-dash/src/web/components/chat/ChatThread.tsx +17 -11
  34. package/bizar-dash/src/web/components/chat/Composer.tsx +2 -0
  35. package/bizar-dash/src/web/hooks/useI18n.ts +13 -0
  36. package/bizar-dash/src/web/lib/i18n.ts +25 -0
  37. package/bizar-dash/src/web/locales/en.json +52 -0
  38. package/bizar-dash/src/web/main.tsx +5 -0
  39. package/bizar-dash/src/web/styles/main.css +70 -8
  40. package/bizar-dash/src/web/views/Activity.tsx +35 -18
  41. package/bizar-dash/src/web/views/Agents.tsx +57 -42
  42. package/bizar-dash/src/web/views/Artifacts.tsx +38 -25
  43. package/bizar-dash/src/web/views/Chat.tsx +8 -0
  44. package/bizar-dash/src/web/views/History.tsx +94 -76
  45. package/bizar-dash/src/web/views/MiniMaxUsage.tsx +21 -4
  46. package/bizar-dash/src/web/views/Mods.tsx +30 -17
  47. package/bizar-dash/src/web/views/Overview.tsx +19 -9
  48. package/bizar-dash/src/web/views/Providers.tsx +16 -16
  49. package/bizar-dash/src/web/views/Schedules.tsx +33 -15
  50. package/bizar-dash/src/web/views/Settings.tsx +97 -1745
  51. package/bizar-dash/src/web/views/Skills.tsx +4 -1
  52. package/bizar-dash/src/web/views/Tasks.tsx +11 -2
  53. package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +8 -2
  54. package/bizar-dash/src/web/views/memory/LightragPanel.tsx +3 -0
  55. package/bizar-dash/src/web/views/memory/ObsidianPanel.tsx +12 -4
  56. package/bizar-dash/src/web/views/memory/SemanticSearchPanel.tsx +3 -0
  57. package/bizar-dash/src/web/views/settings/ActivitySection.tsx +205 -0
  58. package/bizar-dash/src/web/views/settings/AgentSection.tsx +294 -0
  59. package/bizar-dash/src/web/views/settings/AuthSection.tsx +159 -0
  60. package/bizar-dash/src/web/views/settings/BackupSection.tsx +16 -0
  61. package/bizar-dash/src/web/views/settings/EnvVarsSection.tsx +16 -0
  62. package/bizar-dash/src/web/views/settings/GeneralSection.tsx +105 -0
  63. package/bizar-dash/src/web/views/settings/HeadroomSection.tsx +39 -0
  64. package/bizar-dash/src/web/views/settings/MemorySection.tsx +16 -0
  65. package/bizar-dash/src/web/views/settings/NetworkSection.tsx +87 -0
  66. package/bizar-dash/src/web/views/settings/NotificationsSection.tsx +34 -0
  67. package/bizar-dash/src/web/views/settings/ProvidersSection.tsx +16 -0
  68. package/bizar-dash/src/web/views/settings/SkillsSection.tsx +16 -0
  69. package/bizar-dash/src/web/views/settings/SystemLlmSection.tsx +81 -0
  70. package/bizar-dash/src/web/views/settings/ThemeSection.tsx +168 -0
  71. package/bizar-dash/src/web/views/settings/UpdatesSection.tsx +256 -0
  72. package/bizar-dash/tests/a11y.test.tsx +206 -0
  73. package/bizar-dash/tests/backup-restore.test.mjs +217 -0
  74. package/bizar-dash/tests/backup-restore.test.tsx +123 -0
  75. package/bizar-dash/tests/backup-store.test.mjs +300 -0
  76. package/bizar-dash/tests/cli-bugfixes.test.mjs +4 -4
  77. package/bizar-dash/tests/cli-error-visibility.test.mjs +153 -0
  78. package/bizar-dash/tests/cli-refactor.test.mjs +184 -0
  79. package/bizar-dash/tests/components/Button.test.tsx +41 -0
  80. package/bizar-dash/tests/components/Card.test.tsx +42 -0
  81. package/bizar-dash/tests/components/Modal.test.tsx +104 -0
  82. package/bizar-dash/tests/components/Spinner.test.tsx +32 -0
  83. package/bizar-dash/tests/components/StatusBadge.test.tsx +35 -0
  84. package/bizar-dash/tests/components/Toast.test.tsx +108 -0
  85. package/bizar-dash/tests/digest-generation.test.mjs +191 -0
  86. package/bizar-dash/tests/digest-store.test.mjs +264 -0
  87. package/bizar-dash/tests/hooks/useModal.test.tsx +84 -0
  88. package/bizar-dash/tests/hooks/useToast.test.tsx +50 -0
  89. package/bizar-dash/tests/lib/i18n.test.ts +46 -0
  90. package/bizar-dash/tests/lib/utils.test.ts +194 -0
  91. package/bizar-dash/tests/logger.test.mjs +207 -0
  92. package/bizar-dash/tests/metrics.test.mjs +183 -0
  93. package/bizar-dash/tests/rate-limit.test.mjs +298 -0
  94. package/bizar-dash/tests/server-bugfixes.test.mjs +2 -2
  95. package/bizar-dash/tests/setup.ts +7 -0
  96. package/bizar-dash/vitest.config.ts +13 -0
  97. package/cli/artifact-cli.mjs +605 -0
  98. package/cli/artifact-render.mjs +621 -0
  99. package/cli/artifact-server.mjs +847 -0
  100. package/cli/artifact.mjs +38 -2096
  101. package/cli/bg.mjs +5 -13
  102. package/cli/bin.mjs +221 -1350
  103. package/cli/commands/artifact.mjs +20 -0
  104. package/cli/commands/dash.mjs +160 -0
  105. package/cli/commands/headroom.mjs +204 -0
  106. package/cli/commands/install.mjs +169 -0
  107. package/cli/commands/memory.mjs +25 -0
  108. package/cli/commands/minimax.mjs +285 -0
  109. package/cli/commands/mod.mjs +185 -0
  110. package/cli/commands/service.mjs +65 -0
  111. package/cli/commands/usage.mjs +109 -0
  112. package/cli/commands/util.mjs +459 -0
  113. package/cli/digest.mjs +149 -0
  114. package/cli/doctor.mjs +1 -13
  115. package/cli/provision.mjs +2 -13
  116. package/cli/service-controller.mjs +1 -11
  117. package/cli/service.mjs +1 -11
  118. package/cli/utils.mjs +41 -1
  119. package/package.json +6 -1
  120. package/bizar-dash/dist/assets/main-B4OfGAwz.js +0 -361
  121. package/bizar-dash/dist/assets/main-B4OfGAwz.js.map +0 -1
  122. package/bizar-dash/dist/assets/main-DAlLdW8I.css +0 -1
  123. package/bizar-dash/dist/assets/mobile-lbH6szyX.js.map +0 -1
@@ -200,13 +200,16 @@ export function Artifacts({ snapshot, refreshSnapshot }: Props) {
200
200
  </div>
201
201
  <div className="view-actions">
202
202
  <div className="search-input">
203
- <Search size={14} />
203
+ <Search size={14} aria-hidden />
204
+ <label htmlFor="artifacts-search" className="sr-only">Search artifacts</label>
204
205
  <input
206
+ id="artifacts-search"
205
207
  className="input"
206
208
  type="text"
207
209
  placeholder="Search…"
208
210
  value={filter}
209
211
  onChange={(e) => setFilter(e.target.value)}
212
+ aria-label="Search artifacts"
210
213
  />
211
214
  </div>
212
215
  <Button
@@ -275,7 +278,9 @@ function NewPlanCard({ onCreate }: { onCreate: (slug: string, title?: string) =>
275
278
  setTitle('');
276
279
  }}
277
280
  >
281
+ <label htmlFor="new-artifact-slug" className="sr-only">Artifact slug</label>
278
282
  <input
283
+ id="new-artifact-slug"
279
284
  className="input"
280
285
  type="text"
281
286
  placeholder="slug (e.g. dashboard-v3.1)"
@@ -283,8 +288,11 @@ function NewPlanCard({ onCreate }: { onCreate: (slug: string, title?: string) =>
283
288
  required
284
289
  value={slug}
285
290
  onChange={(e) => setSlug(e.target.value)}
291
+ aria-label="Artifact slug"
286
292
  />
293
+ <label htmlFor="new-artifact-title" className="sr-only">Artifact title</label>
287
294
  <input
295
+ id="new-artifact-title"
288
296
  className="input"
289
297
  type="text"
290
298
  placeholder="Title (optional)"
@@ -545,14 +553,14 @@ function PlanEditor({
545
553
  width: 560,
546
554
  children: (
547
555
  <div className="artifact-config-form">
548
- <label className="field-label">Title</label>
549
- <input ref={(el) => { titleEl = el; }} className="input" type="text" defaultValue={meta.title} />
550
- <label className="field-label">Description (markdown)</label>
551
- <textarea ref={(el) => { descEl = el; }} className="textarea" rows={3} defaultValue={meta.description || ''} />
552
- <label className="field-label">Tags (comma-separated)</label>
553
- <input ref={(el) => { tagsEl = el; }} className="input" type="text" defaultValue={(meta.tags || []).join(', ')} />
554
- <label className="field-label">Status</label>
555
- <select ref={(el) => { statusEl = el; }} className="select" defaultValue={meta.status}>
556
+ <label className="field-label" htmlFor="artifact-cfg-title">Title</label>
557
+ <input id="artifact-cfg-title" ref={(el) => { titleEl = el; }} className="input" type="text" defaultValue={meta.title} />
558
+ <label className="field-label" htmlFor="artifact-cfg-desc">Description (markdown)</label>
559
+ <textarea id="artifact-cfg-desc" ref={(el) => { descEl = el; }} className="textarea" rows={3} defaultValue={meta.description || ''} />
560
+ <label className="field-label" htmlFor="artifact-cfg-tags">Tags (comma-separated)</label>
561
+ <input id="artifact-cfg-tags" ref={(el) => { tagsEl = el; }} className="input" type="text" defaultValue={(meta.tags || []).join(', ')} />
562
+ <label className="field-label" htmlFor="artifact-cfg-status">Status</label>
563
+ <select id="artifact-cfg-status" ref={(el) => { statusEl = el; }} className="select" defaultValue={meta.status}>
556
564
  {PLAN_STATUSES.map((s) => (
557
565
  <option key={s} value={s}>{s}</option>
558
566
  ))}
@@ -597,16 +605,16 @@ function PlanEditor({
597
605
  width: 520,
598
606
  children: (
599
607
  <div className="artifact-element-form">
600
- <label className="field-label">Type</label>
601
- <select ref={(el) => { typeEl = el; }} className="select" defaultValue="task">
608
+ <label className="field-label" htmlFor="artifact-elem-type">Type</label>
609
+ <select id="artifact-elem-type" ref={(el) => { typeEl = el; }} className="select" defaultValue="task">
602
610
  {ELEMENT_TYPES.map((t) => (
603
611
  <option key={t.id} value={t.id}>{t.label}</option>
604
612
  ))}
605
613
  </select>
606
- <label className="field-label">Title</label>
607
- <input ref={(el) => { titleEl = el; }} className="input" type="text" placeholder="Element title" autoFocus />
608
- <label className="field-label">Content (markdown)</label>
609
- <textarea ref={(el) => { contentEl = el; }} className="textarea" rows={4} placeholder="Description…" />
614
+ <label className="field-label" htmlFor="artifact-elem-title">Title</label>
615
+ <input id="artifact-elem-title" ref={(el) => { titleEl = el; }} className="input" type="text" placeholder="Element title" autoFocus />
616
+ <label className="field-label" htmlFor="artifact-elem-content">Content (markdown)</label>
617
+ <textarea id="artifact-elem-content" ref={(el) => { contentEl = el; }} className="textarea" rows={4} placeholder="Description…" />
610
618
  </div>
611
619
  ),
612
620
  footer: (
@@ -653,8 +661,8 @@ function PlanEditor({
653
661
  width: 480,
654
662
  children: (
655
663
  <div>
656
- <label className="field-label">Comment</label>
657
- <textarea ref={(el) => { textEl = el; }} className="textarea" rows={4} placeholder="Comment for the whole artifact…" autoFocus />
664
+ <label className="field-label" htmlFor="canvas-comment">Comment</label>
665
+ <textarea id="canvas-comment" ref={(el) => { textEl = el; }} className="textarea" rows={4} placeholder="Comment for the whole artifact…" autoFocus />
658
666
  </div>
659
667
  ),
660
668
  footer: (
@@ -908,18 +916,18 @@ function editElementInline(
908
916
  width: 520,
909
917
  children: (
910
918
  <div className="artifact-element-form">
911
- <label className="field-label">Type</label>
912
- <select ref={(el2) => { typeEl = el2; }} className="select" defaultValue={el.type}>
919
+ <label className="field-label" htmlFor="elem-edit-type">Type</label>
920
+ <select id="elem-edit-type" ref={(el2) => { typeEl = el2; }} className="select" defaultValue={el.type}>
913
921
  {ELEMENT_TYPES.map((t) => (
914
922
  <option key={t.id} value={t.id}>{t.label}</option>
915
923
  ))}
916
924
  </select>
917
- <label className="field-label">Title</label>
918
- <input ref={(el2) => { titleEl = el2; }} className="input" type="text" defaultValue={el.title} />
919
- <label className="field-label">Status</label>
920
- <input ref={(el2) => { statusEl = el2; }} className="input" type="text" defaultValue={el.status} placeholder="open / done / blocked …" />
921
- <label className="field-label">Content (markdown)</label>
922
- <textarea ref={(el2) => { contentEl = el2; }} className="textarea" rows={5} defaultValue={el.content} />
925
+ <label className="field-label" htmlFor="elem-edit-title">Title</label>
926
+ <input id="elem-edit-title" ref={(el2) => { titleEl = el2; }} className="input" type="text" defaultValue={el.title} />
927
+ <label className="field-label" htmlFor="elem-edit-status">Status</label>
928
+ <input id="elem-edit-status" ref={(el2) => { statusEl = el2; }} className="input" type="text" defaultValue={el.status} placeholder="open / done / blocked …" />
929
+ <label className="field-label" htmlFor="elem-edit-content">Content (markdown)</label>
930
+ <textarea id="elem-edit-content" ref={(el2) => { contentEl = el2; }} className="textarea" rows={5} defaultValue={el.content} />
923
931
  </div>
924
932
  ),
925
933
  footer: (
@@ -1464,12 +1472,17 @@ function CommentsPanel({
1464
1472
  setText('');
1465
1473
  }}
1466
1474
  >
1475
+ <label htmlFor="artifacts-comment-input" className="sr-only">
1476
+ {selectedElId ? 'Add a comment to this element' : 'Add a canvas comment'}
1477
+ </label>
1467
1478
  <textarea
1479
+ id="artifacts-comment-input"
1468
1480
  className="textarea"
1469
1481
  rows={2}
1470
1482
  placeholder={selectedElId ? 'Add a comment to this element…' : 'Add a canvas comment…'}
1471
1483
  value={text}
1472
1484
  onChange={(e) => setText(e.target.value)}
1485
+ aria-label={selectedElId ? 'Add a comment to this element' : 'Add a canvas comment'}
1473
1486
  />
1474
1487
  <Button variant="primary" size="sm" type="submit" disabled={!text.trim()}>
1475
1488
  <Send size={12} /> Post
@@ -141,8 +141,10 @@ export function Chat({
141
141
  title: 'Rename session',
142
142
  children: (
143
143
  <input
144
+ id="rename-session-input"
144
145
  autoFocus
145
146
  defaultValue={currentTitle}
147
+ aria-label="Session title"
146
148
  onChange={(e) => {
147
149
  next = e.target.value;
148
150
  }}
@@ -406,6 +408,10 @@ export function Chat({
406
408
  className="chat-thread-scroll"
407
409
  ref={chat.listRef}
408
410
  onScroll={chat.handleScroll}
411
+ role="log"
412
+ aria-live="polite"
413
+ aria-relevant="additions"
414
+ aria-label="Chat message thread"
409
415
  >
410
416
  <ChatThread
411
417
  messages={
@@ -463,6 +469,8 @@ export function Chat({
463
469
  multiple
464
470
  style={{ display: 'none' }}
465
471
  onChange={onFiles}
472
+ aria-label="Attach files to message"
473
+ tabIndex={-1}
466
474
  />
467
475
  </section>
468
476
 
@@ -2,7 +2,7 @@
2
2
  // Shows per-project timelines (task events, plan events, project lifecycle)
3
3
  // aggregated from the activity log.
4
4
 
5
- import { useEffect, useState } from 'react';
5
+ import React, { useEffect, useState } from 'react';
6
6
  import {
7
7
  History as HistoryIcon,
8
8
  RefreshCw,
@@ -17,6 +17,7 @@ import {
17
17
  import { Card, CardTitle, CardMeta } from '../components/Card';
18
18
  import { Button } from '../components/Button';
19
19
  import { Spinner } from '../components/Spinner';
20
+ import { VirtualList } from '../components/VirtualList';
20
21
  import { useToast } from '../components/Toast';
21
22
  import { api } from '../lib/api';
22
23
  import { cn, formatRelative } from '../lib/utils';
@@ -65,6 +66,85 @@ const TIME_RANGES = [
65
66
  { id: 'all', label: 'All time', ms: 0 },
66
67
  ];
67
68
 
69
+ function renderProjectCard(
70
+ p: ProjectHistory,
71
+ events: HistoryEvent[],
72
+ expanded: Set<string>,
73
+ toggleProject: (id: string) => void,
74
+ ): React.ReactNode {
75
+ const isOpen = expanded.has(p.id);
76
+ return (
77
+ <Card key={p.id} className="history-project" style={{ marginBottom: 4 }}>
78
+ <div className="history-project-head">
79
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 4, minWidth: 0, flex: 1 }}>
80
+ <div className="history-project-name">
81
+ <Folder size={16} /> {p.name}
82
+ </div>
83
+ <div className="history-project-meta">{p.path}</div>
84
+ </div>
85
+ <div className="history-project-stats">
86
+ <span className="history-project-stat">
87
+ <span className="history-project-stat-num">{p.tasks.done}</span>
88
+ <span className="muted">/ {p.tasks.total} done</span>
89
+ </span>
90
+ {p.tasks.doing > 0 && (
91
+ <span className="history-project-stat">
92
+ <span className="history-project-stat-num">{p.tasks.doing}</span>
93
+ <span className="muted">doing</span>
94
+ </span>
95
+ )}
96
+ {p.tasks.blocked > 0 && (
97
+ <span className="history-project-stat">
98
+ <span className="history-project-stat-num">{p.tasks.blocked}</span>
99
+ <span className="muted">blocked</span>
100
+ </span>
101
+ )}
102
+ <span className="history-project-stat">
103
+ <FileText size={11} /> <span className="history-project-stat-num">{p.plans}</span>
104
+ <span className="muted"> plan{p.plans === 1 ? '' : 's'}</span>
105
+ </span>
106
+ {p.lastAccessed && (
107
+ <span className="history-project-stat">
108
+ <span className="muted">last opened {formatRelative(p.lastAccessed)}</span>
109
+ </span>
110
+ )}
111
+ </div>
112
+ <button
113
+ type="button"
114
+ className="icon-btn"
115
+ onClick={() => toggleProject(p.id)}
116
+ aria-label={isOpen ? 'Collapse' : 'Expand'}
117
+ >
118
+ {isOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
119
+ </button>
120
+ </div>
121
+
122
+ {isOpen && (
123
+ <div className="history-timeline-mini">
124
+ {events.length === 0 ? (
125
+ <div className="muted" style={{ padding: '12px 8px', fontSize: 12 }}>
126
+ No events in this time range.
127
+ </div>
128
+ ) : (
129
+ events.slice(-100).reverse().map((ev, i) => (
130
+ <div key={i} className="history-timeline-row">
131
+ <span className="history-timeline-ts">
132
+ {new Date(ev.ts).toLocaleTimeString()}
133
+ </span>
134
+ <span className="history-timeline-kind">{ev.kind || 'event'}</span>
135
+ <span className="history-timeline-msg" title={String(ev.text || ev.title || '')}>
136
+ {ev.author ? `@${ev.author} ` : ''}
137
+ {String(ev.text ?? ev.title ?? ev.name ?? '')}
138
+ </span>
139
+ </div>
140
+ ))
141
+ )}
142
+ </div>
143
+ )}
144
+ </Card>
145
+ );
146
+ }
147
+
68
148
  export function History({ snapshot }: Props) {
69
149
  const toast = useToast();
70
150
  const [data, setData] = useState<HistoryResponse | null>(null);
@@ -154,12 +234,15 @@ export function History({ snapshot }: Props) {
154
234
  </div>
155
235
  <div className="view-actions">
156
236
  <div className="tasks-toolbar-group">
157
- <Filter size={14} style={{ color: 'var(--text-dim)' }} />
237
+ <Filter size={14} style={{ color: 'var(--text-dim)' }} aria-hidden />
238
+ <label htmlFor="history-range" className="sr-only">Time range</label>
158
239
  <select
240
+ id="history-range"
159
241
  className="select select-sm"
160
242
  value={rangeId}
161
243
  onChange={(e) => setRangeId(e.target.value)}
162
244
  title="Time range"
245
+ aria-label="Time range"
163
246
  >
164
247
  {TIME_RANGES.map((r) => (
165
248
  <option key={r.id} value={r.id}>{r.label}</option>
@@ -203,80 +286,15 @@ export function History({ snapshot }: Props) {
203
286
  </Card>
204
287
  )}
205
288
 
206
- {data.projects.map((p) => {
207
- const events = eventsByProject.get(p.id) || [];
208
- const isOpen = expanded.has(p.id);
209
- return (
210
- <Card key={p.id} className="history-project">
211
- <div className="history-project-head">
212
- <div style={{ display: 'flex', flexDirection: 'column', gap: 4, minWidth: 0, flex: 1 }}>
213
- <div className="history-project-name">
214
- <Folder size={16} /> {p.name}
215
- </div>
216
- <div className="history-project-meta">{p.path}</div>
217
- </div>
218
- <div className="history-project-stats">
219
- <span className="history-project-stat">
220
- <span className="history-project-stat-num">{p.tasks.done}</span>
221
- <span className="muted">/ {p.tasks.total} done</span>
222
- </span>
223
- {p.tasks.doing > 0 && (
224
- <span className="history-project-stat">
225
- <span className="history-project-stat-num">{p.tasks.doing}</span>
226
- <span className="muted">doing</span>
227
- </span>
228
- )}
229
- {p.tasks.blocked > 0 && (
230
- <span className="history-project-stat">
231
- <span className="history-project-stat-num">{p.tasks.blocked}</span>
232
- <span className="muted">blocked</span>
233
- </span>
234
- )}
235
- <span className="history-project-stat">
236
- <FileText size={11} /> <span className="history-project-stat-num">{p.plans}</span>
237
- <span className="muted"> plan{p.plans === 1 ? '' : 's'}</span>
238
- </span>
239
- {p.lastAccessed && (
240
- <span className="history-project-stat">
241
- <span className="muted">last opened {formatRelative(p.lastAccessed)}</span>
242
- </span>
243
- )}
244
- </div>
245
- <button
246
- type="button"
247
- className="icon-btn"
248
- onClick={() => toggleProject(p.id)}
249
- aria-label={isOpen ? 'Collapse' : 'Expand'}
250
- >
251
- {isOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
252
- </button>
253
- </div>
254
-
255
- {isOpen && (
256
- <div className="history-timeline-mini">
257
- {events.length === 0 ? (
258
- <div className="muted" style={{ padding: '12px 8px', fontSize: 12 }}>
259
- No events in this time range.
260
- </div>
261
- ) : (
262
- events.slice(-100).reverse().map((ev, i) => (
263
- <div key={i} className="history-timeline-row">
264
- <span className="history-timeline-ts">
265
- {new Date(ev.ts).toLocaleTimeString()}
266
- </span>
267
- <span className="history-timeline-kind">{ev.kind || 'event'}</span>
268
- <span className="history-timeline-msg" title={String(ev.text || ev.title || '')}>
269
- {ev.author ? `@${ev.author} ` : ''}
270
- {String(ev.text ?? ev.title ?? ev.name ?? '')}
271
- </span>
272
- </div>
273
- ))
274
- )}
275
- </div>
276
- )}
277
- </Card>
278
- );
279
- })}
289
+ {data.projects.length > 0 && (
290
+ <VirtualList
291
+ items={data.projects}
292
+ itemHeight={70}
293
+ height={Math.min(data.projects.length * 70, 500)}
294
+ className="history-virtual-list"
295
+ renderItem={(p) => renderProjectCard(p, eventsByProject.get(p.id) || [], expanded, toggleProject)}
296
+ />
297
+ )}
280
298
 
281
299
  {/* Global / unassigned events */}
282
300
  {eventsByProject.has('global') && (
@@ -585,18 +585,24 @@ function AnalyticsView({ toast }: { toast: ReturnType<typeof useToast> }) {
585
585
  ))}
586
586
  {range === 'custom' && (
587
587
  <div className="usage-custom-dates">
588
+ <label htmlFor="usage-from-date" className="sr-only">From date</label>
588
589
  <input
590
+ id="usage-from-date"
589
591
  type="date"
590
592
  className="usage-date-input"
591
593
  value={customFrom}
592
594
  onChange={e => setCustomFrom(e.target.value)}
595
+ aria-label="From date"
593
596
  />
594
- <span style={{ color: 'var(--text-muted)', fontSize: 12 }}>to</span>
597
+ <span style={{ color: 'var(--text-muted)', fontSize: 12 }} aria-hidden>to</span>
598
+ <label htmlFor="usage-to-date" className="sr-only">To date</label>
595
599
  <input
600
+ id="usage-to-date"
596
601
  type="date"
597
602
  className="usage-date-input"
598
603
  value={customTo}
599
604
  onChange={e => setCustomTo(e.target.value)}
605
+ aria-label="To date"
600
606
  />
601
607
  </div>
602
608
  )}
@@ -879,9 +885,20 @@ function OnboardingWizard({ step, setStep, keyDraft, setKeyDraft, showKey, setSh
879
885
  <p className="minimax-wizard-prose">Paste your key below. We'll test it against the real API before saving.</p>
880
886
  <div className="minimax-key-row">
881
887
  <div className="minimax-key-input-wrap">
882
- <KeyRound size={12} className="minimax-key-icon" />
883
- <input type={showKey ? 'text' : 'password'} value={keyDraft} onChange={e => setKeyDraft(e.target.value)}
884
- placeholder="sk-cp-..." spellCheck={false} autoComplete="off" className="minimax-key-input" autoFocus />
888
+ <KeyRound size={12} className="minimax-key-icon" aria-hidden />
889
+ <label htmlFor="minimax-key-input" className="sr-only">Subscription Key</label>
890
+ <input
891
+ id="minimax-key-input"
892
+ type={showKey ? 'text' : 'password'}
893
+ value={keyDraft}
894
+ onChange={e => setKeyDraft(e.target.value)}
895
+ placeholder="sk-cp-..."
896
+ spellCheck={false}
897
+ autoComplete="off"
898
+ className="minimax-key-input"
899
+ autoFocus
900
+ aria-label="Subscription Key"
901
+ />
885
902
  <button type="button" onClick={() => setShowKey(v => !v)} className="minimax-key-toggle" aria-label={showKey ? 'Hide key' : 'Show key'}>
886
903
  {showKey ? <EyeOff size={13} /> : <Eye size={13} />}
887
904
  </button>
@@ -204,8 +204,9 @@ export function Mods({ snapshot, refreshSnapshot }: Props) {
204
204
  a <code>mod.json</code>). The folder will be copied to
205
205
  {' '}<code>~/.config/bizar/mods/&lt;id&gt;/</code>.
206
206
  </p>
207
- <label className="field-label">Path</label>
207
+ <label className="field-label" htmlFor="install-mod-path">Path</label>
208
208
  <input
209
+ id="install-mod-path"
209
210
  ref={(el) => (pathEl = el)}
210
211
  className="input"
211
212
  type="text"
@@ -355,20 +356,26 @@ export function Mods({ snapshot, refreshSnapshot }: Props) {
355
356
 
356
357
  {/* v3.16.0 — Registry browser */}
357
358
  <Card className="mods-registry-card">
358
- <div className="mods-registry-head" onClick={() => {
359
- if (!registry && !registryLoading) loadRegistry();
360
- setRegistryOpen((v) => !v);
361
- }}>
362
- <Globe size={14} />
359
+ <button
360
+ type="button"
361
+ className="mods-registry-head"
362
+ aria-expanded={registryOpen}
363
+ aria-controls="mods-registry-body"
364
+ onClick={() => {
365
+ if (!registry && !registryLoading) loadRegistry();
366
+ setRegistryOpen((v) => !v);
367
+ }}
368
+ >
369
+ <Globe size={14} aria-hidden />
363
370
  <span className="mods-registry-title">Mod registry</span>
364
371
  <span className="muted" style={{ fontSize: 11 }}>
365
372
  {registry?.mods ? `${registry.mods.length} available` : 'click to browse'}
366
373
  </span>
367
374
  <span className="mods-registry-spacer" />
368
- {registryOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
369
- </div>
375
+ {registryOpen ? <ChevronDown size={14} aria-hidden /> : <ChevronRight size={14} aria-hidden />}
376
+ </button>
370
377
  {registryOpen && (
371
- <div className="mods-registry-body">
378
+ <div id="mods-registry-body" className="mods-registry-body">
372
379
  {registryLoading ? (
373
380
  <div className="muted" style={{ padding: 12, fontSize: 12 }}>Loading registry…</div>
374
381
  ) : registry?.error ? (
@@ -586,20 +593,26 @@ function ModDetails({ mod }: { mod: Mod }) {
586
593
 
587
594
  {/* v3.20 — Installed instructions (agents / commands / skills) */}
588
595
  <div className="mod-instructions">
589
- <div className="mod-instructions-head" onClick={() => {
590
- if (!instructions && !instructionsLoading) loadInstructions();
591
- setInstructionsOpen((v) => !v);
592
- }}>
593
- <FileCode size={14} />
596
+ <button
597
+ type="button"
598
+ className="mod-instructions-head"
599
+ aria-expanded={instructionsOpen}
600
+ aria-controls={`mod-instructions-body-${mod.id}`}
601
+ onClick={() => {
602
+ if (!instructions && !instructionsLoading) loadInstructions();
603
+ setInstructionsOpen((v) => !v);
604
+ }}
605
+ >
606
+ <FileCode size={14} aria-hidden />
594
607
  <span>Installed instructions</span>
595
608
  <span className="muted" style={{ fontSize: 11 }}>
596
609
  {instructions ? `${instructions.total} files` : 'click to view'}
597
610
  </span>
598
611
  <span className="mod-instructions-spacer" />
599
- {instructionsOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
600
- </div>
612
+ {instructionsOpen ? <ChevronDown size={14} aria-hidden /> : <ChevronRight size={14} aria-hidden />}
613
+ </button>
601
614
  {instructionsOpen && instructions && (
602
- <div className="mod-instructions-body">
615
+ <div id={`mod-instructions-body-${mod.id}`} className="mod-instructions-body">
603
616
  {instructions.agents.length > 0 && (
604
617
  <div className="mod-instructions-section">
605
618
  <div className="mod-instructions-section-title">
@@ -33,6 +33,7 @@ import { Button } from '../components/Button';
33
33
  import { MemoryStatusCard } from './memory/MemoryStatusCard';
34
34
  import { EmptyState } from '../components/EmptyState';
35
35
  import { Spinner } from '../components/Spinner';
36
+ import { VirtualList } from '../components/VirtualList';
36
37
  import { useToast } from '../components/Toast';
37
38
  import { useModal } from '../components/Modal';
38
39
  import { FileBrowser } from '../components/FileBrowser';
@@ -287,11 +288,14 @@ function OverviewInner({
287
288
  }
288
289
  }}
289
290
  >
291
+ <label htmlFor="overview-hero-input" className="sr-only">Describe what you want Odin to do</label>
290
292
  <textarea
293
+ id="overview-hero-input"
291
294
  ref={inputRef}
292
295
  className="overview-input-hero"
293
296
  placeholder="e.g. Implement user authentication with email + password, including registration, login, password reset, and integration tests. Use Bcrypt, JWT tokens, and the existing API style."
294
297
  disabled={submitting}
298
+ aria-label="Describe what you want Odin to do"
295
299
  />
296
300
  <div style={{ display: 'flex', gap: 'var(--space-2)', alignItems: 'center', flexWrap: 'wrap' }}>
297
301
  <Button
@@ -366,22 +370,28 @@ function OverviewInner({
366
370
  No activity yet. Use the chat above or invoke a Bizar command to start a feed.
367
371
  </div>
368
372
  ) : (
369
- <div className={cn('activity-feed-list-wrap', !activityExpanded && 'activity-feed-list-wrap-collapsed')}>
370
- <div className="activity-feed-list">
371
- {activityItems.slice(0, 30).map((it, idx) => {
372
- const k = itemKey(it, idx);
373
- if (hiddenKeys.has(k)) return null;
373
+ <div
374
+ className={cn('activity-feed-list-wrap', !activityExpanded && 'activity-feed-list-wrap-collapsed')}
375
+ aria-live="polite"
376
+ aria-relevant="additions"
377
+ >
378
+ <VirtualList
379
+ items={activityItems.slice(0, 30).filter((it, idx) => !hiddenKeys.has(itemKey(it, idx)))}
380
+ itemHeight={60}
381
+ height={Math.min(activityItems.filter((it, idx) => !hiddenKeys.has(itemKey(it, idx))).length * 60, 480)}
382
+ className="activity-feed-list"
383
+ renderItem={(it, vlIdx) => {
384
+ const originalIdx = activityItems.slice(0, 30).findIndex((a) => a === it);
374
385
  return (
375
386
  <ActivityFeedItem
376
- key={`${it.ts}-${idx}`}
377
387
  item={it}
378
- activityKey={k}
388
+ activityKey={itemKey(it, originalIdx)}
379
389
  onNavigate={setActiveTab}
380
390
  onHide={onHide}
381
391
  />
382
392
  );
383
- })}
384
- </div>
393
+ }}
394
+ />
385
395
  {!activityExpanded && activityItems.length > 8 && <div className="activity-feed-fade" aria-hidden="true" />}
386
396
  </div>
387
397
  )}
@@ -75,14 +75,14 @@ export function Providers({ snapshot, settings, refreshSnapshot }: Props) {
75
75
  title: 'Add Provider',
76
76
  children: (
77
77
  <div className="provider-form">
78
- <label className="field-label">Provider name</label>
79
- <input ref={(el) => { nameEl = el; }} className="input" type="text" placeholder="e.g. Anthropic, OpenAI" autoFocus />
80
- <label className="field-label">Base URL</label>
81
- <input ref={(el) => { urlEl = el; }} className="input" type="text" placeholder="https://api.openai.com/v1" />
82
- <label className="field-label">API Key</label>
83
- <input ref={(el) => { keyEl = el; }} className="input" type="password" placeholder="sk-..." />
84
- <label className="field-label">Models (one per line)</label>
85
- <textarea ref={(el) => { modelsEl = el; }} className="input" rows={4} placeholder="gpt-4o&#10;gpt-4o-mini&#10;..." />
78
+ <label className="field-label" htmlFor="provider-new-name">Provider name</label>
79
+ <input id="provider-new-name" ref={(el) => { nameEl = el; }} className="input" type="text" placeholder="e.g. Anthropic, OpenAI" autoFocus />
80
+ <label className="field-label" htmlFor="provider-new-url">Base URL</label>
81
+ <input id="provider-new-url" ref={(el) => { urlEl = el; }} className="input" type="text" placeholder="https://api.openai.com/v1" />
82
+ <label className="field-label" htmlFor="provider-new-key">API Key</label>
83
+ <input id="provider-new-key" ref={(el) => { keyEl = el; }} className="input" type="password" placeholder="sk-..." />
84
+ <label className="field-label" htmlFor="provider-new-models">Models (one per line)</label>
85
+ <textarea id="provider-new-models" ref={(el) => { modelsEl = el; }} className="input" rows={4} placeholder="gpt-4o&#10;gpt-4o-mini&#10;..." />
86
86
  </div>
87
87
  ),
88
88
  footer: (
@@ -118,14 +118,14 @@ export function Providers({ snapshot, settings, refreshSnapshot }: Props) {
118
118
  title: `Edit "${provider.name}"`,
119
119
  children: (
120
120
  <div className="provider-form">
121
- <label className="field-label">Provider name</label>
122
- <input ref={(el) => { nameEl = el; }} className="input" type="text" defaultValue={provider.name} />
123
- <label className="field-label">Base URL</label>
124
- <input ref={(el) => { urlEl = el; }} className="input" type="text" defaultValue={provider.baseURL} />
125
- <label className="field-label">API Key (leave blank to keep current)</label>
126
- <input ref={(el) => { keyEl = el; }} className="input" type="password" placeholder="sk-..." />
127
- <label className="field-label">Models (one per line)</label>
128
- <textarea ref={(el) => { modelsEl = el; }} className="input" rows={4} defaultValue={(provider.models || []).join('\n')} />
121
+ <label className="field-label" htmlFor="provider-edit-name">Provider name</label>
122
+ <input id="provider-edit-name" ref={(el) => { nameEl = el; }} className="input" type="text" defaultValue={provider.name} />
123
+ <label className="field-label" htmlFor="provider-edit-url">Base URL</label>
124
+ <input id="provider-edit-url" ref={(el) => { urlEl = el; }} className="input" type="text" defaultValue={provider.baseURL} />
125
+ <label className="field-label" htmlFor="provider-edit-key">API Key (leave blank to keep current)</label>
126
+ <input id="provider-edit-key" ref={(el) => { keyEl = el; }} className="input" type="password" placeholder="sk-..." />
127
+ <label className="field-label" htmlFor="provider-edit-models">Models (one per line)</label>
128
+ <textarea id="provider-edit-models" ref={(el) => { modelsEl = el; }} className="input" rows={4} defaultValue={(provider.models || []).join('\n')} />
129
129
  </div>
130
130
  ),
131
131
  footer: (