beads-ui 0.1.2 → 0.3.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 (54) hide show
  1. package/CHANGES.md +29 -2
  2. package/README.md +39 -45
  3. package/app/data/list-selectors.js +98 -0
  4. package/app/data/providers.js +25 -127
  5. package/app/data/sort.js +45 -0
  6. package/app/data/subscription-issue-store.js +161 -0
  7. package/app/data/subscription-issue-stores.js +102 -0
  8. package/app/data/subscriptions-store.js +219 -0
  9. package/app/index.html +8 -0
  10. package/app/main.js +483 -61
  11. package/app/protocol.js +10 -14
  12. package/app/protocol.md +21 -19
  13. package/app/router.js +45 -9
  14. package/app/state.js +27 -11
  15. package/app/styles.css +373 -184
  16. package/app/utils/issue-id-renderer.js +71 -0
  17. package/app/utils/issue-url.js +9 -0
  18. package/app/utils/markdown.js +15 -194
  19. package/app/utils/priority-badge.js +0 -2
  20. package/app/utils/status-badge.js +0 -1
  21. package/app/utils/toast.js +34 -0
  22. package/app/utils/type-badge.js +0 -3
  23. package/app/views/board.js +439 -87
  24. package/app/views/detail.js +364 -154
  25. package/app/views/epics.js +128 -76
  26. package/app/views/issue-dialog.js +163 -0
  27. package/app/views/issue-row.js +10 -11
  28. package/app/views/list.js +164 -93
  29. package/app/views/new-issue-dialog.js +345 -0
  30. package/app/ws.js +36 -9
  31. package/bin/bdui.js +1 -1
  32. package/docs/adr/001-push-only-lists.md +134 -0
  33. package/docs/adr/002-per-subscription-stores-and-full-issue-push.md +200 -0
  34. package/docs/architecture.md +35 -85
  35. package/docs/data-exchange-subscription-plan.md +198 -0
  36. package/docs/db-watching.md +2 -1
  37. package/docs/migration-v2.md +54 -0
  38. package/docs/protocol/issues-push-v2.md +179 -0
  39. package/docs/subscription-issue-store.md +112 -0
  40. package/package.json +11 -3
  41. package/server/bd.js +0 -2
  42. package/server/cli/commands.js +12 -5
  43. package/server/cli/daemon.js +12 -5
  44. package/server/cli/index.js +34 -5
  45. package/server/cli/usage.js +2 -2
  46. package/server/config.js +12 -6
  47. package/server/db.js +0 -1
  48. package/server/index.js +9 -5
  49. package/server/list-adapters.js +218 -0
  50. package/server/subscriptions.js +277 -0
  51. package/server/validators.js +111 -0
  52. package/server/watcher.js +6 -9
  53. package/server/ws.js +466 -227
  54. package/docs/quickstart.md +0 -142
@@ -1,10 +1,13 @@
1
1
  // Issue Detail view implementation (lit-html based)
2
2
  import { html, render } from 'lit-html';
3
+ import { parseView } from '../router.js';
3
4
  import { issueDisplayId } from '../utils/issue-id.js';
5
+ import { issueHashFor } from '../utils/issue-url.js';
4
6
  import { renderMarkdown } from '../utils/markdown.js';
5
7
  import { emojiForPriority } from '../utils/priority-badge.js';
6
8
  import { priority_levels } from '../utils/priority.js';
7
9
  import { statusLabel } from '../utils/status.js';
10
+ import { showToast } from '../utils/toast.js';
8
11
  import { createTypeBadge } from '../utils/type-badge.js';
9
12
 
10
13
  /**
@@ -21,9 +24,11 @@ import { createTypeBadge } from '../utils/type-badge.js';
21
24
  * @property {string} id
22
25
  * @property {string} [title]
23
26
  * @property {string} [description]
27
+ * @property {string} [design]
24
28
  * @property {string} [acceptance]
25
29
  * @property {string} [notes]
26
30
  * @property {string} [status]
31
+ * @property {string} [assignee]
27
32
  * @property {number} [priority]
28
33
  * @property {string[]} [labels]
29
34
  * @property {Dependency[]} [dependencies]
@@ -42,15 +47,19 @@ function defaultNavigateFn(hash) {
42
47
  * @param {HTMLElement} mount_element - Element to render into.
43
48
  * @param {(type: string, payload?: unknown) => Promise<unknown>} sendFn - RPC transport.
44
49
  * @param {(hash: string) => void} [navigateFn] - Navigation function; defaults to setting location.hash.
50
+ * @param {{ snapshotFor?: (client_id: string) => any[], subscribe?: (fn: () => void) => () => void }} [issue_stores] - Optional issue stores for live updates.
45
51
  * @returns {{ load: (id: string) => Promise<void>, clear: () => void, destroy: () => void }} View API.
46
52
  */
