Skip to content

Fix feedback nav button style and remove icon#24

Merged
rohan-patnaik merged 2 commits intomainfrom
feat/feedback-board
Feb 19, 2026
Merged

Fix feedback nav button style and remove icon#24
rohan-patnaik merged 2 commits intomainfrom
feat/feedback-board

Conversation

@rohan-patnaik
Copy link
Copy Markdown
Owner

@rohan-patnaik rohan-patnaik commented Feb 19, 2026

Summary

  • remove the feedback icon from the navbar trigger and keep text-only label
  • align Feedback trigger visual style with other non-selected nav links
  • keep dark/mobile overrides consistent with nav behavior

Validation

  • npm -C apps/web run lint

Summary by CodeRabbit

Release Notes

  • New Features
    • Added feedback widget allowing users to submit feature requests and bug reports from any page in the navigation header
    • Introduced dedicated feedback board page to view all submitted feedback organized by status
    • Enabled administrators to review and mark feedback items as resolved
    • Implemented rate limiting to prevent abuse (5 submissions per hour per user, 1-minute minimum between submissions)

@vercel
Copy link
Copy Markdown

vercel bot commented Feb 19, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
thezenpdf Ready Ready Preview, Comment Feb 19, 2026 8:31pm

@rohan-patnaik
Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Feb 19, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Feb 19, 2026

📝 Walkthrough

Walkthrough

Introduces a complete feedback system featuring server-side mutation and query operations with validation, rate limiting, and admin authentication; a database schema for feedback records; a client-side feedback board page and reusable widget component; styling for feedback UI; and environment-based admin access control via Clerk IDs and email addresses.

Changes

Cohort / File(s) Summary
Environment & Configuration
apps/web/.env.example, docs/OWNER_ACTION_ITEMS.md
Adds environment variables for admin access control (ZENPDF_FEEDBACK_ADMIN_CLERK_IDS, ZENPDF_FEEDBACK_ADMIN_EMAILS) with documentation on usage and permissions.
Backend & Database
apps/web/convex/feedback.ts, apps/web/convex/schema.ts
Implements feedback server module with createFeedback, listFeedback, and setFeedbackResolved operations; includes validation, rate limiting, and admin checks. Adds feedback table schema with indexing on createdAt, user/anon creation, and status.
Frontend Components
apps/web/src/components/FeedbackWidget.tsx, apps/web/src/app/feedback/page.tsx, apps/web/src/components/SiteHeader.tsx
Introduces reusable FeedbackWidget component for feedback submission with form validation and error handling, feedback board page for listing/resolving items with admin-only features, and integrates widget into site navigation.
Styling
apps/web/src/globals.css
Adds comprehensive CSS for feedback UI elements (.feedback-trigger, .feedback-overlay, .feedback-modal) with theme-aware and responsive adjustments, plus Clerk modal cleanup styles.
Type Definitions
apps/web/src/lib/convex.ts
Introduces FeedbackItem and FeedbackListResponse types; extends API surface with feedback namespace containing createFeedback, listFeedback, and setFeedbackResolved operations.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant Widget as FeedbackWidget
    participant Server as Convex Server<br/>(feedback.ts)
    participant DB as Database<br/>(feedback table)
    
    User->>Widget: Opens feedback form
    User->>Widget: Submits heading & message
    Widget->>Widget: Validate input & get anonId
    Widget->>Server: createFeedback mutation
    Server->>Server: Validate submission, check rate limits
    Server->>Server: Resolve user context
    Server->>DB: Insert feedback record
    DB-->>Server: feedbackId
    Server-->>Widget: Return feedback ID
    Widget->>Widget: Show success message
    User->>User: Navigate to feedback board
    
    User->>Server: listFeedback query
    Server->>DB: Fetch feedback items
    DB-->>Server: Feedback records
    Server->>Server: Mask emails (unless admin)
    Server-->>User: Return paginated list
    
    alt User is Admin
        User->>Server: setFeedbackResolved mutation
        Server->>Server: Verify admin authorization
        Server->>DB: Update feedback status & metadata
        DB-->>Server: Updated record
        Server-->>User: Return updated item
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Poem

