@rsktash/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 (68) hide show
  1. package/.github/workflows/publish.yml +28 -0
  2. package/app/protocol.js +216 -0
  3. package/bin/bdui +19 -0
  4. package/client/index.html +12 -0
  5. package/client/postcss.config.js +11 -0
  6. package/client/src/App.tsx +35 -0
  7. package/client/src/components/IssueCard.tsx +73 -0
  8. package/client/src/components/Layout.tsx +175 -0
  9. package/client/src/components/Markdown.tsx +77 -0
  10. package/client/src/components/PriorityBadge.tsx +26 -0
  11. package/client/src/components/SearchDialog.tsx +137 -0
  12. package/client/src/components/SectionEditor.tsx +212 -0
  13. package/client/src/components/StatusBadge.tsx +64 -0
  14. package/client/src/components/TypeBadge.tsx +26 -0
  15. package/client/src/hooks/use-mutation.ts +55 -0
  16. package/client/src/hooks/use-search.ts +19 -0
  17. package/client/src/hooks/use-subscription.ts +187 -0
  18. package/client/src/index.css +133 -0
  19. package/client/src/lib/avatar.ts +17 -0
  20. package/client/src/lib/types.ts +115 -0
  21. package/client/src/lib/ws-client.ts +214 -0
  22. package/client/src/lib/ws-context.tsx +28 -0
  23. package/client/src/main.tsx +10 -0
  24. package/client/src/views/Board.tsx +200 -0
  25. package/client/src/views/Detail.tsx +398 -0
  26. package/client/src/views/List.tsx +461 -0
  27. package/client/tailwind.config.ts +68 -0
  28. package/client/tsconfig.json +16 -0
  29. package/client/vite.config.ts +20 -0
  30. package/package.json +43 -0
  31. package/server/app.js +120 -0
  32. package/server/app.test.js +30 -0
  33. package/server/bd.js +227 -0
  34. package/server/bd.test.js +194 -0
  35. package/server/cli/cli.test.js +207 -0
  36. package/server/cli/commands.integration.test.js +148 -0
  37. package/server/cli/commands.js +285 -0
  38. package/server/cli/commands.unit.test.js +408 -0
  39. package/server/cli/daemon.js +340 -0
  40. package/server/cli/daemon.test.js +31 -0
  41. package/server/cli/index.js +135 -0
  42. package/server/cli/open.js +178 -0
  43. package/server/cli/open.test.js +26 -0
  44. package/server/cli/usage.js +27 -0
  45. package/server/config.js +36 -0
  46. package/server/db.js +154 -0
  47. package/server/db.test.js +169 -0
  48. package/server/dolt-pool.js +257 -0
  49. package/server/dolt-queries.js +646 -0
  50. package/server/index.js +97 -0
  51. package/server/list-adapters.js +395 -0
  52. package/server/list-adapters.test.js +208 -0
  53. package/server/logging.js +23 -0
  54. package/server/registry-watcher.js +200 -0
  55. package/server/subscriptions.js +299 -0
  56. package/server/subscriptions.test.js +128 -0
  57. package/server/validators.js +124 -0
  58. package/server/watcher.js +139 -0
  59. package/server/watcher.test.js +120 -0
  60. package/server/ws.comments.test.js +262 -0
  61. package/server/ws.delete.test.js +119 -0
  62. package/server/ws.js +1309 -0
  63. package/server/ws.labels.test.js +95 -0
  64. package/server/ws.list-refresh.coalesce.test.js +95 -0
  65. package/server/ws.list-subscriptions.test.js +403 -0
  66. package/server/ws.mutation-window.test.js +147 -0
  67. package/server/ws.mutations.test.js +389 -0
  68. package/server/ws.test.js +52 -0
