beads-ui 0.3.1 → 0.4.1

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 (56) hide show
  1. package/CHANGES.md +24 -0
  2. package/README.md +15 -6
  3. package/app/main.bundle.js +617 -0
  4. package/app/main.bundle.js.map +7 -0
  5. package/bin/bdui.js +2 -1
  6. package/package.json +27 -14
  7. package/server/app.js +38 -36
  8. package/server/bd.js +2 -2
  9. package/server/cli/commands.js +8 -8
  10. package/server/cli/daemon.js +13 -5
  11. package/server/cli/index.js +17 -31
  12. package/server/cli/usage.js +3 -2
  13. package/server/db.js +6 -6
  14. package/server/index.js +10 -4
  15. package/server/logging.js +23 -0
  16. package/server/watcher.js +7 -5
  17. package/server/ws.js +23 -11
  18. package/app/data/list-selectors.js +0 -103
  19. package/app/data/providers.js +0 -78
  20. package/app/data/sort.js +0 -47
  21. package/app/data/subscription-issue-store.js +0 -161
  22. package/app/data/subscription-issue-stores.js +0 -128
  23. package/app/data/subscriptions-store.js +0 -227
  24. package/app/main.js +0 -706
  25. package/app/protocol.md +0 -66
  26. package/app/router.js +0 -117
  27. package/app/state.js +0 -105
  28. package/app/utils/issue-id-renderer.js +0 -72
  29. package/app/utils/issue-id.js +0 -11
  30. package/app/utils/issue-type.js +0 -29
  31. package/app/utils/issue-url.js +0 -10
  32. package/app/utils/markdown.js +0 -16
  33. package/app/utils/priority-badge.js +0 -48
  34. package/app/utils/priority.js +0 -1
  35. package/app/utils/status-badge.js +0 -33
  36. package/app/utils/status.js +0 -25
  37. package/app/utils/toast.js +0 -35
  38. package/app/utils/type-badge.js +0 -34
  39. package/app/views/board.js +0 -537
  40. package/app/views/detail.js +0 -1252
  41. package/app/views/epics.js +0 -281
  42. package/app/views/issue-dialog.js +0 -164
  43. package/app/views/issue-row.js +0 -191
  44. package/app/views/list.js +0 -468
  45. package/app/views/nav.js +0 -68
  46. package/app/views/new-issue-dialog.js +0 -348
  47. package/app/ws.js +0 -282
  48. package/docs/adr/001-push-only-lists.md +0 -134
  49. package/docs/adr/002-per-subscription-stores-and-full-issue-push.md +0 -200
  50. package/docs/architecture.md +0 -194
  51. package/docs/data-exchange-subscription-plan.md +0 -198
  52. package/docs/db-watching.md +0 -30
  53. package/docs/migration-v2.md +0 -54
  54. package/docs/protocol/issues-push-v2.md +0 -179
  55. package/docs/subscription-issue-store.md +0 -112
  56. package/server/protocol.js +0 -3
