beads-ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/.beads/issues.jsonl +107 -0
  2. package/.editorconfig +10 -0
  3. package/.eslintrc.json +36 -0
  4. package/.github/workflows/ci.yml +38 -0
  5. package/.prettierignore +5 -0
  6. package/AGENTS.md +85 -0
  7. package/CHANGES.md +5 -0
  8. package/LICENSE +22 -0
  9. package/README.md +75 -0
  10. package/app/data/providers.js +178 -0
  11. package/app/data/providers.test.js +126 -0
  12. package/app/index.html +29 -0
  13. package/app/main.board-switch.test.js +94 -0
  14. package/app/main.deep-link.test.js +64 -0
  15. package/app/main.js +280 -0
  16. package/app/main.live-updates.test.js +229 -0
  17. package/app/main.test.js +17 -0
  18. package/app/main.theme.test.js +41 -0
  19. package/app/main.view-sync.test.js +54 -0
  20. package/app/protocol.js +200 -0
  21. package/app/protocol.md +64 -0
  22. package/app/protocol.test.js +57 -0
  23. package/app/router.js +78 -0
  24. package/app/router.test.js +34 -0
  25. package/app/state.js +87 -0
  26. package/app/state.test.js +21 -0
  27. package/app/styles.css +1343 -0
  28. package/app/utils/issue-id.js +10 -0
  29. package/app/utils/issue-type.js +27 -0
  30. package/app/utils/markdown.js +201 -0
  31. package/app/utils/markdown.test.js +103 -0
  32. package/app/utils/priority-badge.js +49 -0
  33. package/app/utils/priority.js +1 -0
  34. package/app/utils/status-badge.js +33 -0
  35. package/app/utils/status.js +23 -0
  36. package/app/utils/type-badge.js +36 -0
  37. package/app/utils/type-badge.test.js +30 -0
  38. package/app/views/board.js +183 -0
  39. package/app/views/board.test.js +184 -0
  40. package/app/views/detail.acceptance-notes.test.js +67 -0
  41. package/app/views/detail.assignee.test.js +161 -0
  42. package/app/views/detail.deps.test.js +97 -0
  43. package/app/views/detail.edits.test.js +146 -0
  44. package/app/views/detail.js +1039 -0
  45. package/app/views/detail.labels.test.js +73 -0
  46. package/app/views/detail.priority.test.js +86 -0
  47. package/app/views/detail.test.js +188 -0
  48. package/app/views/detail.ui47.test.js +78 -0
  49. package/app/views/epics.js +228 -0
  50. package/app/views/epics.test.js +283 -0
  51. package/app/views/issue-row.js +191 -0
  52. package/app/views/list.inline-edits.test.js +84 -0
  53. package/app/views/list.js +393 -0
  54. package/app/views/list.test.js +479 -0
  55. package/app/views/nav.js +67 -0
  56. package/app/views/nav.test.js +43 -0
  57. package/app/ws.js +252 -0
  58. package/app/ws.test.js +168 -0
  59. package/bin/bdui.js +18 -0
  60. package/docs/architecture.md +244 -0
  61. package/docs/db-watching.md +29 -0
  62. package/docs/quickstart.md +142 -0
  63. package/eslint.config.js +59 -0
  64. package/media/bdui-board.png +0 -0
  65. package/media/bdui-epics.png +0 -0
  66. package/media/bdui-issues.png +0 -0
  67. package/package.json +48 -0
  68. package/prettier.config.js +13 -0
  69. package/server/app.js +80 -0
  70. package/server/app.test.js +29 -0
  71. package/server/bd.js +125 -0
  72. package/server/bd.test.js +93 -0
  73. package/server/cli/cli.test.js +109 -0
  74. package/server/cli/commands.integration.test.js +155 -0
  75. package/server/cli/commands.js +91 -0
  76. package/server/cli/commands.unit.test.js +94 -0
  77. package/server/cli/daemon.js +239 -0
  78. package/server/cli/index.js +74 -0
  79. package/server/cli/open.js +96 -0
  80. package/server/cli/open.test.js +26 -0
  81. package/server/cli/usage.js +22 -0
  82. package/server/config.js +29 -0
  83. package/server/db.js +100 -0
  84. package/server/db.test.js +70 -0
  85. package/server/index.js +29 -0
  86. package/server/protocol.js +3 -0
  87. package/server/protocol.test.js +87 -0
  88. package/server/watcher.js +107 -0
  89. package/server/watcher.test.js +100 -0
  90. package/server/ws.handlers.test.js +174 -0
  91. package/server/ws.js +784 -0
  92. package/server/ws.labels.test.js +95 -0
  93. package/server/ws.mutations.test.js +261 -0
  94. package/server/ws.subscriptions.test.js +116 -0
  95. package/server/ws.test.js +52 -0
  96. package/test/setup-vitest.js +12 -0
  97. package/tsconfig.json +23 -0
  98. package/vitest.config.mjs +14 -0
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Format a beads issue id as a user-facing display string `#${n}`.
3
+ * Extracts the trailing numeric portion of the id and prefixes with '#'.
4
+ * @param {string | null | undefined} id
5
+ * @returns {string}
6
+ */
7
+ export function issueDisplayId(id) {
8
+ const m = String(id || '').match(/(\d+)$/);
9
+ return m ? `#${m[1]}` : '#';
10
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Known issue types in canonical order for dropdowns.
3
+ * @type {Array<'bug'|'feature'|'task'|'epic'|'chore'>}
4
+ */
5
+ export const ISSUE_TYPES = ['bug', 'feature', 'task', 'epic', 'chore'];
6
+
7
+ /**
8
+ * Return a human-friendly label for an issue type.
9
+ * @param {string | null | undefined} type
10
+ * @returns {string}
11
+ */
12
+ export function typeLabel(type) {
13
+ switch ((type || '').toString().toLowerCase()) {
14
+ case 'bug':
15
+ return 'Bug';
16
+ case 'feature':
17
+ return 'Feature';
18
+ case 'task':
19
+ return 'Task';
20
+ case 'epic':
21
+ return 'Epic';
22
+ case 'chore':
23
+ return 'Chore';
24
+ default:
25
+ return '';
26
+ }
27
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Minimal, safe Markdown renderer that builds a DOM fragment without innerHTML.
3
+ * Supports headings, paragraphs, lists, code blocks, inline code, and links.
4
+ * This is intentionally conservative to avoid XSS.
5
+ * @function renderMarkdown
6
+ * @param {string} src Markdown source text
7
+ * @returns {DocumentFragment}
8
+ */
9
+ export function renderMarkdown(src) {
10
+ /** @type {DocumentFragment} */
11
+ const frag = document.createDocumentFragment();
12
+
13
+ /** @type {string[]} */
14
+ const lines = (src || '').replace(/\r\n?/g, '\n').split('\n');
15
+
16
+ /** @type {boolean} */
17
+ let in_code = false;
18
+ /** @type {string[]} */
19
+ let code_acc = [];
20
+
21
+ /** @type {HTMLElement|null} */
22
+ let list_el = null;
23
+ /** @type {string} */
24
+ let list_type = '';
25
+
26
+ /**
27
+ * Flush current paragraph buffer to <p> if any.
28
+ * @param {string[]} buf
29
+ */
30
+ function flushParagraph(buf) {
31
+ if (buf.length === 0) {
32
+ return;
33
+ }
34
+ const p = document.createElement('p');
35
+ appendInline(p, buf.join(' '));
36
+ frag.appendChild(p);
37
+ buf.length = 0;
38
+ }
39
+
40
+ /** @type {string[]} */
41
+ const para = [];
42
+
43
+ for (let i = 0; i < lines.length; i++) {
44
+ const raw = lines[i];
45
+ const line = raw;
46
+
47
+ // fenced code blocks
48
+ if (/^```/.test(line)) {
49
+ if (!in_code) {
50
+ in_code = true;
51
+ code_acc = [];
52
+ } else {
53
+ // flush code block
54
+ const pre = document.createElement('pre');
55
+ const code = document.createElement('code');
56
+ code.textContent = code_acc.join('\n');
57
+ pre.appendChild(code);
58
+ frag.appendChild(pre);
59
+ in_code = false;
60
+ code_acc = [];
61
+ }
62
+ continue;
63
+ }
64
+ if (in_code) {
65
+ code_acc.push(raw);
66
+ continue;
67
+ }
68
+
69
+ // blank line -> paragraph / list break
70
+ if (/^\s*$/.test(line)) {
71
+ flushParagraph(para);
72
+ if (list_el !== null) {
73
+ frag.appendChild(list_el);
74
+ list_el = null;
75
+ list_type = '';
76
+ }
77
+ continue;
78
+ }
79
+
80
+ // heading
81
+ const hx = /^(#{1,6})\s+(.*)$/.exec(line);
82
+ if (hx) {
83
+ flushParagraph(para);
84
+ if (list_el !== null) {
85
+ frag.appendChild(list_el);
86
+ list_el = null;
87
+ list_type = '';
88
+ }
89
+ const level = Math.min(6, hx[1].length);
90
+ const h = /** @type {HTMLHeadingElement} */ (
91
+ document.createElement('h' + String(level))
92
+ );
93
+ appendInline(h, hx[2]);
94
+ frag.appendChild(h);
95
+ continue;
96
+ }
97
+
98
+ // list item
99
+ let m = /^\s*[-*]\s+(.*)$/.exec(line);
100
+ if (m) {
101
+ if (list_el === null || list_type !== 'ul') {
102
+ flushParagraph(para);
103
+ if (list_el !== null) {
104
+ frag.appendChild(list_el);
105
+ }
106
+ list_el = document.createElement('ul');
107
+ list_type = 'ul';
108
+ }
109
+ const li = document.createElement('li');
110
+ appendInline(li, m[1]);
111
+ list_el.appendChild(li);
112
+ continue;
113
+ }
114
+ m = /^\s*(\d+)\.\s+(.*)$/.exec(line);
115
+ if (m) {
116
+ if (list_el === null || list_type !== 'ol') {
117
+ flushParagraph(para);
118
+ if (list_el !== null) {
119
+ frag.appendChild(list_el);
120
+ }
121
+ list_el = document.createElement('ol');
122
+ list_type = 'ol';
123
+ }
124
+ const li = document.createElement('li');
125
+ appendInline(li, m[2]);
126
+ list_el.appendChild(li);
127
+ continue;
128
+ }
129
+
130
+ // otherwise: paragraph text; accumulate to join with spaces
131
+ para.push(line.trim());
132
+ }
133
+
134
+ // flush leftovers
135
+ if (in_code) {
136
+ const pre = document.createElement('pre');
137
+ const code = document.createElement('code');
138
+ code.textContent = code_acc.join('\n');
139
+ pre.appendChild(code);
140
+ frag.appendChild(pre);
141
+ }
142
+ flushParagraph(para);
143
+ if (list_el !== null) {
144
+ frag.appendChild(list_el);
145
+ }
146
+
147
+ return frag;
148
+
149
+ /**
150
+ * Append inline content parsing backticks and links safely.
151
+ * - Inline code: `text`
152
+ * - Links: [text](url) where url starts with http, https, or mailto
153
+ * @param {HTMLElement} el
154
+ * @param {string} text
155
+ */
156
+ function appendInline(el, text) {
157
+ /** @type {RegExp} */
158
+ const re = /(`[^`]+`)|(\[[^\]]+\]\([^)]+\))/g;
159
+ /** @type {number} */
160
+ let last = 0;
161
+ /** @type {any} */
162
+ let match = re.exec(text);
163
+ while (match) {
164
+ const idx = match.index;
165
+ if (idx > last) {
166
+ el.appendChild(document.createTextNode(text.slice(last, idx)));
167
+ }
168
+ const token = match[0];
169
+ if (token[0] === '\u0060') {
170
+ const code = document.createElement('code');
171
+ code.textContent = token.slice(1, -1);
172
+ el.appendChild(code);
173
+ } else if (token[0] === '[') {
174
+ // parse [text](url)
175
+ const m2 = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(token);
176
+ if (m2) {
177
+ const a = document.createElement('a');
178
+ const href = m2[2].trim();
179
+ const allowed = /^(https?:|mailto:)/i.test(href);
180
+ if (allowed) {
181
+ a.setAttribute('href', href);
182
+ } else {
183
+ // if not allowed, render as text to avoid XSS vectors
184
+ a.remove();
185
+ el.appendChild(document.createTextNode(m2[1] + ' (' + href + ')'));
186
+ last = re.lastIndex;
187
+ match = re.exec(text);
188
+ continue;
189
+ }
190
+ a.appendChild(document.createTextNode(m2[1]));
191
+ el.appendChild(a);
192
+ }
193
+ }
194
+ last = re.lastIndex;
195
+ match = re.exec(text);
196
+ }
197
+ if (last < text.length) {
198
+ el.appendChild(document.createTextNode(text.slice(last)));
199
+ }
200
+ }
201
+ }
@@ -0,0 +1,103 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { renderMarkdown } from './markdown.js';
3
+
4
+ describe('utils/markdown', () => {
5
+ test('returns fragment for empty input', () => {
6
+ const frag = renderMarkdown('');
7
+
8
+ expect(frag).toBeInstanceOf(DocumentFragment);
9
+ });
10
+
11
+ test('renders headings', () => {
12
+ const frag = renderMarkdown('# Title\n\n### Sub');
13
+ const host = document.createElement('div');
14
+
15
+ host.appendChild(frag);
16
+
17
+ const h1 = /** @type {HTMLHeadingElement} */ (host.querySelector('h1'));
18
+ const h3 = /** @type {HTMLHeadingElement} */ (host.querySelector('h3'));
19
+ expect(h1.textContent).toBe('Title');
20
+ expect(h3.textContent).toBe('Sub');
21
+ });
22
+
23
+ test('renders paragraphs with and without blank lines', () => {
24
+ const frag = renderMarkdown('First line\ncontinues\n\nSecond para');
25
+ const host = document.createElement('div');
26
+
27
+ host.appendChild(frag);
28
+
29
+ const ps = host.querySelectorAll('p');
30
+ expect(ps.length).toBe(2);
31
+ expect(ps[0].textContent).toBe('First line continues');
32
+ expect(ps[1].textContent).toBe('Second para');
33
+ });
34
+
35
+ test('renders unordered list items', () => {
36
+ const frag = renderMarkdown('- a\n- b');
37
+ const host = document.createElement('div');
38
+
39
+ host.appendChild(frag);
40
+
41
+ const items = host.querySelectorAll('ul li');
42
+ expect(items.length).toBe(2);
43
+ expect(items[0].textContent).toBe('a');
44
+ expect(items[1].textContent).toBe('b');
45
+ });
46
+
47
+ test('renders ordered list items', () => {
48
+ const frag = renderMarkdown('1. a\n2. b');
49
+ const host = document.createElement('div');
50
+
51
+ host.appendChild(frag);
52
+
53
+ const items = host.querySelectorAll('ol li');
54
+ expect(items.length).toBe(2);
55
+ expect(items[0].textContent).toBe('a');
56
+ expect(items[1].textContent).toBe('b');
57
+ });
58
+
59
+ test('renders fenced code block', () => {
60
+ const frag = renderMarkdown('```\nline1\nline2\n```');
61
+ const host = document.createElement('div');
62
+
63
+ host.appendChild(frag);
64
+
65
+ const code = /** @type {HTMLElement} */ (host.querySelector('pre > code'));
66
+ expect(code.textContent).toBe('line1\nline2');
67
+ });
68
+
69
+ test('renders inline code', () => {
70
+ const frag = renderMarkdown('text `code` end');
71
+ const host = document.createElement('div');
72
+
73
+ host.appendChild(frag);
74
+
75
+ const code = /** @type {HTMLElement} */ (host.querySelector('p code'));
76
+ expect(code.textContent).toBe('code');
77
+ });
78
+
79
+ test('renders http and mailto links', () => {
80
+ const frag = renderMarkdown(
81
+ '[web](https://example.com) and [mail](mailto:test@example.com)'
82
+ );
83
+ const host = document.createElement('div');
84
+
85
+ host.appendChild(frag);
86
+
87
+ const hrefs = Array.from(host.querySelectorAll('a')).map((a) =>
88
+ a.getAttribute('href')
89
+ );
90
+ expect(hrefs).toEqual(['https://example.com', 'mailto:test@example.com']);
91
+ });
92
+
93
+ test('filters unsafe link schemes', () => {
94
+ const frag = renderMarkdown('x [danger](javascript:alert(1)) y');
95
+ const host = document.createElement('div');
96
+
97
+ host.appendChild(frag);
98
+
99
+ const anchors = host.querySelectorAll('a');
100
+ expect(anchors.length).toBe(0);
101
+ expect(host.textContent || '').toContain('danger (javascript:alert(1))');
102
+ });
103
+ });
@@ -0,0 +1,49 @@
1
+ import { priority_levels } from './priority.js';
2
+
3
+ /**
4
+ * Create a colored badge for a priority value (0..4).
5
+ * @param {number | null | undefined} priority
6
+ * @returns {HTMLSpanElement}
7
+ */
8
+ export function createPriorityBadge(priority) {
9
+ /** @type {number} */
10
+ const p = typeof priority === 'number' ? priority : 2;
11
+ /** @type {HTMLSpanElement} */
12
+ const el = document.createElement('span');
13
+ el.className = 'priority-badge';
14
+ el.classList.add(`is-p${Math.max(0, Math.min(4, p))}`);
15
+ el.setAttribute('role', 'img');
16
+ const label = labelForPriority(p);
17
+ el.setAttribute('title', label);
18
+ el.setAttribute('aria-label', `Priority: ${label}`);
19
+ el.textContent = emojiForPriority(p) + ' ' + label;
20
+ return el;
21
+ }
22
+
23
+ /**
24
+ * @param {number} p
25
+ */
26
+ function labelForPriority(p) {
27
+ const i = Math.max(0, Math.min(4, p));
28
+ return priority_levels[i] || 'Medium';
29
+ }
30
+
31
+ /**
32
+ * @param {number} p
33
+ */
34
+ export function emojiForPriority(p) {
35
+ switch (p) {
36
+ case 0:
37
+ return '🔥';
38
+ case 1:
39
+ return '⚡️';
40
+ case 2:
41
+ return '🔧';
42
+ case 3:
43
+ return '🪶';
44
+ case 4:
45
+ return '💤';
46
+ default:
47
+ return '🔧';
48
+ }
49
+ }
@@ -0,0 +1 @@
1
+ export const priority_levels = ['Critical', 'High', 'Medium', 'Low', 'Backlog'];
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Create a colored badge for a status value.
3
+ * @param {string | null | undefined} status - 'open' | 'in_progress' | 'closed'
4
+ * @returns {HTMLSpanElement}
5
+ */
6
+ export function createStatusBadge(status) {
7
+ /** @type {HTMLSpanElement} */
8
+ const el = document.createElement('span');
9
+ el.className = 'status-badge';
10
+ const s = String(status || 'open');
11
+ el.classList.add(`is-${s}`);
12
+ el.setAttribute('role', 'img');
13
+ el.setAttribute('title', labelForStatus(s));
14
+ el.setAttribute('aria-label', `Status: ${labelForStatus(s)}`);
15
+ el.textContent = labelForStatus(s);
16
+ return el;
17
+ }
18
+
19
+ /**
20
+ * @param {string} s
21
+ */
22
+ function labelForStatus(s) {
23
+ switch (s) {
24
+ case 'open':
25
+ return 'Open';
26
+ case 'in_progress':
27
+ return 'In progress';
28
+ case 'closed':
29
+ return 'Closed';
30
+ default:
31
+ return 'Unknown';
32
+ }
33
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Known status values in canonical order.
3
+ * @type {Array<'open'|'in_progress'|'closed'>}
4
+ */
5
+ export const STATUSES = ['open', 'in_progress', 'closed'];
6
+
7
+ /**
8
+ * Map canonical status to display label.
9
+ * @param {string | null | undefined} status
10
+ * @returns {string}
11
+ */
12
+ export function statusLabel(status) {
13
+ switch ((status || '').toString()) {
14
+ case 'open':
15
+ return 'Open';
16
+ case 'in_progress':
17
+ return 'In progress';
18
+ case 'closed':
19
+ return 'Closed';
20
+ default:
21
+ return (status || '').toString() || 'Open';
22
+ }
23
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Create a compact, colored badge for an issue type.
3
+ * @param {string | undefined | null} issue_type - One of: bug, feature, task, epic, chore
4
+ * @returns {HTMLSpanElement}
5
+ */
6
+ export function createTypeBadge(issue_type) {
7
+ /** @type {HTMLSpanElement} */
8
+ const el = document.createElement('span');
9
+ el.className = 'type-badge';
10
+
11
+ /** @type {string} */
12
+ const t = (issue_type || '').toString().toLowerCase();
13
+ /** @type {Set<string>} */
14
+ const KNOWN = new Set(['bug', 'feature', 'task', 'epic', 'chore']);
15
+ const kind = KNOWN.has(t) ? t : 'neutral';
16
+ el.classList.add(`type-badge--${kind}`);
17
+ el.setAttribute('role', 'img');
18
+ const label = KNOWN.has(t)
19
+ ? t === 'bug'
20
+ ? 'Bug'
21
+ : t === 'feature'
22
+ ? 'Feature'
23
+ : t === 'task'
24
+ ? 'Task'
25
+ : t === 'epic'
26
+ ? 'Epic'
27
+ : 'Chore'
28
+ : '—';
29
+ el.setAttribute(
30
+ 'aria-label',
31
+ KNOWN.has(t) ? `Issue type: ${label}` : 'Issue type: unknown'
32
+ );
33
+ el.setAttribute('title', KNOWN.has(t) ? `Type: ${label}` : 'Type: unknown');
34
+ el.textContent = label;
35
+ return el;
36
+ }
@@ -0,0 +1,30 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { createTypeBadge } from './type-badge.js';
3
+
4
+ describe('utils/type-badge', () => {
5
+ test('renders known types with modifier class and accessible labels', () => {
6
+ const types = [
7
+ ['bug', 'Bug'],
8
+ ['feature', 'Feature'],
9
+ ['task', 'Task'],
10
+ ['epic', 'Epic'],
11
+ ['chore', 'Chore']
12
+ ];
13
+ for (const [t, label] of /** @type {any[]} */ (types)) {
14
+ const el = createTypeBadge(t);
15
+ expect(el.classList.contains('type-badge')).toBe(true);
16
+ expect(el.classList.contains(`type-badge--${t}`)).toBe(true);
17
+ expect(el.getAttribute('role')).toBe('img');
18
+ const aria = el.getAttribute('aria-label') || '';
19
+ expect(aria.toLowerCase()).toContain('issue type');
20
+ expect(aria).toContain(label);
21
+ expect(el.textContent).toBe(label);
22
+ }
23
+ });
24
+
25
+ test('falls back to neutral for unknown types', () => {
26
+ const el = createTypeBadge('unknown');
27
+ expect(el.classList.contains('type-badge--neutral')).toBe(true);
28
+ expect(el.textContent).toBe('—');
29
+ });
30
+ });