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