beads-ui 0.3.1 → 0.4.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 (56) hide show
  1. package/CHANGES.md +24 -0
  2. package/README.md +15 -6
  3. package/app/main.bundle.js +617 -0
  4. package/app/main.bundle.js.map +7 -0
  5. package/bin/bdui.js +2 -1
  6. package/package.json +27 -14
  7. package/server/app.js +38 -36
  8. package/server/bd.js +2 -2
  9. package/server/cli/commands.js +8 -8
  10. package/server/cli/daemon.js +13 -5
  11. package/server/cli/index.js +17 -31
  12. package/server/cli/usage.js +3 -2
  13. package/server/db.js +6 -6
  14. package/server/index.js +10 -4
  15. package/server/logging.js +23 -0
  16. package/server/watcher.js +7 -5
  17. package/server/ws.js +23 -11
  18. package/app/data/list-selectors.js +0 -103
  19. package/app/data/providers.js +0 -78
  20. package/app/data/sort.js +0 -47
  21. package/app/data/subscription-issue-store.js +0 -161
  22. package/app/data/subscription-issue-stores.js +0 -128
  23. package/app/data/subscriptions-store.js +0 -227
  24. package/app/main.js +0 -706
  25. package/app/protocol.md +0 -66
  26. package/app/router.js +0 -117
  27. package/app/state.js +0 -105
  28. package/app/utils/issue-id-renderer.js +0 -72
  29. package/app/utils/issue-id.js +0 -11
  30. package/app/utils/issue-type.js +0 -29
  31. package/app/utils/issue-url.js +0 -10
  32. package/app/utils/markdown.js +0 -16
  33. package/app/utils/priority-badge.js +0 -48
  34. package/app/utils/priority.js +0 -1
  35. package/app/utils/status-badge.js +0 -33
  36. package/app/utils/status.js +0 -25
  37. package/app/utils/toast.js +0 -35
  38. package/app/utils/type-badge.js +0 -34
  39. package/app/views/board.js +0 -537
  40. package/app/views/detail.js +0 -1252
  41. package/app/views/epics.js +0 -281
  42. package/app/views/issue-dialog.js +0 -164
  43. package/app/views/issue-row.js +0 -191
  44. package/app/views/list.js +0 -468
  45. package/app/views/nav.js +0 -68
  46. package/app/views/new-issue-dialog.js +0 -348
  47. package/app/ws.js +0 -282
  48. package/docs/adr/001-push-only-lists.md +0 -134
  49. package/docs/adr/002-per-subscription-stores-and-full-issue-push.md +0 -200
  50. package/docs/architecture.md +0 -194
  51. package/docs/data-exchange-subscription-plan.md +0 -198
  52. package/docs/db-watching.md +0 -30
  53. package/docs/migration-v2.md +0 -54
  54. package/docs/protocol/issues-push-v2.md +0 -179
  55. package/docs/subscription-issue-store.md +0 -112
  56. package/server/protocol.js +0 -3