🐰 Hoppy whiskers brought feedback cheer,
Where voices echo loud and clear,
Admins hop through admin gates,
Rate limits guard against debates!
From widget bounces to the board so grand,
This feedback system's built to stand! 🌟

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Title check ⚠️ Warning The PR title describes removing an icon from the feedback nav button, but the changeset implements a complete feedback system including server logic, schema, UI components, and admin controls. Update the title to reflect the primary change: implementing a comprehensive feedback system with admin controls and UI components, or clarify if the icon removal is genuinely the main focus.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/feedback-board

Tip

Issue Planner is now in beta. Read the docs and try it out! Share your feedback on Discord.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@rohan-patnaik rohan-patnaik merged commit 0096be2 into main Feb 19, 2026
5 of 6 checks passed
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (3)
docs/OWNER_ACTION_ITEMS.md (1)

3-3: Update the "Last updated" date.

The file was modified in this PR (2026-02-19) but line 3 still reads 2026-02-06.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/OWNER_ACTION_ITEMS.md` at line 3, Replace the outdated "Last updated:
2026-02-06" line in OWNER_ACTION_ITEMS.md with the correct date "Last updated:
2026-02-19" so the file header reflects the PR modification date.
apps/web/src/app/feedback/page.tsx (1)

54-56: Extract the magic number 150 into a named constant.

♻️ Suggested refactor
+const FEEDBACK_LIST_LIMIT = 150;
+
 export default function FeedbackPage() {
   ...
   const feedback = useQuery(api.feedback.listFeedback, {
-    limit: 150,
+    limit: FEEDBACK_LIST_LIMIT,
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/app/feedback/page.tsx` around lines 54 - 56, The magic number
150 used in the useQuery call should be extracted to a named constant for
clarity and maintainability; define a constant (e.g. FEEDBACK_LIST_LIMIT = 150)
near the top of the module and replace the inline literal in the useQuery call
(api.feedback.listFeedback, { limit: 150 }) with { limit: FEEDBACK_LIST_LIMIT }
so the meaning and reuse of the limit is explicit.
apps/web/convex/feedback.ts (1)

300-308: Consider projecting the return value to avoid leaking internal fields to the client.