package/app/protocol.md DELETED
@@ -1,66 +0,0 @@
1
- # beads-ui WebSocket Protocol (v2.0.0)
2
-
3
- Note (2025-10-26)
4
-
5
- - The server no longer implements legacy read RPCs `list-issues` and
6
- `epic-status`. Clients must use the push-only protocol described in
7
- `docs/protocol/issues-push-v2.md` (subscribe-list with per-subscription events
8
- snapshot/upsert/delete). The shapes below are retained for historical
9
- reference of v1.
10
-
11
- This document defines the JSON messages exchanged between the browser client and
12
- the local server.
13
-
14
- - Transport: single WebSocket connection
15
- - Encoding: JSON text frames
16
- - Correlation: all request/response pairs share the same `id`
17
-
18
- ## Envelope Shapes
19
-
20
- - RequestEnvelope: `{ id: string, type: string, payload?: any }`
21
- - ReplyEnvelope:
22
- `{ id: string, ok: boolean, type: string, payload?: any, error?: { code: string, message: string, details?: any } }`
23
-
24
- Server may send unsolicited events (e.g., subscription
25
- `snapshot`/`upsert`/`delete`) using the ReplyEnvelope shape with `ok: true` and
26
- a generated `id`.
27
-
28
- ## Message Types
29
-
30
- - Removed in v2: `list-issues` (use subscriptions + push stores)
31
- - `update-status` payload:
32
- `{ id: string, status: 'open'|'in_progress'|'closed' }`
33
- - `edit-text` payload:
34
- `{ id: string, field: 'title'|'description'|'acceptance'|'notes'|'design', value: string }`
35
- - `update-priority` payload: `{ id: string, priority: 0|1|2|3|4 }`
36
- - `create-issue` payload:
37
- `{ title: string, type?: 'bug'|'feature'|'task'|'epic'|'chore', priority?: 0|1|2|3|4, description?: string }`
38
- - `list-ready` payload: `{}`
39
- - Removed in v2: `subscribe-updates` and the `issues-changed` event. All list
40
- and detail updates flow via per-subscription push envelopes
41
- (`snapshot`/`upsert`/`delete`).
42
- - `dep-add` payload: `{ a: string, b: string, view_id?: string }` where `a`
43
- depends on `b` (i.e., `a` is blocked by `b`). Reply payload is the updated
44
- issue for `view_id` (or `a` when omitted).
45
- - `dep-remove` payload: `{ a: string, b: string, view_id?: string }` removing
46
- the `a` depends on `b` link. Reply payload is the updated issue for `view_id`
47
- (or `a`).
48
-
49
- ## Mapping to `bd` CLI
50
-
51
- - Removed in v2: `list-issues` → use subscriptions and push
52
- (`docs/protocol/issues-push-v2.md`)
53
- - `update-status` → `bd update <id> --status <status>`
54
- - `edit-text` → `bd update <id> --title <t>` or `--description <d>` or
55
- `--acceptance-criteria <a>` or `--notes <n>` or `--design <z>`
56
- - `update-priority` → `bd update <id> --priority <n>`
57
- - `create-issue` → `bd create "title" -t <type> -p <prio> -d "desc"`
58
- - `list-ready` → `bd ready --json`
59
-
60
- ## Errors
61
-
62
- Errors follow the shape `{ code, message, details? }`. Common codes:
63
-
64
- - `bad_request` – malformed payload or unknown type
65
- - `not_found` – entity not found (e.g., issue id)
66
- - `bd_error` – underlying `bd` command failed
package/app/router.js DELETED
@@ -1,117 +0,0 @@
1
- import { issueHashFor } from './utils/issue-url.js';
2
-
3
- /**
4
- * Hash-based router for tabs (issues/epics/board) and deep-linked issue ids.
5
- */
6
-
7
- /**
8
- * Parse an application hash and extract the selected issue id.
9
- * Supports canonical form "#/(issues|epics|board)?issue=<id>" and legacy
10
- * "#/issue/<id>" which we will rewrite to the canonical form.
11
- *
12
- * @param {string} hash
13
- * @returns {string | null}
14
- */
15
- export function parseHash(hash) {
16
- const h = String(hash || '');
17
- // Extract the fragment sans leading '#'
18
- const frag = h.startsWith('#') ? h.slice(1) : h;
19
- const qIndex = frag.indexOf('?');
20
- const query = qIndex >= 0 ? frag.slice(qIndex + 1) : '';
21
- if (query) {
22
- const params = new URLSearchParams(query);
23
- const id = params.get('issue');
24
- if (id) {
25
- return decodeURIComponent(id);
26
- }
27
- }
28
- // Legacy pattern: #/issue/<id>
29
- const m = /^\/issue\/([^\s?#]+)/.exec(frag);
30
- return m && m[1] ? decodeURIComponent(m[1]) : null;
31
- }
32
-
33
- /**
34
- * Parse the current view from hash.
35
- *
36
- * @param {string} hash
37
- * @returns {'issues'|'epics'|'board'}
38
- */
39
- export function parseView(hash) {
40
- const h = String(hash || '');
41
- if (/^#\/epics(\b|\/|$)/.test(h)) {
42
- return 'epics';
43
- }
44
- if (/^#\/board(\b|\/|$)/.test(h)) {
45
- return 'board';
46
- }
47
- // Default to issues (also covers #/issues and unknown/empty)
48
- return 'issues';
49
- }
50
-
51
- /**
52
- * @param {{ getState: () => any, setState: (patch: any) => void }} store
53
- */
54
- export function createHashRouter(store) {
55
- /** @type {(ev?: HashChangeEvent) => any} */
56
- const onHashChange = () => {
57
- const hash = window.location.hash || '';
58
- // Rewrite legacy #/issue/<id> to canonical #/issues?issue=<id>
59
- const legacyMatch = /^#\/issue\/([^\s?#]+)/.exec(hash);
60
- if (legacyMatch && legacyMatch[1]) {
61
- const id = decodeURIComponent(legacyMatch[1]);
62
- // Update state immediately for consumers expecting sync selection
63
- store.setState({ selected_id: id, view: 'issues' });
64
- const next = `#/issues?issue=${encodeURIComponent(id)}`;
65
- if (window.location.hash !== next) {
66
- window.location.hash = next;
67
- return; // will trigger handler again
68
- }
69
- }
70
- const id = parseHash(hash);
71
- const view = parseView(hash);
72
- store.setState({ selected_id: id, view });
73
- };
74
-
75
- return {
76
- start() {
77
- window.addEventListener('hashchange', onHashChange);
78
- onHashChange();
79
- },
80
- stop() {
81
- window.removeEventListener('hashchange', onHashChange);
82
- },
83
- /**
84
- * @param {string} id
85
- */
86
- gotoIssue(id) {
87
- // Keep current view in hash and append issue param via helper
88
- const s = store.getState ? store.getState() : { view: 'issues' };
89
- const view = s.view || 'issues';
90
- const next = issueHashFor(view, id);
91
- if (window.location.hash !== next) {
92
- window.location.hash = next;
93
- } else {
94
- // Force state update even if hash is the same
95
- store.setState({ selected_id: id, view });
96
- }
97
- },
98
- /**
99
- * Navigate to a top-level view.
100
- *
101
- * @param {'issues'|'epics'|'board'} view
102
- */
103
- /**
104
- * @param {'issues'|'epics'|'board'} view
105
- */
106
- gotoView(view) {
107
- const s = store.getState ? store.getState() : { selected_id: null };
108
- const id = s.selected_id;
109
- const next = id ? issueHashFor(view, id) : `#/${view}`;
110
- if (window.location.hash !== next) {
111
- window.location.hash = next;
112
- } else {
113
- store.setState({ view, selected_id: null });
114
- }
115
- }
116
- };
117
- }
package/app/state.js DELETED
@@ -1,105 +0,0 @@
1
- /**
2
- * Minimal app state store with subscription.
3
- */
4
-
5
- /**
6
- * @typedef {'all'|'open'|'in_progress'|'closed'|'ready'} StatusFilter
7
- */
8
-
9
- /**
10
- * @typedef {{ status: StatusFilter, search: string, type: string }} Filters
11
- */
12
-
13
- /**
14
- * @typedef {'issues'|'epics'|'board'} ViewName
15
- */
16
-
17
- /**
18
- * @typedef {'today'|'3'|'7'} ClosedFilter
19
- */
20
-
21
- /**
22
- * @typedef {{ closed_filter: ClosedFilter }} BoardState
23
- */
24
-
25
- /**
26
- * @typedef {{ selected_id: string | null, view: ViewName, filters: Filters, board: BoardState }} AppState
27
- */
28
-
29
- /**
30
- * Create a simple store for application state.
31
- *
32
- * @param {Partial<AppState>} [initial]
33
- * @returns {{ getState: () => AppState, setState: (patch: { selected_id?: string | null, filters?: Partial<Filters> }) => void, subscribe: (fn: (s: AppState) => void) => () => void }}
34
- */
35
- export function createStore(initial = {}) {
36
- /** @type {AppState} */
37
- let state = {
38
- selected_id: initial.selected_id ?? null,
39
- view: initial.view ?? 'issues',
40
- filters: {
41
- status: initial.filters?.status ?? 'all',
42
- search: initial.filters?.search ?? '',
43
- type:
44
- typeof initial.filters?.type === 'string' ? initial.filters?.type : ''
45
- },
46
- board: {
47
- closed_filter:
48
- initial.board?.closed_filter === '3' ||
49
- initial.board?.closed_filter === '7' ||
50
- initial.board?.closed_filter === 'today'
51
- ? initial.board?.closed_filter
52
- : 'today'
53
- }
54
- };
55
-
56
- /** @type {Set<(s: AppState) => void>} */
57
- const subs = new Set();
58
-
59
- function emit() {
60
- for (const fn of Array.from(subs)) {
61
- try {
62
- fn(state);
63
- } catch {
64
- // ignore
65
- }
66
- }
67
- }
68
-
69
- return {
70
- getState() {
71
- return state;
72
- },
73
- /**
74
- * Update state. Nested filters can be partial.
75
- *
76
- * @param {{ selected_id?: string | null, filters?: Partial<Filters>, board?: Partial<BoardState> }} patch
77
- */
78
- setState(patch) {
79
- /** @type {AppState} */
80
- const next = {
81
- ...state,
82
- ...patch,
83
- filters: { ...state.filters, ...(patch.filters || {}) },
84
- board: { ...state.board, ...(patch.board || {}) }
85
- };
86
- // Avoid emitting if nothing changed (shallow compare)
87
- if (
88
- next.selected_id === state.selected_id &&
89
- next.view === state.view &&
90
- next.filters.status === state.filters.status &&
91
- next.filters.search === state.filters.search &&
92
- next.filters.type === state.filters.type &&
93
- next.board.closed_filter === state.board.closed_filter
94
- ) {
95
- return;
96
- }
97
- state = next;
98
- emit();
99
- },
100
- subscribe(fn) {
101
- subs.add(fn);
102
- return () => subs.delete(fn);
103
- }
104
- };
105
- }
@@ -1,72 +0,0 @@
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
- *
9
- * @param {string} id - Full issue id including the prefix (e.g., "UI-123").
10
- * @param {{ class_name?: string, duration_ms?: number }} [opts]
11
- * @returns {HTMLButtonElement}
12
- */
13
- export function createIssueIdRenderer(id, opts) {
14
- /** @type {number} */
15
- const duration =
16
- typeof opts?.duration_ms === 'number' ? opts.duration_ms : 1200;
17
- /** @type {HTMLButtonElement} */
18
- const btn = document.createElement('button');
19
- // Visual: match inline ID look; keep it neutral and text-like
20
- btn.className =
21
- (opts?.class_name ? opts.class_name + ' ' : '') + 'mono id-copy';
22
- btn.type = 'button';
23
- btn.setAttribute('aria-live', 'polite');
24
- btn.setAttribute('title', 'Copy issue ID');
25
- btn.setAttribute('aria-label', `Copy issue ID ${id}`);
26
- const label = issueDisplayId(id);
27
- btn.textContent = label;
28
-
29
- /** Copy handler with feedback. */
30
- async function doCopy() {
31
- // Prevent accidental row navigation and parent handlers
32
- // (click/key handlers call this inside an event context)
33
- try {
34
- if (
35
- navigator.clipboard &&
36
- typeof navigator.clipboard.writeText === 'function'
37
- ) {
38
- await navigator.clipboard.writeText(String(id));
39
- }
40
- const prev = btn.textContent || label;
41
- btn.textContent = 'Copied';
42
- // Keep accessible label consistent with feedback
43
- const oldAria = btn.getAttribute('aria-label') || '';
44
- btn.setAttribute('aria-label', 'Copied');
45
- setTimeout(
46
- () => {
47
- btn.textContent = prev;
48
- btn.setAttribute('aria-label', oldAria);
49
- },
50
- Math.max(80, duration)
51
- );
52
- } catch {
53
- // On failure, leave text as-is; no throw to avoid disruptive UX
54
- }
55
- }
56
-
57
- btn.addEventListener('click', (ev) => {
58
- ev.preventDefault();
59
- ev.stopPropagation();
60
- void doCopy();
61
- });
62
- btn.addEventListener('keydown', (ev) => {
63
- // Ensure keyboard activation works even in non-interactive test envs
64
- if (ev.key === 'Enter' || ev.key === ' ') {
65
- ev.preventDefault();
66
- ev.stopPropagation();
67
- void doCopy();
68
- }
69
- });
70
-
71
- return btn;
72
- }
@@ -1,11 +0,0 @@
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
- *
5
- * @param {string | null | undefined} id
6
- * @returns {string}
7
- */
8
- export function issueDisplayId(id) {
9
- const m = String(id || '').match(/(\d+)$/);
10
- return m ? `#${m[1]}` : '#';
11
- }
@@ -1,29 +0,0 @@
1
- /**
2
- * Known issue types in canonical order for dropdowns.
3
- *
4
- * @type {Array<'bug'|'feature'|'task'|'epic'|'chore'>}
5
- */
6
- export const ISSUE_TYPES = ['bug', 'feature', 'task', 'epic', 'chore'];
7
-
8
- /**
9
- * Return a human-friendly label for an issue type.
10
- *
11
- * @param {string | null | undefined} type
12
- * @returns {string}
13
- */
14
- export function typeLabel(type) {
15
- switch ((type || '').toString().toLowerCase()) {
16
- case 'bug':
17
- return 'Bug';
18
- case 'feature':
19
- return 'Feature';
20
- case 'task':
21
- return 'Task';
22
- case 'epic':
23
- return 'Epic';
24
- case 'chore':
25
- return 'Chore';
26
- default:
27
- return '';
28
- }
29
- }
@@ -1,10 +0,0 @@
1
- /**
2
- * Build a canonical issue hash that retains the view.
3
- *
4
- * @param {'issues'|'epics'|'board'} view
5
- * @param {string} id
6
- */
7
- export function issueHashFor(view, id) {
8
- const v = view === 'epics' || view === 'board' ? view : 'issues';
9
- return `#/${v}?issue=${encodeURIComponent(id)}`;
10
- }
@@ -1,16 +0,0 @@
1
- import DOMPurify from 'dompurify';
2
- import { unsafeHTML } from 'lit-html/directives/unsafe-html.js';
3
- import { marked } from 'marked';
4
-
5
- /**
6
- * Render Markdown safely as HTML using marked and DOMPurify.
7
- * Returns a lit-html TemplateResult via the unsafeHTML directive so it can be
8
- * embedded directly in templates.
9
- *
10
- * @param {string} markdown - Markdown source text
11
- */
12
- export function renderMarkdown(markdown) {
13
- const parsed = /** @type {string} */ (marked.parse(markdown));
14
- const html_string = DOMPurify.sanitize(parsed);
15
- return unsafeHTML(html_string);
16
- }
@@ -1,48 +0,0 @@
1
- import { priority_levels } from './priority.js';
2
-
3
- /**
4
- * Create a colored badge for a priority value (0..4).
5
- *
6
- * @param {number | null | undefined} priority
7
- * @returns {HTMLSpanElement}
8
- */
9
- export function createPriorityBadge(priority) {
10
- const p = typeof priority === 'number' ? priority : 2;
11
- const el = document.createElement('span');
12
- el.className = 'priority-badge';
13
- el.classList.add(`is-p${Math.max(0, Math.min(4, p))}`);
14
- el.setAttribute('role', 'img');
15
- const label = labelForPriority(p);
16
- el.setAttribute('title', label);
17
- el.setAttribute('aria-label', `Priority: ${label}`);
18
- el.textContent = emojiForPriority(p) + ' ' + label;
19
- return el;
20
- }
21
-
22
- /**
23
- * @param {number} p
24
- */
25
- function labelForPriority(p) {
26
- const i = Math.max(0, Math.min(4, p));
27
- return priority_levels[i] || 'Medium';
28
- }
29
-
30
- /**
31
- * @param {number} p
32
- */
33
- export function emojiForPriority(p) {
34
- switch (p) {
35
- case 0:
36
- return '🔥';
37
- case 1:
38
- return '⚡️';
39
- case 2:
40
- return '🔧';
41
- case 3:
42
- return '🪶';
43
- case 4:
44
- return '💤';
45
- default:
46
- return '🔧';
47
- }
48
- }
@@ -1 +0,0 @@
1
- export const priority_levels = ['Critical', 'High', 'Medium', 'Low', 'Backlog'];
@@ -1,33 +0,0 @@
1
- /**
2
- * Create a colored badge for a status value.
3
- *
4
- * @param {string | null | undefined} status - 'open' | 'in_progress' | 'closed'
5
- * @returns {HTMLSpanElement}
6
- */
7
- export function createStatusBadge(status) {
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
- }
@@ -1,25 +0,0 @@
1
- /**
2
- * Known status values in canonical order.
3
- *
4
- * @type {Array<'open'|'in_progress'|'closed'>}
5
- */
6
- export const STATUSES = ['open', 'in_progress', 'closed'];
7
-
8
- /**
9
- * Map canonical status to display label.
10
- *
11
- * @param {string | null | undefined} status
12
- * @returns {string}
13
- */
14
- export function statusLabel(status) {
15
- switch ((status || '').toString()) {
16
- case 'open':
17
- return 'Open';
18
- case 'in_progress':
19
- return 'In progress';
20
- case 'closed':
21
- return 'Closed';
22
- default:
23
- return (status || '').toString() || 'Open';
24
- }
25
- }
@@ -1,35 +0,0 @@
1
- /**
2
- * Show a transient global toast message anchored to the viewport.
3
- *
4
- * @param {string} text - Message text.
5
- * @param {'info'|'success'|'error'} [variant] - Visual variant.
6
- * @param {number} [duration_ms] - Auto-dismiss delay in milliseconds.
7
- */
8
- export function showToast(text, variant = 'info', duration_ms = 2800) {
9
- const el = document.createElement('div');
10
- el.className = 'toast';
11
- el.textContent = text;
12
- el.style.position = 'fixed';
13
- el.style.right = '12px';
14
- el.style.bottom = '12px';
15
- el.style.zIndex = '1000';
16
- el.style.color = '#fff';
17
- el.style.padding = '8px 10px';
18
- el.style.borderRadius = '4px';
19
- el.style.fontSize = '12px';
20
- if (variant === 'success') {
21
- el.style.background = '#156d36';
22
- } else if (variant === 'error') {
23
- el.style.background = '#9f2011';
24
- } else {
25
- el.style.background = 'rgba(0,0,0,0.85)';
26
- }
27
- (document.body || document.documentElement).appendChild(el);
28
- setTimeout(() => {
29
- try {
30
- el.remove();
31
- } catch {
32
- /* ignore */
33
- }
34
- }, duration_ms);
35
- }
@@ -1,34 +0,0 @@
1
- /**
2
- * Create a compact, colored badge for an issue type.
3
- *
4
- * @param {string | undefined | null} issue_type - One of: bug, feature, task, epic, chore
5
- * @returns {HTMLSpanElement}
6
- */
7
- export function createTypeBadge(issue_type) {
8
- const el = document.createElement('span');
9
- el.className = 'type-badge';
10
-
11
- const t = (issue_type || '').toString().toLowerCase();
12
- const KNOWN = new Set(['bug', 'feature', 'task', 'epic', 'chore']);
13
- const kind = KNOWN.has(t) ? t : 'neutral';
14
- el.classList.add(`type-badge--${kind}`);
15
- el.setAttribute('role', 'img');
16
- const label = KNOWN.has(t)
17
- ? t === 'bug'
18
- ? 'Bug'
19
- : t === 'feature'
20
- ? 'Feature'
21
- : t === 'task'
22
- ? 'Task'
23
- : t === 'epic'
24
- ? 'Epic'
25
- : 'Chore'
26
- : '—';
27
- el.setAttribute(
28
- 'aria-label',
29
- KNOWN.has(t) ? `Issue type: ${label}` : 'Issue type: unknown'
30
- );
31
- el.setAttribute('title', KNOWN.has(t) ? `Type: ${label}` : 'Type: unknown');
32
- el.textContent = label;
33
- return el;
34
- }