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,200 @@
1
+ /**
2
+ * Protocol definitions for beads-ui WebSocket communication.
3
+ *
4
+ * Conventions
5
+ * - All messages are JSON objects.
6
+ * - Client → Server uses RequestEnvelope.
7
+ * - Server → Client uses ReplyEnvelope.
8
+ * - Every request is correlated by `id` in replies.
9
+ * - Server can also send unsolicited events (e.g., `issues-changed`).
10
+ *
11
+ * Versioning
12
+ * - Increment `PROTOCOL_VERSION` on breaking changes.
13
+ * - Add new message types without breaking existing ones when possible.
14
+ */
15
+
16
+ /** @constant {string} */
17
+ export const PROTOCOL_VERSION = '1.0.0';
18
+
19
+ /** @typedef {'list-issues'|'show-issue'|'update-status'|'edit-text'|'update-priority'|'create-issue'|'list-ready'|'subscribe-updates'|'issues-changed'|'dep-add'|'dep-remove'|'epic-status'|'update-assignee'|'label-add'|'label-remove'} MessageType */
20
+
21
+ /**
22
+ * @typedef {Object} RequestEnvelope
23
+ * @property {string} id - Unique id to correlate request/response.
24
+ * @property {MessageType} type - Message type.
25
+ * @property {unknown} [payload] - Message payload.
26
+ */
27
+
28
+ /**
29
+ * @typedef {Object} ErrorObject
30
+ * @property {string} code - Stable error code.
31
+ * @property {string} message - Human-readable message.
32
+ * @property {unknown} [details] - Optional extra info for debugging.
33
+ */
34
+
35
+ /**
36
+ * @typedef {Object} ReplyEnvelope
37
+ * @property {string} id - Correlates to the originating request.
38
+ * @property {boolean} ok - True when request succeeded; false on error.
39
+ * @property {MessageType} type - Echoes request type (or event type).
40
+ * @property {unknown} [payload] - Response payload.
41
+ * @property {ErrorObject} [error] - Present when ok=false.
42
+ */
43
+
44
+ /** @type {MessageType[]} */
45
+ export const MESSAGE_TYPES = /** @type {const} */ ([
46
+ 'list-issues',
47
+ 'show-issue',
48
+ 'update-status',
49
+ 'edit-text',
50
+ 'update-priority',
51
+ 'create-issue',
52
+ 'list-ready',
53
+ 'subscribe-updates',
54
+ 'issues-changed',
55
+ 'dep-add',
56
+ 'dep-remove',
57
+ 'epic-status',
58
+ 'update-assignee',
59
+ 'label-add',
60
+ 'label-remove'
61
+ ]);
62
+
63
+ /**
64
+ * Generate a lexically sortable request id.
65
+ * @returns {string}
66
+ */
67
+ export function nextId() {
68
+ const now = Date.now().toString(36);
69
+ const rand = Math.random().toString(36).slice(2, 8);
70
+ return `${now}-${rand}`;
71
+ }
72
+
73
+ /**
74
+ * Create a request envelope.
75
+ * @param {MessageType} type - Message type.
76
+ * @param {unknown} [payload] - Message payload.
77
+ * @param {string} [id] - Optional id; generated if omitted.
78
+ * @returns {RequestEnvelope}
79
+ */
80
+ export function makeRequest(type, payload, id = nextId()) {
81
+ return { id, type, payload };
82
+ }
83
+
84
+ /**
85
+ * Create a successful reply envelope for a given request.
86
+ * @param {RequestEnvelope} req - Original request.
87
+ * @param {unknown} [payload] - Reply payload.
88
+ * @returns {ReplyEnvelope}
89
+ */
90
+ export function makeOk(req, payload) {
91
+ return { id: req.id, ok: true, type: req.type, payload };
92
+ }
93
+
94
+ /**
95
+ * Create an error reply envelope for a given request.
96
+ * @param {RequestEnvelope} req - Original request.
97
+ * @param {string} code - Error code.
98
+ * @param {string} message - Error message.
99
+ * @param {unknown} [details] - Extra details.
100
+ * @returns {ReplyEnvelope}
101
+ */
102
+ export function makeError(req, code, message, details) {
103
+ return {
104
+ id: req.id,
105
+ ok: false,
106
+ type: req.type,
107
+ error: { code, message, details }
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Check if a value is a plain object.
113
+ * @param {unknown} value
114
+ * @returns {value is Record<string, unknown>}
115
+ */
116
+ function isRecord(value) {
117
+ return !!value && typeof value === 'object' && !Array.isArray(value);
118
+ }
119
+
120
+ /**
121
+ * Type guard for MessageType values.
122
+ * @param {unknown} value
123
+ * @returns {value is MessageType}
124
+ */
125
+ export function isMessageType(value) {
126
+ return (
127
+ typeof value === 'string' &&
128
+ MESSAGE_TYPES.includes(/** @type {MessageType} */ (value))
129
+ );
130
+ }
131
+
132
+ /**
133
+ * Type guard for RequestEnvelope.
134
+ * @param {unknown} value
135
+ * @returns {value is RequestEnvelope}
136
+ */
137
+ export function isRequest(value) {
138
+ if (!isRecord(value)) {
139
+ return false;
140
+ }
141
+ return (
142
+ typeof value.id === 'string' &&
143
+ typeof value.type === 'string' &&
144
+ (value.payload === undefined || 'payload' in value)
145
+ );
146
+ }
147
+
148
+ /**
149
+ * Type guard for ReplyEnvelope.
150
+ * @param {unknown} value
151
+ * @returns {value is ReplyEnvelope}
152
+ */
153
+ export function isReply(value) {
154
+ if (!isRecord(value)) {
155
+ return false;
156
+ }
157
+ if (
158
+ typeof value.id !== 'string' ||
159
+ typeof value.ok !== 'boolean' ||
160
+ !isMessageType(value.type)
161
+ ) {
162
+ return false;
163
+ }
164
+ if (value.ok === false) {
165
+ const err = /** @type {any} */ (value).error;
166
+ if (
167
+ !isRecord(err) ||
168
+ typeof err.code !== 'string' ||
169
+ typeof err.message !== 'string'
170
+ ) {
171
+ return false;
172
+ }
173
+ }
174
+ return true;
175
+ }
176
+
177
+ /**
178
+ * Normalize and validate an incoming JSON value as a RequestEnvelope.
179
+ * Throws a user-friendly error if invalid.
180
+ * @param {unknown} json
181
+ * @returns {RequestEnvelope}
182
+ */
183
+ export function decodeRequest(json) {
184
+ if (!isRequest(json)) {
185
+ throw new Error('Invalid request envelope');
186
+ }
187
+ return json;
188
+ }
189
+
190
+ /**
191
+ * Normalize and validate an incoming JSON value as a ReplyEnvelope.
192
+ * @param {unknown} json
193
+ * @returns {ReplyEnvelope}
194
+ */
195
+ export function decodeReply(json) {
196
+ if (!isReply(json)) {
197
+ throw new Error('Invalid reply envelope');
198
+ }
199
+ return json;
200
+ }
@@ -0,0 +1,64 @@
1
+ # beads-ui WebSocket Protocol (v1.0.0)
2
+
3
+ This document defines the JSON messages exchanged between the browser client and
4
+ the local server.
5
+
6
+ - Transport: single WebSocket connection
7
+ - Encoding: JSON text frames
8
+ - Correlation: all request/response pairs share the same `id`
9
+
10
+ ## Envelope Shapes
11
+
12
+ - RequestEnvelope: `{ id: string, type: string, payload?: any }`
13
+ - ReplyEnvelope:
14
+ `{ id: string, ok: boolean, type: string, payload?: any, error?: { code: string, message: string, details?: any } }`
15
+
16
+ Server may send unsolicited events (e.g., `issues-changed`) using the
17
+ ReplyEnvelope shape with `ok: true` and a generated `id`.
18
+
19
+ ## Message Types
20
+
21
+ - `list-issues` payload: `{ filters?: { status?: string, priority?: number } }`
22
+ - `show-issue` payload: `{ id: string }`
23
+ - `update-status` payload:
24
+ `{ id: string, status: 'open'|'in_progress'|'closed' }`
25
+ - `edit-text` payload:
26
+ `{ id: string, field: 'title'|'description'|'acceptance', value: string }`
27
+ - Note: `description` edits are rejected by the server (unsupported by `bd`).
28
+ - `update-priority` payload: `{ id: string, priority: 0|1|2|3|4 }`
29
+ - `create-issue` payload:
30
+ `{ title: string, type?: 'bug'|'feature'|'task'|'epic'|'chore', priority?: 0|1|2|3|4, description?: string }`
31
+ - `list-ready` payload: `{}`
32
+ - `subscribe-updates` payload: `{}` (server responds with `ok` and begins
33
+ emitting events)
34
+ - `issues-changed` payload: `{ ts: number, hint?: { ids?: string[] } }`
35
+ - `dep-add` payload: `{ a: string, b: string, view_id?: string }` where `a`
36
+ depends on `b` (i.e., `a` is blocked by `b`). Reply payload is the updated
37
+ issue for `view_id` (or `a` when omitted).
38
+ - `dep-remove` payload: `{ a: string, b: string, view_id?: string }` removing
39
+ the `a` depends on `b` link. Reply payload is the updated issue for `view_id`
40
+ (or `a`).
41
+
42
+ ## Mapping to `bd` CLI
43
+
44
+ - `list-issues` → `bd list --json [--status <s>] [--priority <n>]`
45
+ - `show-issue` → `bd show <id> --json`
46
+ - `update-status` → `bd update <id> --status <status>`
47
+ - `edit-text` → `bd update <id> --title <t>` or `--acceptance-criteria <a>`
48
+ - `description` has no CLI flag; server responds with an error
49
+ - `update-priority` → `bd update <id> --priority <n>`
50
+ - `create-issue` → `bd create "title" -t <type> -p <prio> -d "desc"`
51
+ - `list-ready` → `bd ready --json`
52
+
53
+ ## Errors
54
+
55
+ Errors follow the shape `{ code, message, details? }`. Common codes:
56
+
57
+ - `bad_request` – malformed payload or unknown type
58
+ - `not_found` – entity not found (e.g., issue id)
59
+ - `bd_error` – underlying `bd` command failed
60
+
61
+ ## Versioning
62
+
63
+ Breaking changes to shapes or semantics increment `PROTOCOL_VERSION` in
64
+ `app/protocol.js`.
@@ -0,0 +1,57 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import {
3
+ MESSAGE_TYPES,
4
+ PROTOCOL_VERSION,
5
+ decodeReply,
6
+ decodeRequest,
7
+ isMessageType,
8
+ isReply,
9
+ isRequest,
10
+ makeError,
11
+ makeOk,
12
+ makeRequest
13
+ } from './protocol.js';
14
+
15
+ describe('protocol', () => {
16
+ test('version and message types', () => {
17
+ expect(typeof PROTOCOL_VERSION).toBe('string');
18
+ expect(Array.isArray(MESSAGE_TYPES)).toBe(true);
19
+ expect(MESSAGE_TYPES.length).toBeGreaterThan(3);
20
+ expect(isMessageType('list-issues')).toBe(true);
21
+ expect(isMessageType('unknown-type')).toBe(false);
22
+ });
23
+
24
+ test('makeRequest / isRequest / decodeRequest', () => {
25
+ const req = makeRequest(
26
+ 'list-issues',
27
+ { filters: { status: 'open' } },
28
+ 'r-1'
29
+ );
30
+ expect(isRequest(req)).toBe(true);
31
+ const round = decodeRequest(JSON.parse(JSON.stringify(req)));
32
+ expect(round.id).toBe('r-1');
33
+ expect(round.type).toBe('list-issues');
34
+ });
35
+
36
+ test('makeOk / makeError / isReply / decodeReply', () => {
37
+ const req = makeRequest('show-issue', { id: 'UI-1' }, 'r-2');
38
+ const ok = makeOk(req, { id: 'UI-1', title: 'T' });
39
+ expect(isReply(ok)).toBe(true);
40
+ const ok2 = decodeReply(JSON.parse(JSON.stringify(ok)));
41
+ expect(ok2.ok).toBe(true);
42
+
43
+ const err = makeError(req, 'not_found', 'Issue not found');
44
+ expect(isReply(err)).toBe(true);
45
+ const err2 = decodeReply(JSON.parse(JSON.stringify(err)));
46
+ expect(err2.ok).toBe(false);
47
+ if (!('error' in err2) || !err2.error) {
48
+ throw new Error('Expected error to be present when ok=false');
49
+ }
50
+ expect(err2.error.code).toBe('not_found');
51
+ });
52
+
53
+ test('invalid envelopes are rejected', () => {
54
+ expect(() => decodeRequest({})).toThrow();
55
+ expect(() => decodeReply({ ok: true })).toThrow();
56
+ });
57
+ });
package/app/router.js ADDED
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Hash-based router for tabs (issues/epics/board) and deep-linked issue ids.
3
+ */
4
+
5
+ /**
6
+ * Parse an application hash and extract the selected issue id.
7
+ * @param {string} hash
8
+ * @returns {string | null}
9
+ */
10
+ export function parseHash(hash) {
11
+ const m = /^#\/issue\/([^\s?#]+)/.exec(hash || '');
12
+ return m && m[1] ? decodeURIComponent(m[1]) : null;
13
+ }
14
+
15
+ /**
16
+ * Parse the current view from hash.
17
+ * @param {string} hash
18
+ * @returns {'issues'|'epics'|'board'}
19
+ */
20
+ export function parseView(hash) {
21
+ const h = String(hash || '');
22
+ if (/^#\/epics(\b|\/|$)/.test(h)) {
23
+ return 'epics';
24
+ }
25
+ if (/^#\/board(\b|\/|$)/.test(h)) {
26
+ return 'board';
27
+ }
28
+ // Default to issues (also covers #/issues and unknown/empty)
29
+ return 'issues';
30
+ }
31
+
32
+ /**
33
+ * Create and start the hash router.
34
+ * @param {{ getState: () => any, setState: (patch: any) => void }} store
35
+ * @returns {{ start: () => void, stop: () => void, gotoIssue: (id: string) => void, gotoView: (v: 'issues'|'epics'|'board') => void }}
36
+ */
37
+ export function createHashRouter(store) {
38
+ /** @type {(ev?: HashChangeEvent) => any} */
39
+ const onHashChange = () => {
40
+ const hash = window.location.hash || '';
41
+ const id = parseHash(hash);
42
+ // Preserve current view when navigating to a detail route so tabs remain stable
43
+ const current = store.getState ? store.getState() : { view: 'issues' };
44
+ const view = id ? current.view || 'issues' : parseView(hash);
45
+ store.setState({ selected_id: id, view });
46
+ };
47
+
48
+ return {
49
+ start() {
50
+ window.addEventListener('hashchange', onHashChange);
51
+ onHashChange();
52
+ },
53
+ stop() {
54
+ window.removeEventListener('hashchange', onHashChange);
55
+ },
56
+ gotoIssue(id) {
57
+ const next = `#/issue/${encodeURIComponent(id)}`;
58
+ if (window.location.hash !== next) {
59
+ window.location.hash = next;
60
+ } else {
61
+ // Force state update even if hash is the same
62
+ store.setState({ selected_id: id, view: 'issues' });
63
+ }
64
+ },
65
+ /**
66
+ * Navigate to a top-level view.
67
+ * @param {'issues'|'epics'|'board'} view
68
+ */
69
+ gotoView(view) {
70
+ const next = `#/${view}`;
71
+ if (window.location.hash !== next) {
72
+ window.location.hash = next;
73
+ } else {
74
+ store.setState({ view, selected_id: null });
75
+ }
76
+ }
77
+ };
78
+ }
@@ -0,0 +1,34 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { createHashRouter, parseHash, parseView } from './router.js';
3
+ import { createStore } from './state.js';
4
+
5
+ describe('router', () => {
6
+ test('parseHash extracts id', () => {
7
+ expect(parseHash('#/issue/UI-5')).toBe('UI-5');
8
+ expect(parseHash('#/anything')).toBeNull();
9
+ });
10
+
11
+ test('router updates store and gotoIssue updates hash', () => {
12
+ document.body.innerHTML = '<div></div>';
13
+ const store = createStore();
14
+ const router = createHashRouter(store);
15
+ router.start();
16
+
17
+ window.location.hash = '#/issue/UI-10';
18
+ // Trigger handler synchronously
19
+ window.dispatchEvent(new HashChangeEvent('hashchange'));
20
+ expect(store.getState().selected_id).toBe('UI-10');
21
+
22
+ router.gotoIssue('UI-11');
23
+ expect(window.location.hash).toBe('#/issue/UI-11');
24
+ router.stop();
25
+ });
26
+
27
+ test('parseView resolves from hash and defaults to issues', () => {
28
+ expect(parseView('#/issues')).toBe('issues');
29
+ expect(parseView('#/epics')).toBe('epics');
30
+ expect(parseView('#/board')).toBe('board');
31
+ expect(parseView('')).toBe('issues');
32
+ expect(parseView('#/unknown')).toBe('issues');
33
+ });
34
+ });
package/app/state.js ADDED
@@ -0,0 +1,87 @@
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 {{ selected_id: string | null, view: ViewName, filters: Filters }} AppState
19
+ */
20
+
21
+ /**
22
+ * Create a simple store for application state.
23
+ * @param {Partial<AppState>} [initial]
24
+ * @returns {{ getState: () => AppState, setState: (patch: { selected_id?: string | null, filters?: Partial<Filters> }) => void, subscribe: (fn: (s: AppState) => void) => () => void }}
25
+ */
26
+ export function createStore(initial = {}) {
27
+ /** @type {AppState} */
28
+ let state = {
29
+ selected_id: /** @type {any} */ (initial).selected_id ?? null,
30
+ view: /** @type {any} */ (initial).view ?? 'issues',
31
+ filters: {
32
+ status: /** @type {any} */ (initial).filters?.status ?? 'all',
33
+ search: /** @type {any} */ (initial).filters?.search ?? '',
34
+ type:
35
+ typeof (/** @type {any} */ (initial).filters?.type) === 'string'
36
+ ? /** @type {any} */ (initial).filters?.type
37
+ : ''
38
+ }
39
+ };
40
+
41
+ /** @type {Set<(s: AppState) => void>} */
42
+ const subs = new Set();
43
+
44
+ function emit() {
45
+ for (const fn of Array.from(subs)) {
46
+ try {
47
+ fn(state);
48
+ } catch {
49
+ // ignore
50
+ }
51
+ }
52
+ }
53
+
54
+ return {
55
+ getState() {
56
+ return state;
57
+ },
58
+ /**
59
+ * Update state. Nested filters can be partial.
60
+ * @param {{ selected_id?: string | null, filters?: Partial<Filters> }} patch
61
+ */
62
+ setState(patch) {
63
+ /** @type {AppState} */
64
+ const next = {
65
+ ...state,
66
+ ...patch,
67
+ filters: { ...state.filters, ...(patch.filters || {}) }
68
+ };
69
+ // Avoid emitting if nothing changed (shallow compare)
70
+ if (
71
+ next.selected_id === state.selected_id &&
72
+ next.view === state.view &&
73
+ next.filters.status === state.filters.status &&
74
+ next.filters.search === state.filters.search &&
75
+ next.filters.type === state.filters.type
76
+ ) {
77
+ return;
78
+ }
79
+ state = next;
80
+ emit();
81
+ },
82
+ subscribe(fn) {
83
+ subs.add(fn);
84
+ return () => subs.delete(fn);
85
+ }
86
+ };
87
+ }
@@ -0,0 +1,21 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { createStore } from './state.js';
3
+
4
+ describe('state store', () => {
5
+ test('get/set/subscribe works and dedupes unchanged', () => {
6
+ const store = createStore();
7
+ const seen = [];
8
+ const off = store.subscribe((s) => seen.push(s));
9
+
10
+ store.setState({ selected_id: 'UI-1' });
11
+ store.setState({ filters: { status: 'open' } });
12
+ // no-op (unchanged)
13
+ store.setState({ filters: { status: 'open' } });
14
+ off();
15
+
16
+ expect(seen.length).toBe(2);
17
+ const state = store.getState();
18
+ expect(state.selected_id).toBe('UI-1');
19
+ expect(state.filters.status).toBe('open');
20
+ });
21
+ });