@flusterduck/mcp-server 0.7.2 → 0.7.4
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.
- package/dist/{chunk-7KMA7AVO.js → chunk-FRDZ4X3Q.js} +19 -12
- package/dist/cli.js +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
|
@@ -402,7 +402,7 @@ Session limits are pooled across all sites in the org, not per-site. All plans i
|
|
|
402
402
|
"title": "CLI reference",
|
|
403
403
|
"description": "Detect your framework, install the right packages, and inject the init call automatically.",
|
|
404
404
|
"group": "SDK & frameworks",
|
|
405
|
-
"content": '# CLI Reference\n\n```bash\nnpx flusterduck-cli init\n```\n\nDetects your framework, installs the right packages, and injects the init call into the correct entry file. With no `--key`, it offers to **sign you in with your browser**: a tab opens, you pick or create a site on flusterduck.com, and the key comes back to the terminal automatically
|
|
405
|
+
"content": '# CLI Reference\n\n```bash\nnpx flusterduck-cli init\n```\n\nDetects your framework, installs the right packages, and injects the init call into the correct entry file. With no `--key`, it offers to **sign you in with your browser**: a tab opens, you pick or create a site on flusterduck.com, and the key comes back to the terminal automatically: no dashboard round-trip, nothing to copy. (Creating a new site returns its key directly; an existing site asks you to paste the `fd_pub_` key already in its script tag.)\n\nAt the end, init offers to **verify the install live**: start your app, click around, and it watches for the first event to arrive, so a wrong-but-well-formed key can never fail silently.\n\nYou don\'t need to install the CLI globally. `npx` pulls the latest version each time.\n\n## Options\n\n### `--key fd_pub_xxxx`\n\nSkip the key prompt:\n\n```bash\nnpx flusterduck-cli init --key fd_pub_xxxxxxxxxxxx\n```\n\nUseful in CI pipelines, onboarding scripts, and anywhere you want a non-interactive run.\n\n### `--skip-install`\n\nInject the setup code without installing packages. Use this if you\'ve already installed the SDK separately or if your package manager workflow requires a separate step.\n\n```bash\nnpx flusterduck-cli init --skip-install --key fd_pub_xxxxxxxxxxxx\n```\n\n## Framework detection\n\nThe CLI reads your `package.json` and project structure to determine your framework, then installs and injects accordingly:\n\n| Detected | Packages installed | File injected |\n|---|---|---|\n| Next.js | `flusterduck` `@flusterduck/next` | `app/layout.tsx` (App Router) or `pages/_app.tsx` (Pages Router) |\n| SvelteKit | `flusterduck` `@flusterduck/svelte` | `src/routes/+layout.svelte` |\n| Nuxt | `flusterduck` `@flusterduck/nuxt` | `nuxt.config.ts` |\n| React (Vite / CRA) | `flusterduck` | `src/main.tsx` or `src/index.tsx` |\n| Generic / unknown | `flusterduck` | No injection |\n\nIf the CLI can\'t determine your framework, it installs the core SDK and exits with a code snippet to add manually.\n\n## What gets injected\n\nThe injected code is a single import and a single init call. For Next.js App Router:\n\n```tsx\n// app/layout.tsx\nimport { FlusterduckScript } from \'@flusterduck/next\'\n\n// Added inside your RootLayout body:\n<FlusterduckScript apiKey={process.env.NEXT_PUBLIC_FLUSTERDUCK_KEY!} />\n```\n\nFor vanilla React:\n\n```tsx\n// src/main.tsx\nimport { init } from \'flusterduck\'\ninit({ key: process.env.VITE_FLUSTERDUCK_KEY! })\n```\n\nThe CLI doesn\'t rewrite existing imports or touch your component tree. If Flusterduck is already present in the target file, it skips injection and tells you.\n\n## Environment variable\n\nThe injected code references an environment variable, not a literal key. After running the CLI, set the variable:\n\n```bash\n# .env.local (Next.js)\nNEXT_PUBLIC_FLUSTERDUCK_KEY=fd_pub_xxxxxxxxxxxx\n\n# .env (Vite / SvelteKit)\nVITE_FLUSTERDUCK_KEY=fd_pub_xxxxxxxxxxxx\n```\n\nThe CLI output tells you the exact variable name for your framework.\n\n## Reading your data\n\nBeyond `init`, the CLI reads and manages friction data straight from your terminal. The binary answers to two names (`flusterduck` and `duck`), so `duck status` and `duck issues` both work.\n\nSign in once and every command authenticates automatically:\n\n```bash\nduck login # browser sign-in (mints a key, nothing to copy) or paste with hidden input\nduck logout # removes the saved key\n```\n\nThe browser option opens flusterduck.com, you pick the site this terminal should manage, and a freshly minted key is sent straight to the CLI on your machine (never through a URL). Either way `login` verifies the key against the API before saving, stores it in a `0600` file under your config directory (`~/.config/flusterduck/credentials.json`), and remembers the key\'s site, so a site-scoped key makes `--site` optional everywhere. Precedence when both exist: `--key` flag, then `FLUSTERDUCK_SECRET_KEY`, then the saved login. Override the API base with `--api` or `FLUSTERDUCK_API_URL`. Every command takes `--json` for raw output.\n\n```bash\nduck login\nduck issues --status open # no --key, no --site\nduck deploy notify # same\n```\n\n### `scores`\n\nPer-page confusion scores for a site, ranked by friction.\n\n```bash\nflusterduck scores --site <site_id>\n```\n\n### `issues`\n\nUX issues detected on a site. Filter with `--status` (`open`, `triaged`, `in_progress`, `resolved`, `verified`, `ignored`, `regressed`) and cap with `--limit`.\n\n```bash\nflusterduck issues --site <site_id> --status open --limit 20\n```\n\n### `insights`\n\nThe confused-vs-calm conversion gap: how much less confused sessions convert than calm ones, the pages and traffic sources hit hardest, and the ranked insights. Pass `--days` (1-90, default 7) to set the window.\n\n```bash\nflusterduck insights --site <site_id> --days 30\n```\n\nThis needs a conversion event wired so Flusterduck knows what "success" is. See [Conversion trigger](./conversion-trigger).\n\n### `issue`\n\nManage a single issue from the terminal. Verbs: `resolve`, `ignore`, `reopen`, `start` (moves it to in progress). Attach context with `--note`.\n\n```bash\nflusterduck issue resolve 7f2c9d4a-... --note "Fixed in #142"\nflusterduck issue ignore 7f2c9d4a-... --note "Third-party widget, can\'t fix"\n```\n\n### `status`\n\nIs Flusterduck receiving data for a key? Authenticates with the **publishable** key (`--key fd_pub_...` or `FLUSTERDUCK_PUBLISHABLE_KEY`), so it works before you\'ve ever opened the dashboard. `--wait` polls for up to 3 minutes until the first event lands.\n\n```bash\nflusterduck status --key fd_pub_xxxxxxxxxxxx --wait\n```\n\n### `deploy notify`\n\nRecord a deploy so Flusterduck captures before/after confusion and verifies your fixes. Run it from CI after each production deploy. Commit hash, author, and PR number are auto-detected on GitHub Actions, Vercel, GitLab, and Bitbucket.\n\n```bash\nflusterduck deploy notify --site <site_id>\n# or outside CI:\nflusterduck deploy notify --site <site_id> --commit "$(git rev-parse HEAD)" --env production\n```\n\nSame thing, no npx: POST to [`/v1/deploys`](./deploy-correlation) directly.\n\n## Troubleshooting\n\n**"Could not automatically inject code"** -- the CLI found the entry file but couldn\'t parse it (unusual syntax, non-standard structure). Wire the init call manually following the [quickstart](./quickstart).\n\n**"Flusterduck is already configured"** -- the SDK was already detected in the target file. Nothing changed.\n\n**"Failed to install dependencies"** -- package installation failed. The CLI prints the exact install command to run yourself.\n'
|
|
406
406
|
},
|
|
407
407
|
{
|
|
408
408
|
"slug": "frameworks",
|
|
@@ -451,7 +451,14 @@ Session limits are pooled across all sites in the org, not per-site. All plans i
|
|
|
451
451
|
"title": "Identifying sessions",
|
|
452
452
|
"description": "Tag sessions with safe user, account, or cohort properties so friction data is segmentable.",
|
|
453
453
|
"group": "SDK & frameworks",
|
|
454
|
-
"content": "# Identifying Sessions\n\nTag sessions with safe user, account, or cohort properties so friction data is segmentable. Once you're calling `identify()`, you can ask whether checkout friction is worse for Grow users than Scale users, whether a redesign cohort is hitting the same issues as the control group, or whether enterprise accounts behave differently during onboarding.\n\nWithout it, you're looking at aggregate confusion scores with no way to slice them.\n\n## identify()\n\nCall it after login, when you have stable properties:\n\n```ts\nimport { identify } from 'flusterduck'\n\nidentify({\n user_id: 'usr_8f3a2c91',\n plan: 'scale',\n org_id: 'org_4b2e9f1c',\n account_age_days: '42',\n})\n```\n\n`identify()` accepts one object: `Record<string, string>`. Values are stringified, truncated, and attached to the current session. Keep IDs opaque. A database primary key or UUID is fine. An email address, display name, or phone number is not.\n\n## What properties to pass\n\nPass things you'd want to filter by when investigating a friction pattern:\n\n```ts\nidentify({\n user_id: 'usr_8f3a2c91',\n plan: 'scale',\n billing: 'annual',\n org_size: '11-50',\n role: 'admin',\n account_age_days: '42',\n experiment_variant: 'checkout-v2',\n feature_flag_new_nav: 'true',\n})\n```\n\nNever pass: email, name, username, IP address, display handle, or any field that links directly to a real person.\n\n## When to call it\n\nCall `identify()` as early as possible after login and after the SDK has initialized. Properties apply to the current session metadata used for future flushes.\n\nIn practice: put it in a component or hook that runs immediately after your auth state resolves.\n\n### React\n\n```tsx\nimport { useEffect } from 'react'\nimport { identify } from 'flusterduck'\n\nexport function IdentityBridge({ userId, plan, orgId }: {\n userId?: string\n plan?: string\n orgId?: string\n}) {\n useEffect(() => {\n if (!userId) return\n identify({\n user_id: userId,\n plan: plan ?? 'trial',\n org_id: orgId ?? '',\n })\n }, [userId, plan, orgId])\n\n return null\n}\n```\n\nRender this inside your auth provider where `userId` is reliably available.\n\n### Next.js\n\n```tsx\n'use client'\nimport { useEffect } from 'react'\nimport { identify } from 'flusterduck'\n\nexport function IdentifyUser({ userId, plan }: { userId?: string; plan?: string }) {\n useEffect(() => {\n if (!userId) return\n identify({ user_id: userId, plan: plan ?? 'trial' })\n }, [userId, plan])\n\n return null\n}\n```\n\n### Vue\n\n```vue\n<script setup lang=\"ts\">\nimport { watch } from 'vue'\nimport { identify } from 'flusterduck'\n\nconst props = defineProps<{ userId?: string; plan?: string }>()\n\nwatch(\n () => props.userId,\n (userId) => {\n if (!userId) return\n identify({ user_id: userId, plan: props.plan ?? 'trial' })\n },\n { immediate: true },\n)\n</script>\n```\n\n## The segment config option\n\nFor properties that apply to every session regardless of who's logged in, use `segment` in your SDK config instead of `identify()`:\n\n```ts\ninit({\n key: process.env.NEXT_PUBLIC_FLUSTERDUCK_KEY!,\n segment: {\n app_version: process.env.NEXT_PUBLIC_APP_VERSION ?? 'unknown',\n region: 'us-east-1',\n experiment_group: 'pricing-v2-b',\n },\n})\n```\n\n`segment` properties are attached from the first signal in the session. `identify()` properties are attached after the call.\n\nUse `segment` for: deploy tags, region, A/B test cohorts, feature flag variants at the session level.\n\nUse `identify()` for: plan tier, organization, user role, account age, anything tied to a specific authenticated user.\n\nIf you pass the same key in both `segment` and `identify()`, the `identify()` value wins after the call.\n\n## Using segments in investigations\n\nWith properties in place, you can filter friction data by segment values. Via MCP:\n\n```\nAre rage clicks on the checkout button worse for Grow plan users than Scale?\nWhich sessions hitting form abandonment on onboarding are enterprise accounts?\nCompare confusion scores between experiment_group: pricing-v2-a and pricing-v2-b.\nShow me sessions where role: admin hit the dead click on the settings page.\n```\n\n## Multiple identify() calls\n\nCalling `identify()` multiple times in the same session merges properties.\n\n```ts\n// On login\nidentify({ user_id: 'usr_8f3a2c91', plan: 'scale' })\n\n// After loading org data\nidentify({ org_id: 'org_4b2e9f1c', org_size: '11-50' })\n```\n\nThe session ends up with all four properties. Use this pattern when you load user data in stages.\n"
|
|
454
|
+
"content": "# Identifying Sessions\n\nTag sessions with safe user, account, or cohort properties so friction data is segmentable. Once you're calling `identify()`, you can ask whether checkout friction is worse for Grow users than Scale users, whether a redesign cohort is hitting the same issues as the control group, or whether enterprise accounts behave differently during onboarding.\n\nWithout it, you're looking at aggregate confusion scores with no way to slice them.\n\n## identify()\n\nCall it after login, when you have stable properties:\n\n```ts\nimport { identify } from 'flusterduck'\n\nidentify({\n user_id: 'usr_8f3a2c91',\n plan: 'scale',\n org_id: 'org_4b2e9f1c',\n account_age_days: '42',\n})\n```\n\n`identify()` accepts one object: `Record<string, string>`. Values are stringified, truncated, and attached to the current session. Keep IDs opaque. A database primary key or UUID is fine. An email address, display name, or phone number is not.\n\n## The string shorthand\n\nWhen all you want is to tag the session with your own user reference, pass a single string:\n\n```ts\nidentify('usr_8f3a2c91')\n```\n\nThis sets the session's `user_ref` and merges with any properties set earlier, it never clobbers them. The ref must be opaque: values that look like contact information (anything containing an `@`, or a long digit run like a phone or card number) are refused in the browser and never sent.\n\nThe `user_ref` is what powers the resolution loop: when Flusterduck verifies that a fix worked after a deploy, the `issue.verified` webhook delivers the refs of exactly the users who hit that issue, so your own systems can tell them it's fixed. See [Resolution loop](/resolution-loop).\n\n## What properties to pass\n\nPass things you'd want to filter by when investigating a friction pattern:\n\n```ts\nidentify({\n user_id: 'usr_8f3a2c91',\n plan: 'scale',\n billing: 'annual',\n org_size: '11-50',\n role: 'admin',\n account_age_days: '42',\n experiment_variant: 'checkout-v2',\n feature_flag_new_nav: 'true',\n})\n```\n\nNever pass: email, name, username, IP address, display handle, or any field that links directly to a real person.\n\n## When to call it\n\nCall `identify()` as early as possible after login and after the SDK has initialized. Properties apply to the current session metadata used for future flushes.\n\nIn practice: put it in a component or hook that runs immediately after your auth state resolves.\n\n### React\n\n```tsx\nimport { useEffect } from 'react'\nimport { identify } from 'flusterduck'\n\nexport function IdentityBridge({ userId, plan, orgId }: {\n userId?: string\n plan?: string\n orgId?: string\n}) {\n useEffect(() => {\n if (!userId) return\n identify({\n user_id: userId,\n plan: plan ?? 'trial',\n org_id: orgId ?? '',\n })\n }, [userId, plan, orgId])\n\n return null\n}\n```\n\nRender this inside your auth provider where `userId` is reliably available.\n\n### Next.js\n\n```tsx\n'use client'\nimport { useEffect } from 'react'\nimport { identify } from 'flusterduck'\n\nexport function IdentifyUser({ userId, plan }: { userId?: string; plan?: string }) {\n useEffect(() => {\n if (!userId) return\n identify({ user_id: userId, plan: plan ?? 'trial' })\n }, [userId, plan])\n\n return null\n}\n```\n\n### Vue\n\n```vue\n<script setup lang=\"ts\">\nimport { watch } from 'vue'\nimport { identify } from 'flusterduck'\n\nconst props = defineProps<{ userId?: string; plan?: string }>()\n\nwatch(\n () => props.userId,\n (userId) => {\n if (!userId) return\n identify({ user_id: userId, plan: props.plan ?? 'trial' })\n },\n { immediate: true },\n)\n</script>\n```\n\n## The segment config option\n\nFor properties that apply to every session regardless of who's logged in, use `segment` in your SDK config instead of `identify()`:\n\n```ts\ninit({\n key: process.env.NEXT_PUBLIC_FLUSTERDUCK_KEY!,\n segment: {\n app_version: process.env.NEXT_PUBLIC_APP_VERSION ?? 'unknown',\n region: 'us-east-1',\n experiment_group: 'pricing-v2-b',\n },\n})\n```\n\n`segment` properties are attached from the first signal in the session. `identify()` properties are attached after the call.\n\nUse `segment` for: deploy tags, region, A/B test cohorts, feature flag variants at the session level.\n\nUse `identify()` for: plan tier, organization, user role, account age, anything tied to a specific authenticated user.\n\nIf you pass the same key in both `segment` and `identify()`, the `identify()` value wins after the call.\n\n## Using segments in investigations\n\nWith properties in place, you can filter friction data by segment values. Via MCP:\n\n```\nAre rage clicks on the checkout button worse for Grow plan users than Scale?\nWhich sessions hitting form abandonment on onboarding are enterprise accounts?\nCompare confusion scores between experiment_group: pricing-v2-a and pricing-v2-b.\nShow me sessions where role: admin hit the dead click on the settings page.\n```\n\n## Multiple identify() calls\n\nCalling `identify()` multiple times in the same session merges properties.\n\n```ts\n// On login\nidentify({ user_id: 'usr_8f3a2c91', plan: 'scale' })\n\n// After loading org data\nidentify({ org_id: 'org_4b2e9f1c', org_size: '11-50' })\n```\n\nThe session ends up with all four properties. Use this pattern when you load user data in stages.\n"
|
|
455
|
+
},
|
|
456
|
+
{
|
|
457
|
+
"slug": "resolution-loop",
|
|
458
|
+
"title": "Resolution loop",
|
|
459
|
+
"description": "Tell exactly the users who hit an issue once the fix is verified, without Flusterduck ever knowing who they are.",
|
|
460
|
+
"group": "SDK & frameworks",
|
|
461
|
+
"content": '# Resolution Loop\n\nClose the loop with the exact users who hit an issue, without Flusterduck ever knowing who they are.\n\nThe loop has three steps:\n\n1. **Tag sessions** with your own opaque user reference via the SDK: `identify(\'usr_8f3a2c91\')`. The ref means something in your database and nothing in ours.\n2. **Fix the issue.** When a deploy ships and Flusterduck\'s verification measures confusion actually dropping on that page, the issue flips to verified. Not "the PR merged": measured, on real traffic.\n3. **Tell exactly the affected users.** The `issue.verified` webhook fires with the list of user refs whose sessions hit the issue. Your server maps refs back to real users and sends the message from your own email stack: "That checkout bug you ran into on Tuesday? Fixed, and we checked."\n\nAlmost nobody does this, because almost nobody knows which users hit which issue. Support teams apologize to whoever writes in; everyone else just remembers your product being broken. The resolution loop turns a silent fix into a retention moment.\n\n## Why there\'s no email field\n\nFlusterduck stores behavioral signals, never identities. That doesn\'t change here:\n\n- Refs are opaque strings you choose. A primary key or UUID, never an email or name.\n- The SDK refuses PII-shaped refs in the browser (anything containing `@`, or a long digit run) before they\'re sent.\n- The API drops PII-shaped values on the way out too, as a second gate, so even a mistakenly stored ref can\'t leave.\n- The mapping from ref to person lives only in your systems, and any sending happens from your email tools.\n\nYou get the precision of session-level identity with none of the data liability. Your privacy policy doesn\'t grow a new paragraph because of us.\n\n## Setting it up\n\n**1. Tag sessions after login:**\n\n```ts\nimport { identify } from \'flusterduck\'\n\n// after your auth state resolves\nidentify(currentUser.id) // opaque ref, sets user_ref\n```\n\nThe shorthand merges with any segment properties you already pass; see [Identifying sessions](/identify).\n\n**2. Subscribe a webhook to `issue.verified`** under Settings > Webhooks. The payload:\n\n```json\n{\n "event": "issue.verified",\n "data": {\n "issue_id": "iss_3a7f2c9d4e1b",\n "title": "Continue button looks enabled but isn\'t",\n "page": "/checkout",\n "confusion_before": 61.4,\n "confusion_after": 18.2,\n "reduction_pct": 70,\n "revenue_recovered_cents": 84000,\n "affected_user_refs": ["usr_8f3a2c91", "usr_1d4e7b22"],\n "identified_sessions": 214,\n "unidentified_sessions": 96,\n "refs_truncated": false\n }\n}\n```\n\n`affected_user_refs` is deduped and capped at 1,000; `refs_truncated` tells you when a very high-volume issue exceeded the scan caps. `unidentified_sessions` counts sessions that hit the issue before you started identifying (or from logged-out users): those users exist, you just can\'t message them, which is itself a good argument for calling `identify()` early.\n\n**3. Send from your side.** A minimal handler:\n\n```ts\napp.post(\'/webhooks/flusterduck\', verifySignature, async (req, res) => {\n const { event, data } = req.body\n if (event === \'issue.verified\' && data.affected_user_refs.length) {\n const users = await db.users.findMany({ where: { id: { in: data.affected_user_refs } } })\n await email.sendBatch(users.map((u) => fixedItNote(u, data)))\n }\n res.sendStatus(200)\n})\n```\n\nDeliveries are HMAC-signed and retried like every other Flusterduck webhook; see [Webhooks](/webhooks) for signature verification.\n\n## Reading the list in the dashboard\n\nEach issue\'s detail page shows an Affected users card once identified sessions exist: how many sessions were identified vs anonymous, with the refs one click away. Useful for a quick "who do I owe an apology to" even before the webhook plumbing exists.\n\n## Good taste with the loop\n\n- Send the note only when the fix is meaningful to the user. A rage-clicked broken Upgrade button deserves a message; a mild layout quirk doesn\'t.\n- Include the measurement if you want the message to land: "confusion on that page dropped 70% after the fix" reads better than "should be fixed now."\n- Don\'t re-identify users across logout on shared devices; call `identify()` per authenticated session.\n'
|
|
455
462
|
},
|
|
456
463
|
{
|
|
457
464
|
"slug": "build-plugins",
|
|
@@ -465,14 +472,14 @@ Session limits are pooled across all sites in the org, not per-site. All plans i
|
|
|
465
472
|
"title": "Detectors, signals & friction types",
|
|
466
473
|
"description": "The three words that come up everywhere, explained in plain English: detectors watch, signals report, friction types organize.",
|
|
467
474
|
"group": "Product",
|
|
468
|
-
"content": "# Detectors, signals, and friction types\n\nThree words come up everywhere in Flusterduck
|
|
475
|
+
"content": "# Detectors, signals, and friction types\n\nThree words come up everywhere in Flusterduck (detectors, signals, and friction types) and they're easy to mix up. Here's the difference in plain English, with one example carried all the way through.\n\n## The one-sentence version\n\n**Detectors watch. Signals report. Friction types organize.** Repeated signals then cluster into **issues**: the ranked list you actually work from.\n\n## Detectors: the watchers\n\nA detector is a small piece of code inside the SDK that watches for one specific pattern of struggle. Nothing more.\n\nThe rage-click detector watches for someone clicking the same spot again and again in frustration. The dead-click detector watches for taps on things that look clickable but aren't. The ghost-toggle detector watches for switches that get clicked but never change. About a hundred of these watchers ship in the SDK, and every one of them runs automatically from the single script tag. There is nothing to configure, instrument, or turn on.\n\nThink of detectors as a hundred quiet observers, each trained to notice exactly one way a person can get stuck.\n\n## Signals: the reports\n\nA signal is what a detector sends the moment it sees its pattern: one timestamped observation from one real person's session:\n\n> Rage click on **\"Apply coupon\"** \xB7 `/checkout` \xB7 2:51 PM\n\nThat's a single signal: which pattern fired, on which element (its visible label, PII-redacted in the browser before anything is sent), on which page, at what moment, with a confidence score for how certain the detector is. Signals are the raw sightings: plentiful, small, and individually not very dramatic. One person rage-clicking once might mean nothing. The value comes from what happens next.\n\n## Friction types: the filing system\n\nA friction type is the named category a signal belongs to: the label Flusterduck's scoring, dashboard, and AI use to group, weight, and rank what the detectors saw. `rage_click`, `dead_click`, `form_abandonment`, `no_op_interaction`: there are 116 of them, each with a weight reflecting how strongly it predicts a lost conversion (a dead-end checkout submit weighs far more than a stray scroll bounce).\n\nFriction types are why the dashboard can say something useful instead of showing you a firehose: every sighting is filed, weighted, and comparable.\n\n## Why are there more detectors than friction types?\n\nBecause several watchers can file reports under one category. Different detectors catch mobile taps that miss their target, thumb-zone misses, and near-miss clicks from different angles, and their reports can land in the same friction type. The detector count grows every time we learn a new way users get stuck (it's an iterative process: every real-world bug we see becomes a detector); the friction-type list grows more slowly, only when a genuinely new *category* of struggle shows up.\n\n## From signals to issues\n\nSignals don't reach your issue list one at a time. When the same friction type keeps firing on the same page or element across multiple sessions, Flusterduck clusters those signals into a single **issue**, with a plain-language title, a severity bounded by how many users it actually reached, a confidence grade, a revenue estimate, and (on paid plans) an AI diagnosis grounded in your page's actual content. That ranked issue list is the product; detectors, signals, and friction types are the machinery underneath it.\n\nRead more: [Signals reference](/signals) \xB7 [Issues](/issues) \xB7 [Scoring](/scoring)\n\n## FAQ\n\n**Do I need to configure any of this?** No. All detectors run automatically from one script tag. You can turn individual detectors off in the SDK config if you ever need to, but the default is everything on.\n\n**Does more detectors mean more data sent from my site?** Barely: signals are tiny structured events (a name, a selector, a redacted label, timestamps), not recordings. There is no session replay and no page content captured from your visitors' sessions.\n\n**Can one user action fire multiple detectors?** Yes, and that's useful: a broken button might draw rage clicks *and* no-op interactions *and* an error signal. Co-occurring signals on the same element are strong evidence they share one root cause, and the clustering treats them that way.\n"
|
|
469
476
|
},
|
|
470
477
|
{
|
|
471
478
|
"slug": "signals",
|
|
472
479
|
"title": "Signals",
|
|
473
480
|
"description": "The raw evidence of user confusion. Every friction behavior the SDK emits as a signal.",
|
|
474
481
|
"group": "Product",
|
|
475
|
-
"content": "# Signals\n\nSignals are the raw evidence of user confusion. Every time a user does something that indicates friction, the SDK emits a signal. The scoring engine weighs signals by type, frequency, and recency to compute page confusion scores and cluster repeated patterns into issues.\n\nThere are 100 built-in signal types across eight tiers.\n\n## Tier 1: Direct friction\n\nHigh-confidence indicators. A single occurrence is meaningful -- you don't need volume to act. These move your page score the most and surface issues fastest.\n\nIf you see tier 1 signals on a critical element (a checkout button, a signup form, an upgrade CTA), investigate before waiting for a cluster.\n\n| Signal | What it means |\n|---|---|\n| `rage_click` | User clicked the same element 3 or more times rapidly. Usually means the element appears clickable but isn't responding as expected. |\n| `dead_click` | User clicked a non-interactive element. Often a label, image, or decorative element the user thought was a button. |\n| `layout_shift_rage` | A rage click followed by a significant layout shift. The interface moved under the user's cursor. |\n| `active_funnel_rejection` | User initiated a multi-step flow and exited before the first meaningful step. Not a casual bounce -- they started, then left. |\n| `dead_end_submit` | User submitted a form and received no visible feedback. No success state, no error message, nothing. |\n| `accidental_click_bounce` | User clicked an element and immediately hit the back button. Likely an accidental tap or misclick. |\n| `error_recovery_loop` | User encountered an error and tried the same action multiple times in quick succession without changing their input. |\n| `silent_failure_retry` | User repeated an action that appeared to succeed but produced no visible result. Common with async operations that fail silently. |\n| `form_abandonment` | User focused one or more form fields and left without submitting. Threshold: at least one meaningful field interaction. |\n| `error_encounter` | An unhandled JavaScript error, rejected promise, or failed network request occurred during the session. Categorized as network, script, or unhandled_promise. |\n| `form_validation_loop` | User submitted a form, hit a validation error, edited the flagged field, and got the same error again. Three or more cycles on a single field within two minutes fires the signal. |\n| `invisible_required_field` | User submitted a form and validation failed on a required field they can't see. The field is offscreen, inside a closed `<details>`, behind an inactive tab, or hidden by a parent with `display:none`. |\n| `ghost_toggle` | User clicked a toggle, checkbox, or switch and the state either never changed (swallowed click) or changed then silently reverted within 2 seconds. Requires 2+ ghost events on the same control within 30 seconds. |\n| `premature_commit_reversal` | User clicked a primary CTA (submit, buy, confirm, add to cart) and reversed the action within 2 seconds via browser back, Ctrl+Z, Escape, or clicking a cancel element. The speed of the reversal indicates regret or accidental commitment. |\n| `cls_interaction_corruption` | A layout shift moved the user's click target between mousedown and mouseup. They pressed on the right element, but the page yanked it away and the click landed on something else. |\n| `paste_blocked` | User pasted into an input field but the value didn't change. The site is blocking paste, usually on password confirmation or email fields. |\n| `autocomplete_fight` | Browser autofill populated multiple form fields and site JavaScript immediately cleared or shortened the values. Common on checkout, login, and address forms where custom input masking fights the browser. |\n\n## Tier 2: Navigation and flow friction\n\nPatterns that emerge across a session. Each single occurrence is moderate-confidence. Clusters are high-confidence.\n\nIf a tier 2 signal appears 20+ times on the same page, or you're seeing 5+ different tier 2 types on the same page simultaneously, treat it like tier 1.\n\n| Signal | What it means |\n|---|---|\n| `u_turn_navigation` | User went forward in a flow, then immediately went back. Repeated occurrences suggest unclear copy or a missing expectation-setter. |\n| `information_maze` | User visited the same page or section multiple times without completing anything. They're looking for something they can't find. |\n| `load_abandonment` | User waited for a page to load and left before it finished. Indicates a performance problem on a high-friction path. |\n| `multi_step_reset` | User restarted a multi-step form or wizard from the beginning. Could be data entry confusion or unclear field validation. |\n| `query_thrashing` | User made multiple search or filter queries in rapid succession. They're not finding what they're looking for. |\n| `filter_spiral` | User applied and removed filters repeatedly without settling. Likely an expectation mismatch between filters and results. |\n| `post_action_anxiety` | User completed an action but immediately checked for confirmation or left the page. Suggests missing confirmation feedback. |\n| `settings_hop` | User visited settings or preferences multiple times in a session without finding what they needed. |\n| `copy_paste_rework` | User selected text and then immediately re-typed it. Often indicates a pre-fill or autocomplete that didn't work. |\n| `repeat_form_input` | User cleared and re-entered the same field more than once. Usually a validation message that's unclear or delayed. |\n| `decision_paralysis` | User's cursor oscillated between two or more interactive elements (pricing tiers, plan selectors, CTAs) with 800ms+ dwell on each, repeating the back-and-forth at least twice within 12 seconds. They can't decide. |\n| `friction_cascade` | A tier 1 signal fired and was followed by 2+ distinct tier 2 or tier 3 signal types within 90 seconds on the same page. The tier 1 signal is the root cause; the followers are symptoms. |\n| `keyboard_trap` | Focus can't escape a container. The user pressed Tab 6+ consecutive times and focus stayed within the same parent region without the user clicking elsewhere. |\n| `modal_stack` | Two or more modal, dialog, or overlay elements are visible at the same time. Stacked modals confuse users: dismiss buttons close the wrong layer, keyboard focus traps conflict, and users lose track of which layer they're interacting with. |\n| `dead_click_trap_zone` | Three or more dead clicks landed in the same viewport grid zone (the screen is divided into a 4x4 grid) within 90 seconds. A hot spot of non-interactive content that users keep mistaking for a button. |\n| `navigation_confusion` | User visited 5+ distinct pages within 30 seconds, spending less than 5 seconds on each. They're lost and clicking through pages trying to find something. |\n| `scroll_to_click_confusion` | User scrolled down, reversed direction by at least 30% of the viewport, and clicked an element within 3 seconds of the reversal. They overshot something they wanted and had to scroll back to find it. |\n| `tab_thrash` | User pressed Tab 10+ times within 5 seconds. Rapid tabbing with direction changes signals they're hunting for a focusable element they can't reach. |\n| `focus_trap` | User pressed Tab or Escape 5+ times within 3 seconds inside a dialog, menu, or modal container without escaping it. The trap isn't releasing focus when the user expects it to. |\n| `keyboard_nav_frustration` | User pressed arrow keys 15+ times within 5 seconds inside a listbox, menu, or tree widget. They're scrolling through options without finding what they want. |\n\n## Tier 3: Passive confusion\n\nLower individual weight, but meaningful in aggregate. These often surface chronic friction that lacks the dramatic edge of rage clicks -- the kind of friction users tolerate rather than abandon over.\n\nA page with dense tier 3 signals and no tier 1 or 2 signals is worth investigating. It usually means users are managing confusion rather than hitting a wall.\n\n| Signal | What it means |\n|---|---|\n| `confidence_collapse` | User paused on a form field for an unusually long time without typing. Hesitation before a field that's unclear. |\n| `target_near_miss` | User's tap or click landed very close to an interactive element but missed it. Common on mobile with small touch targets. |\n| `user_confusion_idle` | User stopped interacting entirely for longer than expected. Could be reading carefully, or could be stuck. |\n| `thrash_hover` | User rapidly moved the cursor back and forth over an element without clicking. Indecision or unclear affordance. |\n| `text_selection_thrash` | User selected text multiple times rapidly. May be trying to copy something that's being blocked. |\n| `jerky_scrolling` | User's scroll pattern was erratic: fast then stopped, or reversed frequently. Usually indicates a busy page layout. |\n| `thrash_cursor` | Rapid cursor movement across the page with no clear direction. A general confusion indicator. |\n| `viewport_thrashing` | User zoomed in and out multiple times. On mobile, this usually means text is too small or layout is not responsive. |\n| `visibility_thrashing` | User repeatedly scrolled elements in and out of view, possibly trying to compare content across sections. |\n| `passive_drift` | User stayed on a page longer than average but generated very few events. Could be reading carefully or completely lost. |\n| `help_hunt` | User opened FAQ, support, documentation, or help pages during an active conversion flow. |\n| `trust_hesitation` | User focused a sensitive field (password, email, credit card) and then clicked through to a privacy, terms, security, or about page before typing more than 2 characters. They didn't trust the form enough to fill it in. |\n| `tooltip_dependency` | User hovered over the same tooltip trigger 3+ times within 5 minutes, spending 500ms+ on each hover. The label is unclear and they keep re-reading the explanation. |\n| `phantom_progress` | A progress indicator (stepper, progress bar, step counter) is visible but the user has been stuck on the same step for 60+ seconds while actively clicking and typing. The wizard isn't advancing. |\n| `scroll_to_nowhere` | User scrolled to the bottom of the page and immediately scrolled back up within 3 seconds. They expected more content below, or couldn't find what they were looking for. |\n| `notification_fatigue` | Browser notification permission prompt was denied within 800ms. A sub-second denial means the prompt was unwanted or the site asked for permissions before establishing trust. |\n| `video_autoplay_escape` | A video or audio element autoplayed and the user immediately paused it, muted it, or scrolled past it within 3 seconds. Autoplaying media is annoying enough that they took evasive action. |\n| `scroll_hijack` | User's scroll reversed direction 4+ times within 3 seconds with large-stroke reversals. The page is overriding native scroll behavior (smooth-scroll libraries, scroll-snap fights, parallax effects). |\n| `input_correction` | User deleted 3+ characters and retyped a field at least twice within 60 seconds. They keep correcting their input, likely because a validation rule or format requirement is unclear. |\n| `hover_dwell` | User hovered over an interactive element for 2+ seconds without clicking. They're reading it, evaluating it, or unsure what it does. Cooldown of 30 seconds per element prevents noise. |\n| `exit_intent` | User's cursor left the browser viewport toward the top of the screen (toward the tab bar or address bar). Classic indicator they're about to close the tab or leave. Fires after 3+ seconds on page, once per 60 seconds. |\n| `copy_frustration` | User triggered the copy action 3+ times within 15 seconds. Repeated copy attempts suggest the selection isn't working as expected or copy is being blocked. |\n| `scroll_depth_abandon` | Tracks the deepest scroll point reached before the user leaves the page. Fires on page hide or visibility change with the max scroll depth as a percentage. Useful for identifying where users give up on long pages. |\n\n## Mobile and touch friction\n\nGesture-level friction that mostly shows up on touch devices, though `false_swipe` also fires for mouse and pen drags.\n\n| Signal | What it means |\n|---|---|\n| `false_swipe` | User performed a clear horizontal swipe or drag over an element that looks swipeable -- a carousel, slider, scroll-snap rail, or anything with draggable/grab styling -- but the element and its scrollable ancestors did not move and no page transition occurred. A false affordance: it looked swipeable, the user tried, and nothing responded. |\n| `swipe_miss` | User swiped horizontally on an element that isn't swipeable, but the page has swipeable elements elsewhere (a carousel or slider exists). They swiped in the wrong spot. |\n| `orientation_thrash` | User rotated their device 3+ times within 30 seconds. They're trying to find an orientation where the content is readable or the layout makes sense. |\n\n## Revenue and close behavior\n\nSignals that correlate directly with lost conversions. These feed the revenue impact estimate.\n\n| Signal | What it means |\n|---|---|\n| `close_click_reversal` | User moved toward closing, dismissing, or canceling something, then stopped and stayed. They were about to leave but didn't. Use this to find moments where users reconsider. |\n| `value_collapse_downgrade` | User selected a higher plan or tier, then downgraded before completing purchase. Usually indicates a pricing page clarity problem. |\n| `layout_exhaustion_settling` | User scrolled through the full page, returned to the top, and selected an option. They were overwhelmed and had to start over to decide. |\n\n## Opt-in interaction signals\n\nThree newer detectors target obstruction and blocked-interaction friction. They are **off by default** while we calibrate their thresholds on real traffic, so enable the ones you want explicitly:\n\n```ts\nimport { init } from 'flusterduck'\n\ninit({\n key: 'fd_pub_...',\n signals: {\n disabledElementAttempt: { enabled: true },\n overlayDismissStruggle: { enabled: true },\n stickyObstructionClick: { enabled: true },\n },\n})\n```\n\n| Signal | What it means |\n|---|---|\n| `disabled_element_attempt` | User clicked a disabled (or disabled-looking) control more than once with no response. Either the disabled state is wrong, or there's no explanation of why the control is unavailable. |\n| `overlay_dismiss_struggle` | User pressed Escape and jabbed at the backdrop or close affordance repeatedly while a modal, dialog, or popup stayed open. The dismiss controls aren't working or aren't where users expect. |\n| `sticky_obstruction_click` | User's click landed on a sticky or fixed element (a header, floating bar, or cookie banner) that's covering the interactive target directly beneath it. |\n\nEvery fire carries a `cf` confidence value (0-1); the scoring engine weighs each fire by it, so a borderline detection counts for less than an unambiguous one.\n\n## Performance\n\nSignals tied to page speed and responsiveness. These correlate with user frustration but originate from browser performance metrics rather than user behavior.\n\n| Signal | What it means |\n|---|---|\n| `long_task_interaction_block` | User clicked, tapped, or pressed a key during or immediately after a long task (50ms+ main thread block). The browser's event loop was frozen and their input was delayed. Payload includes the task duration and estimated delay. |\n| `loading_interaction` | User clicked an element that was in a loading state (marked with `aria-busy`, disabled, or a spinner CSS class) and clicked it again before the loading finished. They're waiting and losing patience. |\n| `lcp` | Largest Contentful Paint measurement for the page. Rated good (under 2500ms), needs improvement (2500-4000ms), or poor (over 4000ms). |\n| `inp` | Interaction to Next Paint measurement. Rated good (under 200ms), needs improvement (200-500ms), or poor (over 500ms). Captures how responsive the page feels after it loads. |\n| `cls` | Cumulative Layout Shift score. Rated good (under 0.1), needs improvement (0.1-0.25), or poor (over 0.25). Measures how much the page jumps around during loading. |\n\n## Aggregate\n\nSession-level and cross-signal patterns. These don't detect a single friction moment. They summarize friction across an entire session or correlate multiple signals into a higher-level finding.\n\n| Signal | What it means |\n|---|---|\n| `session_toxicity_score` | A running 0-100 score reflecting cumulative friction across the session. Computed from the weighted sum of all signals, the number of distinct pages with friction, and the number of distinct signal types observed. Levels: calm (0-20), building (21-40), frustrated (41-60), toxic (61-80), meltdown (81-100). Emitted every 60 seconds and on page unload. |\n| `frustration_burst` | Multiple friction signals fired within a 60-second window and the weighted total crossed a severity threshold. Levels: low, medium, high, critical. Includes the top contributing signal types and detected behavioral sequences (like rage click followed by form validation loop). |\n| `element_impression` | Tracks when a watched element (marked with `[data-fd-impression]` or a custom selector) enters the viewport. Records first-seen time and visibility percentage. Not a friction signal, but useful for measuring whether users actually see a specific element before abandoning. |\n\n## UX anti-patterns\n\nSignals that detect specific anti-patterns in page design. These are distinct from general friction indicators because they point at a concrete design problem, not just user confusion.\n\n| Signal | What it means |\n|---|---|\n| `viewport_dead_zone` | User clicked an interactive element in the bottom 10% of the viewport, but a fixed or sticky overlay (cookie banner, floating bar, sticky header, chat widget) intercepted the click. The intended target never received it. |\n| `input_format_roulette` | User cleared and retyped a formatted field (phone, date, credit card, zip code) 3+ times with different formats within 60 seconds. The expected format is ambiguous and the field isn't telling them what it wants. |\n| `undo_panic` | User clicked a destructive action (delete, remove, archive, send, publish) and panicked within 3 seconds: hit Ctrl+Z, thrashed the cursor, or went back. Tracks whether an undo UI (toast, snackbar) appeared in response. |\n| `scroll_hijack_rage` | Programmatic scroll override moved the viewport and the user fought back with 3+ rapid scroll inputs in the opposite direction within 2 seconds. Distinct from `scroll_hijack` (reversal bursts): this fires when the user is visibly resisting the override. |\n| `text_select_frustration` | User tried to select text 3+ times rapidly on the same element. Often indicates copy protection, CSS `user-select: none`, or an element that swallows selection events. |\n\n## Conversion friction\n\nSignals specific to pricing, checkout, and purchase flows. These fire where money is on the line, so even low-volume occurrences are worth investigating.\n\n| Signal | What it means |\n|---|---|\n| `pricing_comparison_stall` | User's cursor or focus oscillated between pricing tiers for 15+ seconds without selecting one. They're stuck comparing and can't tell which plan fits. |\n| `checkout_field_retreat` | User filled in a checkout field, moved to the next field, then went back and cleared or edited the previous one. Repeated retreats on the same field indicate confusing labels or unexpected validation. |\n| `coupon_code_frustration` | User attempted to apply a coupon or promo code 3+ times within 60 seconds. The code isn't working and there's no clear explanation why. |\n| `price_shock_abandon` | User reached a payment or order summary screen showing a total and left within 5 seconds without interacting. The final price surprised them. |\n| `payment_method_thrash` | User switched between payment methods (credit card, PayPal, Apple Pay, etc.) 3+ times without completing payment. Either a method is failing silently or the user can't find the one they want. |\n| `billing_shipping_confusion` | User copied or re-typed values between billing and shipping address fields, or toggled the \"same as billing\" checkbox 2+ times. The relationship between the two forms is unclear. |\n| `plan_comparison_scroll` | User scrolled a pricing comparison table or feature matrix back and forth horizontally 4+ times within 30 seconds. The table is too wide or the differentiators aren't visible without scrolling. |\n| `pricing_toggle_regret` | User toggled between monthly and annual billing, selected a plan, then toggled back and selected again. They second-guessed the billing interval after committing. |\n| `faq_bounce` | User opened an FAQ or help section during a purchase flow, read it, then left the flow entirely instead of returning to checkout. The FAQ didn't resolve their concern. |\n| `setup_step_abandon` | User completed at least one step of a post-purchase setup wizard and left before finishing. They paid but didn't activate the product. |\n| `integration_retry_failure` | User attempted to connect a third-party integration (OAuth flow, API key entry, webhook test) and the connection failed 2+ times within 5 minutes. Broken integrations during onboarding kill activation. |\n| `first_value_delay` | More than 60 seconds elapsed between account creation and the user's first meaningful action (creating a project, uploading a file, running a query). They signed up but couldn't figure out what to do next. |\n\n## Retention friction\n\nSignals tied to settings, cancellation, and feature adoption. These surface churn risk before it shows up in your billing dashboard.\n\n| Signal | What it means |\n|---|---|\n| `settings_churn` | User visited account, billing, or subscription settings 3+ times within 7 days without making a change. They're thinking about downgrading or canceling but haven't pulled the trigger. |\n| `cancellation_flow_confusion` | User entered a cancellation or downgrade flow and clicked back, restarted, or spent 30+ seconds on a single step. The flow is unclear or the retention offer is confusing them further. |\n| `downgrade_hesitation` | User selected a lower-tier plan, reached the confirmation step, and abandoned without confirming. They want to downgrade but something on the confirmation screen stopped them. |\n| `feature_discovery_failure` | User searched for a term, navigated to 3+ pages, or opened help docs looking for a feature that exists but that they can't find. The feature is buried or named in a way that doesn't match what users expect. |\n| `reactivation_attempt` | A user who previously canceled or let their subscription lapse returned and attempted to resubscribe or re-enable their account. Tracks whether the reactivation succeeded or hit an error. |\n\n## Content friction\n\nSignals that fire when users struggle to read, understand, or consume content on the page.\n\n| Signal | What it means |\n|---|---|\n| `reading_abandonment` | User scrolled past 30% of an article or long-form page, stopped scrolling for 10+ seconds, then left. They started reading and gave up mid-content. |\n| `copy_failure` | User attempted to copy text (Ctrl+C, right-click > Copy, or long-press on mobile) and the clipboard didn't receive the expected content. Copy protection, JavaScript interception, or CSS `user-select: none` blocked them. |\n| `content_overload_scroll` | User scrolled a content-heavy page at high velocity (covering 50%+ of the viewport per second) for 3+ seconds without stopping. They're skimming past content that's too dense to read. |\n| `jargon_hover_loop` | User hovered over the same tooltip, abbreviation tag, or glossary term 3+ times within 2 minutes. The term is unclear and the explanation isn't sticking. |\n| `pagination_thrash` | User clicked forward and backward through paginated content 4+ times within 60 seconds. They're hunting for something specific and the pagination doesn't help them find it. |\n\n## Error handling friction\n\nSignals that surface when error states, permissions, or validation timing confuse users.\n\n| Signal | What it means |\n|---|---|\n| `error_blindness` | An error message appeared on the page but the user continued interacting without acknowledging it for 10+ seconds. They didn't see the error, or the error wasn't visually prominent enough to interrupt their flow. |\n| `silent_logout_surprise` | User attempted an authenticated action and was redirected to a login page without warning. Their session expired silently and the redirect broke their workflow. |\n| `validation_timing_mismatch` | A form field showed a validation error while the user was still actively typing in it. The validation fired before the user finished their input, creating a false error that disappears on its own. |\n| `error_recovery_abandon` | User encountered an error, attempted 1-2 recovery actions (refresh, re-submit, navigate back), then left the page entirely. The error was unrecoverable or the recovery path was unclear. |\n| `permission_denial_break` | User attempted an action that requires elevated permissions (admin, owner, paid tier) and received a denial. They either didn't know the restriction existed or expected to have access. |\n\n## Mobile and touch friction (expanded)\n\nAdditional gesture-level and device-specific friction signals. These complement the existing `false_swipe`, `swipe_miss`, and `orientation_thrash` signals in the base mobile tier.\n\n| Signal | What it means |\n|---|---|\n| `gesture_mismatch` | User performed a recognized gesture (pinch, rotate, two-finger scroll) on an element that doesn't support that gesture type. The element's visual affordance suggested a gesture vocabulary it doesn't actually handle. |\n| `orientation_thrash` | User rotated their device and the layout broke or reflowed in a way that caused immediate scroll correction or a rage tap. Distinct from the base `orientation_thrash` signal: this variant fires only when the rotation triggers a follow-up frustration signal within 3 seconds. |\n| `thumb_zone_miss` | User tapped a target in the top 20% of the screen (outside the natural thumb zone) and missed on the first attempt. They had to stretch or shift grip to reach it. Fires only when a second tap on the same target succeeds within 2 seconds. |\n| `mobile_keyboard_dismiss` | User tapped outside an input to dismiss the on-screen keyboard and accidentally triggered an action on the element behind it. The keyboard dismissed and a button or link underneath received the tap. |\n| `tap_target_adjacency` | User tapped between two adjacent interactive elements (less than 8px gap) and hit the wrong one, then immediately tapped the other. The targets are too close together. |\n| `responsive_layout_break` | A media query or container query breakpoint triggered and caused content to overflow its container, text to truncate mid-word, or interactive elements to overlap. Detected by comparing element bounds before and after a resize or orientation change. |\n\n## Performance friction (expanded)\n\nAdditional performance signals beyond the base `long_task_interaction_block`, `loading_interaction`, `lcp`, `inp`, and `cls` metrics.\n\n| Signal | What it means |\n|---|---|\n| `font_swap_interaction` | A web font loaded and swapped while the user was actively reading or interacting with text. The FOUT (flash of unstyled text) caused a visible text reflow during an active session, not just on initial page load. |\n| `third_party_script_block` | A third-party script (analytics, chat widget, ad tag) blocked the main thread for 100ms+ during user interaction. The delay is attributable to an external domain, not the app's own code. |\n| `memory_pressure_jank` | The browser signaled memory pressure (via the `Device Memory API` or performance observer long-task clustering) and the user experienced visible jank: dropped frames during scroll, animation stutter, or input delay exceeding 500ms. |\n| `image_decode_delay` | A large image decoded on the main thread and blocked interaction for 100ms+. Common with unoptimized PNGs, large JPEGs without progressive encoding, or images decoded synchronously during scroll. |\n\n## Accessibility friction\n\nSignals that detect friction specific to assistive technology users and accessibility-related interaction patterns.\n\n| Signal | What it means |\n|---|---|\n| `focus_order_confusion` | User pressed Tab and focus moved to an element that's visually distant from the previously focused element. The DOM order and visual order disagree, causing a disorienting jump. Fires when the focus target is more than 500px from the previous focus target. |\n| `screen_reader_dead_end` | User moving through the page via `aria-` landmarks or heading shortcuts (`H` key in screen readers) reached a point with no further landmarks or headings, then reversed direction. The page structure has a dead end that forces backtracking. |\n| `motion_sensitivity_trigger` | An animation or transition ran while the user has `prefers-reduced-motion: reduce` set. The site ignored the user's motion preference. |\n| `contrast_interaction_failure` | User hovered or focused an interactive element and the resulting state change (hover color, focus outline) didn't meet WCAG AA contrast ratio against its background. The interactive feedback is invisible to users with low vision. |\n\n## Custom signals\n\nEmit your own signals with `signal()`:\n\n```ts\nimport { signal } from 'flusterduck'\n\n// A CTA that looks active but is gated by a required previous step\nsignal('dead_click', {\n selector: '[data-action=\"publish\"]',\n label: 'Publish',\n blocked_by: 'missing_title',\n})\n\n// A custom multi-step flow step abandonment\nsignal('form_abandonment', {\n form: 'workspace-setup',\n step: 'invite-members',\n fields_touched: 0,\n})\n```\n\nThe first argument is the signal type -- any of the 100 built-in types or a custom string. The second is optional metadata. Never include PII, form values, or anything a user typed.\n\nCustom signal types appear in the dashboard and scoring engine alongside built-in types. They don't get tier weights assigned automatically -- the scoring engine treats them as tier 2 by default until enough data accumulates for it to assess their actual impact.\n\n## Business events\n\nWire conversion outcomes with `track()` for revenue impact estimation:\n\n```ts\nimport { track } from 'flusterduck'\n\ntrack('plan_intent', {\n plan_id: 'scale',\n amount_cents: 9900,\n billing: 'monthly',\n})\n\ntrack('subscription_started', {\n plan_id: 'scale',\n amount_cents: 9900,\n billing: 'monthly',\n})\n\ntrack('checkout_abandoned', {\n plan_id: 'grow',\n amount_cents: 3900,\n})\n```\n\nSafe properties: `plan_id`, `amount_cents`, `billing`, `currency`, `quantity`, `product_id`. Never pass names, emails, addresses, or any user-typed text.\n\n## Legacy aliases\n\nThese older signal names still work:\n\n| Alias | Resolves to |\n|---|---|\n| `speed_frustration` | `silent_failure_retry` |\n| `loop_nav` | `u_turn_navigation` |\n| `scroll_bounce` | `jerky_scrolling` |\n| `form_hesitation` | `confidence_collapse` |\n| `form_abandon` | `form_abandonment` |\n| `tap_miss` | `target_near_miss` |\n| `pinch_zoom` | `viewport_thrashing` |\n\n## How scores and issues work\n\nSignals don't directly create issues. The scoring engine clusters signals by element, page, and user population. When a cluster crosses a confidence threshold, it becomes a UX issue. The confusion score for a page reflects the weighted sum of recent signals, normalized for session volume.\n\nTier weighting means a single `rage_click` moves a page score more than a single `passive_drift`. But 200 `passive_drift` signals on the same element will create an issue regardless of tier -- frequency overrides weight at scale.\n"
|
|
482
|
+
"content": "# Signals\n\nSignals are the raw evidence of user confusion. Every time a user does something that indicates friction, the SDK emits a signal. The scoring engine weighs signals by type, frequency, and recency to compute page confusion scores and cluster repeated patterns into issues.\n\nThere are 100 built-in signal types across eight tiers.\n\n## Tier 1: Direct friction\n\nHigh-confidence indicators. A single occurrence is meaningful -- you don't need volume to act. These move your page score the most and surface issues fastest.\n\nIf you see tier 1 signals on a critical element (a checkout button, a signup form, an upgrade CTA), investigate before waiting for a cluster.\n\n| Signal | What it means |\n|---|---|\n| `rage_click` | User clicked the same element 3 or more times rapidly. Usually means the element appears clickable but isn't responding as expected. |\n| `dead_click` | User clicked a non-interactive element. Often a label, image, or decorative element the user thought was a button. |\n| `layout_shift_rage` | A rage click followed by a significant layout shift. The interface moved under the user's cursor. |\n| `active_funnel_rejection` | User initiated a multi-step flow and exited before the first meaningful step. Not a casual bounce -- they started, then left. |\n| `dead_end_submit` | User submitted a form and received no visible feedback. No success state, no error message, nothing. |\n| `accidental_click_bounce` | User clicked an element and immediately hit the back button. Likely an accidental tap or misclick. |\n| `error_recovery_loop` | User encountered an error and tried the same action multiple times in quick succession without changing their input. |\n| `silent_failure_retry` | User repeated an action that appeared to succeed but produced no visible result. Common with async operations that fail silently. |\n| `form_abandonment` | User focused one or more form fields and left without submitting. Threshold: at least one meaningful field interaction. |\n| `error_encounter` | An unhandled JavaScript error, rejected promise, failed network request, or console-logged error occurred during the session. Console capture matters because most production errors never reach `window.onerror`: error boundaries and `.catch(console.error)` swallow them, and the console is where they land. Messages are scrubbed of tokens, secrets, and PII in the browser; identical messages emit once per session with a hard per-session cap. Together with interaction context this is the flight recorder: what the code was doing at the moment users struggled, attached to issues as evidence and fed to the AI diagnosis and Autofix briefs, with no session recording anywhere. |\n| `form_validation_loop` | User submitted a form, hit a validation error, edited the flagged field, and got the same error again. Three or more cycles on a single field within two minutes fires the signal. |\n| `invisible_required_field` | User submitted a form and validation failed on a required field they can't see. The field is offscreen, inside a closed `<details>`, behind an inactive tab, or hidden by a parent with `display:none`. |\n| `ghost_toggle` | User clicked a toggle, checkbox, or switch and the state either never changed (swallowed click) or changed then silently reverted within 2 seconds. Requires 2+ ghost events on the same control within 30 seconds. |\n| `premature_commit_reversal` | User clicked a primary CTA (submit, buy, confirm, add to cart) and reversed the action within 2 seconds via browser back, Ctrl+Z, Escape, or clicking a cancel element. The speed of the reversal indicates regret or accidental commitment. |\n| `cls_interaction_corruption` | A layout shift moved the user's click target between mousedown and mouseup. They pressed on the right element, but the page yanked it away and the click landed on something else. |\n| `paste_blocked` | User pasted into an input field but the value didn't change. The site is blocking paste, usually on password confirmation or email fields. |\n| `autocomplete_fight` | Browser autofill populated multiple form fields and site JavaScript immediately cleared or shortened the values. Common on checkout, login, and address forms where custom input masking fights the browser. |\n\n## Tier 2: Navigation and flow friction\n\nPatterns that emerge across a session. Each single occurrence is moderate-confidence. Clusters are high-confidence.\n\nIf a tier 2 signal appears 20+ times on the same page, or you're seeing 5+ different tier 2 types on the same page simultaneously, treat it like tier 1.\n\n| Signal | What it means |\n|---|---|\n| `u_turn_navigation` | User went forward in a flow, then immediately went back. Repeated occurrences suggest unclear copy or a missing expectation-setter. |\n| `information_maze` | User visited the same page or section multiple times without completing anything. They're looking for something they can't find. |\n| `load_abandonment` | User waited for a page to load and left before it finished. Indicates a performance problem on a high-friction path. |\n| `multi_step_reset` | User restarted a multi-step form or wizard from the beginning. Could be data entry confusion or unclear field validation. |\n| `query_thrashing` | User made multiple search or filter queries in rapid succession. They're not finding what they're looking for. |\n| `filter_spiral` | User applied and removed filters repeatedly without settling. Likely an expectation mismatch between filters and results. |\n| `post_action_anxiety` | User completed an action but immediately checked for confirmation or left the page. Suggests missing confirmation feedback. |\n| `settings_hop` | User visited settings or preferences multiple times in a session without finding what they needed. |\n| `copy_paste_rework` | User selected text and then immediately re-typed it. Often indicates a pre-fill or autocomplete that didn't work. |\n| `repeat_form_input` | User cleared and re-entered the same field more than once. Usually a validation message that's unclear or delayed. |\n| `decision_paralysis` | User's cursor oscillated between two or more interactive elements (pricing tiers, plan selectors, CTAs) with 800ms+ dwell on each, repeating the back-and-forth at least twice within 12 seconds. They can't decide. |\n| `friction_cascade` | A tier 1 signal fired and was followed by 2+ distinct tier 2 or tier 3 signal types within 90 seconds on the same page. The tier 1 signal is the root cause; the followers are symptoms. |\n| `keyboard_trap` | Focus can't escape a container. The user pressed Tab 6+ consecutive times and focus stayed within the same parent region without the user clicking elsewhere. |\n| `modal_stack` | Two or more modal, dialog, or overlay elements are visible at the same time. Stacked modals confuse users: dismiss buttons close the wrong layer, keyboard focus traps conflict, and users lose track of which layer they're interacting with. |\n| `dead_click_trap_zone` | Three or more dead clicks landed in the same viewport grid zone (the screen is divided into a 4x4 grid) within 90 seconds. A hot spot of non-interactive content that users keep mistaking for a button. |\n| `navigation_confusion` | User visited 5+ distinct pages within 30 seconds, spending less than 5 seconds on each. They're lost and clicking through pages trying to find something. |\n| `scroll_to_click_confusion` | User scrolled down, reversed direction by at least 30% of the viewport, and clicked an element within 3 seconds of the reversal. They overshot something they wanted and had to scroll back to find it. |\n| `tab_thrash` | User pressed Tab 10+ times within 5 seconds. Rapid tabbing with direction changes signals they're hunting for a focusable element they can't reach. |\n| `focus_trap` | User pressed Tab or Escape 5+ times within 3 seconds inside a dialog, menu, or modal container without escaping it. The trap isn't releasing focus when the user expects it to. |\n| `keyboard_nav_frustration` | User pressed arrow keys 15+ times within 5 seconds inside a listbox, menu, or tree widget. They're scrolling through options without finding what they want. |\n\n## Tier 3: Passive confusion\n\nLower individual weight, but meaningful in aggregate. These often surface chronic friction that lacks the dramatic edge of rage clicks -- the kind of friction users tolerate rather than abandon over.\n\nA page with dense tier 3 signals and no tier 1 or 2 signals is worth investigating. It usually means users are managing confusion rather than hitting a wall.\n\n| Signal | What it means |\n|---|---|\n| `confidence_collapse` | User paused on a form field for an unusually long time without typing. Hesitation before a field that's unclear. |\n| `target_near_miss` | User's tap or click landed very close to an interactive element but missed it. Common on mobile with small touch targets. |\n| `user_confusion_idle` | User stopped interacting entirely for longer than expected. Could be reading carefully, or could be stuck. |\n| `thrash_hover` | User rapidly moved the cursor back and forth over an element without clicking. Indecision or unclear affordance. |\n| `text_selection_thrash` | User selected text multiple times rapidly. May be trying to copy something that's being blocked. |\n| `jerky_scrolling` | User's scroll pattern was erratic: fast then stopped, or reversed frequently. Usually indicates a busy page layout. |\n| `thrash_cursor` | Rapid cursor movement across the page with no clear direction. A general confusion indicator. |\n| `viewport_thrashing` | User zoomed in and out multiple times. On mobile, this usually means text is too small or layout is not responsive. |\n| `visibility_thrashing` | User repeatedly scrolled elements in and out of view, possibly trying to compare content across sections. |\n| `passive_drift` | User stayed on a page longer than average but generated very few events. Could be reading carefully or completely lost. |\n| `help_hunt` | User opened FAQ, support, documentation, or help pages during an active conversion flow. |\n| `trust_hesitation` | User focused a sensitive field (password, email, credit card) and then clicked through to a privacy, terms, security, or about page before typing more than 2 characters. They didn't trust the form enough to fill it in. |\n| `tooltip_dependency` | User hovered over the same tooltip trigger 3+ times within 5 minutes, spending 500ms+ on each hover. The label is unclear and they keep re-reading the explanation. |\n| `phantom_progress` | A progress indicator (stepper, progress bar, step counter) is visible but the user has been stuck on the same step for 60+ seconds while actively clicking and typing. The wizard isn't advancing. |\n| `scroll_to_nowhere` | User scrolled to the bottom of the page and immediately scrolled back up within 3 seconds. They expected more content below, or couldn't find what they were looking for. |\n| `notification_fatigue` | Browser notification permission prompt was denied within 800ms. A sub-second denial means the prompt was unwanted or the site asked for permissions before establishing trust. |\n| `video_autoplay_escape` | A video or audio element autoplayed and the user immediately paused it, muted it, or scrolled past it within 3 seconds. Autoplaying media is annoying enough that they took evasive action. |\n| `scroll_hijack` | User's scroll reversed direction 4+ times within 3 seconds with large-stroke reversals. The page is overriding native scroll behavior (smooth-scroll libraries, scroll-snap fights, parallax effects). |\n| `input_correction` | User deleted 3+ characters and retyped a field at least twice within 60 seconds. They keep correcting their input, likely because a validation rule or format requirement is unclear. |\n| `hover_dwell` | User hovered over an interactive element for 2+ seconds without clicking. They're reading it, evaluating it, or unsure what it does. Cooldown of 30 seconds per element prevents noise. |\n| `exit_intent` | User's cursor left the browser viewport toward the top of the screen (toward the tab bar or address bar). Classic indicator they're about to close the tab or leave. Fires after 3+ seconds on page, once per 60 seconds. |\n| `copy_frustration` | User triggered the copy action 3+ times within 15 seconds. Repeated copy attempts suggest the selection isn't working as expected or copy is being blocked. |\n| `scroll_depth_abandon` | Tracks the deepest scroll point reached before the user leaves the page. Fires on page hide or visibility change with the max scroll depth as a percentage. Useful for identifying where users give up on long pages. |\n\n## Mobile and touch friction\n\nGesture-level friction that mostly shows up on touch devices, though `false_swipe` also fires for mouse and pen drags.\n\n| Signal | What it means |\n|---|---|\n| `false_swipe` | User performed a clear horizontal swipe or drag over an element that looks swipeable -- a carousel, slider, scroll-snap rail, or anything with draggable/grab styling -- but the element and its scrollable ancestors did not move and no page transition occurred. A false affordance: it looked swipeable, the user tried, and nothing responded. |\n| `swipe_miss` | User swiped horizontally on an element that isn't swipeable, but the page has swipeable elements elsewhere (a carousel or slider exists). They swiped in the wrong spot. |\n| `orientation_thrash` | User rotated their device 3+ times within 30 seconds. They're trying to find an orientation where the content is readable or the layout makes sense. |\n\n## Revenue and close behavior\n\nSignals that correlate directly with lost conversions. These feed the revenue impact estimate.\n\n| Signal | What it means |\n|---|---|\n| `close_click_reversal` | User moved toward closing, dismissing, or canceling something, then stopped and stayed. They were about to leave but didn't. Use this to find moments where users reconsider. |\n| `value_collapse_downgrade` | User selected a higher plan or tier, then downgraded before completing purchase. Usually indicates a pricing page clarity problem. |\n| `layout_exhaustion_settling` | User scrolled through the full page, returned to the top, and selected an option. They were overwhelmed and had to start over to decide. |\n\n## Opt-in interaction signals\n\nThree newer detectors target obstruction and blocked-interaction friction. They are **off by default** while we calibrate their thresholds on real traffic, so enable the ones you want explicitly:\n\n```ts\nimport { init } from 'flusterduck'\n\ninit({\n key: 'fd_pub_...',\n signals: {\n disabledElementAttempt: { enabled: true },\n overlayDismissStruggle: { enabled: true },\n stickyObstructionClick: { enabled: true },\n },\n})\n```\n\n| Signal | What it means |\n|---|---|\n| `disabled_element_attempt` | User clicked a disabled (or disabled-looking) control more than once with no response. Either the disabled state is wrong, or there's no explanation of why the control is unavailable. |\n| `overlay_dismiss_struggle` | User pressed Escape and jabbed at the backdrop or close affordance repeatedly while a modal, dialog, or popup stayed open. The dismiss controls aren't working or aren't where users expect. |\n| `sticky_obstruction_click` | User's click landed on a sticky or fixed element (a header, floating bar, or cookie banner) that's covering the interactive target directly beneath it. |\n\nEvery fire carries a `cf` confidence value (0-1); the scoring engine weighs each fire by it, so a borderline detection counts for less than an unambiguous one.\n\n## Performance\n\nSignals tied to page speed and responsiveness. These correlate with user frustration but originate from browser performance metrics rather than user behavior.\n\n| Signal | What it means |\n|---|---|\n| `long_task_interaction_block` | User clicked, tapped, or pressed a key during or immediately after a long task (50ms+ main thread block). The browser's event loop was frozen and their input was delayed. Payload includes the task duration and estimated delay. |\n| `loading_interaction` | User clicked an element that was in a loading state (marked with `aria-busy`, disabled, or a spinner CSS class) and clicked it again before the loading finished. They're waiting and losing patience. |\n| `lcp` | Largest Contentful Paint measurement for the page. Rated good (under 2500ms), needs improvement (2500-4000ms), or poor (over 4000ms). |\n| `inp` | Interaction to Next Paint measurement. Rated good (under 200ms), needs improvement (200-500ms), or poor (over 500ms). Captures how responsive the page feels after it loads. |\n| `cls` | Cumulative Layout Shift score. Rated good (under 0.1), needs improvement (0.1-0.25), or poor (over 0.25). Measures how much the page jumps around during loading. |\n\n## Aggregate\n\nSession-level and cross-signal patterns. These don't detect a single friction moment. They summarize friction across an entire session or correlate multiple signals into a higher-level finding.\n\n| Signal | What it means |\n|---|---|\n| `session_toxicity_score` | A running 0-100 score reflecting cumulative friction across the session. Computed from the weighted sum of all signals, the number of distinct pages with friction, and the number of distinct signal types observed. Levels: calm (0-20), building (21-40), frustrated (41-60), toxic (61-80), meltdown (81-100). Emitted every 60 seconds and on page unload. |\n| `frustration_burst` | Multiple friction signals fired within a 60-second window and the weighted total crossed a severity threshold. Levels: low, medium, high, critical. Includes the top contributing signal types and detected behavioral sequences (like rage click followed by form validation loop). |\n| `element_impression` | Tracks when a watched element (marked with `[data-fd-impression]` or a custom selector) enters the viewport. Records first-seen time and visibility percentage. Not a friction signal, but useful for measuring whether users actually see a specific element before abandoning. |\n\n## UX anti-patterns\n\nSignals that detect specific anti-patterns in page design. These are distinct from general friction indicators because they point at a concrete design problem, not just user confusion.\n\n| Signal | What it means |\n|---|---|\n| `viewport_dead_zone` | User clicked an interactive element in the bottom 10% of the viewport, but a fixed or sticky overlay (cookie banner, floating bar, sticky header, chat widget) intercepted the click. The intended target never received it. |\n| `input_format_roulette` | User cleared and retyped a formatted field (phone, date, credit card, zip code) 3+ times with different formats within 60 seconds. The expected format is ambiguous and the field isn't telling them what it wants. |\n| `undo_panic` | User clicked a destructive action (delete, remove, archive, send, publish) and panicked within 3 seconds: hit Ctrl+Z, thrashed the cursor, or went back. Tracks whether an undo UI (toast, snackbar) appeared in response. |\n| `scroll_hijack_rage` | Programmatic scroll override moved the viewport and the user fought back with 3+ rapid scroll inputs in the opposite direction within 2 seconds. Distinct from `scroll_hijack` (reversal bursts): this fires when the user is visibly resisting the override. |\n| `text_select_frustration` | User tried to select text 3+ times rapidly on the same element. Often indicates copy protection, CSS `user-select: none`, or an element that swallows selection events. |\n\n## Conversion friction\n\nSignals specific to pricing, checkout, and purchase flows. These fire where money is on the line, so even low-volume occurrences are worth investigating.\n\n| Signal | What it means |\n|---|---|\n| `pricing_comparison_stall` | User's cursor or focus oscillated between pricing tiers for 15+ seconds without selecting one. They're stuck comparing and can't tell which plan fits. |\n| `checkout_field_retreat` | User filled in a checkout field, moved to the next field, then went back and cleared or edited the previous one. Repeated retreats on the same field indicate confusing labels or unexpected validation. |\n| `coupon_code_frustration` | User attempted to apply a coupon or promo code 3+ times within 60 seconds. The code isn't working and there's no clear explanation why. |\n| `price_shock_abandon` | User reached a payment or order summary screen showing a total and left within 5 seconds without interacting. The final price surprised them. |\n| `payment_method_thrash` | User switched between payment methods (credit card, PayPal, Apple Pay, etc.) 3+ times without completing payment. Either a method is failing silently or the user can't find the one they want. |\n| `billing_shipping_confusion` | User copied or re-typed values between billing and shipping address fields, or toggled the \"same as billing\" checkbox 2+ times. The relationship between the two forms is unclear. |\n| `plan_comparison_scroll` | User scrolled a pricing comparison table or feature matrix back and forth horizontally 4+ times within 30 seconds. The table is too wide or the differentiators aren't visible without scrolling. |\n| `pricing_toggle_regret` | User toggled between monthly and annual billing, selected a plan, then toggled back and selected again. They second-guessed the billing interval after committing. |\n| `faq_bounce` | User opened an FAQ or help section during a purchase flow, read it, then left the flow entirely instead of returning to checkout. The FAQ didn't resolve their concern. |\n| `setup_step_abandon` | User completed at least one step of a post-purchase setup wizard and left before finishing. They paid but didn't activate the product. |\n| `integration_retry_failure` | User attempted to connect a third-party integration (OAuth flow, API key entry, webhook test) and the connection failed 2+ times within 5 minutes. Broken integrations during onboarding kill activation. |\n| `first_value_delay` | More than 60 seconds elapsed between account creation and the user's first meaningful action (creating a project, uploading a file, running a query). They signed up but couldn't figure out what to do next. |\n\n## Retention friction\n\nSignals tied to settings, cancellation, and feature adoption. These surface churn risk before it shows up in your billing dashboard.\n\n| Signal | What it means |\n|---|---|\n| `settings_churn` | User visited account, billing, or subscription settings 3+ times within 7 days without making a change. They're thinking about downgrading or canceling but haven't pulled the trigger. |\n| `cancellation_flow_confusion` | User entered a cancellation or downgrade flow and clicked back, restarted, or spent 30+ seconds on a single step. The flow is unclear or the retention offer is confusing them further. |\n| `downgrade_hesitation` | User selected a lower-tier plan, reached the confirmation step, and abandoned without confirming. They want to downgrade but something on the confirmation screen stopped them. |\n| `feature_discovery_failure` | User searched for a term, navigated to 3+ pages, or opened help docs looking for a feature that exists but that they can't find. The feature is buried or named in a way that doesn't match what users expect. |\n| `reactivation_attempt` | A user who previously canceled or let their subscription lapse returned and attempted to resubscribe or re-enable their account. Tracks whether the reactivation succeeded or hit an error. |\n\n## Content friction\n\nSignals that fire when users struggle to read, understand, or consume content on the page.\n\n| Signal | What it means |\n|---|---|\n| `reading_abandonment` | User scrolled past 30% of an article or long-form page, stopped scrolling for 10+ seconds, then left. They started reading and gave up mid-content. |\n| `copy_failure` | User attempted to copy text (Ctrl+C, right-click > Copy, or long-press on mobile) and the clipboard didn't receive the expected content. Copy protection, JavaScript interception, or CSS `user-select: none` blocked them. |\n| `content_overload_scroll` | User scrolled a content-heavy page at high velocity (covering 50%+ of the viewport per second) for 3+ seconds without stopping. They're skimming past content that's too dense to read. |\n| `jargon_hover_loop` | User hovered over the same tooltip, abbreviation tag, or glossary term 3+ times within 2 minutes. The term is unclear and the explanation isn't sticking. |\n| `pagination_thrash` | User clicked forward and backward through paginated content 4+ times within 60 seconds. They're hunting for something specific and the pagination doesn't help them find it. |\n\n## Error handling friction\n\nSignals that surface when error states, permissions, or validation timing confuse users.\n\n| Signal | What it means |\n|---|---|\n| `error_blindness` | An error message appeared on the page but the user continued interacting without acknowledging it for 10+ seconds. They didn't see the error, or the error wasn't visually prominent enough to interrupt their flow. |\n| `silent_logout_surprise` | User attempted an authenticated action and was redirected to a login page without warning. Their session expired silently and the redirect broke their workflow. |\n| `validation_timing_mismatch` | A form field showed a validation error while the user was still actively typing in it. The validation fired before the user finished their input, creating a false error that disappears on its own. |\n| `error_recovery_abandon` | User encountered an error, attempted 1-2 recovery actions (refresh, re-submit, navigate back), then left the page entirely. The error was unrecoverable or the recovery path was unclear. |\n| `permission_denial_break` | User attempted an action that requires elevated permissions (admin, owner, paid tier) and received a denial. They either didn't know the restriction existed or expected to have access. |\n\n## Mobile and touch friction (expanded)\n\nAdditional gesture-level and device-specific friction signals. These complement the existing `false_swipe`, `swipe_miss`, and `orientation_thrash` signals in the base mobile tier.\n\n| Signal | What it means |\n|---|---|\n| `gesture_mismatch` | User performed a recognized gesture (pinch, rotate, two-finger scroll) on an element that doesn't support that gesture type. The element's visual affordance suggested a gesture vocabulary it doesn't actually handle. |\n| `orientation_thrash` | User rotated their device and the layout broke or reflowed in a way that caused immediate scroll correction or a rage tap. Distinct from the base `orientation_thrash` signal: this variant fires only when the rotation triggers a follow-up frustration signal within 3 seconds. |\n| `thumb_zone_miss` | User tapped a target in the top 20% of the screen (outside the natural thumb zone) and missed on the first attempt. They had to stretch or shift grip to reach it. Fires only when a second tap on the same target succeeds within 2 seconds. |\n| `mobile_keyboard_dismiss` | User tapped outside an input to dismiss the on-screen keyboard and accidentally triggered an action on the element behind it. The keyboard dismissed and a button or link underneath received the tap. |\n| `tap_target_adjacency` | User tapped between two adjacent interactive elements (less than 8px gap) and hit the wrong one, then immediately tapped the other. The targets are too close together. |\n| `responsive_layout_break` | A media query or container query breakpoint triggered and caused content to overflow its container, text to truncate mid-word, or interactive elements to overlap. Detected by comparing element bounds before and after a resize or orientation change. |\n\n## Performance friction (expanded)\n\nAdditional performance signals beyond the base `long_task_interaction_block`, `loading_interaction`, `lcp`, `inp`, and `cls` metrics.\n\n| Signal | What it means |\n|---|---|\n| `font_swap_interaction` | A web font loaded and swapped while the user was actively reading or interacting with text. The FOUT (flash of unstyled text) caused a visible text reflow during an active session, not just on initial page load. |\n| `third_party_script_block` | A third-party script (analytics, chat widget, ad tag) blocked the main thread for 100ms+ during user interaction. The delay is attributable to an external domain, not the app's own code. |\n| `memory_pressure_jank` | The browser signaled memory pressure (via the `Device Memory API` or performance observer long-task clustering) and the user experienced visible jank: dropped frames during scroll, animation stutter, or input delay exceeding 500ms. |\n| `image_decode_delay` | A large image decoded on the main thread and blocked interaction for 100ms+. Common with unoptimized PNGs, large JPEGs without progressive encoding, or images decoded synchronously during scroll. |\n\n## Accessibility friction\n\nSignals that detect friction specific to assistive technology users and accessibility-related interaction patterns.\n\n| Signal | What it means |\n|---|---|\n| `focus_order_confusion` | User pressed Tab and focus moved to an element that's visually distant from the previously focused element. The DOM order and visual order disagree, causing a disorienting jump. Fires when the focus target is more than 500px from the previous focus target. |\n| `screen_reader_dead_end` | User moving through the page via `aria-` landmarks or heading shortcuts (`H` key in screen readers) reached a point with no further landmarks or headings, then reversed direction. The page structure has a dead end that forces backtracking. |\n| `motion_sensitivity_trigger` | An animation or transition ran while the user has `prefers-reduced-motion: reduce` set. The site ignored the user's motion preference. |\n| `contrast_interaction_failure` | User hovered or focused an interactive element and the resulting state change (hover color, focus outline) didn't meet WCAG AA contrast ratio against its background. The interactive feedback is invisible to users with low vision. |\n\n## Custom signals\n\nEmit your own signals with `signal()`:\n\n```ts\nimport { signal } from 'flusterduck'\n\n// A CTA that looks active but is gated by a required previous step\nsignal('dead_click', {\n selector: '[data-action=\"publish\"]',\n label: 'Publish',\n blocked_by: 'missing_title',\n})\n\n// A custom multi-step flow step abandonment\nsignal('form_abandonment', {\n form: 'workspace-setup',\n step: 'invite-members',\n fields_touched: 0,\n})\n```\n\nThe first argument is the signal type -- any of the 100 built-in types or a custom string. The second is optional metadata. Never include PII, form values, or anything a user typed.\n\nCustom signal types appear in the dashboard and scoring engine alongside built-in types. They don't get tier weights assigned automatically -- the scoring engine treats them as tier 2 by default until enough data accumulates for it to assess their actual impact.\n\n## Business events\n\nWire conversion outcomes with `track()` for revenue impact estimation:\n\n```ts\nimport { track } from 'flusterduck'\n\ntrack('plan_intent', {\n plan_id: 'scale',\n amount_cents: 9900,\n billing: 'monthly',\n})\n\ntrack('subscription_started', {\n plan_id: 'scale',\n amount_cents: 9900,\n billing: 'monthly',\n})\n\ntrack('checkout_abandoned', {\n plan_id: 'grow',\n amount_cents: 3900,\n})\n```\n\nSafe properties: `plan_id`, `amount_cents`, `billing`, `currency`, `quantity`, `product_id`. Never pass names, emails, addresses, or any user-typed text.\n\n## Legacy aliases\n\nThese older signal names still work:\n\n| Alias | Resolves to |\n|---|---|\n| `speed_frustration` | `silent_failure_retry` |\n| `loop_nav` | `u_turn_navigation` |\n| `scroll_bounce` | `jerky_scrolling` |\n| `form_hesitation` | `confidence_collapse` |\n| `form_abandon` | `form_abandonment` |\n| `tap_miss` | `target_near_miss` |\n| `pinch_zoom` | `viewport_thrashing` |\n\n## How scores and issues work\n\nSignals don't directly create issues. The scoring engine clusters signals by element, page, and user population. When a cluster crosses a confidence threshold, it becomes a UX issue. The confusion score for a page reflects the weighted sum of recent signals, normalized for session volume.\n\nTier weighting means a single `rage_click` moves a page score more than a single `passive_drift`. But 200 `passive_drift` signals on the same element will create an issue regardless of tier -- frequency overrides weight at scale.\n"
|
|
476
483
|
},
|
|
477
484
|
{
|
|
478
485
|
"slug": "scoring",
|
|
@@ -521,21 +528,21 @@ Session limits are pooled across all sites in the org, not per-site. All plans i
|
|
|
521
528
|
"title": "Revenue impact",
|
|
522
529
|
"description": "Wire conversion events with track() and Flusterduck puts dollar amounts on UX issues.",
|
|
523
530
|
"group": "Product",
|
|
524
|
-
"content": '# Revenue Impact\n\nWire conversion events with `track()` and Flusterduck starts putting dollar amounts on UX issues. Instead of "47 users are rage-clicking the checkout button," you get "47 users are rage-clicking the checkout button and we estimate $5,200/month in lost revenue from sessions with that pattern."\n\nThat\'s the number that gets engineers to prioritize UX bugs over feature work.\n\n## What to track\n\nThree events power revenue estimates:\n\n```ts\nimport { track } from \'flusterduck\'\n\n// User selects a plan or clicks an upgrade CTA\ntrack(\'plan_intent\', {\n plan_id: \'scale\',\n amount_cents: 9900,\n billing: \'monthly\',\n})\n\n// Purchase completed\ntrack(\'subscription_started\', {\n plan_id: \'scale\',\n amount_cents: 9900,\n billing: \'monthly\',\n})\n\n// Checkout started but not completed\ntrack(\'checkout_abandoned\', {\n plan_id: \'grow\',\n amount_cents: 3900,\n})\n```\n\nWhere each one belongs in your code:\n\n**`plan_intent`**: when a user clicks a pricing CTA or selects a plan tier. Before the payment form. This tells the engine what was at stake in sessions that didn\'t convert.\n\n**`subscription_started`**: in your post-payment success handler or your Stripe webhook. This establishes the baseline conversion rate.\n\n**`checkout_abandoned`**: when a user leaves the checkout flow. Use your payment provider\'s abandon detection or wire it to `onbeforeunload` on your checkout page.\n\n## Safe properties\n\nNever pass PII, form values, or any user-typed content. The engine only needs these:\n\n| Property | Type | Notes |\n|---|---|---|\n| `plan_id` | string | Your internal plan identifier (`"grow"`, `"scale"`, `"enterprise"`) |\n| `amount_cents` | number | Integer. In cents, not dollars. `9900` = $99.00 |\n| `billing` | string | `"monthly"` or `"annual"` |\n| `currency` | string | ISO 4217 code. Defaults to `"USD"` |\n| `quantity` | number | Seat count, unit count |\n| `product_id` | string | For multi-product apps |\n\n## How the estimate works\n\nThe engine correlates friction patterns with conversion outcomes:\n\n1. For each open issue, it identifies the sessions containing that friction pattern.\n2. It compares the conversion rate of those sessions against sessions on the same page with no friction signals.\n3. The conversion gap gets monetized using the average `amount_cents` from recent `subscription_started` events.\n4. That\'s projected across the monthly session volume for that page.\n\nThe result is directionally accurate, not exact. It assumes the friction is causing the conversion gap, which is usually right but not always. A page that\'s down or slow will produce high friction signals and low conversions, but the fix isn\'t a UX change. Use the revenue estimate as a prioritization signal, not a forecast.\n\n## Reading revenue estimates\n\n```bash\ncurl "https://api.flusterduck.com/v1/revenue" \\\n -H "Authorization: Bearer fd_sec_xxxxxxxxxxxx"\n```\n\n```json\n{\n "total_at_risk_monthly": 8400,\n "currency": "USD",\n "issues": [\n {\n "issue_id": "iss_xxxxxxxxxxxx",\n "title": "Dead clicks on complete purchase button",\n "page": "/checkout",\n "revenue_at_risk_monthly": 5200,\n "affected_sessions_pct": 14,\n "conversion_gap_pct": 8.3\n },\n {\n "issue_id": "iss_xxxxxxxxxxxx",\n "title": "Form abandonment at billing step",\n "page": "/checkout",\n "revenue_at_risk_monthly": 3200,\n "affected_sessions_pct": 9,\n "conversion_gap_pct": 5.1\n }\n ]\n}\n```\n\n`affected_sessions_pct` is the percentage of checkout sessions that contain this friction pattern. `conversion_gap_pct` is the difference in conversion rate between affected and unaffected sessions.\n\nRevenue estimates are only populated when:\n- You\'ve called `track(\'subscription_started\', ...)` at least 50 times in the past 30 days (the engine needs enough data to establish a baseline conversion rate)\n- There are open issues on pages where conversion events were tracked\n- The issue\'s affected sessions have a statistically meaningful conversion gap\n\nIf the `/revenue` endpoint returns empty `issues`, you either haven\'t tracked enough conversions yet or there aren\'t enough sessions to compute a meaningful gap.\n\n## Revenue via MCP\n\n```\nWhat\'s the revenue impact of the current open issues?\nWhich issue is costing us the most?\nRank the open issues by revenue at risk.\n```\n\nThe `get_revenue_impact` tool returns the same data as the API endpoint, formatted for your AI assistant to reason about.\n\n## Annual vs monthly\n\nThe estimate uses `billing` to annualize correctly. If most of your conversions are annual, the per-session value is higher and the revenue-at-risk number will reflect that. Pass `billing: "annual"` in `subscription_started` events for annual subscribers and `billing: "monthly"` for monthly subscribers.\n\nIf you don\'t pass `billing`, the engine assumes monthly.\n\n## When estimates look too low or too high\n\n**Too low**: You may not be calling `track(\'checkout_abandoned\', ...)`. Without explicit abandonment events, the engine has to infer them from sessions that had `plan_intent` but no `subscription_started`. That inference is less precise than an explicit event.\n\n**Too high**: Check whether your `subscription_started` events are firing in test or CI sessions. If they are, the engine\'s baseline conversion rate will be inflated and the gap will appear larger than it is. Pass `environment: "development"` to your SDK init in non-production environments to keep test data out of production scores.\n\n## See also\n\nThe same conversion events power the [Conversion trigger](./conversion-trigger)
|
|
531
|
+
"content": '# Revenue Impact\n\nWire conversion events with `track()` and Flusterduck starts putting dollar amounts on UX issues. Instead of "47 users are rage-clicking the checkout button," you get "47 users are rage-clicking the checkout button and we estimate $5,200/month in lost revenue from sessions with that pattern."\n\nThat\'s the number that gets engineers to prioritize UX bugs over feature work.\n\n## What to track\n\nThree events power revenue estimates:\n\n```ts\nimport { track } from \'flusterduck\'\n\n// User selects a plan or clicks an upgrade CTA\ntrack(\'plan_intent\', {\n plan_id: \'scale\',\n amount_cents: 9900,\n billing: \'monthly\',\n})\n\n// Purchase completed\ntrack(\'subscription_started\', {\n plan_id: \'scale\',\n amount_cents: 9900,\n billing: \'monthly\',\n})\n\n// Checkout started but not completed\ntrack(\'checkout_abandoned\', {\n plan_id: \'grow\',\n amount_cents: 3900,\n})\n```\n\nWhere each one belongs in your code:\n\n**`plan_intent`**: when a user clicks a pricing CTA or selects a plan tier. Before the payment form. This tells the engine what was at stake in sessions that didn\'t convert.\n\n**`subscription_started`**: in your post-payment success handler or your Stripe webhook. This establishes the baseline conversion rate.\n\n**`checkout_abandoned`**: when a user leaves the checkout flow. Use your payment provider\'s abandon detection or wire it to `onbeforeunload` on your checkout page.\n\n## Safe properties\n\nNever pass PII, form values, or any user-typed content. The engine only needs these:\n\n| Property | Type | Notes |\n|---|---|---|\n| `plan_id` | string | Your internal plan identifier (`"grow"`, `"scale"`, `"enterprise"`) |\n| `amount_cents` | number | Integer. In cents, not dollars. `9900` = $99.00 |\n| `billing` | string | `"monthly"` or `"annual"` |\n| `currency` | string | ISO 4217 code. Defaults to `"USD"` |\n| `quantity` | number | Seat count, unit count |\n| `product_id` | string | For multi-product apps |\n\n## How the estimate works\n\nThe engine correlates friction patterns with conversion outcomes:\n\n1. For each open issue, it identifies the sessions containing that friction pattern.\n2. It compares the conversion rate of those sessions against sessions on the same page with no friction signals.\n3. The conversion gap gets monetized using the average `amount_cents` from recent `subscription_started` events.\n4. That\'s projected across the monthly session volume for that page.\n\nThe result is directionally accurate, not exact. It assumes the friction is causing the conversion gap, which is usually right but not always. A page that\'s down or slow will produce high friction signals and low conversions, but the fix isn\'t a UX change. Use the revenue estimate as a prioritization signal, not a forecast.\n\n## Reading revenue estimates\n\n```bash\ncurl "https://api.flusterduck.com/v1/revenue" \\\n -H "Authorization: Bearer fd_sec_xxxxxxxxxxxx"\n```\n\n```json\n{\n "total_at_risk_monthly": 8400,\n "currency": "USD",\n "issues": [\n {\n "issue_id": "iss_xxxxxxxxxxxx",\n "title": "Dead clicks on complete purchase button",\n "page": "/checkout",\n "revenue_at_risk_monthly": 5200,\n "affected_sessions_pct": 14,\n "conversion_gap_pct": 8.3\n },\n {\n "issue_id": "iss_xxxxxxxxxxxx",\n "title": "Form abandonment at billing step",\n "page": "/checkout",\n "revenue_at_risk_monthly": 3200,\n "affected_sessions_pct": 9,\n "conversion_gap_pct": 5.1\n }\n ]\n}\n```\n\n`affected_sessions_pct` is the percentage of checkout sessions that contain this friction pattern. `conversion_gap_pct` is the difference in conversion rate between affected and unaffected sessions.\n\nRevenue estimates are only populated when:\n- You\'ve called `track(\'subscription_started\', ...)` at least 50 times in the past 30 days (the engine needs enough data to establish a baseline conversion rate)\n- There are open issues on pages where conversion events were tracked\n- The issue\'s affected sessions have a statistically meaningful conversion gap\n\nIf the `/revenue` endpoint returns empty `issues`, you either haven\'t tracked enough conversions yet or there aren\'t enough sessions to compute a meaningful gap.\n\n## Revenue via MCP\n\n```\nWhat\'s the revenue impact of the current open issues?\nWhich issue is costing us the most?\nRank the open issues by revenue at risk.\n```\n\nThe `get_revenue_impact` tool returns the same data as the API endpoint, formatted for your AI assistant to reason about.\n\n## Annual vs monthly\n\nThe estimate uses `billing` to annualize correctly. If most of your conversions are annual, the per-session value is higher and the revenue-at-risk number will reflect that. Pass `billing: "annual"` in `subscription_started` events for annual subscribers and `billing: "monthly"` for monthly subscribers.\n\nIf you don\'t pass `billing`, the engine assumes monthly.\n\n## When estimates look too low or too high\n\n**Too low**: You may not be calling `track(\'checkout_abandoned\', ...)`. Without explicit abandonment events, the engine has to infer them from sessions that had `plan_intent` but no `subscription_started`. That inference is less precise than an explicit event.\n\n**Too high**: Check whether your `subscription_started` events are firing in test or CI sessions. If they are, the engine\'s baseline conversion rate will be inflated and the gap will appear larger than it is. Pass `environment: "development"` to your SDK init in non-production environments to keep test data out of production scores.\n\n## See also\n\nThe same conversion events power the [Conversion trigger](./conversion-trigger): the confused-vs-calm analysis that shows, site-wide, how much less confused sessions convert than calm ones. Revenue impact dollarizes individual issues; the conversion trigger proves confusion is costing conversions and pinpoints where. Wire the event once and both get sharper.\n'
|
|
525
532
|
},
|
|
526
533
|
{
|
|
527
534
|
"slug": "conversion-trigger",
|
|
528
535
|
"title": "Conversion trigger",
|
|
529
536
|
"description": "Tell Flusterduck what success is, then see how much less confused sessions convert than calm ones.",
|
|
530
537
|
"group": "Product",
|
|
531
|
-
"content": '# Conversion trigger\n\nFlusterduck already knows where people get confused. The conversion trigger tells it what *success* looks like on your site
|
|
538
|
+
"content": '# Conversion trigger\n\nFlusterduck already knows where people get confused. The conversion trigger tells it what *success* looks like on your site: the single event that means a visitor did the thing you wanted. Once that\'s wired, Flusterduck can prove the part that matters to the business: **confused sessions convert less than calm ones**, and it can put a number on the gap.\n\nThis page covers the two steps to set it up, then the confused-vs-calm insight it unlocks.\n\n## Why this exists\n\nConfusion scores tell you *where* friction is. They don\'t, on their own, tell you whether that friction costs you anything. A page can be a little confusing and still convert fine; another can look calm and quietly leak sign-ups.\n\nThe conversion trigger closes that loop. When Flusterduck knows which event counts as a conversion, it splits every session into two cohorts, **confused** (produced at least one friction signal) and **calm** (zero friction signals), and compares how often each cohort converts. The difference is the money story: "confused sessions convert 12 points lower than calm ones, and it\'s worst on `/checkout`."\n\n## Step 1: Fire the conversion event from the SDK\n\nConversions are ordinary business events, sent with `track()`. Call it the moment a conversion actually completes: in your post-payment success handler, your thank-you page, or wherever you already know the visitor succeeded.\n\n```ts\nimport { track } from \'flusterduck\'\n\n// In your success handler, once the purchase is confirmed\ntrack(\'purchase_completed\', {\n plan_id: \'scale\',\n amount_cents: 9900,\n billing: \'monthly\',\n})\n```\n\nFlusterduck recognizes three realized-purchase events out of the box:\n\n| Event | Fire it when |\n|---|---|\n| `checkout_completed` | An order or checkout finishes |\n| `purchase_completed` | A one-time purchase is confirmed |\n| `subscription_started` | A subscription or trial-to-paid conversion completes |\n\nPick the one that best names your success moment. If you have several (a store with both one-off orders and subscriptions), you can fire more than one. Step 2 lets you choose which counts.\n\nThe same privacy rules as [Revenue impact](./revenue) apply: never pass PII, form values, or typed content. Safe properties are `plan_id`, `amount_cents` (integer cents), `billing`, `currency`, `quantity`, `product_id`.\n\n> The SDK sends `track(\'purchase_completed\', \u2026)` as a business event named `purchase_completed`. That name is exactly what you\'ll reference in Step 2.\n\n## Step 2: Choose which event counts as the conversion\n\nTell Flusterduck which business event is *the* conversion for this site. Open **Settings \u2192 Revenue** and set the **Conversion goal** field to the event name you fired in Step 1 (for example, `purchase_completed`).\n\nUnder the hood this is stored as `revenue_config.conversion_goal` on the site\'s production SDK config. If you manage config through the API, set it with the `manage` write API:\n\n```bash\ncurl -X PATCH "https://api.flusterduck.com/v1/manage/sdk-config" \\\n -H "Authorization: Bearer <jwt>" \\\n -H "Content-Type: application/json" \\\n -d \'{\n "site_id": "<site_id>",\n "environment": "production",\n "revenue_config": { "conversion_goal": "purchase_completed" }\n }\'\n```\n\n**Leaving the goal unset is a valid choice.** With no `conversion_goal`, Flusterduck counts *any* realized-purchase event (`checkout_completed`, `purchase_completed`, or `subscription_started`) as a conversion. Set an explicit goal when you fire several of those events but only one of them is the outcome you care about. The goal narrows the definition to exactly that event.\n\n## The insight it unlocks\n\nWith a conversion firing, the confused-vs-calm analysis becomes available. It reports:\n\n- **Cohort comparison**: conversion rate, average session duration, pages per session, and bounce rate for confused vs calm sessions, plus the deltas between them.\n- **By page**: where the conversion gap is widest, so you know which confusing page costs the most conversions.\n- **By source**: which traffic sources (UTM source / referrer host) lose the most conversions to confusion.\n- **Ranked insights**: pre-written, narratable findings such as "Confused visitors convert 12 points lower." Each carries a confidence flag; a cohort under the minimum sample size is marked low-confidence so you don\'t over-read early data.\n\n### Read it three ways\n\n**REST**: `GET /v1/query/insights?site_id=<id>&days=7` (window is 1\u201390 days, default 7):\n\n```bash\ncurl "https://api.flusterduck.com/v1/query/insights?site_id=<site_id>&days=30" \\\n -H "Authorization: Bearer fd_sec_xxxxxxxxxxxx"\n```\n\n**CLI**: the [CLI](./cli) prints a readable summary: the headline gap, the top per-page gaps, the hardest-hit sources, and the ranked insights.\n\n```bash\nflusterduck insights --site <site_id> --days 30\n```\n\n**MCP**: the `get_conversion_insights` tool (local [MCP server](./mcp) and the remote Cloudflare worker) returns the same analysis for your AI assistant, so you can just ask:\n\n```\nHow much less do confused sessions convert than calm ones?\nWhich page loses the most conversions to confusion?\nWhich traffic source is hit hardest by confusion?\n```\n\n## Sampling and confidence\n\nThe engine needs a floor of sessions in **each** cohort before it will make a confident claim (the `min_sample` in the response, 30 by default). Until both cohorts clear it, the analysis still returns, but the headline insight reads "Still gathering data" and cohorts are flagged `low_confidence`. This is deliberate: a 3-session cohort can show a wild, meaningless gap. Give it real traffic before acting on the number.\n\n## Relationship to revenue impact\n\nThe conversion trigger and [Revenue impact](./revenue) are complementary. Revenue impact attaches dollar figures to individual open issues using the `amount_cents` you pass. The confused-vs-calm insight is the broader, site-wide picture: it doesn\'t need dollar amounts (only a conversion event) to show that confusion is depressing conversions and where. Wire the conversion event once and both get sharper.\n'
|
|
532
539
|
},
|
|
533
540
|
{
|
|
534
541
|
"slug": "mcp",
|
|
535
542
|
"title": "MCP integration",
|
|
536
543
|
"description": "Give your AI assistant read access to scores, issues, journeys, and revenue leakage.",
|
|
537
544
|
"group": "Integrations",
|
|
538
|
-
"content": '# MCP Integration\n\nSet this up once and you stop opening the dashboard to check on friction data. Ask your AI assistant:\n\n> "What\'s broken on checkout right now?"\n\nIt calls `get_site_context`, pulls your live scores and open issues, and tells you. Post-deploy checks, issue triage, weekly summaries. All without touching a UI.\n\n## Fastest install\n\nThe hosted server needs no keys and no install \u2014 add the URL and sign in with your Flusterduck account when your client prompts you:\n\n```bash\nclaude mcp add --transport http flusterduck https://mcp.flusterduck.com/mcp\n```\n\nPrefer running the server locally (stdio)? `npx` fetches it on first run. Grab an MCP key and your site ID from the dashboard (Settings \u2192 API keys), then:\n\n```bash\nclaude mcp add flusterduck \\\n --env FLUSTERDUCK_MCP_KEY=fd_mcp_xxxxxxxxxxxx \\\n --env FLUSTERDUCK_SITE_ID=your-site-id \\\n -- npx -y @flusterduck/mcp-server\n```\n\n## Setup\n\n### Claude Desktop\n\nAdd to `~/Library/Application Support/Claude/claude_desktop_config.json` (npx pulls the server automatically \u2014 nothing to install first):\n\n```json\n{\n "mcpServers": {\n "flusterduck": {\n "command": "npx",\n "args": ["-y", "@flusterduck/mcp-server"],\n "env": {\n "FLUSTERDUCK_MCP_KEY": "fd_mcp_xxxxxxxxxxxx",\n "FLUSTERDUCK_SITE_ID": "7f2c9d4a-4b5e-4c3a-9b1d-2e6a4f8c7d5b"\n }\n }\n }\n}\n```\n\nRestart Claude Desktop. Tools are available immediately.\n\n### Claude Code CLI\n\n```bash\nclaude mcp add flusterduck \\\n --env FLUSTERDUCK_MCP_KEY=fd_mcp_xxxxxxxxxxxx \\\n --env FLUSTERDUCK_SITE_ID=your-site-id \\\n -- npx -y @flusterduck/mcp-server\n```\n\nOr add directly to `~/.claude/settings.json`:\n\n```json\n{\n "mcpServers": {\n "flusterduck": {\n "command": "npx",\n "args": ["-y", "@flusterduck/mcp-server"],\n "env": {\n "FLUSTERDUCK_MCP_KEY": "fd_mcp_xxxxxxxxxxxx",\n "FLUSTERDUCK_SITE_ID": "7f2c9d4a-4b5e-4c3a-9b1d-2e6a4f8c7d5b"\n }\n }\n }\n}\n```\n\n### Cursor\n\n`~/.cursor/mcp.json` (global) or `.cursor/mcp.json` in the project root:\n\n```json\n{\n "mcpServers": {\n "flusterduck": {\n "command": "npx",\n "args": ["-y", "@flusterduck/mcp-server"],\n "env": {\n "FLUSTERDUCK_MCP_KEY": "fd_mcp_xxxxxxxxxxxx",\n "FLUSTERDUCK_SITE_ID": "7f2c9d4a-4b5e-4c3a-9b1d-2e6a4f8c7d5b"\n }\n }\n }\n}\n```\n\nReload the Cursor window after saving.\n\n### Windsurf\n\n`~/.codeium/windsurf/mcp_config.json`:\n\n```json\n{\n "mcpServers": {\n "flusterduck": {\n "command": "npx",\n "args": ["-y", "@flusterduck/mcp-server"],\n "env": {\n "FLUSTERDUCK_MCP_KEY": "fd_mcp_xxxxxxxxxxxx",\n "FLUSTERDUCK_SITE_ID": "7f2c9d4a-4b5e-4c3a-9b1d-2e6a4f8c7d5b"\n }\n }\n }\n}\n```\n\n### VS Code (GitHub Copilot)\n\n`.vscode/mcp.json` in the workspace:\n\n```json\n{\n "servers": {\n "flusterduck": {\n "type": "stdio",\n "command": "npx",\n "args": ["-y", "@flusterduck/mcp-server"],\n "env": {\n "FLUSTERDUCK_MCP_KEY": "fd_mcp_xxxxxxxxxxxx",\n "FLUSTERDUCK_SITE_ID": "7f2c9d4a-4b5e-4c3a-9b1d-2e6a4f8c7d5b"\n }\n }\n }\n}\n```\n\n### Remote server\n\nPoint your client at `https://mcp.flusterduck.com/mcp`. There is nothing to copy: the first connection opens a consent page, you sign in with your Flusterduck account, and access is granted through OAuth 2.1 with PKCE. Disconnecting from your MCP client revokes access immediately.\n\n## Keys\n\nThe hosted server never needs a key \u2014 sign-in handles it. MCP keys (`fd_mcp_`, created under Settings > API Keys) are only for the local stdio server.\n\n## MCP or CLI?\n\nBoth surfaces expose the same data; pick by where the agent runs. MCP is right for interactive assistants (Claude Desktop, Cursor, Claude Code sessions) \u2014 persistent connection, tools, and the multi-step prompts below. The [CLI](./cli) is right for CI jobs and shell scripts: `npx flusterduck-cli issues --site <id> --json` for reads, `issue resolve|ignore|reopen|start` to manage issues, and `deploy notify` to record deploys \u2014 every command takes `--json`, so agents can parse the output directly.\n\n**Read-only** (`mcp:read`): all query tools and prompts. Can\'t write anything. Start here.\n\n**Read + write** (`mcp:read` + `manage:write`): adds `update_issue`, `update_alert`, `add_annotation`, `create_alert_rule`, `update_alert_rule`, and `delete_alert_rule`. Needed for triage and annotation workflows.\n\n## Prompts\n\nThe prompts are the highest-value part. They\'re multi-step workflows built into the server. The AI calls several tools in sequence and synthesizes the result into a useful answer. Use prompts for the common cases; use raw tools when you need something specific.\n\n### post_deploy_check\n\nFinds your most recent deploy, compares confusion scores before and after, identifies pages where friction spiked, and returns PASS / WARN / FAIL. Writes an annotation automatically on FAIL.\n\nRun this after every deploy. Takes about 30 seconds.\n\n### triage_open_issues\n\nScans open issues by signal count, moves each to the right status (`triaged`, `in_progress`, or `ignored`), adds a triage note to each, and writes a summary annotation. Requires `manage:write`.\n\nMost useful first thing Monday morning before standup.\n\n### diagnose_page\n\nFive-step diagnosis for a specific page. Returns: current friction score, top signal sources by volume, worst-performing element, likely root cause, and one concrete fix recommendation.\n\n```\npage_path: "/settings/billing"\n```\n\n### investigate_session\n\nFull investigation of a single session: event timeline in order, dominant signal types, matching open issues, and a verdict on whether the session represents a recurring pattern or an outlier.\n\n```\nsession_id: "ses_xxxxxxxxxxxx"\n```\n\n### weekly_summary\n\nA weekly UX friction digest formatted for a PM or eng lead: score trends, newly opened issues, open alerts, revenue at risk, top three recommendations. Under 300 words.\n\nGood to run before Monday standup.\n\n## What a real investigation looks like\n\nYou ask: "Did the deploy yesterday break anything on checkout?"\n\nThe AI runs this automatically:\n\n1. `get_deploys`: finds yesterday\'s deploy and its timestamp\n2. `get_page` with `/checkout`: sees the confusion score jumped from 14 to 38 in the hour after\n3. `get_issues` filtered to `open`: two issues opened within 90 minutes of the deploy, a rage click spike on `#place-order` and elevated form abandonment on the payment step\n4. `get_elements` scoped to `/checkout`: `#place-order` has 52 rage clicks in 24 hours, baseline is 5/day\n5. `diagnose_page`: returns root cause. The button appears inactive during payment processing with no visual indication, causing repeated clicking.\n6. `add_annotation`: "Deploy 2026-06-09: /checkout confusion +171%. #place-order rage clicks 10x baseline. Root cause: missing loading state on payment submit."\n\nThe whole sequence runs in about 30 seconds and leaves a permanent timeline marker.\n\n## Read tools\n\nAll require `mcp:read` scope. Start with `get_site_context`. It gives you the full picture in one call and tells you where to dig next.\n\n**Documentation** \u2014 the full Flusterduck docs are bundled into the server, so your assistant can read them before acting on data.\n\n- `list_docs`: Lists every documentation page (slug, title, group). Start here to see what\'s available.\n- `search_docs`: Full-text search across all docs. Pass `query`; returns matching pages with excerpts.\n- `get_doc`: Returns a full documentation page by `slug` (e.g. `alerts`, `scoring`, `webhooks`, `react`, `mcp`). Read a feature\'s page before acting \u2014 for example, read `alerts` before creating a rule to understand threshold semantics.\n\n**Start here**\n\n- `get_site_context`: Full snapshot of scores, open issues, active alerts, recent deploys, and top recommendations. The right first call for any investigation.\n- `get_scores`: Every tracked page ranked by current confusion score.\n- `get_recommendations`: Prioritized fix list ranked by estimated confusion reduction.\n\n**Issues and alerts**\n\n- `get_issues`: All UX issues. Filter by status: `open`, `triaged`, `in_progress`, `verified`, `resolved`, `regressed`.\n- `get_issue`: Full detail on one issue, including evidence, session links, verification history, and deploy correlation.\n- `get_alerts`: Active and resolved alerts. Filter by `open`, `acknowledged`, or `resolved`.\n- `list_alert_rules`: All configured rules with thresholds, channels, and enabled state. Call this before creating or modifying rules.\n\n**Page deep-dives**\n\n- `get_page`: Score history, active issues, element friction, annotations, and confusion budget for one page. Pass `page: "/checkout"`.\n- `get_elements`: Element-level breakdown showing which buttons, forms, and links generate the most signals. Scope by `page` or pull site-wide.\n- `get_trends`: Confusion score over time. Pass `days` (1-90) and optionally `page`.\n- `compare_pages`: Side-by-side confusion score comparison. Pass `a` and `b` as page paths.\n- `diagnose_journey_friction`: High-friction navigation edges from recent sessions. Filter by `signal_type` or `min_friction_weight`.\n\n**Sessions and raw data**\n\n- `get_session_detail`: Full event timeline for one session in chronological order.\n- `get_heuristics`: The complete signal catalog with all 33 types, scoring weights, and thresholds.\n- `query_raw_rows`: SQL-style access to allowlisted tables (`events`, `signals`, `sessions`, `page_scores`, `score_history`, `ux_issues`, `alerts`, `deploys`).\n- `download_events_csv`: Raw event export as CSV.\n- `explore`: A deterministic, typed query engine over session data \u2014 no natural language, no LLM, built explicitly for agents to answer ad-hoc questions the rest of the surface doesn\'t cover directly. Pick a `window_days` (1-90, default 7), up to 12 AND-ed `filters` over `signal`, `page`, `source`, `confused`, `converted`, `event_type` (ops `is` / `is_not` / `contains`, contains only on `page` and `source`), and exactly one `output`: `{mode: "list"}` to pull matching sessions, or `{mode: "measure", metric, group_by?}` to compute `count`, `avg_pageviews`, `conversion_rate`, `avg_dwell_ms`, or `bounce_rate` as a scalar or, with `group_by` (`page`, `source`, `day`, `signal`, `cohort`), a series. See "Explore via MCP" below for a list and a measure example.\n\n**Deploy correlation**\n\n- `get_deploys`: All deploys Flusterduck knows about, with `confusion_before` and `confusion_after` populated for deploys older than 5 minutes.\n- `get_revenue_impact`: Revenue impact estimates for active friction. Only populated when conversion tracking is wired.\n- `get_flows`: Page-to-page navigation edges from recent sessions.\n\n**Conversion impact**\n\n- `get_conversion_insights`: The confused-vs-calm conversion analysis \u2014 how much less confused sessions convert than calm ones, broken down by page and traffic source, plus ranked narratable insights. Pass `days` (1-90, default 7). Needs a conversion event wired; see [Conversion trigger](./conversion-trigger).\n\n**Org-level** (requires org-scoped key)\n\n- `get_audit_log`: Organization audit log.\n- `get_degradation`: Active and recent backend degradation events. Also available to site-scoped keys, since degradation is operational health rather than tenant data.\n- `get_webhook_deliveries`: Outbound webhook delivery history and failure details.\n\n## Explore via MCP\n\n`explore` is the odd one out on purpose: everything else in this list is a fixed shape (scores, issues, trends), but `explore` is a small closed query language over session data \u2014 a window, up to 12 AND-ed filters, and one output mode. Still fully deterministic and validated against the same allowlists the dashboard uses; there\'s no free-text query and no model in the loop.\n\n**List** \u2014 sessions that rage-clicked on `/pricing` in the last 7 days:\n\n```\nwindow_days: 7\nfilters: [\n { "field": "page", "op": "is", "value": "/pricing" },\n { "field": "signal", "op": "is", "value": "rage_click" }\n]\noutput: { "mode": "list", "limit": 20 }\n```\n\nReturns up to 20 matching sessions, most recent first \u2014 each with its page list, signal counts, source, and confused/converted flags. Use this to cite concrete example sessions as evidence in a diagnosis.\n\n**Measure** \u2014 does confusion actually cost this site conversions?\n\n```\nwindow_days: 30\noutput: { "mode": "measure", "metric": "conversion_rate", "group_by": "cohort" }\n```\n\nReturns conversion rate as a two-point series: the `confused` cohort (sessions with at least one friction signal) versus the `calm` cohort. Swap `group_by` for `page`, `source`, `day`, or `signal` to see the same metric broken down a different way, or drop `group_by` entirely for a single scalar across all matching sessions.\n\n## Write tools\n\nAll require `manage:write` scope.\n\n**`update_issue`**: Change status, add a triage note, or assign an issue.\n\n```\nissue_id: "iss_3a7f2c9d4e1b"\nstatus: "triaged"\nnote: "Confirmed on Safari iOS 17. Disabled-state styling on #place-order doesn\'t communicate that payment is processing."\nassigned_to: "eng-lead"\n```\n\nStatus options: `open`, `triaged`, `in_progress`, `verified`, `resolved`, `ignored`.\n\n**`update_alert`**: Acknowledge, mark investigating, or resolve an alert.\n\n```\nalert_id: "alt_9b5e1f4c2d8a"\nstatus: "resolved"\nresolved_reason: "Deploy 2026-06-09 added a spinner and disabled state to the payment submit button."\n```\n\n**`add_annotation`**: Write a timeline marker visible to the whole team.\n\n```\nmessage: "Redesigned billing flow launched. Monitoring confusion score on /settings/billing."\n```\n\n**`create_alert_rule`**: Create a new alert rule.\n\n```\nname: "Checkout rage click spike"\ntrigger_type: "spike"\nthreshold: 25\ncooldown_minutes: 60\nchannels: ["email", "slack"]\npage_pattern: "/checkout*"\n```\n\n**`update_alert_rule`**: Update an existing rule. Use `enabled: false` to silence it temporarily rather than deleting.\n\n**`delete_alert_rule`**: Permanently remove a rule. Can\'t be undone.\n\n## Questions that work well\n\n- What\'s broken on checkout right now?\n- Did the last deploy make things worse?\n- Which page has the most dead clicks this week?\n- Triage the open issues and tell me what to fix first.\n- Write a friction summary for the PM standup.\n- Which sessions show rage clicks on the upgrade button?\n- How does this week\'s confusion score compare to last week?\n- Create a spike alert for the pricing page and notify Slack.\n- What\'s the revenue impact of the current open issues?\n- Which elements on `/onboarding` are generating the most friction?\n'
|
|
545
|
+
"content": '# MCP Integration\n\nSet this up once and you stop opening the dashboard to check on friction data. Ask your AI assistant:\n\n> "What\'s broken on checkout right now?"\n\nIt calls `get_site_context`, pulls your live scores and open issues, and tells you. Post-deploy checks, issue triage, weekly summaries. All without touching a UI.\n\n## Fastest install\n\nThe hosted server needs no keys and no install. Add the URL and sign in with your Flusterduck account when your client prompts you:\n\n```bash\nclaude mcp add --transport http flusterduck https://mcp.flusterduck.com/mcp\n```\n\nPrefer running the server locally (stdio)? `npx` fetches it on first run. Grab an MCP key and your site ID from the dashboard (Settings \u2192 API keys), then:\n\n```bash\nclaude mcp add flusterduck \\\n --env FLUSTERDUCK_MCP_KEY=fd_mcp_xxxxxxxxxxxx \\\n --env FLUSTERDUCK_SITE_ID=your-site-id \\\n -- npx -y @flusterduck/mcp-server\n```\n\n## Setup\n\n### Claude Desktop\n\nAdd to `~/Library/Application Support/Claude/claude_desktop_config.json` (npx pulls the server automatically, nothing to install first):\n\n```json\n{\n "mcpServers": {\n "flusterduck": {\n "command": "npx",\n "args": ["-y", "@flusterduck/mcp-server"],\n "env": {\n "FLUSTERDUCK_MCP_KEY": "fd_mcp_xxxxxxxxxxxx",\n "FLUSTERDUCK_SITE_ID": "7f2c9d4a-4b5e-4c3a-9b1d-2e6a4f8c7d5b"\n }\n }\n }\n}\n```\n\nRestart Claude Desktop. Tools are available immediately.\n\n### Claude Code CLI\n\n```bash\nclaude mcp add flusterduck \\\n --env FLUSTERDUCK_MCP_KEY=fd_mcp_xxxxxxxxxxxx \\\n --env FLUSTERDUCK_SITE_ID=your-site-id \\\n -- npx -y @flusterduck/mcp-server\n```\n\nOr add directly to `~/.claude/settings.json`:\n\n```json\n{\n "mcpServers": {\n "flusterduck": {\n "command": "npx",\n "args": ["-y", "@flusterduck/mcp-server"],\n "env": {\n "FLUSTERDUCK_MCP_KEY": "fd_mcp_xxxxxxxxxxxx",\n "FLUSTERDUCK_SITE_ID": "7f2c9d4a-4b5e-4c3a-9b1d-2e6a4f8c7d5b"\n }\n }\n }\n}\n```\n\n### Cursor\n\n`~/.cursor/mcp.json` (global) or `.cursor/mcp.json` in the project root:\n\n```json\n{\n "mcpServers": {\n "flusterduck": {\n "command": "npx",\n "args": ["-y", "@flusterduck/mcp-server"],\n "env": {\n "FLUSTERDUCK_MCP_KEY": "fd_mcp_xxxxxxxxxxxx",\n "FLUSTERDUCK_SITE_ID": "7f2c9d4a-4b5e-4c3a-9b1d-2e6a4f8c7d5b"\n }\n }\n }\n}\n```\n\nReload the Cursor window after saving.\n\n### Windsurf\n\n`~/.codeium/windsurf/mcp_config.json`:\n\n```json\n{\n "mcpServers": {\n "flusterduck": {\n "command": "npx",\n "args": ["-y", "@flusterduck/mcp-server"],\n "env": {\n "FLUSTERDUCK_MCP_KEY": "fd_mcp_xxxxxxxxxxxx",\n "FLUSTERDUCK_SITE_ID": "7f2c9d4a-4b5e-4c3a-9b1d-2e6a4f8c7d5b"\n }\n }\n }\n}\n```\n\n### VS Code (GitHub Copilot)\n\n`.vscode/mcp.json` in the workspace:\n\n```json\n{\n "servers": {\n "flusterduck": {\n "type": "stdio",\n "command": "npx",\n "args": ["-y", "@flusterduck/mcp-server"],\n "env": {\n "FLUSTERDUCK_MCP_KEY": "fd_mcp_xxxxxxxxxxxx",\n "FLUSTERDUCK_SITE_ID": "7f2c9d4a-4b5e-4c3a-9b1d-2e6a4f8c7d5b"\n }\n }\n }\n}\n```\n\n### Remote server\n\nPoint your client at `https://mcp.flusterduck.com/mcp`. There is nothing to copy: the first connection opens a consent page, you sign in with your Flusterduck account, and access is granted through OAuth 2.1 with PKCE. Disconnecting from your MCP client revokes access immediately.\n\n## Keys\n\nThe hosted server never needs a key; sign-in handles it. MCP keys (`fd_mcp_`, created under Settings > API Keys) are only for the local stdio server.\n\n## MCP or CLI?\n\nBoth surfaces expose the same data; pick by where the agent runs. MCP is right for interactive assistants (Claude Desktop, Cursor, Claude Code sessions): persistent connection, tools, and the multi-step prompts below. The [CLI](./cli) is right for CI jobs and shell scripts: `npx flusterduck-cli issues --site <id> --json` for reads, `issue resolve|ignore|reopen|start` to manage issues, and `deploy notify` to record deploys. Every command takes `--json`, so agents can parse the output directly.\n\n**Read-only** (`mcp:read`): all query tools and prompts. Can\'t write anything. Start here.\n\n**Read + write** (`mcp:read` + `manage:write`): adds `update_issue`, `update_alert`, `add_annotation`, `create_alert_rule`, `update_alert_rule`, and `delete_alert_rule`. Needed for triage and annotation workflows.\n\n## Prompts\n\nThe prompts are the highest-value part. They\'re multi-step workflows built into the server. The AI calls several tools in sequence and synthesizes the result into a useful answer. Use prompts for the common cases; use raw tools when you need something specific.\n\n### post_deploy_check\n\nFinds your most recent deploy, compares confusion scores before and after, identifies pages where friction spiked, and returns PASS / WARN / FAIL. Writes an annotation automatically on FAIL.\n\nRun this after every deploy. Takes about 30 seconds.\n\n### triage_open_issues\n\nScans open issues by signal count, moves each to the right status (`triaged`, `in_progress`, or `ignored`), adds a triage note to each, and writes a summary annotation. Requires `manage:write`.\n\nMost useful first thing Monday morning before standup.\n\n### diagnose_page\n\nFive-step diagnosis for a specific page. Returns: current friction score, top signal sources by volume, worst-performing element, likely root cause, and one concrete fix recommendation.\n\n```\npage_path: "/settings/billing"\n```\n\n### investigate_session\n\nFull investigation of a single session: event timeline in order, dominant signal types, matching open issues, and a verdict on whether the session represents a recurring pattern or an outlier.\n\n```\nsession_id: "ses_xxxxxxxxxxxx"\n```\n\n### weekly_summary\n\nA weekly UX friction digest formatted for a PM or eng lead: score trends, newly opened issues, open alerts, revenue at risk, top three recommendations. Under 300 words.\n\nGood to run before Monday standup.\n\n## What a real investigation looks like\n\nYou ask: "Did the deploy yesterday break anything on checkout?"\n\nThe AI runs this automatically:\n\n1. `get_deploys`: finds yesterday\'s deploy and its timestamp\n2. `get_page` with `/checkout`: sees the confusion score jumped from 14 to 38 in the hour after\n3. `get_issues` filtered to `open`: two issues opened within 90 minutes of the deploy, a rage click spike on `#place-order` and elevated form abandonment on the payment step\n4. `get_elements` scoped to `/checkout`: `#place-order` has 52 rage clicks in 24 hours, baseline is 5/day\n5. `diagnose_page`: returns root cause. The button appears inactive during payment processing with no visual indication, causing repeated clicking.\n6. `add_annotation`: "Deploy 2026-06-09: /checkout confusion +171%. #place-order rage clicks 10x baseline. Root cause: missing loading state on payment submit."\n\nThe whole sequence runs in about 30 seconds and leaves a permanent timeline marker.\n\n## Read tools\n\nAll require `mcp:read` scope. Start with `get_site_context`. It gives you the full picture in one call and tells you where to dig next.\n\n**Documentation**: the full Flusterduck docs are bundled into the server, so your assistant can read them before acting on data.\n\n- `list_docs`: Lists every documentation page (slug, title, group). Start here to see what\'s available.\n- `search_docs`: Full-text search across all docs. Pass `query`; returns matching pages with excerpts.\n- `get_doc`: Returns a full documentation page by `slug` (e.g. `alerts`, `scoring`, `webhooks`, `react`, `mcp`). Read a feature\'s page before acting. For example, read `alerts` before creating a rule to understand threshold semantics.\n\n**Start here**\n\n- `get_site_context`: Full snapshot of scores, open issues, active alerts, recent deploys, and top recommendations. The right first call for any investigation.\n- `get_scores`: Every tracked page ranked by current confusion score.\n- `get_recommendations`: Prioritized fix list ranked by estimated confusion reduction.\n\n**Issues and alerts**\n\n- `get_issues`: All UX issues. Filter by status: `open`, `triaged`, `in_progress`, `verified`, `resolved`, `regressed`.\n- `get_issue`: Full detail on one issue, including evidence, session links, verification history, and deploy correlation.\n- `get_alerts`: Active and resolved alerts. Filter by `open`, `acknowledged`, or `resolved`.\n- `list_alert_rules`: All configured rules with thresholds, channels, and enabled state. Call this before creating or modifying rules.\n\n**Page deep-dives**\n\n- `get_page`: Score history, active issues, element friction, annotations, and confusion budget for one page. Pass `page: "/checkout"`.\n- `get_elements`: Element-level breakdown showing which buttons, forms, and links generate the most signals. Scope by `page` or pull site-wide.\n- `get_trends`: Confusion score over time. Pass `days` (1-90) and optionally `page`.\n- `compare_pages`: Side-by-side confusion score comparison. Pass `a` and `b` as page paths.\n- `diagnose_journey_friction`: High-friction navigation edges from recent sessions. Filter by `signal_type` or `min_friction_weight`.\n\n**Sessions and raw data**\n\n- `get_session_detail`: Full event timeline for one session in chronological order.\n- `get_heuristics`: The complete signal catalog with all 33 types, scoring weights, and thresholds.\n- `query_raw_rows`: SQL-style access to allowlisted tables (`events`, `signals`, `sessions`, `page_scores`, `score_history`, `ux_issues`, `alerts`, `deploys`).\n- `download_events_csv`: Raw event export as CSV.\n- `explore`: A deterministic, typed query engine over session data. No natural language, no LLM, built explicitly for agents to answer ad-hoc questions the rest of the surface doesn\'t cover directly. Pick a `window_days` (1-90, default 7), up to 12 AND-ed `filters` over `signal`, `page`, `source`, `confused`, `converted`, `event_type` (ops `is` / `is_not` / `contains`, contains only on `page` and `source`), and exactly one `output`: `{mode: "list"}` to pull matching sessions, or `{mode: "measure", metric, group_by?}` to compute `count`, `avg_pageviews`, `conversion_rate`, `avg_dwell_ms`, or `bounce_rate` as a scalar or, with `group_by` (`page`, `source`, `day`, `signal`, `cohort`), a series. See "Explore via MCP" below for a list and a measure example.\n\n**Deploy correlation**\n\n- `get_deploys`: All deploys Flusterduck knows about, with `confusion_before` and `confusion_after` populated for deploys older than 5 minutes.\n- `get_revenue_impact`: Revenue impact estimates for active friction. Only populated when conversion tracking is wired.\n- `get_flows`: Page-to-page navigation edges from recent sessions.\n\n**Conversion impact**\n\n- `get_conversion_insights`: The confused-vs-calm conversion analysis: how much less confused sessions convert than calm ones, broken down by page and traffic source, plus ranked narratable insights. Pass `days` (1-90, default 7). Needs a conversion event wired; see [Conversion trigger](./conversion-trigger).\n\n**Org-level** (requires org-scoped key)\n\n- `get_audit_log`: Organization audit log.\n- `get_degradation`: Active and recent backend degradation events. Also available to site-scoped keys, since degradation is operational health rather than tenant data.\n- `get_webhook_deliveries`: Outbound webhook delivery history and failure details.\n\n## Explore via MCP\n\n`explore` is the odd one out on purpose: everything else in this list is a fixed shape (scores, issues, trends), but `explore` is a small closed query language over session data: a window, up to 12 AND-ed filters, and one output mode. Still fully deterministic and validated against the same allowlists the dashboard uses; there\'s no free-text query and no model in the loop.\n\n**List**: sessions that rage-clicked on `/pricing` in the last 7 days:\n\n```\nwindow_days: 7\nfilters: [\n { "field": "page", "op": "is", "value": "/pricing" },\n { "field": "signal", "op": "is", "value": "rage_click" }\n]\noutput: { "mode": "list", "limit": 20 }\n```\n\nReturns up to 20 matching sessions, most recent first, each with its page list, signal counts, source, and confused/converted flags. Use this to cite concrete example sessions as evidence in a diagnosis.\n\n**Measure**: does confusion actually cost this site conversions?\n\n```\nwindow_days: 30\noutput: { "mode": "measure", "metric": "conversion_rate", "group_by": "cohort" }\n```\n\nReturns conversion rate as a two-point series: the `confused` cohort (sessions with at least one friction signal) versus the `calm` cohort. Swap `group_by` for `page`, `source`, `day`, or `signal` to see the same metric broken down a different way, or drop `group_by` entirely for a single scalar across all matching sessions.\n\n## Write tools\n\nAll require `manage:write` scope.\n\n**`update_issue`**: Change status, add a triage note, or assign an issue.\n\n```\nissue_id: "iss_3a7f2c9d4e1b"\nstatus: "triaged"\nnote: "Confirmed on Safari iOS 17. Disabled-state styling on #place-order doesn\'t communicate that payment is processing."\nassigned_to: "eng-lead"\n```\n\nStatus options: `open`, `triaged`, `in_progress`, `verified`, `resolved`, `ignored`.\n\n**`update_alert`**: Acknowledge, mark investigating, or resolve an alert.\n\n```\nalert_id: "alt_9b5e1f4c2d8a"\nstatus: "resolved"\nresolved_reason: "Deploy 2026-06-09 added a spinner and disabled state to the payment submit button."\n```\n\n**`add_annotation`**: Write a timeline marker visible to the whole team.\n\n```\nmessage: "Redesigned billing flow launched. Monitoring confusion score on /settings/billing."\n```\n\n**`create_alert_rule`**: Create a new alert rule.\n\n```\nname: "Checkout rage click spike"\ntrigger_type: "spike"\nthreshold: 25\ncooldown_minutes: 60\nchannels: ["email", "slack"]\npage_pattern: "/checkout*"\n```\n\n**`update_alert_rule`**: Update an existing rule. Use `enabled: false` to silence it temporarily rather than deleting.\n\n**`delete_alert_rule`**: Permanently remove a rule. Can\'t be undone.\n\n## Questions that work well\n\n- What\'s broken on checkout right now?\n- Did the last deploy make things worse?\n- Which page has the most dead clicks this week?\n- Triage the open issues and tell me what to fix first.\n- Write a friction summary for the PM standup.\n- Which sessions show rage clicks on the upgrade button?\n- How does this week\'s confusion score compare to last week?\n- Create a spike alert for the pricing page and notify Slack.\n- What\'s the revenue impact of the current open issues?\n- Which elements on `/onboarding` are generating the most friction?\n'
|
|
539
546
|
},
|
|
540
547
|
{
|
|
541
548
|
"slug": "api",
|
|
@@ -549,7 +556,7 @@ Session limits are pooled across all sites in the org, not per-site. All plans i
|
|
|
549
556
|
"title": "Webhooks",
|
|
550
557
|
"description": "Push events to your own server as they happen, signed and retried.",
|
|
551
558
|
"group": "Integrations",
|
|
552
|
-
"content": '# Webhooks\n\nFlusterduck can push events to your own server as they happen. Configure a webhook endpoint in the dashboard under Settings > Integrations > Webhooks.\n\n## Events\n\n| Event | Fires when |\n|---|---|\n| `issue.created` | A new UX issue is detected |\n| `issue.updated` | An issue\'s status changes (triaged, resolved, regressed, etc.) |\n| `alert.triggered` | An alert rule fires |\n| `alert.acknowledged` | An alert is acknowledged |\n| `alert.resolved` | An alert is resolved |\n| `score.spike` | A page confusion score increases sharply |\n| `deploy.recorded` | A deploy is recorded and scored |\n\n## Payload shape\n\nEvery webhook delivery is a POST request with a JSON body:\n\n```json\n{\n "event": "issue.created",\n "site_id": "7f2c9d4a-4b5e-4c3a-9b1d-2e6a4f8c7d5b",\n "timestamp": "2026-06-10T14:22:05Z",\n "data": { ... }\n}\n```\n\nThe `data` object contains the full resource that changed. For `issue.created` it\'s the full issue object. For `alert.triggered` it\'s the full alert with the rule that fired it.\n\n### issue.created\n\n```json\n{\n "event": "issue.created",\n "site_id": "7f2c9d4a-4b5e-4c3a-9b1d-2e6a4f8c7d5b",\n "timestamp": "2026-06-10T14:22:05Z",\n "data": {\n "id": "iss_3a7f2c9d4e1b",\n "title": "Dead clicks on upgrade button",\n "page": "/pricing",\n "selector": "[data-cta=\'upgrade\']",\n "signal_type": "dead_click",\n "signal_count": 47,\n "severity": 72,\n "status": "open",\n "created_at": "2026-06-10T14:22:05Z"\n }\n}\n```\n\n### alert.triggered\n\n```json\n{\n "event": "alert.triggered",\n "site_id": "7f2c9d4a-4b5e-4c3a-9b1d-2e6a4f8c7d5b",\n "timestamp": "2026-06-10T14:22:05Z",\n "data": {\n "id": "alt_9b5e1f4c2d8a",\n "rule_id": "rul_4c8b2e7a5f1d",\n "rule_name": "Checkout rage click spike",\n "page": "/checkout",\n "trigger_type": "spike",\n "score_before": 31,\n "score_after": 68,\n "status": "open",\n "triggered_at": "2026-06-10T14:22:05Z"\n }\n}\n```\n\n### deploy.recorded\n\n```json\n{\n "event": "deploy.recorded",\n "site_id": "7f2c9d4a-4b5e-4c3a-9b1d-2e6a4f8c7d5b",\n "timestamp": "2026-06-10T14:22:05Z",\n "data": {\n "id": "dep_6e3d1a8f7c2b",\n "version": "2026.06.10",\n "environment": "production",\n "confusion_before": 42,\n "confusion_after": 31,\n "recorded_at": "2026-06-10T14:22:05Z"\n }\n}\n```\n\n## Signature verification\n\nEvery webhook delivery includes two headers:\n\n```\nX-Flusterduck-Signature: sha256=<hex>\nX-Flusterduck-Timestamp: <unix timestamp seconds>\n```\n\nThe signature is HMAC-SHA256 over `timestamp.raw_body` using your webhook secret. Verify it on your server before trusting the payload.\n\n### Node.js (Express)\n\n```ts\nimport crypto from \'crypto\'\nimport express from \'express\'\n\nconst app = express()\n\napp.post(\'/webhooks/flusterduck\', express.raw({ type: \'application/json\' }), (req, res) => {\n const signature = req.headers[\'x-flusterduck-signature\'] as string\n const timestamp = req.headers[\'x-flusterduck-timestamp\'] as string\n\n if (!verifySignature(req.body, timestamp, signature)) {\n return res.status(401).send(\'Invalid signature\')\n }\n\n const event = JSON.parse(req.body.toString())\n // handle event...\n\n res.status(200).send(\'ok\')\n})\n\nfunction verifySignature(body: Buffer, timestamp: string, signature: string): boolean {\n const secret = process.env.FLUSTERDUCK_WEBHOOK_SECRET!\n const payload = `${timestamp}.${body.toString()}`\n const expected = \'sha256=\' + crypto\n .createHmac(\'sha256\', secret)\n .update(payload)\n .digest(\'hex\')\n\n // Constant-time comparison prevents timing attacks\n return crypto.timingSafeEqual(\n Buffer.from(signature),\n Buffer.from(expected)\n )\n}\n```\n\n### Next.js API route (App Router)\n\n```ts\n// app/api/webhooks/flusterduck/route.ts\nimport crypto from \'crypto\'\nimport { NextRequest, NextResponse } from \'next/server\'\n\nexport async function POST(req: NextRequest) {\n const body = await req.text()\n const signature = req.headers.get(\'x-flusterduck-signature\') ?? \'\'\n const timestamp = req.headers.get(\'x-flusterduck-timestamp\') ?? \'\'\n\n const secret = process.env.FLUSTERDUCK_WEBHOOK_SECRET!\n const payload = `${timestamp}.${body}`\n const expected = \'sha256=\' + crypto\n .createHmac(\'sha256\', secret)\n .update(payload)\n .digest(\'hex\')\n\n const valid = crypto.timingSafeEqual(\n Buffer.from(signature),\n Buffer.from(expected)\n )\n\n if (!valid) {\n return NextResponse.json({ error: \'Invalid signature\' }, { status: 401 })\n }\n\n const event = JSON.parse(body)\n // handle event...\n\n return NextResponse.json({ received: true })\n}\n```\n\n### Replay protection\n\nThe timestamp in `X-Flusterduck-Timestamp` is Unix seconds. Reject requests where the timestamp is more than 5 minutes old:\n\n```ts\nconst fiveMinutes = 5 * 60\nconst age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10)\nif (age > fiveMinutes) {\n return res.status(401).send(\'Request too old\')\n}\n```\n\n## Retry behavior\n\nFailed deliveries (non-2xx response or timeout) are retried up to 5 times with exponential backoff:\n\n| Attempt | Delay |\n|---|---|\n| 1 | Immediate |\n| 2 | 1 minute |\n| 3 | 5 minutes |\n| 4 | 30 minutes |\n| 5 | 2 hours |\n\nAfter 5 failures the delivery is marked failed and won\'t be retried automatically. View failed deliveries and retry them manually under Settings > Integrations > Webhooks > Delivery History.\n\nDeliveries are deduplicated: the same event won\'t be delivered twice to the same endpoint within a 10-minute window, even across retries.\n\n## Testing\n\nSend a test event from the dashboard to verify your endpoint before going live. It uses the same signature mechanism as live events.\n\nYour endpoint must return a 2xx status within 10 seconds. Longer than that counts as a timeout and is treated as a failure.\n'
|
|
559
|
+
"content": '# Webhooks\n\nFlusterduck can push events to your own server as they happen. Configure a webhook endpoint in the dashboard under Settings > Integrations > Webhooks.\n\n## Events\n\n| Event | Fires when |\n|---|---|\n| `issue.created` | A new UX issue is detected |\n| `issue.updated` | An issue\'s status changes (triaged, resolved, regressed, etc.) |\n| `issue.verified` | A fix is measured working after a deploy; carries the affected user refs for the [resolution loop](/resolution-loop) |\n| `alert.triggered` | An alert rule fires |\n| `alert.acknowledged` | An alert is acknowledged |\n| `alert.resolved` | An alert is resolved |\n| `score.spike` | A page confusion score increases sharply |\n| `deploy.recorded` | A deploy is recorded and scored |\n\n## Payload shape\n\nEvery webhook delivery is a POST request with a JSON body:\n\n```json\n{\n "event": "issue.created",\n "site_id": "7f2c9d4a-4b5e-4c3a-9b1d-2e6a4f8c7d5b",\n "timestamp": "2026-06-10T14:22:05Z",\n "data": { ... }\n}\n```\n\nThe `data` object contains the full resource that changed. For `issue.created` it\'s the full issue object. For `alert.triggered` it\'s the full alert with the rule that fired it.\n\n### issue.created\n\n```json\n{\n "event": "issue.created",\n "site_id": "7f2c9d4a-4b5e-4c3a-9b1d-2e6a4f8c7d5b",\n "timestamp": "2026-06-10T14:22:05Z",\n "data": {\n "id": "iss_3a7f2c9d4e1b",\n "title": "Dead clicks on upgrade button",\n "page": "/pricing",\n "selector": "[data-cta=\'upgrade\']",\n "signal_type": "dead_click",\n "signal_count": 47,\n "severity": 72,\n "status": "open",\n "created_at": "2026-06-10T14:22:05Z"\n }\n}\n```\n\n### alert.triggered\n\n```json\n{\n "event": "alert.triggered",\n "site_id": "7f2c9d4a-4b5e-4c3a-9b1d-2e6a4f8c7d5b",\n "timestamp": "2026-06-10T14:22:05Z",\n "data": {\n "id": "alt_9b5e1f4c2d8a",\n "rule_id": "rul_4c8b2e7a5f1d",\n "rule_name": "Checkout rage click spike",\n "page": "/checkout",\n "trigger_type": "spike",\n "score_before": 31,\n "score_after": 68,\n "status": "open",\n "triggered_at": "2026-06-10T14:22:05Z"\n }\n}\n```\n\n### deploy.recorded\n\n```json\n{\n "event": "deploy.recorded",\n "site_id": "7f2c9d4a-4b5e-4c3a-9b1d-2e6a4f8c7d5b",\n "timestamp": "2026-06-10T14:22:05Z",\n "data": {\n "id": "dep_6e3d1a8f7c2b",\n "version": "2026.06.10",\n "environment": "production",\n "confusion_before": 42,\n "confusion_after": 31,\n "recorded_at": "2026-06-10T14:22:05Z"\n }\n}\n```\n\n## Signature verification\n\nEvery webhook delivery includes two headers:\n\n```\nX-Flusterduck-Signature: sha256=<hex>\nX-Flusterduck-Timestamp: <unix timestamp seconds>\n```\n\nThe signature is HMAC-SHA256 over `timestamp.raw_body` using your webhook secret. Verify it on your server before trusting the payload.\n\n### Node.js (Express)\n\n```ts\nimport crypto from \'crypto\'\nimport express from \'express\'\n\nconst app = express()\n\napp.post(\'/webhooks/flusterduck\', express.raw({ type: \'application/json\' }), (req, res) => {\n const signature = req.headers[\'x-flusterduck-signature\'] as string\n const timestamp = req.headers[\'x-flusterduck-timestamp\'] as string\n\n if (!verifySignature(req.body, timestamp, signature)) {\n return res.status(401).send(\'Invalid signature\')\n }\n\n const event = JSON.parse(req.body.toString())\n // handle event...\n\n res.status(200).send(\'ok\')\n})\n\nfunction verifySignature(body: Buffer, timestamp: string, signature: string): boolean {\n const secret = process.env.FLUSTERDUCK_WEBHOOK_SECRET!\n const payload = `${timestamp}.${body.toString()}`\n const expected = \'sha256=\' + crypto\n .createHmac(\'sha256\', secret)\n .update(payload)\n .digest(\'hex\')\n\n // Constant-time comparison prevents timing attacks\n return crypto.timingSafeEqual(\n Buffer.from(signature),\n Buffer.from(expected)\n )\n}\n```\n\n### Next.js API route (App Router)\n\n```ts\n// app/api/webhooks/flusterduck/route.ts\nimport crypto from \'crypto\'\nimport { NextRequest, NextResponse } from \'next/server\'\n\nexport async function POST(req: NextRequest) {\n const body = await req.text()\n const signature = req.headers.get(\'x-flusterduck-signature\') ?? \'\'\n const timestamp = req.headers.get(\'x-flusterduck-timestamp\') ?? \'\'\n\n const secret = process.env.FLUSTERDUCK_WEBHOOK_SECRET!\n const payload = `${timestamp}.${body}`\n const expected = \'sha256=\' + crypto\n .createHmac(\'sha256\', secret)\n .update(payload)\n .digest(\'hex\')\n\n const valid = crypto.timingSafeEqual(\n Buffer.from(signature),\n Buffer.from(expected)\n )\n\n if (!valid) {\n return NextResponse.json({ error: \'Invalid signature\' }, { status: 401 })\n }\n\n const event = JSON.parse(body)\n // handle event...\n\n return NextResponse.json({ received: true })\n}\n```\n\n### Replay protection\n\nThe timestamp in `X-Flusterduck-Timestamp` is Unix seconds. Reject requests where the timestamp is more than 5 minutes old:\n\n```ts\nconst fiveMinutes = 5 * 60\nconst age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10)\nif (age > fiveMinutes) {\n return res.status(401).send(\'Request too old\')\n}\n```\n\n## Retry behavior\n\nFailed deliveries (non-2xx response or timeout) are retried up to 5 times with exponential backoff:\n\n| Attempt | Delay |\n|---|---|\n| 1 | Immediate |\n| 2 | 1 minute |\n| 3 | 5 minutes |\n| 4 | 30 minutes |\n| 5 | 2 hours |\n\nAfter 5 failures the delivery is marked failed and won\'t be retried automatically. View failed deliveries and retry them manually under Settings > Integrations > Webhooks > Delivery History.\n\nDeliveries are deduplicated: the same event won\'t be delivered twice to the same endpoint within a 10-minute window, even across retries.\n\n## Testing\n\nSend a test event from the dashboard to verify your endpoint before going live. It uses the same signature mechanism as live events.\n\nYour endpoint must return a 2xx status within 10 seconds. Longer than that counts as a timeout and is treated as a failure.\n'
|
|
553
560
|
},
|
|
554
561
|
{
|
|
555
562
|
"slug": "slack",
|
|
@@ -591,7 +598,7 @@ Session limits are pooled across all sites in the org, not per-site. All plans i
|
|
|
591
598
|
"title": "Security",
|
|
592
599
|
"description": "Collection rules, data access boundaries, hashing, input limits, and the OWASP checklist.",
|
|
593
600
|
"group": "Privacy & security",
|
|
594
|
-
"content": "# Security\n\nFlusterduck collects behavioral signals, not private content. This page covers the security model: what leaves the browser, how authentication and authorization work, how keys are protected, where rate limits sit, and what the database enforces. If you're running a security review, this is your reference
|
|
601
|
+
"content": "# Security\n\nFlusterduck collects behavioral signals, not private content. This page covers the security model: what leaves the browser, how authentication and authorization work, how keys are protected, where rate limits sit, and what the database enforces. If you're running a security review, this is your reference, and if you have a question it doesn't answer, ask us at support@flusterduck.com.\n\n## What leaves the browser, and what never does\n\nThe SDK captures behavioral patterns: clicks, scroll depth, cursor movement, timing between interactions, and the **visible label** of an element a user interacted with (button text, a heading, an aria-label) so traces read in plain language: \"Rage click on *Apply coupon*\" instead of a bare CSS selector. Labels are PII-redacted **in the browser before anything is sent**: email addresses, phone numbers, card-length digit runs, and web addresses are replaced with `[redacted]` in place. A second, server-side redaction pass runs at ingest as a backstop.\n\nWhat is never collected, by architecture rather than configuration:\n\n- No session replay or screen recording\n- No DOM recording or page-content capture from visitors' sessions\n- No form values, no user-entered text, no keystrokes\n- No passwords, tokens, or credentials\n- No raw IP addresses: hashed at the edge before storage, originals never written\n\n## Authentication\n\nFour distinct authentication paths exist, each scoped to the surface it protects.\n\n### Publishable keys (`fd_pub_`)\n\nThe browser SDK authenticates with a publishable key included in each event batch. The ingest endpoint validates the key against a salted HMAC-SHA256 hash (raw keys are never stored) and rejects batches for inactive sites, lapsed billing, or expired trials. Publishable keys can only write events. They can never read scores, issues, or any other data.\n\n### User JWTs\n\nThe dashboard authenticates via Supabase Auth JWTs, validated server-side against the auth server on every request, so a stolen session cookie with an expired token is caught rather than trusted. After validation, every route independently checks org membership and role; admin-only operations require the `owner` or `admin` role.\n\n### Secret keys (`fd_sec_`)\n\nServer-side integrations use secret keys with the read and write APIs. Each key carries explicit scopes (`query:read`, `manage:write`, `webhook:write`), a per-key rate limit, and an optional IP allowlist with CIDR support. Revoked, expired, mis-scoped, or wrong-IP requests fail closed.\n\n### MCP keys (`fd_mcp_`)\n\nMCP keys work like secret keys but carry only the `mcp:read` scope. They give AI assistants read access to scores and issues through the hosted MCP server, never write access.\n\n## Authorization\n\nAuthentication proves identity; authorization proves access. Every read route verifies that the caller's organization owns the requested site before returning a byte. Every write route checks org membership with the appropriate role: viewers can't modify anything; members can update issues but can't create sites or keys. Users can't change their own role or remove themselves from an org. Org creation and admin counts are capped. Management actions are recorded in an audit log with hashed IP and user-agent for forensics.\n\n## Rate limits\n\nEvery endpoint enforces rate limits through an atomic, database-backed counter. When a limit is hit, the API returns HTTP 429 with a `reset_at` timestamp.\n\n| Surface | Limit | Window |\n|---------|-------|--------|\n| Event ingestion | 10,000 requests | 60 seconds, per environment |\n| Read API | 100 requests (or per-key custom RPM) | 60 seconds, per org + caller |\n| Write API | 30 requests | 60 seconds, per actor |\n| Waitlist | 5 requests | 15 minutes, per IP |\n| Webhook delivery | 60 requests | 60 seconds |\n\nAPI keys can carry a custom per-key limit, which replaces the default on the read API.\n\n## Cryptography\n\n- **API keys** are never stored in plaintext. Keys are hashed with HMAC-SHA256 using a server-side salt that exists only as a deployment secret. There is no hardcoded fallback; if the secret is absent the system refuses to operate rather than degrade.\n- **IP addresses** are hashed with a separate salted HMAC and truncated before storage. The original IP is never written to any table.\n- **All key and signature comparisons are constant-time.** No comparison short-circuits on the first mismatched byte, so timing attacks yield nothing.\n- **Outbound webhooks** are signed with HMAC-SHA256 over `{timestamp}.{payload}`, giving you both integrity and replay protection: verification rejects signatures older than 300 seconds. Your endpoint secret is shown once at creation and stored encrypted.\n- **Slack requests** are verified with Slack's `v0` signing scheme and a 5-minute timestamp tolerance.\n- **Integration credentials and webhook secrets** are encrypted at rest with AES-256-GCM, each value under its own random IV, with key material held only as a deployment secret.\n\n## Database isolation\n\nBrowser clients cannot query the database. This is enforced in layers, so no single mistake can expose data:\n\n1. **No grants.** Browser-facing database roles hold no privileges on any product table, sequence, or function. The only thing a browser can do against the database layer is authenticate.\n2. **Deny-all row-level security.** RLS is enabled on every table, and product tables carry explicit deny-all policies for client roles, so even if a grant were ever mistakenly issued, the policy layer still returns nothing.\n3. **Future-proofing.** Default privileges are configured so tables and functions added later inherit the same lockdown automatically, rather than depending on someone remembering.\n4. **All access flows through the API.** Reads and writes happen exclusively in server-side edge functions that authenticate the caller, verify org ownership, and query with service credentials. Where the dashboard needs key metadata, it reads through views that structurally exclude secret material (key hashes are not selectable, even by authenticated org members).\n5. **Authorization helpers are hardened.** The functions that back org-scoping run with pinned search paths to prevent search-path injection.\n\n## Input validation\n\n- Request bodies are capped at 64KB on every endpoint, enforced on the compressed size *and* re-checked after decompression, so compressed payloads can't smuggle larger bodies in.\n- User-controlled strings are sanitized (HTML/script metacharacters stripped) and length-capped before storage.\n- Every UUID parameter is validated against a strict format before any query runs.\n- Enumerated fields (trigger types, notification channels, member roles, issue statuses, key scopes) are validated against fixed allowlists; unknown values are rejected, not stored.\n- Numeric parameters are clamped to safe ranges with sane defaults.\n\n## Content Security Policy\n\nThe web app generates a per-request CSP with a cryptographically random nonce (16 bytes, never reused). Inline scripts without the nonce are blocked; network calls are restricted to same-origin and our auth endpoint with no wildcards; framing is denied entirely (`frame-ancestors 'none'`), and plugin/worker execution is blocked. `upgrade-insecure-requests` is set in production.\n\n## Audit logging\n\nManagement actions are recorded with actor identity (user or API key), action and target, hashed IP, hashed user-agent, and operation metadata. Nothing in the audit trail contains raw network identifiers.\n\n## Browser SDK hygiene\n\nUse attributes or stable selectors for signal context. Don't send private user data in metadata.\n\n```ts\nsignal('dead_click', {\n page: '/checkout',\n selector: '[data-action=\"continue\"]',\n})\n```\n\nDon't do this:\n\n```ts\ntrack('checkout_note', {\n email: user.email,\n message: form.message,\n})\n```\n\nPayloads are scrubbed at ingest as a backstop: keys that look like credentials or personal fields are dropped, and element labels are PII-redacted. But the safest data is data you never send.\n\n## Network-level controls\n\n- API keys support IP allowlists with CIDR notation; requests from outside the allowlist get a 403.\n- Customer webhook URLs are validated against internal, private, and metadata address ranges before any delivery, redirects are refused outright, and the same checks re-run at delivery time, so a webhook endpoint can never be used to probe internal networks.\n- The site scanner's fetches run through the same public-address validation. AI-diagnosis page fetches are made by our rendering provider against the site's own registered public URL, never an address derived from visitor input.\n- Unauthenticated browser navigations redirect to login; unauthenticated API fetches receive a 401. The middleware distinguishes the two by fetch metadata, so APIs never leak redirect chains.\n\n## Reporting a vulnerability\n\nWe publish a `security.txt` at [flusterduck.com/.well-known/security.txt](https://flusterduck.com/.well-known/security.txt). If you find something, tell us at support@flusterduck.com. We read every report and fix what's real, regardless of severity.\n"
|
|
595
602
|
},
|
|
596
603
|
{
|
|
597
604
|
"slug": "pkg-sdk",
|
|
@@ -605,7 +612,7 @@ Session limits are pooled across all sites in the org, not per-site. All plans i
|
|
|
605
612
|
"title": "flusterduck-cli",
|
|
606
613
|
"description": "CLI that detects your framework, installs, and wires up init.",
|
|
607
614
|
"group": "Packages",
|
|
608
|
-
"content": '# flusterduck-cli\n\nThe command line for Flusterduck: install the SDK, read scores and issues, manage issues, and record deploys
|
|
615
|
+
"content": '# flusterduck-cli\n\nThe command line for Flusterduck: install the SDK, read scores and issues, manage issues, and record deploys, from your terminal or CI.\n\n## Install\n\n```bash\nnpx flusterduck-cli init\n```\n\nNo global install is required. Run it from your project root with `npx`.\n\n## Setup\n\n```bash\n# Detect framework, sign in with your browser, pick or create a site,\n# install packages, inject init, then verify the first event arrives.\nnpx flusterduck-cli init\n\n# Provide your publishable key non-interactively (CI, scripts).\nnpx flusterduck-cli init --key fd_pub_xxxxxxxxxxxx\n```\n\nConfirm data is flowing anytime:\n\n```bash\nnpx flusterduck-cli status --key fd_pub_xxxxxxxxxxxx --wait\n```\n\nThe CLI inspects your `package.json` and project files, picks the matching wrapper (React, Next.js, Vue, Svelte, Nuxt, or the core SDK), installs it with your package manager, and wires up initialization in the correct entry point.\n\n## Sign in once\n\nThe binary answers to both `flusterduck` and `duck`. Run `duck login` to verify and save your `fd_sec_` key locally (0600 file, readable only by you). After that, no command needs `--key`, and a site-scoped key makes `--site` optional too. `duck logout` removes it. Explicit `--key` / `FLUSTERDUCK_SECRET_KEY` always take precedence.\n\n## Read\n\nAdd `--json` to any command for machine-readable output.\n\n```bash\nduck scores # site remembered from login\nduck scores --site <site_id> # or explicit\nnpx flusterduck-cli issues --site <site_id> --status open --limit 20\nnpx flusterduck-cli insights --site <site_id> --days 30\n```\n\n## Manage\n\n```bash\nnpx flusterduck-cli issue resolve <issue_id> --note "Fixed in #142"\nnpx flusterduck-cli issue ignore <issue_id> --note "Third-party widget"\nnpx flusterduck-cli issue reopen <issue_id>\nnpx flusterduck-cli issue start <issue_id> # moves it to in progress\n```\n\n## Record deploys\n\n```bash\nnpx flusterduck-cli deploy notify --site <site_id>\n```\n\nRun it from CI after each production deploy. Commit hash, author, and PR number are auto-detected on GitHub Actions, Vercel, GitLab, and Bitbucket; override with `--commit`, `--message`, `--author`, `--env`. Flusterduck captures confusion before and after the deploy and verifies whether your fixes actually reduced friction. See [Deploy correlation](./deploy-correlation).\n\n## Links\n\nPublished on npm as `flusterduck-cli`, with `@flusterduck/cli` as a supported scoped alias (same versions, same commands). Install pulls the latest published version. Full command reference: [CLI](./cli).\n'
|
|
609
616
|
},
|
|
610
617
|
{
|
|
611
618
|
"slug": "pkg-create",
|
|
@@ -738,14 +745,14 @@ A pre-written tooltip tells you what the author thought you'd need to know. Guid
|
|
|
738
745
|
"title": "Setting up Guide",
|
|
739
746
|
"description": "Enable Guide in init(), configure the floating button and keyboard shortcut, scope to pages.",
|
|
740
747
|
"group": "Guide",
|
|
741
|
-
"content": '# Setting up Guide\n\nGuide ships as part of the core SDK. No separate package, no browser extension, no extra script tag. Enable it in your `init()` call and it\'s live.\n\n## Enable Guide\n\n```ts\nimport { init } from \'flusterduck\'\n\ninit({\n key: \'fd_pub_...\',\n guide: {\n enabled: true,\n },\n})\n```\n\nThat\'s it. The SDK adds a floating help button to the page and registers the keyboard shortcut. Users activate Guide mode, hover over elements, and get explanations.\n\n## Modes\n\nGuide has three modes, set with `mode`:\n\n```ts\nguide: {\n enabled: true,\n mode: \'both\', // \'explain\' (default) | \'feedback\' | \'both\'\n}\n```\n\n- **`explain`** (default) \u2014 hover any element in Guide mode, get a plain-English explanation.\n- **`feedback`** \u2014 the help button opens a "Tell us what\'s confusing" box instead. Users type what lost them and submit; nothing else. No AI calls are made in this mode, so it also works on plans or pages where you don\'t want generated explanations.\n- **`both`** \u2014 hover-to-explain as normal, plus a "Still confused? Tell us" link on every explanation card that opens the feedback box.\n\nFeedback submissions appear in your dashboard under **Guide \u2192 Confusion reports**, verbatim, with the page (and the element, when the report came from an explanation card). Comments are capped at 500 characters and sanitized server-side; submissions are rate limited per visitor.\n\nCustomize the panel title:\n\n```ts\nguide: {\n enabled: true,\n mode: \'feedback\',\n feedbackPrompt: \'What were you looking for?\',\n}\n```\n\nOpen the feedback box from your own UI (a "Report a problem" menu item, for example):\n\n```ts\nimport { guide } from \'flusterduck\'\n\nguide.feedback()\n```\n\n## Activation mechanisms\n\nGuide is always user-triggered. It never pops up on its own. You decide how users activate it: a built-in floating button, a keyboard shortcut, your own custom trigger, or any combination.\n\n### Floating help button\n\nA small fixed-position button in the bottom-right corner of the viewport. One tap toggles Guide mode on and off.\n\n```ts\nguide: {\n enabled: true,\n fab: {\n position: \'bottom-right\', // \'bottom-right\' | \'bottom-left\'\n offset: { x: 24, y: 24 }, // pixels from corner\n label: \'Help\', // screen reader label and tooltip\n },\n}\n```\n\nIf you already have a chat widget or cookie banner in the bottom-right, move the button to `bottom-left` or adjust the offset.\n\nThe button renders inside a closed shadow root. It won\'t inherit your fonts, colors, or resets, and its CSS won\'t leak into your page.\n\n### No floating button\n\nSet `fab: false` to hide the built-in button entirely. Users activate Guide through the keyboard shortcut or your own trigger.\n\n```ts\nguide: {\n enabled: true,\n fab: false,\n}\n```\n\n### Your own trigger\n\nWire Guide to any element in your app. The `guide` export gives you programmatic control:\n\n```ts\nimport { guide } from \'flusterduck\'\n\n// In your help menu\ndocument.querySelector(\'#my-help-btn\').addEventListener(\'click\', () => {\n guide.toggle()\n})\n\n// Or conditionally\nif (userIsNew) guide.activate()\n```\n\n`guide.activate()`, `guide.deactivate()`, `guide.toggle()`, and `guide.isActive()` work regardless of whether the FAB is visible. This is the recommended path if you want Guide to feel native to your app rather than an overlay.\n\n### Keyboard shortcut\n\nPress `?` (Shift + /) to toggle Guide mode. This matches the convention used by GitHub, Gmail, and Slack for help overlays.\n\nThe shortcut only fires when no text input is focused. If the cursor is in an `<input>`, `<textarea>`, or `contenteditable` element, the keystroke passes through normally.\n\n```ts\nguide: {\n enabled: true,\n shortcut: \'?\', // any single character, or false to disable\n}\n```\n\nSet `shortcut: false` if `?` conflicts with your app\'s own shortcuts. You can also trigger Guide mode programmatically:\n\n```ts\nimport { guide } from \'flusterduck\'\n\nguide.activate() // enter Guide mode\nguide.deactivate() // exit Guide mode\nguide.toggle() // flip\n```\n\nWire these to your own button, keyboard shortcut, or menu item if you want full control over activation.\n\n### Mobile\n\nOn touch devices, the floating button still appears. When Guide mode is active, a single tap on any element shows the explanation card. Tap elsewhere or tap the button again to dismiss. No hover required.\n\nLong-press also works as a trigger: the user holds on an element for 400ms and the explanation appears. This avoids interference with normal tap interactions.\n\n## Positioning the explanation card\n\nThe explanation card appears near the cursor (or near the tapped element on mobile). It automatically flips to stay within the viewport. You don\'t need to configure positioning.\n\nIf you want to constrain where the card can appear:\n\n```ts\nguide: {\n enabled: true,\n card: {\n maxWidth: 320, // pixels, default 320\n offset: 12, // gap between cursor and card edge\n },\n}\n```\n\nThe card renders inside the same closed shadow root as the button. Your page styles don\'t affect it.\n\n## Scoping to specific pages\n\nBy default, Guide is available on every page the SDK loads on. To restrict it:\n\n```ts\nguide: {\n enabled: true,\n pages: [\'/checkout\', \'/settings\', \'/onboarding/*\'],\n}\n```\n\nGlob patterns work. `*` matches any single path segment. `**` matches any depth.\n\nOn pages not in the list, the button and shortcut are both hidden. The SDK still detects friction signals normally; only the Guide UI is scoped.\n\n## Server-side settings\n\nThe snippet controls how the widget looks; the dashboard controls whether it answers. Under **Settings \u2192 Guide** you can:\n\n- **Disable Guide entirely** \u2014 a real kill switch, enforced by the API. Requests are rejected server-side no matter what the client snippet asks for.\n- **Restrict the mode** \u2014 e.g. feedback-only: explanation requests get a `403 guide_explain_disabled` even if a stale snippet still asks for them.\n- **Allowlist pages** \u2014 same glob syntax as the client `pages` option, enforced at the API so the client scoping can\'t be bypassed.\n\nEverything Guide collects lands in the dashboard under **Guide**: total explanations shown, the elements users ask about most (your strongest confusion evidence), confusion reports with triage controls, and a closed-loop view of pages where Guide was active and the underlying issue has since been fixed.\n\n## Guide as an API\n\nGuide\'s backend is a plain HTTPS API authenticated with your publishable key \u2014 the same contract whether you call it from the SDK, your own client, or a native app. The base endpoint is:\n\n```\nPOST https://api.flusterduck.com/v1/guide\nx-fd-key: fd_pub_...\nContent-Type: application/json\n```\n\n### Explanation requests\n\n```json\n{\n "fingerprint": "a1b2c3d4e5f60718",\n "page": "/checkout",\n "context": {\n "selector": "button.submit",\n "label": "Place order",\n "role": "button",\n "tagName": "button",\n "isDisabled": false,\n "isExpanded": false,\n "isRequired": false,\n "parentLabel": "Payment form",\n "parentRole": "form"\n }\n}\n```\n\n`fingerprint` is any stable hash of the element + page (the SDK uses a context hash); it keys the server-side cache. `context.selector` is required; everything else is optional but improves the explanation. Response envelope:\n\n```json\n{ "data": { "explanation": "Charges your card and places the order. You\'ll get a confirmation email within a few seconds." }, "error": null }\n```\n\nExplanations are cached server-side per fingerprint, sanitized (no HTML, no URLs, no email addresses), and rate limited at 200 requests/minute per site and 15/minute per visitor IP.\n\n### Feedback submissions\n\n```json\n{\n "kind": "feedback",\n "page": "/checkout",\n "comment": "I typed my discount code but nothing happened",\n "selector": "button#apply-coupon",\n "label": "Apply coupon"\n}\n```\n\n`page` and `comment` (3\u2013500 characters) are required; `selector` and `label` are optional. Response: `{ "data": { "received": true }, "error": null }`. Rate limited at 60/minute per site and 5 per 5 minutes per visitor IP.\n\n### Errors\n\n| Status | Code | Meaning |\n| --- | --- | --- |\n| 403 | `guide_disabled` | Guide is switched off in Settings \u2192 Guide |\n| 403 | `guide_explain_disabled` | The site is in feedback-only mode |\n| 403 | `guide_feedback_disabled` | The site is in explain-only mode |\n| 403 | `guide_page_not_allowed` | The page isn\'t in the server-side allowlist |\n| 400 | `invalid_*` / `comment_too_short` | Validation failure \u2014 the message says which field |\n| 429 | `rate_limited` | Back off and retry later |\n\n## Bring your own API (BYOK)\n\nBy default, Guide calls the Flusterduck edge function for AI explanations, which uses your plan\'s included allocation. If you want to use your own Anthropic key, your own model, or a completely custom backend, point Guide at your endpoint:\n\n```ts\nguide: {\n enabled: true,\n apiEndpoint: \'https://your-server.com/api/guide-explain\',\n}\n```\n\nYour endpoint receives the exact request bodies documented above \u2014 explanation requests and, when feedback mode is on, feedback submissions (distinguished by `"kind": "feedback"`). For explanations, return JSON with an `explanation` field (a bare `{ "explanation": "..." }` or the `{ "data": { ... } }` envelope both work):\n\n```json\n{\n "explanation": "Charges your card and places the order. You\'ll get a confirmation email within a few seconds."\n}\n```\n\nBYOK requests don\'t count against your Flusterduck Guide allocation. You\'re paying your own provider directly.\n\n## Custom explanations\n\nFor critical elements where you know exactly what the explanation should say, skip the AI entirely:\n\n```html\n<button data-fd-guide="Creates a new project. Doesn\'t affect existing projects. You can rename or delete it later from Settings.">\n New Project\n</button>\n```\n\nWhen a user hovers over an element with `data-fd-guide`, the SDK shows that text directly. No API call, no latency, no AI cost. The attribute content always wins over generated explanations.\n\nUse this for high-traffic elements where you want guaranteed wording, or for controls that handle money, deletion, or permissions where precision matters.\n\n## Disabling Guide for specific elements\n\n```html\n<div data-fd-guide-ignore>\n <!-- nothing inside this subtree will show Guide explanations -->\n</div>\n```\n\nUseful for decorative regions, ads, or third-party embeds where explanations would be confusing or irrelevant.\n\n## Content Security Policy\n\nGuide runs entirely within the existing SDK script. No new CSP entries are needed for the UI or style injection (it uses `adoptedStyleSheets` inside a shadow root, which bypasses `style-src` restrictions).\n\nThe SDK already requires your CSP\'s `connect-src` to include the Flusterduck API domain for event ingestion. Guide uses the same endpoint for fetching explanations. If ingestion works, Guide works.\n\n## Privacy\n\nGuide follows the same privacy rules as the rest of the SDK. When extracting element context to generate an explanation, it reads:\n\n- Tag name, role, and accessible label\n- Element state (disabled, checked, expanded)\n- Parent context (the nearest labeled ancestor)\n- The CSS selector Flusterduck already computes for signal tracking\n\nIt never reads form values, text content the user typed, or any attribute you haven\'t explicitly set. The `data-fd-guide` attribute is opt-in content you control entirely.\n\nExplanation requests are scoped to your site\'s publishable key. Flusterduck never sends element context from one customer\'s site to generate explanations for another.\n'
|
|
748
|
+
"content": '# Setting up Guide\n\nGuide ships as part of the core SDK. No separate package, no browser extension, no extra script tag. Enable it in your `init()` call and it\'s live.\n\n## Enable Guide\n\n```ts\nimport { init } from \'flusterduck\'\n\ninit({\n key: \'fd_pub_...\',\n guide: {\n enabled: true,\n },\n})\n```\n\nThat\'s it. The SDK adds a floating help button to the page and registers the keyboard shortcut. Users activate Guide mode, hover over elements, and get explanations.\n\n## Modes\n\nGuide has three modes, set with `mode`:\n\n```ts\nguide: {\n enabled: true,\n mode: \'both\', // \'explain\' (default) | \'feedback\' | \'both\'\n}\n```\n\n- **`explain`** (default): hover any element in Guide mode, get a plain-English explanation.\n- **`feedback`**: the help button opens a "Tell us what\'s confusing" box instead. Users type what lost them and submit; nothing else. No AI calls are made in this mode, so it also works on plans or pages where you don\'t want generated explanations.\n- **`both`**: hover-to-explain as normal, plus a "Still confused? Tell us" link on every explanation card that opens the feedback box.\n\nFeedback submissions appear in your dashboard under **Guide \u2192 Confusion reports**, verbatim, with the page (and the element, when the report came from an explanation card). Comments are capped at 500 characters and sanitized server-side; submissions are rate limited per visitor.\n\nCustomize the panel title:\n\n```ts\nguide: {\n enabled: true,\n mode: \'feedback\',\n feedbackPrompt: \'What were you looking for?\',\n}\n```\n\nOpen the feedback box from your own UI (a "Report a problem" menu item, for example):\n\n```ts\nimport { guide } from \'flusterduck\'\n\nguide.feedback()\n```\n\n## Activation mechanisms\n\nGuide is always user-triggered. It never pops up on its own. You decide how users activate it: a built-in floating button, a keyboard shortcut, your own custom trigger, or any combination.\n\n### Floating help button\n\nA small fixed-position button in the bottom-right corner of the viewport. One tap toggles Guide mode on and off.\n\n```ts\nguide: {\n enabled: true,\n fab: {\n position: \'bottom-right\', // \'bottom-right\' | \'bottom-left\'\n offset: { x: 24, y: 24 }, // pixels from corner\n label: \'Help\', // screen reader label and tooltip\n },\n}\n```\n\nIf you already have a chat widget or cookie banner in the bottom-right, move the button to `bottom-left` or adjust the offset.\n\nThe button renders inside a closed shadow root. It won\'t inherit your fonts, colors, or resets, and its CSS won\'t leak into your page.\n\n### No floating button\n\nSet `fab: false` to hide the built-in button entirely. Users activate Guide through the keyboard shortcut or your own trigger.\n\n```ts\nguide: {\n enabled: true,\n fab: false,\n}\n```\n\n### Your own trigger\n\nWire Guide to any element in your app. The `guide` export gives you programmatic control:\n\n```ts\nimport { guide } from \'flusterduck\'\n\n// In your help menu\ndocument.querySelector(\'#my-help-btn\').addEventListener(\'click\', () => {\n guide.toggle()\n})\n\n// Or conditionally\nif (userIsNew) guide.activate()\n```\n\n`guide.activate()`, `guide.deactivate()`, `guide.toggle()`, and `guide.isActive()` work regardless of whether the FAB is visible. This is the recommended path if you want Guide to feel native to your app rather than an overlay.\n\n### Keyboard shortcut\n\nPress `?` (Shift + /) to toggle Guide mode. This matches the convention used by GitHub, Gmail, and Slack for help overlays.\n\nThe shortcut only fires when no text input is focused. If the cursor is in an `<input>`, `<textarea>`, or `contenteditable` element, the keystroke passes through normally.\n\n```ts\nguide: {\n enabled: true,\n shortcut: \'?\', // any single character, or false to disable\n}\n```\n\nSet `shortcut: false` if `?` conflicts with your app\'s own shortcuts. You can also trigger Guide mode programmatically:\n\n```ts\nimport { guide } from \'flusterduck\'\n\nguide.activate() // enter Guide mode\nguide.deactivate() // exit Guide mode\nguide.toggle() // flip\n```\n\nWire these to your own button, keyboard shortcut, or menu item if you want full control over activation.\n\n### Mobile\n\nOn touch devices, the floating button still appears. When Guide mode is active, a single tap on any element shows the explanation card. Tap elsewhere or tap the button again to dismiss. No hover required.\n\nLong-press also works as a trigger: the user holds on an element for 400ms and the explanation appears. This avoids interference with normal tap interactions.\n\n## Positioning the explanation card\n\nThe explanation card appears near the cursor (or near the tapped element on mobile). It automatically flips to stay within the viewport. You don\'t need to configure positioning.\n\nIf you want to constrain where the card can appear:\n\n```ts\nguide: {\n enabled: true,\n card: {\n maxWidth: 320, // pixels, default 320\n offset: 12, // gap between cursor and card edge\n },\n}\n```\n\nThe card renders inside the same closed shadow root as the button. Your page styles don\'t affect it.\n\n## Scoping to specific pages\n\nBy default, Guide is available on every page the SDK loads on. To restrict it:\n\n```ts\nguide: {\n enabled: true,\n pages: [\'/checkout\', \'/settings\', \'/onboarding/*\'],\n}\n```\n\nGlob patterns work. `*` matches any single path segment. `**` matches any depth.\n\nOn pages not in the list, the button and shortcut are both hidden. The SDK still detects friction signals normally; only the Guide UI is scoped.\n\n## Server-side settings\n\nThe snippet controls how the widget looks; the dashboard controls whether it answers. Under **Settings \u2192 Guide** you can:\n\n- **Disable Guide entirely**: a real kill switch, enforced by the API. Requests are rejected server-side no matter what the client snippet asks for.\n- **Restrict the mode**, e.g. feedback-only: explanation requests get a `403 guide_explain_disabled` even if a stale snippet still asks for them.\n- **Allowlist pages**: same glob syntax as the client `pages` option, enforced at the API so the client scoping can\'t be bypassed.\n\nEverything Guide collects lands in the dashboard under **Guide**: total explanations shown, the elements users ask about most (your strongest confusion evidence), confusion reports with triage controls, and a closed-loop view of pages where Guide was active and the underlying issue has since been fixed.\n\n## Guide as an API\n\nGuide\'s backend is a plain HTTPS API authenticated with your publishable key, the same contract whether you call it from the SDK, your own client, or a native app. The base endpoint is:\n\n```\nPOST https://api.flusterduck.com/v1/guide\nx-fd-key: fd_pub_...\nContent-Type: application/json\n```\n\n### Explanation requests\n\n```json\n{\n "fingerprint": "a1b2c3d4e5f60718",\n "page": "/checkout",\n "context": {\n "selector": "button.submit",\n "label": "Place order",\n "role": "button",\n "tagName": "button",\n "isDisabled": false,\n "isExpanded": false,\n "isRequired": false,\n "parentLabel": "Payment form",\n "parentRole": "form"\n }\n}\n```\n\n`fingerprint` is any stable hash of the element + page (the SDK uses a context hash); it keys the server-side cache. `context.selector` is required; everything else is optional but improves the explanation. Response envelope:\n\n```json\n{ "data": { "explanation": "Charges your card and places the order. You\'ll get a confirmation email within a few seconds." }, "error": null }\n```\n\nExplanations are cached server-side per fingerprint, sanitized (no HTML, no URLs, no email addresses), and rate limited at 200 requests/minute per site and 15/minute per visitor IP.\n\n### Feedback submissions\n\n```json\n{\n "kind": "feedback",\n "page": "/checkout",\n "comment": "I typed my discount code but nothing happened",\n "selector": "button#apply-coupon",\n "label": "Apply coupon"\n}\n```\n\n`page` and `comment` (3\u2013500 characters) are required; `selector` and `label` are optional. Response: `{ "data": { "received": true }, "error": null }`. Rate limited at 60/minute per site and 5 per 5 minutes per visitor IP.\n\n### Errors\n\n| Status | Code | Meaning |\n| --- | --- | --- |\n| 403 | `guide_disabled` | Guide is switched off in Settings \u2192 Guide |\n| 403 | `guide_explain_disabled` | The site is in feedback-only mode |\n| 403 | `guide_feedback_disabled` | The site is in explain-only mode |\n| 403 | `guide_page_not_allowed` | The page isn\'t in the server-side allowlist |\n| 400 | `invalid_*` / `comment_too_short` | Validation failure; the message says which field |\n| 429 | `rate_limited` | Back off and retry later |\n\n## Bring your own API (BYOK)\n\nBy default, Guide calls the Flusterduck edge function for AI explanations, which uses your plan\'s included allocation. If you want to use your own Anthropic key, your own model, or a completely custom backend, point Guide at your endpoint:\n\n```ts\nguide: {\n enabled: true,\n apiEndpoint: \'https://your-server.com/api/guide-explain\',\n}\n```\n\nYour endpoint receives the exact request bodies documented above: explanation requests and, when feedback mode is on, feedback submissions (distinguished by `"kind": "feedback"`). For explanations, return JSON with an `explanation` field (a bare `{ "explanation": "..." }` or the `{ "data": { ... } }` envelope both work):\n\n```json\n{\n "explanation": "Charges your card and places the order. You\'ll get a confirmation email within a few seconds."\n}\n```\n\nBYOK requests don\'t count against your Flusterduck Guide allocation. You\'re paying your own provider directly.\n\n## Custom explanations\n\nFor critical elements where you know exactly what the explanation should say, skip the AI entirely:\n\n```html\n<button data-fd-guide="Creates a new project. Doesn\'t affect existing projects. You can rename or delete it later from Settings.">\n New Project\n</button>\n```\n\nWhen a user hovers over an element with `data-fd-guide`, the SDK shows that text directly. No API call, no latency, no AI cost. The attribute content always wins over generated explanations.\n\nUse this for high-traffic elements where you want guaranteed wording, or for controls that handle money, deletion, or permissions where precision matters.\n\n## Disabling Guide for specific elements\n\n```html\n<div data-fd-guide-ignore>\n <!-- nothing inside this subtree will show Guide explanations -->\n</div>\n```\n\nUseful for decorative regions, ads, or third-party embeds where explanations would be confusing or irrelevant.\n\n## Content Security Policy\n\nGuide runs entirely within the existing SDK script. No new CSP entries are needed for the UI or style injection (it uses `adoptedStyleSheets` inside a shadow root, which bypasses `style-src` restrictions).\n\nThe SDK already requires your CSP\'s `connect-src` to include the Flusterduck API domain for event ingestion. Guide uses the same endpoint for fetching explanations. If ingestion works, Guide works.\n\n## Privacy\n\nGuide follows the same privacy rules as the rest of the SDK. When extracting element context to generate an explanation, it reads:\n\n- Tag name, role, and accessible label\n- Element state (disabled, checked, expanded)\n- Parent context (the nearest labeled ancestor)\n- The CSS selector Flusterduck already computes for signal tracking\n\nIt never reads form values, text content the user typed, or any attribute you haven\'t explicitly set. The `data-fd-guide` attribute is opt-in content you control entirely.\n\nExplanation requests are scoped to your site\'s publishable key. Flusterduck never sends element context from one customer\'s site to generate explanations for another.\n'
|
|
742
749
|
},
|
|
743
750
|
{
|
|
744
751
|
"slug": "autofix",
|
|
745
752
|
"title": "Autofix",
|
|
746
753
|
"description": "Generate an AI fix proposal per issue: root cause, the change, an agent prompt. Never edits your repo.",
|
|
747
754
|
"group": "Issues",
|
|
748
|
-
"content": "# Autofix\n\nAutofix turns an issue's evidence into a concrete fix
|
|
755
|
+
"content": "# Autofix\n\nAutofix turns an issue's evidence into a concrete fix: a root cause, the specific change to make, likely places to look, a way to verify it, and a prompt you can paste straight into your coding agent (Claude Code, Cursor). When you connect the Flusterduck GitHub App, Autofix goes further: it researches your repository and opens a **draft pull request** with the change.\n\n## Two modes\n\n**Brief (always available).** Autofix reads the behavioral evidence already on the issue (the same no-PII data used for diagnosis) and proposes a change. You (or your agent) ship it. Flusterduck never sees your repository in this mode.\n\n**Draft PR (GitHub App connected).** With the GitHub App installed, Autofix researches the connected repo, reads the files most likely at fault, plans the smallest safe change, and opens a **draft** pull request. It only edits files it read, never creates files, and never merges. If it isn't confident it can fix the issue safely from what it can see, it falls back to the brief rather than pushing a guess.\n\nThe model that plans repo-level fixes is Claude Sonnet.\n\n## Using it\n\nOpen any issue and press **Generate fix** on the Autofix card. You get:\n\n- **Root cause**: the specific UX/code cause the evidence supports.\n- **The change**: what to change, specific enough to implement.\n- **Likely places to look**: best-guess file/component locations (never asserted as fact).\n- **How to verify**: a manual check or test to confirm the fix worked.\n- **Agent prompt**: a self-contained instruction to paste into your coding agent.\n- **Draft PR**: when the GitHub App is connected and the fix is confident, a link to the opened pull request and the files it changed.\n\nRegenerate any time to get a fresh proposal.\n\n## Limits and cost\n\nAutofix is a **count of fixes**, included by plan: Scale (5/mo), Pro (20/mo), Enterprise (unlimited). Grow includes none. The count only decrements on a successful generation, and resets each billing cycle.\n\nNeed more than your monthly allowance? Autofix scales with your plan, not \xE0 la carte: **upgrade your plan** for more fixes. There are no add-on packs to buy.\n\nAutofix runs on its own AI budget, separate from issue diagnosis and Guide, so heavy autofix use can never starve diagnosis (and vice versa). You never see a dollar meter, just the fix count.\n\n## Safety boundary\n\nThe draft-PR boundary is deliberate: a wrong draft PR is a shrug, a wrong auto-merge is an outage. Autofix always opens PRs as drafts and never merges. You review and ship.\n"
|
|
749
756
|
},
|
|
750
757
|
{
|
|
751
758
|
"slug": "guide-explanations",
|
package/dist/cli.js
CHANGED
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flusterduck/mcp-server",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.4",
|
|
4
4
|
"description": "MCP server for UX friction data. Connect Claude, Cursor, Windsurf, Copilot, Cline to rage click and dead click analytics. Model Context Protocol.",
|
|
5
5
|
"mcpName": "com.flusterduck/mcp-server",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE.md",
|