47
53
  export function createDetailView(
48
54
  mount_element,
49
55
  sendFn,
50
- navigateFn = defaultNavigateFn
56
+ navigateFn = defaultNavigateFn,
57
+ issue_stores = undefined
51
58
  ) {
52
59
  /** @type {IssueDetail | null} */
53
60
  let current = null;
61
+ /** @type {string | null} */
62
+ let current_id = null;
54
63
  /** @type {boolean} */
55
64
  let pending = false;
56
65
  /** @type {boolean} */
@@ -58,42 +67,25 @@ export function createDetailView(
58
67
  /** @type {boolean} */
59
68
  let edit_desc = false;
60
69
  /** @type {boolean} */
70
+ let edit_design = false;
71
+ /** @type {boolean} */
72
+ let edit_notes = false;
73
+ /** @type {boolean} */
61
74
  let edit_accept = false;
62
75
  /** @type {boolean} */
63
76
  let edit_assignee = false;
64
77
  /** @type {string} */
65
78
  let new_label_text = '';
66
79
 
67
- /**
68
- * Show a transient toast message.
69
- * @param {string} text
70
- */
71
- function showToast(text) {
72
- /** @type {HTMLDivElement} */
73
- const toast = document.createElement('div');
74
- toast.className = 'toast';
75
- toast.textContent = text;
76
- toast.style.position = 'absolute';
77
- toast.style.right = '12px';
78
- toast.style.bottom = '12px';
79
- toast.style.background = 'rgba(0,0,0,0.8)';
80
- toast.style.color = '#fff';
81
- toast.style.padding = '8px 10px';
82
- toast.style.borderRadius = '4px';
83
- toast.style.fontSize = '12px';
84
- mount_element.appendChild(toast);
85
- setTimeout(() => {
86
- try {
87
- toast.remove();
88
- } catch {
89
- /* ignore */
90
- }
91
- }, 2800);
92
- }
93
-
94
80
  /** @param {string} id */
95
81
  function issueHref(id) {
96
- return `#/issue/${id}`;
82
+ try {
83
+ /** @type {'issues'|'epics'|'board'} */
84
+ const view = parseView(window.location.hash || '');
85
+ return issueHashFor(view, id);
86
+ } catch {
87
+ return issueHashFor('issues', id);
88
+ }
97
89
  }
98
90
 
99
91
  /**
@@ -110,6 +102,46 @@ export function createDetailView(
110
102
  );
111
103
  }
112
104
 
105
+ /**
106
+ * Refresh current from subscription store snapshot if available.
107
+ */
108
+ function refreshFromStore() {
109
+ if (
110
+ !current_id ||
111
+ !issue_stores ||
112
+ typeof issue_stores.snapshotFor !== 'function'
113
+ ) {
114
+ return;
115
+ }
116
+ const arr = /** @type {IssueDetail[]} */ (
117
+ issue_stores.snapshotFor(`detail:${current_id}`)
118
+ );
119
+ if (Array.isArray(arr) && arr.length > 0) {
120
+ // First item is the issue for this subscription
121
+ const found =
122
+ arr.find((it) => String(it.id) === String(current_id)) || arr[0];
123
+ current = /** @type {IssueDetail} */ (found);
124
+ }
125
+ }
126
+
127
+ // Live updates: re-render when issue stores change
128
+ if (issue_stores && typeof issue_stores.subscribe === 'function') {
129
+ issue_stores.subscribe(() => {
130
+ try {
131
+ const prev_id = current && current.id ? String(current.id) : null;
132
+ refreshFromStore();
133
+ // Only re-render when the current entity changed or when we were loading
134
+ if (!prev_id || (current && String(current.id) === prev_id)) {
135
+ doRender();
136
+ } else {
137
+ doRender();
138
+ }
139
+ } catch {
140
+ // ignore
141
+ }
142
+ });
143
+ }
144
+
113
145
  // Handlers
114
146
  const onTitleSpanClick = () => {
115
147
  edit_title = true;
@@ -131,8 +163,9 @@ export function createDetailView(
131
163
  if (!current || pending) {
132
164
  return;
133
165
  }
134
- /** @type {HTMLInputElement|null} */
135
- const input = /** @type {any} */ (mount_element.querySelector('h2 input'));
166
+ const input = /** @type {HTMLInputElement|null} */ (
167
+ mount_element.querySelector('h2 input')
168
+ );
136
169
  const prev = current.title || '';
137
170
  const next = input ? input.value : '';
138
171
  if (next === prev) {
@@ -159,7 +192,7 @@ export function createDetailView(
159
192
  current.title = prev;
160
193
  edit_title = false;
161
194
  doRender();
162
- showToast('Failed to save title');
195
+ showToast('Failed to save title', 'error');
163
196
  } finally {
164
197
  pending = false;
165
198
  }
@@ -191,14 +224,11 @@ export function createDetailView(
191
224
  if (!current || pending) {
192
225
  return;
193
226
  }
194
- /** @type {HTMLInputElement|null} */
195
- const input = /** @type {any} */ (
227
+ const input = /** @type {HTMLInputElement|null} */ (
196
228
  mount_element.querySelector('#detail-root .prop.assignee input')
197
229
  );
198
- const prev = String(
199
- (current && /** @type {any} */ (current).assignee) || ''
200
- );
201
- const next = input ? String(input.value || '') : '';
230
+ const prev = current?.assignee ?? '';
231
+ const next = input?.value ?? '';
202
232
  if (next === prev) {
203
233
  edit_assignee = false;
204
234
  doRender();
@@ -220,10 +250,10 @@ export function createDetailView(
220
250
  }
221
251
  } catch {
222
252
  // revert visually
223
- /** @type {any} */ (current).assignee = prev;
253
+ current.assignee = prev;
224
254
  edit_assignee = false;
225
255
  doRender();
226
- showToast('Failed to update assignee');
256
+ showToast('Failed to update assignee', 'error');
227
257
  } finally {
228
258
  pending = false;
229
259
  }
@@ -238,8 +268,7 @@ export function createDetailView(
238
268
  * @param {Event} ev
239
269
  */
240
270
  const onLabelInput = (ev) => {
241
- /** @type {HTMLInputElement} */
242
- const el = /** @type {any} */ (ev.currentTarget);
271
+ const el = /** @type {HTMLInputElement} */ (ev.currentTarget);
243
272
  new_label_text = el.value || '';
244
273
  };
245
274
  /**
@@ -271,7 +300,7 @@ export function createDetailView(
271
300
  doRender();
272
301
  }
273
302
  } catch {
274
- showToast('Failed to add label');
303
+ showToast('Failed to add label', 'error');
275
304
  } finally {
276
305
  pending = false;
277
306
  }
@@ -294,7 +323,7 @@ export function createDetailView(
294
323
  doRender();
295
324
  }
296
325
  } catch {
297
- showToast('Failed to remove label');
326
+ showToast('Failed to remove label', 'error');
298
327
  } finally {
299
328
  pending = false;
300
329
  }
@@ -307,8 +336,7 @@ export function createDetailView(
307
336
  doRender();
308
337
  return;
309
338
  }
310
- /** @type {HTMLSelectElement} */
311
- const sel = /** @type {any} */ (ev.currentTarget);
339
+ const sel = /** @type {HTMLSelectElement} */ (ev.currentTarget);
312
340
  const prev = current.status || 'open';
313
341
  const next = sel.value;
314
342
  if (next === prev) {
@@ -329,7 +357,7 @@ export function createDetailView(
329
357
  } catch {
330
358
  current.status = prev;
331
359
  doRender();
332
- showToast('Failed to update status');
360
+ showToast('Failed to update status', 'error');
333
361
  } finally {
334
362
  pending = false;
335
363
  }
@@ -342,8 +370,7 @@ export function createDetailView(
342
370
  doRender();
343
371
  return;
344
372
  }
345
- /** @type {HTMLSelectElement} */
346
- const sel = /** @type {any} */ (ev.currentTarget);
373
+ const sel = /** @type {HTMLSelectElement} */ (ev.currentTarget);
347
374
  const prev = typeof current.priority === 'number' ? current.priority : 2;
348
375
  const next = Number(sel.value);
349
376
  if (next === prev) {
@@ -364,7 +391,7 @@ export function createDetailView(
364
391
  } catch {
365
392
  current.priority = prev;
366
393
  doRender();
367
- showToast('Failed to update priority');
394
+ showToast('Failed to update priority', 'error');
368
395
  } finally {
369
396
  pending = false;
370
397
  }
@@ -381,10 +408,7 @@ export function createDetailView(
381
408
  if (ev.key === 'Escape') {
382
409
  edit_desc = false;
383
410
  doRender();
384
- } else if (
385
- ev.key === 'Enter' &&
386
- /** @type {KeyboardEvent} */ (ev).ctrlKey
387
- ) {
411
+ } else if (ev.key === 'Enter' && ev.ctrlKey) {
388
412
  const btn = /** @type {HTMLButtonElement|null} */ (
389
413
  mount_element.querySelector('#detail-root .editable-actions button')
390
414
  );
@@ -397,8 +421,7 @@ export function createDetailView(
397
421
  if (!current || pending) {
398
422
  return;
399
423
  }
400
- /** @type {HTMLTextAreaElement|null} */
401
- const ta = /** @type {any} */ (
424
+ const ta = /** @type {HTMLTextAreaElement|null} */ (
402
425
  mount_element.querySelector('#detail-root textarea')
403
426
  );
404
427
  const prev = current.description || '';
@@ -427,7 +450,7 @@ export function createDetailView(
427
450
  current.description = prev;
428
451
  edit_desc = false;
429
452
  doRender();
430
- showToast('Failed to save description');
453
+ showToast('Failed to save description', 'error');
431
454
  } finally {
432
455
  pending = false;
433
456
  }
@@ -437,6 +460,148 @@ export function createDetailView(
437
460
  doRender();
438
461
  };
439
462
 
463
+ // Design inline edit handlers (same UX as Description)
464
+ const onDesignEdit = () => {
465
+ edit_design = true;
466
+ doRender();
467
+ try {
468
+ const ta = /** @type {HTMLTextAreaElement|null} */ (
469
+ mount_element.querySelector('#detail-root .design textarea')
470
+ );
471
+ if (ta) {
472
+ ta.focus();
473
+ }
474
+ } catch {
475
+ // ignore focus errors
476
+ }
477
+ };
478
+ /**
479
+ * @param {KeyboardEvent} ev
480
+ */
481
+ const onDesignKeydown = (ev) => {
482
+ if (ev.key === 'Escape') {
483
+ edit_design = false;
484
+ doRender();
485
+ } else if (ev.key === 'Enter' && (ev.ctrlKey || ev.metaKey)) {
486
+ const btn = /** @type {HTMLButtonElement|null} */ (
487
+ mount_element.querySelector(
488
+ '#detail-root .design .editable-actions button'
489
+ )
490
+ );
491
+ if (btn) {
492
+ btn.click();
493
+ }
494
+ }
495
+ };
496
+ const onDesignSave = async () => {
497
+ if (!current || pending) {
498
+ return;
499
+ }
500
+ const ta = /** @type {HTMLTextAreaElement|null} */ (
501
+ mount_element.querySelector('#detail-root .design textarea')
502
+ );
503
+ const prev = current.design || '';
504
+ const next = ta ? ta.value : '';
505
+ if (next === prev) {
506
+ edit_design = false;
507
+ doRender();
508
+ return;
509
+ }
510
+ pending = true;
511
+ if (ta) {
512
+ ta.disabled = true;
513
+ }
514
+ try {
515
+ const updated = await sendFn('edit-text', {
516
+ id: current.id,
517
+ field: 'design',
518
+ value: next
519
+ });
520
+ if (updated && typeof updated === 'object') {
521
+ current = /** @type {IssueDetail} */ (updated);
522
+ edit_design = false;
523
+ doRender();
524
+ }
525
+ } catch {
526
+ current.design = prev;
527
+ edit_design = false;
528
+ doRender();
529
+ showToast('Failed to save design', 'error');
530
+ } finally {
531
+ pending = false;
532
+ }
533
+ };
534
+ const onDesignCancel = () => {
535
+ edit_design = false;
536
+ doRender();
537
+ };
538
+
539
+ // Notes inline edit handlers
540
+ const onNotesEdit = () => {
541
+ edit_notes = true;
542
+ doRender();
543
+ };
544
+ /**
545
+ * @param {KeyboardEvent} ev
546
+ */
547
+ const onNotesKeydown = (ev) => {
548
+ if (ev.key === 'Escape') {
549
+ edit_notes = false;
550
+ doRender();
551
+ } else if (ev.key === 'Enter' && (ev.ctrlKey || ev.metaKey)) {
552
+ const btn = /** @type {HTMLButtonElement|null} */ (
553
+ mount_element.querySelector(
554
+ '#detail-root .notes .editable-actions button'
555
+ )
556
+ );
557
+ if (btn) {
558
+ btn.click();
559
+ }
560
+ }
561
+ };
562
+ const onNotesSave = async () => {
563
+ if (!current || pending) {
564
+ return;
565
+ }
566
+ const ta = /** @type {HTMLTextAreaElement|null} */ (
567
+ mount_element.querySelector('#detail-root .notes textarea')
568
+ );
569
+ const prev = current.notes || '';
570
+ const next = ta ? ta.value : '';
571
+ if (next === prev) {
572
+ edit_notes = false;
573
+ doRender();
574
+ return;
575
+ }
576
+ pending = true;
577
+ if (ta) {
578
+ ta.disabled = true;
579
+ }
580
+ try {
581
+ const updated = await sendFn('edit-text', {
582
+ id: current.id,
583
+ field: 'notes',
584
+ value: next
585
+ });
586
+ if (updated && typeof updated === 'object') {
587
+ current = /** @type {IssueDetail} */ (updated);
588
+ edit_notes = false;
589
+ doRender();
590
+ }
591
+ } catch {
592
+ current.notes = prev;
593
+ edit_notes = false;
594
+ doRender();
595
+ showToast('Failed to save notes', 'error');
596
+ } finally {
597
+ pending = false;
598
+ }
599
+ };
600
+ const onNotesCancel = () => {
601
+ edit_notes = false;
602
+ doRender();
603
+ };
604
+
440
605
  const onAcceptEdit = () => {
441
606
  edit_accept = true;
442
607
  doRender();
@@ -448,10 +613,7 @@ export function createDetailView(
448
613
  if (ev.key === 'Escape') {
449
614
  edit_accept = false;
450
615
  doRender();
451
- } else if (
452
- ev.key === 'Enter' &&
453
- /** @type {KeyboardEvent} */ (ev).ctrlKey
454
- ) {
616
+ } else if (ev.key === 'Enter' && (ev.ctrlKey || ev.metaKey)) {
455
617
  const btn = /** @type {HTMLButtonElement|null} */ (
456
618
  mount_element.querySelector(
457
619
  '#detail-root .acceptance .editable-actions button'
@@ -466,8 +628,7 @@ export function createDetailView(
466
628
  if (!current || pending) {
467
629
  return;
468
630
  }
469
- /** @type {HTMLTextAreaElement|null} */
470
- const ta = /** @type {any} */ (
631
+ const ta = /** @type {HTMLTextAreaElement|null} */ (
471
632
  mount_element.querySelector('#detail-root .acceptance textarea')
472
633
  );
473
634
  const prev = current.acceptance || '';
@@ -496,7 +657,7 @@ export function createDetailView(
496
657
  current.acceptance = prev;
497
658
  edit_accept = false;
498
659
  doRender();
499
- showToast('Failed to save acceptance');
660
+ showToast('Failed to save acceptance', 'error');
500
661
  } finally {
501
662
  pending = false;
502
663
  }
@@ -524,13 +685,10 @@ export function createDetailView(
524
685
  : items.map((dep) => {
525
686
  const did = dep.id;
526
687
  const href = issueHref(did);
527
- return html` <li
528
- style="display:grid;grid-template-columns:auto auto 1fr auto;gap:6px;align-items:center;padding:2px 0;cursor:pointer;"
688
+ return html`<li
689
+ data-href=${href}
529
690
  @click=${() => navigateFn(href)}
530
691
  >
531
- <a href=${href} @click=${makeDepLinkClick(href)}
532
- >${issueDisplayId(did)}</a
533
- >
534
692
  ${createTypeBadge(dep.issue_type || '')}
535
693
  <span class="text-truncate">${dep.title || ''}</span>
536
694
  <button
@@ -562,14 +720,10 @@ export function createDetailView(
562
720
  aria-label="Edit title"
563
721
  .value=${issue.title || ''}
564
722
  @keydown=${onTitleInputKeydown}
565
- style="width:100%;font-size:inherit;line-height:inherit;"
566
723
  />
567
- <button style="margin-left:6px" @click=${onTitleSave}>Save</button>
568
- <button style="margin-left:6px" @click=${onTitleCancel}>
569
- Cancel
570
- </button>
724
+ <button @click=${onTitleSave}>Save</button>
725
+ <button @click=${onTitleCancel}>Cancel</button>
571
726
  </h2>
572
- <span class="mono detail-id">${issueDisplayId(issue.id)}</span>
573
727
  </div>`
574
728
  : html`<div class="detail-title">
575
729
  <h2>
@@ -583,7 +737,6 @@ export function createDetailView(
583
737
  >${issue.title || ''}</span
584
738
  >
585
739
  </h2>
586
- <span class="mono detail-id">${issueDisplayId(issue.id)}</span>
587
740
  </div>`;
588
741
 
589
742
  const status_select = html`<select
@@ -667,7 +820,7 @@ export function createDetailView(
667
820
  const accept_block = edit_accept
668
821
  ? html`<div class="acceptance">
669
822
  ${acceptance_text.trim().length > 0
670
- ? html`<div class="props-card__title">Acceptance</div>`
823
+ ? html`<div class="props-card__title">Acceptance Criteria</div>`
671
824
  : ''}
672
825
  <textarea
673
826
  @keydown=${onAcceptKeydown}
@@ -681,29 +834,66 @@ export function createDetailView(
681
834
  </div>
682
835
  </div>`
683
836
  : html`<div class="acceptance">
684
- ${acceptance_text.trim().length > 0
685
- ? html`<div class="props-card__title">Acceptance</div>
686
- <div
687
- class="md editable"
688
- tabindex="0"
689
- role="button"
690
- aria-label="Edit acceptance"
691
- @click=${onAcceptEdit}
692
- @keydown=${onAcceptEditableKeydown}
693
- >
694
- ${renderMarkdown(acceptance_text)}
695
- </div>`
696
- : ''}
837
+ ${(() => {
838
+ const text = acceptance_text;
839
+ const has = text.trim().length > 0;
840
+ return html`${has
841
+ ? html`<div class="props-card__title">Acceptance Criteria</div>`
842
+ : ''}
843
+ <div
844
+ class="md editable"
845
+ tabindex="0"
846
+ role="button"
847
+ aria-label="Edit acceptance criteria"
848
+ @click=${onAcceptEdit}
849
+ @keydown=${onAcceptEditableKeydown}
850
+ >
851
+ ${has
852
+ ? renderMarkdown(text)
853
+ : html`<div class="muted">Add acceptance criteria…</div>`}
854
+ </div>`;
855
+ })()}
697
856
  </div>`;
698
857
 
699
- // Notes (read-only): show heading only if there is content
858
+ // Notes: editable in-place similar to Description
700
859
  const notes_text = String(issue.notes || '');
701
- const notes_block = html`<div class="notes">
702
- ${notes_text.trim().length > 0
703
- ? html`<div class="props-card__title">Notes</div>
704
- <div class="md">${renderMarkdown(notes_text)}</div>`
705
- : ''}
706
- </div>`;
860
+ const notes_block = edit_notes
861
+ ? html`<div class="notes">
862
+ ${notes_text.trim().length > 0
863
+ ? html`<div class="props-card__title">Notes</div>`
864
+ : ''}
865
+ <textarea
866
+ @keydown=${onNotesKeydown}
867
+ .value=${notes_text}
868
+ rows="6"
869
+ style="width:100%"
870
+ ></textarea>
871
+ <div class="editable-actions">
872
+ <button @click=${onNotesSave}>Save</button>
873
+ <button @click=${onNotesCancel}>Cancel</button>
874
+ </div>
875
+ </div>`
876
+ : html`<div class="notes">
877
+ ${(() => {
878
+ const text = notes_text;
879
+ const has = text.trim().length > 0;
880
+ return html`${has
881
+ ? html`<div class="props-card__title">Notes</div>`
882
+ : ''}
883
+ <div
884
+ class="md editable"
885
+ tabindex="0"
886
+ role="button"
887
+ aria-label="Edit notes"
888
+ @click=${onNotesEdit}
889
+ @keydown=${onNotesEditableKeydown}
890
+ >
891
+ ${has
892
+ ? renderMarkdown(text)
893
+ : html`<div class="muted">Add notes…</div>`}
894
+ </div>`;
895
+ })()}
896
+ </div>`;
707
897
 
708
898
  // Labels section
709
899
  const labels = Array.isArray(issue.labels) ? issue.labels : [];
@@ -729,7 +919,7 @@ export function createDetailView(
729
919
  <input
730
920
  type="text"
731
921
  aria-label="Add label"
732
- placeholder="Add label"
922
+ placeholder="Add label"
733
923
  .value=${new_label_text}
734
924
  @input=${onLabelInput}
735
925
  @keydown=${onLabelKeydown}
@@ -739,12 +929,53 @@ export function createDetailView(
739
929
  </div>
740
930
  </div>`;
741
931
 
932
+ // Design section block
933
+ const design_text = String(issue.design || '');
934
+ const design_block = edit_design
935
+ ? html`<div class="design">
936
+ ${design_text.trim().length > 0
937
+ ? html`<div class="props-card__title">Design</div>`
938
+ : ''}
939
+ <textarea
940
+ @keydown=${onDesignKeydown}
941
+ .value=${design_text}
942
+ rows="6"
943
+ style="width:100%"
944
+ ></textarea>
945
+ <div class="editable-actions">
946
+ <button @click=${onDesignSave}>Save</button>
947
+ <button @click=${onDesignCancel}>Cancel</button>
948
+ </div>
949
+ </div>`
950
+ : html`<div class="design">
951
+ ${(() => {
952
+ const text = design_text;
953
+ const has = text.trim().length > 0;
954
+ return html`${has
955
+ ? html`<div class="props-card__title">Design</div>`
956
+ : ''}
957
+ <div
958
+ class="md editable"
959
+ tabindex="0"
960
+ role="button"
961
+ aria-label="Edit design"
962
+ @click=${onDesignEdit}
963
+ @keydown=${onDesignEditableKeydown}
964
+ >
965
+ ${has
966
+ ? renderMarkdown(text)
967
+ : html`<div class="muted">Add design…</div>`}
968
+ </div>`;
969
+ })()}
970
+ </div>`;
971
+
742
972
  return html`
743
973
  <div class="panel__body" id="detail-root">
744
974
  <div style="position:relative">
745
975
  <div class="detail-layout">
746
976
  <div class="detail-main">
747
- ${title_zone} ${desc_block} ${notes_block} ${accept_block}
977
+ ${title_zone} ${desc_block} ${design_block} ${notes_block}
978
+ ${accept_block}
748
979
  </div>
749
980
  <div class="detail-side">
750
981
  <div class="props-card">
@@ -773,12 +1004,7 @@ export function createDetailView(
773
1004
  .value=${/** @type {any} */ (issue).assignee || ''}
774
1005
  size=${Math.min(
775
1006
  40,
776
- Math.max(
777
- 12,
778
- String(
779
- /** @type {any} */ (issue).assignee || ''
780
- ).length + 3
781
- )
1007
+ Math.max(12, (issue.assignee || '').length + 3)
782
1008
  )}
783
1009
  @keydown=${
784
1010
  /** @param {KeyboardEvent} e */ (e) => {
@@ -807,9 +1033,7 @@ export function createDetailView(
807
1033
  Cancel
808
1034
  </button>`
809
1035
  : html`${(() => {
810
- const raw = String(
811
- /** @type {any} */ (issue).assignee || ''
812
- );
1036
+ const raw = issue.assignee || '';
813
1037
  const has = raw.trim().length > 0;
814
1038
  const text = has ? raw : 'Unassigned';
815
1039
  const cls = has ? 'editable' : 'editable muted';
@@ -838,29 +1062,13 @@ export function createDetailView(
838
1062
 
839
1063
  function doRender() {
840
1064
  if (!current) {
841
- renderPlaceholder('No issue selected');
1065
+ renderPlaceholder(current_id ? 'Loading…' : 'No issue selected');
842
1066
  return;
843
1067
  }
844
1068
  render(detailTemplate(current), mount_element);
845
1069
  // panel header removed for detail view; ID is shown inline with title
846
1070
  }
847
1071
 
848
- /**
849
- * Create an anchor click handler for dependency links.
850
- * @param {string} href
851
- * @returns {(ev: Event) => void}
852
- */
853
- function makeDepLinkClick(href) {
854
- return (ev) => {
855
- ev.preventDefault();
856
- /** @type {Event} */
857
- const e = ev;
858
- // stop bubbling to the li row click
859
- e.stopPropagation();
860
- navigateFn(href);
861
- };
862
- }
863
-
864
1072
  /**
865
1073
  * Create a click handler for the remove button of a dependency row.
866
1074
  * @param {string} did
@@ -869,9 +1077,7 @@ export function createDetailView(
869
1077
  */
870
1078
  function makeDepRemoveClick(did, title) {
871
1079
  return async (ev) => {
872
- /** @type {Event} */
873
- const e = ev;
874
- e.stopPropagation();
1080
+ ev.stopPropagation();
875
1081
  if (!current || pending) {
876
1082
  return;
877
1083
  }
@@ -917,10 +1123,10 @@ export function createDetailView(
917
1123
  if (!current || pending) {
918
1124
  return;
919
1125
  }
920
- /** @type {HTMLButtonElement} */
921
- const btn = /** @type {any} */ (ev.currentTarget);
922
- /** @type {HTMLInputElement|null} */
923
- const input = /** @type {any} */ (btn.previousElementSibling);
1126
+ const btn = /** @type {HTMLButtonElement} */ (ev.currentTarget);
1127
+ const input = /** @type {HTMLInputElement|null} */ (
1128
+ btn.previousElementSibling
1129
+ );
924
1130
  const target = input ? input.value.trim() : '';
925
1131
  if (!target || target === current.id) {
926
1132
  showToast('Enter a different issue id');
@@ -961,7 +1167,7 @@ export function createDetailView(
961
1167
  }
962
1168
  }
963
1169
  } catch {
964
- showToast('Failed to add dependency');
1170
+ showToast('Failed to add dependency', 'error');
965
1171
  } finally {
966
1172
  pending = false;
967
1173
  }
@@ -998,34 +1204,38 @@ export function createDetailView(
998
1204
  }
999
1205
  }
1000
1206
 
1207
+ /**
1208
+ * @param {KeyboardEvent} ev
1209
+ */
1210
+ function onNotesEditableKeydown(ev) {
1211
+ if (ev.key === 'Enter') {
1212
+ onNotesEdit();
1213
+ }
1214
+ }
1215
+
1216
+ /**
1217
+ * @param {KeyboardEvent} ev
1218
+ */
1219
+ function onDesignEditableKeydown(ev) {
1220
+ if (ev.key === 'Enter') {
1221
+ onDesignEdit();
1222
+ }
1223
+ }
1224
+
1001
1225
  return {
1002
1226
  async load(id) {
1003
1227
  if (!id) {
1004
1228
  renderPlaceholder('No issue selected');
1005
1229
  return;
1006
1230
  }
1007
- /** @type {unknown} */
1008
- let result;
1009
- try {
1010
- result = await sendFn('show-issue', { id });
1011
- } catch {
1012
- result = null;
1013
- }
1014
- if (!result || typeof result !== 'object') {
1015
- renderPlaceholder('Issue not found');
1016
- return;
1017
- }
1018
- const issue = /** @type {IssueDetail} */ (result);
1019
- // Some backends may normalize ID casing (e.g., UI-1 vs ui-1).
1020
- // Treat IDs case-insensitively to avoid false negatives on deep links.
1021
- if (
1022
- !issue ||
1023
- String(issue.id || '').toLowerCase() !== String(id || '').toLowerCase()
1024
- ) {
1025
- renderPlaceholder('Issue not found');
1026
- return;
1231
+ current_id = String(id);
1232
+ // Try from store first; show placeholder while waiting for snapshot
1233
+ current = null;
1234
+ refreshFromStore();
1235
+ if (!current) {
1236
+ renderPlaceholder('Loading…');
1027
1237
  }
1028
- current = issue;
1238
+ // Render from current (if available) or keep placeholder until push arrives
1029
1239
  pending = false;
1030
1240
  doRender();
1031
1241
  },