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,202 @@
1
+ "use client";
2
+
3
+ import { useState, useRef, useEffect } from "react";
4
+ import { useAuth } from "@/lib/auth";
5
+
6
+ export function AuthButton() {
7
+ const { isAuthenticated, isLoading, session, login, logout } = useAuth();
8
+ const [showModal, setShowModal] = useState(false);
9
+ const [showDropdown, setShowDropdown] = useState(false);
10
+ const [handle, setHandle] = useState("");
11
+ const [isSubmitting, setIsSubmitting] = useState(false);
12
+ const [error, setError] = useState("");
13
+ const dropdownRef = useRef<HTMLDivElement>(null);
14
+
15
+ // Close dropdown on outside click
16
+ useEffect(() => {
17
+ if (!showDropdown) return;
18
+
19
+ const handleClickOutside = (e: MouseEvent) => {
20
+ if (
21
+ dropdownRef.current &&
22
+ !dropdownRef.current.contains(e.target as Node)
23
+ ) {
24
+ setShowDropdown(false);
25
+ }
26
+ };
27
+
28
+ document.addEventListener("mousedown", handleClickOutside);
29
+ return () => document.removeEventListener("mousedown", handleClickOutside);
30
+ }, [showDropdown]);
31
+
32
+ const handleLogin = async (e: React.FormEvent) => {
33
+ e.preventDefault();
34
+ if (!handle.trim()) return;
35
+
36
+ setIsSubmitting(true);
37
+ setError("");
38
+
39
+ try {
40
+ await login(handle.trim());
41
+ } catch (err) {
42
+ setError(err instanceof Error ? err.message : "Login failed");
43
+ setIsSubmitting(false);
44
+ }
45
+ };
46
+
47
+ const handleLogout = async () => {
48
+ setShowDropdown(false);
49
+ await logout();
50
+ };
51
+
52
+ if (isLoading) {
53
+ return (
54
+ <div className="w-4 h-4 rounded-full border-2 border-zinc-200 border-t-zinc-400 animate-spin" />
55
+ );
56
+ }
57
+
58
+ if (isAuthenticated && session) {
59
+ return (
60
+ <div className="relative" ref={dropdownRef}>
61
+ <button
62
+ onClick={() => setShowDropdown((prev) => !prev)}
63
+ className="flex items-center gap-2 px-3 py-1.5 rounded-full hover:bg-zinc-50 transition-colors cursor-pointer"
64
+ >
65
+ {session.avatar ? (
66
+ /* eslint-disable-next-line @next/next/no-img-element */
67
+ <img
68
+ src={session.avatar}
69
+ alt={session.handle}
70
+ width={24}
71
+ height={24}
72
+ className="rounded-full"
73
+ />
74
+ ) : (
75
+ <div className="w-6 h-6 rounded-full bg-emerald-100 flex items-center justify-center text-[11px] text-emerald-700 font-medium">
76
+ {(session.displayName || session.handle).charAt(0).toUpperCase()}
77
+ </div>
78
+ )}
79
+ <span className="text-sm text-zinc-600 max-w-[100px] truncate">
80
+ {session.displayName || session.handle}
81
+ </span>
82
+ </button>
83
+
84
+ {showDropdown && (
85
+ <div className="absolute right-0 top-full mt-2 w-44 bg-white rounded-xl shadow-xl border border-zinc-100 py-2 z-50">
86
+ <a
87
+ href={session.did ? `https://www.impactindexer.org/data?did=${session.did}` : "#"}
88
+ target="_blank"
89
+ rel="noopener noreferrer"
90
+ className="block px-3 py-1.5 text-xs text-zinc-400 truncate hover:text-emerald-600 transition-colors"
91
+ >
92
+ @{session.handle}
93
+ </a>
94
+ <div className="h-px bg-zinc-100 my-1" />
95
+ <button
96
+ onClick={handleLogout}
97
+ className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-zinc-600 hover:bg-zinc-50 transition-colors cursor-pointer"
98
+ >
99
+ <svg
100
+ className="w-3.5 h-3.5 text-zinc-400"
101
+ fill="none"
102
+ viewBox="0 0 24 24"
103
+ strokeWidth={1.5}
104
+ stroke="currentColor"
105
+ >
106
+ <path
107
+ strokeLinecap="round"
108
+ strokeLinejoin="round"
109
+ d="M8.25 9V5.25A2.25 2.25 0 0 1 10.5 3h6a2.25 2.25 0 0 1 2.25 2.25v13.5A2.25 2.25 0 0 1 16.5 21h-6a2.25 2.25 0 0 1-2.25-2.25V15m-3 0-3-3m0 0 3-3m-3 3H15"
110
+ />
111
+ </svg>
112
+ Sign out
113
+ </button>
114
+ </div>
115
+ )}
116
+ </div>
117
+ );
118
+ }
119
+
120
+ return (
121
+ <>
122
+ <button
123
+ onClick={() => setShowModal(true)}
124
+ className="px-4 py-1.5 text-sm font-medium text-zinc-600 rounded-full hover:text-zinc-900 hover:bg-zinc-50 transition-colors cursor-pointer whitespace-nowrap"
125
+ >
126
+ Sign in
127
+ </button>
128
+
129
+ {showModal && (
130
+ <div
131
+ className="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]"
132
+ onClick={() => setShowModal(false)}
133
+ >
134
+ {/* Backdrop */}
135
+ <div className="absolute inset-0 bg-black/20 backdrop-blur-sm" />
136
+
137
+ {/* Modal */}
138
+ <div
139
+ className="relative w-full max-w-sm mx-4 bg-white rounded-xl shadow-lg border border-zinc-200 p-6"
140
+ onClick={(e) => e.stopPropagation()}
141
+ >
142
+ <h2 className="text-lg font-semibold text-zinc-900 mb-1">
143
+ Sign in with ATProto
144
+ </h2>
145
+ <p className="text-sm text-zinc-400 mb-5">
146
+ Enter your Bluesky handle to connect.
147
+ </p>
148
+
149
+ <form onSubmit={handleLogin}>
150
+ <label
151
+ htmlFor="auth-handle"
152
+ className="block text-sm text-zinc-600 mb-1.5"
153
+ >
154
+ Handle
155
+ </label>
156
+ <input
157
+ id="auth-handle"
158
+ type="text"
159
+ value={handle}
160
+ onChange={(e) => setHandle(e.target.value)}
161
+ placeholder="alice.bsky.social"
162
+ disabled={isSubmitting}
163
+ autoFocus
164
+ className="w-full px-3 py-2 text-sm bg-white border border-zinc-200 rounded-lg
165
+ placeholder:text-zinc-300
166
+ focus:outline-none focus:ring-2 focus:ring-emerald-500/30 focus:border-emerald-400
167
+ disabled:opacity-50 disabled:cursor-not-allowed"
168
+ />
169
+ <p className="text-xs text-zinc-300 mt-1.5">
170
+ Just a username? We&apos;ll add .bsky.social for you.
171
+ </p>
172
+
173
+ {error && <p className="text-sm text-red-500 mt-2">{error}</p>}
174
+
175
+ <div className="flex gap-2 mt-5">
176
+ <button
177
+ type="button"
178
+ onClick={() => setShowModal(false)}
179
+ disabled={isSubmitting}
180
+ className="flex-1 px-3 py-2 text-sm text-zinc-600 bg-zinc-50 rounded-lg
181
+ hover:bg-zinc-100 transition-colors
182
+ disabled:opacity-50 disabled:cursor-not-allowed"
183
+ >
184
+ Cancel
185
+ </button>
186
+ <button
187
+ type="submit"
188
+ disabled={isSubmitting || !handle.trim()}
189
+ className="flex-1 px-3 py-2 text-sm text-white bg-emerald-600 rounded-lg
190
+ hover:bg-emerald-700 transition-colors
191
+ disabled:opacity-50 disabled:cursor-not-allowed"
192
+ >
193
+ {isSubmitting ? "Connecting..." : "Connect"}
194
+ </button>
195
+ </div>
196
+ </form>
197
+ </div>
198
+ </div>
199
+ )}
200
+ </>
201
+ );
202
+ }
@@ -0,0 +1,246 @@
1
+ "use client";
2
+
3
+ import { useRef, useState, useEffect } from "react";
4
+ import { GraphNode, PRIORITY_LABELS, PRIORITY_COLORS, getPrefixLabel } from "@/lib/types";
5
+ import { formatRelativeTime } from "@/lib/utils";
6
+
7
+ interface BeadTooltipProps {
8
+ node: GraphNode;
9
+ x: number;
10
+ y: number;
11
+ prefixColor: string;
12
+ allNodes: GraphNode[];
13
+ }
14
+
15
+ const COLORS = {
16
+ bg: "#FFFFFF",
17
+ border: "#E5E7EB",
18
+ borderLight: "#F3F4F6",
19
+ shadow: "rgba(0,0,0,0.08)",
20
+ text: "#131316",
21
+ textMuted: "#737680",
22
+ textDim: "#7F818B",
23
+ };
24
+
25
+ export function BeadTooltip({ node, x, y, prefixColor, allNodes }: BeadTooltipProps) {
26
+ const ref = useRef<HTMLDivElement>(null);
27
+ const [pos, setPos] = useState({ x: 0, y: 0 });
28
+
29
+ useEffect(() => {
30
+ if (!ref.current) return;
31
+ const tt = ref.current.getBoundingClientRect();
32
+ const pad = 14;
33
+
34
+ // Prefer placing above and to the right of cursor
35
+ let nx = x + pad;
36
+ let ny = y - tt.height - pad;
37
+
38
+ // Clamp to viewport edges
39
+ if (nx + tt.width > window.innerWidth - 16) {
40
+ nx = x - tt.width - pad;
41
+ }
42
+ if (nx < 16) nx = 16;
43
+ if (ny < 16) {
44
+ ny = y + pad + 8;
45
+ }
46
+ if (ny + tt.height > window.innerHeight - 16) {
47
+ ny = window.innerHeight - tt.height - 16;
48
+ }
49
+
50
+ setPos({ x: nx, y: ny });
51
+ }, [x, y]);
52
+
53
+ // Resolve blocker IDs to short titles
54
+ const blockers = node.dependentIds
55
+ .map((id) => {
56
+ const blockerNode = allNodes.find((n) => n.id === id);
57
+ return blockerNode ? { id, title: blockerNode.title } : { id, title: id };
58
+ })
59
+ .filter(Boolean);
60
+
61
+ const priorityLabel = PRIORITY_LABELS[node.priority] || `P${node.priority}`;
62
+ const priorityColor = PRIORITY_COLORS[node.priority] || "#a1a1aa";
63
+
64
+ return (
65
+ <div
66
+ ref={ref}
67
+ className="bead-tooltip"
68
+ style={{
69
+ position: "fixed",
70
+ left: pos.x,
71
+ top: pos.y,
72
+ width: 280,
73
+ zIndex: 100,
74
+ pointerEvents: "none",
75
+ background: COLORS.bg,
76
+ border: `1px solid ${COLORS.border}`,
77
+ boxShadow: `0 8px 32px ${COLORS.shadow}, 0 2px 8px ${COLORS.shadow}`,
78
+ borderRadius: 8,
79
+ padding: "16px 18px",
80
+ animation: "beadTooltipFade 0.2s ease",
81
+ }}
82
+ >
83
+ {/* Accent bar */}
84
+ <div
85
+ style={{
86
+ width: 24,
87
+ height: 2,
88
+ background: prefixColor,
89
+ opacity: 0.5,
90
+ marginBottom: 8,
91
+ borderRadius: 1,
92
+ }}
93
+ />
94
+
95
+ {/* Prefix + ID */}
96
+ <div style={{ display: "flex", alignItems: "center", gap: 6, marginBottom: 6 }}>
97
+ <span
98
+ style={{
99
+ fontSize: 10,
100
+ fontWeight: 600,
101
+ color: prefixColor,
102
+ letterSpacing: 0.5,
103
+ textTransform: "uppercase",
104
+ }}
105
+ >
106
+ {getPrefixLabel(node.prefix)}
107
+ </span>
108
+ <span
109
+ style={{
110
+ fontSize: 10,
111
+ fontFamily: "monospace",
112
+ color: COLORS.textDim,
113
+ }}
114
+ >
115
+ {node.id}
116
+ </span>
117
+ </div>
118
+
119
+ {/* Title */}
120
+ <div
121
+ style={{
122
+ fontSize: 14,
123
+ fontWeight: 600,
124
+ color: COLORS.text,
125
+ lineHeight: 1.25,
126
+ marginBottom: 12,
127
+ overflow: "hidden",
128
+ display: "-webkit-box",
129
+ WebkitLineClamp: 2,
130
+ WebkitBoxOrient: "vertical",
131
+ }}
132
+ >
133
+ {node.title}
134
+ </div>
135
+
136
+ {/* Metadata rows */}
137
+ <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
138
+ {/* Created */}
139
+ <div>
140
+ <div style={labelStyle}>Created</div>
141
+ <div style={valueStyle}>{formatRelativeTime(node.createdAt)}</div>
142
+ </div>
143
+
144
+ {/* Owner */}
145
+ {node.owner && (
146
+ <div>
147
+ <div style={labelStyle}>Owner</div>
148
+ <div style={valueStyle}>{node.owner}</div>
149
+ </div>
150
+ )}
151
+
152
+ {/* Assignee */}
153
+ {(node.assignee || node.createdBy) && (
154
+ <div>
155
+ {node.assignee && (
156
+ <>
157
+ <div style={labelStyle}>Assignee</div>
158
+ <div style={valueStyle}>{node.assignee}</div>
159
+ </>
160
+ )}
161
+ {node.createdBy && !node.assignee && (
162
+ <>
163
+ <div style={labelStyle}>Created by</div>
164
+ <div style={valueStyle}>{node.createdBy}</div>
165
+ </>
166
+ )}
167
+ {node.createdBy && node.assignee && node.createdBy !== node.assignee && (
168
+ <>
169
+ <div style={{ ...labelStyle, marginTop: 4 }}>Created by</div>
170
+ <div style={valueStyle}>{node.createdBy}</div>
171
+ </>
172
+ )}
173
+ </div>
174
+ )}
175
+
176
+ {/* Blocked by */}
177
+ <div>
178
+ <div style={labelStyle}>Blocked by</div>
179
+ {blockers.length === 0 ? (
180
+ <div style={{ ...valueStyle, color: "#10b981" }}>None</div>
181
+ ) : (
182
+ <div style={{ display: "flex", flexDirection: "column", gap: 3 }}>
183
+ {blockers.map((b) => (
184
+ <div key={b.id} style={{ display: "flex", alignItems: "baseline", gap: 6 }}>
185
+ <span
186
+ style={{
187
+ fontSize: 10,
188
+ fontFamily: "monospace",
189
+ color: COLORS.textDim,
190
+ flexShrink: 0,
191
+ }}
192
+ >
193
+ {b.id}
194
+ </span>
195
+ <span
196
+ style={{
197
+ fontSize: 12,
198
+ color: COLORS.textMuted,
199
+ overflow: "hidden",
200
+ textOverflow: "ellipsis",
201
+ whiteSpace: "nowrap",
202
+ }}
203
+ >
204
+ {b.title}
205
+ </span>
206
+ </div>
207
+ ))}
208
+ </div>
209
+ )}
210
+ </div>
211
+
212
+ {/* Priority */}
213
+ <div style={{ borderTop: `1px solid ${COLORS.borderLight}`, paddingTop: 8 }}>
214
+ <div style={labelStyle}>Priority</div>
215
+ <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
216
+ <div
217
+ style={{
218
+ width: 8,
219
+ height: 8,
220
+ borderRadius: "50%",
221
+ background: priorityColor,
222
+ flexShrink: 0,
223
+ }}
224
+ />
225
+ <span style={valueStyle}>{priorityLabel}</span>
226
+ </div>
227
+ </div>
228
+ </div>
229
+ </div>
230
+ );
231
+ }
232
+
233
+ const labelStyle: React.CSSProperties = {
234
+ fontSize: 10,
235
+ fontWeight: 600,
236
+ letterSpacing: 1.5,
237
+ textTransform: "uppercase",
238
+ color: "#7F818B",
239
+ marginBottom: 3,
240
+ };
241
+
242
+ const valueStyle: React.CSSProperties = {
243
+ fontSize: 12,
244
+ color: "#737680",
245
+ lineHeight: 1.45,
246
+ };