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
@@ -0,0 +1,71 @@
1
+ import { issueDisplayId } from './issue-id.js';
2
+
3
+ /**
4
+ * Create a reusable, copy-to-clipboard issue ID renderer.
5
+ * Looks like the current inline ID (monospace `#123`) but acts as a button
6
+ * that copies the full, prefixed ID (e.g., `UI-123`) when activated.
7
+ * Shows transient "Copied" feedback and then restores the ID.
8
+ * @param {string} id - Full issue id including the prefix (e.g., "UI-123").
9
+ * @param {{ class_name?: string, duration_ms?: number }} [opts]
10
+ * @returns {HTMLButtonElement}
11
+ */
12
+ export function createIssueIdRenderer(id, opts) {
13
+ /** @type {number} */
14
+ const duration =
15
+ typeof opts?.duration_ms === 'number' ? opts.duration_ms : 1200;
16
+ /** @type {HTMLButtonElement} */
17
+ const btn = document.createElement('button');
18
+ // Visual: match inline ID look; keep it neutral and text-like
19
+ btn.className =
20
+ (opts?.class_name ? opts.class_name + ' ' : '') + 'mono id-copy';
21
+ btn.type = 'button';
22
+ btn.setAttribute('aria-live', 'polite');
23
+ btn.setAttribute('title', 'Copy issue ID');
24
+ btn.setAttribute('aria-label', `Copy issue ID ${id}`);
25
+ const label = issueDisplayId(id);
26
+ btn.textContent = label;
27
+
28
+ /** Copy handler with feedback */
29
+ async function doCopy() {
30
+ // Prevent accidental row navigation and parent handlers
31
+ // (click/key handlers call this inside an event context)
32
+ try {
33
+ if (
34
+ navigator.clipboard &&
35
+ typeof navigator.clipboard.writeText === 'function'
36
+ ) {
37
+ await navigator.clipboard.writeText(String(id));
38
+ }
39
+ const prev = btn.textContent || label;
40
+ btn.textContent = 'Copied';
41
+ // Keep accessible label consistent with feedback
42
+ const oldAria = btn.getAttribute('aria-label') || '';
43
+ btn.setAttribute('aria-label', 'Copied');
44
+ setTimeout(
45
+ () => {
46
+ btn.textContent = prev;
47
+ btn.setAttribute('aria-label', oldAria);
48
+ },
49
+ Math.max(80, duration)
50
+ );
51
+ } catch {
52
+ // On failure, leave text as-is; no throw to avoid disruptive UX
53
+ }
54
+ }
55
+
56
+ btn.addEventListener('click', (ev) => {
57
+ ev.preventDefault();
58
+ ev.stopPropagation();
59
+ void doCopy();
60
+ });
61
+ btn.addEventListener('keydown', (ev) => {
62
+ // Ensure keyboard activation works even in non-interactive test envs
63
+ if (ev.key === 'Enter' || ev.key === ' ') {
64
+ ev.preventDefault();
65
+ ev.stopPropagation();
66
+ void doCopy();
67
+ }
68
+ });
69
+
70
+ return btn;
71
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Build a canonical issue hash that retains the view.
3
+ * @param {'issues'|'epics'|'board'} view
4
+ * @param {string} id
5
+ */
6
+ export function issueHashFor(view, id) {
7
+ const v = view === 'epics' || view === 'board' ? view : 'issues';
8
+ return `#/${v}?issue=${encodeURIComponent(id)}`;
9
+ }
@@ -1,201 +1,22 @@
1
+ import DOMPurify from 'dompurify';
2
+ import { html } from 'lit-html';
3
+ import { unsafeHTML } from 'lit-html/directives/unsafe-html.js';
4
+ import { marked } from 'marked';
5
+
1
6
  /**
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.
7
+ * Render Markdown safely as HTML using marked and DOMPurify.
8
+ * Returns a lit-html TemplateResult via the unsafeHTML directive so it can be
9
+ * embedded directly in templates.
5
10
  * @function renderMarkdown
6
11
  * @param {string} src Markdown source text
7
- * @returns {DocumentFragment}
12
+ * @returns {import('lit-html').TemplateResult}
8
13
  */
9
14
  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
15
  /** @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
- }
16
+ const markdown = String(src || '');
17
+ /** @type {string} */
18
+ const parsed = /** @type {string} */ (marked.parse(markdown));
19
+ /** @type {string} */
20
+ const html_string = DOMPurify.sanitize(parsed);
21
+ return html`${unsafeHTML(html_string)}`;
201
22
  }
@@ -6,9 +6,7 @@ import { priority_levels } from './priority.js';
6
6
  * @returns {HTMLSpanElement}
7
7
  */
8
8
  export function createPriorityBadge(priority) {
9
- /** @type {number} */
10
9
  const p = typeof priority === 'number' ? priority : 2;
11
- /** @type {HTMLSpanElement} */
12
10
  const el = document.createElement('span');
13
11
  el.className = 'priority-badge';
14
12
  el.classList.add(`is-p${Math.max(0, Math.min(4, p))}`);
@@ -4,7 +4,6 @@
4
4
  * @returns {HTMLSpanElement}
5
5
  */
6
6
  export function createStatusBadge(status) {
7
- /** @type {HTMLSpanElement} */
8
7
  const el = document.createElement('span');
9
8
  el.className = 'status-badge';
10
9
  const s = String(status || 'open');
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Show a transient global toast message anchored to the viewport.
3
+ * @param {string} text - Message text.
4
+ * @param {'info'|'success'|'error'} [variant] - Visual variant.
5
+ * @param {number} [duration_ms] - Auto-dismiss delay in milliseconds.
6
+ */
7
+ export function showToast(text, variant = 'info', duration_ms = 2800) {
8
+ const el = document.createElement('div');
9
+ el.className = 'toast';
10
+ el.textContent = text;
11
+ el.style.position = 'fixed';
12
+ el.style.right = '12px';
13
+ el.style.bottom = '12px';
14
+ el.style.zIndex = '1000';
15
+ el.style.color = '#fff';
16
+ el.style.padding = '8px 10px';
17
+ el.style.borderRadius = '4px';
18
+ el.style.fontSize = '12px';
19
+ if (variant === 'success') {
20
+ el.style.background = '#156d36';
21
+ } else if (variant === 'error') {
22
+ el.style.background = '#9f2011';
23
+ } else {
24
+ el.style.background = 'rgba(0,0,0,0.85)';
25
+ }
26
+ (document.body || document.documentElement).appendChild(el);
27
+ setTimeout(() => {
28
+ try {
29
+ el.remove();
30
+ } catch {
31
+ /* ignore */
32
+ }
33
+ }, duration_ms);
34
+ }
@@ -4,13 +4,10 @@
4
4
  * @returns {HTMLSpanElement}
5
5
  */
6
6
  export function createTypeBadge(issue_type) {
7
- /** @type {HTMLSpanElement} */
8
7
  const el = document.createElement('span');
9
8
  el.className = 'type-badge';
10
9
 
11
- /** @type {string} */
12
10
  const t = (issue_type || '').toString().toLowerCase();
13
- /** @type {Set<string>} */
14
11
  const KNOWN = new Set(['bug', 'feature', 'task', 'epic', 'chore']);
15
12
  const kind = KNOWN.has(t) ? t : 'neutral';
16
13
  el.classList.add(`type-badge--${kind}`);