create-feedbax 0.2.0 → 0.2.2

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 (143) hide show
  1. package/README.md +17 -5
  2. package/dist/cli.js +2610 -291
  3. package/dist/index.js +2375 -200
  4. package/package.json +7 -7
  5. package/template/.feedbax-template.sha256 +1 -1
  6. package/template/.gitignore.template +1 -0
  7. package/template/.npmignore.template +1 -0
  8. package/template/.nvmrc +1 -0
  9. package/template/AGENTS.md +13 -0
  10. package/template/CONNECTED.md +418 -0
  11. package/template/README.md +97 -0
  12. package/template/components.json +1 -1
  13. package/template/eslint.config.mjs +23 -0
  14. package/template/feedbax-vite.ts +79 -0
  15. package/template/migrations/0001_connected.sql +6 -0
  16. package/template/migrations/0002_participation.sql +8 -0
  17. package/template/migrations/0003_images.sql +3 -0
  18. package/template/migrations/0004_followed_status.sql +9 -0
  19. package/template/migrations/0005_github.sql +4 -0
  20. package/template/migrations/0006_integration.sql +3 -0
  21. package/template/migrations/0007_ownership.sql +2 -0
  22. package/template/migrations/0008_integration_recovery.sql +5 -0
  23. package/template/migrations/0009_observed_history.sql +3 -0
  24. package/template/package.json +39 -34
  25. package/template/src/authorized-draft-post.tsx +1 -0
  26. package/template/src/changelog-page.tsx +63 -54
  27. package/template/src/changelog-runtime.ts +2 -1
  28. package/template/src/cloudflare-workers.d.ts +9 -0
  29. package/template/src/comment-creation.test.tsx +95 -7
  30. package/template/src/comment-thread-page.ts +11 -1
  31. package/template/src/components/command-palette.tsx +59 -30
  32. package/template/src/components/connected-identity-provider.tsx +135 -0
  33. package/template/src/components/device-profile-provider.tsx +41 -26
  34. package/template/src/components/follow-post-button.tsx +78 -0
  35. package/template/src/components/header.tsx +2 -0
  36. package/template/src/components/image-attachments.tsx +187 -0
  37. package/template/src/components/loader.tsx +48 -5
  38. package/template/src/components/notification-inbox.tsx +232 -0
  39. package/template/src/components/portal-footer.tsx +14 -0
  40. package/template/src/components/post-history.tsx +101 -0
  41. package/template/src/components/profile-menu.tsx +37 -0
  42. package/template/src/components/ui/components/command.tsx +30 -29
  43. package/template/src/components/ui/components/message-scroller.tsx +2 -2
  44. package/template/src/components/ui/components/scroll-area.tsx +0 -1
  45. package/template/src/components/ui/components/switch.tsx +29 -0
  46. package/template/src/components/ui/styles/globals.css +1 -1
  47. package/template/src/configured-feedback.test.ts +82 -0
  48. package/template/src/configured-feedback.ts +61 -0
  49. package/template/src/connected-identity.test.ts +148 -0
  50. package/template/src/connected-identity.ts +127 -0
  51. package/template/src/connected-participation.test.tsx +67 -0
  52. package/template/src/connected-runtime.test.ts +244 -0
  53. package/template/src/connected-runtime.ts +176 -0
  54. package/template/src/connected-social.test.tsx +158 -0
  55. package/template/src/feedback-cache-invalidation.ts +3 -0
  56. package/template/src/feedback-runtime.ts +123 -13
  57. package/template/src/feedbax/changelog/index.test.ts +35 -0
  58. package/template/src/feedbax/changelog/index.ts +5 -1
  59. package/template/src/feedbax/changelog/notion-changelog.test.ts +1 -1
  60. package/template/src/feedbax/changelog/notion-changelog.ts +1 -1
  61. package/template/src/feedbax/config/index.test.ts +73 -1
  62. package/template/src/feedbax/config/index.ts +205 -9
  63. package/template/src/feedbax/feedback/comment-storage.ts +38 -0
  64. package/template/src/feedbax/feedback/connected-feedback.ts +235 -0
  65. package/template/src/feedbax/feedback/connected.test.ts +272 -0
  66. package/template/src/feedbax/feedback/connected.ts +225 -0
  67. package/template/src/feedbax/feedback/email-delivery.test.ts +65 -0
  68. package/template/src/feedbax/feedback/email-delivery.ts +66 -0
  69. package/template/src/feedbax/feedback/feedback-storage.ts +1 -0
  70. package/template/src/feedbax/feedback/github-comments.ts +390 -0
  71. package/template/src/feedbax/feedback/github-feedback.test.ts +1138 -0
  72. package/template/src/feedbax/feedback/github-feedback.ts +581 -0
  73. package/template/src/feedbax/feedback/github-request.ts +120 -0
  74. package/template/src/feedbax/feedback/github-service-client.ts +56 -0
  75. package/template/src/feedbax/feedback/identity-handoff.test.ts +81 -0
  76. package/template/src/feedbax/feedback/identity-handoff.ts +119 -0
  77. package/template/src/feedbax/feedback/image-feedback.ts +114 -0
  78. package/template/src/feedbax/feedback/images.test.ts +223 -0
  79. package/template/src/feedbax/feedback/images.ts +350 -0
  80. package/template/src/feedbax/feedback/index.ts +226 -16
  81. package/template/src/feedbax/feedback/integration-auth.ts +138 -0
  82. package/template/src/feedbax/feedback/notion-comments.ts +2 -35
  83. package/template/src/feedbax/feedback/notion-image-review.ts +55 -0
  84. package/template/src/feedbax/feedback/observed-history.test.ts +150 -0
  85. package/template/src/feedbax/feedback/observed-history.ts +219 -0
  86. package/template/src/feedbax/feedback/ownership.ts +170 -0
  87. package/template/src/feedbax/feedback/participation.test.ts +653 -0
  88. package/template/src/feedbax/feedback/participation.ts +502 -0
  89. package/template/src/history-runtime.test.ts +80 -0
  90. package/template/src/history-runtime.ts +37 -0
  91. package/template/src/images-runtime.test.ts +133 -0
  92. package/template/src/images-runtime.ts +125 -0
  93. package/template/src/index.css +103 -440
  94. package/template/src/integration-runtime.test.ts +365 -0
  95. package/template/src/integration-runtime.ts +289 -0
  96. package/template/src/lazy-post-detail-overlay.tsx +13 -2
  97. package/template/src/my-feedback-page.test.tsx +152 -0
  98. package/template/src/my-feedback-page.tsx +398 -0
  99. package/template/src/my-feedback-runtime.test.ts +93 -0
  100. package/template/src/my-feedback-runtime.ts +154 -0
  101. package/template/src/overlay-post-detail.test.tsx +59 -8
  102. package/template/src/overlay-post-detail.tsx +20 -69
  103. package/template/src/participation-client.ts +63 -0
  104. package/template/src/participation-runtime.test.ts +35 -0
  105. package/template/src/participation-runtime.ts +255 -0
  106. package/template/src/portal-comments.ts +35 -5
  107. package/template/src/portal-feedback-form.tsx +29 -6
  108. package/template/src/portal-feedback-runtime.ts +3 -0
  109. package/template/src/portal-feedback-server-function.ts +6 -3
  110. package/template/src/portal-feedback-submission.ts +19 -0
  111. package/template/src/portal-server-entry.ts +13 -0
  112. package/template/src/post-creation-flow.tsx +2 -2
  113. package/template/src/post-detail-content.test.tsx +233 -0
  114. package/template/src/post-detail-content.tsx +162 -0
  115. package/template/src/post-detail-query.ts +34 -0
  116. package/template/src/post-results.tsx +1 -1
  117. package/template/src/public-cache-policy.test.ts +11 -4
  118. package/template/src/public-cache-policy.ts +1 -1
  119. package/template/src/public-feedback-index.tsx +9 -39
  120. package/template/src/public-feedback-page.test.tsx +65 -0
  121. package/template/src/public-feedback-page.ts +12 -1
  122. package/template/src/public-portal-error.tsx +1 -1
  123. package/template/src/public-post-detail-skeleton.tsx +54 -0
  124. package/template/src/public-post-detail.tsx +437 -271
  125. package/template/src/public-post-route.test.tsx +39 -3
  126. package/template/src/public-post-route.tsx +15 -28
  127. package/template/src/public-release-links.test.ts +120 -0
  128. package/template/src/public-release-links.ts +130 -0
  129. package/template/src/public-roadmap-route.test.tsx +38 -2
  130. package/template/src/public-roadmap-view.tsx +56 -23
  131. package/template/src/public-shell.test.tsx +26 -0
  132. package/template/src/routeTree.gen.ts +70 -3
  133. package/template/src/router.tsx +2 -0
  134. package/template/src/routes/__root.tsx +3 -5
  135. package/template/src/routes/email-preferences.tsx +77 -0
  136. package/template/src/routes/my-feedback.tsx +16 -0
  137. package/template/src/routes/submit.tsx +1 -1
  138. package/template/src/safe-public-failure.ts +3 -1
  139. package/template/src/server.ts +87 -2
  140. package/template/src/submission-intent.ts +26 -0
  141. package/template/src/trusted-feedback-handler.test.ts +1 -1
  142. package/template/src/vote-toggle.tsx +42 -9
  143. package/template/vite.config.ts +2 -0
