beads-ui 0.1.1 → 0.2.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 (82) hide show
  1. package/CHANGES.md +27 -1
  2. package/README.md +39 -45
  3. package/app/data/providers.js +57 -26
  4. package/app/index.html +8 -0
  5. package/app/main.js +179 -33
  6. package/app/protocol.md +3 -4
  7. package/app/router.js +45 -9
  8. package/app/state.js +27 -11
  9. package/app/styles.css +170 -6
  10. package/app/utils/issue-id-renderer.js +71 -0
  11. package/app/utils/issue-url.js +9 -0
  12. package/app/utils/toast.js +35 -0
  13. package/app/views/board.js +347 -17
  14. package/app/views/detail.js +292 -92
  15. package/app/views/epics.js +2 -2
  16. package/app/views/issue-dialog.js +170 -0
  17. package/app/views/issue-row.js +9 -8
  18. package/app/views/list.js +85 -11
  19. package/app/views/new-issue-dialog.js +352 -0
  20. package/app/ws.js +30 -0
  21. package/docs/architecture.md +1 -1
  22. package/package.json +17 -1
  23. package/server/cli/commands.js +11 -3
  24. package/server/cli/index.js +35 -4
  25. package/server/cli/usage.js +1 -1
  26. package/server/watcher.js +3 -3
  27. package/server/ws.js +39 -19
  28. package/.beads/issues.jsonl +0 -107
  29. package/.editorconfig +0 -10
  30. package/.eslintrc.json +0 -36
  31. package/.github/workflows/ci.yml +0 -38
  32. package/.prettierignore +0 -5
  33. package/AGENTS.md +0 -85
  34. package/app/data/providers.test.js +0 -126
  35. package/app/main.board-switch.test.js +0 -94
  36. package/app/main.deep-link.test.js +0 -64
  37. package/app/main.live-updates.test.js +0 -229
  38. package/app/main.test.js +0 -17
  39. package/app/main.theme.test.js +0 -41
  40. package/app/main.view-sync.test.js +0 -54
  41. package/app/protocol.test.js +0 -57
  42. package/app/router.test.js +0 -34
  43. package/app/state.test.js +0 -21
  44. package/app/utils/markdown.test.js +0 -103
  45. package/app/utils/type-badge.test.js +0 -30
  46. package/app/views/board.test.js +0 -184
  47. package/app/views/detail.acceptance-notes.test.js +0 -67
  48. package/app/views/detail.assignee.test.js +0 -161
  49. package/app/views/detail.deps.test.js +0 -97
  50. package/app/views/detail.edits.test.js +0 -146
  51. package/app/views/detail.labels.test.js +0 -73
  52. package/app/views/detail.priority.test.js +0 -86
  53. package/app/views/detail.test.js +0 -188
  54. package/app/views/detail.ui47.test.js +0 -78
  55. package/app/views/epics.test.js +0 -283
  56. package/app/views/list.inline-edits.test.js +0 -84
  57. package/app/views/list.test.js +0 -479
  58. package/app/views/nav.test.js +0 -43
  59. package/app/ws.test.js +0 -168
  60. package/docs/quickstart.md +0 -142
  61. package/eslint.config.js +0 -59
  62. package/media/bdui-board.png +0 -0
  63. package/media/bdui-epics.png +0 -0
  64. package/media/bdui-issues.png +0 -0
  65. package/prettier.config.js +0 -13
  66. package/server/app.test.js +0 -29
  67. package/server/bd.test.js +0 -93
  68. package/server/cli/cli.test.js +0 -109
  69. package/server/cli/commands.integration.test.js +0 -155
  70. package/server/cli/commands.unit.test.js +0 -94
  71. package/server/cli/open.test.js +0 -26
  72. package/server/db.test.js +0 -70
  73. package/server/protocol.test.js +0 -87
  74. package/server/watcher.test.js +0 -100
  75. package/server/ws.handlers.test.js +0 -174
  76. package/server/ws.labels.test.js +0 -95
  77. package/server/ws.mutations.test.js +0 -261
  78. package/server/ws.subscriptions.test.js +0 -116
  79. package/server/ws.test.js +0 -52
  80. package/test/setup-vitest.js +0 -12
  81. package/tsconfig.json +0 -23
  82. package/vitest.config.mjs +0 -14
