heartbeads 0.4.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 (205) hide show
  1. package/.next/BUILD_ID +1 -0
  2. package/.next/app-build-manifest.json +49 -0
  3. package/.next/app-path-routes-manifest.json +1 -0
  4. package/.next/build-manifest.json +32 -0
  5. package/.next/export-marker.json +1 -0
  6. package/.next/images-manifest.json +1 -0
  7. package/.next/next-minimal-server.js.nft.json +1 -0
  8. package/.next/next-server.js.nft.json +1 -0
  9. package/.next/package.json +1 -0
  10. package/.next/prerender-manifest.json +1 -0
  11. package/.next/react-loadable-manifest.json +8 -0
  12. package/.next/required-server-files.json +1 -0
  13. package/.next/routes-manifest.json +1 -0
  14. package/.next/server/app/_not-found/page.js +1 -0
  15. package/.next/server/app/_not-found/page.js.nft.json +1 -0
  16. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -0
  17. package/.next/server/app/_not-found.html +1 -0
  18. package/.next/server/app/_not-found.meta +6 -0
  19. package/.next/server/app/_not-found.rsc +9 -0
  20. package/.next/server/app/api/auth/route.js +1 -0
  21. package/.next/server/app/api/auth/route.js.nft.json +1 -0
  22. package/.next/server/app/api/beads/route.js +8 -0
  23. package/.next/server/app/api/beads/route.js.nft.json +1 -0
  24. package/.next/server/app/api/beads/stream/route.js +10 -0
  25. package/.next/server/app/api/beads/stream/route.js.nft.json +1 -0
  26. package/.next/server/app/api/beads.body +1 -0
  27. package/.next/server/app/api/beads.meta +1 -0
  28. package/.next/server/app/api/config/route.js +8 -0
  29. package/.next/server/app/api/config/route.js.nft.json +1 -0
  30. package/.next/server/app/api/docs/page.js +120 -0
  31. package/.next/server/app/api/docs/page.js.nft.json +1 -0
  32. package/.next/server/app/api/docs/page_client-reference-manifest.js +1 -0
  33. package/.next/server/app/api/docs.html +120 -0
  34. package/.next/server/app/api/docs.meta +5 -0
  35. package/.next/server/app/api/docs.rsc +70 -0
  36. package/.next/server/app/api/login/route.js +1 -0
  37. package/.next/server/app/api/login/route.js.nft.json +1 -0
  38. package/.next/server/app/api/logout/route.js +1 -0
  39. package/.next/server/app/api/logout/route.js.nft.json +1 -0
  40. package/.next/server/app/api/oauth/callback/route.js +1 -0
  41. package/.next/server/app/api/oauth/callback/route.js.nft.json +1 -0
  42. package/.next/server/app/api/oauth/client-metadata.json/route.js +1 -0
  43. package/.next/server/app/api/oauth/client-metadata.json/route.js.nft.json +1 -0
  44. package/.next/server/app/api/oauth/jwks.json/route.js +1 -0
  45. package/.next/server/app/api/oauth/jwks.json/route.js.nft.json +1 -0
  46. package/.next/server/app/api/records/route.js +1 -0
  47. package/.next/server/app/api/records/route.js.nft.json +1 -0
  48. package/.next/server/app/api/status/route.js +1 -0
  49. package/.next/server/app/api/status/route.js.nft.json +1 -0
  50. package/.next/server/app/api/v1/graph/route.js +1 -0
  51. package/.next/server/app/api/v1/graph/route.js.nft.json +1 -0
  52. package/.next/server/app/api/v1/issues/[id]/route.js +1 -0
  53. package/.next/server/app/api/v1/issues/[id]/route.js.nft.json +1 -0
  54. package/.next/server/app/api/v1/ready/route.js +1 -0
  55. package/.next/server/app/api/v1/ready/route.js.nft.json +1 -0
  56. package/.next/server/app/index.html +1 -0
  57. package/.next/server/app/index.meta +5 -0
  58. package/.next/server/app/index.rsc +9 -0
  59. package/.next/server/app/login/page.js +1 -0
  60. package/.next/server/app/login/page.js.nft.json +1 -0
  61. package/.next/server/app/login/page_client-reference-manifest.js +1 -0
  62. package/.next/server/app/login.html +1 -0
  63. package/.next/server/app/login.meta +5 -0
  64. package/.next/server/app/login.rsc +9 -0
  65. package/.next/server/app/opengraph-image.png/route.js +1 -0
  66. package/.next/server/app/opengraph-image.png/route.js.nft.json +1 -0
  67. package/.next/server/app/opengraph-image.png.body +0 -0
  68. package/.next/server/app/opengraph-image.png.meta +1 -0
  69. package/.next/server/app/page.js +24 -0
  70. package/.next/server/app/page.js.nft.json +1 -0
  71. package/.next/server/app/page_client-reference-manifest.js +1 -0
  72. package/.next/server/app/twitter-image.png/route.js +1 -0
  73. package/.next/server/app/twitter-image.png/route.js.nft.json +1 -0
  74. package/.next/server/app/twitter-image.png.body +0 -0
  75. package/.next/server/app/twitter-image.png.meta +1 -0
  76. package/.next/server/app-paths-manifest.json +22 -0
  77. package/.next/server/chunks/247.js +12 -0
  78. package/.next/server/chunks/29.js +1 -0
  79. package/.next/server/chunks/343.js +1 -0
  80. package/.next/server/chunks/460.js +12 -0
  81. package/.next/server/chunks/533.js +38 -0
  82. package/.next/server/chunks/542.js +27 -0
  83. package/.next/server/chunks/590.js +6 -0
  84. package/.next/server/chunks/615.js +15 -0
  85. package/.next/server/chunks/696.js +25 -0
  86. package/.next/server/chunks/719.js +2 -0
  87. package/.next/server/chunks/739.js +1 -0
  88. package/.next/server/chunks/950.js +2 -0
  89. package/.next/server/chunks/font-manifest.json +1 -0
  90. package/.next/server/edge-runtime-webpack.js +2 -0
  91. package/.next/server/edge-runtime-webpack.js.map +1 -0
  92. package/.next/server/font-manifest.json +1 -0
  93. package/.next/server/functions-config-manifest.json +1 -0
  94. package/.next/server/interception-route-rewrite-manifest.js +1 -0
  95. package/.next/server/middleware-build-manifest.js +1 -0
  96. package/.next/server/middleware-manifest.json +32 -0
  97. package/.next/server/middleware-react-loadable-manifest.js +1 -0
  98. package/.next/server/middleware.js +14 -0
  99. package/.next/server/middleware.js.map +1 -0
  100. package/.next/server/next-font-manifest.js +1 -0
  101. package/.next/server/next-font-manifest.json +1 -0
  102. package/.next/server/pages/404.html +1 -0
  103. package/.next/server/pages/500.html +1 -0
  104. package/.next/server/pages/_app.js +1 -0
  105. package/.next/server/pages/_app.js.nft.json +1 -0
  106. package/.next/server/pages/_document.js +1 -0
  107. package/.next/server/pages/_document.js.nft.json +1 -0
  108. package/.next/server/pages/_error.js +1 -0
  109. package/.next/server/pages/_error.js.nft.json +1 -0
  110. package/.next/server/pages-manifest.json +1 -0
  111. package/.next/server/server-reference-manifest.js +1 -0
  112. package/.next/server/server-reference-manifest.json +1 -0
  113. package/.next/server/webpack-runtime.js +1 -0
  114. package/.next/static/chunks/149.a3e3a5dc03e21086.js +1 -0
  115. package/.next/static/chunks/2200cc46-7c93a0e00b0bb825.js +1 -0
  116. package/.next/static/chunks/788-aa413085174e935a.js +1 -0
  117. package/.next/static/chunks/945-3ff1d381a0af1ecd.js +2 -0
  118. package/.next/static/chunks/971-bb44d52bcd9ee2a9.js +1 -0
  119. package/.next/static/chunks/app/_not-found/page-200b7a7a6cfc29df.js +1 -0
  120. package/.next/static/chunks/app/api/docs/page-1dc18f40154cdce6.js +1 -0
  121. package/.next/static/chunks/app/layout-13e3cdaaa416edb6.js +1 -0
  122. package/.next/static/chunks/app/login/page-60d930d64f021753.js +1 -0
  123. package/.next/static/chunks/app/not-found-ae1139bed2018dd8.js +1 -0
  124. package/.next/static/chunks/app/page-583300dd8af66e5a.js +1 -0
  125. package/.next/static/chunks/framework-6e06c675866dc992.js +1 -0
  126. package/.next/static/chunks/main-app-8b0c4a1007dbb7f4.js +1 -0
  127. package/.next/static/chunks/main-e680fb049d7426e1.js +1 -0
  128. package/.next/static/chunks/pages/_app-0c3037849002a4aa.js +1 -0
  129. package/.next/static/chunks/pages/_error-a647cd2c75dc4dc7.js +1 -0
  130. package/.next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
  131. package/.next/static/chunks/webpack-117444a4bfe51057.js +1 -0
  132. package/.next/static/css/8c1b520a38ba4ccd.css +3 -0
  133. package/.next/static/vFM69sDrBUf_9ULwPmVAE/_buildManifest.js +1 -0
  134. package/.next/static/vFM69sDrBUf_9ULwPmVAE/_ssgManifest.js +1 -0
  135. package/README.md +389 -0
  136. package/app/api/auth/route.ts +103 -0
  137. package/app/api/beads/route.ts +27 -0
  138. package/app/api/beads/stream/route.ts +83 -0
  139. package/app/api/config/route.ts +48 -0
  140. package/app/api/docs/page.tsx +497 -0
  141. package/app/api/login/route.ts +42 -0
  142. package/app/api/logout/route.ts +14 -0
  143. package/app/api/oauth/callback/route.ts +97 -0
  144. package/app/api/oauth/client-metadata.json/route.ts +33 -0
  145. package/app/api/oauth/jwks.json/route.ts +32 -0
  146. package/app/api/records/route.ts +168 -0
  147. package/app/api/status/route.ts +25 -0
  148. package/app/api/v1/graph/route.ts +251 -0
  149. package/app/api/v1/issues/[id]/route.ts +158 -0
  150. package/app/api/v1/ready/route.ts +229 -0
  151. package/app/globals.css +230 -0
  152. package/app/layout.tsx +51 -0
  153. package/app/login/page.tsx +164 -0
  154. package/app/not-found.tsx +91 -0
  155. package/app/opengraph-image.png +0 -0
  156. package/app/page.tsx +2041 -0
  157. package/app/twitter-image.png +0 -0
  158. package/bin/heartbeads.mjs +225 -0
  159. package/components/ActivityItem.tsx +326 -0
  160. package/components/ActivityOverlay.tsx +125 -0
  161. package/components/ActivityPanel.tsx +345 -0
  162. package/components/AllCommentsPanel.tsx +270 -0
  163. package/components/AuthButton.tsx +202 -0
  164. package/components/BeadTooltip.tsx +246 -0
  165. package/components/BeadsGraph.tsx +2493 -0
  166. package/components/BeadsLogo.tsx +94 -0
  167. package/components/CommentTooltip.tsx +338 -0
  168. package/components/ContextMenu.tsx +272 -0
  169. package/components/DescriptionModal.tsx +595 -0
  170. package/components/GraphStats.tsx +121 -0
  171. package/components/HeartIcon.tsx +33 -0
  172. package/components/HelpPanel.tsx +339 -0
  173. package/components/MobileActionSheet.tsx +255 -0
  174. package/components/NodeDetail.tsx +793 -0
  175. package/components/SettingsModal.tsx +315 -0
  176. package/components/StatusLegend.tsx +99 -0
  177. package/components/TimelineBar.tsx +116 -0
  178. package/components/TutorialOverlay.tsx +235 -0
  179. package/hooks/useBeadsComments.ts +81 -0
  180. package/hooks/useIsMobile.ts +19 -0
  181. package/lib/activity.ts +377 -0
  182. package/lib/agent.ts +29 -0
  183. package/lib/api-helpers.ts +46 -0
  184. package/lib/auth/client.ts +221 -0
  185. package/lib/auth.tsx +159 -0
  186. package/lib/comments.ts +413 -0
  187. package/lib/diff-beads.ts +128 -0
  188. package/lib/discover.ts +228 -0
  189. package/lib/env.ts +33 -0
  190. package/lib/gate.ts +55 -0
  191. package/lib/parse-beads.ts +234 -0
  192. package/lib/session.ts +52 -0
  193. package/lib/settings.ts +42 -0
  194. package/lib/timeline.ts +138 -0
  195. package/lib/tts.ts +397 -0
  196. package/lib/types.ts +271 -0
  197. package/lib/utils.ts +48 -0
  198. package/lib/watch-beads.ts +97 -0
  199. package/next.config.mjs +4 -0
  200. package/package.json +81 -0
  201. package/postcss.config.mjs +9 -0
  202. package/public/image.png +0 -0
  203. package/scripts/generate-jwk.js +38 -0
  204. package/tailwind.config.ts +41 -0
  205. package/tsconfig.json +24 -0
