hazo_feedback
v2.4.0
Published
Drop-in contextual feedback widget for hazo apps
Readme
hazo_feedback
Drop-in contextual feedback widget for hazo_* workspace apps. Collect bug reports, feature requests, praise, and general feedback with automatic screenshot, console errors, breadcrumbs, and consumer context — no setup required beyond a few config steps.
Features
- Floating feedback button — icon-only pill by default (minimized mode), expands on hover to reveal the label; opens dialog on desktop or drawer on mobile
- Auto-capture — page URL, route, viewport, user agent, app version
- Screenshot — lazy-loaded
html2canvas, user-editable (annotate on roadmap) - Context — console errors (ring buffer), breadcrumbs (50-entry ring), consumer-registered state
- Four categories — bug, feature, general, praise
- Rich text editor — Tiptap-powered body with inline image paste support
- Attachments — upload files, paste images, auto-screenshot (client-side zip download)
- Anonymous + authenticated — anon users via
ensure_anon_idcookie; auth users get snapshotted details - Rate limiting — in-memory token bucket per anon/user/IP
- Admin inbox — list, filter, search (full-text), detail view with rich context display
- Status workflow — new → triaged → in_progress → resolved/wont_fix
- Admin comments — threaded conversation on each submission
- AI prompt export — one-click markdown export optimized for Claude Code paste-and-debug
- Acknowledgement email — ref ID sent in-dialog; email via
hazo_notify(when configured) - Ref ID system — Crockford base32, collision-safe, app-prefixed (e.g.,
myapp-AB12345) - Mobile-friendly — responsive dialog on desktop, vaul-backed drawer on mobile
Installation
npm install hazo_feedbackPeer Dependencies
Ensure your app has these installed (hazo_feedback declares them as peers):
| Package | Version | Notes |
|---------|---------|-------|
| react | ^18.0.0 \|\| ^19.0.0 | |
| react-dom | ^18.0.0 \|\| ^19.0.0 | |
| next | ^14.0.0 \|\| ^16.0.0 | |
| hazo_core | ^1.0.0 | Error classes, correlation ID, logger factory |
| hazo_connect | ^3.0.0 | Database access |
| hazo_auth | ^9.0.1 | User auth, ensure_anon_id, get_client_ip |
| hazo_ui | ^3.1.3 | Dialog, Drawer, Form, Button primitives |
| hazo_files | ^3.0.0 | File upload, storage, retrieval |
| hazo_notify | ^6.1.0 | Acknowledgement + reply email templates (required, not optional) |
| lucide-react | ^0.553.0 | Icons |
| @tiptap/react | ^3.20.5 | Rich text editor |
| @tiptap/starter-kit | ^3.20.5 | Tiptap extensions (bold, italic, lists, etc.) |
| @tiptap/extension-image | ^3.20.5 | Tiptap image support |
| @tiptap/extension-link | ^3.20.5 | Tiptap link support |
| @tiptap/extension-placeholder | ^3.20.5 | Tiptap placeholder support |
Quick Start
1. Server Setup
In your app's API route handler factory (or instrumentation file if using Next.js initialization):
import { createFeedbackServer, hazo_feedback_template_manifest } from 'hazo_feedback';
import { sync_system_templates } from 'hazo_notify/template_manager';
// In instrumentation.ts or app bootstrap
export async function initFeedbackServer(
hazoConnect: any,
fileManager: any,
notifyConnect: any
) {
const feedbackServer = createFeedbackServer({
getHazoConnect: () => hazoConnect,
getFileManager: () => fileManager,
notifyOptions: {
getHazoConnect: () => notifyConnect,
from: '[email protected]',
fromName: 'MyApp Feedback',
},
});
// Register templates in hazo_notify
await sync_system_templates(
[
...hazo_auth_template_manifest,
...hazo_feedback_template_manifest,
],
{ getHazoConnect: () => notifyConnect }
);
return feedbackServer;
}2. API Route
Create src/app/api/feedback/[...path]/route.ts:
import 'server-only';
export const runtime = 'nodejs'; // CRITICAL: see SETUP_CHECKLIST.md
import { NextRequest, NextResponse } from 'next/server';
import { getFeedbackServer } from '@/lib/feedback/server';
export async function GET(req: NextRequest, ctx: any) {
return (await getFeedbackServer()).handlers.GET(req, ctx);
}
export async function POST(req: NextRequest, ctx: any) {
return (await getFeedbackServer()).handlers.POST(req, ctx);
}
export async function PATCH(req: NextRequest, ctx: any) {
return (await getFeedbackServer()).handlers.PATCH(req, ctx);
}
export async function DELETE(req: NextRequest, ctx: any) {
return (await getFeedbackServer()).handlers.DELETE(req, ctx);
}3. Client Setup
Wrap your app layout in FeedbackProvider:
// app/layout.tsx
import { FeedbackProvider } from 'hazo_feedback/client';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<FeedbackProvider appId="myapp" apiBase="/api/feedback">
{children}
</FeedbackProvider>
</body>
</html>
);
}Add the widget to your layout (or a specific page):
import { FeedbackWidget } from 'hazo_feedback/client';
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<>
{children}
{/* Default: icon-only pill, expands to show label on hover, draggable */}
<FeedbackWidget />
{/* Always-expanded style (pre-v2.1.2 behaviour): */}
{/* <FeedbackWidget minimized={false} /> */}
{/* Pinned in place, no dragging: */}
{/* <FeedbackWidget draggable={false} /> */}
</>
);
}Draggable launcher (v2.3.0+)
By default the trigger bubble can be repositioned:
- Drag it (mouse, touch, or pen — all share one Pointer Events code path) to anywhere on screen.
- Keyboard: with the trigger focused,
Alt+ArrowUp/ArrowDown/ArrowLeft/ArrowRightnudges it 16px per press;Alt+0resets it to the default bottom-right spot.
The chosen position persists in localStorage (namespaced per appId — two apps on one origin won't collide) and is restored on next load, clamped to stay fully on screen. A short (<4px) pointer movement still counts as a click and opens the feedback dialog as before; only an actual drag suppresses the click.
// Pin the bubble in place (no dragging, no keyboard nudge):
<FeedbackWidget draggable={false} />
// Custom persistence key (defaults to `hazo_feedback:widget-position:{appId}`):
<FeedbackWidget storageKey="my-custom-key" />
// Custom starting position / Alt+0 reset target (defaults to 16px from bottom-right):
<FeedbackWidget defaultPosition={{ right: 24, bottom: 96 }} />Deep-linking via query params (v2.4.0+)
By default FeedbackProvider checks window.location.search once on mount for a feedback query param. If present, it opens the dialog (or drawer, on mobile) pre-filled and then strips the params from the URL via history.replaceState — so a reload after closing won't reopen it.
/tools/cgt?feedback=1&fb_category=bug&fb_subject=Chart%20broken
/tools/cgt?feedback=general
/tools/cgt?feedback=bug&fb_subject=Export%20failsfeedback— required to trigger anything. A truthy alias (1,true,yes,open, case-insensitive) opens with no implied category. A valid category value (bug,feature,general,praise) opens with that category preselected. Any other value still opens the dialog, just with no category implied (a typo degrades gracefully instead of swallowing the link).fb_category— optional, takes precedence over whateverfeedbackimplied. An invalid value is ignored.fb_subject— optional, trimmed and clamped to 200 characters; seeds the Subject field. Prefilling the rich-text body is deliberately not supported — subject is a plain, React-escaped<input>, but the body field is a Tiptap editor and accepting caller-supplied HTML into it would be an XSS footgun.- Only these three params are consumed and stripped — any other query params on the page survive untouched.
- The read happens exactly once, in a mount effect (same SSR-safe pattern as the draggable launcher). It does not re-scan on client-side route changes within a single-page session — deep links are treated as entry-point navigations.
Opt out entirely with:
<FeedbackProvider appId="myapp" apiBase="/api/feedback" queryParams={false}>
{children}
</FeedbackProvider>4. Tailwind CSS Setup (REQUIRED)
Without the @source directive, Tailwind v4 JIT won't find hazo_feedback classes.
In your CSS entry file (e.g., app/globals.css):
@import "tailwindcss";
/* REQUIRED: tells Tailwind v4 to scan hazo_feedback dist for class names */
@source "../node_modules/hazo_feedback/dist";
/* Your other CSS imports */5. Admin Page
Create a protected route (e.g., src/app/admin/feedback/page.tsx) with auth checks:
import { hazo_get_auth } from 'hazo_auth/server-lib';
import { FeedbackAdminPage } from 'hazo_feedback/client';
export default async function FeedbackAdminPage() {
const auth = await hazo_get_auth(request, {
required_permissions: ['hazo_feedback:myapp:admin'],
});
if (!auth.user) {
return <div>Unauthorized</div>;
}
return <FeedbackAdminPage appId="myapp" apiBase="/api/feedback" />;
}Configuration
Copy config/hazo_feedback_config.ini.sample to config/hazo_feedback_config.ini and customize:
[app]
app_id = myapp
[admin]
admin_scope = hazo_feedback:myapp:admin
[rate_limit]
per_anon_count = 10
per_anon_window_ms = 60000
per_user_count = 50
per_user_window_ms = 3600000
per_ip_count = 100
per_ip_window_ms = 60000
[attachments]
max_count = 5
max_bytes_per_file = 10485760
total_max_bytes = 26214400
[notify]
acknowledge_email_enabled = true
acknowledge_email_from = [email protected]
acknowledge_email_from_name = MyApp
acknowledge_email_subject = We received your feedback ({{ref_id}})Key settings:
app_id— stamped into ref IDs and used for scoping admin permissionsadmin_scope— matches the permission provisioned viascripts/provision_feedback_admin.sql- Rate limit settings — per-window count/duration for anon, authed, and IP-based limits
- Attachment limits — Note: Vercel's edge runtime has a 4.5 MB body limit; Node runtime (declared via
export const runtime = 'nodejs') removes this
Consumer Context Registration
Register app state for inclusion in submissions:
import { useRegisterFeedbackContext } from 'hazo_feedback/client';
export function MyFeature() {
useRegisterFeedbackContext('my_feature', {
featureVersion: '2.1.0',
userTier: 'pro',
selectedTool: 'magic-wand',
});
return <div>...</div>;
}Multiple components can register context. At submit time, all registered data is merged, deduplicated, and sanitized for PII before storage.
Imperative API
Breadcrumbs
Log navigation, UI interactions, and state changes:
import { feedback } from 'hazo_feedback/client';
// In an event handler, route change, etc.
feedback.breadcrumb('user:clicked-button', {
buttonId: 'save-doc',
docId: selectedDoc.id,
});
feedback.breadcrumb('user:route-changed', {
from: '/editor',
to: '/workspace',
});
feedback.breadcrumb('app:error-caught', {
error: err.message,
stack: err.stack,
});Breadcrumbs are captured in a 50-entry ring buffer. At submit time, all entries are serialized and stored, and redacted for PII.
Open (v2.4.0+)
Open the feedback dialog/drawer from anywhere in your app — a button, a caught error, a support link — without going through a URL:
import { feedback } from 'hazo_feedback/client';
// Plain open, defaults to the General tab:
feedback.open();
// Pre-scoped, same shape as the query-param prefill:
feedback.open({ category: 'feature', subject: 'Add dark mode' });feedback.open(prefill?) accepts the same { category?, subject? } shape parsed from the URL by parseFeedbackQuery (see Deep-linking via query params). Like feedback.breadcrumb(), it's a no-op (not a throw) when called before a FeedbackProvider has mounted.
Internationalization
Pass a translate function to customize strings:
<FeedbackProvider
appId="myapp"
translate={(key, vars) => {
const dict: Record<string, string> = {
'button.open_feedback': 'Send feedback',
'dialog.title': 'Help us improve',
// ... etc
};
return dict[key] || key;
}}
>
{children}
</FeedbackProvider>If no translate function is provided, hazo_feedback uses English defaults from FEEDBACK_STRINGS:
import { FEEDBACK_STRINGS } from 'hazo_feedback/client';
console.log(FEEDBACK_STRINGS['button.open_feedback']); // "Send feedback"Admin Page Features
List view:
- Filter by status, priority, category
- Free-text search across ref_id, subject, body, user name, user email
- Sort by created date, updated date, status
- Pagination
Detail view:
- Overview tab — submission metadata (ref_id, user, category, status), action buttons (priority, status, export prompt, zip attachments)
- Context tab — consumer-registered state, breadcrumbs, console errors (all redacted, key names highlighted)
- Attachments tab — images, files, with copy/download options
- Activity tab — event log (status/priority changes, comments, prompt exports)
AI Prompt export:
- Markdown with submission details, context, errors, breadcrumbs
- Copy to clipboard with fallback dialog
- Optimized for Claude Code paste-and-debug workflow
Comment thread:
- Admin comments on submissions
- Timestamped, attributed to actor
- No external notification (comment is visible on next admin visit)
Reply threads
After a feedback submission, the admin can reply via the Conversation tab in the admin dashboard. The submitter is notified via hazo_notify's in-app inbox (always) and email (configurable). The submitter can reply back from the standalone thread page: <FeedbackThread refId="…" apiBase="/api/feedback" />.
Both the admin ConversationTab and FeedbackThread use the shared ReplyComposer component — the same Tiptap rich-text editor with inline image paste and file attachments available in the main submission widget.
Using ReplyComposer standalone:
import { ReplyComposer } from 'hazo_feedback/client';
<ReplyComposer
onSend={async (bodyHtml, inlineBlobs, attachments) => {
// post reply to your endpoint; throw on error to keep the form open
await sendReply({ bodyHtml, inlineBlobs, attachments });
}}
placeholder="Write a reply…"
sendLabel="Send reply"
translate={t}
/>The component resets automatically when onSend resolves. Throw from onSend to display an inline error and keep the form state intact.
Inline images in reply bubbles are served via the thread attachment endpoint (GET /thread/:refId/attachment/:attachmentId), which is accessible to both the submitter and admins — no separate auth token required beyond being logged in as the submitter.
Constraints:
- Authed users only (anon submitters don't get reply threads in v2.1).
- User can reply only after the first admin reply (server returns
409 Conflictotherwise). - Replies are immutable and rate-limited per user.
- See
SETUP_CHECKLIST.mdfor mounting<FeedbackThread />.
Public voting / feature board
Mount <PublicFeatureBoard apiBase="/api/feedback" /> on a logged-in route. Admins toggle individual feature submissions to public via the "Make public" button on the submission detail page. Users vote with a single click (toggle); top-voted features bubble to the top.
Constraints:
category='feature'only (server rejects other categories with422).- Authed users only — anon users see a sign-in prompt.
- Self-voting allowed.
- Vote counts are computed on read (no denormalization in v2.1).
TypeScript Types
// From hazo_feedback
export type FeedbackCategory = 'bug' | 'feature' | 'general' | 'praise';
export type FeedbackStatus = 'new' | 'triaged' | 'in_progress' | 'resolved' | 'wont_fix';
export type FeedbackPriority = 'low' | 'medium' | 'high' | 'urgent';
export type AttachmentKind = 'screenshot' | 'pasted_image' | 'uploaded_file';
export interface FeedbackSubmission {
id: string;
ref_id: string;
app_id: string;
user_id: string | null;
category: FeedbackCategory;
subject: string | null;
body_html: string | null;
body_text: string | null;
status: FeedbackStatus;
priority: FeedbackPriority | null;
url: string;
route: string | null;
viewport_w: number | null;
viewport_h: number | null;
user_agent: string | null;
app_version: string | null;
consumer_context: Record<string, unknown> | null;
consumer_context_redacted: string[] | null;
recent_errors: unknown[] | null;
breadcrumbs: BreadcrumbEntry[] | null;
attachment_count: number;
acknowledge_email_sent_at: string | null;
created_at: string;
updated_at: string;
resolved_at: string | null;
}
export interface FeedbackAttachment {
id: string;
submission_id: string;
inline_id: string | null;
file_id: string;
mime_type: string;
size_bytes: number;
kind: AttachmentKind;
created_at: string;
}
export interface BreadcrumbEntry {
type: string;
message: string;
timestamp: number;
data?: Record<string, unknown>;
}
// From hazo_feedback/client hooks
export type CopyState = 'idle' | 'copied' | 'failed';API Reference
Server Entry
import { createFeedbackServer, hazo_feedback_template_manifest } from 'hazo_feedback';
export function createFeedbackServer(options: {
getHazoConnect: () => Promise<any> | any;
getFileManager: () => Promise<any> | any;
appId?: string; // Overrides config; useful for test-app
adminScope?: string; // Overrides config
notifyOptions?: {
getHazoConnect: () => Promise<any> | any;
from: string;
fromName?: string;
};
logger?: Logger; // hazo_logs interface (optional)
}): FeedbackServer;Returns:
interface FeedbackServer {
handlers: {
GET(req: NextRequest, ctx: any): Promise<NextResponse>;
POST(req: NextRequest, ctx: any): Promise<NextResponse>;
PATCH(req: NextRequest, ctx: any): Promise<NextResponse>;
DELETE(req: NextRequest, ctx: any): Promise<NextResponse>;
};
}Client Entry
// Components
export { FeedbackProvider } from 'hazo_feedback/client';
export { FeedbackWidget } from 'hazo_feedback/client';
export { FeedbackAdminPage } from 'hazo_feedback/client';
export { FeedbackThread } from 'hazo_feedback/client';
export { PublicFeatureBoard } from 'hazo_feedback/client';
// Hooks
export { useRegisterFeedbackContext } from 'hazo_feedback/client';
export { useCopyToClipboard } from 'hazo_feedback/client';
export { useDraggableWidget } from 'hazo_feedback/client';
// Draggable widget position
export { DEFAULT_POSITION } from 'hazo_feedback/client';
// type WidgetPosition, type UseDraggableWidgetOptions, type UseDraggableWidgetResult
// Imperative API
export { feedback } from 'hazo_feedback/client';
// Usage: feedback.breadcrumb(type, data), feedback.open({ category?, subject? })
// Deep-link query-param prefill (v2.4.0+)
export { parseFeedbackQuery, stripFeedbackQuery, FEEDBACK_QUERY_PARAMS } from 'hazo_feedback/client';
export { FEEDBACK_CATEGORIES } from 'hazo_feedback/client';
// type FeedbackPrefill
// i18n defaults
export { FEEDBACK_STRINGS } from 'hazo_feedback/client';Component Props
<FeedbackProvider appId apiBase translate source user appVersion captureErrors redactContext maxAttachments maxBytesPerFile totalMaxBytes queryParams? />
queryParams(boolean, defaulttrue) — Whentrue, readsfeedback/fb_category/fb_subjectfromwindow.location.searchonce on mount and opens the dialog pre-filled (see Deep-linking via query params). Set tofalseto disable this entirely — the widget then behaves exactly as in pre-v2.4.0 releases.
<FeedbackWidget minimized? draggable? storageKey? defaultPosition? className? />
minimized(boolean, defaulttrue) — Whentrue, renders an icon-only pill that expands on hover to reveal the label text via CSS animation. Set tofalsefor the always-expanded style.draggable(boolean, defaulttrue) — Whentrue, the trigger can be repositioned by dragging (Pointer Events) or withAlt+Arrow keys (Alt+0resets). Position persists tolocalStorage. Set tofalseto pin the bubble at its default bottom-right spot exactly as in pre-v2.3.0 releases.storageKey(string, optional) — Overrides thelocalStoragekey used to persist the dragged position. Defaults tostorageKeyFor(appId)from the enclosingFeedbackProvider, i.e.hazo_feedback:widget-position:{appId}.defaultPosition(WidgetPosition, optional) —{ right, bottom }px offsets the bubble starts at and resets to onAlt+0. Defaults toDEFAULT_POSITION({ right: 16, bottom: 16 }, matching the originalbottom-4 right-4placement).className(string, optional) — Additional Tailwind classes merged onto the trigger button.
<FeedbackThread refId apiBase translate /> — Standalone reply thread for the submitter.
<PublicFeatureBoard apiBase translate pageSize defaultSort onSubmissionClick /> — Logged-in feature roadmap with voting.
Deferral Ledger (v1.0)
The following features are planned for v1.1+ and are NOT included in v1.0:
- Annotation tool — edit/mark up screenshots (blocked: markerjs3 Linkware License)
- Cross-app super-admin — single admin view across all apps (deferred: HRBAC scope complexity)
- Webhook notifications — POST webhook on new submission (deferred: delivery/retry complexity)
- Bulk actions — select multiple submissions, change status/priority (deferred: UI/UX polish)
- Assignment/ownership — assign feedback to team members (deferred: hazo_auth role-based assignment not yet built)
- Custom fields — app-defined submission fields beyond standard set (deferred: schema migration complexity)
- AI-powered categorization — auto-categorize/tag submissions (deferred: LLM cost/latency tradeoff)
- Survey mode — structured form (not free-text dialog) (deferred: design pending)
- Sentiment analysis — flag praise/complaints for quick triage (deferred: hazo_llm_api integration)
- Duplicate detection — flag similar submissions (deferred: semantic search complexity)
- Rate limiting per consumer — per-context limits (deferred: implementation pending)
- Privacy mode — redact all context, PII, errors (deferred: UX confirmation flow)
- Dark mode — theme switching (deferred: hazo_ui Dark mode support pending)
See design/hazo_feedback_v1_plan.md for full roadmap.
Contributing
See SETUP_CHECKLIST.md for step-by-step setup for local development.
License
MIT. See package.json.
