beads-ui 0.1.0

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 (98) hide show
  1. package/.beads/issues.jsonl +107 -0
  2. package/.editorconfig +10 -0
  3. package/.eslintrc.json +36 -0
  4. package/.github/workflows/ci.yml +38 -0
  5. package/.prettierignore +5 -0
  6. package/AGENTS.md +85 -0
  7. package/CHANGES.md +5 -0
  8. package/LICENSE +22 -0
  9. package/README.md +75 -0
  10. package/app/data/providers.js +178 -0
  11. package/app/data/providers.test.js +126 -0
  12. package/app/index.html +29 -0
  13. package/app/main.board-switch.test.js +94 -0
  14. package/app/main.deep-link.test.js +64 -0
  15. package/app/main.js +280 -0
  16. package/app/main.live-updates.test.js +229 -0
  17. package/app/main.test.js +17 -0
  18. package/app/main.theme.test.js +41 -0
  19. package/app/main.view-sync.test.js +54 -0
  20. package/app/protocol.js +200 -0
  21. package/app/protocol.md +64 -0
  22. package/app/protocol.test.js +57 -0
  23. package/app/router.js +78 -0
  24. package/app/router.test.js +34 -0
  25. package/app/state.js +87 -0
  26. package/app/state.test.js +21 -0
  27. package/app/styles.css +1343 -0
  28. package/app/utils/issue-id.js +10 -0
  29. package/app/utils/issue-type.js +27 -0
  30. package/app/utils/markdown.js +201 -0
  31. package/app/utils/markdown.test.js +103 -0
  32. package/app/utils/priority-badge.js +49 -0
  33. package/app/utils/priority.js +1 -0
  34. package/app/utils/status-badge.js +33 -0
  35. package/app/utils/status.js +23 -0
  36. package/app/utils/type-badge.js +36 -0
  37. package/app/utils/type-badge.test.js +30 -0
  38. package/app/views/board.js +183 -0
  39. package/app/views/board.test.js +184 -0
  40. package/app/views/detail.acceptance-notes.test.js +67 -0
  41. package/app/views/detail.assignee.test.js +161 -0
  42. package/app/views/detail.deps.test.js +97 -0
  43. package/app/views/detail.edits.test.js +146 -0
  44. package/app/views/detail.js +1039 -0
  45. package/app/views/detail.labels.test.js +73 -0
  46. package/app/views/detail.priority.test.js +86 -0
  47. package/app/views/detail.test.js +188 -0
  48. package/app/views/detail.ui47.test.js +78 -0
  49. package/app/views/epics.js +228 -0
  50. package/app/views/epics.test.js +283 -0
  51. package/app/views/issue-row.js +191 -0
  52. package/app/views/list.inline-edits.test.js +84 -0
  53. package/app/views/list.js +393 -0
  54. package/app/views/list.test.js +479 -0
  55. package/app/views/nav.js +67 -0
  56. package/app/views/nav.test.js +43 -0
  57. package/app/ws.js +252 -0
  58. package/app/ws.test.js +168 -0
  59. package/bin/bdui.js +18 -0
  60. package/docs/architecture.md +244 -0
  61. package/docs/db-watching.md +29 -0
  62. package/docs/quickstart.md +142 -0
  63. package/eslint.config.js +59 -0
  64. package/media/bdui-board.png +0 -0
  65. package/media/bdui-epics.png +0 -0
  66. package/media/bdui-issues.png +0 -0
  67. package/package.json +48 -0
  68. package/prettier.config.js +13 -0
  69. package/server/app.js +80 -0
  70. package/server/app.test.js +29 -0
  71. package/server/bd.js +125 -0
  72. package/server/bd.test.js +93 -0
  73. package/server/cli/cli.test.js +109 -0
  74. package/server/cli/commands.integration.test.js +155 -0
  75. package/server/cli/commands.js +91 -0
  76. package/server/cli/commands.unit.test.js +94 -0
  77. package/server/cli/daemon.js +239 -0
  78. package/server/cli/index.js +74 -0
  79. package/server/cli/open.js +96 -0
  80. package/server/cli/open.test.js +26 -0
  81. package/server/cli/usage.js +22 -0
  82. package/server/config.js +29 -0
  83. package/server/db.js +100 -0
  84. package/server/db.test.js +70 -0
  85. package/server/index.js +29 -0
  86. package/server/protocol.js +3 -0
  87. package/server/protocol.test.js +87 -0
  88. package/server/watcher.js +107 -0
  89. package/server/watcher.test.js +100 -0
  90. package/server/ws.handlers.test.js +174 -0
  91. package/server/ws.js +784 -0
  92. package/server/ws.labels.test.js +95 -0
  93. package/server/ws.mutations.test.js +261 -0
  94. package/server/ws.subscriptions.test.js +116 -0
  95. package/server/ws.test.js +52 -0
  96. package/test/setup-vitest.js +12 -0
  97. package/tsconfig.json +23 -0
  98. package/vitest.config.mjs +14 -0
