{"slug": "show-hn-feedback-widget-with-screenshots-annotations-and-ai-triage", "title": "Show HN: Feedback widget with screenshots, annotations, and AI triage", "summary": "Make This Better launched a feedback widget that captures annotated screenshots, console errors, and DOM state, and uses AI triage to turn user reports into tasks for coding agents like Claude Code, Cursor, and Codex. The widget, available via npm as 'makethisbetter' and a script tag, integrates with React, Vue, Astro, and Rails, and aims to close the loop from user frustration to shipped fix without developer context-switching.", "body_md": "Feedback widget with screenshots, annotations, and AI triage.\n\nYou ship with AI agents. Your users hit bugs you never see in dev. They leave. You never find out why.\n\nThis widget gives your users a way to report exactly what went wrong — annotated screenshot, console errors, DOM state, browser info — in two clicks. AI triage turns that into a structured task your coding agent (Claude Code, Cursor, Codex) picks up automatically. The agent ships the fix. The user gets notified.\n\nNo more \"can you describe what happened?\" No more lost screenshots in Slack. The full loop, from frustrated user to shipped fix, runs without you context-switching.\n\n```\n<script src=\"https://unpkg.com/makethisbetter@1\"></script>\n<script>\n  MakeThisBetter.init({ projectKey: 'mtb_proj_YOUR_KEY' })\n</script>\nnpm install makethisbetter\njs\nimport { MakeThisBetter } from 'makethisbetter'\n\nMakeThisBetter.init({ projectKey: 'mtb_proj_YOUR_KEY' })\n```\n\nThat's it. A feedback tab appears on your page.\n\n``` php\nUser clicks feedback tab\n  -> Annotates the problem (click to pin, drag to draw)\n  -> Adds a comment\n  -> Submits\n     +-- Screenshot captured automatically\n     +-- Console errors collected\n     +-- Page context assembled (URL, browser, OS, selectors)\n     +-- Sent to Make This Better API\n     +-- AI asks a clarifying question if needed\n  -> Dashboard shows structured feedback\n  -> AI triage produces an agent-ready task\n  -> Your coding agent picks it up and ships the fix\n  -> User gets notified: the fix is live\n```\n\n**React / Next.js**\n\n```\n// app/providers.tsx (App Router) or pages/_app.tsx (Pages Router)\n'use client'\nimport { useEffect } from 'react'\n\nexport function FeedbackProvider({ user }: { user?: { id: string, email?: string } }) {\n  useEffect(() => {\n    import('makethisbetter').then(({ MakeThisBetter }) => {\n      MakeThisBetter.init({\n        projectKey: process.env.NEXT_PUBLIC_MTB_KEY!,\n        user\n      })\n    })\n    return () => {\n      import('makethisbetter').then(({ MakeThisBetter }) => MakeThisBetter.destroy())\n    }\n  }, [user])\n  return null\n}\n```\n\n**Vue / Nuxt**\n\n``` js\n// plugins/makethisbetter.client.ts (Nuxt) or main.ts (Vue)\nimport { MakeThisBetter } from 'makethisbetter'\n\nexport default defineNuxtPlugin(() => {\n  MakeThisBetter.init({\n    projectKey: useRuntimeConfig().public.mtbKey,\n  })\n\n  return {\n    provide: { mtbDestroy: () => MakeThisBetter.destroy() }\n  }\n})\n```\n\n**Astro**\n\n``` php\n<!-- src/components/Feedback.astro -->\n<script>\n  import { MakeThisBetter } from 'makethisbetter'\n  MakeThisBetter.init({ projectKey: import.meta.env.PUBLIC_MTB_KEY })\n</script>\n```\n\n**Rails**\n\n```\n<%# app/views/layouts/application.html.erb %>\n<body>\n  <%= yield %>\n\n  <%# Turbo replaces <body> on every visit, taking any script-appended element\n      with it. Render the host yourself and mark it permanent, or the widget is\n      rebuilt after each navigation and anything mid-flight — a half-written\n      report, a screen recording — is lost. %>\n  <div id=\"mtb-widget-host\" data-turbo-permanent></div>\n\n  <% if current_user&.admin? %>\n    <script src=\"https://unpkg.com/makethisbetter@1\"></script>\n    <script>\n      MakeThisBetter.init({\n        projectKey: '<%= Rails.application.credentials.mtb_project_key %>',\n        user: { id: '<%= current_user.id %>', email: '<%= current_user.email %>' }\n      })\n    </script>\n  <% end %>\n</body>\n```\n\nThe host must sit inside `<body>`\n\n— Turbo pairs permanent elements by id within\nthe body snapshot, so one placed in `<head>`\n\nis never matched and nothing\nhappens. It needs both the `id`\n\nand the attribute; either alone does nothing.\n\nOnly Turbo-driven apps need this. React, Vue and Svelte routers re-render inside\ntheir own container and never replace `<body>`\n\n, so the host survives on its own.\n\n**Plain HTML / Static Sites**\n\n```\n<script src=\"https://unpkg.com/makethisbetter@1\"></script>\n<script>\n  MakeThisBetter.init({ projectKey: 'mtb_proj_YOUR_KEY' })\n</script>\n```\n\nClick any element to pin it, or drag to draw a freeform highlight. The SDK captures the element's CSS selector, text content, and position.\n\nSwitch to **Replay** mode in the toolbar to capture an Interaction Replay (up to 60 seconds). It records rrweb DOM mutations and interaction events. It does not capture screen video or audio and does not request browser media permissions. The recorder loads lazily, so there is zero cost until the reporter starts a replay.\n\n**What a replay contains**\n\n| Captured | Not captured |\n|---|---|\n| The page's DOM structure and every mutation to it | Passwords, payment-card data, OTPs, access tokens, private keys, and other high-confidence credentials — replaced with `[Filtered]` |\n| Visible text content and ordinary form values | Content inside an element you mark `rr-block` or `rr-mask` |\n| Mouse positions, clicks, scrolls, viewport size | Screen video, audio, camera, microphone |\n| Stylesheets needed to render the replay | Cookies, `localStorage` , HTTP request or response bodies |\n\nSensitive-data filtering is fixed and cannot be disabled through SDK configuration. Ordinary values such as search queries, issue descriptions, and internal form fields remain available because they are often necessary to reproduce a problem.\n\n**Excluding an element.** Add rrweb's privacy classes to any page region that the SDK must not capture. These classes apply consistently to Interaction Replay, click and input breadcrumbs, annotation metadata, and screenshots:\n\n`class=\"rr-block\"`\n\n— hides the entire marked region while preserving its footprint.`class=\"rr-mask\"`\n\n— hides text and form-control content while preserving the surrounding layout.\n\n``` php\n<div class=\"rr-block\"><!-- never appears in a replay --></div>\n<span class=\"rr-mask\">Account balance: $12,400</span>\n```\n\nIf screenshot or Replay filtering cannot complete, that attachment is silently omitted and the text feedback still submits. Automated filtering cannot identify every site-specific secret, so use `rr-block`\n\nor `rr-mask`\n\non sensitive application regions.\n\nThe SDK watches for signals that a user is struggling and proactively offers to collect feedback:\n\n| Signal | Trigger |\n|---|---|\n| Rage click | 4+ clicks on the same interaction target within 1.5 seconds |\n| Interaction error | Uncaught error within 2 seconds after an interaction |\n| Dead click (DOM) | Command-style control with no response after 1 second |\n| Rapid navigation | 3+ browser back/forward navigations within 5 seconds |\n| Form failure | The same form fails validation twice within 30 seconds |\n| Error page | Landing on a 404/500 error page |\n\nDisable with `frustrationDetection: false`\n\n.\n\nBefore submission, an AI assistant may ask one short follow-up question to clarify the real need — avoiding [XY problems](https://xyproblem.info/) where users describe their attempted solution instead of the actual problem. Once the exchange is complete, the widget submits the feedback automatically.\n\nEvery submission automatically includes:\n\n- Page URL origin and pathname, browser, OS, screen resolution\n- Error type, script pathname, and line/column location (via\n`window.onerror`\n\nand`window.onunhandledrejection`\n\n) - Target element selector and text\n- Annotated screenshot with privacy covers applied before upload (via\n`html-to-image`\n\n) - Annotation coordinates and draw paths\n\nBuilt-in support for 7 languages:\n\n| Code | Language |\n|---|---|\n`en` |\nEnglish (default) |\n`zh-CN` |\nChinese (Simplified) |\n`ja` |\nJapanese |\n`ko` |\nKorean |\n`es` |\nSpanish |\n`fr` |\nFrench |\n`de` |\nGerman |\n\n```\nMakeThisBetter.init({\n  // Required\n  projectKey: 'mtb_proj_xxx',\n\n  // Optional\n  locale: 'en',              // UI language. Unset: falls back to <html lang>, then 'en'\n  position: 'right',         // Tab position: 'left' | 'right'\n  tabText: 'Feedback',       // Label on the docked tab. Unset: the locale's own wording\n  brandColors: {             // Optional semantic colors for the complete Widget\n    primary: '#2563eb',\n    hover: '#1d4ed8',\n    active: '#1e40af',\n    onPrimary: '#ffffff',\n  },\n  tabColor: '#2563eb',       // Legacy launcher-only color; ignored when brandColors is valid\n  entryMode: 'button',       // 'button' docks a tab | 'api' renders none\n  theme: 'auto',             // 'light' | 'dark' | 'auto'\n  frustrationDetection: true, // Proactive frustration prompts\n  apiUrl: 'https://...',     // Self-hosted API endpoint\n\n  // User identification (recommended).\n  // Ignored entirely when a valid userToken/userTokenFn JWT is present —\n  // see Identity Verification below.\n  user: {\n    id: 'usr_123',\n    email: 'alex@example.com',\n    name: 'Alex Chen',\n  },\n})\n```\n\nUse `brandColors`\n\nto apply your product's semantic colors across the launcher,\nannotation tools, focus and selection states, calls to action, Reporter bubbles,\nand AI decoration. Supply all four values as six-digit hex colors. The SDK uses\nthem exactly as provided and does not generate a color scale.\n\n```\nMakeThisBetter.init({\n  projectKey: 'mtb_proj_xxx',\n  tabText: 'Report a problem',\n  brandColors: {\n    primary: '#2563eb',\n    hover: '#1d4ed8',\n    active: '#1e40af',\n    onPrimary: '#ffffff',\n  },\n})\n```\n\nSuccess, error, warning, and recording colors keep their state meanings. An\nincomplete group or any value that is not `#RRGGBB`\n\nrejects the complete group\nand leaves the default Widget colors in place.\n\n`tabColor`\n\nremains available for existing installations and for products that\nonly expose one brand color. It accepts one six-digit hex color, affects only\nthe docked launcher, and derives its launcher hover, active, and foreground\ncolors. When both options are present and `brandColors`\n\nis valid, `tabColor`\n\nis\nignored. Omit both options to keep the Make This Better green.\n\nThe `/makethisbetter setup`\n\nskill recommends `brandColors`\n\nonly when it finds a\ncomplete semantic group in your design system. When it finds only a primary\ncolor, it offers `tabColor`\n\ninstead; it never guesses the missing shades.\n\n`locale`\n\nis resolved once, in this order: the `locale`\n\nyou pass, then the page's\n`<html lang>`\n\nattribute, then `en`\n\n. A tag with no exact match is retried without\nits region (`fr-CA`\n\n→ `fr`\n\n), and anything still unmatched falls back to `en`\n\n.\n\n`MakeThisBetter.setLocale('zh-CN')`\n\nswitches the language for the tab and for\nanything opened afterwards. A popup that is already on screen keeps the language it\nwas opened in, so a reporter is never re-rendered mid-sentence. Call it before the\nreporter opens the widget — for example, in the same place your app applies a\nlanguage change.\n\n```\nMakeThisBetter.setLocale('zh-CN')\n```\n\nIdentity verification links feedback to authenticated users and lets them view their own submissions on the feedback board.\n\n**Level 0 -- Anonymous** (default): No user token. Feedback is anonymous.\n\n```\nMakeThisBetter.init({ projectKey: 'mtb_proj_xxx' })\n```\n\nAnonymous reporters are offered a follow-up: the success card shows an optional\nemail field, and an address entered there is sent to the reporter endpoint and\nkept in `localStorage`\n\nunder `mtb_reporter_email`\n\nso the field is not asked for\nagain on later reports from the same browser. The field is skipped entirely when\n`user`\n\nis set or when a JWT already identifies the reporter. Clearing site data\nclears it.\n\n**Level 1 -- Static token**: Pass a pre-generated JWT. Simple, but the token may expire during long sessions.\n\n```\nMakeThisBetter.init({\n  projectKey: 'mtb_proj_xxx',\n  userToken: 'eyJhbGciOiJIUzI1NiIs...',\n})\n```\n\n**Level 2 -- Dynamic token (recommended)**: Pass an async function that returns a fresh JWT. The SDK calls it before each API request, so tokens never go stale.\n\n``` js\nMakeThisBetter.init({\n  projectKey: 'mtb_proj_xxx',\n  userTokenFn: async () => {\n    const res = await fetch('/api/mtb-token')\n    const { token } = await res.json()\n    return token\n  },\n})\n```\n\nWhen `userToken`\n\nor `userTokenFn`\n\nis set, the widget sends an `X-User-Token`\n\nheader with every request. After a successful submission, a \"View my feedback\" link appears that opens the project board filtered to the user's submissions.\n\n**The JWT wins over user.** These are not two independent ways to name the\nreporter. Whenever the server receives a valid token, it takes the reporter's id,\nemail and name from the token's\n\n`sub`\n\n, `email`\n\nand `name`\n\nclaims and discards the\n`user`\n\nfields the widget sent alongside them — a claim you leave out is simply not\nrecorded, even if `user`\n\ncarried it. Put everything you want attributed in the\ntoken, and treat `user`\n\nas the anonymous-only path.Generate tokens server-side using your project's Signing Secret (available in your project settings):\n\n```\n# Rails example\npayload = {\n  sub: current_user.id,\n  email: current_user.email,\n  name: current_user.name,\n  exp: 1.hour.from_now.to_i,\n}\nJWT.encode(payload, project.signing_secret, 'HS256')\n// Only show to beta users\nif (user.isBetaTester) {\n  MakeThisBetter.init({ projectKey: 'mtb_proj_xxx', user: { id: user.id } })\n}\n```\n\nThe Widget works with any backend — not just makethisbetter.dev. Implement the\nSubmission Session profile you need from the\n[Self-Hosting API Specification](https://github.com/makethisbetter/makethisbetter-js/blob/main/SELF_HOSTING.md),\nthen point `apiUrl`\n\nat your API version root:\n\n```\nMakeThisBetter.init({\n  projectKey: 'your-key',\n  apiUrl: 'https://feedback.yoursite.com/api/v1',\n})\n```\n\nThe minimum backend supports the Submission Session flow: create a Session with multipart context, optionally clarify it with the in-memory Submission Token, then explicitly finalize or abandon it. The Widget takes care of annotation, Interaction Replay, frustration detection, and context collection. Anonymous board handoff and post-submit email capture use two additional optional operations documented in the specification.\n\nThe cloud platform at [makethisbetter.dev](https://makethisbetter.dev) adds AI triage, dashboard, GitHub/Linear sync, and email notifications on top.\n\nIf your site sends a `Content-Security-Policy`\n\nheader, three directives can\naffect the widget.\n\n** script-src.** The npm build ships inside your own bundle and needs no entry\nof its own. The CDN build loads from unpkg:\n\n```\nscript-src https://unpkg.com;\n```\n\nInteraction Replay loads the rrweb recorder on demand. The SDK first tries a\ndynamic `import('@rrweb/record')`\n\n— when your bundler resolved that dependency,\nthe recorder is part of your own assets and nothing changes. When that import\nis unavailable (the CDN build, or a bundler that externalized it), the SDK\ninjects a version-pinned, SRI-checked script from jsDelivr:\n\n```\nscript-src https://cdn.jsdelivr.net;\n```\n\nThe injected tag carries a `sha384`\n\n`integrity`\n\nattribute and\n`crossorigin=\"anonymous\"`\n\n, so the browser refuses to run the file if the CDN\ncontent ever changes; the exact hash is pinned in the source next to the URL.\nBecause the tag is created by script, adding that hash to `script-src`\n\ndoes not\nallowlist it — either allow the `cdn.jsdelivr.net`\n\nhost, or use\n`'strict-dynamic'`\n\nwith a nonce on the widget's own script tag so trust\npropagates to scripts it creates.\n\n**When script-src blocks the recorder**, nothing on your page breaks: the\nreplay cannot start, the SDK logs\n\n`[MakeThisBetter] Interaction replay unavailable, falling back to markup`\n\n, and\nthe toolbar switches back to Markup mode. Annotation, screenshots, and text\nfeedback are unaffected.** connect-src.** Submissions go to\n\n`https://makethisbetter.dev`\n\nby default,\nor to the origin of your `apiUrl`\n\nwhen self-hosting:\n\n```\nconnect-src https://makethisbetter.dev;\n```\n\n** style-src.** The widget injects one\n\n`<style>`\n\nelement into its own shadow\nroot and never touches your page's stylesheets. Browsers still evaluate the\npage's CSP for elements inside a shadow root, so a `style-src`\n\nthat forbids\n`'unsafe-inline'`\n\ncan leave the widget unstyled. Browser behavior inside shadow\nroots has varied between engines and versions — if you run a strict\n`style-src`\n\n, open the widget once and check.An allowlist that cannot add jsDelivr can serve the recorder from its own\norigin instead. Before injecting the CDN script, the loader checks for an\nexisting `window.rrwebRecord`\n\nglobal and uses it as-is:\n\n```\ncurl -o public/vendor/rrweb-record.min.js \\\n  https://cdn.jsdelivr.net/npm/@rrweb/record@2.1.0/umd/record.min.js\n<script src=\"/vendor/rrweb-record.min.js\"></script>\n<script src=\"https://unpkg.com/makethisbetter@1\"></script>\n<script>\n  MakeThisBetter.init({ projectKey: 'mtb_proj_YOUR_KEY' })\n</script>\n```\n\nNo SDK configuration is needed — the pre-loaded global wins and the CDN is\nnever contacted. Keep the file at the version the SDK pins\n(`@rrweb/record@2.1.0`\n\n), because the replay privacy filter is written against\nthat recorder's event shape.\n\nThe widget scopes itself to the document it was initialized in: it positions against that frame's viewport and reports that frame's URL, DOM, and interactions. Install it in the frame whose UI you want feedback on. Capturing across frame boundaries — a parent page recording an embedded iframe, or the reverse — is not supported.\n\n``` js\nimport { MakeThisBetter } from 'makethisbetter'\n\n// Start the widget\nMakeThisBetter.init(config: MakeThisBetterConfig): void\n\n// Remove the widget and clean up all listeners\nMakeThisBetter.destroy(): void\n\n// Open annotation mode from your own UI. Idempotent while already open.\nMakeThisBetter.open(): void\n\n// Close annotation mode and restore the page\nMakeThisBetter.close(): void\n\n// Show or remove the docked tab on non-touch devices without tearing down\nMakeThisBetter.showLauncher(): void\nMakeThisBetter.hideLauncher(): void\n\n// Switch the UI language for anything opened afterwards\nMakeThisBetter.setLocale(locale: string): void\n```\n\nA tab docked to the edge of the screen is the right default, but it is not\nalways right — a full-screen editor, a map, or a phone where every pixel is\nspoken for. Set `entryMode: 'api'`\n\nto render no tab at all and open the widget\nfrom wherever the entry point belongs in your product:\n\n```\nMakeThisBetter.init({ projectKey: 'mtb_proj_xxx', entryMode: 'api' })\n\ndocument.querySelector('#menu-feedback')\n  .addEventListener('click', () => MakeThisBetter.open())\n```\n\nIn `api`\n\nmode nothing is reachable until you wire up that call, so the widget\nlogs a warning on init as a reminder. On non-touch devices, `showLauncher()`\n\ncan\nbring the docked tab back at runtime. Touch devices never render the SDK tab;\nput the entry in your own mobile UI and call `MakeThisBetter.open()`\n\nfrom it.\n\nThe widget runs inside a Shadow DOM container, isolating its styles from your page. No CSS conflicts, no z-index wars.\n\n```\nShadow DOM Host (#mtb-widget-host)\n+-- Feedback Tab (entry point)\n+-- Annotation Toolbar (Mark up / Replay toggle)\n+-- Annotation Session (pin + draw overlays)\n+-- Comment Popup (description + submit)\n+-- AI Clarification Card (follow-up conversation)\n+-- Success Card (confirmation)\n+-- Frustration Prompt (proactive trigger)\n```\n\n| Format | Size | gzip |\n|---|---|---|\nIIFE (`makethisbetter.js` ) |\n~160 KB | ~44 KB |\nESM (`makethisbetter.esm.js` ) |\n~187 KB | ~49 KB |\nCJS (`makethisbetter.cjs` ) |\n~146 KB | ~39 KB |\nScreenshot chunk (`html-to-image.js` / `.cjs` ) |\n~17 KB / ~14 KB | ~6 KB / ~5 KB |\nStandalone ESM (`makethisbetter.standalone.js` ) |\n~205 KB | ~55 KB |\n\nThe ESM and CJS bundles load the screenshot renderer (html-to-image) on demand from the sibling chunk the first time a capture path warms up, so sessions that never open the widget skip its weight; bundlers split it into the consumer's own chunks the same way. The IIFE bundle keeps it inlined — classic-script pages get exactly one file, and its size includes the renderer.\n\nCopying a single file out of `dist/`\n\nby path — Rails importmap downloads,\nself-hosting, vendoring into a repo — must use the standalone ESM build\n(`dist/makethisbetter.standalone.js`\n\n, also exported as\n`makethisbetter/standalone`\n\n): it inlines the screenshot chunk so it survives\nbeing served under a digested or relocated filename. Vendoring the split ESM\nfile alone leaves its relative `./html-to-image.js`\n\nimport unresolvable and\nscreenshot capture silently degrades to text-only feedback. The rrweb recorder (~78 KB) is loaded on demand when\nInteraction Replay starts and is not included in these numbers.\n\n```\ngit clone https://github.com/makethisbetter/makethisbetter-js.git\ncd makethisbetter-js\nnpm install\nnpm run dev          # Dev server at localhost:5173\nnpm run build        # Build all formats to dist/\nnpm test             # Run tests\nnpm run type-check   # TypeScript validation\n```\n\n| Package | What it does |\n|---|---|\n|\n\n[@makethisbetter/mcp](https://github.com/makethisbetter/mcp)[makethisbetter CLI](https://github.com/makethisbetter/cli)[makethisbetter Skills](https://github.com/makethisbetter/skills)`/makethisbetter`\n\nin your editor**GitHub repo settings**\n\n**Description**: Drop-in widget for AI-powered user feedback and automated fixes** Homepage**:[https://makethisbetter.dev](https://makethisbetter.dev)** Topics**: feedback, widget, ai, mcp, claude-code, cursor, vibe-coding", "url": "https://wpnews.pro/news/show-hn-feedback-widget-with-screenshots-annotations-and-ai-triage", "canonical_source": "https://github.com/makethisbetter/makethisbetter-js", "published_at": "2026-08-05 12:07:42+00:00", "updated_at": "2026-08-05 12:23:12.668214+00:00", "lang": "en", "topics": ["ai-tools", "ai-agents", "developer-tools"], "entities": ["Make This Better", "Claude Code", "Cursor", "Codex", "React", "Vue", "Astro", "Rails"], "alternates": {"html": "https://wpnews.pro/news/show-hn-feedback-widget-with-screenshots-annotations-and-ai-triage", "markdown": "https://wpnews.pro/news/show-hn-feedback-widget-with-screenshots-annotations-and-ai-triage.md", "text": "https://wpnews.pro/news/show-hn-feedback-widget-with-screenshots-annotations-and-ai-triage.txt", "jsonld": "https://wpnews.pro/news/show-hn-feedback-widget-with-screenshots-annotations-and-ai-triage.jsonld"}}