ctx.db.get(args.feedbackId) returns the full document including internal identifiers (createdByUserId, createdByAnonId, createdByClerkId). While only admins reach this point, returning a trimmed projection — consistent with listFeedback's shape — would minimize client-side data exposure and keep the API surface uniform.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/convex/feedback.ts` around lines 300 - 308, The current return from
ctx.db.get(args.feedbackId) exposes internal fields (createdByUserId,
createdByAnonId, createdByClerkId); instead, return a projected object that
matches the shape used by listFeedback: include only the public fields (id,
title, message, status, updatedAt, resolvedAt, resolvedByClerkId, any
tags/metadata you surface) and omit internal identifiers; implement this by
replacing the direct ctx.db.get(...) return with a projection/mapping step after
fetching the document (use ctx.db.get(args.feedbackId) to fetch then map to the
trimmed shape) so admins still get the needed fields but no internal IDs are
leaked.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/web/.env.example`:
- Around line 34-41: Move the two new env entries (including their comment
lines) for ZENPDF_FEEDBACK_ADMIN_CLERK_IDS and ZENPDF_FEEDBACK_ADMIN_EMAILS so
they appear alphabetically before the ZENPDF_FREE_ACCOUNT_* block: place them
right after the ZENPDF_ANON_MAX_DAILY_MINUTES= line and before
ZENPDF_FREE_ACCOUNT_MAX_FILES_PER_JOB=, and remove their old lines after
NEXT_PUBLIC_DONATE_CARD_EMBED_URL; keep the exact variable names and
accompanying comment text intact so dotenv-linter no longer reports UnorderedKey
for ZENPDF_FEEDBACK_ADMIN_CLERK_IDS and ZENPDF_FEEDBACK_ADMIN_EMAILS.

In `@apps/web/convex/feedback.ts`:
- Around line 53-55: Replace the implicit NODE_ENV check with an explicit opt-in
env var check so admin fallback is disabled by default: where the code currently
returns process.env.NODE_ENV !== "production" when adminClerkIds.size === 0 &&
adminEmails.size === 0, change it to check a dedicated flag like
process.env.ZENPDF_FEEDBACK_ADMIN_DEV_MODE === "true" (or similar truthy check)
and return true only when that flag is explicitly set; update any related logic
that grants admin rights (references: adminClerkIds, adminEmails,
process.env.NODE_ENV) to rely on this explicit opt-in instead of NODE_ENV.

In `@apps/web/convex/schema.ts`:
- Around line 144-160: The feedback table currently stores createdByEmail in
plaintext (defined on the feedback table via createdByEmail) which risks
violating right-to-erasure; either remove the createdByEmail column from the
feedback.defineTable schema and stop writing identity?.email?.toLowerCase()
anywhere (rely on createdByClerkId), or keep the column but implement a
deletion/retention flow that nulls it: add an on-delete or scheduled mutation
that sets feedback.createdByEmail = null when the Clerk user identified by
createdByClerkId is deleted (and update any code paths that write createdByEmail
to respect anonymization/TTL). Use the feedback defineTable entry and any
mutation/write functions that reference createdByEmail / createdByClerkId to
locate and apply the change.

In `@apps/web/src/app/globals.css`:
- Around line 665-667: The .feedback-trigger:active rule is a no-op because
transform: translateY(0) is the default; either remove the rule or change it to
a meaningful tactile press transform (e.g., translateY(1px)) to match the
behavior used by .paper-button:active; update the .feedback-trigger:active
selector to apply the desired press offset (or remove it if you intend no
effect) and ensure any CSS transition on .feedback-trigger exists so the press
is animated consistently.

In `@apps/web/src/components/FeedbackWidget.tsx`:
- Around line 217-218: The conditional success/error paragraphs in
FeedbackWidget (the submitSuccess and submitError renderings) lack an aria-live
region; change them to always-rendered elements (e.g., persistent <p> or <div>
for the success and error messages) and add appropriate aria-live attributes
(e.g., aria-live="polite" or "assertive") and role if needed so screen readers
announce updates when submitSuccess/submitError values change; ensure the
elements contain an empty string when there is no message so they remain present
in the DOM while only the text content toggles.
- Around line 146-221: The modal lacks a focus trap and is rendered in-place
inside the nav; wrap the dialog in a portal and add a minimal Tab-wrap focus
trap: create a modalRef (e.g., const modalRef = useRef<HTMLDivElement |
null>(null)), render the overlay using ReactDOM.createPortal into document.body
instead of in-place where `open` is checked, and in a useEffect attach keydown
handlers that capture Tab/Shift+Tab to keep focus cycling between the first and
last focusable elements inside the modal (including the Close button, form
inputs, the Submit button and the `Link`), also ensure focus is moved into the
modal on open (focus headingRef or first focusable) and restored on close by
using handleClose to return focus to the previously focused element.

---

Nitpick comments:
In `@apps/web/convex/feedback.ts`:
- Around line 300-308: The current return from ctx.db.get(args.feedbackId)
exposes internal fields (createdByUserId, createdByAnonId, createdByClerkId);
instead, return a projected object that matches the shape used by listFeedback:
include only the public fields (id, title, message, status, updatedAt,
resolvedAt, resolvedByClerkId, any tags/metadata you surface) and omit internal
identifiers; implement this by replacing the direct ctx.db.get(...) return with
a projection/mapping step after fetching the document (use
ctx.db.get(args.feedbackId) to fetch then map to the trimmed shape) so admins
still get the needed fields but no internal IDs are leaked.

In `@apps/web/src/app/feedback/page.tsx`:
- Around line 54-56: The magic number 150 used in the useQuery call should be
extracted to a named constant for clarity and maintainability; define a constant
(e.g. FEEDBACK_LIST_LIMIT = 150) near the top of the module and replace the
inline literal in the useQuery call (api.feedback.listFeedback, { limit: 150 })
with { limit: FEEDBACK_LIST_LIMIT } so the meaning and reuse of the limit is
explicit.

In `@docs/OWNER_ACTION_ITEMS.md`:
- Line 3: Replace the outdated "Last updated: 2026-02-06" line in
OWNER_ACTION_ITEMS.md with the correct date "Last updated: 2026-02-19" so the
file header reflects the PR modification date.

Comment on lines +34 to +41
# Optional: comma-separated Clerk user IDs that can resolve feedback.
# Example: ZENPDF_FEEDBACK_ADMIN_CLERK_IDS=user_123,user_456
ZENPDF_FEEDBACK_ADMIN_CLERK_IDS=
# Optional alternative/addition: comma-separated admin emails for feedback resolve access.
# Example: ZENPDF_FEEDBACK_ADMIN_EMAILS=admin@example.com,ops@example.com
# Privacy note: emails are PII. Prefer Clerk roles/permissions or a database-backed admin list
# for better auditability and easier rotation than long-lived environment variables.
ZENPDF_FEEDBACK_ADMIN_EMAILS=
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Resolve dotenv-linter UnorderedKey warnings for the two new vars.

ZENPDF_FEEDBACK_ADMIN_CLERK_IDS and ZENPDF_FEEDBACK_ADMIN_EMAILS should be placed before the ZENPDF_FREE_ACCOUNT_* block (alphabetically FEEDBACK < FREE). The static analysis tool flags both at lines 36 and 41.

🔧 Suggested placement

Move the four lines (comment + key for each variable) to appear after line 13 (ZENPDF_ANON_MAX_DAILY_MINUTES=) and before line 14 (ZENPDF_FREE_ACCOUNT_MAX_FILES_PER_JOB=):

 ZENPDF_ANON_MAX_DAILY_MINUTES=
+# Optional: comma-separated Clerk user IDs that can resolve feedback.
+# Example: ZENPDF_FEEDBACK_ADMIN_CLERK_IDS=user_123,user_456
+ZENPDF_FEEDBACK_ADMIN_CLERK_IDS=
+# Optional alternative/addition: comma-separated admin emails for feedback resolve access.
+# Example: ZENPDF_FEEDBACK_ADMIN_EMAILS=admin@example.com,ops@example.com
+# Privacy note: emails are PII. Prefer Clerk roles/permissions or a database-backed admin list
+# for better auditability and easier rotation than long-lived environment variables.
+ZENPDF_FEEDBACK_ADMIN_EMAILS=
 ZENPDF_FREE_ACCOUNT_MAX_FILES_PER_JOB=

And remove them from their current location after NEXT_PUBLIC_DONATE_CARD_EMBED_URL.

🧰 Tools
🪛 dotenv-linter (4.0.0)

[warning] 36-36: [UnorderedKey] The ZENPDF_FEEDBACK_ADMIN_CLERK_IDS key should go before the ZENPDF_FREE_ACCOUNT_MAX_CONCURRENT_JOBS key

(UnorderedKey)


[warning] 41-41: [UnorderedKey] The ZENPDF_FEEDBACK_ADMIN_EMAILS key should go before the ZENPDF_FREE_ACCOUNT_MAX_CONCURRENT_JOBS key

(UnorderedKey)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/.env.example` around lines 34 - 41, Move the two new env entries
(including their comment lines) for ZENPDF_FEEDBACK_ADMIN_CLERK_IDS and
ZENPDF_FEEDBACK_ADMIN_EMAILS so they appear alphabetically before the
ZENPDF_FREE_ACCOUNT_* block: place them right after the
ZENPDF_ANON_MAX_DAILY_MINUTES= line and before
ZENPDF_FREE_ACCOUNT_MAX_FILES_PER_JOB=, and remove their old lines after
NEXT_PUBLIC_DONATE_CARD_EMBED_URL; keep the exact variable names and
accompanying comment text intact so dotenv-linter no longer reports UnorderedKey
for ZENPDF_FEEDBACK_ADMIN_CLERK_IDS and ZENPDF_FEEDBACK_ADMIN_EMAILS.

