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,40 +1,76 @@
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 { createPriorityBadge } from '../utils/priority-badge.js';
4
4
  import { createTypeBadge } from '../utils/type-badge.js';
5
5
 
6
6
  /**
7
- * @typedef {{ id: string, title?: string, status?: 'open'|'in_progress'|'closed', priority?: number, issue_type?: string, updated_at?: string }} IssueLite
7
+ * @typedef {{
8
+ * id: string,
9
+ * title?: string,
10
+ * status?: 'open'|'in_progress'|'closed',
11
+ * priority?: number,
12
+ * issue_type?: string,
13
+ * updated_at?: string,
14
+ * closed_at?: string
15
+ * }} IssueLite
8
16
  */
9
17
 
10
18
  /**
11
- * Create the Board view with four columns: Open, Ready, In progress, Closed.
19
+ * Create the Board view with Open + Blocked stacked, then Ready, In progress, Closed.
12
20
  * Data providers are expected to return raw arrays; this view applies sorting.
13
21
  *
14
22
  * Sorting rules:
15
- * - Open: updated_at desc
23
+ * - Open: priority asc, then updated_at desc when present
16
24
  * - Ready: priority asc, then updated_at desc when present
17
25
  * - In progress: updated_at desc
18
- * - Closed: updated_at desc
26
+ * - Closed: closed_at desc (fallback to updated_at)
19
27
  * @param {HTMLElement} mount_element
20
- * @param {{ getOpen: () => Promise<any[]>, getReady: () => Promise<any[]>, getInProgress: () => Promise<any[]>, getClosed: (limit?: number) => Promise<any[]> }} data
21
- * @param {(id: string) => void} goto_issue - Navigate to issue detail.
28
+ * @param {{ getOpen: () => Promise<any[]>, getReady: () => Promise<any[]>, getBlocked?: () => Promise<any[]>, getInProgress: () => Promise<any[]>, getClosed: (limit?: number) => Promise<any[]> }} data
29
+ * @param {(id: string) => void} gotoIssue - Navigate to issue detail.
30
+ * @param {{ getState: () => any, setState: (patch: any) => void, subscribe?: (fn: (s:any)=>void)=>()=>void }} [store]
22
31
  * @returns {{ load: () => Promise<void>, clear: () => void }}
23
32
  */
