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
@@ -1,4 +1,5 @@
1
1
  import js from "@eslint/js";
2
+ import { plugin as shadcn } from "@shadcn/lint";
2
3
  import globals from "globals";
3
4
  import tseslint from "typescript-eslint";
4
5
 
@@ -17,9 +18,31 @@ export default tseslint.config(
17
18
  js.configs.recommended,
18
19
  ...tseslint.configs.recommended,
19
20
  {
21
+ plugins: { shadcn },
20
22
  languageOptions: {
21
23
  globals: { ...globals.browser, ...globals.node },
22
24
  parserOptions: { tsconfigRootDir: import.meta.dirname },
23
25
  },
24
26
  },
27
+ {
28
+ files: ["**/*.{jsx,tsx}"],
29
+ rules: {
30
+ "shadcn/no-raw-colors": "error",
31
+ // Cloudflare's script discovers these challenge containers by class name.
32
+ "shadcn/no-unknown-classes": ["error", { allow: ["cf-turnstile"] }],
33
+ "shadcn/require-static-classes": "error",
34
+ },
35
+ },
36
+ {
37
+ // Shared primitives own their class forwarding; application usage is checked.
38
+ files: ["packages/ui/src/components/**", "src/components/ui/**"],
39
+ rules: { "shadcn/require-static-classes": "off" },
40
+ },
41
+ {
42
+ // The upstream Sonner wrapper keeps this marker for toast customization.
43
+ files: ["**/components/sonner.tsx"],
44
+ rules: {
45
+ "shadcn/no-unknown-classes": ["error", { allow: ["toaster"] }],
46
+ },
47
+ },
25
48
  );