package/app/main.js ADDED
@@ -0,0 +1,280 @@
1
+ import { html, render } from 'lit-html';
2
+ import { createDataLayer } from './data/providers.js';
3
+ import { createHashRouter } from './router.js';
4
+ import { createStore } from './state.js';
5
+ import { createBoardView } from './views/board.js';
6
+ import { createDetailView } from './views/detail.js';
7
+ import { createEpicsView } from './views/epics.js';
8
+ import { createListView } from './views/list.js';
9
+ import { createTopNav } from './views/nav.js';
10
+ import { createWsClient } from './ws.js';
11
+
12
+ /**
13
+ * Bootstrap the SPA shell with two panels.
14
+ * @param {HTMLElement} root_element - The container element to render into.
15
+ */
16
+ export function bootstrap(root_element) {
17
+ // Render route shells (nav is mounted in header)
18
+ const shell = html`
19
+ <section id="issues-root" class="route issues">
20
+ <aside id="list-panel" class="panel"></aside>
21
+ </section>
22
+ <section id="epics-root" class="route epics" hidden></section>
23
+ <section id="board-root" class="route board" hidden></section>
24
+ <section id="detail-panel" class="route detail" hidden></section>
25
+ `;
26
+ render(shell, root_element);
27
+
28
+ /** @type {HTMLElement|null} */
29
+ const nav_mount = document.getElementById('top-nav');
30
+ /** @type {HTMLElement|null} */
31
+ const issues_root = document.getElementById('issues-root');
32
+ /** @type {HTMLElement|null} */
33
+ const epics_root = document.getElementById('epics-root');
34
+ /** @type {HTMLElement|null} */
35
+ const board_root = document.getElementById('board-root');
36
+
37
+ /** @type {HTMLElement|null} */
38
+ const list_mount = document.getElementById('list-panel');
39
+ /** @type {HTMLElement|null} */
40
+ const detail_mount = document.getElementById('detail-panel');
41
+ if (list_mount && issues_root && epics_root && board_root && detail_mount) {
42
+ const client = createWsClient();
43
+ // Load persisted filters (status/search/type) from localStorage
44
+ /** @type {{ status: 'all'|'open'|'in_progress'|'closed'|'ready', search: string, type: string }} */
45
+ let persistedFilters = { status: 'all', search: '', type: '' };
46
+ try {
47
+ const raw = window.localStorage.getItem('beads-ui.filters');
48
+ if (raw) {
49
+ const obj = JSON.parse(raw);
50
+ if (obj && typeof obj === 'object') {
51
+ const ALLOWED = ['bug', 'feature', 'task', 'epic', 'chore'];
52
+ /** @type {string} */
53
+ let parsed_type = '';
54
+ if (typeof obj.type === 'string' && ALLOWED.includes(obj.type)) {
55
+ parsed_type = obj.type;
56
+ } else if (Array.isArray(obj.types)) {
57
+ // Backwards compatibility: pick first valid from previous array format
58
+ /** @type {string} */
59
+ let first_valid = '';
60
+ for (const it of obj.types) {
61
+ if (ALLOWED.includes(String(it))) {
62
+ first_valid = /** @type {string} */ (it);
63
+ break;
64
+ }
65
+ }
66
+ parsed_type = first_valid;
67
+ }
68
+ persistedFilters = {
69
+ status: ['all', 'open', 'in_progress', 'closed', 'ready'].includes(
70
+ obj.status
71
+ )
72
+ ? obj.status
73
+ : 'all',
74
+ search: typeof obj.search === 'string' ? obj.search : '',
75
+ type: parsed_type
76
+ };
77
+ }
78
+ }
79
+ } catch {
80
+ // ignore parse errors
81
+ }
82
+ // Load last-view from storage
83
+ /** @type {'issues'|'epics'|'board'} */
84
+ let last_view = 'issues';
85
+ try {
86
+ const raw_view = window.localStorage.getItem('beads-ui.view');
87
+ if (
88
+ raw_view === 'issues' ||
89
+ raw_view === 'epics' ||
90
+ raw_view === 'board'
91
+ ) {
92
+ last_view = raw_view;
93
+ }
94
+ } catch {
95
+ // ignore
96
+ }
97
+ const store = createStore({ filters: persistedFilters, view: last_view });
98
+ const router = createHashRouter(store);
99
+ router.start();
100
+ /**
101
+ * @param {string} type
102
+ * @param {unknown} payload
103
+ */
104
+ const transport = async (type, payload) => {
105
+ try {
106
+ return await client.send(/** @type {any} */ (type), payload);
107
+ } catch {
108
+ return [];
109
+ }
110
+ };
111
+ // Top navigation (optional mount)
112
+ if (nav_mount) {
113
+ createTopNav(nav_mount, store, router);
114
+ }
115
+
116
+ const issues_view = createListView(
117
+ list_mount,
118
+ transport,
119
+ (hash) => {
120
+ const id = hash.replace('#/issue/', '');
121
+ if (id) {
122
+ router.gotoIssue(id);
123
+ }
124
+ },
125
+ store
126
+ );
127
+ // Persist filter changes to localStorage
128
+ store.subscribe((s) => {
129
+ try {
130
+ const data = {
131
+ status: s.filters.status,
132
+ search: s.filters.search,
133
+ type: typeof s.filters.type === 'string' ? s.filters.type : ''
134
+ };
135
+ window.localStorage.setItem('beads-ui.filters', JSON.stringify(data));
136
+ } catch {
137
+ // ignore
138
+ }
139
+ });
140
+ void issues_view.load();
141
+ const detail = createDetailView(detail_mount, transport, (hash) => {
142
+ const id = hash.replace('#/issue/', '');
143
+ if (id) {
144
+ router.gotoIssue(id);
145
+ }
146
+ });
147
+
148
+ // React to selectedId changes -> show detail page full-width
149
+ store.subscribe((s) => {
150
+ const id = s.selected_id;
151
+ if (id) {
152
+ void detail.load(id);
153
+ } else {
154
+ detail.clear();
155
+ }
156
+ });
157
+
158
+ // Initial deep-link: if router set a selectedId before subscription, load it now
159
+ const initialId = store.getState().selected_id;
160
+ if (initialId) {
161
+ void detail.load(initialId);
162
+ } else {
163
+ detail.clear();
164
+ }
165
+
166
+ // Refresh views on push updates (target minimally and avoid flicker)
167
+ client.on('issues-changed', (payload) => {
168
+ const s = store.getState();
169
+ const hintIds =
170
+ payload && payload.hint && Array.isArray(payload.hint.ids)
171
+ ? /** @type {string[]} */ (payload.hint.ids)
172
+ : null;
173
+
174
+ const showingDetail = Boolean(s.selected_id);
175
+
176
+ // If a top-level view is visible (and not detail), refresh that view
177
+ if (!showingDetail) {
178
+ if (s.view === 'issues') {
179
+ void issues_view.load();
180
+ } else if (s.view === 'epics') {
181
+ void epics_view.load();
182
+ } else if (s.view === 'board') {
183
+ void board_view.load();
184
+ }
185
+ }
186
+
187
+ // If a detail is visible, re-fetch it when relevant or when hints are absent
188
+ if (showingDetail && s.selected_id) {
189
+ if (!hintIds || hintIds.includes(s.selected_id)) {
190
+ void detail.load(s.selected_id);
191
+ }
192
+ }
193
+ });
194
+
195
+ // Toggle route shells on view/detail change and persist
196
+ const data = createDataLayer(/** @type {any} */ (transport), client.on);
197
+ const epics_view = createEpicsView(epics_root, data, (id) =>
198
+ router.gotoIssue(id)
199
+ );
200
+ const board_view = createBoardView(board_root, data, (id) =>
201
+ router.gotoIssue(id)
202
+ );
203
+ // Preload epics when switching to view
204
+ /**
205
+ * @param {{ selected_id: string | null, view: 'issues'|'epics'|'board', filters: any }} s
206
+ */
207
+ const onRouteChange = (s) => {
208
+ const showDetail = Boolean(s.selected_id);
209
+ if (issues_root && epics_root && board_root && detail_mount) {
210
+ issues_root.hidden = showDetail || s.view !== 'issues';
211
+ epics_root.hidden = showDetail || s.view !== 'epics';
212
+ board_root.hidden = showDetail || s.view !== 'board';
213
+ detail_mount.hidden = !showDetail;
214
+ }
215
+ if (!showDetail && s.view === 'epics') {
216
+ void epics_view.load();
217
+ }
218
+ if (!showDetail && s.view === 'board') {
219
+ void board_view.load();
220
+ }
221
+ try {
222
+ window.localStorage.setItem('beads-ui.view', s.view);
223
+ } catch {
224
+ // ignore
225
+ }
226
+ };
227
+ store.subscribe(onRouteChange);
228
+ // Ensure initial state is reflected (fixes reload on #/epics)
229
+ onRouteChange(store.getState());
230
+ }
231
+ }
232
+
233
+ if (typeof window !== 'undefined' && typeof document !== 'undefined') {
234
+ window.addEventListener('DOMContentLoaded', () => {
235
+ // Initialize theme from saved preference or OS preference
236
+ try {
237
+ const saved = window.localStorage.getItem('beads-ui.theme');
238
+ const prefersDark =
239
+ window.matchMedia &&
240
+ window.matchMedia('(prefers-color-scheme: dark)').matches;
241
+ const initial =
242
+ saved === 'dark' || saved === 'light'
243
+ ? saved
244
+ : prefersDark
245
+ ? 'dark'
246
+ : 'light';
247
+ document.documentElement.setAttribute('data-theme', initial);
248
+ const sw = /** @type {HTMLInputElement|null} */ (
249
+ document.getElementById('theme-switch')
250
+ );
251
+ if (sw) {
252
+ sw.checked = initial === 'dark';
253
+ }
254
+ } catch {
255
+ // ignore theme init errors
256
+ }
257
+
258
+ // Wire up theme switch in header
259
+ const themeSwitch = /** @type {HTMLInputElement|null} */ (
260
+ document.getElementById('theme-switch')
261
+ );
262
+ if (themeSwitch) {
263
+ themeSwitch.addEventListener('change', () => {
264
+ const mode = themeSwitch.checked ? 'dark' : 'light';
265
+ document.documentElement.setAttribute('data-theme', mode);
266
+ try {
267
+ window.localStorage.setItem('beads-ui.theme', mode);
268
+ } catch {
269
+ // ignore persistence errors
270
+ }
271
+ });
272
+ }
273
+
274
+ /** @type {HTMLElement|null} */
275
+ const app_root = document.getElementById('app');
276
+ if (app_root) {
277
+ bootstrap(app_root);
278
+ }
279
+ });
280
+ }
@@ -0,0 +1,229 @@
1
+ import { describe, expect, test, vi } from 'vitest';
2
+ import { bootstrap } from './main.js';
3
+
4
+ // Provide a mutable client instance for module-level mock
5
+ /** @type {any} */
6
+ let CLIENT = null;
7
+ vi.mock('./ws.js', () => ({
8
+ createWsClient: () => CLIENT
9
+ }));
10
+
11
+ describe('live updates: issues-changed handling', () => {
12
+ test('refreshes list only when on issues view and preserves scroll', async () => {
13
+ /** @type {{ send: import('vitest').Mock, on: (t: string, h: (p:any)=>void)=>void, trigger: (t:string, p:any)=>void }} */
14
+ CLIENT = {
15
+ send: vi.fn(async (type) => {
16
+ if (type === 'list-issues') {
17
+ return [
18
+ { id: 'UI-1', title: 'A', status: 'open' },
19
+ { id: 'UI-2', title: 'B', status: 'open' }
20
+ ];
21
+ }
22
+ if (type === 'show-issue') {
23
+ return { id: 'UI-1' };
24
+ }
25
+ if (type === 'epic-status') {
26
+ return [];
27
+ }
28
+ return null;
29
+ }),
30
+ /**
31
+ * @param {string} _type
32
+ * @param {(p:any)=>void} handler
33
+ */
34
+ on(_type, handler) {
35
+ this._handler = handler;
36
+ return () => {};
37
+ },
38
+ /**
39
+ * @param {string} type
40
+ * @param {any} payload
41
+ */
42
+ trigger(type, payload) {
43
+ if (type === 'issues-changed' && this._handler) this._handler(payload);
44
+ },
45
+ close() {},
46
+ getState() {
47
+ return 'open';
48
+ }
49
+ };
50
+
51
+ document.body.innerHTML = '<main id="app"></main>';
52
+ const root = /** @type {HTMLElement} */ (document.getElementById('app'));
53
+
54
+ bootstrap(root);
55
+ await Promise.resolve();
56
+
57
+ // Simulate a scrolled list container
58
+ const listRoot = /** @type {HTMLElement} */ (
59
+ document.getElementById('list-root')
60
+ );
61
+ if (listRoot) {
62
+ listRoot.scrollTop = 120;
63
+ }
64
+
65
+ const callsBefore = CLIENT.send.mock.calls.length;
66
+ CLIENT.trigger('issues-changed', { ts: Date.now() });
67
+ await Promise.resolve();
68
+
69
+ const callsAfter = CLIENT.send.mock.calls.length;
70
+ // One additional list-issues request, no detail fetch
71
+ const newCalls = CLIENT.send.mock.calls.slice(callsBefore);
72
+ const types = newCalls.map(/** @param {any} c */ (c) => c[0]);
73
+ expect(types).toEqual(['list-issues']);
74
+
75
+ // Scroll should remain
76
+ const listRootAfter = /** @type {HTMLElement} */ (
77
+ document.getElementById('list-root')
78
+ );
79
+ expect(listRootAfter.scrollTop).toBe(120);
80
+ expect(callsAfter).toBe(callsBefore + 1);
81
+ });
82
+
83
+ test('refreshes detail only when detail is visible and id matches hint', async () => {
84
+ /** @type {{ send: import('vitest').Mock, on: (t: string, h: (p:any)=>void)=>void, trigger: (t:string, p:any)=>void }} */
85
+ CLIENT = {
86
+ send: vi.fn(async (type, payload) => {
87
+ if (type === 'list-issues') {
88
+ return [];
89
+ }
90
+ if (type === 'show-issue') {
91
+ return { id: payload.id };
92
+ }
93
+ if (type === 'epic-status') {
94
+ return [];
95
+ }
96
+ return null;
97
+ }),
98
+ /**
99
+ * @param {string} _type
100
+ * @param {(p:any)=>void} handler
101
+ */
102
+ on(_type, handler) {
103
+ this._handler = handler;
104
+ return () => {};
105
+ },
106
+ /**
107
+ * @param {string} type
108
+ * @param {any} payload
109
+ */
110
+ trigger(type, payload) {
111
+ if (type === 'issues-changed' && this._handler) this._handler(payload);
112
+ },
113
+ close() {},
114
+ getState() {
115
+ return 'open';
116
+ }
117
+ };
118
+
119
+ // Navigate to detail view
120
+ window.location.hash = '#/issue/UI-1';
121
+ document.body.innerHTML = '<main id="app"></main>';
122
+ const root = /** @type {HTMLElement} */ (document.getElementById('app'));
123
+
124
+ bootstrap(root);
125
+ await Promise.resolve();
126
+
127
+ CLIENT.send.mockClear();
128
+ CLIENT.trigger('issues-changed', {
129
+ ts: Date.now(),
130
+ hint: { ids: ['UI-1'] }
131
+ });
132
+ await Promise.resolve();
133
+
134
+ const calls = CLIENT.send.mock.calls.map(/** @param {any} c */ (c) => c[0]);
135
+ expect(calls).toEqual(['show-issue']);
136
+ });
137
+
138
+ test('refreshes epics when epics view visible', async () => {
139
+ CLIENT = {
140
+ send: vi.fn(async (type) => {
141
+ if (type === 'epic-status') {
142
+ return [];
143
+ }
144
+ return [];
145
+ }),
146
+ /**
147
+ * @param {string} _type
148
+ * @param {(p:any)=>void} handler
149
+ */
150
+ on(_type, handler) {
151
+ this._handler = handler;
152
+ return () => {};
153
+ },
154
+ /**
155
+ * @param {string} type
156
+ * @param {any} payload
157
+ */
158
+ trigger(type, payload) {
159
+ if (type === 'issues-changed' && this._handler) this._handler(payload);
160
+ },
161
+ close() {},
162
+ getState() {
163
+ return 'open';
164
+ }
165
+ };
166
+
167
+ window.location.hash = '#/epics';
168
+ document.body.innerHTML = '<main id="app"></main>';
169
+ const root = /** @type {HTMLElement} */ (document.getElementById('app'));
170
+ bootstrap(root);
171
+ await Promise.resolve();
172
+
173
+ // Ignore initial load
174
+ CLIENT.send.mockClear();
175
+ CLIENT.trigger('issues-changed', { ts: Date.now() });
176
+ await Promise.resolve();
177
+
178
+ const calls = CLIENT.send.mock.calls.map(/** @param {any} c */ (c) => c[0]);
179
+ expect(calls).toEqual(['epic-status']);
180
+ });
181
+
182
+ test('refreshes board when board view visible', async () => {
183
+ CLIENT = {
184
+ send: vi.fn(async (type) => {
185
+ if (type === 'list-issues') {
186
+ return [];
187
+ }
188
+ if (type === 'epic-status') {
189
+ return [];
190
+ }
191
+ return [];
192
+ }),
193
+ /**
194
+ * @param {string} _type
195
+ * @param {(p:any)=>void} handler
196
+ */
197
+ on(_type, handler) {
198
+ this._handler = handler;
199
+ return () => {};
200
+ },
201
+ /**
202
+ * @param {string} type
203
+ * @param {any} payload
204
+ */
205
+ trigger(type, payload) {
206
+ if (type === 'issues-changed' && this._handler) this._handler(payload);
207
+ },
208
+ close() {},
209
+ getState() {
210
+ return 'open';
211
+ }
212
+ };
213
+
214
+ window.location.hash = '#/board';
215
+ document.body.innerHTML = '<main id="app"></main>';
216
+ const root = /** @type {HTMLElement} */ (document.getElementById('app'));
217
+ bootstrap(root);
218
+ await Promise.resolve();
219
+
220
+ CLIENT.send.mockClear();
221
+ CLIENT.trigger('issues-changed', { ts: Date.now() });
222
+ await Promise.resolve();
223
+
224
+ const calls = CLIENT.send.mock.calls.map(/** @param {any} c */ (c) => c[0]);
225
+ // Board loads multiple list-issues, assert at least one
226
+ expect(calls.length > 0).toBe(true);
227
+ expect(new Set(calls).has('list-issues')).toBe(true);
228
+ });
229
+ });
@@ -0,0 +1,17 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { bootstrap } from './main.js';
3
+
4
+ describe('app/main (jsdom)', () => {
5
+ test('renders two-panel shell into root', () => {
6
+ document.body.innerHTML = '<main id="app"></main>';
7
+ const root_element = /** @type {HTMLElement} */ (
8
+ document.getElementById('app')
9
+ );
10
+ bootstrap(root_element);
11
+
12
+ const list_panel = root_element.querySelector('#list-panel');
13
+ const detail_panel = root_element.querySelector('#detail-panel');
14
+ expect(list_panel).not.toBeNull();
15
+ expect(detail_panel).not.toBeNull();
16
+ });
17
+ });
@@ -0,0 +1,41 @@
1
+ import { describe, expect, test } from 'vitest';
2
+
3
+ describe('theme toggle', () => {
4
+ test('sets dark data-theme and persists preference', async () => {
5
+ document.body.innerHTML = `
6
+ <header class="app-header">
7
+ <h1 class="app-title">beads-ui</h1>
8
+ <div class="header-actions">
9
+ <label class="theme-toggle">
10
+ <span>Dark</span>
11
+ <input id="theme-switch" type="checkbox" />
12
+ </label>
13
+ </div>
14
+ </header>
15
+ <main id="app"></main>`;
16
+
17
+ // Simulate the DOMContentLoaded logic from main.js
18
+ const themeSwitch = /** @type {HTMLInputElement} */ (
19
+ document.getElementById('theme-switch')
20
+ );
21
+ themeSwitch.checked = true;
22
+ themeSwitch.dispatchEvent(new Event('change'));
23
+
24
+ // Apply attribute as in main.js handler
25
+ document.documentElement.setAttribute('data-theme', 'dark');
26
+ window.localStorage.setItem('beads-ui.theme', 'dark');
27
+
28
+ expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
29
+ expect(window.localStorage.getItem('beads-ui.theme')).toBe('dark');
30
+ });
31
+
32
+ test('can switch back to light explicitly', async () => {
33
+ document.documentElement.setAttribute('data-theme', 'dark');
34
+ window.localStorage.setItem('beads-ui.theme', 'dark');
35
+ // Simulate toggle off
36
+ document.documentElement.setAttribute('data-theme', 'light');
37
+ window.localStorage.setItem('beads-ui.theme', 'light');
38
+ expect(document.documentElement.getAttribute('data-theme')).toBe('light');
39
+ expect(window.localStorage.getItem('beads-ui.theme')).toBe('light');
40
+ });
41
+ });
@@ -0,0 +1,54 @@
1
+ import { describe, expect, test, vi } from 'vitest';
2
+ import { bootstrap } from './main.js';
3
+
4
+ // Mock WS client before importing the app
5
+ vi.mock('./ws.js', () => ({
6
+ createWsClient: () => ({
7
+ /**
8
+ * @param {string} type
9
+ */
10
+ async send(type) {
11
+ // Return minimal data for the list view
12
+ if (type === 'list-issues') {
13
+ return [];
14
+ }
15
+ if (type === 'show-issue') {
16
+ return null;
17
+ }
18
+ if (type === 'epic-status') {
19
+ return [];
20
+ }
21
+ return null;
22
+ },
23
+ on() {
24
+ return () => {};
25
+ },
26
+ close() {},
27
+ getState() {
28
+ return 'open';
29
+ }
30
+ })
31
+ }));
32
+
33
+ describe('initial view sync on reload (#/epics)', () => {
34
+ test('shows Epics view when hash is #/epics', async () => {
35
+ window.location.hash = '#/epics';
36
+ document.body.innerHTML = '<main id="app"></main>';
37
+ const root = /** @type {HTMLElement} */ (document.getElementById('app'));
38
+
39
+ bootstrap(root);
40
+
41
+ // Allow any microtasks to flush
42
+ await Promise.resolve();
43
+
44
+ const issuesRoot = /** @type {HTMLElement} */ (
45
+ document.getElementById('issues-root')
46
+ );
47
+ const epicsRoot = /** @type {HTMLElement} */ (
48
+ document.getElementById('epics-root')
49
+ );
50
+
51
+ expect(issuesRoot.hidden).toBe(true);
52
+ expect(epicsRoot.hidden).toBe(false);
53
+ });
54
+ });