@@ -13,7 +13,7 @@ import {
13
13
  } from "@testing-library/react";
14
14
  import {
15
15
  createMemoryHistory,
16
- createRootRoute,
16
+ createRootRouteWithContext,
17
17
  createRouter,
18
18
  Outlet,
19
19
  RouterProvider,
@@ -28,6 +28,8 @@ import {
28
28
  vi,
29
29
  } from "vitest";
30
30
 
31
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
32
+
31
33
  import { loadPublicPost } from "./public-post-page";
32
34
  import { createPublicPostRoute } from "./public-post-route";
33
35
  import { PublicPostUnavailable } from "./public-post-unavailable";
@@ -76,7 +78,7 @@ describe("/p/$slug", () => {
76
78
  expect(details.getByText("Submitted")).toBeTruthy();
77
79
  expect(details.getByText("Updated")).toBeTruthy();
78
80
  expect(history.location.pathname).toBe("/p/keyboard-first-search");
79
- expect(screen.getByText("Please add Vim bindings.")).toBeTruthy();
81
+ expect(await screen.findByText("Please add Vim bindings.")).toBeTruthy();
80
82
  expect(screen.getByText("Ari")).toBeTruthy();
81
83
  expect(document.body.textContent).not.toContain("secret-id");
82
84
  });
@@ -161,6 +163,30 @@ describe("/p/$slug", () => {
161
163
  "native-cursor",
162
164
  );
163
165
  });