@@ -0,0 +1,79 @@
1
+ import { build } from "esbuild";
2
+ import { existsSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ import type { Plugin, ResolvedConfig } from "vite";
5
+
6
+ // Only the server receives provider identifiers and operational configuration.
7
+ // The client gets a fixed allowlist, even when copied configuration is customized.
8
+ export function publicFeedbaxConfiguration(): Plugin {
9
+ let configuration: ResolvedConfig;
10
+ return {
11
+ name: "feedbax-public-configuration",
12
+ enforce: "pre",
13
+ configResolved(value) {
14
+ configuration = value;
15
+ },
16
+ async transform(code, id) {
17
+ const root = configuration.root;
18
+ if (
19
+ this.environment.name !== "client" ||
20
+ id.split("?")[0] !== resolve(root, "src/feedbax.ts")
21
+ )
22
+ return;
23
+ const localConfig = resolve(root, "src/feedbax/config/index.ts");
24
+ const result = await build({
25
+ stdin: {
26
+ contents: code,
27
+ loader: "ts",
28
+ resolveDir: resolve(root, "src"),
29
+ },
30
+ bundle: true,
31
+ write: false,
32
+ platform: "node",
33
+ format: "esm",
34
+ alias: {
35
+ "@feedbax/config": existsSync(localConfig)
36
+ ? localConfig
37
+ : resolve(root, "../../packages/config/src/index.ts"),
38
+ },
39
+ define: { "import.meta.env": JSON.stringify(configuration.env) },
40
+ });
41
+ const full = (
42
+ await import(
43
+ `data:text/javascript;base64,${Buffer.from(result.outputFiles[0]!.text).toString("base64")}`
44
+ )
45
+ ).default;
46
+ const publicConfiguration = {
47
+ product: full.product,
48
+ theme: full.theme,
49
+ features: full.features,
50
+ submissions: full.submissions,
51
+ turnstile: full.turnstile,
52
+ history: full.history,
53
+ ...(full.connected
54
+ ? {
55
+ connected: {
56
+ issuer: full.connected.issuer,
57
+ loginUrl: full.connected.loginUrl,
58
+ images: full.connected.images,
59
+ ...(full.connected.email
60
+ ? { email: { provider: full.connected.email.provider } }
61
+ : {}),
62
+ },
63
+ }
64
+ : {}),
65
+ };
66
+ const publicCode = `export default ${JSON.stringify(publicConfiguration)};`;
67
+ return {
68
+ code: publicCode,
69
+ map: {
70
+ version: 3,
71
+ sources: ["feedbax-public-configuration.ts"],
72
+ sourcesContent: [publicCode],
73
+ names: [],
74
+ mappings: "",
75
+ },
76
+ };
77
+ },
78
+ };
79
+ }
@@ -0,0 +1,6 @@
1
+ CREATE TABLE IF NOT EXISTS participants (subject TEXT PRIMARY KEY NOT NULL, display_name TEXT NOT NULL);
2
+ CREATE TABLE IF NOT EXISTS identity_nonces (nonce TEXT PRIMARY KEY NOT NULL, expires_at INTEGER NOT NULL);
3
+ CREATE TABLE IF NOT EXISTS participant_sessions (token_hash TEXT PRIMARY KEY NOT NULL, subject TEXT NOT NULL REFERENCES participants(subject), expires_at INTEGER NOT NULL);
4
+ CREATE TABLE IF NOT EXISTS participant_votes (post_slug TEXT NOT NULL, subject TEXT NOT NULL REFERENCES participants(subject), PRIMARY KEY(post_slug, subject));
5
+ CREATE TABLE IF NOT EXISTS comment_attribution (comment_id TEXT PRIMARY KEY NOT NULL, subject TEXT NOT NULL REFERENCES participants(subject), display_name TEXT NOT NULL);
6
+ CREATE INDEX IF NOT EXISTS participant_sessions_expiry ON participant_sessions(expires_at);
@@ -0,0 +1,8 @@
1
+ CREATE TABLE IF NOT EXISTS post_follows (post_slug TEXT NOT NULL, subject TEXT NOT NULL REFERENCES participants(subject), followed_at INTEGER NOT NULL, PRIMARY KEY(post_slug, subject));
2
+ CREATE INDEX IF NOT EXISTS post_follows_subject ON post_follows(subject);
3
+ CREATE TABLE IF NOT EXISTS participant_contacts (subject TEXT PRIMARY KEY REFERENCES participants(subject), email TEXT, email_enabled INTEGER NOT NULL DEFAULT 0);
4
+ CREATE TABLE IF NOT EXISTS post_observations (post_slug TEXT PRIMARY KEY, status TEXT NOT NULL, checked_at INTEGER NOT NULL, lease_until INTEGER NOT NULL DEFAULT 0, comment_cursor TEXT, lease_token TEXT);
5
+ CREATE TABLE IF NOT EXISTS post_events (id TEXT PRIMARY KEY, post_slug TEXT NOT NULL, kind TEXT NOT NULL, title TEXT NOT NULL, message TEXT NOT NULL, created_at INTEGER NOT NULL, actor_subject TEXT);
6
+ CREATE TABLE IF NOT EXISTS participant_notifications (id TEXT PRIMARY KEY, event_id TEXT NOT NULL REFERENCES post_events(id), subject TEXT NOT NULL REFERENCES participants(subject), created_at INTEGER NOT NULL, read_at INTEGER, email_state TEXT NOT NULL DEFAULT 'pending', attempts INTEGER NOT NULL DEFAULT 0, next_attempt_at INTEGER NOT NULL DEFAULT 0, lease_until INTEGER NOT NULL DEFAULT 0, lease_token TEXT, UNIQUE(event_id, subject));
7
+ CREATE INDEX IF NOT EXISTS notifications_subject ON participant_notifications(subject, created_at, id);
8
+ CREATE INDEX IF NOT EXISTS notifications_delivery ON participant_notifications(email_state, next_attempt_at);
@@ -0,0 +1,3 @@
1
+ CREATE TABLE IF NOT EXISTS image_attachments (id TEXT PRIMARY KEY, subject TEXT NOT NULL REFERENCES participants(subject), object_key TEXT NOT NULL UNIQUE, content_type TEXT NOT NULL, bytes INTEGER NOT NULL, created_at INTEGER NOT NULL, post_slug TEXT, comment_id TEXT, deleting INTEGER NOT NULL DEFAULT 0);
2
+ CREATE INDEX IF NOT EXISTS image_attachments_post ON image_attachments(post_slug,comment_id);
3
+ CREATE INDEX IF NOT EXISTS image_attachments_cleanup ON image_attachments(created_at) WHERE post_slug IS NULL;
@@ -0,0 +1,9 @@
1
+ ALTER TABLE post_follows ADD COLUMN observed_status TEXT;
2
+ ALTER TABLE post_follows ADD COLUMN baseline_token TEXT;
3
+ ALTER TABLE post_follows ADD COLUMN ready INTEGER NOT NULL DEFAULT 0;
4
+ ALTER TABLE post_follows ADD COLUMN baseline_cursor TEXT;
5
+ ALTER TABLE post_follows ADD COLUMN baseline_lease_until INTEGER NOT NULL DEFAULT 0;
6
+ ALTER TABLE post_follows ADD COLUMN baseline_checked_at INTEGER NOT NULL DEFAULT 0;
7
+ UPDATE post_follows SET observed_status=(SELECT status FROM post_observations WHERE post_observations.post_slug=post_follows.post_slug);
8
+ ALTER TABLE post_events ADD COLUMN status TEXT;
9
+ CREATE TABLE follow_comment_baselines (post_slug TEXT NOT NULL, subject TEXT NOT NULL, event_id TEXT NOT NULL, PRIMARY KEY(post_slug,subject,event_id), FOREIGN KEY(post_slug,subject) REFERENCES post_follows(post_slug,subject) ON DELETE CASCADE);
@@ -0,0 +1,4 @@
1
+ CREATE TABLE IF NOT EXISTS github_posts (installation_id TEXT NOT NULL, repository_id INTEGER NOT NULL, post_id TEXT NOT NULL, slug TEXT NOT NULL, external_id TEXT, issue_number INTEGER, issue_id INTEGER, record TEXT NOT NULL, state TEXT NOT NULL CHECK(state IN ('reserved','creating','ready','uncertain','failed','withdrawn')), last_status TEXT NOT NULL DEFAULT 'New', vote_count INTEGER NOT NULL DEFAULT 0, projection_hash TEXT, public_updated_at INTEGER, PRIMARY KEY(installation_id,post_id), UNIQUE(installation_id,slug), UNIQUE(installation_id,external_id), UNIQUE(installation_id,repository_id,issue_id));
2
+ CREATE INDEX IF NOT EXISTS github_posts_repository ON github_posts(installation_id,repository_id,state);
3
+ CREATE TABLE IF NOT EXISTS github_comments (installation_id TEXT NOT NULL, repository_id INTEGER NOT NULL, comment_id TEXT NOT NULL, post_id TEXT NOT NULL, discussion_id TEXT NOT NULL, remote_id INTEGER, record TEXT NOT NULL, state TEXT NOT NULL CHECK(state IN ('creating','ready','uncertain','deleted')), PRIMARY KEY(installation_id,comment_id), UNIQUE(installation_id,repository_id,remote_id), FOREIGN KEY(installation_id,post_id) REFERENCES github_posts(installation_id,post_id));
4
+ CREATE INDEX IF NOT EXISTS github_comments_post ON github_comments(installation_id,repository_id,post_id,state);
@@ -0,0 +1,3 @@
1
+ CREATE TABLE IF NOT EXISTS integration_credentials (installation_id TEXT PRIMARY KEY, portal_secret TEXT NOT NULL, delivery_secret TEXT NOT NULL, revoked INTEGER NOT NULL DEFAULT 0, paired_at INTEGER NOT NULL);
2
+ CREATE TABLE IF NOT EXISTS portal_integration_nonces (installation_id TEXT NOT NULL, nonce TEXT NOT NULL, expires_at INTEGER NOT NULL, PRIMARY KEY(installation_id,nonce));
3
+ CREATE TABLE IF NOT EXISTS integration_receipts (installation_id TEXT NOT NULL, event_id TEXT NOT NULL, kind TEXT NOT NULL, state TEXT NOT NULL DEFAULT 'pending', received_at INTEGER NOT NULL, cursor TEXT NOT NULL DEFAULT '', last_checked_at INTEGER, failure_code TEXT, PRIMARY KEY(installation_id,event_id));
@@ -0,0 +1,2 @@
1
+ CREATE TABLE IF NOT EXISTS post_ownership (post_id TEXT PRIMARY KEY, post_slug TEXT NOT NULL, subject TEXT NOT NULL, title TEXT NOT NULL, type TEXT NOT NULL, created_at INTEGER NOT NULL, withdrawn INTEGER NOT NULL DEFAULT 0);
2
+ CREATE INDEX IF NOT EXISTS owned_posts_subject ON post_ownership(subject,created_at,post_id);
@@ -0,0 +1,5 @@
1
+ ALTER TABLE integration_receipts ADD COLUMN attempts INTEGER NOT NULL DEFAULT 0;
2
+ ALTER TABLE integration_receipts ADD COLUMN next_attempt INTEGER NOT NULL DEFAULT 0;
3
+ ALTER TABLE integration_receipts ADD COLUMN lease_token TEXT;
4
+ ALTER TABLE integration_receipts ADD COLUMN lease_until INTEGER NOT NULL DEFAULT 0;
5
+ CREATE INDEX integration_receipts_due ON integration_receipts(installation_id,state,next_attempt,lease_until);
@@ -0,0 +1,3 @@
1
+ CREATE TABLE IF NOT EXISTS public_post_observations (post_slug TEXT PRIMARY KEY, revision INTEGER NOT NULL DEFAULT 0, projection TEXT, checked_at INTEGER NOT NULL DEFAULT 0, lease_until INTEGER NOT NULL DEFAULT 0, lease_token TEXT);
2
+ CREATE TABLE IF NOT EXISTS public_post_history (id TEXT PRIMARY KEY, post_slug TEXT NOT NULL REFERENCES public_post_observations(post_slug), revision INTEGER NOT NULL, kind TEXT NOT NULL CHECK(kind IN ('status','update')), status TEXT, message TEXT, observed_at INTEGER NOT NULL, UNIQUE(post_slug,revision,kind));
3
+ CREATE INDEX IF NOT EXISTS public_post_history_order ON public_post_history(post_slug,observed_at,revision,kind);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "feedbax-installation",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
@@ -16,52 +16,57 @@
16
16
  "dependencies": {
17
17
  "@tailwindcss/vite": "4.3.3",
18
18
  "@tanstack/react-form": "^1.33.5",
19
- "@tanstack/react-query": "5.101.4",
20
- "@tanstack/react-router": "1.170.31",
21
- "@tanstack/react-start": "1.168.48",
22
- "dotenv": "17.4.2",
23
- "lucide-react": "1.33.0",
19
+ "@tanstack/react-query": "5.104.3",
20
+ "@tanstack/react-router": "1.170.44",
21
+ "@tanstack/react-start": "1.169.2",
22
+ "dotenv": "18.0.8",
23
+ "lucide-react": "1.57.0",
24
24
  "next-themes": "0.4.6",
25
- "react": "19.2.8",
26
- "react-dom": "19.2.8",
25
+ "react": "19.3.1",
26
+ "react-dom": "19.3.1",
27
27
  "sonner": "2.0.8",
28
28
  "tailwindcss": "4.3.3",
29
- "zod": "4.4.3",
30
- "@base-ui/react": "1.7.0",
29
+ "zod": "4.6.7",
30
+ "@base-ui/react": "1.9.0",
31
31
  "@fontsource-variable/geist": "5.3.0",
32
- "@shadcn/react": "0.3.0",
32
+ "@shadcn/react": "0.3.1",
33
33
  "class-variance-authority": "0.7.1",
34
34
  "clsx": "2.1.1",
35
35
  "cmdk": "^1.1.1",
36
36
  "shadcn": "latest",
37
- "tailwind-merge": "3.6.0",
38
- "tw-animate-css": "1.4.0"
37
+ "tailwind-merge": "3.7.0",
38
+ "tw-animate-css": "1.4.0",
39
+ "drizzle-orm": "0.45.4"
39
40
  },
40
41
  "devDependencies": {
41
- "@cloudflare/vite-plugin": "1.53.0",
42
- "@tanstack/react-router-devtools": "1.167.1",
43
- "@tanstack/router-cli": "1.167.32",
44
- "@testing-library/dom": "10.4.1",
45
- "@testing-library/react": "16.3.2",
46
- "@testing-library/user-event": "14.6.1",
47
- "@types/react": "19.2.18",
48
- "@types/react-dom": "19.2.4",
49
- "@vitejs/plugin-react": "6.1.0",
50
- "jsdom": "29.1.1",
42
+ "@cloudflare/vite-plugin": "1.64.0",
43
+ "@cloudflare/workers-types": "^5.20261015.1",
44
+ "@tanstack/react-router-devtools": "1.167.2",
45
+ "@tanstack/router-cli": "1.167.44",
46
+ "@testing-library/dom": "10.4.2",
47
+ "@testing-library/react": "16.3.3",
48
+ "@testing-library/user-event": "14.6.8",
49
+ "@types/react": "19.3.0",
50
+ "@types/react-dom": "19.3.0",
51
+ "@vitejs/plugin-react": "6.1.2",
52
+ "jsdom": "30.1.2",
53
+ "miniflare": "4.20260730.0",
51
54
  "typescript": "6.0.3",
52
- "vite": "8.2.2",
53
- "web-vitals": "5.3.0",
54
- "wrangler": "4.124.0",
55
+ "vite": "8.3.4",
56
+ "web-vitals": "6.2.3",
57
+ "wrangler": "4.151.0",
58
+ "esbuild": "0.28.2",
55
59
  "@tailwindcss/postcss": "4.3.3",
56
60
  "tailwindcss": "4.3.3",
57
- "vitest": "4.1.11",
61
+ "vitest": "5.0.4",
58
62
  "@eslint/js": "10.0.1",
59
- "@types/node": "22.20.1",
60
- "feedbax": "0.2.0",
61
- "eslint": "10.8.1",
62
- "globals": "17.11.0",
63
- "prettier": "3.9.6",
64
- "typescript-eslint": "8.67.0"
63
+ "@shadcn/lint": "0.2.1",
64
+ "@types/node": "24.19.2",
65
+ "feedbax": "0.2.2",
66
+ "eslint": "10.12.0",
67
+ "globals": "17.13.0",
68
+ "prettier": "3.9.10",
69
+ "typescript-eslint": "8.71.2"
65
70
  },
66
71
  "pnpm": {
67
72
  "onlyBuiltDependencies": [
@@ -69,7 +74,7 @@
69
74
  ]
70
75
  },
71
76
  "engines": {
72
- "node": ">=22.14.0"
77
+ "node": "^24.15.0 || >=26.0.0"
73
78
  },
74
79
  "packageManager": "pnpm@10.29.3",
75
80
  "license": "Apache-2.0"
@@ -146,6 +146,7 @@ export function AuthorizedDraftPost({ slug }: { slug: string }) {
146
146
  ) : (
147
147
  <PublicPostDetail
148
148
  post={post}
149
+ published={false}
149
150
  display={overlaid ? "overlay" : "page"}
150
151
  features={{ voting: false, comments: false, changelog: true }}
151
152
  summaryMarker={<Badge variant="outline">Draft</Badge>}
@@ -3,9 +3,8 @@ import type {
3
3
  PublicChangelogEntry,
4
4
  } from "@feedbax/changelog";
5
5
  import { Badge } from "@feedbax/ui/components/badge";
6
- import { Button } from "@feedbax/ui/components/button";
6
+ import { Button, buttonVariants } from "@feedbax/ui/components/button";
7
7
  import { ButtonGroup } from "@feedbax/ui/components/button-group";
8
- import { cn } from "@feedbax/ui/lib/utils";
9
8
  import { useRouterState } from "@tanstack/react-router";
10
9
  import { ChevronDown, Link2 } from "lucide-react";
11
10
  import { useEffect, useMemo, useState } from "react";
@@ -80,21 +79,24 @@ export function ChangelogPage({
80
79
  ]);
81
80
 
82
81
  return (
83
- <main className="changelog-page">
84
- <header className="changelog-heading">
82
+ <main className="mx-auto w-full max-w-[96rem] px-6 py-10 lg:px-10">
83
+ <header className="flex flex-wrap items-start justify-between gap-5">
85
84
  <div>
86
- <h1>Changelog</h1>
87
- <p>Product updates, fixes, and improvements.</p>
85
+ <h1 className="text-2xl font-semibold tracking-tight">Changelog</h1>
86
+ <p className="mt-2 text-sm text-muted-foreground">
87
+ Product updates, fixes, and improvements.
88
+ </p>
88
89
  </div>
90
+ <CommandPaletteTrigger className="h-10" />
89
91
  </header>
90
- <div className="changelog-toolbar">
91
- <ButtonGroup className="changelog-filters" aria-label="Filter updates">
92
+ {labels.length ? (
93
+ <ButtonGroup className="mt-6 flex-wrap" aria-label="Filter updates">
92
94
  {[undefined, ...labels].map((label) => (
93
95
  <Button
94
- className="h-11 md:h-8"
95
96
  key={label ?? "all"}
96
97
  type="button"
97
- variant="outline"
98
+ size="sm"
99
+ variant={selectedLabel === label ? "secondary" : "outline"}
98
100
  aria-pressed={selectedLabel === label}
99
101
  disabled={loading}
100
102
  onClick={() => void replaceForLabel(label)}
@@ -103,23 +105,30 @@ export function ChangelogPage({
103
105
  </Button>
104
106
  ))}
105
107
  </ButtonGroup>
106
- <CommandPaletteTrigger className="h-11 md:h-10" />
107
- </div>
108
- <ol className="changelog-timeline" aria-label="Product updates">
108
+ ) : null}
109
+ <ol
110
+ className="changelog-timeline"
111
+ aria-label="Product updates"
112
+ aria-busy={loading}
113
+ >
109
114
  {page.items.map((entry) => (
110
115
  <TimelineEntry entry={entry} key={entry.slug} />
111
116
  ))}
112
117
  </ol>
118
+ {!page.items.length && !loading ? (
119
+ <p className="py-12 text-sm text-muted-foreground">
120
+ No updates published yet.
121
+ </p>
122
+ ) : null}
113
123
  {page.nextCursor ? (
114
- <div className="changelog-load-more">
124
+ <div className="mx-auto mt-8 flex max-w-5xl justify-center">
115
125
  <Button
116
- className="h-11 min-w-64 gap-2 text-sm md:h-10"
117
126
  type="button"
118
127
  variant="outline"
119
128
  disabled={loading}
120
129
  onClick={() => void loadMore()}
121
130
  >
122
- Load more updates
131
+ {loading ? "Loading updates…" : "Load more updates"}
123
132
  <ChevronDown aria-hidden="true" />
124
133
  </Button>
125
134
  </div>
@@ -135,28 +144,46 @@ function TimelineEntry({ entry }: { entry: PublicChangelogEntry }) {
135
144
  <time dateTime={entry.date}>{formatDate(entry.date)}</time>
136
145
  <span className="changelog-timeline-marker" aria-hidden="true" />
137
146
  <article id={entry.slug} tabIndex={-1}>
138
- <div className={cn("changelog-entry-layout", image && "has-image")}>
139
- <div className="changelog-entry-copy">
140
- <div className="changelog-labels" aria-label="Labels">
141
- {entry.labels.map((label) => (
142
- <Label key={label} label={label} />
143
- ))}
144
- </div>
145
- <div className="changelog-entry-title">
146
- <h2>{entry.title}</h2>
147
- <a href={`#${entry.slug}`} aria-label={`Link to ${entry.title}`}>
148
- <Link2 aria-hidden="true" />
149
- </a>
150
- </div>
151
- <p className="changelog-entry-summary">{entry.summary}</p>
152
- <div className="changelog-entry-body">
153
- <p>{entry.body}</p>
154
- </div>
147
+ {entry.labels.length ? (
148
+ <div className="mb-3 flex flex-wrap gap-2" aria-label="Labels">
149
+ {entry.labels.map((label) => (
150
+ <Badge key={label} variant="outline">
151
+ {label}
152
+ </Badge>
153
+ ))}
155
154
  </div>
156
- {image ? (
157
- <img src={image.src} alt={image.alt} width={760} height={420} />
158
- ) : null}
155
+ ) : null}
156
+ <div className="flex items-start justify-between gap-3">
157
+ <h2 className="text-lg font-semibold tracking-tight">
158
+ {entry.title}
159
+ </h2>
160
+ <a
161
+ className={buttonVariants({ variant: "ghost", size: "icon-sm" })}
162
+ href={`#${entry.slug}`}
163
+ aria-label={`Link to ${entry.title}`}
164
+ >
165
+ <Link2 aria-hidden="true" />
166
+ </a>
167
+ </div>
168
+ <p className="mt-3 text-sm leading-relaxed">{entry.summary}</p>
169
+ <div className="changelog-entry-body">
170
+ {entry.body
171
+ .trim()
172
+ .split(/\n\s*\n/)
173
+ .filter(Boolean)
174
+ .map((paragraph, index) => (
175
+ <p key={index}>{paragraph}</p>
176
+ ))}
159
177
  </div>
178
+ {image ? (
179
+ <img
180
+ className="mt-6 w-full border"
181
+ src={image.src}
182
+ alt={image.alt}
183
+ width={760}
184
+ height={420}
185
+ />
186
+ ) : null}
160
187
  </article>
161
188
  </li>
162
189
  );
@@ -190,24 +217,6 @@ function useUnexpiredImage(image: PublicChangelogEntry["image"]) {
190
217
  : undefined;
191
218
  }
192
219
 
193
- function Label({ label }: { label: string }) {
194
- const tone = label.toLowerCase();
195
- return (
196
- <Badge
197
- className={cn(
198
- "changelog-label",
199
- tone === "new feature" && "is-new",
200
- tone === "improved" && "is-improved",
201
- tone === "fixed" && "is-fixed",
202
- )}
203
- variant="outline"
204
- >
205
- <span aria-hidden="true" />
206
- {label}
207
- </Badge>
208
- );
209
- }
210
-
211
220
  function formatDate(date: string) {
212
221
  const [year, month, day] = date.split("-").map(Number);
213
222
  return new Intl.DateTimeFormat("en-US", {
@@ -8,7 +8,8 @@ import { requiredEnvironmentValue } from "./feedback-runtime";
8
8
 
9
9
  let configuredChangelog: ReturnType<typeof createChangelogModule> | undefined;
10
10
 
11
- export function createConfiguredChangelogModule() {
11
+ export function createConfiguredChangelogModule(fresh = false) {
12
+ if (fresh) return buildConfiguredChangelogModule();
12
13
  configuredChangelog ??= buildConfiguredChangelogModule();
13
14
  return configuredChangelog;
14
15
  }
@@ -6,6 +6,15 @@ declare module "cloudflare:workers" {
6
6
  }>;
7
7
  };
8
8
  export const env: {
9
+ GITHUB_SERVICE_SECRET?: string;
10
+ GITHUB_DELIVERY_SECRET?: string;
11
+ GITHUB_PAIRING_SECRET?: string;
12
+ FEEDBAX_UPDATES?: import("@cloudflare/workers-types").Queue<string>;
13
+ FEEDBAX_EMAIL?: import("@feedbax/feedback").CloudflareEmailBinding;
14
+ EMAIL_PROVIDER_SECRET?: string;
15
+ FEEDBAX_IMAGES?: import("@cloudflare/workers-types").R2Bucket;
16
+ FEEDBAX_DB?: import("@feedbax/feedback").ConnectedDatabase;
17
+ IDENTITY_HANDOFF_SECRET?: string;
9
18
  TRUSTED_SUBMISSIONS_RATE_LIMITER: {
10
19
  limit(options: { key: string }): Promise<{ success: boolean }>;
11
20
  };
@@ -41,7 +41,7 @@ describe("Comment creation", () => {
41
41
  });
42
42
  expect(composer.classList.contains("comment-composer")).toBe(true);
43
43
  expect(screen.getByLabelText("Add a comment").getAttribute("rows")).toBe(
44
- "4",
44
+ "3",
45
45
  );
46
46
  expect(
47
47
  screen.getByPlaceholderText("Share context, an example, or a question…"),
@@ -111,12 +111,15 @@ describe("Comment creation", () => {
111
111
  });
112
112
  fireEvent.click(screen.getByRole("button", { name: "Post comment" }));
113
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
- });
114
+ await waitFor(() =>
115
+ expect(submitComment).toHaveBeenCalledWith({
116
+ slug: "roadmap-search",
117
+ body: "Please add shortcuts.",
118
+ displayName: "Ari",
119
+ email: "private@example.com",
120
+ submissionKey: expect.stringMatching(/^[A-Za-z0-9_-]{43}$/),
121
+ }),
122
+ );
120
123
 
121
124
  confirm({
122
125
  id: "notion-comment",
@@ -147,6 +150,48 @@ describe("Comment creation", () => {
147
150
  expect(await screen.findByLabelText("Add a comment")).toBeTruthy();
148
151
  });
149
152
 
153
+ it("does not duplicate a native Comment that arrives before its submission confirmation", async () => {
154
+ saveDeviceProfile(localStorage, {
155
+ name: "Ari",
156
+ email: "private@example.com",
157
+ });
158
+ const confirmed = {
159
+ id: "native-comment",
160
+ discussionId: "native-thread",
161
+ body: "One comment",
162
+ author: { kind: "participant" as const, displayName: "Ari" },
163
+ createdAt: new Date(),
164
+ };
165
+ let finish!: (value: typeof confirmed) => void;
166
+ const submitComment = vi.fn(
167
+ () =>
168
+ new Promise<typeof confirmed>((resolve) => {
169
+ finish = resolve;
170
+ }),
171
+ );
172
+ const view = renderCommentDetail({ submitComment });
173
+ fireEvent.change(await screen.findByLabelText("Add a comment"), {
174
+ target: { value: confirmed.body },
175
+ });
176
+ fireEvent.click(screen.getByRole("button", { name: "Post comment" }));
177
+ view.rerender(
178
+ <DeviceProfileProvider>
179
+ <PublicPostDetail
180
+ post={post}
181
+ submitComment={submitComment}
182
+ comments={{
183
+ items: [{ id: confirmed.discussionId, comments: [confirmed] }],
184
+ }}
185
+ />
186
+ </DeviceProfileProvider>,
187
+ );
188
+ await waitFor(() => expect(submitComment).toHaveBeenCalledTimes(1));
189
+ finish(confirmed);
190
+ await waitFor(() =>
191
+ expect(screen.getAllByText("One comment")).toHaveLength(1),
192
+ );
193
+ });
194
+
150
195
  it("rolls back a rejected Comment and shows an actionable error", async () => {
151
196
  saveDeviceProfile(localStorage, {
152
197
  name: "Ari",
@@ -164,6 +209,49 @@ describe("Comment creation", () => {
164
209
  await screen.findByRole("alert");
165
210
  expect(screen.queryByText("Temporary message")).toBeNull();
166
211
  expect(screen.getByRole("alert").textContent).toContain("Try again");
212
+ expect(
213
+ (screen.getByLabelText("Add a comment") as HTMLTextAreaElement).value,
214
+ ).toBe("Temporary message");
215
+ });
216
+
217
+ it("retries a lost Comment response with the same intent and preserves the composer", async () => {
218
+ saveDeviceProfile(localStorage, {
219
+ name: "Ari",
220
+ email: "private@example.com",
221
+ });
222
+ const requests: Record<string, unknown>[] = [];
223
+ vi.spyOn(globalThis, "fetch").mockImplementation(async (_url, init) => {
224
+ requests.push(JSON.parse(String(init?.body)));
225
+ if (requests.length === 1) throw new Error("Response lost. Try again.");
226
+ return Response.json({
227
+ comment: {
228
+ id: "recovered-comment",
229
+ discussionId: "recovered-thread",
230
+ body: "Keep this message",
231
+ author: { kind: "participant", displayName: "Ari" },
232
+ createdAt: "2026-08-25T01:00:00.000Z",
233
+ },
234
+ });
235
+ });
236
+ renderCommentDetail({ submitComment: submitPublicComment });
237
+ fireEvent.change(await screen.findByLabelText("Add a comment"), {
238
+ target: { value: "Keep this message" },
239
+ });
240
+ fireEvent.click(screen.getByRole("button", { name: "Post comment" }));
241
+ await screen.findByRole("alert");
242
+ expect(
243
+ (screen.getByLabelText("Add a comment") as HTMLTextAreaElement).value,
244
+ ).toBe("Keep this message");
245
+ fireEvent.click(screen.getByRole("button", { name: "Post comment" }));
246
+ await waitFor(() => expect(requests).toHaveLength(2));
247
+ await waitFor(() =>
248
+ expect(
249
+ (screen.getByLabelText("Add a comment") as HTMLTextAreaElement).value,
250
+ ).toBe(""),
251
+ );
252
+ expect(requests[0]?.submissionKey).toMatch(/^[A-Za-z0-9_-]{43}$/);
253
+ expect(requests[1]?.submissionKey).toBe(requests[0]?.submissionKey);
254
+ expect(screen.getAllByText("Keep this message")).toHaveLength(1);
167
255
  });
168
256
 
169
257
  it("renders a fresh Turnstile challenge after a stale Participation Pass is rejected", async () => {
@@ -10,7 +10,17 @@ export function mergeCommentThreads(
10
10
  merged.set(
11
11
  thread.id,
12
12
  existing
13
- ? { ...existing, comments: [...existing.comments, ...thread.comments] }
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
+ }
14
24
  : thread,
15
25
  );
16
26
  }