@@ -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,6 +24,7 @@ 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]
@@ -58,42 +62,25 @@ export function createDetailView(
58
62
  /** @type {boolean} */
59
63
  let edit_desc = false;
60
64
  /** @type {boolean} */
65
+ let edit_design = false;
66
+ /** @type {boolean} */
67
+ let edit_notes = false;
68
+ /** @type {boolean} */
61
69
  let edit_accept = false;
62
70
  /** @type {boolean} */
63
71
  let edit_assignee = false;
64
72
  /** @type {string} */
65
73
  let new_label_text = '';
66
74
 
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
75
  /** @param {string} id */
95
76
  function issueHref(id) {
96
- return `#/issue/${id}`;
77
+ try {
78
+ /** @type {'issues'|'epics'|'board'} */
79
+ const view = parseView(window.location.hash || '');
80
+ return issueHashFor(view, id);
81
+ } catch {
82
+ return issueHashFor('issues', id);
83
+ }
97
84
  }
98
85
 
99
86
  /**
@@ -159,7 +146,7 @@ export function createDetailView(
159
146
  current.title = prev;
160
147
  edit_title = false;
161
148
  doRender();
162
- showToast('Failed to save title');
149
+ showToast('Failed to save title', 'error');
163
150
  } finally {
164
151
  pending = false;
165
152
  }
@@ -223,7 +210,7 @@ export function createDetailView(
223
210
  /** @type {any} */ (current).assignee = prev;
224
211
  edit_assignee = false;
225
212
  doRender();
226
- showToast('Failed to update assignee');
213
+ showToast('Failed to update assignee', 'error');
227
214
  } finally {
228
215
  pending = false;
229
216
  }
@@ -271,7 +258,7 @@ export function createDetailView(
271
258
  doRender();
272
259
  }
273
260
  } catch {
274
- showToast('Failed to add label');
261
+ showToast('Failed to add label', 'error');
275
262
  } finally {
276
263
  pending = false;
277
264
  }
@@ -294,7 +281,7 @@ export function createDetailView(
294
281
  doRender();
295
282
  }
296
283
  } catch {
297
- showToast('Failed to remove label');
284
+ showToast('Failed to remove label', 'error');
298
285
  } finally {
299
286
  pending = false;
300
287
  }
@@ -329,7 +316,7 @@ export function createDetailView(
329
316
  } catch {
330
317
  current.status = prev;
331
318
  doRender();
332
- showToast('Failed to update status');
319
+ showToast('Failed to update status', 'error');
333
320
  } finally {
334
321
  pending = false;
335
322
  }
@@ -364,7 +351,7 @@ export function createDetailView(
364
351
  } catch {
365
352
  current.priority = prev;
366
353
  doRender();
367
- showToast('Failed to update priority');
354
+ showToast('Failed to update priority', 'error');
368
355
  } finally {
369
356
  pending = false;
370
357
  }
@@ -427,7 +414,7 @@ export function createDetailView(
427
414
  current.description = prev;
428
415
  edit_desc = false;
429
416
  doRender();
430
- showToast('Failed to save description');
417
+ showToast('Failed to save description', 'error');
431
418
  } finally {
432
419
  pending = false;
433
420
  }
@@ -437,6 +424,150 @@ export function createDetailView(
437
424
  doRender();
438
425
  };
439
426
 