166
+
167
+ it("renders the full-page Post before a slow Comment request completes", async () => {
168
+ const feedback = createFeedbackModule({ initialItems: [publishedPost] });
169
+ let finish!: (page: CommentThreadPage) => void;
170
+ const loadComments = vi.fn(
171
+ () =>
172
+ new Promise<CommentThreadPage>((resolve) => {
173
+ finish = resolve;
174
+ }),
175
+ );
176
+ const history = createMemoryHistory({
177
+ initialEntries: ["/p/keyboard-first-search"],
178
+ });
179
+ const router = createPostRouter({ feedback, history, loadComments });
180
+ render(<RouterProvider router={router} />);
181
+ expect(
182
+ await screen.findByRole("heading", { name: "Keyboard-first search" }),
183
+ ).toBeTruthy();
184
+ expect(
185
+ screen.getByRole("status", { name: "Loading Comments" }),
186
+ ).toBeTruthy();
187
+ finish({ items: [] });
188
+ expect(await screen.findByText("No comments yet")).toBeTruthy();
189
+ });
164
190
  });
165
191
 
166
192
  function createPostRouter({
@@ -172,7 +198,16 @@ function createPostRouter({
172
198
  history: ReturnType<typeof createMemoryHistory>;
173
199
  loadComments?: (slug: string, cursor?: string) => Promise<CommentThreadPage>;
174
200
  }) {
175
- const rootRoute = createRootRoute({ component: () => <Outlet /> });
201
+ const queryClient = new QueryClient({
202
+ defaultOptions: { queries: { retry: false } },
203
+ });
204
+ const rootRoute = createRootRouteWithContext<{ queryClient: QueryClient }>()({
205
+ component: () => (
206
+ <QueryClientProvider client={queryClient}>
207
+ <Outlet />
208
+ </QueryClientProvider>
209
+ ),
210
+ });
176
211
  const postRoute = createPublicPostRoute({
177
212
  loadPost: (slug) => loadPublicPost({ feedback, slug }),
178
213
  loadComments:
@@ -185,6 +220,7 @@ function createPostRouter({
185
220
  getParentRoute: () => rootRoute,
186
221
  } as never);
187
222
  return createRouter({
223
+ context: { queryClient },
188
224
  history,
189
225
  routeTree: rootRoute.addChildren([postRoute]),
190
226
  });
@@ -1,15 +1,14 @@
1
1
  import type { CommentThreadPage, PublicPost } from "@feedbax/feedback";
2
2
  import { createFileRoute } from "@tanstack/react-router";
3
- import { type ReactNode, useState } from "react";
3
+ import { useSuspenseQuery } from "@tanstack/react-query";
4
+ import { type ReactNode } from "react";
4
5
  import type { PortalCommentRequest } from "./portal-comments";
5
- import { mergeCommentThreads } from "./comment-thread-page";
6
+ import { PostDetailContent } from "./post-detail-content";
6
7
  import feedbax from "./feedbax";
7
8
  import { createRouteHead } from "./portal-head";
8
9
 
9
- import {
10
- PublicPostDetail,
11
- PublicPostDetailSkeleton,
12
- } from "./public-post-detail";
10
+ import { PublicPostDetailSkeleton } from "./public-post-detail-skeleton";
11
+ import { publicPostQuery } from "./post-detail-query";
13
12
 
14
13
  export function createPublicPostRoute({
15
14
  loadPost,
@@ -21,11 +20,11 @@ export function createPublicPostRoute({
21
20
  renderUnavailable: (slug: string) => ReactNode;
22
21
  }) {
23
22
  const route = createFileRoute("/p/$slug")({
24
- loader: async ({ params }) => {
25
- const post = await loadPost(params.slug);
26
- const comments =
27
- post && loadComments ? await loadComments(params.slug) : { items: [] };
28
- return { post, comments };
23
+ loader: async ({ params, context }) => {
24
+ const post = await context.queryClient.ensureQueryData(
25
+ publicPostQuery(params.slug, loadPost),
26
+ );
27
+ return { post };
29
28
  },
30
29
  head: ({ loaderData, params }) =>
31
30
  loaderData?.post
@@ -45,26 +44,14 @@ export function createPublicPostRoute({
45
44
  });
46
45
 
47
46
  function PostRoute() {
48
- const data = route.useLoaderData();
49
47
  const { slug } = route.useParams();
50
- const [comments, setComments] = useState(data.comments);
51
- return data.post ? (
52
- <PublicPostDetail
53
- post={data.post}
54
- comments={comments}
48
+ const { data: post } = useSuspenseQuery(publicPostQuery(slug, loadPost));
49
+ return post ? (
50
+ <PostDetailContent
51
+ post={post}
52
+ loadComments={loadComments}
55
53
  submitComment={submitPublicComment}
56
54
  mutateComment={mutatePublicComment}
57
- loadMore={
58
- loadComments && comments.nextCursor
59
- ? async () => {
60
- const next = await loadComments(slug, comments.nextCursor);
61
- setComments({
62
- items: mergeCommentThreads(comments.items, next.items),
63
- ...(next.nextCursor ? { nextCursor: next.nextCursor } : {}),
64
- });
65
- }
66
- : undefined
67
- }
68
55
  />
69
56
  ) : (
70
57
  renderUnavailable(slug)
@@ -0,0 +1,120 @@
1
+ import { expect, it } from "vitest";
2
+ import { createFeedbackModule } from "@feedbax/feedback";
3
+ import {
4
+ createChangelogModule,
5
+ createInMemoryChangelogStorage,
6
+ } from "@feedbax/changelog";
7
+ import { createPublicReleaseFeedbackModule } from "./public-release-links";
8
+
9
+ it("links only published local Changelog Entries or safe explicit external release notes", async () => {
10
+ const entry = {
11
+ id: "entry",
12
+ slug: "released-export",
13
+ title: "Export",
14
+ summary: "Public",
15
+ body: "Public",
16
+ date: "2026-10-15" as const,
17
+ labels: [],
18
+ images: [],
19
+ published: true,
20
+ createdAt: new Date(1000),
21
+ updatedAt: new Date(1000),
22
+ };
23
+ const changelog = createChangelogModule({
24
+ storage: createInMemoryChangelogStorage([
25
+ entry,
26
+ { ...entry, id: "draft", slug: "private-release", published: false },
27
+ ]),
28
+ });
29
+ const post = {
30
+ id: "p",
31
+ slug: "export",
32
+ title: "Export",
33
+ description: "Public",
34
+ type: "Feature Request" as const,
35
+ status: "Shipped" as const,
36
+ published: true,
37
+ createdAt: new Date(1000),
38
+ updatedAt: new Date(1000),
39
+ };
40
+ const module = (url: string) =>
41
+ createPublicReleaseFeedbackModule({
42
+ feedback: createFeedbackModule({
43
+ initialItems: [{ ...post, releaseNotesUrl: url }],
44
+ }),
45
+ siteUrl: "https://portal.example",
46
+ changelog,
47
+ });
48
+ expect(
49
+ (await module("/changelog#released-export").getPublicPost(post.slug))
50
+ ?.releaseNotesUrl,
51
+ ).toBe("/changelog#released-export");
52
+ for (const url of [
53
+ "/changelog#private-release",
54
+ "/internal/private",
55
+ "//evil.example",
56
+ "https://user:password@release.example",
57
+ "javascript:alert(1)",
58
+ "https://portal.example/changelog#private-release",
59
+ "https://github.com/private-team/private-repo/issues/1",
60
+ "https://github.com/private-team/private-repo/pull/2",
61
+ "https://github.com/private-team/private-repo/%69ssues/1",
62
+ "https://github.com./private-team/private-repo/issues/1",
63
+ ])
64
+ expect(
65
+ (await module(url).getPublicPost(post.slug))?.releaseNotesUrl,
66
+ ).toBeUndefined();
67
+ expect(
68
+ (
69
+ await module("https://example.com/releases/export").getPublicPost(
70
+ post.slug,
71
+ )
72
+ )?.releaseNotesUrl,
73
+ ).toBe("https://example.com/releases/export");
74
+ expect(
75
+ (await module("/changelog#private-release").listPublicPosts()).items[0]
76
+ ?.releaseNotesUrl,
77
+ ).toBeUndefined();
78
+ });
79
+
80
+ it("checks current public visibility before exposing a GitHub release URL", async () => {
81
+ const post = {
82
+ id: "p",
83
+ slug: "export",
84
+ title: "Export",
85
+ description: "Public",
86
+ type: "Feature Request" as const,
87
+ status: "Shipped" as const,
88
+ published: true,
89
+ createdAt: new Date(1000),
90
+ updatedAt: new Date(1000),
91
+ };
92
+ const module = (owner: string) =>
93
+ createPublicReleaseFeedbackModule({
94
+ feedback: createFeedbackModule({
95
+ initialItems: [
96
+ {
97
+ ...post,
98
+ releaseNotesUrl: `https://github.com/${owner}/feedback/releases/tag/v1`,
99
+ },
100
+ ],
101
+ }),
102
+ siteUrl: "https://portal.example",
103
+ request: async (input, init) => {
104
+ expect(String(input)).toBe(
105
+ `https://api.github.com/repos/${owner}/feedback`,
106
+ );
107
+ expect(init?.redirect).toBe("manual");
108
+ expect(new Headers(init?.headers).has("Authorization")).toBe(false);
109
+ return owner === "public-team"
110
+ ? Response.json({ id: 123, private: false })
111
+ : new Response(null, { status: 404 });
112
+ },
113
+ });
114
+ expect(
115
+ (await module("public-team").getPublicPost(post.slug))?.releaseNotesUrl,
116
+ ).toBe("https://github.com/public-team/feedback/releases/tag/v1");
117
+ expect(
118
+ (await module("private-team").getPublicPost(post.slug))?.releaseNotesUrl,
119
+ ).toBeUndefined();
120
+ });
@@ -0,0 +1,130 @@
1
+ import type { FeedbackModule, PublicPost } from "@feedbax/feedback";
2
+ import type { ChangelogModule } from "@feedbax/changelog";
3
+
4
+ export function createPublicReleaseFeedbackModule(options: {
5
+ feedback: FeedbackModule;
6
+ siteUrl?: string;
7
+ changelog?: ChangelogModule;
8
+ request?: typeof fetch;
9
+ }): FeedbackModule {
10
+ const { feedback } = options;
11
+ let published: Promise<Set<string>> | undefined;
12
+ const publishedSlugs = () =>
13
+ (published ??= (async () => {
14
+ const slugs = new Set<string>();
15
+ if (!options.changelog) return slugs;
16
+ let cursor: string | undefined;
17
+ for (let page = 0; page < 10; page++) {
18
+ const result = await options.changelog.listPublishedEntries({ cursor });
19
+ for (const entry of result.items) slugs.add(entry.slug);
20
+ cursor = result.nextCursor;
21
+ if (!cursor) break;
22
+ }
23
+ return slugs;
24
+ })());
25
+ const resolve = async (value: string) => {
26
+ if (
27
+ /[\\\s]/u.test(value) ||
28
+ Array.from(value).some(
29
+ (character) =>
30
+ character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127,
31
+ )
32
+ )
33
+ return;
34
+ try {
35
+ const url = new URL(value, options.siteUrl ?? "https://feedbax.invalid");
36
+ if (
37
+ url.protocol !== "https:" ||
38
+ url.username ||
39
+ url.password ||
40
+ value.startsWith("//")
41
+ )
42
+ return;
43
+ const local =
44
+ value.startsWith("/") ||
45
+ (options.siteUrl && url.origin === new URL(options.siteUrl).origin);
46
+ if (!local) {
47
+ const host = url.hostname.toLowerCase().replace(/\.+$/, "");
48
+ if (
49
+ [
50
+ "api.github.com",
51
+ "gist.github.com",
52
+ "raw.githubusercontent.com",
53
+ ].includes(host)
54
+ )
55
+ return;
56
+ if (host === "github.com" || host === "www.github.com") {
57
+ const path = decodeURIComponent(url.pathname);
58
+ const release = path.match(
59
+ /^\/([A-Za-z0-9-]+)\/([A-Za-z0-9_.-]+)\/releases(?:\/|$)/,
60
+ );
61
+ if (!release || path.includes("%")) return;
62
+ const response = await (options.request ?? fetch)(
63
+ `https://api.github.com/repos/${release[1]}/${release[2]}`,
64
+ {
65
+ redirect: "manual",
66
+ signal: AbortSignal.timeout(5000),
67
+ headers: {
68
+ Accept: "application/vnd.github+json",
69
+ "User-Agent": "Feedbax",
70
+ "X-GitHub-Api-Version": "2026-03-10",
71
+ },
72
+ },
73
+ );
74
+ if (!response.ok) return;
75
+ const repository: unknown = await response.json();
76
+ if (
77
+ !repository ||
78
+ typeof repository !== "object" ||
79
+ Reflect.get(repository, "private") !== false
80
+ )
81
+ return;
82
+ }
83
+ return value;
84
+ }
85
+ if (
86
+ url.pathname !== "/changelog" ||
87
+ url.search ||
88
+ !/^#[a-z0-9]+(?:-[a-z0-9]+)*$/.test(url.hash)
89
+ )
90
+ return;
91
+ return (await publishedSlugs()).has(url.hash.slice(1))
92
+ ? value
93
+ : undefined;
94
+ } catch {
95
+ return;
96
+ }
97
+ };
98
+ const project = async <T extends PublicPost>(post: T): Promise<T> => {
99
+ if (!post.releaseNotesUrl) return post;
100
+ const url = await resolve(post.releaseNotesUrl);
101
+ const result = { ...post };
102
+ if (url) result.releaseNotesUrl = url;
103
+ else delete result.releaseNotesUrl;
104
+ return result;
105
+ };
106
+ const roadmap: FeedbackModule["listPublicRoadmapPosts"] = async (query) => {
107
+ const page = await feedback.listPublicRoadmapPosts(query);
108
+ return { ...page, items: await Promise.all(page.items.map(project)) };
109
+ };
110
+ return {
111
+ ...feedback,
112
+ async getPublicPost(slug) {
113
+ const post = await feedback.getPublicPost(slug);
114
+ return post ? project(post) : undefined;
115
+ },
116
+ async listPublicPosts(query) {
117
+ const page = await feedback.listPublicPosts(query);
118
+ return { ...page, items: await Promise.all(page.items.map(project)) };
119
+ },
120
+ listPublicRoadmapPosts: roadmap,
121
+ async getPublicPostRoadmap() {
122
+ const [planned, progress, shipped] = await Promise.all([
123
+ roadmap({ status: "Planned" }),
124
+ roadmap({ status: "In Progress" }),
125
+ roadmap({ status: "Shipped" }),
126
+ ]);
127
+ return { Planned: planned, "In Progress": progress, Shipped: shipped };
128
+ },
129
+ };
130
+ }
@@ -128,7 +128,7 @@ describe("/roadmap route", () => {
128
128
  render(<RouterProvider router={router} />);
129
129
 
130
130
  const loadMore = await screen.findByRole("button", {
131
- name: "Load more Planned Posts",
131
+ name: "View more Planned Posts",
132
132
  });
133
133
  fireEvent.click(loadMore);
134
134
 
@@ -152,7 +152,7 @@ describe("/roadmap route", () => {
152
152
  ).toBeTruthy();
153
153
  await waitFor(() =>
154
154
  expect(
155
- screen.queryByRole("button", { name: "Load more Planned Posts" }),
155
+ screen.queryByRole("button", { name: "View more Planned Posts" }),
156
156
  ).toBeNull(),
157
157
  );
158
158
  expect(
@@ -163,6 +163,42 @@ describe("/roadmap route", () => {
163
163
  ).toBe("1 more Planned Post loaded.");
164
164
  });
165
165
 
166
+ it("reveals five cards at a time before requesting another server page", async () => {
167
+ const items = Array.from({ length: 12 }, (_, index) =>
168
+ post(`planned-${index}`, "Planned"),
169
+ );
170
+ const loadMore = vi.fn().mockResolvedValue({
171
+ items: [post("planned-12", "Planned")],
172
+ totalCount: 13,
173
+ });
174
+ render(
175
+ <PublicRoadmapView
176
+ roadmap={{
177
+ Planned: { items, totalCount: 13, nextCursor: "next-page" },
178
+ "In Progress": { items: [], totalCount: 0 },
179
+ Shipped: { items: [], totalCount: 0 },
180
+ }}
181
+ loadMore={loadMore}
182
+ />,
183
+ );
184
+ expect(screen.getAllByRole("link")).toHaveLength(5);
185
+ const button = screen.getByRole("button", {
186
+ name: "View more Planned Posts",
187
+ });
188
+ fireEvent.click(button);
189
+ expect(screen.getAllByRole("link")).toHaveLength(10);
190
+ expect(loadMore).not.toHaveBeenCalled();
191
+ fireEvent.click(button);
192
+ expect(
193
+ await screen.findByRole("link", { name: /planned-12 / }),
194
+ ).toBeTruthy();
195
+ expect(screen.getAllByRole("link")).toHaveLength(13);
196
+ expect(loadMore).toHaveBeenCalledOnce();
197
+ expect(
198
+ screen.queryByRole("button", { name: "View more Planned Posts" }),
199
+ ).toBeNull();
200
+ });
201
+
166
202
  it("shows one status column at a time behind mobile status tabs", async () => {
167
203
  Object.defineProperty(window, "matchMedia", {
168
204
  configurable: true,
@@ -50,6 +50,12 @@ const roadmapGroups = [
50
50
  ] as const;
51
51
 
52
52
  const mobileMediaQuery = "(max-width: 1023px)";
53
+ const previewSize = 5;
54
+ const initialVisible = {
55
+ Planned: previewSize,
56
+ "In Progress": previewSize,
57
+ Shipped: previewSize,
58
+ };
53
59
 
54
60
  export function PublicRoadmapView({
55
61
  roadmap,
@@ -63,6 +69,7 @@ export function PublicRoadmapView({
63
69
  const mobile = useMediaQuery(mobileMediaQuery);
64
70
  const [activeStatus, setActiveStatus] = useState<RoadmapStatus>("Planned");
65
71
  const [columns, setColumns] = useState(roadmap);
72
+ const [visible, setVisible] = useState(initialVisible);
66
73
  const [loading, setLoading] = useState<Partial<Record<RoadmapStatus, true>>>(
67
74
  {},
68
75
  );
@@ -71,7 +78,10 @@ export function PublicRoadmapView({
71
78
  );
72
79
  const [loadStatusMessage, setLoadStatusMessage] = useState("");
73
80
 
74
- useEffect(() => setColumns(roadmap), [roadmap]);
81
+ useEffect(() => {
82
+ setColumns(roadmap);
83
+ setVisible(initialVisible);
84
+ }, [roadmap]);
75
85
 
76
86
  const visibleGroups = mobile
77
87
  ? roadmapGroups.filter(({ status }) => status === activeStatus)
@@ -89,13 +99,26 @@ export function PublicRoadmapView({
89
99
  return next;
90
100
  });
91
101
  try {
92
- const nextPage = await loadMore(query);
93
- setColumns((current) => ({
94
- ...current,
95
- [query.status]: appendRoadmapPage(current[query.status], nextPage),
96
- }));
102
+ const before = Math.min(
103
+ visible[query.status],
104
+ columns[query.status].items.length,
105
+ );
106
+ const target = before + previewSize;
107
+ let combined = columns[query.status];
108
+ const cursors = new Set<string>();
109
+ do {
110
+ if (!combined.nextCursor || cursors.has(combined.nextCursor)) break;
111
+ cursors.add(combined.nextCursor);
112
+ combined = appendRoadmapPage(
113
+ combined,
114
+ await loadMore({ status: query.status, cursor: combined.nextCursor }),
115
+ );
116
+ } while (combined.items.length < target && combined.nextCursor);
117
+ setColumns((current) => ({ ...current, [query.status]: combined }));
118
+ const count = Math.min(target, combined.items.length) - before;
119
+ setVisible((current) => ({ ...current, [query.status]: before + count }));
97
120
  setLoadStatusMessage(
98
- `${nextPage.items.length} more ${group.heading} ${nextPage.items.length === 1 ? "Post" : "Posts"} loaded.`,
121
+ `${count} more ${group.heading} ${count === 1 ? "Post" : "Posts"} loaded.`,
99
122
  );
100
123
  } catch {
101
124
  setErrors((current) => ({ ...current, [query.status]: true }));
@@ -168,7 +191,7 @@ export function PublicRoadmapView({
168
191
  <RoadmapEmptyState heading={heading} />
169
192
  ) : (
170
193
  <ol className="grid gap-3">
171
- {column.items.map((item) => (
194
+ {column.items.slice(0, visible[status]).map((item) => (
172
195
  <RoadmapItem
173
196
  item={item}
174
197
  key={item.slug}
@@ -200,26 +223,36 @@ export function PublicRoadmapView({
200
223
  </Button>
201
224
  </div>
202
225
  ) : null}
203
- {column.nextCursor && !errors[status] && loadMore ? (
226
+ {(column.items.length > visible[status] ||
227
+ (column.nextCursor && loadMore)) &&
228
+ !errors[status] ? (
204
229
  <Button
205
- aria-label={`Load more ${heading} Posts`}
230
+ aria-label={`View more ${heading} Posts`}
206
231
  className="mt-4 h-10 w-full gap-2"
207
232
  disabled={loading[status] === true}
208
233
  type="button"
209
234
  variant="ghost"
210
- onClick={() =>
211
- void loadNextPage({
212
- status,
213
- cursor: column.nextCursor,
214
- })
215
- }
235
+ onClick={() => {
236
+ if (
237
+ column.items.length - visible[status] >= previewSize ||
238
+ !column.nextCursor ||
239
+ !loadMore
240
+ ) {
241
+ setVisible((current) => ({
242
+ ...current,
243
+ [status]: current[status] + previewSize,
244
+ }));
245
+ } else if (column.nextCursor) {
246
+ void loadNextPage({ status, cursor: column.nextCursor });
247
+ }
248
+ }}
216
249
  >
217
250
  {loading[status] ? (
218
251
  <>
219
252
  <Spinner aria-hidden="true" /> Loading…
220
253
  </>
221
254
  ) : (
222
- "Load more"
255
+ "View more"
223
256
  )}
224
257
  </Button>
225
258
  ) : null}
@@ -306,12 +339,12 @@ function RoadmapIntro() {
306
339
  return (
307
340
  <section aria-labelledby="roadmap-heading">
308
341
  <h1
309
- className="text-3xl font-semibold tracking-tight"
342
+ className="text-2xl font-semibold tracking-tight"
310
343
  id="roadmap-heading"
311
344
  >
312
345
  Roadmap
313
346
  </h1>
314
- <p className="mt-2 text-base text-muted-foreground">
347
+ <p className="mt-2 text-sm text-muted-foreground">
315
348
  What we’re planning, building, and shipping.
316
349
  </p>
317
350
  </section>
@@ -342,15 +375,15 @@ function RoadmapItem({ item, masked }: { item: PublicPost; masked: boolean }) {
342
375
  contextual={masked}
343
376
  className="group block border bg-background/30 transition-colors duration-150 hover:bg-muted/30"
344
377
  >
345
- <article className="p-5">
346
- <h3 className="text-base font-medium group-hover:text-foreground">
378
+ <article className="flex h-40 flex-col p-4">
379
+ <h3 className="line-clamp-2 min-h-10 text-sm font-medium leading-5 group-hover:text-foreground">
347
380
  {item.title}
348
381
  </h3>
349
- <p className="mt-2 text-sm leading-relaxed text-muted-foreground">
382
+ <p className="mt-2 line-clamp-2 min-h-10 text-sm leading-5 text-muted-foreground">
350
383
  {item.description}
351
384
  </p>
352
385
  <div
353
- className="mt-4 flex flex-wrap items-center gap-3 text-xs text-muted-foreground"
386
+ className="mt-auto flex items-center gap-3 pt-3 text-xs text-muted-foreground"
354
387
  role="group"
355
388
  aria-label="Post metadata"
356
389
  >
@@ -365,6 +365,32 @@ describe("command palette", () => {
365
365
  await waitFor(() => expect(history.location.pathname).toBe("/roadmap"));
366
366
  });
367
367
 
368
+ it("keeps previous search results visible while a new term is loading", async () => {
369
+ let finish: ((page: PublicPostPage) => void) | undefined;
370
+ renderShell("/", {
371
+ searchPosts: (term) =>
372
+ term === "keyboard"
373
+ ? Promise.resolve({ items: [palettePost()] })
374
+ : new Promise((resolve) => {
375
+ finish = resolve;
376
+ }),
377
+ });
378
+ await screen.findByRole("link", { name: "Feedback" });
379
+ fireEvent.keyDown(window, { key: "k", metaKey: true });
380
+ const input = await screen.findByPlaceholderText("Search feedback…");
381
+ fireEvent.change(input, { target: { value: "keyboard" } });
382
+ await screen.findByText("Keyboard-first search");
383
+ fireEvent.change(input, { target: { value: "next search" } });
384
+ await waitFor(() => expect(finish).toBeTypeOf("function"));
385
+ expect(screen.getByText("Keyboard-first search")).toBeTruthy();
386
+ finish?.({ items: [] });
387
+ await waitFor(() =>
388
+ expect(
389
+ screen.getByRole("status", { name: "Search status" }).textContent,
390
+ ).toBe("No Posts match this search."),
391
+ );
392
+ });
393
+
368
394
  it("shows an empty state when no Posts match", async () => {
369
395
  renderShell("/");
370
396