@@ -0,0 +1,229 @@
1
+ /**
2
+ * GET /api/v1/ready — Actionable issues for AI agents.
3
+ *
4
+ * Returns issues that are ready to work on: open or in_progress
5
+ * with no unresolved blockers. Sorted by priority (critical first).
6
+ * No auth required.
7
+ *
8
+ * Query params:
9
+ * ?unclaimed=true Only unclaimed issues
10
+ * ?type=bug,feature Filter by issue type (comma-separated)
11
+ * ?assignee=handle Filter by assignee
12
+ * ?prefix=beads-map Filter by repo prefix
13
+ * ?limit=20 Max issues returned (default: all)
14
+ */
15
+
16
+ import { loadBeadsData } from "@/lib/parse-beads";
17
+ import { discoverBeadsDir } from "@/lib/discover";
18
+ import { fetchBeadsComments, getClaimedNodes } from "@/lib/comments";
19
+ import type { BeadsComment, ClaimInfo } from "@/lib/comments";
20
+ import type { GraphNode } from "@/lib/types";
21
+ import { jsonResponse, errorResponse, OPTIONS } from "@/lib/api-helpers";
22
+ import { readFileSync } from "fs";
23
+ import { join } from "path";
24
+
25
+ export const dynamic = "force-dynamic";
26
+ export { OPTIONS };
27
+
28
+ let heartbeadsVersion = "0.0.0";
29
+ try {
30
+ const pkg = JSON.parse(
31
+ readFileSync(join(process.cwd(), "package.json"), "utf-8")
32
+ );
33
+ heartbeadsVersion = pkg.version || heartbeadsVersion;
34
+ } catch {
35
+ // ignore
36
+ }
37
+
38
+ function serializeComment(c: BeadsComment): Record<string, unknown> {
39
+ return {
40
+ author: {
41
+ handle: c.handle,
42
+ did: c.did,
43
+ ...(c.displayName ? { displayName: c.displayName } : {}),
44
+ },
45
+ text: c.text,
46
+ createdAt: c.createdAt,
47
+ likes: c.likes.length,
48
+ replies: c.replies.map(serializeComment),
49
+ };
50
+ }
51
+
52
+ export async function GET(request: Request) {
53
+ try {
54
+ const discovery = discoverBeadsDir();
55
+ const url = new URL(request.url);
56
+ const params = url.searchParams;
57
+
58
+ // Parse query params
59
+ const unclaimedOnly = params.get("unclaimed") === "true";
60
+ const typeFilter = params.get("type")?.split(",").filter(Boolean);
61
+ const assigneeFilter = params.get("assignee") || null;
62
+ const prefixFilter = params.get("prefix") || null;
63
+ const limitParam = params.get("limit");
64
+ const limit = limitParam ? Math.max(parseInt(limitParam) || 0, 1) : null;
65
+
66
+ // Load beads data + comments in parallel
67
+ const [beadsData, commentsResult] = await Promise.all([
68
+ Promise.resolve(loadBeadsData(discovery.beadsDir)),
69
+ fetchBeadsComments().catch((err) => {
70
+ console.error("[api/v1/ready] Failed to fetch comments:", err);
71
+ return null;
72
+ }),
73
+ ]);
74
+
75
+ const claims = commentsResult
76
+ ? getClaimedNodes(commentsResult.allComments)
77
+ : new Map<string, ClaimInfo>();
78
+
79
+ // Build status map for blocker resolution
80
+ const nodeMap = new Map<string, GraphNode>(
81
+ beadsData.graphData.nodes.map((n) => [n.id, n])
82
+ );
83
+
84
+ // Filter to "ready" issues:
85
+ // - status is open or in_progress
86
+ // - all blockers have status "closed"
87
+ let readyIssues = beadsData.graphData.nodes.filter((node) => {
88
+ if (node.status !== "open" && node.status !== "in_progress") {
89
+ return false;
90
+ }
91
+
92
+ // Check all blockers are closed
93
+ // blockerIds = IDs of issues that BLOCK this one (i.e. this depends on them)
94
+ // Actually in the GraphNode, blockerIds are "IDs of issues this blocks"
95
+ // and dependentIds are "IDs of issues blocking this"
96
+ // Let me verify: from types.ts comments:
97
+ // blockerCount: number; // issues this blocks
98
+ // dependentCount: number; // issues that depend on this
99
+ // blockerIds: string[]; // IDs of issues this blocks
100
+ // dependentIds: string[]; // IDs of issues blocking this
101
+ //
102
+ // Wait, the naming is confusing. Let me check parse-beads for the actual logic.
103
+ // In the graph: source = depends_on_id (blocker), target = issue_id (blocked)
104
+ // So for issue X:
105
+ // blockerIds = nodes that X blocks (X is upstream of them)
106
+ // dependentIds = nodes that block X (X depends on them, they are upstream)
107
+ //
108
+ // For "ready", we need: all of X's UPSTREAM blockers (dependentIds) are closed
109
+ for (const depId of node.dependentIds) {
110
+ const depNode = nodeMap.get(depId);
111
+ if (depNode && depNode.status !== "closed") {
112
+ return false; // Has an unresolved blocker
113
+ }
114
+ }
115
+
116
+ return true;
117
+ });
118
+
119
+ // Apply filters
120
+ if (typeFilter) {
121
+ readyIssues = readyIssues.filter((n) =>
122
+ typeFilter.includes(n.issueType)
123
+ );
124
+ }
125
+ if (assigneeFilter) {
126
+ readyIssues = readyIssues.filter(
127
+ (n) => n.assignee === assigneeFilter
128
+ );
129
+ }
130
+ if (prefixFilter) {
131
+ readyIssues = readyIssues.filter((n) => n.prefix === prefixFilter);
132
+ }
133
+ if (unclaimedOnly) {
134
+ readyIssues = readyIssues.filter((n) => !claims.has(n.id));
135
+ }
136
+
137
+ // Sort by priority ascending (0=critical first), then created_at ascending (oldest first)
138
+ readyIssues.sort((a, b) => {
139
+ if (a.priority !== b.priority) return a.priority - b.priority;
140
+ return (
141
+ new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
142
+ );
143
+ });
144
+
145
+ // Apply limit
146
+ if (limit) {
147
+ readyIssues = readyIssues.slice(0, limit);
148
+ }
149
+
150
+ // Enrich issues
151
+ const enrichedIssues = readyIssues.map((node) => {
152
+ const nodeComments =
153
+ commentsResult?.commentsByNode.get(node.id) || [];
154
+ const claim = claims.get(node.id) || null;
155
+
156
+ return {
157
+ id: node.id,
158
+ title: node.title,
159
+ description: node.description || null,
160
+ status: node.status,
161
+ priority: node.priority,
162
+ issue_type: node.issueType,
163
+ owner: node.owner || null,
164
+ assignee: node.assignee || null,
165
+ labels: [] as string[],
166
+ created_at: node.createdAt,
167
+ updated_at: node.updatedAt,
168
+ prefix: node.prefix,
169
+ blockers: node.dependentIds, // issues that block this one (upstream)
170
+ dependents: node.blockerIds, // issues this one blocks (downstream)
171
+ comments: nodeComments.map(serializeComment),
172
+ claimed_by: claim
173
+ ? {
174
+ handle: claim.handle,
175
+ did: claim.did,
176
+ ...(claim.displayName
177
+ ? { displayName: claim.displayName }
178
+ : {}),
179
+ claimed_at: claim.claimedAt,
180
+ }
181
+ : null,
182
+ };
183
+ });
184
+
185
+ // Build summary stats
186
+ const byPriority: Record<string, number> = {};
187
+ const byType: Record<string, number> = {};
188
+ let unclaimed = 0;
189
+ for (const issue of readyIssues) {
190
+ const p = String(issue.priority);
191
+ byPriority[p] = (byPriority[p] || 0) + 1;
192
+ byType[issue.issueType] = (byType[issue.issueType] || 0) + 1;
193
+ if (!claims.has(issue.id)) unclaimed++;
194
+ }
195
+
196
+ const warnings: string[] = [];
197
+ if (!commentsResult) {
198
+ warnings.push("Failed to fetch comments from indexer");
199
+ }
200
+
201
+ return jsonResponse({
202
+ issues: enrichedIssues,
203
+ stats: {
204
+ total_ready: enrichedIssues.length,
205
+ unclaimed,
206
+ by_priority: byPriority,
207
+ by_type: byType,
208
+ },
209
+ _meta: {
210
+ generated_at: new Date().toISOString(),
211
+ api_version: "v1" as const,
212
+ heartbeads_version: heartbeadsVersion,
213
+ ...(warnings.length ? { warnings } : {}),
214
+ },
215
+ });
216
+ } catch (error: unknown) {
217
+ const message =
218
+ error instanceof Error ? error.message : "Unknown error";
219
+ if (message.includes("No .beads/ directory found")) {
220
+ return errorResponse(
221
+ "No .beads directory found",
222
+ 404,
223
+ "Run bd init in your project first, or set BEADS_DIR."
224
+ );
225
+ }
226
+ console.error("[api/v1/ready] Error:", error);
227
+ return errorResponse("Internal server error", 500);
228
+ }
229
+ }
@@ -0,0 +1,230 @@
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ /* ============================================================================
6
+ Beads Map - Custom Styles
7
+ Design: Clean, minimal graph visualization
8
+ ============================================================================ */
9
+
10
+ @layer base {
11
+ html,
12
+ body {
13
+ @apply h-full overflow-hidden;
14
+ }
15
+
16
+ body {
17
+ @apply bg-white text-zinc-800 antialiased;
18
+ font-feature-settings: "rlig" 1, "calt" 1;
19
+ }
20
+ }
21
+
22
+ @layer components {
23
+ /* Card with subtle hover lift */
24
+ .card-lift {
25
+ @apply transition-all duration-200 ease-out;
26
+ }
27
+ .card-lift:hover {
28
+ @apply shadow-md -translate-y-0.5;
29
+ }
30
+
31
+ /* Status badge base */
32
+ .status-badge {
33
+ @apply inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium;
34
+ }
35
+
36
+ /* Scrollbar styling */
37
+ .custom-scrollbar::-webkit-scrollbar {
38
+ width: 4px;
39
+ }
40
+ .custom-scrollbar::-webkit-scrollbar-track {
41
+ @apply bg-transparent;
42
+ }
43
+ .custom-scrollbar::-webkit-scrollbar-thumb {
44
+ @apply bg-zinc-200 rounded-full;
45
+ }
46
+ .custom-scrollbar::-webkit-scrollbar-thumb:hover {
47
+ @apply bg-zinc-300;
48
+ }
49
+ }
50
+
51
+ @layer utilities {
52
+ /* Fade in animation */
53
+ @keyframes fadeIn {
54
+ from {
55
+ opacity: 0;
56
+ transform: translateY(8px);
57
+ }
58
+ to {
59
+ opacity: 1;
60
+ transform: translateY(0);
61
+ }
62
+ }
63
+ .animate-fade-in {
64
+ animation: fadeIn 0.3s ease-out forwards;
65
+ }
66
+
67
+ /* Pulse subtle */
68
+ @keyframes pulseSoft {
69
+ 0%,
70
+ 100% {
71
+ opacity: 1;
72
+ }
73
+ 50% {
74
+ opacity: 0.6;
75
+ }
76
+ }
77
+ .animate-pulse-soft {
78
+ animation: pulseSoft 2s ease-in-out infinite;
79
+ }
80
+
81
+ /* Bead tooltip fade-in */
82
+ @keyframes beadTooltipFade {
83
+ from {
84
+ opacity: 0;
85
+ transform: translateY(4px);
86
+ }
87
+ to {
88
+ opacity: 1;
89
+ transform: translateY(0);
90
+ }
91
+ }
92
+
93
+ /* Mobile slide-in drawer from right */
94
+ @keyframes slideInRight {
95
+ from {
96
+ transform: translateX(100%);
97
+ }
98
+ to {
99
+ transform: translateX(0);
100
+ }
101
+ }
102
+ .animate-slide-in-right {
103
+ animation: slideInRight 0.25s ease-out;
104
+ }
105
+
106
+ /* Mobile bottom action sheet slide-up */
107
+ @keyframes slideUpSheet {
108
+ from {
109
+ transform: translateY(100%);
110
+ }
111
+ to {
112
+ transform: translateY(0);
113
+ }
114
+ }
115
+ .animate-slide-up-sheet {
116
+ animation: slideUpSheet 0.2s ease-out;
117
+ }
118
+ }
119
+
120
+ /* Markdown prose inside description box */
121
+ .description-markdown h1,
122
+ .description-markdown h2,
123
+ .description-markdown h3,
124
+ .description-markdown h4 {
125
+ @apply font-semibold text-zinc-700 mt-2 mb-1;
126
+ }
127
+ .description-markdown h1 { font-size: 0.85rem; }
128
+ .description-markdown h2 { font-size: 0.8rem; }
129
+ .description-markdown h3 { font-size: 0.75rem; }
130
+ .description-markdown h4 { font-size: 0.7rem; }
131
+
132
+ .description-markdown p {
133
+ @apply mb-1.5;
134
+ }
135
+ .description-markdown p:last-child {
136
+ @apply mb-0;
137
+ }
138
+
139
+ .description-markdown ul,
140
+ .description-markdown ol {
141
+ @apply pl-4 mb-1.5 space-y-0.5;
142
+ }
143
+ .description-markdown ul { @apply list-disc; }
144
+ .description-markdown ol { @apply list-decimal; }
145
+
146
+ .description-markdown code {
147
+ @apply bg-zinc-200/60 text-zinc-700 px-1 py-0.5 rounded text-[10px] font-mono;
148
+ }
149
+ .description-markdown pre {
150
+ @apply bg-zinc-200/60 rounded-md p-2 mb-1.5 overflow-x-auto;
151
+ }
152
+ .description-markdown pre code {
153
+ @apply bg-transparent p-0 text-[10px];
154
+ }
155
+
156
+ .description-markdown a {
157
+ @apply text-emerald-600 underline underline-offset-2 hover:text-emerald-700;
158
+ }
159
+
160
+ .description-markdown blockquote {
161
+ @apply border-l-2 border-zinc-300 pl-2 text-zinc-500 italic my-1.5;
162
+ }
163
+
164
+ .description-markdown table {
165
+ @apply w-full text-[10px] border-collapse mb-1.5;
166
+ }
167
+ .description-markdown th {
168
+ @apply text-left font-semibold text-zinc-600 border-b border-zinc-200 pb-0.5 pr-2;
169
+ }
170
+ .description-markdown td {
171
+ @apply border-b border-zinc-100 py-0.5 pr-2;
172
+ }
173
+
174
+ .description-markdown hr {
175
+ @apply border-zinc-200 my-2;
176
+ }
177
+
178
+ .description-markdown strong {
179
+ @apply font-semibold text-zinc-700;
180
+ }
181
+
182
+ .description-markdown img {
183
+ @apply max-w-full rounded;
184
+ }
185
+
186
+ /* Timeline slider custom styling */
187
+ .timeline-slider {
188
+ -webkit-appearance: none;
189
+ appearance: none;
190
+ background: transparent;
191
+ cursor: pointer;
192
+ }
193
+ .timeline-slider::-webkit-slider-runnable-track {
194
+ height: 4px;
195
+ background: #e4e4e7; /* zinc-200 */
196
+ border-radius: 2px;
197
+ }
198
+ .timeline-slider::-webkit-slider-thumb {
199
+ -webkit-appearance: none;
200
+ width: 12px;
201
+ height: 12px;
202
+ background: #10b981; /* emerald-500 */
203
+ border-radius: 50%;
204
+ margin-top: -4px;
205
+ cursor: pointer;
206
+ transition: transform 0.1s ease;
207
+ }
208
+ .timeline-slider::-webkit-slider-thumb:hover {
209
+ transform: scale(1.2);
210
+ }
211
+ .timeline-slider::-moz-range-track {
212
+ height: 4px;
213
+ background: #e4e4e7;
214
+ border-radius: 2px;
215
+ border: none;
216
+ }
217
+ .timeline-slider::-moz-range-thumb {
218
+ width: 12px;
219
+ height: 12px;
220
+ background: #10b981;
221
+ border-radius: 50%;
222
+ border: none;
223
+ cursor: pointer;
224
+ }
225
+
226
+ /* Force-graph container overrides */
227
+ .force-graph-container canvas {
228
+ @apply rounded-lg;
229
+ touch-action: none; /* Prevent browser gestures from conflicting with graph pan/zoom */
230
+ }
package/app/layout.tsx ADDED
@@ -0,0 +1,51 @@
1
+ import type { Metadata, Viewport } from "next";
2
+ import { AuthProvider } from "@/lib/auth";
3
+ import "./globals.css";
4
+
5
+ export const metadata: Metadata = {
6
+ metadataBase: new URL(process.env.PUBLIC_URL || "http://localhost:3000"),
7
+ title: "Heartbeads",
8
+ description:
9
+ "Interactive dependency graph viewer for beads issues — see your project's heartbeat",
10
+ openGraph: {
11
+ title: "Heartbeads",
12
+ description: "Interactive dependency graph viewer for beads issues — see your project's heartbeat",
13
+ type: "website",
14
+ siteName: "Heartbeads",
15
+ images: [
16
+ {
17
+ url: "/image.png",
18
+ width: 1200,
19
+ height: 630,
20
+ alt: "Heartbeads — interactive dependency graph viewer",
21
+ },
22
+ ],
23
+ },
24
+ twitter: {
25
+ card: "summary_large_image",
26
+ title: "Heartbeads",
27
+ description: "Interactive dependency graph viewer for beads issues — see your project's heartbeat",
28
+ images: ["/image.png"],
29
+ },
30
+ };
31
+
32
+ export const viewport: Viewport = {
33
+ width: "device-width",
34
+ initialScale: 1,
35
+ maximumScale: 1,
36
+ userScalable: false,
37
+ };
38
+
39
+ export default function RootLayout({
40
+ children,
41
+ }: {
42
+ children: React.ReactNode;
43
+ }) {
44
+ return (
45
+ <html lang="en">
46
+ <body>
47
+ <AuthProvider>{children}</AuthProvider>
48
+ </body>
49
+ </html>
50
+ );
51
+ }
@@ -0,0 +1,164 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect, useRef } from "react";
4
+ import { BeadsLogo } from "@/components/BeadsLogo";
5
+
6
+ /**
7
+ * Password login page for dashboard access control.
8
+ *
9
+ * Shown when heartbeads is started with --password flag.
10
+ * Matches the design language of the 404 page and loading screen:
11
+ * ECG flatline + animated heartbeat logo, clean centered form.
12
+ *
13
+ * This is NOT the ATProto/Bluesky sign-in (that's in the navbar).
14
+ * This gate controls who can view the dashboard and API.
15
+ */
16
+ export default function LoginPage() {
17
+ const [password, setPassword] = useState("");
18
+ const [error, setError] = useState("");
19
+ const [loading, setLoading] = useState(false);
20
+ const [from, setFrom] = useState("/");
21
+ const inputRef = useRef<HTMLInputElement>(null);
22
+
23
+ // Read ?from= query param for post-login redirect (avoid useSearchParams / Suspense)
24
+ useEffect(() => {
25
+ const params = new URLSearchParams(window.location.search);
26
+ const fromParam = params.get("from");
27
+ if (fromParam) setFrom(fromParam);
28
+
29
+ // Auto-focus the password input
30
+ inputRef.current?.focus();
31
+ }, []);
32
+
33
+ async function handleSubmit(e: React.FormEvent) {
34
+ e.preventDefault();
35
+ if (!password.trim()) return;
36
+
37
+ setError("");
38
+ setLoading(true);
39
+
40
+ try {
41
+ const res = await fetch("/api/auth", {
42
+ method: "POST",
43
+ headers: { "Content-Type": "application/json" },
44
+ body: JSON.stringify({ password: password.trim() }),
45
+ });
46
+
47
+ if (res.ok) {
48
+ window.location.href = from;
49
+ } else {
50
+ const data = await res.json();
51
+ setError(data.error || "Invalid password");
52
+ }
53
+ } catch {
54
+ setError("Connection error");
55
+ } finally {
56
+ setLoading(false);
57
+ }
58
+ }
59
+
60
+ return (
61
+ <div className="h-screen flex flex-col items-center justify-center bg-white text-zinc-800 px-6 select-none">
62
+ {/* ECG flatline + animated heartbeat logo */}
63
+ <div className="relative w-full max-w-xs mb-6">
64
+ <svg
65
+ viewBox="0 0 320 40"
66
+ fill="none"
67
+ className="w-full text-zinc-200"
68
+ aria-hidden="true"
69
+ >
70
+ <line
71
+ x1="0"
72
+ y1="20"
73
+ x2="320"
74
+ y2="20"
75
+ stroke="currentColor"
76
+ strokeWidth="1.5"
77
+ strokeLinecap="round"
78
+ strokeDasharray="4 6"
79
+ opacity="0.6"
80
+ />
81
+ </svg>
82
+ <div className="absolute inset-0 flex items-center justify-center">
83
+ <div className="bg-white px-4">
84
+ <BeadsLogo className="w-10 h-10 text-emerald-500" />
85
+ </div>
86
+ </div>
87
+ </div>
88
+
89
+ {/* Title */}
90
+ <h1
91
+ className="text-lg font-semibold text-zinc-900 mb-1 animate-fade-in"
92
+ >
93
+ heartbeads
94
+ </h1>
95
+ <p
96
+ className="text-sm text-zinc-400 mb-6 animate-fade-in"
97
+ style={{ animationDelay: "0.05s" }}
98
+ >
99
+ This dashboard is password-protected.
100
+ </p>
101
+
102
+ {/* Login form */}
103
+ <form
104
+ onSubmit={handleSubmit}
105
+ className="w-full max-w-xs animate-fade-in"
106
+ style={{ animationDelay: "0.1s" }}
107
+ >
108
+ <input
109
+ ref={inputRef}
110
+ type="password"
111
+ value={password}
112
+ onChange={(e) => {
113
+ setPassword(e.target.value);
114
+ if (error) setError("");
115
+ }}
116
+ placeholder="Enter password"
117
+ className="w-full rounded-lg border border-zinc-200 bg-zinc-50 px-4 py-2.5 text-sm text-zinc-900 placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-emerald-500/30 focus:border-emerald-500 transition-colors"
118
+ autoComplete="current-password"
119
+ disabled={loading}
120
+ />
121
+
122
+ {/* Error message */}
123
+ {error && (
124
+ <p className="mt-2 text-xs text-red-500 animate-fade-in">
125
+ {error}
126
+ </p>
127
+ )}
128
+
129
+ <button
130
+ type="submit"
131
+ disabled={loading || !password.trim()}
132
+ className="mt-3 w-full inline-flex items-center justify-center gap-2 px-5 py-2.5 rounded-full bg-emerald-500 text-white text-sm font-medium shadow-sm hover:bg-emerald-600 hover:shadow-md transition-all duration-200 ease-out disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-emerald-500 disabled:hover:shadow-sm"
133
+ >
134
+ {loading ? (
135
+ <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
136
+ ) : (
137
+ <svg
138
+ className="w-4 h-4"
139
+ fill="none"
140
+ viewBox="0 0 24 24"
141
+ strokeWidth={1.5}
142
+ stroke="currentColor"
143
+ >
144
+ <path
145
+ strokeLinecap="round"
146
+ strokeLinejoin="round"
147
+ d="M13.5 10.5V6.75a4.5 4.5 0 119 0v3.75M3.75 21.75h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H3.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"
148
+ />
149
+ </svg>
150
+ )}
151
+ {loading ? "Unlocking..." : "Unlock"}
152
+ </button>
153
+ </form>
154
+
155
+ {/* Footer */}
156
+ <p
157
+ className="mt-10 text-[11px] text-zinc-300 animate-fade-in"
158
+ style={{ animationDelay: "0.15s" }}
159
+ >
160
+ password protection enabled
161
+ </p>
162
+ </div>
163
+ );
164
+ }