package/app/main.js DELETED
@@ -1,706 +0,0 @@
1
- /**
2
- * @import { MessageType } from './protocol.js'
3
- */
4
- import { html, render } from 'lit-html';
5
- import { createListSelectors } from './data/list-selectors.js';
6
- import { createDataLayer } from './data/providers.js';
7
- import { createSubscriptionIssueStores } from './data/subscription-issue-stores.js';
8
- import { createSubscriptionStore } from './data/subscriptions-store.js';
9
- import { createHashRouter, parseHash } from './router.js';
10
- import { createStore } from './state.js';
11
- import { showToast } from './utils/toast.js';
12
- import { createBoardView } from './views/board.js';
13
- import { createDetailView } from './views/detail.js';
14
- import { createEpicsView } from './views/epics.js';
15
- import { createIssueDialog } from './views/issue-dialog.js';
16
- import { createListView } from './views/list.js';
17
- import { createTopNav } from './views/nav.js';
18
- import { createNewIssueDialog } from './views/new-issue-dialog.js';
19
- import { createWsClient } from './ws.js';
20
-
21
- /**
22
- * Bootstrap the SPA shell with two panels.
23
- *
24
- * @param {HTMLElement} root_element - The container element to render into.
25
- */
26
- export function bootstrap(root_element) {
27
- // Render route shells (nav is mounted in header)
28
- const shell = html`
29
- <section id="issues-root" class="route issues">
30
- <aside id="list-panel" class="panel"></aside>
31
- </section>
32
- <section id="epics-root" class="route epics" hidden></section>
33
- <section id="board-root" class="route board" hidden></section>
34
- <section id="detail-panel" class="route detail" hidden></section>
35
- `;
36
- render(shell, root_element);
37
-
38
- /** @type {HTMLElement|null} */
39
- const nav_mount = document.getElementById('top-nav');
40
- /** @type {HTMLElement|null} */
41
- const issues_root = document.getElementById('issues-root');
42
- /** @type {HTMLElement|null} */
43
- const epics_root = document.getElementById('epics-root');
44
- /** @type {HTMLElement|null} */
45
- const board_root = document.getElementById('board-root');
46
-
47
- /** @type {HTMLElement|null} */
48
- const list_mount = document.getElementById('list-panel');
49
- /** @type {HTMLElement|null} */
50
- const detail_mount = document.getElementById('detail-panel');
51
- if (list_mount && issues_root && epics_root && board_root && detail_mount) {
52
- const client = createWsClient();
53
- // Subscriptions: wire client events and expose subscribe/unsubscribe helpers
54
- const subscriptions = createSubscriptionStore((type, payload) =>
55
- client.send(type, payload)
56
- );
57
- // Per-subscription stores (source of truth)
58
- const sub_issue_stores = createSubscriptionIssueStores();
59
- // Route per-subscription push envelopes to the owning store
60
- client.on('snapshot', (payload) => {
61
- const p = /** @type {any} */ (payload);
62
- const id = p && typeof p.id === 'string' ? p.id : '';
63
- const store = id ? sub_issue_stores.getStore(id) : null;
64
- if (store && p && p.type === 'snapshot') {
65
- try {
66
- store.applyPush(p);
67
- } catch {
68
- // ignore
69
- }
70
- }
71
- });
72
- client.on('upsert', (payload) => {
73
- const p = /** @type {any} */ (payload);
74
- const id = p && typeof p.id === 'string' ? p.id : '';
75
- const store = id ? sub_issue_stores.getStore(id) : null;
76
- if (store && p && p.type === 'upsert') {
77
- try {
78
- store.applyPush(p);
79
- } catch {
80
- // ignore
81
- }
82
- }
83
- });
84
- client.on('delete', (payload) => {
85
- const p = /** @type {any} */ (payload);
86
- const id = p && typeof p.id === 'string' ? p.id : '';
87
- const store = id ? sub_issue_stores.getStore(id) : null;
88
- if (store && p && p.type === 'delete') {
89
- try {
90
- store.applyPush(p);
91
- } catch {
92
- // ignore
93
- }
94
- }
95
- });
96
- // Derived list selectors: render from per-subscription snapshots
97
- const listSelectors = createListSelectors(sub_issue_stores);
98
- // Show toasts for WebSocket connectivity changes
99
- /** @type {boolean} */
100
- let had_disconnect = false;
101
- if (typeof client.onConnection === 'function') {
102
- /** @type {(s: 'connecting'|'open'|'closed'|'reconnecting') => void} */
103
- const onConn = (s) => {
104
- if (s === 'reconnecting' || s === 'closed') {
105
- had_disconnect = true;
106
- showToast('Connection lost. Reconnecting…', 'error', 4000);
107
- } else if (s === 'open' && had_disconnect) {
108
- had_disconnect = false;
109
- showToast('Reconnected', 'success', 2200);
110
- }
111
- };
112
- client.onConnection(onConn);
113
- }
114
- // Load persisted filters (status/search/type) from localStorage
115
- /** @type {{ status: 'all'|'open'|'in_progress'|'closed'|'ready', search: string, type: string }} */
116
- let persisted_filters = { status: 'all', search: '', type: '' };
117
- try {
118
- const raw = window.localStorage.getItem('beads-ui.filters');
119
- if (raw) {
120
- const obj = JSON.parse(raw);
121
- if (obj && typeof obj === 'object') {
122
- const ALLOWED = ['bug', 'feature', 'task', 'epic', 'chore'];
123
- let parsed_type = '';
124
- if (typeof obj.type === 'string' && ALLOWED.includes(obj.type)) {
125
- parsed_type = obj.type;
126
- } else if (Array.isArray(obj.types)) {
127
- // Backwards compatibility: pick first valid from previous array format
128
- let first_valid = '';
129
- for (const it of obj.types) {
130
- if (ALLOWED.includes(String(it))) {
131
- first_valid = /** @type {string} */ (it);
132
- break;
133
- }
134
- }
135
- parsed_type = first_valid;
136
- }
137
- persisted_filters = {
138
- status: ['all', 'open', 'in_progress', 'closed', 'ready'].includes(
139
- obj.status
140
- )
141
- ? obj.status
142
- : 'all',
143
- search: typeof obj.search === 'string' ? obj.search : '',
144
- type: parsed_type
145
- };
146
- }
147
- }
148
- } catch {
149
- // ignore parse errors
150
- }
151
- // Load last-view from storage
152
- /** @type {'issues'|'epics'|'board'} */
153
- let last_view = 'issues';
154
- try {
155
- const raw_view = window.localStorage.getItem('beads-ui.view');
156
- if (
157
- raw_view === 'issues' ||
158
- raw_view === 'epics' ||
159
- raw_view === 'board'
160
- ) {
161
- last_view = raw_view;
162
- }
163
- } catch {
164
- // ignore
165
- }
166
- // Load board preferences
167
- /** @type {{ closed_filter: 'today'|'3'|'7' }} */
168
- let persistedBoard = { closed_filter: 'today' };
169
- try {
170
- const raw_board = window.localStorage.getItem('beads-ui.board');
171
- if (raw_board) {
172
- const obj = JSON.parse(raw_board);
173
- if (obj && typeof obj === 'object') {
174
- const cf = String(obj.closed_filter || 'today');
175
- if (cf === 'today' || cf === '3' || cf === '7') {
176
- persistedBoard.closed_filter = cf;
177
- }
178
- }
179
- }
180
- } catch {
181
- // ignore parse errors
182
- }
183
-
184
- const store = createStore({
185
- filters: persisted_filters,
186
- view: last_view,
187
- board: persistedBoard
188
- });
189
- const router = createHashRouter(store);
190
- router.start();
191
- /**
192
- * @param {string} type
193
- * @param {unknown} payload
194
- */
195
- const transport = async (type, payload) => {
196
- try {
197
- return await client.send(/** @type {MessageType} */ (type), payload);
198
- } catch {
199
- return [];
200
- }
201
- };
202
- // Top navigation (optional mount)
203
- if (nav_mount) {
204
- createTopNav(nav_mount, store, router);
205
- }
206
-
207
- // Global New Issue dialog (UI-106) mounted at root so it is always visible
208
- const new_issue_dialog = createNewIssueDialog(
209
- root_element,
210
- (type, payload) => client.send(type, payload),
211
- router,
212
- store
213
- );
214
- // Header button
215
- try {
216
- const btn_new = /** @type {HTMLButtonElement|null} */ (
217
- document.getElementById('new-issue-btn')
218
- );
219
- if (btn_new) {
220
- btn_new.addEventListener('click', () => new_issue_dialog.open());
221
- }
222
- } catch {
223
- // ignore missing header
224
- }
225
-
226
- // Local transport shim: for list-issues, serve from local listSelectors;
227
- // otherwise forward to ws transport for mutations/show.
228
- /**
229
- * @param {MessageType} type
230
- * @param {unknown} payload
231
- */
232
- const listTransport = async (type, payload) => {
233
- if (type === 'list-issues') {
234
- try {
235
- return listSelectors.selectIssuesFor('tab:issues');
236
- } catch {
237
- return [];
238
- }
239
- }
240
- return transport(type, payload);
241
- };
242
-
243
- const issues_view = createListView(
244
- list_mount,
245
- /** @type {any} */ (listTransport),
246
- (hash) => {
247
- const id = parseHash(hash);
248
- if (id) {
249
- router.gotoIssue(id);
250
- }
251
- },
252
- store,
253
- subscriptions,
254
- sub_issue_stores
255
- );
256
- // Persist filter changes to localStorage
257
- store.subscribe((s) => {
258
- try {
259
- const data = {
260
- status: s.filters.status,
261
- search: s.filters.search,
262
- type: typeof s.filters.type === 'string' ? s.filters.type : ''
263
- };
264
- window.localStorage.setItem('beads-ui.filters', JSON.stringify(data));
265
- } catch {
266
- // ignore
267
- }
268
- });
269
- // Persist board preferences
270
- store.subscribe((s) => {
271
- try {
272
- window.localStorage.setItem(
273
- 'beads-ui.board',
274
- JSON.stringify({ closed_filter: s.board.closed_filter })
275
- );
276
- } catch {
277
- // ignore
278
- }
279
- });
280
- void issues_view.load();
281
-
282
- // Dialog for issue details (UI-104)
283
- const dialog = createIssueDialog(detail_mount, store, () => {
284
- // Close: clear selection and return to current view
285
- const s = store.getState();
286
- store.setState({ selected_id: null });
287
- try {
288
- /** @type {'issues'|'epics'|'board'} */
289
- const v = s.view || 'issues';
290
- router.gotoView(v);
291
- } catch {
292
- // ignore
293
- }
294
- });
295
-
296
- /** @type {ReturnType<typeof createDetailView> | null} */
297
- let detail = null;
298
- // Mount details into the dialog body only
299
- detail = createDetailView(
300
- dialog.getMount(),
301
- transport,
302
- (hash) => {
303
- const id = parseHash(hash);
304
- if (id) {
305
- router.gotoIssue(id);
306
- }
307
- },
308
- sub_issue_stores
309
- );
310
-
311
- // If router already set a selected id (deep-link), open dialog now
312
- const initial_id = store.getState().selected_id;
313
- if (initial_id) {
314
- detail_mount.hidden = false;
315
- dialog.open(initial_id);
316
- if (detail) {
317
- void detail.load(initial_id);
318
- }
319
- // Ensure detail subscription is active on initial deep-link
320
- const client_id = `detail:${initial_id}`;
321
- const spec = { type: 'issue-detail', params: { id: initial_id } };
322
- // Register store first to avoid dropping the initial snapshot
323
- try {
324
- sub_issue_stores.register(client_id, spec);
325
- } catch {
326
- // ignore
327
- }
328
- void subscriptions.subscribeList(client_id, spec).catch(() => {});
329
- }
330
-
331
- // Open/close dialog based on selected_id (always dialog; no page variant)
332
- /** @type {null | (() => Promise<void>)} */
333
- let unsub_detail = null;
334
- store.subscribe((s) => {
335
- const id = s.selected_id;
336
- if (id) {
337
- detail_mount.hidden = false;
338
- dialog.open(id);
339
- if (detail) {
340
- void detail.load(id);
341
- }
342
- // Wire per-issue subscription for detail
343
- const client_id = `detail:${id}`;
344
- const spec = { type: 'issue-detail', params: { id } };
345
- // Ensure per-subscription issue store exists before subscribing
346
- try {
347
- sub_issue_stores.register(client_id, spec);
348
- } catch {
349
- // ignore
350
- }
351
- // Subscribe server-side
352
- void subscriptions
353
- .subscribeList(client_id, spec)
354
- .then((unsub) => {
355
- // Unsubscribe previous if any
356
- if (unsub_detail) {
357
- void unsub_detail().catch(() => {});
358
- }
359
- unsub_detail = unsub;
360
- })
361
- .catch(() => {});
362
- } else {
363
- try {
364
- dialog.close();
365
- } catch {
366
- // ignore
367
- }
368
- if (detail) {
369
- detail.clear();
370
- }
371
- detail_mount.hidden = true;
372
- if (unsub_detail) {
373
- void unsub_detail().catch(() => {});
374
- unsub_detail = null;
375
- }
376
- }
377
- });
378
-
379
- // Removed: issues-changed handling. All views re-render from
380
- // per-subscription stores which are updated by snapshot/upsert/delete.
381
-
382
- // Toggle route shells on view/detail change and persist
383
- const data = createDataLayer(transport);
384
- const epics_view = createEpicsView(
385
- epics_root,
386
- data,
387
- (id) => router.gotoIssue(id),
388
- subscriptions,
389
- sub_issue_stores
390
- );
391
- const board_view = createBoardView(
392
- board_root,
393
- data,
394
- (id) => router.gotoIssue(id),
395
- store,
396
- subscriptions,
397
- sub_issue_stores
398
- );
399
- // Preload epics when switching to view
400
- /**
401
- * @param {{ selected_id: string | null, view: 'issues'|'epics'|'board', filters: any }} s
402
- */
403
- // --- Subscriptions: tab-level management and filter-driven updates ---
404
- /** @type {null | (() => Promise<void>)} */
405
- let unsub_issues_tab = null;
406
- /** @type {null | (() => Promise<void>)} */
407
- let unsub_epics_tab = null;
408
- /** @type {null | (() => Promise<void>)} */
409
- let unsub_board_ready = null;
410
- /** @type {null | (() => Promise<void>)} */
411
- let unsub_board_in_progress = null;
412
- /** @type {null | (() => Promise<void>)} */
413
- let unsub_board_closed = null;
414
- /** @type {null | (() => Promise<void>)} */
415
- let unsub_board_blocked = null;
416
-
417
- /**
418
- * Compute subscription spec for Issues tab based on filters.
419
- *
420
- * @param {{ status?: string }} filters
421
- * @returns {{ type: string, params?: Record<string, string|number|boolean> }}
422
- */
423
- function computeIssuesSpec(filters) {
424
- const st = String(filters?.status || 'all');
425
- if (st === 'ready') {
426
- return { type: 'ready-issues' };
427
- }
428
- if (st === 'in_progress') {
429
- return { type: 'in-progress-issues' };
430
- }
431
- if (st === 'closed') {
432
- return { type: 'closed-issues' };
433
- }
434
- // "all" and "open" map to all-issues; client filters apply locally
435
- return { type: 'all-issues' };
436
- }
437
-
438
- /** @type {string|null} */
439
- let last_issues_spec_key = null;
440
- /**
441
- * Ensure only the active tab has subscriptions; clean up previous.
442
- *
443
- * @param {{ view: 'issues'|'epics'|'board', filters: any }} s
444
- */
445
- function ensureTabSubscriptions(s) {
446
- // Issues tab
447
- if (s.view === 'issues') {
448
- const spec = computeIssuesSpec(s.filters || {});
449
- const key = JSON.stringify(spec);
450
- // Register store first to capture the initial snapshot
451
- try {
452
- sub_issue_stores.register('tab:issues', spec);
453
- } catch {
454
- // ignore
455
- }
456
- // Only (re)subscribe if not yet subscribed or the spec changed
457
- if (!unsub_issues_tab || key !== last_issues_spec_key) {
458
- void subscriptions
459
- .subscribeList('tab:issues', spec)
460
- .then((unsub) => {
461
- unsub_issues_tab = unsub;
462
- last_issues_spec_key = key;
463
- })
464
- .catch(() => {
465
- // ignore transport errors; retry on next change
466
- });
467
- }
468
- } else if (unsub_issues_tab) {
469
- void unsub_issues_tab().catch(() => {});
470
- unsub_issues_tab = null;
471
- last_issues_spec_key = null;
472
- try {
473
- sub_issue_stores.unregister('tab:issues');
474
- } catch {
475
- // ignore
476
- }
477
- }
478
-
479
- // Epics tab
480
- if (s.view === 'epics') {
481
- // Register store first to avoid race with initial snapshot
482
- try {
483
- sub_issue_stores.register('tab:epics', { type: 'epics' });
484
- } catch {
485
- // ignore
486
- }
487
- void subscriptions
488
- .subscribeList('tab:epics', { type: 'epics' })
489
- .then((unsub) => {
490
- unsub_epics_tab = unsub;
491
- })
492
- .catch(() => {});
493
- } else if (unsub_epics_tab) {
494
- void unsub_epics_tab().catch(() => {});
495
- unsub_epics_tab = null;
496
- try {
497
- sub_issue_stores.unregister('tab:epics');
498
- } catch {
499
- // ignore
500
- }
501
- }
502
-
503
- // Board tab subscribes to lists used by columns
504
- if (s.view === 'board') {
505
- if (!unsub_board_ready) {
506
- try {
507
- sub_issue_stores.register('tab:board:ready', {
508
- type: 'ready-issues'
509
- });
510
- } catch {
511
- // ignore
512
- }
513
- void subscriptions
514
- .subscribeList('tab:board:ready', { type: 'ready-issues' })
515
- .then((u) => (unsub_board_ready = u))
516
- .catch(() => {});
517
- }
518
- if (!unsub_board_in_progress) {
519
- try {
520
- sub_issue_stores.register('tab:board:in-progress', {
521
- type: 'in-progress-issues'
522
- });
523
- } catch {
524
- // ignore
525
- }
526
- void subscriptions
527
- .subscribeList('tab:board:in-progress', {
528
- type: 'in-progress-issues'
529
- })
530
- .then((u) => (unsub_board_in_progress = u))
531
- .catch(() => {});
532
- }
533
- if (!unsub_board_closed) {
534
- try {
535
- sub_issue_stores.register('tab:board:closed', {
536
- type: 'closed-issues'
537
- });
538
- } catch {
539
- // ignore
540
- }
541
- void subscriptions
542
- .subscribeList('tab:board:closed', { type: 'closed-issues' })
543
- .then((u) => (unsub_board_closed = u))
544
- .catch(() => {});
545
- }
546
- if (!unsub_board_blocked) {
547
- try {
548
- sub_issue_stores.register('tab:board:blocked', {
549
- type: 'blocked-issues'
550
- });
551
- } catch {
552
- // ignore
553
- }
554
- void subscriptions
555
- .subscribeList('tab:board:blocked', { type: 'blocked-issues' })
556
- .then((u) => (unsub_board_blocked = u))
557
- .catch(() => {});
558
- }
559
- } else {
560
- // Unsubscribe all board lists when leaving the board view
561
- if (unsub_board_ready) {
562
- void unsub_board_ready().catch(() => {});
563
- unsub_board_ready = null;
564
- try {
565
- sub_issue_stores.unregister('tab:board:ready');
566
- } catch {
567
- // ignore
568
- }
569
- }
570
- if (unsub_board_in_progress) {
571
- void unsub_board_in_progress().catch(() => {});
572
- unsub_board_in_progress = null;
573
- try {
574
- sub_issue_stores.unregister('tab:board:in-progress');
575
- } catch {
576
- // ignore
577
- }
578
- }
579
- if (unsub_board_closed) {
580
- void unsub_board_closed().catch(() => {});
581
- unsub_board_closed = null;
582
- try {
583
- sub_issue_stores.unregister('tab:board:closed');
584
- } catch {
585
- // ignore
586
- }
587
- }
588
- if (unsub_board_blocked) {
589
- void unsub_board_blocked().catch(() => {});
590
- unsub_board_blocked = null;
591
- try {
592
- sub_issue_stores.unregister('tab:board:blocked');
593
- } catch {
594
- // ignore
595
- }
596
- }
597
- }
598
- }
599
-
600
- /**
601
- * Manage route visibility and list subscriptions per view.
602
- *
603
- * @param {{ selected_id: string | null, view: 'issues'|'epics'|'board', filters: any }} s
604
- */
605
- const onRouteChange = (s) => {
606
- if (issues_root && epics_root && board_root && detail_mount) {
607
- // Underlying route visibility is controlled only by selected view
608
- issues_root.hidden = s.view !== 'issues';
609
- epics_root.hidden = s.view !== 'epics';
610
- board_root.hidden = s.view !== 'board';
611
- // detail_mount visibility handled in subscription above
612
- }
613
- // Ensure subscriptions for the active tab before loading the view to
614
- // avoid empty initial renders due to racing list-delta.
615
- ensureTabSubscriptions(s);
616
- if (!s.selected_id && s.view === 'epics') {
617
- void epics_view.load();
618
- }
619
- if (!s.selected_id && s.view === 'board') {
620
- void board_view.load();
621
- }
622
- try {
623
- window.localStorage.setItem('beads-ui.view', s.view);
624
- } catch {
625
- // ignore
626
- }
627
- };
628
- store.subscribe(onRouteChange);
629
- // Ensure initial state is reflected (fixes reload on #/epics)
630
- onRouteChange(store.getState());
631
-
632
- // Removed redundant filter-change subscription: handled by ensureTabSubscriptions
633
-
634
- // Keyboard shortcuts: Ctrl/Cmd+N opens new issue; Ctrl/Cmd+Enter submits inside dialog
635
- window.addEventListener('keydown', (ev) => {
636
- const is_modifier = ev.ctrlKey || ev.metaKey;
637
- const key = String(ev.key || '').toLowerCase();
638
- const target = /** @type {HTMLElement} */ (ev.target);
639
- const tag =
640
- target && target.tagName ? String(target.tagName).toLowerCase() : '';
641
- const is_editable =
642
- tag === 'input' ||
643
- tag === 'textarea' ||
644
- tag === 'select' ||
645
- (target &&
646
- typeof target.isContentEditable === 'boolean' &&
647
- target.isContentEditable);
648
- if (is_modifier && key === 'n') {
649
- // Do not hijack when typing in inputs; common UX
650
- if (!is_editable) {
651
- ev.preventDefault();
652
- new_issue_dialog.open();
653
- }
654
- }
655
- });
656
- }
657
- }
658
-
659
- if (typeof window !== 'undefined' && typeof document !== 'undefined') {
660
- window.addEventListener('DOMContentLoaded', () => {
661
- // Initialize theme from saved preference or OS preference
662
- try {
663
- const saved = window.localStorage.getItem('beads-ui.theme');
664
- const prefersDark =
665
- window.matchMedia &&
666
- window.matchMedia('(prefers-color-scheme: dark)').matches;
667
- const initial =
668
- saved === 'dark' || saved === 'light'
669
- ? saved
670
- : prefersDark
671
- ? 'dark'
672
- : 'light';
673
- document.documentElement.setAttribute('data-theme', initial);
674
- const sw = /** @type {HTMLInputElement|null} */ (
675
- document.getElementById('theme-switch')
676
- );
677
- if (sw) {
678
- sw.checked = initial === 'dark';
679
- }
680
- } catch {
681
- // ignore theme init errors
682
- }
683
-
684
- // Wire up theme switch in header
685
- const themeSwitch = /** @type {HTMLInputElement|null} */ (
686
- document.getElementById('theme-switch')
687
- );
688
- if (themeSwitch) {
689
- themeSwitch.addEventListener('change', () => {
690
- const mode = themeSwitch.checked ? 'dark' : 'light';
691
- document.documentElement.setAttribute('data-theme', mode);
692
- try {
693
- window.localStorage.setItem('beads-ui.theme', mode);
694
- } catch {
695
- // ignore persistence errors
696
- }
697
- });
698
- }
699
-
700
- /** @type {HTMLElement|null} */
701
- const app_root = document.getElementById('app');
702
- if (app_root) {
703
- bootstrap(app_root);
704
- }
705
- });
706
- }