24
- export function createBoardView(mount_element, data, goto_issue) {
33
+ export function createBoardView(mount_element, data, gotoIssue, store) {
25
34
  /** @type {IssueLite[]} */
26
35
  let list_open = [];
27
36
  /** @type {IssueLite[]} */
28
37
  let list_ready = [];
29
38
  /** @type {IssueLite[]} */
39
+ let list_blocked = [];
40
+ /** @type {IssueLite[]} */
30
41
  let list_in_progress = [];
31
42
  /** @type {IssueLite[]} */
32
43
  let list_closed = [];
44
+ /** @type {IssueLite[]} */
45
+ let list_closed_raw = [];
46
+
47
+ /**
48
+ * Closed column filter mode.
49
+ * 'today' → items with closed_at since local day start
50
+ * '3' → last 3 days; '7' → last 7 days
51
+ * @type {'today'|'3'|'7'}
52
+ */
53
+ let closed_filter_mode = 'today';
54
+ if (store) {
55
+ try {
56
+ const s = store.getState();
57
+ const cf =
58
+ s && s.board ? String(s.board.closed_filter || 'today') : 'today';
59
+ if (cf === 'today' || cf === '3' || cf === '7') {
60
+ closed_filter_mode = /** @type {any} */ (cf);
61
+ }
62
+ } catch {
63
+ // ignore store init errors
64
+ }
65
+ }
33
66
 
34
67
  function template() {
35
68
  return html`
36
69
  <div class="panel__body board-root">
37
- ${columnTemplate('Open', 'open-col', list_open)}
70
+ <div class="board-stack-2">
71
+ ${columnTemplate('Open', 'open-col', list_open)}
72
+ ${columnTemplate('Blocked', 'blocked-col', list_blocked)}
73
+ </div>
38
74
  ${columnTemplate('Ready', 'ready-col', list_ready)}
39
75
  ${columnTemplate('In Progress', 'in-progress-col', list_in_progress)}
40
76
  ${columnTemplate('Closed', 'closed-col', list_closed)}
@@ -50,10 +86,42 @@ export function createBoardView(mount_element, data, goto_issue) {
50
86
  function columnTemplate(title, id, items) {
51
87
  return html`
52
88
  <section class="board-column" id=${id}>
53
- <header class="board-column__header" role="heading" aria-level="2">
54
- ${title}
89
+ <header
90
+ class="board-column__header"
91
+ id=${id + '-header'}
92
+ role="heading"
93
+ aria-level="2"
94
+ >
95
+ <span>${title}</span>
96
+ ${id === 'closed-col'
97
+ ? html`<label class="board-closed-filter">
98
+ <span class="visually-hidden">Filter closed issues</span>
99
+ <select
100
+ id="closed-filter"
101
+ aria-label="Filter closed issues"
102
+ @change=${onClosedFilterChange}
103
+ >
104
+ <option
105
+ value="today"
106
+ ?selected=${closed_filter_mode === 'today'}
107
+ >
108
+ Today
109
+ </option>
110
+ <option value="3" ?selected=${closed_filter_mode === '3'}>
111
+ Last 3 days
112
+ </option>
113
+ <option value="7" ?selected=${closed_filter_mode === '7'}>
114
+ Last 7 days
115
+ </option>
116
+ </select>
117
+ </label>`
118
+ : ''}
55
119
  </header>
56
- <div class="board-column__body">
120
+ <div
121
+ class="board-column__body"
122
+ role="list"
123
+ aria-labelledby=${id + '-header'}
124
+ >
57
125
  ${items.map((it) => cardTemplate(it))}
58
126
  </div>
59
127
  </section>
@@ -68,7 +136,9 @@ export function createBoardView(mount_element, data, goto_issue) {
68
136
  <article
69
137
  class="board-card"
70
138
  data-issue-id=${it.id}
71
- @click=${() => goto_issue(it.id)}
139
+ role="listitem"
140
+ tabindex="-1"
141
+ @click=${() => gotoIssue(it.id)}
72
142
  >
73
143
  <div class="board-card__title text-truncate">
74
144
  ${it.title || '(no title)'}
@@ -76,7 +146,7 @@ export function createBoardView(mount_element, data, goto_issue) {
76
146
  <div class="board-card__meta">
77
147
  ${createTypeBadge(/** @type {any} */ (it).issue_type)}
78
148
  ${createPriorityBadge(/** @type {any} */ (it).priority)}
79
- <span class="mono">${issueDisplayId(it.id)}</span>
149
+ ${createIssueIdRenderer(it.id, { class_name: 'mono' })}
80
150
  </div>
81
151
  </article>
82
152
  `;
@@ -84,6 +154,174 @@ export function createBoardView(mount_element, data, goto_issue) {
84
154
 
85
155
  function doRender() {
86
156
  render(template(), mount_element);
157
+ postRenderEnhance();
158
+ }
159
+
160
+ /**
161
+ * Enhance rendered board with a11y and keyboard navigation.
162
+ * - Roving tabindex per column (first card tabbable)
163
+ * - ArrowUp/ArrowDown within column
164
+ * - ArrowLeft/ArrowRight to adjacent non-empty column (focus top card)
165
+ * - Enter/Space to open details for focused card
166
+ */
167
+ function postRenderEnhance() {
168
+ try {
169
+ /** @type {HTMLElement[]} */
170
+ const columns = Array.from(
171
+ mount_element.querySelectorAll('.board-column')
172
+ );
173
+ for (const col of columns) {
174
+ /** @type {HTMLElement|null} */
175
+ const body = /** @type {any} */ (
176
+ col.querySelector('.board-column__body')
177
+ );
178
+ if (!body) {
179
+ continue;
180
+ }
181
+ /** @type {HTMLElement[]} */
182
+ const cards = Array.from(body.querySelectorAll('.board-card'));
183
+ // Assign aria-label using column header for screen readers
184
+ const header = /** @type {HTMLElement|null} */ (
185
+ col.querySelector('.board-column__header')
186
+ );
187
+ const col_name = header ? header.textContent?.trim() || '' : '';
188
+ for (const card of cards) {
189
+ const title_el = /** @type {HTMLElement|null} */ (
190
+ card.querySelector('.board-card__title')
191
+ );
192
+ const t = title_el ? title_el.textContent?.trim() || '' : '';
193
+ card.setAttribute(
194
+ 'aria-label',
195
+ `Issue ${t || '(no title)'} — Column ${col_name}`
196
+ );
197
+ // Default roving setup
198
+ card.tabIndex = -1;
199
+ }
200
+ if (cards.length > 0) {
201
+ cards[0].tabIndex = 0;
202
+ }
203
+ }
204
+ } catch {
205
+ // non-fatal
206
+ }
207
+ }
208
+
209
+ // Delegate keyboard handling from mount_element
210
+ mount_element.addEventListener('keydown', (ev) => {
211
+ /** @type {HTMLElement} */
212
+ const target = /** @type {any} */ (ev.target);
213
+ if (!target || !(target instanceof HTMLElement)) {
214
+ return;
215
+ }
216
+ // Do not intercept keys inside editable controls
217
+ const tag = String(target.tagName || '').toLowerCase();
218
+ if (
219
+ tag === 'input' ||
220
+ tag === 'textarea' ||
221
+ tag === 'select' ||
222
+ /** @type {any} */ (target).isContentEditable === true
223
+ ) {
224
+ return;
225
+ }
226
+ const card = target.closest('.board-card');
227
+ if (!card) {
228
+ return;
229
+ }
230
+ const key = String(ev.key || '');
231
+ if (key === 'Enter' || key === ' ') {
232
+ ev.preventDefault();
233
+ const id = /** @type {HTMLElement} */ (card).getAttribute(
234
+ 'data-issue-id'
235
+ );
236
+ if (id) {
237
+ gotoIssue(id);
238
+ }
239
+ return;
240
+ }
241
+ if (
242
+ key !== 'ArrowUp' &&
243
+ key !== 'ArrowDown' &&
244
+ key !== 'ArrowLeft' &&
245
+ key !== 'ArrowRight'
246
+ ) {
247
+ return;
248
+ }
249
+ ev.preventDefault();
250
+ // Column context
251
+ const col = /** @type {HTMLElement|null} */ (card.closest('.board-column'));
252
+ if (!col) {
253
+ return;
254
+ }
255
+ const body = /** @type {HTMLElement|null} */ (
256
+ col.querySelector('.board-column__body')
257
+ );
258
+ if (!body) {
259
+ return;
260
+ }
261
+ /** @type {HTMLElement[]} */
262
+ const cards = Array.from(body.querySelectorAll('.board-card'));
263
+ const idx = cards.indexOf(/** @type {HTMLElement} */ (card));
264
+ if (idx === -1) {
265
+ return;
266
+ }
267
+ if (key === 'ArrowDown' && idx < cards.length - 1) {
268
+ moveFocus(cards[idx], cards[idx + 1]);
269
+ return;
270
+ }
271
+ if (key === 'ArrowUp' && idx > 0) {
272
+ moveFocus(cards[idx], cards[idx - 1]);
273
+ return;
274
+ }
275
+ if (key === 'ArrowRight' || key === 'ArrowLeft') {
276
+ // Find adjacent column with at least one card
277
+ /** @type {HTMLElement[]} */
278
+ const cols = Array.from(mount_element.querySelectorAll('.board-column'));
279
+ const col_idx = cols.indexOf(col);
280
+ if (col_idx === -1) {
281
+ return;
282
+ }
283
+ const dir = key === 'ArrowRight' ? 1 : -1;
284
+ let next_idx = col_idx + dir;
285
+ /** @type {HTMLElement|null} */
286
+ let target_col = null;
287
+ while (next_idx >= 0 && next_idx < cols.length) {
288
+ const candidate = cols[next_idx];
289
+ const c_body = /** @type {HTMLElement|null} */ (
290
+ candidate.querySelector('.board-column__body')
291
+ );
292
+ const c_cards = c_body
293
+ ? Array.from(c_body.querySelectorAll('.board-card'))
294
+ : [];
295
+ if (c_cards.length > 0) {
296
+ target_col = candidate;
297
+ break;
298
+ }
299
+ next_idx += dir;
300
+ }
301
+ if (target_col) {
302
+ const first = /** @type {HTMLElement|null} */ (
303
+ target_col.querySelector('.board-column__body .board-card')
304
+ );
305
+ if (first) {
306
+ moveFocus(/** @type {HTMLElement} */ (card), first);
307
+ }
308
+ }
309
+ return;
310
+ }
311
+ });
312
+
313
+ /**
314
+ * @param {HTMLElement} from
315
+ * @param {HTMLElement} to
316
+ */
317
+ function moveFocus(from, to) {
318
+ try {
319
+ from.tabIndex = -1;
320
+ to.tabIndex = 0;
321
+ to.focus();
322
+ } catch {
323
+ // ignore focus errors
324
+ }
87
325
  }
88
326
 
89
327
  /**
@@ -116,6 +354,77 @@ export function createBoardView(mount_element, data, goto_issue) {
116
354
  });
117
355
  }
118
356
 
357
+ /**
358
+ * Sort by closed_at desc with updated_at fallback.
359
+ * @param {IssueLite[]} arr
360
+ */
361
+ function sortByClosedDesc(arr) {
362
+ arr.sort((a, b) => {
363
+ const ca = a.closed_at || a.updated_at || '';
364
+ const cb = b.closed_at || b.updated_at || '';
365
+ return ca < cb ? 1 : ca > cb ? -1 : 0;
366
+ });
367
+ }
368
+
369
+ /**
370
+ * Recompute closed list from raw using the current filter and sort.
371
+ */
372
+ function applyClosedFilter() {
373
+ /** @type {IssueLite[]} */
374
+ let items = Array.isArray(list_closed_raw) ? [...list_closed_raw] : [];
375
+ const now = new Date();
376
+ /** @type {number} */
377
+ let since_ts = 0;
378
+ if (closed_filter_mode === 'today') {
379
+ const start = new Date(
380
+ now.getFullYear(),
381
+ now.getMonth(),
382
+ now.getDate(),
383
+ 0,
384
+ 0,
385
+ 0,
386
+ 0
387
+ );
388
+ since_ts = start.getTime();
389
+ } else if (closed_filter_mode === '3') {
390
+ since_ts = now.getTime() - 3 * 24 * 60 * 60 * 1000;
391
+ } else if (closed_filter_mode === '7') {
392
+ since_ts = now.getTime() - 7 * 24 * 60 * 60 * 1000;
393
+ }
394
+ items = items.filter((it) => {
395
+ const s = it.closed_at || '';
396
+ if (!s || isNaN(Date.parse(s))) {
397
+ return false;
398
+ }
399
+ const t = Date.parse(s);
400
+ return t >= since_ts;
401
+ });
402
+ sortByClosedDesc(items);
403
+ list_closed = items;
404
+ }
405
+
406
+ /**
407
+ * @param {Event} ev
408
+ */
409
+ function onClosedFilterChange(ev) {
410
+ try {
411
+ const el = /** @type {HTMLSelectElement} */ (ev.target);
412
+ const v = String(el.value || 'today');
413
+ closed_filter_mode = v === '3' || v === '7' ? v : 'today';
414
+ if (store) {
415
+ try {
416
+ store.setState({ board: { closed_filter: closed_filter_mode } });
417
+ } catch {
418
+ // ignore store errors
419
+ }
420
+ }
421
+ applyClosedFilter();
422
+ doRender();
423
+ } catch {
424
+ // ignore
425
+ }
426
+ }
427
+
119
428
  return {
120
429
  async load() {
121
430
  /** @type {IssueLite[]} */
@@ -123,6 +432,8 @@ export function createBoardView(mount_element, data, goto_issue) {
123
432
  /** @type {IssueLite[]} */
124
433
  let r = [];
125
434
  /** @type {IssueLite[]} */
435
+ let b = [];
436
+ /** @type {IssueLite[]} */
126
437
  let p = [];
127
438
  /** @type {IssueLite[]} */
128
439
  let c = [];
@@ -136,6 +447,13 @@ export function createBoardView(mount_element, data, goto_issue) {
136
447
  } catch {
137
448
  r = [];
138
449
  }
450
+ try {
451
+ // getBlocked is optional for backward compatibility in tests
452
+ const fn = /** @type {any} */ (data).getBlocked;
453
+ b = typeof fn === 'function' ? /** @type {any} */ (await fn()) : [];
454
+ } catch {
455
+ b = [];
456
+ }
139
457
  try {
140
458
  p = /** @type {any} */ (await data.getInProgress());
141
459
  } catch {
@@ -154,6 +472,13 @@ export function createBoardView(mount_element, data, goto_issue) {
154
472
  o = o.filter((it) => !ready_ids.has(it.id));
155
473
  }
156
474
 
475
+ // Remove items from Open that are blocked (UI-121)
476
+ if (o.length > 0 && b.length > 0) {
477
+ /** @type {Set<string>} */
478
+ const blocked_ids = new Set(b.map((it) => it.id));
479
+ o = o.filter((it) => !blocked_ids.has(it.id));
480
+ }
481
+
157
482
  // Remove items from Ready that are already In Progress by id
158
483
  if (r.length > 0 && p.length > 0) {
159
484
  /** @type {Set<string>} */
@@ -161,21 +486,26 @@ export function createBoardView(mount_element, data, goto_issue) {
161
486
  r = r.filter((it) => !in_progress_ids.has(it.id));
162
487
  }
163
488
 
164
- sortByUpdatedDesc(o);
489
+ // UI-119: Open sorted like Ready (priority asc, then updated desc)
490
+ sortReady(o);
165
491
  sortReady(r);
492
+ sortReady(b);
166
493
  sortByUpdatedDesc(p);
167
- sortByUpdatedDesc(c);
494
+ // Closed handled separately to use closed_at and filtering
168
495
 
169
496
  list_open = o;
170
497
  list_ready = r;
498
+ list_blocked = b;
171
499
  list_in_progress = p;
172
- list_closed = c;
500
+ list_closed_raw = c;
501
+ applyClosedFilter();
173
502
  doRender();
174
503
  },
175
504
  clear() {
176
505
  mount_element.replaceChildren();
177
506
  list_open = [];
178
507
  list_ready = [];
508
+ list_blocked = [];
179
509
  list_in_progress = [];
180
510
  list_closed = [];
181
511
  }