create-feedbax 0.1.0 → 0.2.1

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 (251) hide show
  1. package/LICENSE +201 -21
  2. package/README.md +11 -1
  3. package/dist/cli.js +3283 -22
  4. package/dist/index.js +3394 -160
  5. package/package.json +40 -14
  6. package/template/.feedbax-template.sha256 +1 -0
  7. package/template/.gitignore.template +6 -0
  8. package/template/.npmignore.template +5 -0
  9. package/template/.prettierignore.template +4 -0
  10. package/template/AGENTS.md +13 -0
  11. package/template/CONNECTED.md +404 -0
  12. package/template/LICENSE +201 -0
  13. package/template/README.md +56 -5
  14. package/template/components.json +25 -0
  15. package/template/eslint.config.mjs +48 -0
  16. package/template/migrations/0001_connected.sql +6 -0
  17. package/template/migrations/0002_participation.sql +8 -0
  18. package/template/migrations/0003_images.sql +3 -0
  19. package/template/migrations/0004_followed_status.sql +9 -0
  20. package/template/package.json +74 -14
  21. package/template/public/feedbax-mark.svg +4 -0
  22. package/template/public/robots.txt +3 -0
  23. package/template/src/authorized-draft-post.test.tsx +212 -0
  24. package/template/src/authorized-draft-post.tsx +205 -0
  25. package/template/src/authorized-draft-query.ts +74 -0
  26. package/template/src/browser-post-state.test.ts +120 -0
  27. package/template/src/browser-post-state.ts +134 -0
  28. package/template/src/browser-vote-state.test.ts +14 -0
  29. package/template/src/browser-vote-state.ts +27 -0
  30. package/template/src/capability-authorization-error.ts +3 -0
  31. package/template/src/changelog-page.test.tsx +139 -0
  32. package/template/src/changelog-page.tsx +228 -0
  33. package/template/src/changelog-query.ts +13 -0
  34. package/template/src/changelog-runtime.ts +27 -0
  35. package/template/src/cloudflare-turnstile.ts +31 -0
  36. package/template/src/cloudflare-workers-test.ts +3 -0
  37. package/template/src/cloudflare-workers.d.ts +24 -0
  38. package/template/src/comment-creation.test.tsx +311 -0
  39. package/template/src/comment-profile.ts +5 -0
  40. package/template/src/comment-thread-page.ts +28 -0
  41. package/template/src/components/command-palette-context.tsx +71 -0
  42. package/template/src/components/command-palette.tsx +275 -0
  43. package/template/src/components/connected-identity-provider.tsx +108 -0
  44. package/template/src/components/create-post-trigger.tsx +44 -0
  45. package/template/src/components/device-profile-provider.tsx +324 -0
  46. package/template/src/components/follow-post-button.tsx +89 -0
  47. package/template/src/components/header.tsx +97 -0
  48. package/template/src/components/image-attachments.tsx +187 -0
  49. package/template/src/components/loader.tsx +53 -0
  50. package/template/src/components/notification-inbox.tsx +232 -0
  51. package/template/src/components/portal-footer.tsx +14 -0
  52. package/template/src/components/profile-menu.tsx +119 -0
  53. package/template/src/components/ui/components/alert-dialog.tsx +185 -0
  54. package/template/src/components/ui/components/attachment.tsx +207 -0
  55. package/template/src/components/ui/components/avatar.tsx +107 -0
  56. package/template/src/components/ui/components/badge.tsx +52 -0
  57. package/template/src/components/ui/components/bubble.tsx +128 -0
  58. package/template/src/components/ui/components/button-group.tsx +87 -0
  59. package/template/src/components/ui/components/button.tsx +56 -0
  60. package/template/src/components/ui/components/card.tsx +103 -0
  61. package/template/src/components/ui/components/checkbox.tsx +26 -0
  62. package/template/src/components/ui/components/command.tsx +195 -0
  63. package/template/src/components/ui/components/dialog.tsx +154 -0
  64. package/template/src/components/ui/components/drawer.tsx +229 -0
  65. package/template/src/components/ui/components/dropdown-menu.tsx +270 -0
  66. package/template/src/components/ui/components/empty.tsx +101 -0
  67. package/template/src/components/ui/components/field.tsx +236 -0
  68. package/template/src/components/ui/components/input-group.tsx +155 -0
  69. package/template/src/components/ui/components/input.tsx +20 -0
  70. package/template/src/components/ui/components/item.tsx +201 -0
  71. package/template/src/components/ui/components/kbd.tsx +26 -0
  72. package/template/src/components/ui/components/label.tsx +18 -0
  73. package/template/src/components/ui/components/marker.tsx +71 -0
  74. package/template/src/components/ui/components/message-scroller.tsx +128 -0
  75. package/template/src/components/ui/components/message.tsx +92 -0
  76. package/template/src/components/ui/components/native-select.tsx +65 -0
  77. package/template/src/components/ui/components/popover.tsx +88 -0
  78. package/template/src/components/ui/components/portal-primitives.test.tsx +85 -0
  79. package/template/src/components/ui/components/radio-group.tsx +36 -0
  80. package/template/src/components/ui/components/scroll-area.tsx +51 -0
  81. package/template/src/components/ui/components/secondary-form-actions.test.tsx +23 -0
  82. package/template/src/components/ui/components/select.tsx +199 -0
  83. package/template/src/components/ui/components/separator.tsx +23 -0
  84. package/template/src/components/ui/components/sheet.tsx +135 -0
  85. package/template/src/components/ui/components/skeleton.tsx +13 -0
  86. package/template/src/components/ui/components/sonner.tsx +43 -0
  87. package/template/src/components/ui/components/spinner.tsx +16 -0
  88. package/template/src/components/ui/components/switch.tsx +29 -0
  89. package/template/src/components/ui/components/tabs.tsx +80 -0
  90. package/template/src/components/ui/components/textarea.tsx +18 -0
  91. package/template/src/components/ui/components/tooltip.tsx +64 -0
  92. package/template/src/components/ui/hooks/.gitkeep +0 -0
  93. package/template/src/components/ui/lib/utils.ts +6 -0
  94. package/template/src/components/ui/styles/globals.css +151 -0
  95. package/template/src/connected-identity.test.ts +144 -0
  96. package/template/src/connected-identity.ts +122 -0
  97. package/template/src/connected-participation.test.tsx +67 -0
  98. package/template/src/connected-runtime.test.ts +241 -0
  99. package/template/src/connected-runtime.ts +176 -0
  100. package/template/src/connected-social.test.tsx +158 -0
  101. package/template/src/create-post-overlay.tsx +82 -0
  102. package/template/src/draft-post-list.tsx +39 -0
  103. package/template/src/feedback-cache-invalidation.test.ts +185 -0
  104. package/template/src/feedback-cache-invalidation.ts +98 -0
  105. package/template/src/feedback-runtime.ts +105 -0
  106. package/template/src/feedbax/changelog/index.test.ts +156 -0
  107. package/template/src/feedbax/changelog/index.ts +206 -0
  108. package/template/src/feedbax/changelog/notion-changelog.test.ts +395 -0
  109. package/template/src/feedbax/changelog/notion-changelog.ts +400 -0
  110. package/template/src/feedbax/config/index.test.ts +139 -0
  111. package/template/src/feedbax/config/index.ts +364 -0
  112. package/template/src/feedbax/feedback/connected-feedback.ts +206 -0
  113. package/template/src/feedbax/feedback/connected.test.ts +272 -0
  114. package/template/src/feedbax/feedback/connected.ts +215 -0
  115. package/template/src/feedbax/feedback/email-delivery.test.ts +65 -0
  116. package/template/src/feedbax/feedback/email-delivery.ts +66 -0
  117. package/template/src/feedbax/feedback/feedback-storage.ts +43 -0
  118. package/template/src/feedbax/feedback/identity-handoff.test.ts +81 -0
  119. package/template/src/feedbax/feedback/identity-handoff.ts +119 -0
  120. package/template/src/feedbax/feedback/image-feedback.ts +101 -0
  121. package/template/src/feedbax/feedback/images.test.ts +223 -0
  122. package/template/src/feedbax/feedback/images.ts +324 -0
  123. package/template/src/feedbax/feedback/index.test.ts +900 -0
  124. package/template/src/feedbax/feedback/index.ts +996 -0
  125. package/template/src/feedbax/feedback/notion-comments.test.ts +171 -0
  126. package/template/src/feedbax/feedback/notion-comments.ts +231 -0
  127. package/template/src/feedbax/feedback/notion-data-source.test.ts +364 -0
  128. package/template/src/feedbax/feedback/notion-data-source.ts +458 -0
  129. package/template/src/feedbax/feedback/notion-feedback.test.ts +998 -0
  130. package/template/src/feedbax/feedback/notion-feedback.ts +651 -0
  131. package/template/src/feedbax/feedback/notion-image-review.ts +55 -0
  132. package/template/src/feedbax/feedback/notion-request.ts +74 -0
  133. package/template/src/feedbax/feedback/participation.test.ts +653 -0
  134. package/template/src/feedbax/feedback/participation.ts +502 -0
  135. package/template/src/feedbax.test-fixture.ts +64 -0
  136. package/template/src/feedbax.ts +69 -0
  137. package/template/src/images-runtime.test.ts +133 -0
  138. package/template/src/images-runtime.ts +125 -0
  139. package/template/src/index.css +1795 -0
  140. package/template/src/lazy-create-post-overlay.tsx +6 -0
  141. package/template/src/lazy-post-detail-overlay.tsx +21 -0
  142. package/template/src/masked-post-link.tsx +27 -0
  143. package/template/src/overlay-post-detail.test.tsx +181 -0
  144. package/template/src/overlay-post-detail.tsx +43 -0
  145. package/template/src/participation-client.ts +63 -0
  146. package/template/src/participation-runtime.test.ts +35 -0
  147. package/template/src/participation-runtime.ts +255 -0
  148. package/template/src/portal-comments.test.ts +375 -0
  149. package/template/src/portal-comments.ts +403 -0
  150. package/template/src/portal-features.test.tsx +150 -0
  151. package/template/src/portal-feedback-form.tsx +530 -0
  152. package/template/src/portal-feedback-runtime.test.ts +32 -0
  153. package/template/src/portal-feedback-runtime.ts +58 -0
  154. package/template/src/portal-feedback-server-function.ts +96 -0
  155. package/template/src/portal-feedback-submission.test.ts +307 -0
  156. package/template/src/portal-feedback-submission.ts +224 -0
  157. package/template/src/portal-head.ts +58 -0
  158. package/template/src/portal-server-entry.ts +68 -0
  159. package/template/src/portal-voting.test.ts +106 -0
  160. package/template/src/portal-voting.ts +149 -0
  161. package/template/src/post-badges.tsx +27 -0
  162. package/template/src/post-creation-flow.test.tsx +230 -0
  163. package/template/src/post-creation-flow.tsx +88 -0
  164. package/template/src/post-detail-content.test.tsx +233 -0
  165. package/template/src/post-detail-content.tsx +162 -0
  166. package/template/src/post-detail-overlay.test.tsx +114 -0
  167. package/template/src/post-detail-overlay.tsx +67 -0
  168. package/template/src/post-detail-query.ts +34 -0
  169. package/template/src/post-feed-controls.test.tsx +239 -0
  170. package/template/src/post-feed-controls.tsx +298 -0
  171. package/template/src/post-presentation.ts +37 -0
  172. package/template/src/post-queries.test.ts +158 -0
  173. package/template/src/post-queries.ts +26 -0
  174. package/template/src/post-results.tsx +165 -0
  175. package/template/src/public-cache-policy.test.ts +148 -0
  176. package/template/src/public-cache-policy.ts +119 -0
  177. package/template/src/public-cache-tags.ts +7 -0
  178. package/template/src/public-changelog-page.ts +15 -0
  179. package/template/src/public-changelog-server-function.ts +27 -0
  180. package/template/src/public-date.ts +11 -0
  181. package/template/src/public-failure-state.test.tsx +69 -0
  182. package/template/src/public-feedback-index.tsx +94 -0
  183. package/template/src/public-feedback-page.test.tsx +355 -0
  184. package/template/src/public-feedback-page.ts +86 -0
  185. package/template/src/public-feedback-server-function.ts +22 -0
  186. package/template/src/public-portal-error.tsx +27 -0
  187. package/template/src/public-post-detail-skeleton.tsx +54 -0
  188. package/template/src/public-post-detail.tsx +772 -0
  189. package/template/src/public-post-page.test.tsx +173 -0
  190. package/template/src/public-post-page.ts +15 -0
  191. package/template/src/public-post-route.test.tsx +250 -0
  192. package/template/src/public-post-route.tsx +158 -0
  193. package/template/src/public-post-server-function.ts +48 -0
  194. package/template/src/public-post-unavailable.tsx +10 -0
  195. package/template/src/public-roadmap-page.test.tsx +139 -0
  196. package/template/src/public-roadmap-page.ts +24 -0
  197. package/template/src/public-roadmap-route.test.tsx +254 -0
  198. package/template/src/public-roadmap-route.tsx +50 -0
  199. package/template/src/public-roadmap-server-function.ts +44 -0
  200. package/template/src/public-roadmap-view.tsx +411 -0
  201. package/template/src/public-shell.test.tsx +544 -0
  202. package/template/src/roadmap-query.ts +21 -0
  203. package/template/src/root-branding.test.ts +53 -0
  204. package/template/src/routeTree.gen.ts +177 -0
  205. package/template/src/routed-post-creation-form.tsx +28 -0
  206. package/template/src/router.tsx +27 -0
  207. package/template/src/routes/__root.tsx +114 -0
  208. package/template/src/routes/changelog.tsx +39 -0
  209. package/template/src/routes/email-preferences.tsx +77 -0
  210. package/template/src/routes/index.tsx +61 -0
  211. package/template/src/routes/p.$slug.tsx +16 -0
  212. package/template/src/routes/roadmap.tsx +11 -0
  213. package/template/src/routes/submit.tsx +28 -0
  214. package/template/src/safe-public-failure.ts +47 -0
  215. package/template/src/server.ts +151 -0
  216. package/template/src/similar-posts.tsx +45 -0
  217. package/template/src/submit-route.test.tsx +134 -0
  218. package/template/src/trusted-feedback-handler.test.ts +425 -0
  219. package/template/src/trusted-feedback-handler.ts +181 -0
  220. package/template/src/trusted-feedback-runtime.ts +17 -0
  221. package/template/src/turnstile-configuration.test.ts +37 -0
  222. package/template/src/turnstile-configuration.ts +19 -0
  223. package/template/src/use-media-query.ts +17 -0
  224. package/template/src/vote-toggle.test.tsx +171 -0
  225. package/template/src/vote-toggle.tsx +244 -0
  226. package/template/tsconfig.json +45 -0
  227. package/template/vite.config.ts +20 -0
  228. package/template/vitest.config.ts +4 -0
  229. package/template/vitest.setup.ts +3 -0
  230. package/template/wrangler.jsonc +35 -0
  231. package/dist/cli.d.ts +0 -3
  232. package/dist/cli.d.ts.map +0 -1
  233. package/dist/cli.js.map +0 -1
  234. package/dist/index.d.ts +0 -21
  235. package/dist/index.d.ts.map +0 -1
  236. package/dist/index.js.map +0 -1
  237. package/template/.env.example +0 -4
  238. package/template/apps/portal/components.json +0 -20
  239. package/template/apps/portal/package.json +0 -31
  240. package/template/apps/portal/src/lib/utils.ts +0 -6
  241. package/template/apps/portal/src/routeTree.gen.ts +0 -59
  242. package/template/apps/portal/src/router.tsx +0 -10
  243. package/template/apps/portal/src/routes/__root.tsx +0 -31
  244. package/template/apps/portal/src/routes/index.tsx +0 -35
  245. package/template/apps/portal/src/styles.css +0 -91
  246. package/template/apps/portal/tsconfig.json +0 -15
  247. package/template/apps/portal/vite.config.ts +0 -13
  248. package/template/feedbax.config.ts +0 -13
  249. package/template/feedbax.jsonc +0 -11
  250. package/template/pnpm-workspace.yaml +0 -3
  251. package/template/turbo.json +0 -8