Comment on lines +53 to +55
if (adminClerkIds.size === 0 && adminEmails.size === 0) {
return process.env.NODE_ENV !== "production";
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Convex server functions NODE_ENV environment variable automatic

💡 Result:

Convex does not automatically set process.env.NODE_ENV for your server functions (queries/mutations/actions/http actions). In Convex, process.env contains:

  • Deployment environment variables you set in the Convex dashboard / via npx convex env set (they’re per-deployment, so dev and prod can differ). [1]
  • A small set of system environment variables that are always present (currently documented as CONVEX_CLOUD_URL and CONVEX_SITE_URL). [1][2]

So if you want an environment switch inside Convex functions, set your own variable (e.g. APP_ENV=development|production) in each deployment and read it via process.env.APP_ENV. [1]

Sources: [1] [2]


🌐 Web query:

Convex documentation environment variables NODE_ENV production

💡 Result:

Convex doesn’t document (or guarantee) a built-in NODE_ENV value you can rely on inside Convex functions. Instead, Convex’s docs recommend using Convex deployment environment variables (set per deployment: dev vs prod) and reading them via process.env. [1]

What to do:

  • Create your own env var (e.g. APP_ENV=production) in the Convex Dashboard → Deployment Settings (or via CLI) and branch on that. [1], [2]
    • CLI examples: npx convex env set APP_ENV production / npx convex env list [2]
  • Remember Convex has two runtimes: most functions run in Convex’s default JS runtime; Node.js is only for actions (files using "use node"), so Node-specific assumptions like NODE_ENV are not something Convex calls out as part of the platform contract. [3]
  • For “prod vs dev” at the project level, Convex’s CLI uses CONVEX_DEPLOYMENT (written to .env.local for local dev) and production deployments are configured separately (often via CI with CONVEX_DEPLOY_KEY). [4]

Sources:
[1] Convex Docs – Environment Variables https://docs.convex.dev/production/environment-variables
[2] Convex Docs – CLI (env commands) https://docs.convex.dev/cli
[3] Convex Docs – Function runtimes (default vs Node.js actions) https://docs.convex.dev/functions/runtimes
[4] Convex Docs – Project configuration (dev/prod config) https://docs.convex.dev/production/project-configuration


Security: NODE_ENV is not automatically set in Convex, creating a privilege escalation risk.

Convex does not automatically set process.env.NODE_ENV in server functions. The only system environment variables present are CONVEX_CLOUD_URL and CONVEX_SITE_URL. If neither admin-list env var is configured and NODE_ENV is not explicitly set as a deployment environment variable, the condition process.env.NODE_ENV !== "production" evaluates to true (since undefined !== "production"), granting admin rights to all authenticated users—including in production deployments.

Switch to an explicit opt-in pattern:

Safe-by-default fix
   if (adminClerkIds.size === 0 && adminEmails.size === 0) {
-    return process.env.NODE_ENV !== "production";
+    return process.env.ZENPDF_FEEDBACK_ADMIN_DEV_MODE === "true";
   }

Developers must explicitly set ZENPDF_FEEDBACK_ADMIN_DEV_MODE=true in the Convex dashboard for development environments if they want dev-mode admin fallback; all other deployments deny by default.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (adminClerkIds.size === 0 && adminEmails.size === 0) {
return process.env.NODE_ENV !== "production";
}
if (adminClerkIds.size === 0 && adminEmails.size === 0) {
return process.env.ZENPDF_FEEDBACK_ADMIN_DEV_MODE === "true";
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/convex/feedback.ts` around lines 53 - 55, Replace the implicit
NODE_ENV check with an explicit opt-in env var check so admin fallback is
disabled by default: where the code currently returns process.env.NODE_ENV !==
"production" when adminClerkIds.size === 0 && adminEmails.size === 0, change it
to check a dedicated flag like process.env.ZENPDF_FEEDBACK_ADMIN_DEV_MODE ===
"true" (or similar truthy check) and return true only when that flag is
explicitly set; update any related logic that grants admin rights (references:
adminClerkIds, adminEmails, process.env.NODE_ENV) to rely on this explicit
opt-in instead of NODE_ENV.

Comment on lines +144 to +160
feedback: defineTable({
heading: v.string(),
message: v.string(),
status: feedbackStatus,
createdByUserId: v.optional(v.id("users")),
createdByAnonId: v.optional(v.string()),
createdByClerkId: v.optional(v.string()),
createdByEmail: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
resolvedAt: v.optional(v.number()),
resolvedByClerkId: v.optional(v.string()),
})
.index("by_created", ["createdAt"])
.index("by_user_created", ["createdByUserId", "createdAt"])
.index("by_anon_created", ["createdByAnonId", "createdAt"])
.index("by_status_updated", ["status", "updatedAt"]),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

createdByEmail persisted in plaintext poses a GDPR/CCPA right-to-erasure gap.

createdByEmail is written on every authenticated submission (identity?.email?.toLowerCase()) and has no TTL, anonymization path, or deletion trigger. If a user later deletes their Clerk account, their email remains in every feedback row they created, violating the right to erasure. The privacy note in .env.example addresses the admin env var, but the stored DB field is the higher-risk surface.

Options:

  1. Drop createdByEmail from the schema and rely solely on createdByClerkId — admins can look up the email via Clerk's admin API when needed.
  2. Or add a scheduled mutation / onDelete hook that nullifies createdByEmail when the corresponding user record is removed.

The resolvedByClerkId field has the same concern for the admin user's email, but that data is not currently surfaced or stored as email — only as a Clerk ID, which is acceptable.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/convex/schema.ts` around lines 144 - 160, The feedback table
currently stores createdByEmail in plaintext (defined on the feedback table via
createdByEmail) which risks violating right-to-erasure; either remove the
createdByEmail column from the feedback.defineTable schema and stop writing
identity?.email?.toLowerCase() anywhere (rely on createdByClerkId), or keep the
column but implement a deletion/retention flow that nulls it: add an on-delete
or scheduled mutation that sets feedback.createdByEmail = null when the Clerk
user identified by createdByClerkId is deleted (and update any code paths that
write createdByEmail to respect anonymization/TTL). Use the feedback defineTable
entry and any mutation/write functions that reference createdByEmail /
createdByClerkId to locate and apply the change.

Comment on lines +665 to +667
.feedback-trigger:active {
transform: translateY(0);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

.feedback-trigger:active transform is a no-op.

transform: translateY(0) is the browser default — no transition or hover transform is set on .feedback-trigger to reset, so the :active rule has no visual effect. Compare with .paper-button:active { transform: translateY(1px) } (Line 148) which gives tactile press feedback. If the intent is to suppress any inherited transform, it still does nothing here.

🔧 Suggested fix for a subtle press effect
 .feedback-trigger:active {
-  transform: translateY(0);
+  transform: translateY(1px);
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.feedback-trigger:active {
transform: translateY(0);
}
.feedback-trigger:active {
transform: translateY(1px);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/app/globals.css` around lines 665 - 667, The
.feedback-trigger:active rule is a no-op because transform: translateY(0) is the
default; either remove the rule or change it to a meaningful tactile press
transform (e.g., translateY(1px)) to match the behavior used by
.paper-button:active; update the .feedback-trigger:active selector to apply the
desired press offset (or remove it if you intend no effect) and ensure any CSS
transition on .feedback-trigger exists so the press is animated consistently.

Comment on lines +146 to +221
{open ? (
<div
className="feedback-overlay"
role="dialog"
aria-modal="true"
aria-labelledby="feedback-modal-title"
onClick={(event) => {
if (event.currentTarget === event.target) {
handleClose();
}
}}
>
<div className="paper-card feedback-modal">
<button
type="button"
className="paper-button--ghost feedback-modal-close"
onClick={handleClose}
>
Close
</button>

<div className="pr-24">
<span className="ink-label">Share feedback</span>
<h2 id="feedback-modal-title" className="mt-2 text-2xl">
Help improve ZenPDF
</h2>
<p className="mt-2 text-sm text-ink-700">
Report issues or product ideas. Open items stay visible to everyone.
</p>
</div>

<form className="mt-5 space-y-4" onSubmit={submit}>
<label className="field-label" htmlFor="feedback-heading">
Heading
</label>
<input
ref={headingRef}
id="feedback-heading"
className="field-input"
value={heading}
onChange={(event) => setHeading(event.target.value)}
placeholder="Short summary"
required
minLength={HEADING_MIN_CHARS}
maxLength={HEADING_MAX_CHARS}
/>

<label className="field-label" htmlFor="feedback-message">
Message
</label>
<textarea
id="feedback-message"
className="field-input min-h-32 resize-y"
value={message}
onChange={(event) => setMessage(event.target.value)}
placeholder="Describe the issue, expected behavior, and context."
required
minLength={MESSAGE_MIN_CHARS}
maxLength={MESSAGE_MAX_CHARS}
/>

<div className="flex flex-wrap items-center gap-2">
<button type="submit" className="paper-button w-auto" disabled={isSubmitting}>
{isSubmitting ? "Submitting..." : "Submit feedback"}
</button>
<Link href="/feedback" className="paper-button--ghost w-auto" onClick={handleClose}>
View feedback board
</Link>
</div>
</form>

{submitSuccess ? <p className="alert alert--success mt-3">{submitSuccess}</p> : null}
{submitError ? <p className="alert alert--error mt-3">{submitError}</p> : null}
</div>
</div>
) : null}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Two compounding modal accessibility issues: no focus trap + no portal.

1. No focus trap
When keyboard focus reaches the last interactive element ("View feedback board" link, Line 211), pressing Tab moves focus to whatever comes next in the page's tab order — elements behind the overlay. aria-modal="true" is a semantic hint and its support is incomplete across browser/AT combinations; it is not a substitute for a programmatic focus trap.

2. Modal rendered inside <nav>
The overlay is conditionally rendered in-place, which means its DOM parent is <nav aria-label="Primary"> (desktop) or <nav aria-label="Primary mobile"> (mobile). While position: fixed makes it visually full-screen, screen reader users navigating by landmark could encounter the dialog inside the nav landmark rather than as a top-level dialog. The fix is to render via createPortal into document.body.

🔧 Suggested fix — portal + minimal Tab-wrap focus trap
+"use client";
+
+import { createPortal } from "react-dom";
 import Link from "next/link";
 import { useMutation } from "convex/react";
-import { FormEvent, useCallback, useEffect, useRef, useState } from "react";
+import { FormEvent, useCallback, useEffect, useRef, useState, type RefObject } from "react";

Render via portal:

-      {open ? (
-        <div
-          className="feedback-overlay"
-          role="dialog"
-          ...
-        >
-          ...
-        </div>
-      ) : null}
+      {open
+        ? createPortal(
+            <div
+              className="feedback-overlay"
+              role="dialog"
+              aria-modal="true"
+              aria-labelledby="feedback-modal-title"
+              onClick={(event) => {
+                if (event.currentTarget === event.target) handleClose();
+              }}
+            >
+              <div className="paper-card feedback-modal" ref={modalRef}>
+                {/* …existing modal content… */}
+              </div>
+            </div>,
+            document.body,
+          )
+        : null}

Add a Tab-wrap focus trap inside the useEffect:

+    // Collect focusable elements within the modal and trap Tab
+    const FOCUSABLE = 'button:not([disabled]), [href], input, textarea, [tabindex]:not([tabindex="-1"])';
+    const onKeyDown = (event: KeyboardEvent) => {
+      if (event.key === "Escape") { event.preventDefault(); handleClose(); return; }
+      if (event.key !== "Tab" || !modalRef.current) return;
+      const focusable = Array.from(modalRef.current.querySelectorAll<HTMLElement>(FOCUSABLE));
+      const first = focusable[0];
+      const last = focusable[focusable.length - 1];
+      if (event.shiftKey && document.activeElement === first) {
+        event.preventDefault(); last?.focus();
+      } else if (!event.shiftKey && document.activeElement === last) {
+        event.preventDefault(); first?.focus();
+      }
+    };

You would also need:

const modalRef = useRef<HTMLDivElement | null>(null);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/FeedbackWidget.tsx` around lines 146 - 221, The modal
lacks a focus trap and is rendered in-place inside the nav; wrap the dialog in a
portal and add a minimal Tab-wrap focus trap: create a modalRef (e.g., const
modalRef = useRef<HTMLDivElement | null>(null)), render the overlay using
ReactDOM.createPortal into document.body instead of in-place where `open` is
checked, and in a useEffect attach keydown handlers that capture Tab/Shift+Tab
to keep focus cycling between the first and last focusable elements inside the
modal (including the Close button, form inputs, the Submit button and the
`Link`), also ensure focus is moved into the modal on open (focus headingRef or
first focusable) and restored on close by using handleClose to return focus to
the previously focused element.

Comment on lines +217 to +218
{submitSuccess ? <p className="alert alert--success mt-3">{submitSuccess}</p> : null}
{submitError ? <p className="alert alert--error mt-3">{submitError}</p> : null}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Dynamic success/error paragraphs need aria-live to be announced by screen readers.

These elements are conditionally rendered; without an aria-live region, assistive technologies will not announce the outcome of the submission to users who rely on screen readers.

🔧 Suggested fix
-            {submitSuccess ? <p className="alert alert--success mt-3">{submitSuccess}</p> : null}
-            {submitError ? <p className="alert alert--error mt-3">{submitError}</p> : null}
+            <p className="alert alert--success mt-3" aria-live="polite" role="status">
+              {submitSuccess}
+            </p>
+            <p className="alert alert--error mt-3" aria-live="assertive" role="alert">
+              {submitError}
+            </p>

Using always-present elements (with empty content when idle) avoids the timing issue where the region is inserted and announced simultaneously.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{submitSuccess ? <p className="alert alert--success mt-3">{submitSuccess}</p> : null}
{submitError ? <p className="alert alert--error mt-3">{submitError}</p> : null}
<p className="alert alert--success mt-3" aria-live="polite" role="status">
{submitSuccess}
</p>
<p className="alert alert--error mt-3" aria-live="assertive" role="alert">
{submitError}
</p>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/FeedbackWidget.tsx` around lines 217 - 218, The
conditional success/error paragraphs in FeedbackWidget (the submitSuccess and
submitError renderings) lack an aria-live region; change them to always-rendered
elements (e.g., persistent <p> or <div> for the success and error messages) and
add appropriate aria-live attributes (e.g., aria-live="polite" or "assertive")
and role if needed so screen readers announce updates when
submitSuccess/submitError values change; ensure the elements contain an empty
string when there is no message so they remain present in the DOM while only the
text content toggles.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Feb 19, 2026

Caution

Review failed

The pull request is closed.

📝 Walkthrough

Walkthrough

A complete feedback feature is introduced with configuration, backend Convex functions for submission/listing/resolution, database schema with indices, frontend components including a feedback widget and board page, styling updates, and administrative controls for managing feedback items.

Changes

Cohort / File(s) Summary
Configuration & Schema
apps/web/.env.example, apps/web/convex/schema.ts, docs/OWNER_ACTION_ITEMS.md
Added environment variables for feedback admin configuration, new feedback table schema with feedbackStatus type and multiple indices, and documentation for owner setup including admin clerk IDs and emails.
Backend Logic
apps/web/convex/feedback.ts, apps/web/src/lib/convex.ts
Implemented feedback mutations (createFeedback, setFeedbackResolved) and query (listFeedback) with admin identity resolution, input validation, rate limiting (5 per hour, 1-minute minimum interval), and Convex API type definitions for feedback items and responses.
Frontend Components
apps/web/src/components/FeedbackWidget.tsx, apps/web/src/components/SiteHeader.tsx, apps/web/src/app/globals.css
Added FeedbackWidget component with form validation and submission flow, integrated into SiteHeader for desktop and mobile, and introduced CSS styling for feedback trigger, modal overlay, and Clerk UI cleanup.
Feedback Board Page
apps/web/src/app/feedback/page.tsx
New page component displaying feedback items with open/resolved/total counts, per-item metadata (creator email masked for non-admins, relative timestamps), and admin controls to toggle resolution status.

Sequence Diagram(s)

sequenceDiagram
    participant User as User / Client
    participant Widget as FeedbackWidget
    participant Convex as Convex Backend
    participant DB as Database

    User->>Widget: Click feedback trigger
    Widget->>Widget: Open modal, focus input
    User->>Widget: Enter heading & message
    User->>Widget: Click submit
    Widget->>Widget: Validate input lengths<br/>(4-120, 12-2000)
    Widget->>Widget: Get or create anonId
    Widget->>Convex: createFeedback mutation<br/>(heading, message, anonId)
    Convex->>Convex: Resolve user identity
    Convex->>Convex: Check rate limits<br/>(5/hour, 1-min min interval)
    Convex->>DB: Insert feedback record<br/>(open status, timestamps)
    DB-->>Convex: feedbackId
    Convex-->>Widget: { feedbackId }
    Widget->>Widget: Show success message
    Widget->>Widget: Close modal after delay
Loading
sequenceDiagram
    participant Admin as Admin User
    participant Page as FeedbackPage
    participant Convex as Convex Backend
    participant DB as Database

    Admin->>Page: Navigate to /feedback
    Page->>Convex: listFeedback query (limit)
    Convex->>Convex: Verify admin rights<br/>(clerk ID / email)
    Convex->>DB: Query open items<br/>(by_status_updated index)
    Convex->>DB: Query resolved items fallback
    DB-->>Convex: feedback items list
    Convex->>Convex: Compose view with email<br/>(include creator email)
    Convex-->>Page: FeedbackListResponse
    Page->>Page: Compute counts & render
    Page->>Page: Display feedback items<br/>(status, times, emails)
    Admin->>Page: Click resolve checkbox
    Page->>Convex: setFeedbackResolved mutation
    Convex->>Convex: Assert admin rights
    Convex->>DB: Patch feedback status<br/>(resolved, timestamp)
    DB-->>Convex: updated item
    Convex-->>Page: confirmation
    Page->>Page: Update UI, show loading
    Page->>Page: Refresh item state
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Poem

🐰 A feedback feature hops into place,
With modals, mutations, and database grace,
Rate limits bounce, admins resolve with care,
From widget to board—improvements everywhere! ✨📋

✨ Finishing Touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/feedback-board

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant