create-feedbax 0.1.0 → 0.2.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 (210) hide show
  1. package/LICENSE +201 -21
  2. package/README.md +11 -1
  3. package/dist/cli.js +1261 -26
  4. package/dist/index.js +1402 -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/LICENSE +201 -0
  11. package/template/components.json +25 -0
  12. package/template/eslint.config.mjs +25 -0
  13. package/template/package.json +70 -14
  14. package/template/public/feedbax-mark.svg +4 -0
  15. package/template/public/robots.txt +3 -0
  16. package/template/src/authorized-draft-post.test.tsx +212 -0
  17. package/template/src/authorized-draft-post.tsx +204 -0
  18. package/template/src/authorized-draft-query.ts +74 -0
  19. package/template/src/browser-post-state.test.ts +120 -0
  20. package/template/src/browser-post-state.ts +134 -0
  21. package/template/src/browser-vote-state.test.ts +14 -0
  22. package/template/src/browser-vote-state.ts +27 -0
  23. package/template/src/capability-authorization-error.ts +3 -0
  24. package/template/src/changelog-page.test.tsx +139 -0
  25. package/template/src/changelog-page.tsx +219 -0
  26. package/template/src/changelog-query.ts +13 -0
  27. package/template/src/changelog-runtime.ts +27 -0
  28. package/template/src/cloudflare-turnstile.ts +31 -0
  29. package/template/src/cloudflare-workers-test.ts +3 -0
  30. package/template/src/cloudflare-workers.d.ts +18 -0
  31. package/template/src/comment-creation.test.tsx +270 -0
  32. package/template/src/comment-profile.ts +5 -0
  33. package/template/src/comment-thread-page.ts +18 -0
  34. package/template/src/components/command-palette-context.tsx +71 -0
  35. package/template/src/components/command-palette.tsx +246 -0
  36. package/template/src/components/create-post-trigger.tsx +44 -0
  37. package/template/src/components/device-profile-provider.tsx +309 -0
  38. package/template/src/components/header.tsx +95 -0
  39. package/template/src/components/loader.tsx +10 -0
  40. package/template/src/components/profile-menu.tsx +89 -0
  41. package/template/src/components/ui/components/alert-dialog.tsx +185 -0
  42. package/template/src/components/ui/components/attachment.tsx +207 -0
  43. package/template/src/components/ui/components/avatar.tsx +107 -0
  44. package/template/src/components/ui/components/badge.tsx +52 -0
  45. package/template/src/components/ui/components/bubble.tsx +128 -0
  46. package/template/src/components/ui/components/button-group.tsx +87 -0
  47. package/template/src/components/ui/components/button.tsx +56 -0
  48. package/template/src/components/ui/components/card.tsx +103 -0
  49. package/template/src/components/ui/components/checkbox.tsx +26 -0
  50. package/template/src/components/ui/components/command.tsx +194 -0
  51. package/template/src/components/ui/components/dialog.tsx +154 -0
  52. package/template/src/components/ui/components/drawer.tsx +229 -0
  53. package/template/src/components/ui/components/dropdown-menu.tsx +270 -0
  54. package/template/src/components/ui/components/empty.tsx +101 -0
  55. package/template/src/components/ui/components/field.tsx +236 -0
  56. package/template/src/components/ui/components/input-group.tsx +155 -0
  57. package/template/src/components/ui/components/input.tsx +20 -0
  58. package/template/src/components/ui/components/item.tsx +201 -0
  59. package/template/src/components/ui/components/kbd.tsx +26 -0
  60. package/template/src/components/ui/components/label.tsx +18 -0
  61. package/template/src/components/ui/components/marker.tsx +71 -0
  62. package/template/src/components/ui/components/message-scroller.tsx +128 -0
  63. package/template/src/components/ui/components/message.tsx +92 -0
  64. package/template/src/components/ui/components/native-select.tsx +65 -0
  65. package/template/src/components/ui/components/popover.tsx +88 -0
  66. package/template/src/components/ui/components/portal-primitives.test.tsx +85 -0
  67. package/template/src/components/ui/components/radio-group.tsx +36 -0
  68. package/template/src/components/ui/components/scroll-area.tsx +52 -0
  69. package/template/src/components/ui/components/secondary-form-actions.test.tsx +23 -0
  70. package/template/src/components/ui/components/select.tsx +199 -0
  71. package/template/src/components/ui/components/separator.tsx +23 -0
  72. package/template/src/components/ui/components/sheet.tsx +135 -0
  73. package/template/src/components/ui/components/skeleton.tsx +13 -0
  74. package/template/src/components/ui/components/sonner.tsx +43 -0
  75. package/template/src/components/ui/components/spinner.tsx +16 -0
  76. package/template/src/components/ui/components/tabs.tsx +80 -0
  77. package/template/src/components/ui/components/textarea.tsx +18 -0
  78. package/template/src/components/ui/components/tooltip.tsx +64 -0
  79. package/template/src/components/ui/hooks/.gitkeep +0 -0
  80. package/template/src/components/ui/lib/utils.ts +6 -0
  81. package/template/src/components/ui/styles/globals.css +151 -0
  82. package/template/src/create-post-overlay.tsx +82 -0
  83. package/template/src/draft-post-list.tsx +39 -0
  84. package/template/src/feedback-cache-invalidation.test.ts +185 -0
  85. package/template/src/feedback-cache-invalidation.ts +97 -0
  86. package/template/src/feedback-runtime.ts +38 -0
  87. package/template/src/feedbax/changelog/index.test.ts +156 -0
  88. package/template/src/feedbax/changelog/index.ts +206 -0
  89. package/template/src/feedbax/changelog/notion-changelog.test.ts +395 -0
  90. package/template/src/feedbax/changelog/notion-changelog.ts +400 -0
  91. package/template/src/feedbax/config/index.test.ts +111 -0
  92. package/template/src/feedbax/config/index.ts +271 -0
  93. package/template/src/feedbax/feedback/feedback-storage.ts +43 -0
  94. package/template/src/feedbax/feedback/index.test.ts +900 -0
  95. package/template/src/feedbax/feedback/index.ts +942 -0
  96. package/template/src/feedbax/feedback/notion-comments.test.ts +171 -0
  97. package/template/src/feedbax/feedback/notion-comments.ts +231 -0
  98. package/template/src/feedbax/feedback/notion-data-source.test.ts +364 -0
  99. package/template/src/feedbax/feedback/notion-data-source.ts +458 -0
  100. package/template/src/feedbax/feedback/notion-feedback.test.ts +998 -0
  101. package/template/src/feedbax/feedback/notion-feedback.ts +651 -0
  102. package/template/src/feedbax/feedback/notion-request.ts +74 -0
  103. package/template/src/feedbax.test-fixture.ts +64 -0
  104. package/template/src/feedbax.ts +69 -0
  105. package/template/src/index.css +2138 -0
  106. package/template/src/lazy-create-post-overlay.tsx +6 -0
  107. package/template/src/lazy-post-detail-overlay.tsx +10 -0
  108. package/template/src/masked-post-link.tsx +27 -0
  109. package/template/src/overlay-post-detail.test.tsx +130 -0
  110. package/template/src/overlay-post-detail.tsx +92 -0
  111. package/template/src/portal-comments.test.ts +375 -0
  112. package/template/src/portal-comments.ts +384 -0
  113. package/template/src/portal-features.test.tsx +150 -0
  114. package/template/src/portal-feedback-form.tsx +515 -0
  115. package/template/src/portal-feedback-runtime.test.ts +32 -0
  116. package/template/src/portal-feedback-runtime.ts +55 -0
  117. package/template/src/portal-feedback-server-function.ts +93 -0
  118. package/template/src/portal-feedback-submission.test.ts +307 -0
  119. package/template/src/portal-feedback-submission.ts +214 -0
  120. package/template/src/portal-head.ts +58 -0
  121. package/template/src/portal-server-entry.ts +64 -0
  122. package/template/src/portal-voting.test.ts +106 -0
  123. package/template/src/portal-voting.ts +149 -0
  124. package/template/src/post-badges.tsx +27 -0
  125. package/template/src/post-creation-flow.test.tsx +230 -0
  126. package/template/src/post-creation-flow.tsx +88 -0
  127. package/template/src/post-detail-overlay.test.tsx +114 -0
  128. package/template/src/post-detail-overlay.tsx +67 -0
  129. package/template/src/post-feed-controls.test.tsx +239 -0
  130. package/template/src/post-feed-controls.tsx +298 -0
  131. package/template/src/post-presentation.ts +37 -0
  132. package/template/src/post-queries.test.ts +158 -0
  133. package/template/src/post-queries.ts +26 -0
  134. package/template/src/post-results.tsx +165 -0
  135. package/template/src/public-cache-policy.test.ts +141 -0
  136. package/template/src/public-cache-policy.ts +119 -0
  137. package/template/src/public-cache-tags.ts +7 -0
  138. package/template/src/public-changelog-page.ts +15 -0
  139. package/template/src/public-changelog-server-function.ts +27 -0
  140. package/template/src/public-date.ts +11 -0
  141. package/template/src/public-failure-state.test.tsx +69 -0
  142. package/template/src/public-feedback-index.tsx +124 -0
  143. package/template/src/public-feedback-page.test.tsx +290 -0
  144. package/template/src/public-feedback-page.ts +75 -0
  145. package/template/src/public-feedback-server-function.ts +22 -0
  146. package/template/src/public-portal-error.tsx +27 -0
  147. package/template/src/public-post-detail.tsx +682 -0
  148. package/template/src/public-post-page.test.tsx +173 -0
  149. package/template/src/public-post-page.ts +15 -0
  150. package/template/src/public-post-route.test.tsx +214 -0
  151. package/template/src/public-post-route.tsx +171 -0
  152. package/template/src/public-post-server-function.ts +48 -0
  153. package/template/src/public-post-unavailable.tsx +10 -0
  154. package/template/src/public-roadmap-page.test.tsx +139 -0
  155. package/template/src/public-roadmap-page.ts +24 -0
  156. package/template/src/public-roadmap-route.test.tsx +218 -0
  157. package/template/src/public-roadmap-route.tsx +50 -0
  158. package/template/src/public-roadmap-server-function.ts +44 -0
  159. package/template/src/public-roadmap-view.tsx +378 -0
  160. package/template/src/public-shell.test.tsx +518 -0
  161. package/template/src/roadmap-query.ts +21 -0
  162. package/template/src/root-branding.test.ts +53 -0
  163. package/template/src/routeTree.gen.ts +131 -0
  164. package/template/src/routed-post-creation-form.tsx +28 -0
  165. package/template/src/router.tsx +25 -0
  166. package/template/src/routes/__root.tsx +116 -0
  167. package/template/src/routes/changelog.tsx +39 -0
  168. package/template/src/routes/index.tsx +61 -0
  169. package/template/src/routes/p.$slug.tsx +16 -0
  170. package/template/src/routes/roadmap.tsx +11 -0
  171. package/template/src/routes/submit.tsx +28 -0
  172. package/template/src/safe-public-failure.ts +45 -0
  173. package/template/src/server.ts +111 -0
  174. package/template/src/similar-posts.tsx +45 -0
  175. package/template/src/submit-route.test.tsx +134 -0
  176. package/template/src/trusted-feedback-handler.test.ts +425 -0
  177. package/template/src/trusted-feedback-handler.ts +181 -0
  178. package/template/src/trusted-feedback-runtime.ts +17 -0
  179. package/template/src/turnstile-configuration.test.ts +37 -0
  180. package/template/src/turnstile-configuration.ts +19 -0
  181. package/template/src/use-media-query.ts +17 -0
  182. package/template/src/vote-toggle.test.tsx +171 -0
  183. package/template/src/vote-toggle.tsx +211 -0
  184. package/template/tsconfig.json +45 -0
  185. package/template/vite.config.ts +20 -0
  186. package/template/vitest.config.ts +4 -0
  187. package/template/vitest.setup.ts +3 -0
  188. package/template/wrangler.jsonc +35 -0
  189. package/dist/cli.d.ts +0 -3
  190. package/dist/cli.d.ts.map +0 -1
  191. package/dist/cli.js.map +0 -1
  192. package/dist/index.d.ts +0 -21
  193. package/dist/index.d.ts.map +0 -1
  194. package/dist/index.js.map +0 -1
  195. package/template/.env.example +0 -4
  196. package/template/README.md +0 -7
  197. package/template/apps/portal/components.json +0 -20
  198. package/template/apps/portal/package.json +0 -31
  199. package/template/apps/portal/src/lib/utils.ts +0 -6
  200. package/template/apps/portal/src/routeTree.gen.ts +0 -59
  201. package/template/apps/portal/src/router.tsx +0 -10
  202. package/template/apps/portal/src/routes/__root.tsx +0 -31
  203. package/template/apps/portal/src/routes/index.tsx +0 -35
  204. package/template/apps/portal/src/styles.css +0 -91
  205. package/template/apps/portal/tsconfig.json +0 -15
  206. package/template/apps/portal/vite.config.ts +0 -13
  207. package/template/feedbax.config.ts +0 -13
  208. package/template/feedbax.jsonc +0 -11
  209. package/template/pnpm-workspace.yaml +0 -3
  210. package/template/turbo.json +0 -8