@@ -0,0 +1,311 @@
1
+ // @vitest-environment jsdom
2
+ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import { cleanup } from "@testing-library/react";
5
+ import type { ComponentProps } from "react";
6
+
7
+ import { saveDeviceProfile } from "./browser-post-state";
8
+ import { PublicPostDetail } from "./public-post-detail";
9
+ import { submitPublicComment } from "./public-post-route";
10
+ import { DeviceProfileProvider } from "./components/device-profile-provider";
11
+
12
+ const post = {
13
+ slug: "roadmap-search",
14
+ title: "Roadmap search",
15
+ description: "Search the roadmap.",
16
+ type: "Feature Request" as const,
17
+ status: "Planned" as const,
18
+ createdAt: new Date("2026-08-25T00:00:00.000Z"),
19
+ updatedAt: new Date("2026-08-25T00:00:00.000Z"),
20
+ voteCount: 1,
21
+ };
22
+
23
+ afterEach(() => {
24
+ cleanup();
25
+ localStorage.clear();
26
+ sessionStorage.clear();
27
+ vi.restoreAllMocks();
28
+ delete window.turnstile;
29
+ });
30
+
31
+ describe("Comment creation", () => {
32
+ it("renders a clearly identified Comment composer for a complete Device Profile", async () => {
33
+ saveDeviceProfile(localStorage, {
34
+ name: "Ari",
35
+ email: "private@example.com",
36
+ });
37
+ renderCommentDetail({ submitComment: vi.fn() });
38
+
39
+ const composer = await screen.findByRole("form", {
40
+ name: "Comment composer",
41
+ });
42
+ expect(composer.classList.contains("comment-composer")).toBe(true);
43
+ expect(screen.getByLabelText("Add a comment").getAttribute("rows")).toBe(
44
+ "3",
45
+ );
46
+ expect(
47
+ screen.getByPlaceholderText("Share context, an example, or a question…"),
48
+ ).toBeTruthy();
49
+ });
50
+
51
+ it("deserializes Notion confirmation and clears a rejected Participation Pass", async () => {
52
+ vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
53
+ Response.json({
54
+ comment: {
55
+ id: "notion-comment",
56
+ discussionId: "notion-discussion",
57
+ body: "Confirmed",
58
+ author: { kind: "participant", displayName: "Ari" },
59
+ createdAt: "2026-08-25T01:00:00.000Z",
60
+ },
61
+ }),
62
+ );
63
+ const confirmed = await submitPublicComment({
64
+ slug: "roadmap-search",
65
+ body: "Confirmed",
66
+ displayName: "Ari",
67
+ email: "private@example.com",
68
+ });
69
+ expect(confirmed.createdAt).toEqual(new Date("2026-08-25T01:00:00.000Z"));
70
+
71
+ sessionStorage.setItem("feedbax:participation-pass", "expired");
72
+ vi.mocked(fetch).mockResolvedValueOnce(
73
+ Response.json(
74
+ {
75
+ error: "Comment verification is required.",
76
+ code: "verification_required",
77
+ },
78
+ { status: 400 },
79
+ ),
80
+ );
81
+ await expect(
82
+ submitPublicComment({
83
+ slug: "roadmap-search",
84
+ body: "Retry",
85
+ displayName: "Ari",
86
+ email: "private@example.com",
87
+ participationPass: "expired",
88
+ }),
89
+ ).rejects.toThrow("verification is required");
90
+ expect(sessionStorage.getItem("feedbax:participation-pass")).toBeNull();
91
+ });
92
+ it("adds a Comment optimistically and replaces it with Notion's confirmation", async () => {
93
+ saveDeviceProfile(localStorage, {
94
+ name: "Ari",
95
+ email: "private@example.com",
96
+ });
97
+ let confirm!: (value: {
98
+ id: string;
99
+ discussionId: string;
100
+ body: string;
101
+ author: { kind: "participant"; displayName: string };
102
+ createdAt: Date;
103
+ }) => void;
104
+ const submitComment = vi.fn(
105
+ () => new Promise<never>((resolve) => (confirm = resolve as never)),
106
+ );
107
+ renderCommentDetail({ submitComment });
108
+
109
+ fireEvent.change(await screen.findByLabelText("Add a comment"), {
110
+ target: { value: "Please add shortcuts." },
111
+ });
112
+ fireEvent.click(screen.getByRole("button", { name: "Post comment" }));
113
+ expect(screen.getByText("Please add shortcuts.")).toBeTruthy();
114
+ expect(submitComment).toHaveBeenCalledWith({
115
+ slug: "roadmap-search",
116
+ body: "Please add shortcuts.",
117
+ displayName: "Ari",
118
+ email: "private@example.com",
119
+ });
120
+
121
+ confirm({
122
+ id: "notion-comment",
123
+ discussionId: "notion-discussion",
124
+ body: "Please add shortcuts.",
125
+ author: { kind: "participant", displayName: "Ari" },
126
+ createdAt: new Date("2026-08-25T01:00:00.000Z"),
127
+ });
128
+ await waitFor(() =>
129
+ expect(screen.getAllByText("Please add shortcuts.")).toHaveLength(1),
130
+ );
131
+ });
132
+
133
+ it("replaces the Comment composer with profile completion until name and email are present", async () => {
134
+ saveDeviceProfile(localStorage, { name: "Ari" });
135
+ const submitComment = vi.fn();
136
+ renderCommentDetail({ submitComment });
137
+
138
+ expect(await screen.findByText("Complete your profile")).toBeTruthy();
139
+ expect(screen.queryByLabelText("Add a comment")).toBeNull();
140
+ fireEvent.click(screen.getByRole("button", { name: "Complete profile" }));
141
+ fireEvent.change(await screen.findByLabelText("Email"), {
142
+ target: { value: "private@example.com" },
143
+ });
144
+ fireEvent.click(screen.getByRole("button", { name: "Save profile" }));
145
+
146
+ expect(submitComment).not.toHaveBeenCalled();
147
+ expect(await screen.findByLabelText("Add a comment")).toBeTruthy();
148
+ });
149
+
150
+ it("does not duplicate a native Comment that arrives before its submission confirmation", async () => {
151
+ saveDeviceProfile(localStorage, {
152
+ name: "Ari",
153
+ email: "private@example.com",
154
+ });
155
+ const confirmed = {
156
+ id: "native-comment",
157
+ discussionId: "native-thread",
158
+ body: "One comment",
159
+ author: { kind: "participant" as const, displayName: "Ari" },
160
+ createdAt: new Date(),
161
+ };
162
+ let finish!: (value: typeof confirmed) => void;
163
+ const submitComment = vi.fn(
164
+ () =>
165
+ new Promise<typeof confirmed>((resolve) => {
166
+ finish = resolve;
167
+ }),
168
+ );
169
+ const view = renderCommentDetail({ submitComment });
170
+ fireEvent.change(await screen.findByLabelText("Add a comment"), {
171
+ target: { value: confirmed.body },
172
+ });
173
+ fireEvent.click(screen.getByRole("button", { name: "Post comment" }));
174
+ view.rerender(
175
+ <DeviceProfileProvider>
176
+ <PublicPostDetail
177
+ post={post}
178
+ submitComment={submitComment}
179
+ comments={{
180
+ items: [{ id: confirmed.discussionId, comments: [confirmed] }],
181
+ }}
182
+ />
183
+ </DeviceProfileProvider>,
184
+ );
185
+ finish(confirmed);
186
+ await waitFor(() =>
187
+ expect(screen.getAllByText("One comment")).toHaveLength(1),
188
+ );
189
+ });
190
+
191
+ it("rolls back a rejected Comment and shows an actionable error", async () => {
192
+ saveDeviceProfile(localStorage, {
193
+ name: "Ari",
194
+ email: "private@example.com",
195
+ });
196
+ renderCommentDetail({
197
+ submitComment: () =>
198
+ Promise.reject(new Error("Notion timed out. Try again.")),
199
+ });
200
+ fireEvent.change(await screen.findByLabelText("Add a comment"), {
201
+ target: { value: "Temporary message" },
202
+ });
203
+ fireEvent.click(screen.getByRole("button", { name: "Post comment" }));
204
+ expect(screen.getByText("Temporary message")).toBeTruthy();
205
+ await screen.findByRole("alert");
206
+ expect(screen.queryByText("Temporary message")).toBeNull();
207
+ expect(screen.getByRole("alert").textContent).toContain("Try again");
208
+ });
209
+
210
+ it("renders a fresh Turnstile challenge after a stale Participation Pass is rejected", async () => {
211
+ saveDeviceProfile(localStorage, {
212
+ name: "Ari",
213
+ email: "private@example.com",
214
+ });
215
+ sessionStorage.setItem("feedbax:participation-pass", "expired");
216
+ const renderTurnstile = vi.fn().mockReturnValue("widget-1");
217
+ window.turnstile = { render: renderTurnstile, reset: vi.fn() };
218
+ renderCommentDetail({
219
+ turnstileSiteKey: "public-site-key",
220
+ submitComment: () => {
221
+ sessionStorage.removeItem("feedbax:participation-pass");
222
+ return Promise.reject(new Error("Comment verification is required."));
223
+ },
224
+ });
225
+ fireEvent.change(await screen.findByLabelText("Add a comment"), {
226
+ target: { value: "Retry with verification" },
227
+ });
228
+ fireEvent.click(screen.getByRole("button", { name: "Post comment" }));
229
+
230
+ await screen.findByRole("alert");
231
+ await waitFor(() => expect(renderTurnstile).toHaveBeenCalled());
232
+ });
233
+
234
+ it("rolls back an optimistic Comment edit when the server rejects it", async () => {
235
+ saveDeviceProfile(localStorage, {
236
+ name: "Ari",
237
+ email: "private@example.com",
238
+ });
239
+ localStorage.setItem(
240
+ "feedbax:comment-capabilities",
241
+ JSON.stringify({
242
+ "comment-1": {
243
+ capability: "signed-capability",
244
+ expiresAt: Date.now() + 60_000,
245
+ },
246
+ }),
247
+ );
248
+ vi.spyOn(window, "prompt").mockReturnValue("Corrected message");
249
+ let rejectMutation!: (reason: Error) => void;
250
+ const mutateComment = vi.fn(
251
+ () =>
252
+ new Promise<never>((_resolve, reject) => {
253
+ rejectMutation = reject;
254
+ }),
255
+ );
256
+ renderCommentDetail({
257
+ comments: {
258
+ items: [
259
+ {
260
+ id: "discussion-1",
261
+ comments: [
262
+ {
263
+ id: "comment-1",
264
+ body: "Original message",
265
+ author: { kind: "participant", displayName: "Ari" },
266
+ createdAt: new Date("2026-08-25T01:00:00.000Z"),
267
+ },
268
+ ],
269
+ },
270
+ ],
271
+ },
272
+ submitComment: vi.fn(),
273
+ mutateComment,
274
+ });
275
+
276
+ const actions = await screen.findByRole("group", {
277
+ name: "Comment actions by Ari",
278
+ });
279
+ expect(
280
+ Array.from(actions.querySelectorAll("button"), (button) =>
281
+ button.textContent?.trim(),
282
+ ),
283
+ ).toEqual(["Reply", "Edit", "Delete"]);
284
+ fireEvent.click(screen.getByRole("button", { name: "Edit" }));
285
+ fireEvent.change(screen.getByRole("textbox", { name: "Comment text" }), {
286
+ target: { value: "Corrected message" },
287
+ });
288
+ fireEvent.click(screen.getByRole("button", { name: "Save" }));
289
+ expect(screen.getByText("Corrected message")).toBeTruthy();
290
+ expect(mutateComment).toHaveBeenCalledWith({
291
+ action: "edit",
292
+ commentId: "comment-1",
293
+ commentCapability: "signed-capability",
294
+ body: "Corrected message",
295
+ });
296
+ rejectMutation(new Error("Notion rejected the update. Try again."));
297
+ await screen.findByRole("alert");
298
+ expect(screen.getByText("Original message")).toBeTruthy();
299
+ expect(screen.queryByText("Corrected message")).toBeNull();
300
+ });
301
+ });
302
+
303
+ function renderCommentDetail(
304
+ props: Omit<ComponentProps<typeof PublicPostDetail>, "post"> = {},
305
+ ) {
306
+ return render(
307
+ <DeviceProfileProvider>
308
+ <PublicPostDetail post={post} {...props} />
309
+ </DeviceProfileProvider>,
310
+ );
311
+ }
@@ -0,0 +1,5 @@
1
+ import { z } from "zod";
2
+
3
+ export function isValidCommentEmail(value: unknown): value is string {
4
+ return typeof value === "string" && z.email().safeParse(value.trim()).success;
5
+ }
@@ -0,0 +1,28 @@
1
+ import type { CommentThreadPage } from "@feedbax/feedback";
2
+
3
+ export function mergeCommentThreads(
4
+ current: CommentThreadPage["items"],
5
+ next: CommentThreadPage["items"],
6
+ ): CommentThreadPage["items"] {
7
+ const merged = new Map(current.map((thread) => [thread.id, thread]));
8
+ for (const thread of next) {
9
+ const existing = merged.get(thread.id);
10
+ merged.set(
11
+ thread.id,
12
+ existing
13
+ ? {
14
+ ...existing,
15
+ comments: [
16
+ ...new Map(
17
+ [...existing.comments, ...thread.comments].map((comment) => [
18
+ comment.id,
19
+ comment,
20
+ ]),
21
+ ).values(),
22
+ ],
23
+ }
24
+ : thread,
25
+ );
26
+ }
27
+ return [...merged.values()];
28
+ }
@@ -0,0 +1,71 @@
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useEffect,
6
+ useMemo,
7
+ useState,
8
+ type ReactNode,
9
+ } from "react";
10
+ import { Search } from "lucide-react";
11
+
12
+ import { Button } from "@feedbax/ui/components/button";
13
+ import { cn } from "@feedbax/ui/lib/utils";
14
+
15
+ const ignoreOpenChange = () => {};
16
+
17
+ const CommandPaletteContext = createContext<{
18
+ open: boolean;
19
+ setOpen: (open: boolean) => void;
20
+ } | null>(null);
21
+
22
+ export function CommandPaletteProvider({ children }: { children: ReactNode }) {
23
+ const [open, setOpen] = useState(false);
24
+
25
+ useEffect(() => {
26
+ function onKeyDown(event: KeyboardEvent) {
27
+ if (event.key.toLowerCase() === "k" && (event.metaKey || event.ctrlKey)) {
28
+ event.preventDefault();
29
+ setOpen((current) => !current);
30
+ }
31
+ }
32
+ window.addEventListener("keydown", onKeyDown);
33
+ return () => window.removeEventListener("keydown", onKeyDown);
34
+ }, []);
35
+
36
+ const value = useMemo(() => ({ open, setOpen }), [open]);
37
+ return (
38
+ <CommandPaletteContext.Provider value={value}>
39
+ {children}
40
+ </CommandPaletteContext.Provider>
41
+ );
42
+ }
43
+
44
+ export function useCommandPalette() {
45
+ const context = useContext(CommandPaletteContext);
46
+ const setOpen = context?.setOpen ?? ignoreOpenChange;
47
+ const openPalette = useCallback(() => setOpen(true), [setOpen]);
48
+ return { open: context?.open ?? false, setOpen, openPalette };
49
+ }
50
+
51
+ export function CommandPaletteTrigger({
52
+ className,
53
+ compact = false,
54
+ }: {
55
+ className?: string;
56
+ compact?: boolean;
57
+ }) {
58
+ const { openPalette } = useCommandPalette();
59
+ return (
60
+ <Button
61
+ className={cn("h-10 gap-2 px-4 text-sm", className)}
62
+ variant="outline"
63
+ type="button"
64
+ aria-haspopup="dialog"
65
+ onClick={openPalette}
66
+ >
67
+ <Search aria-hidden="true" />
68
+ <span className={compact ? "sr-only" : undefined}>Search</span>
69
+ </Button>
70
+ );
71
+ }
@@ -0,0 +1,275 @@
1
+ import { useEffect, useState } from "react";
2
+ import { useInfiniteQuery, keepPreviousData } from "@tanstack/react-query";
3
+ import { useRouter } from "@tanstack/react-router";
4
+ import type { PublicPostPage } from "@feedbax/feedback";
5
+ import type { PortalFeatures } from "@feedbax/config";
6
+
7
+ import { Button } from "@feedbax/ui/components/button";
8
+ import { Kbd, KbdGroup } from "@feedbax/ui/components/kbd";
9
+ import { Spinner } from "@feedbax/ui/components/spinner";
10
+ import {
11
+ Command,
12
+ CommandDialog,
13
+ CommandEmpty,
14
+ CommandGroup,
15
+ CommandInput,
16
+ CommandItem,
17
+ CommandList,
18
+ } from "@feedbax/ui/components/command";
19
+ import { FileText, LayoutList, Map, Newspaper, Plus } from "lucide-react";
20
+ import { publicPostsQuery } from "../post-queries";
21
+ import { useCommandPalette } from "./command-palette-context";
22
+ import feedbax from "../feedbax";
23
+ import { useDeviceProfile } from "./device-profile-provider";
24
+
25
+ export {
26
+ CommandPaletteProvider,
27
+ CommandPaletteTrigger,
28
+ useCommandPalette,
29
+ } from "./command-palette-context";
30
+
31
+ type SearchPosts = (term: string) => Promise<PublicPostPage>;
32
+ type NavigationTarget = "/" | "/roadmap" | "/changelog";
33
+
34
+ export function CommandPalette({
35
+ searchPosts,
36
+ features = feedbax.features,
37
+ }: {
38
+ searchPosts: SearchPosts;
39
+ features?: PortalFeatures;
40
+ }) {
41
+ const router = useRouter();
42
+ const { open, setOpen } = useCommandPalette();
43
+ const [term, setTerm] = useState("");
44
+ const [selection, setSelection] = useState("go-feedback");
45
+ const deferredTerm = useDebouncedValue(term, 200);
46
+ const { completeProfile, openProfileSetup } = useDeviceProfile();
47
+
48
+ useEffect(() => {
49
+ if (!open) {
50
+ setTerm("");
51
+ setSelection("go-feedback");
52
+ }
53
+ }, [open]);
54
+
55
+ const query = useInfiniteQuery({
56
+ ...publicPostsQuery(
57
+ { search: deferredTerm, sort: features.voting ? "top" : "new" },
58
+ (search) => searchPosts(search.search ?? ""),
59
+ ),
60
+ enabled: open && deferredTerm.trim().length > 0,
61
+ placeholderData: keepPreviousData,
62
+ });
63
+ const posts = query.data?.pages.flatMap((page) => page.items) ?? [];
64
+ const hasTerm = term.trim().length > 0;
65
+ const settling = term.trim() !== deferredTerm.trim();
66
+ const showEmpty =
67
+ open &&
68
+ deferredTerm.trim().length > 0 &&
69
+ !query.isPending &&
70
+ !query.isError &&
71
+ !settling &&
72
+ posts.length === 0;
73
+ const searching = open && hasTerm && (settling || query.isFetching);
74
+ const searchStatus = searching
75
+ ? "Searching Posts…"
76
+ : showEmpty
77
+ ? "No Posts match this search."
78
+ : deferredTerm.trim().length > 0 && posts.length > 0
79
+ ? `${posts.length} ${posts.length === 1 ? "Post" : "Posts"} found.`
80
+ : "";
81
+
82
+ useEffect(() => {
83
+ const firstPost = posts[0];
84
+ if (firstPost) setSelection(`post-${firstPost.slug}`);
85
+ }, [posts[0]?.slug]);
86
+
87
+ function getCurrentRouteOptions() {
88
+ const currentRoute = router.state.matches.at(-1);
89
+ if (!currentRoute) return undefined;
90
+ return {
91
+ from: currentRoute.fullPath,
92
+ to: "." as const,
93
+ params: currentRoute.params,
94
+ search: currentRoute.search,
95
+ };
96
+ }
97
+
98
+ function openPost(slug: string) {
99
+ const currentRouteOptions = getCurrentRouteOptions();
100
+ if (!currentRouteOptions) return;
101
+ setOpen(false);
102
+ router.navigate({
103
+ ...currentRouteOptions,
104
+ state: { postDetailOverlay: { slug } },
105
+ mask: {
106
+ to: "/p/$slug",
107
+ params: { slug },
108
+ unmaskOnReload: true,
109
+ },
110
+ });
111
+ }
112
+
113
+ function navigate(to: NavigationTarget) {
114
+ setOpen(false);
115
+ router.navigate({ to });
116
+ }
117
+
118
+ return (
119
+ <CommandDialog
120
+ open={open}
121
+ onOpenChange={setOpen}
122
+ title="Search feedback"
123
+ description="Find Posts and navigate the portal."
124
+ className="sm:max-w-xl"
125
+ >
126
+ <Command
127
+ shouldFilter={false}
128
+ className="gap-0"
129
+ value={selection}
130
+ onValueChange={setSelection}
131
+ >
132
+ <CommandInput
133
+ aria-label="Search feedback"
134
+ value={term}
135
+ onValueChange={setTerm}
136
+ placeholder="Search feedback…"
137
+ />
138
+ <CommandList
139
+ aria-busy={searching || undefined}
140
+ className="min-h-28 max-h-96 p-2"
141
+ >
142
+ {showEmpty ? (
143
+ <CommandEmpty>No Posts match this search.</CommandEmpty>
144
+ ) : null}
145
+ {query.isError && hasTerm ? (
146
+ <div
147
+ role="alert"
148
+ className="space-y-3 px-4 py-8 text-center text-sm text-muted-foreground"
149
+ >
150
+ <p>Search is unavailable. Try again.</p>
151
+ <Button variant="outline" onClick={() => void query.refetch()}>
152
+ Retry search
153
+ </Button>
154
+ </div>
155
+ ) : null}
156
+ {searching ? (
157
+ <div
158
+ role="status"
159
+ className={
160
+ posts.length
161
+ ? "flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground"
162
+ : "flex items-center justify-center gap-2 px-4 py-8 text-sm text-muted-foreground"
163
+ }
164
+ >
165
+ <Spinner aria-hidden="true" />
166
+ Searching Posts…
167
+ </div>
168
+ ) : null}
169
+ {hasTerm && !query.isError && posts.length > 0 ? (
170
+ <CommandGroup heading="Posts">
171
+ {posts.map((post) => (
172
+ <CommandItem
173
+ key={post.slug}
174
+ value={`post-${post.slug}`}
175
+ onSelect={() => openPost(post.slug)}
176
+ className="min-h-14 gap-3"
177
+ >
178
+ <FileText aria-hidden="true" />
179
+ <div className="min-w-0 flex-1">
180
+ <div className="truncate text-sm font-medium">
181
+ {post.title}
182
+ </div>
183
+ <div className="truncate text-xs text-muted-foreground">
184
+ {post.description}
185
+ </div>
186
+ </div>
187
+ <div className="shrink-0 text-right text-xs text-muted-foreground">
188
+ <span className="block">{post.status}</span>
189
+ <span className="mt-1 hidden sm:block">{post.type}</span>
190
+ </div>
191
+ </CommandItem>
192
+ ))}
193
+ </CommandGroup>
194
+ ) : null}
195
+ {!term.trim() ? (
196
+ <CommandGroup heading="Navigate">
197
+ <CommandItem
198
+ className="min-h-11"
199
+ value="go-feedback"
200
+ onSelect={() => navigate("/")}
201
+ >
202
+ <LayoutList aria-hidden="true" />
203
+ Feedback
204
+ </CommandItem>
205
+ <CommandItem
206
+ className="min-h-11"
207
+ value="go-roadmap"
208
+ onSelect={() => navigate("/roadmap")}
209
+ >
210
+ <Map aria-hidden="true" />
211
+ Roadmap
212
+ </CommandItem>
213
+ {features.changelog ? (
214
+ <CommandItem
215
+ className="min-h-11"
216
+ value="go-changelog"
217
+ onSelect={() => navigate("/changelog")}
218
+ >
219
+ <Newspaper aria-hidden="true" />
220
+ Changelog
221
+ </CommandItem>
222
+ ) : null}
223
+ <CommandItem
224
+ className="min-h-11"
225
+ value="new-post"
226
+ onSelect={() => {
227
+ if (!completeProfile) {
228
+ setOpen(false);
229
+ openProfileSetup();
230
+ return;
231
+ }
232
+ const currentRouteOptions = getCurrentRouteOptions();
233
+ if (!currentRouteOptions) return;
234
+ setOpen(false);
235
+ router.navigate({
236
+ ...currentRouteOptions,
237
+ state: { createPostOverlay: true },
238
+ mask: { to: "/submit", unmaskOnReload: true },
239
+ });
240
+ }}
241
+ >
242
+ <Plus aria-hidden="true" />
243
+ New post
244
+ </CommandItem>
245
+ </CommandGroup>
246
+ ) : null}
247
+ </CommandList>
248
+ <div className="flex items-center justify-between gap-4 border-t px-4 py-3 text-xs text-muted-foreground">
249
+ <span className="flex items-center gap-1.5">
250
+ <KbdGroup>
251
+ <Kbd>↑</Kbd>
252
+ <Kbd>↓</Kbd>
253
+ </KbdGroup>{" "}
254
+ to navigate <Kbd>↵</Kbd> to open
255
+ </span>
256
+ <span className="flex items-center gap-1.5">
257
+ <Kbd>Esc</Kbd> to close
258
+ </span>
259
+ </div>
260
+ <div aria-label="Search status" className="sr-only" role="status">
261
+ {searchStatus}
262
+ </div>
263
+ </Command>
264
+ </CommandDialog>
265
+ );
266
+ }
267
+
268
+ function useDebouncedValue<T>(value: T, delay: number): T {
269
+ const [debounced, setDebounced] = useState(value);
270
+ useEffect(() => {
271
+ const timeout = window.setTimeout(() => setDebounced(value), delay);
272
+ return () => window.clearTimeout(timeout);
273
+ }, [value, delay]);
274
+ return debounced;
275
+ }