@@ -0,0 +1,133 @@
1
+ @import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=JetBrains+Mono:wght@400;500;600;700&display=swap');
2
+
3
+ @tailwind base;
4
+ @tailwind components;
5
+ @tailwind utilities;
6
+
7
+ @layer base {
8
+ :root {
9
+ /* Background layers */
10
+ --bg-base: #FDFBF7;
11
+ --bg-surface: #F8F5EF;
12
+ --bg-elevated: #FFFFFF;
13
+ --bg-hover: #F5F0E8;
14
+ --bg-overlay: rgba(0,0,0,0.4);
15
+
16
+ /* Text */
17
+ --text-primary: #1A1A1A;
18
+ --text-secondary: #6B6B6B;
19
+ --text-tertiary: #9CA3AF;
20
+ --text-inverse: #FAFAF9;
21
+
22
+ /* Borders */
23
+ --border-subtle: rgba(0,0,0,0.06);
24
+ --border-default: rgba(0,0,0,0.1);
25
+
26
+ /* Accent */
27
+ --accent: #16A34A;
28
+ --accent-soft: rgba(22,163,74,0.1);
29
+
30
+ /* Status */
31
+ --status-open: #3B82F6;
32
+ --status-in-progress: #F59E0B;
33
+ --status-blocked: #EF4444;
34
+ --status-closed: #22C55E;
35
+
36
+ /* Priority */
37
+ --priority-0: #EF4444;
38
+ --priority-1: #F97316;
39
+ --priority-2: #3B82F6;
40
+ --priority-3: #22C55E;
41
+ --priority-4: #D1D5DB;
42
+
43
+ /* Type */
44
+ --type-task: #16A34A;
45
+ --type-bug: #EF4444;
46
+ --type-feature: #6366F1;
47
+ --type-epic: #7C3AED;
48
+ --type-chore: #78716C;
49
+
50
+ /* Spacing (4px grid) */
51
+ --space-1: 4px;
52
+ --space-2: 8px;
53
+ --space-3: 12px;
54
+ --space-4: 16px;
55
+ --space-5: 20px;
56
+ --space-6: 24px;
57
+ --space-8: 32px;
58
+ --space-10: 40px;
59
+ --space-12: 48px;
60
+ --space-16: 64px;
61
+
62
+ /* Radius */
63
+ --radius-sm: 6px;
64
+ --radius-md: 10px;
65
+ --radius-lg: 14px;
66
+
67
+ /* Shadows */
68
+ --shadow-sm: 0 1px 2px rgba(0,0,0,0.04);
69
+ --shadow-card: 0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.03);
70
+ --shadow-md: 0 4px 12px rgba(0,0,0,0.06);
71
+
72
+ /* Transitions */
73
+ --transition-fast: 120ms ease;
74
+ --transition-medium: 200ms cubic-bezier(0.16, 1, 0.3, 1);
75
+ }
76
+
77
+ body {
78
+ font-family: 'DM Sans', system-ui, -apple-system, sans-serif;
79
+ background: var(--bg-base);
80
+ color: var(--text-primary);
81
+ -webkit-font-smoothing: antialiased;
82
+ -moz-osx-font-smoothing: grayscale;
83
+ }
84
+ }
85
+
86
+ /* IssueCard styles */
87
+ .issue-card {
88
+ background: var(--bg-elevated);
89
+ border: 1px solid var(--border-subtle);
90
+ border-radius: var(--radius-md);
91
+ box-shadow: var(--shadow-card);
92
+ transition: box-shadow 0.2s cubic-bezier(0.16, 1, 0.3, 1), transform 0.2s cubic-bezier(0.16, 1, 0.3, 1);
93
+ }
94
+
95
+ .issue-card:hover {
96
+ box-shadow: var(--shadow-md);
97
+ transform: translateY(-1px);
98
+ }
99
+
100
+ .issue-card--dimmed {
101
+ opacity: 0.6;
102
+ }
103
+
104
+ /* Skeleton shimmer */
105
+ .skeleton-shimmer {
106
+ background: linear-gradient(
107
+ 90deg,
108
+ var(--bg-hover) 25%,
109
+ var(--bg-elevated) 50%,
110
+ var(--bg-hover) 75%
111
+ );
112
+ background-size: 200% 100%;
113
+ animation: shimmer 1.5s ease-in-out infinite;
114
+ }
115
+
116
+ @keyframes shimmer {
117
+ 0% { background-position: 200% 0; }
118
+ 100% { background-position: -200% 0; }
119
+ }
120
+
121
+ @keyframes spin {
122
+ from { transform: rotate(0deg); }
123
+ to { transform: rotate(360deg); }
124
+ }
125
+
126
+ .issue-card__handle {
127
+ opacity: 0;
128
+ transition: opacity 120ms ease;
129
+ }
130
+
131
+ .issue-card:hover .issue-card__handle {
132
+ opacity: 0.6;
133
+ }
@@ -0,0 +1,17 @@
1
+ const AVATAR_COLORS = [
2
+ "#E57373", "#F06292", "#BA68C8", "#9575CD",
3
+ "#7986CB", "#64B5F6", "#4FC3F7", "#4DD0E1",
4
+ "#4DB6AC", "#81C784", "#AED581", "#FFB74D",
5
+ ];
6
+
7
+ export function getInitials(name: string): string {
8
+ const parts = name.trim().split(/\s+/);
9
+ if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
10
+ return name.slice(0, 2).toUpperCase();
11
+ }
12
+
13
+ export function getAvatarColor(name: string): string {
14
+ let hash = 0;
15
+ for (let i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash);
16
+ return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length];
17
+ }
@@ -0,0 +1,115 @@
1
+ // Issue shape from server
2
+ export interface Issue {
3
+ id: string;
4
+ title: string;
5
+ description?: string;
6
+ status: "open" | "in_progress" | "blocked" | "closed";
7
+ priority: number;
8
+ issue_type: string;
9
+ assignee?: string;
10
+ owner?: string;
11
+ created_at: string | number;
12
+ updated_at: string | number;
13
+ closed_at?: string | number | null;
14
+ labels?: string[];
15
+ // Epic fields
16
+ total_children?: number;
17
+ closed_children?: number;
18
+ eligible_for_close?: boolean;
19
+ // Detail fields
20
+ acceptance?: string;
21
+ notes?: string;
22
+ design?: string;
23
+ dependencies?: Array<{ id: string; title: string; status: string }>;
24
+ dependents?: Array<{ id: string; title: string; status: string }>;
25
+ comments?: Comment[];
26
+ parent_id?: string;
27
+ parent_title?: string;
28
+ parent_status?: string;
29
+ parent_type?: string;
30
+ }
31
+
32
+ export interface Comment {
33
+ id: string;
34
+ issue_id: string;
35
+ author: string;
36
+ text: string;
37
+ created_at: number;
38
+ updated_at: number;
39
+ }
40
+
41
+ // Subscription types
42
+ export type SubscriptionType =
43
+ | "all-issues"
44
+ | "epics"
45
+ | "blocked-issues"
46
+ | "ready-issues"
47
+ | "in-progress-issues"
48
+ | "closed-issues"
49
+ | "search-issues"
50
+ | "issue-detail";
51
+
52
+ export interface SubscriptionSpec {
53
+ id: string;
54
+ type: SubscriptionType;
55
+ params?: Record<string, string | number | boolean>;
56
+ }
57
+
58
+ // Message envelopes
59
+ export interface RequestEnvelope {
60
+ id: string;
61
+ type: string;
62
+ payload?: unknown;
63
+ }
64
+
65
+ export interface ReplyEnvelope {
66
+ id: string;
67
+ ok: boolean;
68
+ type: string;
69
+ payload?: unknown;
70
+ error?: { code: string; message: string; details?: unknown };
71
+ }
72
+
73
+ // Push event payloads
74
+ export interface SnapshotPayload {
75
+ type: "snapshot";
76
+ id: string;
77
+ revision: number;
78
+ issues: Issue[];
79
+ total?: number;
80
+ }
81
+
82
+ export interface UpsertPayload {
83
+ type: "upsert";
84
+ id: string;
85
+ revision: number;
86
+ issue: Issue;
87
+ }
88
+
89
+ export interface DeletePayload {
90
+ type: "delete";
91
+ id: string;
92
+ revision: number;
93
+ issue_id: string;
94
+ }
95
+
96
+ export type PushEvent = SnapshotPayload | UpsertPayload | DeletePayload;
97
+
98
+ // Mutation payloads
99
+ export interface UpdateStatusPayload {
100
+ id: string;
101
+ status: "open" | "in_progress" | "closed";
102
+ }
103
+
104
+ export interface EditTextPayload {
105
+ id: string;
106
+ field: "title" | "description" | "acceptance" | "notes" | "design";
107
+ value: string;
108
+ }
109
+
110
+ export interface CreateIssuePayload {
111
+ title: string;
112
+ type?: string;
113
+ priority?: number;
114
+ description?: string;
115
+ }
@@ -0,0 +1,214 @@
1
+ import type {
2
+ ReplyEnvelope,
3
+ RequestEnvelope,
4
+ SubscriptionSpec,
5
+ PushEvent,
6
+ UpdateStatusPayload,
7
+ EditTextPayload,
8
+ CreateIssuePayload,
9
+ Issue,
10
+ Comment,
11
+ } from "./types";
12
+
13
+ type PushHandler = (event: PushEvent) => void;
14
+ type ConnectionHandler = (connected: boolean) => void;
15
+
16
+ let counter = 0;
17
+ function nextId(): string {
18
+ return `req-${Date.now()}-${++counter}`;
19
+ }
20
+
21
+ export class WsClient {
22
+ private ws: WebSocket | null = null;
23
+ private pending = new Map<
24
+ string,
25
+ { resolve: (v: unknown) => void; reject: (e: Error) => void }
26
+ >();
27
+ private sendQueue: string[] = [];
28
+ private pushHandlers = new Set<PushHandler>();
29
+ private connectionHandlers = new Set<ConnectionHandler>();
30
+ private activeSubscriptions = new Map<string, SubscriptionSpec>();
31
+ private reconnectDelay = 500;
32
+ private url: string;
33
+ private disposed = false;
34
+
35
+ constructor() {
36
+ const proto = location.protocol === "https:" ? "wss:" : "ws:";
37
+ this.url = `${proto}//${location.host}/ws`;
38
+ }
39
+
40
+ connect(): void {
41
+ if (this.disposed) return;
42
+ this.ws = new WebSocket(this.url);
43
+ this.ws.onopen = () => {
44
+ this.reconnectDelay = 500;
45
+ // Flush queued messages
46
+ for (const msg of this.sendQueue) {
47
+ this.ws?.send(msg);
48
+ }
49
+ this.sendQueue = [];
50
+ // Resubscribe all active subscriptions
51
+ for (const [, spec] of this.activeSubscriptions) {
52
+ this.rawSend("subscribe-list", spec);
53
+ }
54
+ for (const h of this.connectionHandlers) h(true);
55
+ };
56
+ this.ws.onclose = () => {
57
+ for (const h of this.connectionHandlers) h(false);
58
+ this.scheduleReconnect();
59
+ };
60
+ this.ws.onmessage = (e) => {
61
+ this.handleMessage(JSON.parse(e.data));
62
+ };
63
+ }
64
+
65
+ dispose(): void {
66
+ this.disposed = true;
67
+ this.ws?.close();
68
+ this.ws = null;
69
+ for (const [, p] of this.pending) {
70
+ p.reject(new Error("disposed"));
71
+ }
72
+ this.pending.clear();
73
+ this.sendQueue = [];
74
+ }
75
+
76
+ private scheduleReconnect(): void {
77
+ if (this.disposed) return;
78
+ setTimeout(() => this.connect(), this.reconnectDelay);
79
+ this.reconnectDelay = Math.min(this.reconnectDelay * 2, 10000);
80
+ for (const [id, p] of this.pending) {
81
+ p.reject(new Error("disconnected"));
82
+ this.pending.delete(id);
83
+ }
84
+ }
85
+
86
+ private handleMessage(msg: ReplyEnvelope): void {
87
+ if (
88
+ msg.type === "snapshot" ||
89
+ msg.type === "upsert" ||
90
+ msg.type === "delete"
91
+ ) {
92
+ const event = msg.payload as PushEvent;
93
+ for (const handler of this.pushHandlers) handler(event);
94
+ return;
95
+ }
96
+ const pending = this.pending.get(msg.id);
97
+ if (pending) {
98
+ this.pending.delete(msg.id);
99
+ if (msg.ok) pending.resolve(msg.payload);
100
+ else pending.reject(new Error(msg.error?.message || "unknown error"));
101
+ }
102
+ }
103
+
104
+ /** Send without promise tracking (used for resubscribe on reconnect) */
105
+ private rawSend(type: string, payload?: unknown): void {
106
+ const id = nextId();
107
+ const envelope: RequestEnvelope = { id, type, payload };
108
+ const msg = JSON.stringify(envelope);
109
+ if (this.ws?.readyState === WebSocket.OPEN) {
110
+ this.ws.send(msg);
111
+ } else {
112
+ this.sendQueue.push(msg);
113
+ }
114
+ }
115
+
116
+ private send<T = unknown>(type: string, payload?: unknown): Promise<T> {
117
+ return new Promise((resolve, reject) => {
118
+ const id = nextId();
119
+ this.pending.set(id, {
120
+ resolve: resolve as (v: unknown) => void,
121
+ reject,
122
+ });
123
+ const envelope: RequestEnvelope = { id, type, payload };
124
+ const msg = JSON.stringify(envelope);
125
+ if (this.ws?.readyState === WebSocket.OPEN) {
126
+ this.ws.send(msg);
127
+ } else {
128
+ this.sendQueue.push(msg);
129
+ }
130
+ });
131
+ }
132
+
133
+ onConnection(handler: ConnectionHandler): () => void {
134
+ this.connectionHandlers.add(handler);
135
+ return () => this.connectionHandlers.delete(handler);
136
+ }
137
+
138
+ onPush(handler: PushHandler): () => void {
139
+ this.pushHandlers.add(handler);
140
+ return () => this.pushHandlers.delete(handler);
141
+ }
142
+
143
+ // Subscriptions — tracked for auto-resubscribe on reconnect
144
+ subscribe(spec: SubscriptionSpec): Promise<{ id: string; key: string }> {
145
+ this.activeSubscriptions.set(spec.id, spec);
146
+ return this.send("subscribe-list", spec);
147
+ }
148
+
149
+ unsubscribe(id: string): Promise<void> {
150
+ this.activeSubscriptions.delete(id);
151
+ return this.send("unsubscribe-list", { id });
152
+ }
153
+
154
+ // Mutations
155
+ updateStatus(payload: UpdateStatusPayload): Promise<Issue> {
156
+ return this.send("update-status", payload);
157
+ }
158
+
159
+ editText(payload: EditTextPayload): Promise<Issue> {
160
+ return this.send("edit-text", payload);
161
+ }
162
+
163
+ createIssue(payload: CreateIssuePayload): Promise<{ created: boolean }> {
164
+ return this.send("create-issue", payload);
165
+ }
166
+
167
+ updatePriority(id: string, priority: number): Promise<Issue> {
168
+ return this.send("update-priority", { id, priority });
169
+ }
170
+
171
+ updateAssignee(id: string, assignee: string): Promise<Issue> {
172
+ return this.send("update-assignee", { id, assignee });
173
+ }
174
+
175
+ addLabel(id: string, label: string): Promise<Issue> {
176
+ return this.send("label-add", { id, label });
177
+ }
178
+
179
+ removeLabel(id: string, label: string): Promise<Issue> {
180
+ return this.send("label-remove", { id, label });
181
+ }
182
+
183
+ deleteIssue(id: string): Promise<{ deleted: boolean; id: string }> {
184
+ return this.send("delete-issue", { id });
185
+ }
186
+
187
+ getComments(id: string): Promise<Comment[]> {
188
+ return this.send("get-comments", { id });
189
+ }
190
+
191
+ addComment(id: string, text: string): Promise<Comment[]> {
192
+ return this.send("add-comment", { id, text });
193
+ }
194
+
195
+ addDep(a: string, b: string): Promise<Issue> {
196
+ return this.send("dep-add", { a, b });
197
+ }
198
+
199
+ removeDep(a: string, b: string): Promise<Issue> {
200
+ return this.send("dep-remove", { a, b });
201
+ }
202
+
203
+ listWorkspaces(): Promise<unknown> {
204
+ return this.send("list-workspaces");
205
+ }
206
+
207
+ getWorkspace(): Promise<unknown> {
208
+ return this.send("get-workspace");
209
+ }
210
+
211
+ setWorkspace(path: string, database: string): Promise<unknown> {
212
+ return this.send("set-workspace", { path, database });
213
+ }
214
+ }
@@ -0,0 +1,28 @@
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ useRef,
5
+ type ReactNode,
6
+ } from "react";
7
+ import { WsClient } from "./ws-client";
8
+
9
+ const WsContext = createContext<WsClient | null>(null);
10
+
11
+ export function WsProvider({ children }: { children: ReactNode }) {
12
+ const clientRef = useRef<WsClient | null>(null);
13
+ if (!clientRef.current) {
14
+ clientRef.current = new WsClient();
15
+ clientRef.current.connect();
16
+ }
17
+ return (
18
+ <WsContext.Provider value={clientRef.current}>
19
+ {children}
20
+ </WsContext.Provider>
21
+ );
22
+ }
23
+
24
+ export function useWs(): WsClient {
25
+ const client = useContext(WsContext);
26
+ if (!client) throw new Error("useWs must be inside WsProvider");
27
+ return client;
28
+ }
@@ -0,0 +1,10 @@
1
+ import { StrictMode } from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import { App } from "./App";
4
+ import "./index.css";
5
+
6
+ createRoot(document.getElementById("root")!).render(
7
+ <StrictMode>
8
+ <App />
9
+ </StrictMode>
10
+ );