@@ -0,0 +1,518 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import {
4
+ cleanup,
5
+ fireEvent,
6
+ render,
7
+ screen,
8
+ waitFor,
9
+ within,
10
+ } from "@testing-library/react";
11
+ import {
12
+ Outlet,
13
+ RouterProvider,
14
+ createMemoryHistory,
15
+ createRootRoute,
16
+ createRoute,
17
+ createRouter,
18
+ } from "@tanstack/react-router";
19
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
20
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
21
+ import { ThemeProvider } from "next-themes";
22
+ import type { PublicPostPage } from "@feedbax/feedback";
23
+
24
+ import Header from "./components/header";
25
+ import feedbax from "./feedbax";
26
+ import {
27
+ CommandPalette,
28
+ CommandPaletteProvider,
29
+ } from "./components/command-palette";
30
+ import { ChangelogPage } from "./changelog-page";
31
+ import { deviceProfileKey, readDeviceProfile } from "./browser-post-state";
32
+ import { PublicRoadmapView } from "./public-roadmap-view";
33
+ import { CreatePostOverlay } from "./create-post-overlay";
34
+ import { DeviceProfileProvider } from "./components/device-profile-provider";
35
+ import { saveDeviceProfile } from "./browser-post-state";
36
+
37
+ const portalFeedbackMutations = {
38
+ submitPost: async () => {
39
+ throw new Error("Not used in this test.");
40
+ },
41
+ editDraftPost: async () => {
42
+ throw new Error("Not used in this test.");
43
+ },
44
+ withdrawDraftPost: async () => {
45
+ throw new Error("Not used in this test.");
46
+ },
47
+ };
48
+
49
+ beforeEach(() => {
50
+ localStorage.clear();
51
+ document.documentElement.className = "";
52
+ window.ResizeObserver = class {
53
+ observe() {}
54
+ unobserve() {}
55
+ disconnect() {}
56
+ } as unknown as typeof ResizeObserver;
57
+ window.HTMLElement.prototype.scrollIntoView = function () {};
58
+ Object.defineProperty(window, "matchMedia", {
59
+ configurable: true,
60
+ value: (query: string): MediaQueryList =>
61
+ ({
62
+ matches: false,
63
+ media: query,
64
+ onchange: null,
65
+ addEventListener: () => undefined,
66
+ removeEventListener: () => undefined,
67
+ addListener: () => undefined,
68
+ removeListener: () => undefined,
69
+ dispatchEvent: () => true,
70
+ }) as MediaQueryList,
71
+ });
72
+ });
73
+ afterEach(cleanup);
74
+
75
+ describe("public portal shell", () => {
76
+ it("uses the configured product logo in the home link", async () => {
77
+ renderShell("/");
78
+
79
+ const logo = (
80
+ await screen.findByRole("link", { name: `${feedbax.product.name} home` })
81
+ ).querySelector("img");
82
+ if (!logo) throw new Error("Expected the home link to contain a logo.");
83
+ expect(logo.getAttribute("src")).toBe(feedbax.product.logo);
84
+ });
85
+
86
+ it("keeps the public navigation sticky while the page scrolls", async () => {
87
+ renderShell("/");
88
+
89
+ const header = (
90
+ await screen.findByRole("navigation", {
91
+ name: "Public portal",
92
+ })
93
+ ).closest("header");
94
+ expect(header?.classList.contains("sticky")).toBe(true);
95
+ expect(header?.classList.contains("top-0")).toBe(true);
96
+ });
97
+
98
+ it("offers skip navigation and global search on every destination", async () => {
99
+ renderShell("/p/keyboard-first-search");
100
+
101
+ expect(
102
+ (
103
+ await screen.findByRole("link", { name: "Skip to content" })
104
+ ).getAttribute("href"),
105
+ ).toBe("#main-content");
106
+ fireEvent.click(screen.getByRole("button", { name: "Search" }));
107
+
108
+ expect(
109
+ await screen.findByRole("dialog", { name: "Search feedback" }),
110
+ ).toBeTruthy();
111
+ });
112
+
113
+ it("offers direct destinations and identifies the current destination", async () => {
114
+ renderShell("/roadmap");
115
+
116
+ const navigation = await screen.findByRole("navigation", {
117
+ name: "Public portal",
118
+ });
119
+ expect(navigation.querySelector("[aria-current='page']")?.textContent).toBe(
120
+ "Roadmap",
121
+ );
122
+ expect(
123
+ screen.getByRole("link", { name: "Feedback" }).getAttribute("href"),
124
+ ).toBe("/");
125
+ expect(
126
+ screen.getByRole("link", { name: "Changelog" }).getAttribute("href"),
127
+ ).toBe("/changelog");
128
+ });
129
+
130
+ it.each(["/p/keyboard-first-search", "/submit"])(
131
+ "keeps Feedback current for the contextual route %s",
132
+ async (route) => {
133
+ renderShell(route);
134
+
135
+ const navigation = await screen.findByRole("navigation", {
136
+ name: "Public portal",
137
+ });
138
+ expect(
139
+ navigation.querySelector("[aria-current='page']")?.textContent,
140
+ ).toBe("Feedback");
141
+ },
142
+ );
143
+
144
+ it("opens visible New post actions contextually", async () => {
145
+ saveDeviceProfile(localStorage, {
146
+ name: "Ama",
147
+ email: "ama@example.com",
148
+ });
149
+ const { history } = renderShell("/roadmap");
150
+
151
+ await screen.findByText("Ama");
152
+ fireEvent.click(await screen.findByRole("button", { name: "New post" }));
153
+
154
+ expect(
155
+ await screen.findByRole("dialog", { name: "Create a Post" }),
156
+ ).toBeTruthy();
157
+ expect(document.body.textContent).toContain("Roadmap");
158
+ expect(history.location.pathname).toBe("/submit");
159
+ });
160
+
161
+ it("opens profile setup instead of Post creation when the profile is incomplete", async () => {
162
+ const { history } = renderShell("/roadmap");
163
+
164
+ fireEvent.click(await screen.findByRole("button", { name: "New post" }));
165
+
166
+ expect(await screen.findByRole("dialog", { name: "Profile" })).toBeTruthy();
167
+ expect(history.location.pathname).toBe("/roadmap");
168
+ expect(screen.queryByRole("dialog", { name: "Create a Post" })).toBeNull();
169
+ });
170
+
171
+ it("renders Changelog as a complete timeline destination", async () => {
172
+ renderShell("/changelog");
173
+
174
+ expect(
175
+ await screen.findByRole("heading", { level: 1, name: "Changelog" }),
176
+ ).toBeTruthy();
177
+ expect(screen.queryByRole("button", { name: "Follow updates" })).toBeNull();
178
+ });
179
+ });
180
+
181
+ describe("profile menu", () => {
182
+ it("opens profile setup directly when no Device Profile exists", async () => {
183
+ renderShell("/");
184
+
185
+ fireEvent.click(await screen.findByRole("button", { name: "Profile" }));
186
+
187
+ expect(await screen.findByLabelText("Display name")).toBeTruthy();
188
+ expect(
189
+ screen.getByText(
190
+ /Saved only on this device.*not sign-in.*cannot be recovered/i,
191
+ ),
192
+ ).toBeTruthy();
193
+ });
194
+
195
+ it("rejects an empty display name inline without saving", async () => {
196
+ renderShell("/");
197
+
198
+ fireEvent.click(await screen.findByRole("button", { name: "Profile" }));
199
+ fireEvent.click(
200
+ await screen.findByRole("button", { name: "Save profile" }),
201
+ );
202
+
203
+ expect(await screen.findByText("Display name is required.")).toBeTruthy();
204
+ expect(readDeviceProfile(localStorage)).toBeUndefined();
205
+ });
206
+
207
+ it("rejects an invalid email inline without saving", async () => {
208
+ renderShell("/");
209
+
210
+ fireEvent.click(await screen.findByRole("button", { name: "Profile" }));
211
+ fireEvent.change(await screen.findByLabelText("Display name"), {
212
+ target: { value: "Ama Mensah" },
213
+ });
214
+ fireEvent.change(screen.getByLabelText("Email"), {
215
+ target: { value: "not-an-email" },
216
+ });
217
+ fireEvent.click(screen.getByRole("button", { name: "Save profile" }));
218
+
219
+ expect(
220
+ await screen.findByText("Enter a valid email address."),
221
+ ).toBeTruthy();
222
+ expect(readDeviceProfile(localStorage)).toBeUndefined();
223
+ });
224
+
225
+ it("saves a Device Profile and manages it from the profile menu", async () => {
226
+ renderShell("/");
227
+
228
+ fireEvent.click(await screen.findByRole("button", { name: "Profile" }));
229
+ fireEvent.change(await screen.findByLabelText("Display name"), {
230
+ target: { value: "Ama Mensah" },
231
+ });
232
+ fireEvent.change(screen.getByLabelText("Email"), {
233
+ target: { value: "ama@example.com" },
234
+ });
235
+ fireEvent.click(screen.getByRole("button", { name: "Save profile" }));
236
+
237
+ await waitFor(() =>
238
+ expect(readDeviceProfile(localStorage)).toEqual({
239
+ name: "Ama Mensah",
240
+ email: "ama@example.com",
241
+ }),
242
+ );
243
+ fireEvent.click(await screen.findByRole("button", { name: "Profile" }));
244
+
245
+ expect(
246
+ await screen.findByRole("menuitem", { name: "Clear profile" }),
247
+ ).toBeTruthy();
248
+ fireEvent.click(screen.getByRole("menuitem", { name: "Clear profile" }));
249
+
250
+ await waitFor(() =>
251
+ expect(readDeviceProfile(localStorage)).toBeUndefined(),
252
+ );
253
+ expect(await screen.findByRole("button", { name: "Profile" })).toBeTruthy();
254
+ });
255
+
256
+ it("lets a Participant add the email required for commenting", async () => {
257
+ localStorage.setItem(deviceProfileKey, JSON.stringify({ name: "Ama" }));
258
+ renderShell("/");
259
+
260
+ await screen.findByText("Ama");
261
+ fireEvent.click(await screen.findByRole("button", { name: "Profile" }));
262
+ fireEvent.click(
263
+ await screen.findByRole("menuitem", { name: "Edit profile" }),
264
+ );
265
+
266
+ expect(
267
+ (await screen.findByLabelText("Display name")).getAttribute("value"),
268
+ ).toBe("Ama");
269
+ fireEvent.change(screen.getByLabelText("Email"), {
270
+ target: { value: "ama@example.com" },
271
+ });
272
+ fireEvent.click(screen.getByRole("button", { name: "Save profile" }));
273
+
274
+ await waitFor(() =>
275
+ expect(readDeviceProfile(localStorage)).toEqual({
276
+ name: "Ama",
277
+ email: "ama@example.com",
278
+ }),
279
+ );
280
+ });
281
+
282
+ it("keeps theme selection inside the configured profile menu", async () => {
283
+ localStorage.setItem(deviceProfileKey, JSON.stringify({ name: "Ama" }));
284
+ renderShell("/");
285
+
286
+ await screen.findByText("Ama");
287
+ fireEvent.click(screen.getByRole("button", { name: "Profile" }));
288
+ fireEvent.click(await screen.findByRole("menuitemradio", { name: "Dark" }));
289
+
290
+ await waitFor(() =>
291
+ expect(document.documentElement.classList.contains("dark")).toBe(true),
292
+ );
293
+ });
294
+ });
295
+
296
+ describe("command palette", () => {
297
+ it("labels search input and announces pending results", async () => {
298
+ renderShell("/", {
299
+ searchPosts: () => new Promise<PublicPostPage>(() => undefined),
300
+ });
301
+
302
+ await screen.findByRole("link", { name: "Feedback" });
303
+ fireEvent.keyDown(window, { key: "k", metaKey: true });
304
+ const input = await screen.findByPlaceholderText("Search feedback…");
305
+ expect(input.getAttribute("aria-label")).toBe("Search feedback");
306
+ fireEvent.change(input, { target: { value: "keyboard" } });
307
+
308
+ const status = await screen.findByRole("status", {
309
+ name: "Search status",
310
+ });
311
+ expect(status.closest("[role='listbox']")).toBeNull();
312
+ await waitFor(() => expect(status.textContent).toBe("Searching Posts…"));
313
+ });
314
+
315
+ it("opens with Cmd/Ctrl+K, closes on Escape, and restores focus", async () => {
316
+ const { history } = renderShell("/");
317
+
318
+ const feedbackLink = await screen.findByRole("link", {
319
+ name: "Feedback",
320
+ });
321
+ feedbackLink.focus();
322
+ fireEvent.keyDown(window, { key: "k", metaKey: true });
323
+
324
+ expect(await screen.findByRole("dialog")).toBeTruthy();
325
+
326
+ fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" });
327
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
328
+ expect(document.activeElement).toBe(feedbackLink);
329
+ expect(history.location.pathname).toBe("/");
330
+ });
331
+
332
+ it("searches Posts and opens the selected result", async () => {
333
+ const { history } = renderShell("/roadmap", {
334
+ searchPosts: async (term) =>
335
+ term.toLowerCase().includes("keyboard")
336
+ ? { items: [palettePost()], nextCursor: undefined }
337
+ : { items: [], nextCursor: undefined },
338
+ });
339
+
340
+ await screen.findByRole("link", { name: "Feedback" });
341
+ fireEvent.keyDown(window, { key: "k", ctrlKey: true });
342
+ fireEvent.change(await screen.findByPlaceholderText("Search feedback…"), {
343
+ target: { value: "keyboard" },
344
+ });
345
+
346
+ const palette = await screen.findByRole("dialog");
347
+ expect(
348
+ await within(palette).findByText("Keyboard-first search"),
349
+ ).toBeTruthy();
350
+ expect(
351
+ within(palette).getByText("Open search without reaching for the mouse."),
352
+ ).toBeTruthy();
353
+ expect(within(palette).getByText("Feature Request")).toBeTruthy();
354
+ expect(within(palette).getByText("Planned")).toBeTruthy();
355
+ fireEvent.keyDown(screen.getByPlaceholderText("Search feedback…"), {
356
+ key: "Enter",
357
+ });
358
+
359
+ await waitFor(() =>
360
+ expect(history.location.pathname).toBe("/p/keyboard-first-search"),
361
+ );
362
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
363
+
364
+ history.back();
365
+ await waitFor(() => expect(history.location.pathname).toBe("/roadmap"));
366
+ });
367
+
368
+ it("shows an empty state when no Posts match", async () => {
369
+ renderShell("/");
370
+
371
+ await screen.findByRole("link", { name: "Feedback" });
372
+ fireEvent.keyDown(window, { key: "k", metaKey: true });
373
+ fireEvent.change(await screen.findByPlaceholderText("Search feedback…"), {
374
+ target: { value: "zzz-nothing" },
375
+ });
376
+
377
+ expect(
378
+ (await screen.findAllByText("No Posts match this search.")).length,
379
+ ).toBeGreaterThan(0);
380
+ });
381
+
382
+ it("offers navigation actions including New post", async () => {
383
+ saveDeviceProfile(localStorage, {
384
+ name: "Ama",
385
+ email: "ama@example.com",
386
+ });
387
+ const { history } = renderShell("/");
388
+
389
+ await screen.findByText("Ama");
390
+ await screen.findByRole("link", { name: "Feedback" });
391
+ fireEvent.keyDown(window, { key: "k", metaKey: true });
392
+ expect(
393
+ await screen.findByRole("option", { name: "Feedback" }),
394
+ ).toBeTruthy();
395
+ expect(screen.getByRole("option", { name: "Roadmap" })).toBeTruthy();
396
+ expect(screen.getByRole("option", { name: "Changelog" })).toBeTruthy();
397
+ expect(screen.getByRole("option", { name: "New post" })).toBeTruthy();
398
+
399
+ const input = screen.getByPlaceholderText("Search feedback…");
400
+ fireEvent.keyDown(input, { key: "ArrowDown" });
401
+ fireEvent.keyDown(input, { key: "Enter" });
402
+
403
+ await waitFor(() => expect(history.location.pathname).toBe("/roadmap"));
404
+
405
+ fireEvent.keyDown(window, { key: "k", metaKey: true });
406
+ const reopenedInput =
407
+ await screen.findByPlaceholderText("Search feedback…");
408
+ fireEvent.keyDown(reopenedInput, { key: "ArrowDown" });
409
+ fireEvent.keyDown(reopenedInput, { key: "ArrowDown" });
410
+ fireEvent.keyDown(reopenedInput, { key: "ArrowDown" });
411
+ fireEvent.keyDown(reopenedInput, { key: "Enter" });
412
+
413
+ await waitFor(() => expect(history.location.pathname).toBe("/submit"));
414
+ });
415
+
416
+ it("opens from the roadmap Search button", async () => {
417
+ renderShell("/roadmap");
418
+
419
+ fireEvent.click(await screen.findByRole("button", { name: "Search" }));
420
+
421
+ expect(await screen.findByRole("dialog")).toBeTruthy();
422
+ });
423
+
424
+ it("opens from the Changelog Search button", async () => {
425
+ renderShell("/changelog");
426
+
427
+ fireEvent.click(await screen.findByRole("button", { name: "Search" }));
428
+
429
+ expect(await screen.findByRole("dialog")).toBeTruthy();
430
+ });
431
+ });
432
+
433
+ function palettePost() {
434
+ return {
435
+ slug: "keyboard-first-search",
436
+ title: "Keyboard-first search",
437
+ description: "Open search without reaching for the mouse.",
438
+ type: "Feature Request" as const,
439
+ status: "Planned" as const,
440
+ createdAt: new Date("2026-08-22T10:00:00.000Z"),
441
+ updatedAt: new Date("2026-08-22T10:00:00.000Z"),
442
+ };
443
+ }
444
+
445
+ function renderShell(
446
+ initialEntry: string,
447
+ {
448
+ searchPosts,
449
+ }: {
450
+ searchPosts?: (term: string) => Promise<PublicPostPage>;
451
+ } = {},
452
+ ) {
453
+ const history = createMemoryHistory({ initialEntries: [initialEntry] });
454
+ const queryClient = new QueryClient({
455
+ defaultOptions: { queries: { retry: false } },
456
+ });
457
+ const root = createRootRoute({
458
+ component: () => (
459
+ <QueryClientProvider client={queryClient}>
460
+ <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
461
+ <DeviceProfileProvider>
462
+ <CommandPaletteProvider>
463
+ <Header />
464
+ <Outlet />
465
+ <CommandPalette
466
+ searchPosts={
467
+ searchPosts ??
468
+ (async () => ({ items: [], nextCursor: undefined }))
469
+ }
470
+ />
471
+ <CreatePostOverlay mutations={portalFeedbackMutations} />
472
+ </CommandPaletteProvider>
473
+ </DeviceProfileProvider>
474
+ </ThemeProvider>
475
+ </QueryClientProvider>
476
+ ),
477
+ });
478
+ const index = createRoute({
479
+ getParentRoute: () => root,
480
+ path: "/",
481
+ component: () => <main>Feedback page</main>,
482
+ });
483
+ const roadmap = createRoute({
484
+ getParentRoute: () => root,
485
+ path: "/roadmap",
486
+ component: () => (
487
+ <PublicRoadmapView
488
+ roadmap={{
489
+ Planned: { items: [], totalCount: 0 },
490
+ "In Progress": { items: [], totalCount: 0 },
491
+ Shipped: { items: [], totalCount: 0 },
492
+ }}
493
+ maskPostLinks
494
+ />
495
+ ),
496
+ });
497
+ const changelog = createRoute({
498
+ getParentRoute: () => root,
499
+ path: "/changelog",
500
+ component: ChangelogPage,
501
+ });
502
+ const submit = createRoute({
503
+ getParentRoute: () => root,
504
+ path: "/submit",
505
+ component: () => <main>Create Post</main>,
506
+ });
507
+ const post = createRoute({
508
+ getParentRoute: () => root,
509
+ path: "/p/$slug",
510
+ component: () => <main>Post detail</main>,
511
+ });
512
+ const router = createRouter({
513
+ history,
514
+ routeTree: root.addChildren([index, roadmap, changelog, submit, post]),
515
+ });
516
+ render(<RouterProvider router={router} />);
517
+ return { history };
518
+ }
@@ -0,0 +1,21 @@
1
+ import type {
2
+ PublicPostRoadmap,
3
+ PublicRoadmapPage,
4
+ PublicRoadmapQuery,
5
+ } from "@feedbax/feedback";
6
+ import { queryOptions } from "@tanstack/react-query";
7
+
8
+ export type FetchPublicRoadmap = () => Promise<PublicPostRoadmap>;
9
+ export type FetchPublicRoadmapStatusPage = (
10
+ query: PublicRoadmapQuery,
11
+ ) => Promise<PublicRoadmapPage>;
12
+
13
+ export const publicRoadmapQueryKey = ["public-post-roadmap"] as const;
14
+
15
+ export function publicRoadmapQuery(fetchRoadmap: FetchPublicRoadmap) {
16
+ return queryOptions({
17
+ queryKey: publicRoadmapQueryKey,
18
+ queryFn: fetchRoadmap,
19
+ staleTime: 30_000,
20
+ });
21
+ }
@@ -0,0 +1,53 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { createPortalHead, createRouteHead } from "./portal-head";
4
+
5
+ describe("portal document branding", () => {
6
+ it("uses the resolved product logo as the favicon", () => {
7
+ expect(
8
+ createPortalHead({
9
+ name: "Acme",
10
+ description: "Acme feedback",
11
+ logo: "/brand/acme.svg",
12
+ }).links,
13
+ ).toContainEqual({
14
+ rel: "icon",
15
+ href: "/brand/acme.svg",
16
+ });
17
+ });
18
+ });
19
+
20
+ describe("route metadata", () => {
21
+ it("builds canonical and social metadata from the configured site URL", () => {
22
+ const head = createRouteHead(
23
+ {
24
+ name: "Acme",
25
+ description: "Acme feedback",
26
+ logo: "/logo.svg",
27
+ siteUrl: "https://feedback.acme.test",
28
+ },
29
+ {
30
+ title: "Roadmap",
31
+ description: "See what Acme is building.",
32
+ path: "/roadmap",
33
+ },
34
+ );
35
+ expect(head.meta).toContainEqual({ title: "Roadmap · Acme" });
36
+ expect(head.meta).toContainEqual({
37
+ property: "og:url",
38
+ content: "https://feedback.acme.test/roadmap",
39
+ });
40
+ expect(head.links).toEqual([
41
+ { rel: "canonical", href: "https://feedback.acme.test/roadmap" },
42
+ ]);
43
+ });
44
+
45
+ it("marks private-facing routes noindex", () => {
46
+ expect(
47
+ createRouteHead(
48
+ { name: "Acme", description: "Feedback", logo: "/logo.svg" },
49
+ { path: "/submit", noIndex: true },
50
+ ).meta,
51
+ ).toContainEqual({ name: "robots", content: "noindex" });
52
+ });
53
+ });