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,5 @@
1
+ node_modules
2
+ coverage
3
+ dist
4
+ .beads
5
+
package/AGENTS.md ADDED
@@ -0,0 +1,85 @@
1
+ # Agents
2
+
3
+ ## Beads (bd) — Work Tracking
4
+
5
+ Use MCP `beads` (bd) as our dependency‑aware issue tracker. Run
6
+ `beads/quickstart` to learn how to use it.
7
+
8
+ ### Issue Types
9
+
10
+ - `bug` - Something broken that needs fixing
11
+ - `feature` - New functionality
12
+ - `task` - Work item (tests, docs, refactoring)
13
+ - `epic` - Large feature composed of multiple issues
14
+ - `chore` - Maintenance work (dependencies, tooling)
15
+
16
+ ### Priorities
17
+
18
+ - `0` - Critical (security, data loss, broken builds)
19
+ - `1` - High (major features, important bugs)
20
+ - `2` - Medium (nice-to-have features, minor bugs)
21
+ - `3` - Low (polish, optimization)
22
+ - `4` - Backlog (future ideas)
23
+
24
+ ### Dependency Types
25
+
26
+ - `blocks` - Hard dependency (issue X blocks issue Y)
27
+ - `related` - Soft relationship (issues are connected)
28
+ - `parent-child` - Epic/subtask relationship
29
+ - `discovered-from` - Track issues discovered during work
30
+
31
+ Only `blocks` dependencies affect the ready work queue.
32
+
33
+ ### Structured Fields and Labels
34
+
35
+ - Use issue `type` and `priority` fields.
36
+ - Use issue type "epic" and `parent-child` dependencies.
37
+ - Use `related` or `discovered-from` dependencies.
38
+ - Area pointers are labels, e.g.: `frontend`, `backend`
39
+
40
+ ### Agent Workflow
41
+
42
+ If no issue is specified, run `bd ready` and claim an unblocked issue.
43
+
44
+ 1. Open issue with `bd show <id>` and read all linked docs.
45
+ 2. Assign to `agent`, update status as you work (`in_progress` → `closed`);
46
+ maintain dependencies, and attach notes/links for traceability.
47
+ 3. Discover new work? Create linked issue with dependency
48
+ `discovered-from:<parent-id>`.
49
+ 4. Land the change; run tests/lint; update any referenced docs.
50
+ 5. Close the issue with `bd close <id>`.
51
+
52
+ ## Coding Standards
53
+
54
+ - Use ECMAScript modules.
55
+ - Classes, interfaces, and factory types use `PascalCase`.
56
+ - Functions and methods use `camelCase`.
57
+ - Variables and parameters use `lower_snake_case`, unless they refer to a
58
+ function or class.
59
+ - Constants are `UPPER_SNAKE_CASE`.
60
+ - File and directory names are `kebab-case`.
61
+ - Use `.js` files with JSDoc type annotations (TypeScript mode).
62
+ - Use `.ts` files only for interface definitions.
63
+ - Type only imports: `@import { X, Y, Z } from './file.js` in top-of-file JSDoc.
64
+ - Add JSDoc to all functions and methods with `@param` (and `@returns` for non
65
+ trivial return types).
66
+ - Annotate local variables with `@type` blocks if their type is not obvious from
67
+ the initializer.
68
+ - Use blocks for all control flow statements, even single-line bodies.
69
+ - Avoid runtime type checks, undefined/null checks and optional chaining
70
+ operators (`?.`, `??`) unless strictly necessary.
71
+
72
+ ## Unit Testing Standards
73
+
74
+ - Write short, focused test-case functions asserting one behavior each.
75
+ - Do not use "should" in test names; use verbs like "returns", "throws",
76
+ "emits", or "calls"
77
+ - Structure: setup → execution → assertion (separate with blank lines).
78
+ - Never change implementation code to make tests pass.
79
+
80
+ ## Pre‑Handoff Validation
81
+
82
+ - Run type checks: `npm run typecheck`
83
+ - Run tests: `npm test`
84
+ - Run eslint: `npm run lint`
85
+ - Run prettier: `npm run format`
package/CHANGES.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changes
2
+
3
+ ## 0.1.0
4
+
5
+ - 🥇 Initial release
package/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2025 Maximilian Antoni
2
+
3
+ Permission is hereby granted, free of charge, to any person
4
+ obtaining a copy of this software and associated documentation
5
+ files (the "Software"), to deal in the Software without
6
+ restriction, including without limitation the rights to use,
7
+ copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ copies of the Software, and to permit persons to whom the
9
+ Software is furnished to do so, subject to the following
10
+ conditions:
11
+
12
+ The above copyright notice and this permission notice shall be
13
+ included in all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
16
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
17
+ OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
18
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
19
+ HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
20
+ WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
21
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
22
+ OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,75 @@
1
+ # beads-ui
2
+
3
+ Local‑first UI for the `bd` CLI (beads) — a fast, dependency‑aware issue
4
+ tracker.
5
+
6
+ beads-ui complements the upstream beads project by providing a single‑page web
7
+ app served from a local Node.js server. It talks to `bd` over a local WebSocket
8
+ to list issues, show details, and apply edits. All changes happen by executing
9
+ `bd` commands, and live updates flow in as the database changes on disk.
10
+
11
+ Upstream beads (CLI and docs): https://github.com/steveyegge/beads
12
+
13
+ ## Features
14
+
15
+ - Issues list with inline edits, quick filters, and keyboard navigation
16
+ - Epics view grouped by epic (from `bd epic status --json`) with expandable rows
17
+ - Board view with Ready / In progress / Closed columns
18
+ - Deep links for navigation; state persists across reloads
19
+ - Live updates via FS watch + WebSocket; optimistic UI with rollbacks on error
20
+ - Dark theme toggle, saved per user
21
+ - Local CLI helper `bdui` to daemonize the server and open your browser
22
+
23
+ ## Screenshots
24
+
25
+ Issues
26
+
27
+ ![Issues view](media/bdui-issues.png)
28
+
29
+ Epics
30
+
31
+ ![Epics view](media/bdui-epics.png)
32
+
33
+ Board
34
+
35
+ ![Board view](media/bdui-board.png)
36
+
37
+ ## Quickstart
38
+
39
+ Prerequisites:
40
+
41
+ - Node.js >= 22
42
+ - `bd` CLI on your PATH (or set `BD_BIN=/path/to/bd`)
43
+
44
+ Install and start:
45
+
46
+ ```sh
47
+ npm install -g beads-ui
48
+ bdui start
49
+ ```
50
+
51
+ See `bdui --help` for options.
52
+
53
+ Environment variables:
54
+
55
+ - `BDUI_RUNTIME_DIR`: override runtime directory for PID/logs. Defaults to
56
+ `$XDG_RUNTIME_DIR/beads-ui` or the system temp dir.
57
+ - `BDUI_NO_OPEN=1`: disable opening the default browser on `start`.
58
+ - `PORT`: overrides the listen port (default `3000`). The server binds to
59
+ `127.0.0.1`.
60
+
61
+ Platform notes:
62
+
63
+ - macOS/Linux are fully supported. On Windows, the CLI uses `cmd /c start` to
64
+ open URLs and relies on Node’s `process.kill` semantics for stopping the
65
+ daemon.
66
+
67
+ ## Developer Workflow
68
+
69
+ - Type check: `npm run typecheck`
70
+ - Tests: `npm test`
71
+ - Lint: `npm run lint`
72
+ - Format: `npm run format`
73
+
74
+ See `docs/quickstart.md` for details and `docs/architecture.md` for the protocol
75
+ and component overview.
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Data layer: typed wrappers around the ws transport for bd-backed queries.
3
+ * @param {(type: import('../protocol.js').MessageType, payload?: unknown) => Promise<unknown>} transport - Request/response function.
4
+ * @param {(type: import('../protocol.js').MessageType, handler: (payload: unknown) => void) => void} [on_event] - Optional event subscription (used to invalidate caches on push updates).
5
+ * @returns {{ getEpicStatus: () => Promise<unknown[]>, getReady: () => Promise<unknown[]>, getOpen: () => Promise<unknown[]>, getInProgress: () => Promise<unknown[]>, getClosed: (limit?: number) => Promise<unknown[]>, getIssue: (id: string) => Promise<unknown>, updateIssue: (input: { id: string, title?: string, acceptance?: string, status?: 'open'|'in_progress'|'closed', priority?: number, assignee?: string }) => Promise<unknown> }}
6
+ */
7
+ export function createDataLayer(transport, on_event) {
8
+ /** @type {{ list_ready?: unknown, list_open?: unknown, list_in_progress?: unknown, list_closed_10?: unknown, epic_status?: unknown }} */
9
+ const cache = {};
10
+
11
+ // Invalidate caches on server push updates when available
12
+ if (on_event) {
13
+ try {
14
+ on_event('issues-changed', () => {
15
+ cache.list_ready = undefined;
16
+ cache.list_open = undefined;
17
+ cache.list_in_progress = undefined;
18
+ cache.list_closed_10 = undefined;
19
+ cache.epic_status = undefined;
20
+ });
21
+ } catch {
22
+ // noop
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Get epic status groups via `bd epic status --json`.
28
+ * @returns {Promise<unknown[]>}
29
+ */
30
+ async function getEpicStatus() {
31
+ if (Array.isArray(cache.epic_status)) {
32
+ return /** @type {unknown[]} */ (cache.epic_status);
33
+ }
34
+ /** @type {unknown} */
35
+ const res = await transport('epic-status');
36
+ const arr = Array.isArray(res) ? res : [];
37
+ cache.epic_status = arr;
38
+ return arr;
39
+ }
40
+
41
+ /**
42
+ * Ready issues: `bd ready --json`.
43
+ * Sort by priority then updated_at on the UI; transport returns raw list.
44
+ * @returns {Promise<unknown[]>}
45
+ */
46
+ async function getReady() {
47
+ if (Array.isArray(cache.list_ready)) {
48
+ return /** @type {unknown[]} */ (cache.list_ready);
49
+ }
50
+ /** @type {unknown} */
51
+ const res = await transport('list-issues', { filters: { ready: true } });
52
+ const arr = Array.isArray(res) ? res : [];
53
+ cache.list_ready = arr;
54
+ return arr;
55
+ }
56
+
57
+ /**
58
+ * Open issues: `bd list -s open --json`.
59
+ * @returns {Promise<unknown[]>}
60
+ */
61
+ async function getOpen() {
62
+ if (Array.isArray(cache.list_open)) {
63
+ return /** @type {unknown[]} */ (cache.list_open);
64
+ }
65
+ /** @type {unknown} */
66
+ const res = await transport('list-issues', {
67
+ filters: { status: 'open' }
68
+ });
69
+ const arr = Array.isArray(res) ? res : [];
70
+ cache.list_open = arr;
71
+ return arr;
72
+ }
73
+
74
+ /**
75
+ * In progress issues: `bd list -s in_progress --json`.
76
+ * @returns {Promise<unknown[]>}
77
+ */
78
+ async function getInProgress() {
79
+ if (Array.isArray(cache.list_in_progress)) {
80
+ return /** @type {unknown[]} */ (cache.list_in_progress);
81
+ }
82
+ /** @type {unknown} */
83
+ const res = await transport('list-issues', {
84
+ filters: { status: 'in_progress' }
85
+ });
86
+ const arr = Array.isArray(res) ? res : [];
87
+ cache.list_in_progress = arr;
88
+ return arr;
89
+ }
90
+
91
+ /**
92
+ * Closed issues: `bd list -s closed -l 10 --json`.
93
+ * @param {number} [limit] - Optional limit (defaults to 10).
94
+ * @returns {Promise<unknown[]>}
95
+ */
96
+ async function getClosed(limit = 10) {
97
+ if (limit === 10 && Array.isArray(cache.list_closed_10)) {
98
+ return /** @type {unknown[]} */ (cache.list_closed_10);
99
+ }
100
+ /** @type {unknown} */
101
+ const res = await transport('list-issues', {
102
+ filters: { status: 'closed', limit }
103
+ });
104
+ const arr = Array.isArray(res) ? res : [];
105
+ if (limit === 10) {
106
+ cache.list_closed_10 = arr;
107
+ }
108
+ return arr;
109
+ }
110
+
111
+ /**
112
+ * Show a single issue via `bd show <id> --json`.
113
+ * @param {string} id
114
+ * @returns {Promise<unknown>}
115
+ */
116
+ async function getIssue(id) {
117
+ /** @type {unknown} */
118
+ const res = await transport('show-issue', { id });
119
+ return res;
120
+ }
121
+
122
+ /**
123
+ * Update issue fields by dispatching specific mutations.
124
+ * Supported fields: title, acceptance, status, priority, assignee.
125
+ * Returns the updated issue on success.
126
+ * @param {{ id: string, title?: string, acceptance?: string, status?: 'open'|'in_progress'|'closed', priority?: number, assignee?: string }} input
127
+ * @returns {Promise<unknown>}
128
+ */
129
+ async function updateIssue(input) {
130
+ const { id } = input;
131
+ /** @type {unknown} */
132
+ let last = null;
133
+ if (typeof input.title === 'string') {
134
+ last = await transport('edit-text', {
135
+ id,
136
+ field: 'title',
137
+ value: input.title
138
+ });
139
+ }
140
+ if (typeof input.acceptance === 'string') {
141
+ last = await transport('edit-text', {
142
+ id,
143
+ field: 'acceptance',
144
+ value: input.acceptance
145
+ });
146
+ }
147
+ if (typeof input.status === 'string') {
148
+ last = await transport('update-status', {
149
+ id,
150
+ status: input.status
151
+ });
152
+ }
153
+ if (typeof input.priority === 'number') {
154
+ last = await transport('update-priority', {
155
+ id,
156
+ priority: input.priority
157
+ });
158
+ }
159
+ // type updates are not supported via UI
160
+ if (typeof input.assignee === 'string') {
161
+ last = await transport('update-assignee', {
162
+ id,
163
+ assignee: input.assignee
164
+ });
165
+ }
166
+ return last;
167
+ }
168
+
169
+ return {
170
+ getEpicStatus,
171
+ getReady,
172
+ getOpen,
173
+ getInProgress,
174
+ getClosed,
175
+ getIssue,
176
+ updateIssue
177
+ };
178
+ }
@@ -0,0 +1,126 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { createDataLayer } from './providers.js';
3
+
4
+ // Using a minimal fixture shaped like epic-status-example.json
5
+ const epicFixture = [
6
+ {
7
+ epic: {
8
+ id: 'WK-1',
9
+ title: 'Example Epic',
10
+ description: 'Example',
11
+ acceptance_criteria: 'Demo',
12
+ notes: '',
13
+ status: 'open',
14
+ priority: 1,
15
+ issue_type: 'epic',
16
+ created_at: '2025-10-21T00:00:00.000Z',
17
+ updated_at: '2025-10-21T00:00:00.000Z'
18
+ },
19
+ total_children: 2,
20
+ closed_children: 1,
21
+ eligible_for_close: false
22
+ }
23
+ ];
24
+
25
+ /**
26
+ * @returns {{ calls: { type: string, payload: any }[], send: (type: string, payload?: any) => Promise<any> }}
27
+ */
28
+ function makeTransportRecorder() {
29
+ /** @type {{ type: string, payload: any }[]} */
30
+ const calls = [];
31
+ return {
32
+ calls,
33
+ /**
34
+ * @param {string} type
35
+ * @param {any} [payload]
36
+ */
37
+ async send(type, payload) {
38
+ calls.push({ type, payload });
39
+ // default fake payloads
40
+ if (type === 'epic-status') {
41
+ return [];
42
+ }
43
+ if (type === 'list-issues') {
44
+ return [];
45
+ }
46
+ if (type === 'show-issue') {
47
+ return { id: payload?.id || 'X' };
48
+ }
49
+ if (
50
+ type === 'update-status' ||
51
+ type === 'update-priority' ||
52
+ type === 'edit-text' ||
53
+ type === 'update-assignee'
54
+ ) {
55
+ return { id: payload?.id || 'X' };
56
+ }
57
+ return null;
58
+ }
59
+ };
60
+ }
61
+
62
+ describe('data/providers', () => {
63
+ test('getClosed requests list-issues with status and limit=10 by default', async () => {
64
+ const rec = makeTransportRecorder();
65
+ const data = createDataLayer((t, p) => rec.send(t, p));
66
+ await data.getClosed();
67
+ const last = rec.calls[rec.calls.length - 1];
68
+ expect(last.type).toBe('list-issues');
69
+ expect(last.payload.filters.status).toBe('closed');
70
+ expect(last.payload.filters.limit).toBe(10);
71
+ });
72
+
73
+ test('getInProgress requests list-issues with status=in_progress', async () => {
74
+ const rec = makeTransportRecorder();
75
+ const data = createDataLayer((t, p) => rec.send(t, p));
76
+ await data.getInProgress();
77
+ const last = rec.calls[rec.calls.length - 1];
78
+ expect(last.type).toBe('list-issues');
79
+ expect(last.payload.filters.status).toBe('in_progress');
80
+ });
81
+
82
+ test('getReady uses list-issues with ready:true', async () => {
83
+ const rec = makeTransportRecorder();
84
+ const data = createDataLayer((t, p) => rec.send(t, p));
85
+ await data.getReady();
86
+ const last = rec.calls[rec.calls.length - 1];
87
+ expect(last.type).toBe('list-issues');
88
+ expect(last.payload.filters.ready).toBe(true);
89
+ });
90
+
91
+ test('getEpicStatus calls epic-status and returns fixture-shaped data', async () => {
92
+ const rec = makeTransportRecorder();
93
+ const data = createDataLayer(async (t, p) => {
94
+ if (t === 'epic-status') {
95
+ rec.calls.push({ type: t, payload: p });
96
+ return epicFixture;
97
+ }
98
+ return rec.send(t, p);
99
+ });
100
+ const res = await data.getEpicStatus();
101
+ const last = rec.calls[rec.calls.length - 1];
102
+ expect(last.type).toBe('epic-status');
103
+ expect(Array.isArray(res)).toBe(true);
104
+ // basic shape check from fixture
105
+ // @ts-ignore
106
+ expect(res[0].epic?.id).toBeDefined();
107
+ });
108
+
109
+ test('updateIssue dispatches field-specific mutations', async () => {
110
+ const rec = makeTransportRecorder();
111
+ const data = createDataLayer((t, p) => rec.send(t, p));
112
+ await data.updateIssue({
113
+ id: 'UI-1',
114
+ title: 'X',
115
+ acceptance: 'Y',
116
+ status: 'in_progress',
117
+ priority: 2,
118
+ assignee: 'max'
119
+ });
120
+ const types = rec.calls.map((c) => c.type);
121
+ expect(types).toContain('edit-text');
122
+ expect(types).toContain('update-status');
123
+ expect(types).toContain('update-priority');
124
+ expect(types).toContain('update-assignee');
125
+ });
126
+ });
package/app/index.html ADDED
@@ -0,0 +1,29 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Beads</title>
7
+ <link rel="stylesheet" href="./styles.css" />
8
+ </head>
9
+ <body>
10
+ <header class="app-header">
11
+ <div class="header-left">
12
+ <h1 class="app-title">Beads</h1>
13
+ <nav id="top-nav" class="header-nav" aria-label="Primary"></nav>
14
+ </div>
15
+ <div class="header-actions">
16
+ <label class="theme-toggle" title="Toggle dark mode">
17
+ <span>Dark</span>
18
+ <input
19
+ id="theme-switch"
20
+ type="checkbox"
21
+ aria-label="Toggle dark mode"
22
+ />
23
+ </label>
24
+ </div>
25
+ </header>
26
+ <main id="app" class="app-shell" aria-live="polite"></main>
27
+ <script type="module" src="/main.bundle.js"></script>
28
+ </body>
29
+ </html>
@@ -0,0 +1,94 @@
1
+ import { describe, expect, test, vi } from 'vitest';
2
+ import { bootstrap } from './main.js';
3
+
4
+ // Mock the Board view to manipulate DOM content deterministically
5
+ vi.mock('./views/board.js', () => ({
6
+ /**
7
+ * @param {HTMLElement} mount
8
+ */
9
+ createBoardView: (mount) => ({
10
+ async load() {
11
+ // Simulate a rendered board shell
12
+ mount.innerHTML = '<div class="panel__body board-root"></div>';
13
+ },
14
+ clear() {
15
+ // No-op in this test; we no longer depend on clearing when switching views
16
+ }
17
+ })
18
+ }));
19
+
20
+ // Mock WS client to avoid network and provide minimal data
21
+ vi.mock('./ws.js', () => ({
22
+ createWsClient: () => ({
23
+ /**
24
+ * @param {string} type
25
+ */
26
+ async send(type) {
27
+ if (type === 'list-issues') {
28
+ return [];
29
+ }
30
+ if (type === 'show-issue') {
31
+ return null;
32
+ }
33
+ if (type === 'epic-status') {
34
+ return [];
35
+ }
36
+ return null;
37
+ },
38
+ on() {
39
+ return () => {};
40
+ },
41
+ close() {},
42
+ getState() {
43
+ return 'open';
44
+ }
45
+ })
46
+ }));
47
+
48
+ describe('board visibility on view change', () => {
49
+ test('hides board when leaving and shows again when returning', async () => {
50
+ // Start on issues, then go to board so subscribers are active
51
+ window.location.hash = '#/issues';
52
+ document.body.innerHTML = '<main id="app"></main>';
53
+ const root = /** @type {HTMLElement} */ (document.getElementById('app'));
54
+
55
+ bootstrap(root);
56
+
57
+ // Allow initial render to flush
58
+ await Promise.resolve();
59
+ await Promise.resolve();
60
+
61
+ const boardRoot = /** @type {HTMLElement} */ (
62
+ document.getElementById('board-root')
63
+ );
64
+
65
+ // Navigate to board
66
+ window.location.hash = '#/board';
67
+ window.dispatchEvent(new HashChangeEvent('hashchange'));
68
+ await Promise.resolve();
69
+ await Promise.resolve();
70
+
71
+ // Board is visible and rendered with its internal shell
72
+ expect(boardRoot.hidden).toBe(false);
73
+ expect(boardRoot.querySelector('.board-root')).not.toBeNull();
74
+
75
+ // Navigate away to issues
76
+ window.location.hash = '#/issues';
77
+ window.dispatchEvent(new HashChangeEvent('hashchange'));
78
+
79
+ await Promise.resolve();
80
+ await Promise.resolve();
81
+
82
+ // Board route gets hidden but DOM may remain; CSS [hidden] must hide it
83
+ expect(boardRoot.hidden).toBe(true);
84
+ expect(boardRoot.querySelector('.board-root')).not.toBeNull();
85
+
86
+ // Go back to Board, content is still there (or re-rendered by load)
87
+ window.location.hash = '#/board';
88
+ window.dispatchEvent(new HashChangeEvent('hashchange'));
89
+ await Promise.resolve();
90
+ await Promise.resolve();
91
+ expect(boardRoot.hidden).toBe(false);
92
+ expect(boardRoot.querySelector('.board-root')).not.toBeNull();
93
+ });
94
+ });
@@ -0,0 +1,64 @@
1
+ import { describe, expect, test, vi } from 'vitest';
2
+ // Import after mocking
3
+ import { bootstrap } from './main.js';
4
+
5
+ // Mock WS client before importing the app
6
+ const calls = [];
7
+ const issues = [
8
+ { id: 'UI-1', title: 'One', status: 'open', priority: 1 },
9
+ { id: 'UI-2', title: 'Two', status: 'open', priority: 2 }
10
+ ];
11
+ vi.mock('./ws.js', () => ({
12
+ createWsClient: () => ({
13
+ /**
14
+ * @param {string} type
15
+ * @param {any} payload
16
+ */
17
+ async send(type, payload) {
18
+ calls.push({ type, payload });
19
+ if (type === 'list-issues') {
20
+ return issues;
21
+ }
22
+ if (type === 'show-issue') {
23
+ const id = /** @type {any} */ (payload).id;
24
+ const it = issues.find((i) => i.id === id);
25
+ return it || null;
26
+ }
27
+ return null;
28
+ },
29
+ on() {
30
+ return () => {};
31
+ },
32
+ close() {},
33
+ getState() {
34
+ return 'open';
35
+ }
36
+ })
37
+ }));
38
+
39
+ describe('deep link on initial load (UI-44)', () => {
40
+ test('loads detail and highlights list item when hash includes issue id', async () => {
41
+ window.location.hash = '#/issue/UI-2';
42
+ document.body.innerHTML = '<main id="app"></main>';
43
+ const root = /** @type {HTMLElement} */ (document.getElementById('app'));
44
+
45
+ bootstrap(root);
46
+
47
+ // Allow async loads to complete
48
+ await Promise.resolve();
49
+ await Promise.resolve();
50
+
51
+ const detailId = /** @type {HTMLElement} */ (
52
+ document.querySelector('#detail-panel .detail-title .detail-id')
53
+ );
54
+ expect(detailId && detailId.textContent).toBe('#2');
55
+
56
+ const list = /** @type {HTMLElement} */ (
57
+ document.getElementById('list-root')
58
+ );
59
+ const selected = /** @type {HTMLElement|null} */ (
60
+ list.querySelector('tr.issue-row.selected')
61
+ );
62
+ expect(selected && selected.getAttribute('data-issue-id')).toBe('UI-2');
63
+ });
64
+ });