427
+ // Design inline edit handlers (same UX as Description)
428
+ const onDesignEdit = () => {
429
+ edit_design = true;
430
+ doRender();
431
+ try {
432
+ const ta = /** @type {HTMLTextAreaElement|null} */ (
433
+ mount_element.querySelector('#detail-root .design textarea')
434
+ );
435
+ if (ta) {
436
+ ta.focus();
437
+ }
438
+ } catch {
439
+ // ignore focus errors
440
+ }
441
+ };
442
+ /**
443
+ * @param {KeyboardEvent} ev
444
+ */
445
+ const onDesignKeydown = (ev) => {
446
+ if (ev.key === 'Escape') {
447
+ edit_design = false;
448
+ doRender();
449
+ } else if (ev.key === 'Enter' && (ev.ctrlKey || ev.metaKey)) {
450
+ const btn = /** @type {HTMLButtonElement|null} */ (
451
+ mount_element.querySelector(
452
+ '#detail-root .design .editable-actions button'
453
+ )
454
+ );
455
+ if (btn) {
456
+ btn.click();
457
+ }
458
+ }
459
+ };
460
+ const onDesignSave = async () => {
461
+ if (!current || pending) {
462
+ return;
463
+ }
464
+ /** @type {HTMLTextAreaElement|null} */
465
+ const ta = /** @type {any} */ (
466
+ mount_element.querySelector('#detail-root .design textarea')
467
+ );
468
+ const prev = current.design || '';
469
+ const next = ta ? ta.value : '';
470
+ if (next === prev) {
471
+ edit_design = false;
472
+ doRender();
473
+ return;
474
+ }
475
+ pending = true;
476
+ if (ta) {
477
+ ta.disabled = true;
478
+ }
479
+ try {
480
+ const updated = await sendFn('edit-text', {
481
+ id: current.id,
482
+ field: 'design',
483
+ value: next
484
+ });
485
+ if (updated && typeof updated === 'object') {
486
+ current = /** @type {IssueDetail} */ (updated);
487
+ edit_design = false;
488
+ doRender();
489
+ }
490
+ } catch {
491
+ current.design = prev;
492
+ edit_design = false;
493
+ doRender();
494
+ showToast('Failed to save design', 'error');
495
+ } finally {
496
+ pending = false;
497
+ }
498
+ };
499
+ const onDesignCancel = () => {
500
+ edit_design = false;
501
+ doRender();
502
+ };
503
+
504
+ // Notes inline edit handlers
505
+ const onNotesEdit = () => {
506
+ edit_notes = true;
507
+ doRender();
508
+ };
509
+ /**
510
+ * @param {KeyboardEvent} ev
511
+ */
512
+ const onNotesKeydown = (ev) => {
513
+ if (ev.key === 'Escape') {
514
+ edit_notes = false;
515
+ doRender();
516
+ } else if (ev.key === 'Enter' && (ev.ctrlKey || ev.metaKey)) {
517
+ const btn = /** @type {HTMLButtonElement|null} */ (
518
+ mount_element.querySelector(
519
+ '#detail-root .notes .editable-actions button'
520
+ )
521
+ );
522
+ if (btn) {
523
+ btn.click();
524
+ }
525
+ }
526
+ };
527
+ const onNotesSave = async () => {
528
+ if (!current || pending) {
529
+ return;
530
+ }
531
+ /** @type {HTMLTextAreaElement|null} */
532
+ const ta = /** @type {any} */ (
533
+ mount_element.querySelector('#detail-root .notes textarea')
534
+ );
535
+ const prev = current.notes || '';
536
+ const next = ta ? ta.value : '';
537
+ if (next === prev) {
538
+ edit_notes = false;
539
+ doRender();
540
+ return;
541
+ }
542
+ pending = true;
543
+ if (ta) {
544
+ ta.disabled = true;
545
+ }
546
+ try {
547
+ const updated = await sendFn('edit-text', {
548
+ id: current.id,
549
+ field: 'notes',
550
+ value: next
551
+ });
552
+ if (updated && typeof updated === 'object') {
553
+ current = /** @type {IssueDetail} */ (updated);
554
+ edit_notes = false;
555
+ doRender();
556
+ }
557
+ } catch {
558
+ current.notes = prev;
559
+ edit_notes = false;
560
+ doRender();
561
+ showToast('Failed to save notes', 'error');
562
+ } finally {
563
+ pending = false;
564
+ }
565
+ };
566
+ const onNotesCancel = () => {
567
+ edit_notes = false;
568
+ doRender();
569
+ };
570
+
440
571
  const onAcceptEdit = () => {
441
572
  edit_accept = true;
442
573
  doRender();
@@ -448,10 +579,7 @@ export function createDetailView(
448
579
  if (ev.key === 'Escape') {
449
580
  edit_accept = false;
450
581
  doRender();
451
- } else if (
452
- ev.key === 'Enter' &&
453
- /** @type {KeyboardEvent} */ (ev).ctrlKey
454
- ) {
582
+ } else if (ev.key === 'Enter' && (ev.ctrlKey || ev.metaKey)) {
455
583
  const btn = /** @type {HTMLButtonElement|null} */ (
456
584
  mount_element.querySelector(
457
585
  '#detail-root .acceptance .editable-actions button'
@@ -496,7 +624,7 @@ export function createDetailView(
496
624
  current.acceptance = prev;
497
625
  edit_accept = false;
498
626
  doRender();
499
- showToast('Failed to save acceptance');
627
+ showToast('Failed to save acceptance', 'error');
500
628
  } finally {
501
629
  pending = false;
502
630
  }
@@ -524,13 +652,10 @@ export function createDetailView(
524
652
  : items.map((dep) => {
525
653
  const did = dep.id;
526
654
  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;"
655
+ return html`<li
656
+ data-href=${href}
529
657
  @click=${() => navigateFn(href)}
530
658
  >
531
- <a href=${href} @click=${makeDepLinkClick(href)}
532
- >${issueDisplayId(did)}</a
533
- >
534
659
  ${createTypeBadge(dep.issue_type || '')}
535
660
  <span class="text-truncate">${dep.title || ''}</span>
536
661
  <button
@@ -562,14 +687,10 @@ export function createDetailView(
562
687
  aria-label="Edit title"
563
688
  .value=${issue.title || ''}
564
689
  @keydown=${onTitleInputKeydown}
565
- style="width:100%;font-size:inherit;line-height:inherit;"
566
690
  />
567
- <button style="margin-left:6px" @click=${onTitleSave}>Save</button>
568
- <button style="margin-left:6px" @click=${onTitleCancel}>
569
- Cancel
570
- </button>
691
+ <button @click=${onTitleSave}>Save</button>
692
+ <button @click=${onTitleCancel}>Cancel</button>
571
693
  </h2>
572
- <span class="mono detail-id">${issueDisplayId(issue.id)}</span>
573
694
  </div>`
574
695
  : html`<div class="detail-title">
575
696
  <h2>
@@ -583,7 +704,6 @@ export function createDetailView(
583
704
  >${issue.title || ''}</span
584
705
  >
585
706
  </h2>
586
- <span class="mono detail-id">${issueDisplayId(issue.id)}</span>
587
707
  </div>`;
588
708
 
589
709
  const status_select = html`<select
@@ -667,7 +787,7 @@ export function createDetailView(
667
787
  const accept_block = edit_accept
668
788
  ? html`<div class="acceptance">
669
789
  ${acceptance_text.trim().length > 0
670
- ? html`<div class="props-card__title">Acceptance</div>`
790
+ ? html`<div class="props-card__title">Acceptance Criteria</div>`
671
791
  : ''}
672
792
  <textarea
673
793
  @keydown=${onAcceptKeydown}
@@ -681,29 +801,66 @@ export function createDetailView(
681
801
  </div>
682
802
  </div>`
683
803
  : 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
- : ''}
804
+ ${(() => {
805
+ const text = acceptance_text;
806
+ const has = text.trim().length > 0;
807
+ return html`${has
808
+ ? html`<div class="props-card__title">Acceptance Criteria</div>`
809
+ : ''}
810
+ <div
811
+ class="md editable"
812
+ tabindex="0"
813
+ role="button"
814
+ aria-label="Edit acceptance criteria"
815
+ @click=${onAcceptEdit}
816
+ @keydown=${onAcceptEditableKeydown}
817
+ >
818
+ ${has
819
+ ? renderMarkdown(text)
820
+ : html`<div class="muted">Add acceptance criteria…</div>`}
821
+ </div>`;
822
+ })()}
697
823
  </div>`;
698
824
 
699
- // Notes (read-only): show heading only if there is content
825
+ // Notes: editable in-place similar to Description
700
826
  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>`;
827
+ const notes_block = edit_notes
828
+ ? html`<div class="notes">
829
+ ${notes_text.trim().length > 0
830
+ ? html`<div class="props-card__title">Notes</div>`
831
+ : ''}
832
+ <textarea
833
+ @keydown=${onNotesKeydown}
834
+ .value=${notes_text}
835
+ rows="6"
836
+ style="width:100%"
837
+ ></textarea>
838
+ <div class="editable-actions">
839
+ <button @click=${onNotesSave}>Save</button>
840
+ <button @click=${onNotesCancel}>Cancel</button>
841
+ </div>
842
+ </div>`
843
+ : html`<div class="notes">
844
+ ${(() => {
845
+ const text = notes_text;
846
+ const has = text.trim().length > 0;
847
+ return html`${has
848
+ ? html`<div class="props-card__title">Notes</div>`
849
+ : ''}
850
+ <div
851
+ class="md editable"
852
+ tabindex="0"
853
+ role="button"
854
+ aria-label="Edit notes"
855
+ @click=${onNotesEdit}
856
+ @keydown=${onNotesEditableKeydown}
857
+ >
858
+ ${has
859
+ ? renderMarkdown(text)
860
+ : html`<div class="muted">Add notes…</div>`}
861
+ </div>`;
862
+ })()}
863
+ </div>`;
707
864
 
708
865
  // Labels section
709
866
  const labels = Array.isArray(issue.labels) ? issue.labels : [];
@@ -729,7 +886,7 @@ export function createDetailView(
729
886
  <input
730
887
  type="text"
731
888
  aria-label="Add label"
732
- placeholder="Add label"
889
+ placeholder="Add label"
733
890
  .value=${new_label_text}
734
891
  @input=${onLabelInput}
735
892
  @keydown=${onLabelKeydown}
@@ -739,12 +896,53 @@ export function createDetailView(
739
896
  </div>
740
897
  </div>`;
741
898
 
899
+ // Design section block
900
+ const design_text = String(issue.design || '');
901
+ const design_block = edit_design
902
+ ? html`<div class="design">
903
+ ${design_text.trim().length > 0
904
+ ? html`<div class="props-card__title">Design</div>`
905
+ : ''}
906
+ <textarea
907
+ @keydown=${onDesignKeydown}
908
+ .value=${design_text}
909
+ rows="6"
910
+ style="width:100%"
911
+ ></textarea>
912
+ <div class="editable-actions">
913
+ <button @click=${onDesignSave}>Save</button>
914
+ <button @click=${onDesignCancel}>Cancel</button>
915
+ </div>
916
+ </div>`
917
+ : html`<div class="design">
918
+ ${(() => {
919
+ const text = design_text;
920
+ const has = text.trim().length > 0;
921
+ return html`${has
922
+ ? html`<div class="props-card__title">Design</div>`
923
+ : ''}
924
+ <div
925
+ class="md editable"
926
+ tabindex="0"
927
+ role="button"
928
+ aria-label="Edit design"
929
+ @click=${onDesignEdit}
930
+ @keydown=${onDesignEditableKeydown}
931
+ >
932
+ ${has
933
+ ? renderMarkdown(text)
934
+ : html`<div class="muted">Add design…</div>`}
935
+ </div>`;
936
+ })()}
937
+ </div>`;
938
+
742
939
  return html`
743
940
  <div class="panel__body" id="detail-root">
744
941
  <div style="position:relative">
745
942
  <div class="detail-layout">
746
943
  <div class="detail-main">
747
- ${title_zone} ${desc_block} ${notes_block} ${accept_block}
944
+ ${title_zone} ${desc_block} ${design_block} ${notes_block}
945
+ ${accept_block}
748
946
  </div>
749
947
  <div class="detail-side">
750
948
  <div class="props-card">
@@ -845,22 +1043,6 @@ export function createDetailView(
845
1043
  // panel header removed for detail view; ID is shown inline with title
846
1044
  }
847
1045
 
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
1046
  /**
865
1047
  * Create a click handler for the remove button of a dependency row.
866
1048
  * @param {string} did
@@ -961,7 +1143,7 @@ export function createDetailView(
961
1143
  }
962
1144
  }
963
1145
  } catch {
964
- showToast('Failed to add dependency');
1146
+ showToast('Failed to add dependency', 'error');
965
1147
  } finally {
966
1148
  pending = false;
967
1149
  }
@@ -998,6 +1180,24 @@ export function createDetailView(
998
1180
  }
999
1181
  }
1000
1182
 
1183
+ /**
1184
+ * @param {KeyboardEvent} ev
1185
+ */
1186
+ function onNotesEditableKeydown(ev) {
1187
+ if (ev.key === 'Enter') {
1188
+ onNotesEdit();
1189
+ }
1190
+ }
1191
+
1192
+ /**
1193
+ * @param {KeyboardEvent} ev
1194
+ */
1195
+ function onDesignEditableKeydown(ev) {
1196
+ if (ev.key === 'Enter') {
1197
+ onDesignEdit();
1198
+ }
1199
+ }
1200
+
1001
1201
  return {
1002
1202
  async load(id) {
1003
1203
  if (!id) {
@@ -1,5 +1,5 @@
1
1
  import { html, render } from 'lit-html';
2
- import { issueDisplayId } from '../utils/issue-id.js';
2
+ import { createIssueIdRenderer } from '../utils/issue-id-renderer.js';
3
3
  import { createIssueRowRenderer } from './issue-row.js';
4
4
 
5
5
  /**
@@ -62,7 +62,7 @@ export function createEpicsView(mount_element, data, goto_issue) {
62
62
  tabindex="0"
63
63
  aria-expanded=${is_open}
64
64
  >
65
- <span class="mono">${issueDisplayId(id)}</span>
65
+ ${createIssueIdRenderer(id, { class_name: 'mono' })}
66
66
  <span class="text-truncate" style="margin-left:8px"
67
67
  >${epic.title || '(no title)'}</span
68
68
  >