@flusterduck/mcp-server 0.5.3 → 0.5.5

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.
@@ -313,6 +313,13 @@ All plans include a 7-day free trial. No credit card required.
313
313
  "group": "Getting started",
314
314
  "content": '# Testing Your Integration\n\n`@flusterduck/test-suite` is a Playwright-based CLI that simulates user frustration patterns against a running app. It verifies your Flusterduck setup before you go to production: signals are being detected, the scoring engine is receiving them, and issues will be created from real friction.\n\n## Install\n\n```bash\npnpm add -D @flusterduck/test-suite\n```\n\nOr run without installing:\n\n```bash\nnpx @flusterduck/test-suite run --url http://localhost:3000\n```\n\n## Running a simulation\n\nPoint it at your dev server:\n\n```bash\nnpx @flusterduck/test-suite run \\\n --url http://localhost:3000 \\\n --key fd_pub_xxxxxxxxxxxx \\\n --scenarios all\n```\n\nThe CLI opens a headless Chromium browser, navigates through your app, and executes frustration scenarios on each page. Each scenario generates specific signal types. After the run, it reports which signals were emitted, whether they were received by the scoring engine, and which scenarios produced no signals.\n\n## Scenarios\n\n### rage-click\n\nClicks the same element 5 times in rapid succession. Targets interactive elements: buttons, links, form submits.\n\n### dead-click\n\nClicks non-interactive elements: labels, images, decorative containers, text nodes near interactive elements.\n\n### form-abandon\n\nFills in one or more fields of a form and navigates away without submitting.\n\n### navigation-loop\n\nNavigates forward in a multi-step flow, then immediately back. Repeats 3 times.\n\n### scroll-thrash\n\nScrolls down rapidly, then back up, then down again, within a 3-second window.\n\n### tap-miss\n\nOn mobile viewport (375px), taps adjacent to interactive elements rather than on them. Simulates small touch targets.\n\n### idle-confusion\n\nLoads a page and stays inactive for 15 seconds without interacting.\n\n## Running specific scenarios\n\n```bash\nnpx @flusterduck/test-suite run \\\n --url http://localhost:3000/checkout \\\n --key fd_pub_xxxxxxxxxxxx \\\n --scenarios rage-click,form-abandon,dead-click\n```\n\n## Output\n\n```\nFlusterduck Test Suite v0.4.1\nTarget: http://localhost:3000\nScenarios: rage-click, form-abandon, dead-click\n\nRunning rage-click on /...\n Found 3 interactive elements\n Executed rage-click on button[type="submit"] signal received\n Executed rage-click on [data-cta="upgrade"] signal received\n Executed rage-click on nav a:nth-child(2) signal received\n\nRunning form-abandon on /checkout...\n Found 2 forms\n Executed form-abandon on #checkout-form signal received\n Executed form-abandon on #coupon-form signal received (low confidence)\n\nRunning dead-click on /pricing...\n Found 6 non-interactive elements with click affordance\n Executed dead-click on .plan-card signal received\n Executed dead-click on .feature-badge no signal (expected: element filtered)\n\nSummary\n Scenarios run: 3\n Signals emitted: 6\n Signals received by engine: 5\n Missed signals: 1 (.feature-badge matched ignoreElements pattern)\n Issues detected: 0 (volume too low for clustering)\n\nAll critical scenarios passed.\n```\n\n"Issues detected: 0" is expected during testing. Issue clustering requires signals from multiple distinct sessions. A single simulation run won\'t cross that threshold. The important check is that signals are emitted and received.\n\n## CI integration\n\nRun the test suite after your dev server starts to verify the integration hasn\'t broken:\n\n```yaml\n# .github/workflows/test.yml\n- name: Start dev server\n run: pnpm dev &\n \n- name: Wait for server\n run: npx wait-on http://localhost:3000\n\n- name: Run Flusterduck test suite\n run: |\n npx @flusterduck/test-suite run \\\n --url http://localhost:3000 \\\n --key ${{ secrets.FLUSTERDUCK_TEST_KEY }} \\\n --scenarios rage-click,form-abandon \\\n --fail-on-missed-signals\n```\n\n`--fail-on-missed-signals` exits with code 1 if any expected signals weren\'t received by the engine. Use this to catch SDK initialization regressions.\n\nUse a separate `fd_pub_` key for CI testing. Create one under Settings > API Keys and label it "test." Data from this key won\'t affect your production scores if you scope your dashboard view to the production key.\n\n## Testing consent flows\n\nSimulate a session where tracking starts paused:\n\n```bash\nnpx @flusterduck/test-suite run \\\n --url http://localhost:3000 \\\n --key fd_pub_xxxxxxxxxxxx \\\n --scenarios rage-click \\\n --consent-state declined\n```\n\nWith `--consent-state declined`, the runner calls `setConsent(false)` after page load. Signals should not be received by the engine. The test fails if they are.\n\n```bash\n--consent-state accepted # setConsent(true), signals should flow (default)\n--consent-state declined # setConsent(false), signals should not flow\n--consent-state unset # no consent call, tests default behavior\n```\n\n## Testing with identify()\n\nPass segment properties to simulate an identified session:\n\n```bash\nnpx @flusterduck/test-suite run \\\n --url http://localhost:3000/checkout \\\n --key fd_pub_xxxxxxxxxxxx \\\n --scenarios rage-click \\\n --identify \'{"plan": "scale", "role": "admin"}\'\n```\n\nThe runner calls `identify()` with the provided properties before executing scenarios. Verify in your dashboard that the session has the expected segment properties attached.\n\n## Debugging a missed signal\n\nIf a signal shows "no signal" in the output:\n\n1. Check `ignoreElements` in your SDK config. The element selector might match a suppression rule.\n2. Check `ignorePages`. The page path might be on the skip list.\n3. Check `sampleRate`. If it\'s set below 1.0, some sessions won\'t be tracked. The test runner uses a fixed seed to ensure consistent sampling, but a very low sample rate can still produce skipped sessions.\n4. Run with `--debug` to see the full SDK console output in the terminal.\n\n```bash\nnpx @flusterduck/test-suite run \\\n --url http://localhost:3000 \\\n --key fd_pub_xxxxxxxxxxxx \\\n --scenarios rage-click \\\n --debug\n```\n'
315
315
  },
316
+ {
317
+ "slug": "architecture",
318
+ "title": "Architecture overview",
319
+ "description": "The full edge-function data flow: SDK to ingest to scores to issues to alerts to delivery.",
320
+ "group": "Getting started",
321
+ "content": '# Architecture overview\n\nFlusterduck runs entirely on edge functions. The browser SDK collects behavioral signals, sends them to an ingestion endpoint, and a server-side pipeline turns raw events into scores, issues, and alerts. Your frontend never talks to the database.\n\nThis page maps the full data flow for teams doing compliance reviews, integration planning, or just wanting to know where their data goes.\n\n## The pipeline\n\nEvery piece of friction data moves through five stages:\n\n1. **SDK** collects behavioral signals in the browser\n2. **ingest** validates, deduplicates, and stores events\n3. **compute-scores** calculates per-page confusion scores\n4. **process-issues** clusters signals into UX issues\n5. **process-alerts** evaluates alert rules and queues notifications\n\nAfter alerts fire, two delivery paths carry notifications out: the **webhooks** function delivers signed HTTP payloads to your endpoints, and the **slack** function handles slash commands and interactive buttons.\n\nA sixth function, **deploy-correlation**, sits outside the main pipeline. It records deploys and captures before/after confusion snapshots so the engine can verify whether a fix worked.\n\n## How data enters\n\nThe SDK batches behavioral events and POSTs them to the `ingest` edge function. Each batch contains a session ID, the publishable key (`fd_pub_`), the current URL, viewport dimensions, and up to 250 events.\n\n```ts\n// What the SDK sends (simplified)\n{\n sid: "session-uuid",\n key: "fd_pub_xxxxxxxxxxxx",\n url: "https://yourapp.com/checkout",\n ts: 1719014400000,\n events: [\n { type: "signal", signal_type: "dead_click", element: "button.submit", ts: 1719014399800 },\n { type: "signal", signal_type: "rage_click", element: "button.submit", ts: 1719014399950 }\n ]\n}\n```\n\nThe SDK runs client-side only. It attaches listeners for clicks, scroll, keyboard, touch, form focus/blur, navigation, and errors. It doesn\'t attach listeners that would capture keystrokes, form values, or page text. That constraint is enforced at the code level.\n\n## ingest\n\nThe first edge function in the chain. It does six things, in order:\n\n1. Validates the `fd_pub_` key against the `api_keys` table using HMAC-SHA256 lookup\n2. Enforces rate limits (10,000 requests per minute per publishable key)\n3. Checks session limits against the org\'s billing plan\n4. Upserts the session record with device type, browser, country, and screen bucket\n5. Normalizes each event, hashes the IP address, generates a dedupe key, strips sensitive fields, and inserts into the `events` table\n6. Extracts typed signals from events, assigns weights from the signal registry, and inserts into the `signals` table\n\nIP addresses are hashed with SHA-256 plus `IP_HASH_SALT` and truncated to 32 hex characters before storage. The raw IP never hits the database.\n\nAfter writing signals, ingest fires an async call to `compute-scores`. It doesn\'t wait for a response. The browser gets back `{ ok: true, accepted: 12, rejected: 0 }` within milliseconds.\n\n## compute-scores\n\nRuns on a 15-minute window by default. For each active site, it:\n\n1. Loads all signals and sessions from the scoring window\n2. Groups signals by page and signal type\n3. Calculates a weighted score per page: `sum(signal_weight * confidence) / active_sessions`\n4. Compares the raw score against the page\'s baseline (rolling mean and standard deviation)\n5. Normalizes the score to a 0-100 scale using z-score math: `((raw - mean) / std_dev) * 25 + 50`\n6. Writes scores to `page_scores` and appends to `score_history`\n\nEach score row includes a `duck_state` (calm, uneasy, frustrated, panicking), a trend direction, the dominant signal type, a signal breakdown with percentages, and a co-occurrence multiplier that boosts the score when many different users hit the same friction.\n\nThe baseline requires at least 7 samples before it kicks in. Until then, scores use raw values.\n\nAfter scoring, `compute-scores` also back-fills `confusion_after` on recent deploys. Any deploy older than 5 minutes but younger than 48 hours gets a snapshot of current page scores, so deploy-correlation can compare before and after.\n\n## process-issues\n\nRuns on a 60-minute window. This is where individual signals become actionable issues.\n\nThe function clusters signals by a composite key: `page + signal_type + element`. If a cluster has at least 3 fires or spans 2+ sessions, it becomes a UX issue.\n\nFor each cluster, process-issues:\n\n1. Generates a fingerprint hash from `site_id:page:signal:element`\n2. Looks up existing open issues with the same fingerprint\n3. Updates the existing issue if found, or creates a new one\n4. Calculates issue confidence from signal count, session breadth, and per-fire confidence\n5. Estimates revenue impact using the site\'s revenue config (if `track()` is wired)\n6. Writes signal evidence rows linking the issue to its source events\n\nIssues have a lifecycle: `open`, `triaged`, `in_progress`, `verified`, `resolved`, `ignored`, `regressed`. If new signals match an issue that was previously `verified`, its status flips to `regressed`. That\'s how you know a fix didn\'t hold.\n\n## process-alerts\n\nEvaluates alert rules against the latest page scores. Each rule has a trigger type:\n\n| Trigger | Fires when |\n|---|---|\n| `budget` | Score exceeds threshold |\n| `spike` | Score jumps above baseline by threshold amount |\n| `anomaly` | Deviation factor exceeds threshold (encoded as threshold/10) |\n| `trend` | Score trend is "up" and score exceeds threshold |\n| `new_page` | Page has no baseline and score exceeds threshold |\n| `co_occurrence` | Multiple users affected and count exceeds threshold |\n| `positive` | Score drops below threshold (for tracking improvements) |\n\nBefore firing, the function claims a cooldown lock using an atomic database RPC. Two overlapping runs can\'t fire the same alert twice. Cooldown is configurable per rule, from 1 minute to 24 hours.\n\nWhen an alert fires, four things happen:\n\n1. An alert row is created with severity (warning, high, critical), score snapshot, and signal breakdown\n2. An incident event is logged\n3. Delivery queue rows are created for each configured channel (email, Slack, webhook, PagerDuty)\n4. Webhook delivery rows are created for any registered endpoints subscribed to `alert.fired`\n\n## webhooks\n\nProcesses the `webhook_deliveries` queue. For each pending delivery:\n\n1. Claims the delivery with a processing lock (stale locks auto-expire after 10 minutes)\n2. Looks up the endpoint and decrypts its stored secret\n3. Signs the payload with HMAC-SHA256, including a timestamp for replay protection\n4. POSTs to the endpoint URL with an 8-second timeout\n5. On success, marks the delivery as sent and resets the endpoint\'s failure counter\n6. On failure, schedules a retry with exponential backoff (30s, 60s, 120s, ... up to 1 hour)\n\nDeliveries are retried up to 10 times. After 10 failures, the delivery is marked `dead`.\n\nEvery webhook request includes these headers:\n\n```\nx-flusterduck-event: alert.fired\nx-flusterduck-delivery: delivery-uuid\nx-flusterduck-timestamp: 1719014400\nx-flusterduck-signature: sha256=...\n```\n\n## deploy-correlation\n\nSits outside the scoring pipeline. You call it by POSTing to the `deploy-correlation` endpoint with an `fd_sec_` key.\n\nWhen a deploy is recorded:\n\n1. The current `page_scores` are captured as `confusion_before`\n2. A deploy row is written with commit hash, author, PR number, and provider\n3. Verification records are created for every open issue on the site\n\nLater, when `compute-scores` runs, it fills in `confusion_after` on deploys that are at least 5 minutes old. Comparing the two snapshots tells you whether confusion went up or down after the deploy.\n\nThe build plugins for Vite and webpack call this endpoint automatically at the end of each production build.\n\n## slack\n\nHandles two interaction types:\n\n1. **Slash commands** (`/flusterduck scores`, `/flusterduck issues`, `/flusterduck help`) that query live data and respond ephemerally\n2. **Block actions** from interactive buttons on alert messages (acknowledge, resolve)\n\nAll requests are verified against Slack\'s signing secret with a 5-minute timestamp tolerance.\n\n## Read and write APIs\n\nTwo more edge functions handle all external data access:\n\n**query** is the read API. It accepts user JWTs, `fd_sec_` secret keys, or `fd_mcp_` MCP keys. Routes include scores, issues, alerts, deploys, trends, sessions, recommendations, revenue, raw event export, and MCP context. Rate limited to 100 requests per minute per org.\n\n**manage** is the write API. JWT only, no API keys. It handles CRUD for orgs, sites, environments, API keys, SDK configs, alert rules, issues, members, webhooks, integrations, and billing. Rate limited to 30 requests per minute per org. Admin-only routes verify the user\'s role before proceeding.\n\n## What the frontend can and can\'t do\n\nThe frontend app (`apps/web`) uses the Supabase client for one thing: authentication. Every `supabase.auth.*` call is allowed. Every data query goes through the `query` edge function. Every write goes through `manage`.\n\nThis is enforced by RLS. All 23 tables have row-level security enabled. The `anon` role is revoked on all data tables. The `authenticated` role is revoked on service-only tables (alert delivery queue, Slack messages, scheduled tasks, Stripe events, waitlist). The only way to read or write data is through the edge functions, which authenticate every request and verify org membership before touching a row.\n\n## Key types\n\n| Prefix | Where it\'s used | What it can do |\n|---|---|---|\n| `fd_pub_` | Browser SDK | Send signals to ingest. Nothing else. |\n| `fd_sec_` | Your server | Read data via query API, record deploys |\n| `fd_mcp_` | AI assistants | Read data via MCP bridge, scoped per key |\n\nAll keys are stored as HMAC-SHA256 hashes. The raw key is shown once at creation and never stored.\n\n## Data flow diagram\n\n```\nBrowser SDK\n |\n | POST /ingest (fd_pub_ key, event batch)\n v\n[ingest] --> events table, signals table, sessions table\n |\n | async invoke\n v\n[compute-scores] --> page_scores table, score_history table\n | |\n | | back-fills confusion_after on recent deploys\n | v\n | deploys table\n | async invoke\n v\n[process-issues] --> ux_issues table, issue_signal_evidence table\n |\n v\n[process-alerts] --> alerts table, alert_delivery_queue, webhook_deliveries\n | |\n +-------------------+\n | |\n v v\n[webhooks] [slack]\n | |\n v v\nYour endpoint Slack channel\n```\n\n## Where things run\n\n| Component | Runtime | Location |\n|---|---|---|\n| Browser SDK | Browser JS | Your users\' browsers |\n| Edge functions | Deno (Supabase) | Supabase edge network |\n| Database | PostgreSQL 15 | Supabase managed |\n| Frontend shell | Next.js App Router | Vercel edge |\n| MCP worker | Cloudflare Worker | Cloudflare edge |\n\nThe `apps/web` frontend on Vercel serves the auth callback, middleware, an `/api/v1` proxy to edge functions, and a `/d.js` redirect to the CDN-hosted SDK script. It doesn\'t render a dashboard UI directly. The proxy layer means your frontend code never needs to know about Supabase URLs.\n'
322
+ },
316
323
  {
317
324
  "slug": "sdk",
318
325
  "title": "SDK reference",
@@ -395,7 +402,7 @@ All plans include a 7-day free trial. No credit card required.
395
402
  "title": "Signals",
396
403
  "description": "The raw evidence of user confusion. Every friction behavior the SDK emits as a signal.",
397
404
  "group": "Product",
398
- "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 33 built-in signal types across four 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 navigated back. 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\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 navigated away. 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\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 navigated to FAQ, support, documentation, or help pages during an active conversion flow. |\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## 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 33 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"
405
+ "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"
399
406
  },
400
407
  {
401
408
  "slug": "scoring",
@@ -404,6 +411,13 @@ All plans include a 7-day free trial. No credit card required.
404
411
  "group": "Product",
405
412
  "content": '# Confusion Scores\n\nA confusion score is a number from 0 to 100 that measures how much friction a page generates relative to its session volume. Direct measurement of how often users hit behavioral friction patterns. Not a satisfaction proxy, not survey data.\n\nMost pages sit between 10 and 40. Above 50 is a page worth prioritizing. Above 70 is a page that\'s actively costing you.\n\n## How scores are calculated\n\nEvery signal has a weight based on its tier. Tier 1 signals (rage clicks, dead clicks, form abandonment on a conversion element) carry roughly 3.5x the weight of tier 3 signals (passive drift, hover thrash). The engine sums the weighted signals for a page, normalizes for session volume, and applies recency decay so old signals fade out.\n\nVolume normalization matters because raw counts don\'t. Ten rage clicks across 20 sessions is a different problem than ten rage clicks across 2,000 sessions. The rate is what counts, not the total.\n\nRecency decay means last week\'s friction matters more than the same friction pattern from three months ago. The half-life is 14 days. A page that used to be bad but isn\'t anymore will show that in the score.\n\n## Score components\n\nThe engine weighs five factors:\n\n**Signal frequency**: signals per session on this page. The rate, not the total.\n\n**Tier weighting**: tier 1 signals multiply by 3.5x relative to tier 3. A single rage click on your checkout button moves the score more than fifty passive drifts.\n\n**Recency factor**: 14-day half-life. Recent signals are weighted more heavily.\n\n**Session coverage**: the percentage of sessions that contained at least one signal on this page. A page where 40% of sessions produce friction is different from a page where 4% do, even if the total signal count is the same.\n\n**Co-occurrence bonus**: when multiple signal types cluster on the same element (rage clicks and dead clicks both on the same button), the score increases beyond what each signal type would contribute individually. Co-occurrence is usually more meaningful than either signal type alone.\n\n## Baselines\n\nEach page has a rolling 7-day baseline. The baseline is used to calculate:\n\n- `trend`: whether the page is `up`, `down`, or `stable` relative to recent history\n- Spike alert thresholds (a `spike` alert fires when the current score exceeds baseline + threshold)\n- Anomaly detection (the `anomaly` trigger compares current score to historical variance, not a fixed number)\n\nNew pages don\'t have a baseline until they\'ve accumulated 7 days of data. During that window, relative alerts won\'t fire for that page. Budget and new-page alerts still will.\n\n## Confusion budgets\n\nA confusion budget is an absolute ceiling for a specific page. Set it and forget it. When the page crosses that number, a `budget` alert fires regardless of whether the score changed recently.\n\nBudgets are set per-page in the dashboard under Settings > Alert Rules, or via the API:\n\n```json\n{\n "trigger_type": "budget",\n "threshold": 50,\n "page_pattern": "/checkout*",\n "channels": ["email", "pagerduty"],\n "name": "Checkout budget"\n}\n```\n\nMost teams set budgets on their highest-value pages. Reasonable starting numbers:\n\n| Page | Budget |\n|---|---|\n| Checkout | 50 |\n| Pricing | 45 |\n| Onboarding | 40 |\n| Account/billing settings | 35 |\n\nThese are starting points, not rules. Set them based on your own baseline data once you\'ve been running for a few weeks.\n\nThe budget concept is simpler than anomaly detection. You don\'t need historical data. You\'re just defining what "too broken" looks like for a specific page.\n\n## Score interpretation\n\n| Score | What it means |\n|---|---|\n| 0-20 | Clean. No meaningful friction patterns. |\n| 21-40 | Normal. Some friction, nothing alarming. |\n| 41-60 | Elevated. At least one issue worth investigating. |\n| 61-80 | High. Multiple users hitting the same problems. |\n| 81-100 | Critical. Something is broken or completely baffling to users. |\n\nThese are rough guides, not absolute thresholds. A score of 45 on a low-traffic onboarding page reads differently than a score of 45 on checkout with 10,000 daily sessions. Use scores for comparison and trend tracking.\n\n## What moves scores quickly\n\n**`rage_click` and `dead_click`** on a critical element will move a page score significantly in a single session. That\'s by design. Those signals are high-confidence.\n\n**`form_abandonment`** on a conversion form gets treated as tier 1 regardless of the page tier. If users are abandoning your checkout form, the engine weights it accordingly.\n\n**`error_recovery_loop`** and `silent_failure_retry` are also fast movers. They indicate users who hit a broken state and keep trying the same thing, which is a very specific kind of friction.\n\n**Tier 3 signals** (passive drift, thrash hover) need volume to move the needle. Twenty instances on the same element from different sessions will. Five won\'t.\n\n## Scores via the API\n\n```bash\ncurl "https://api.flusterduck.com/v1/scores" \\\n -H "Authorization: Bearer fd_sec_xxxxxxxxxxxx"\n```\n\n```json\n{\n "scores": [\n {\n "page": "/checkout",\n "score": 72,\n "trend": "up",\n "top_signal": "rage_click",\n "issue_count": 3,\n "updated_at": "2026-06-10T14:22:05Z"\n },\n {\n "page": "/pricing",\n "score": 41,\n "trend": "stable",\n "top_signal": "dead_click",\n "issue_count": 1,\n "updated_at": "2026-06-10T14:21:47Z"\n }\n ]\n}\n```\n\nGet score history for a specific page:\n\n```bash\ncurl "https://api.flusterduck.com/v1/trends?page=/checkout&days=30" \\\n -H "Authorization: Bearer fd_sec_xxxxxxxxxxxx"\n```\n\n## Scores via MCP\n\n```\nWhat pages have the highest confusion scores right now?\nHow has the /checkout score trended over the past two weeks?\nWhich pages crossed their confusion budgets this week?\nShow me all pages with a score above 50.\n```\n\n## How scores relate to issues\n\nScores are a summary. Issues are the detail. A high score means there\'s friction on the page. Issues tell you specifically what the friction is, which element it\'s on, how many users hit it, and what the likely cause is.\n\nA page can have a high score with no open issues if the signals are dispersed across many elements without enough clustering for the engine to create an issue. That\'s unusual but possible. It usually means several small problems rather than one big one.\n\nA page can also have open issues and a moderate score if the issues are being managed and the fix is in progress. The score reflects live signal volume, not the issue count.\n\nStart with scores to prioritize pages. Then read issues to know what to fix.\n'
406
413
  },
414
+ {
415
+ "slug": "scoring-internals",
416
+ "title": "Scoring engine internals",
417
+ "description": "The exact formula, every signal weight, confidence scaling, baseline math, and normalization.",
418
+ "group": "Product",
419
+ "content": "# Scoring engine internals\n\nThe [confusion scores](/docs/scoring) page covers what scores mean and how to read them. This page covers how they're computed: the exact formula, every signal weight, the confidence system, baseline math, and normalization.\n\nYou're here because a score moved and you want to know why. Good.\n\n## The formula\n\nEvery page score follows this pipeline:\n\n1. Sum the weighted contribution of each signal: `count * weight * confidence`\n2. Divide by active users on the page (volume normalization)\n3. Multiply by the co-occurrence multiplier\n4. Normalize against the page's baseline (if one exists)\n5. Clamp to 0-100\n\nIn code, the core calculation is two lines:\n\n```typescript\nconst rawScore = totalWeightedScore / activeUsers;\nconst score = rawScore * coOccurrenceMultiplier;\n```\n\nIf a baseline exists with 7+ samples, the raw score gets z-score normalized:\n\n```typescript\nconst normalizedScore = ((score - baselineMean) / baselineStdDev) * 25 + 50;\n```\n\nThat maps scores into a distribution centered at 50, where each standard deviation equals 25 points. A page performing at its historical average lands at 50. One standard deviation worse lands at 75. Two worse hits 100.\n\nWithout a baseline, the raw score is clamped directly to 0-100.\n\n## Signal weights\n\nEvery signal has a fixed weight that reflects how strong an indicator of friction it is. Higher weight means a single fire moves the score more. The engine uses these weights unless you've set custom weights via the SDK config.\n\n### Tier 1 (high-confidence friction, weights 12-38)\n\n| Signal | Weight | What it detects |\n|---|---|---|\n| `active_funnel_rejection` | 38 | High-intent user hits friction at terminal step, leaves without converting |\n| `dead_end_submit` | 30 | Submit button clicked, no network request or DOM change follows |\n| `layout_shift_rage` | 28 | Click lands where a target was before a layout shift moved it |\n| `rage_click` | 25 | 3+ clustered clicks within two seconds |\n| `error_recovery_loop` | 24 | 3+ submit attempts with repeated errors on the same form |\n| `silent_failure_retry` | 22 | Repeated clicks on a primary action while loading stays unresolved |\n| `overlay_dismiss_struggle` | 22 | Repeated Escape presses and close clicks while a modal stays open |\n| `form_abandonment` | 20 | User enters form data, exits without submitting |\n| `disabled_element_attempt` | 20 | User clicks a disabled control more than once |\n| `focus_trap` | 20 | Keyboard focus can't escape a container despite Tab and Escape |\n| `accidental_click_bounce` | 18 | Navigation reversed almost immediately after the click that caused it |\n| `dead_click` | 12 | Click on a non-interactive element with no response |\n\n### Tier 2 (behavioral patterns, weights 10-18)\n\n| Signal | Weight |\n|---|---|\n| `u_turn_navigation` | 18 |\n| `information_maze` | 18 |\n| `multi_step_reset` | 18 |\n| `close_click_reversal` | 18 |\n| `load_abandonment` | 16 |\n| `query_thrashing` | 15 |\n| `filter_spiral` | 15 |\n| `tab_thrash` | 15 |\n| `copy_paste_rework` | 14 |\n| `repeat_form_input` | 14 |\n| `sticky_obstruction_click` | 14 |\n| `post_action_anxiety` | 13 |\n| `settings_hop` | 12 |\n| `false_swipe` | 12 |\n| `keyboard_nav_frustration` | 10 |\n\n### Tier 3 (ambient signals, weights 6-15)\n\n| Signal | Weight |\n|---|---|\n| `thrash_cursor` | 15 |\n| `help_hunt` | 14 |\n| `confidence_collapse` | 13 |\n| `target_near_miss` | 12 |\n| `user_confusion_idle` | 12 |\n| `thrash_hover` | 10 |\n| `text_selection_thrash` | 10 |\n| `jerky_scrolling` | 9 |\n| `viewport_thrashing` | 9 |\n| `visibility_thrashing` | 9 |\n| `passive_drift` | 8 |\n| `scroll_depth_abandon` | 6 |\n\n### Revenue tier (weights 34-40)\n\n| Signal | Weight |\n|---|---|\n| `value_collapse_downgrade` | 40 | \n| `layout_exhaustion_settling` | 34 |\n\nRevenue signals carry the heaviest weights because they represent friction that directly costs money. A single `value_collapse_downgrade` fire (user intended a higher plan, hit friction, bought a lower one) contributes 40 points per fire before normalization.\n\n### Custom weights\n\nYou can override any signal weight through the SDK config. The engine merges your custom weights on top of the defaults:\n\n```typescript\nconst weights = { ...SIGNAL_WEIGHTS, ...customWeights };\n```\n\nIf you set `rage_click` to 50, it'll carry double the default influence. If you set `passive_drift` to 0, it won't contribute at all.\n\nUnknown signal types that somehow reach the engine (shouldn't happen, but the code is defensive) get a fallback weight of 10.\n\n## The confidence system\n\nEach signal fire can carry a confidence value between 0 and 1. Confidence scales the weighted contribution of that specific fire:\n\n```typescript\nfunction scoreFire(weight: number, count: number, confidence = 1): number {\n const cf = Math.max(0, Math.min(1, confidence));\n return count * weight * cf;\n}\n```\n\nA rage click detected from a clear 3-click cluster in 800ms might fire with confidence 0.95. A borderline detection (exactly 3 clicks at exactly 2000ms) might fire at 0.4. The score reflects that difference.\n\nKey properties of the confidence system:\n\n- **Defaults to 1.** If no confidence is provided, the signal contributes at full weight. All pre-confidence-system behavior is preserved.\n- **Clamped to [0, 1].** Confidence can never amplify a signal beyond its base weight. A value of 5 gets clamped to 1. A value of -1 gets clamped to 0.\n- **NaN falls back to 1.** Malformed confidence values are treated as full confidence rather than zeroed out, so a bug in confidence reporting can't silently suppress real signals.\n\nIn the edge function, confidence works slightly differently. Instead of averaging confidence per signal type, the server sums per-fire confidence values:\n\n```typescript\nagg.confidenceSum += cf;\n// ...\nweighted_score = agg.confidenceSum * weight;\n```\n\nWhen every fire has confidence 1.0, this equals `count * weight`, which is the same as the legacy formula. When fires have mixed confidence, the sum naturally weights higher-confidence detections more heavily.\n\n## Volume normalization\n\nRaw weighted scores are divided by the number of active users on the page:\n\n```\nrawScore = totalWeightedScore / activeUsers\n```\n\nTen rage clicks across 20 sessions is a 12.5 raw score (`10 * 25 / 20`). Ten rage clicks across 2,000 sessions is 0.125 (`10 * 25 / 2000`). The rate matters, not the count.\n\nIf `activeUsers` is zero, negative, or NaN, the engine returns an empty score (score 0, no breakdown, no trend). Division by zero doesn't happen.\n\n## Co-occurrence multiplier\n\nWhen multiple users are frustrated on the same page simultaneously, the score increases. The multiplier is a step function based on how many users showed frustration signals within the scoring window:\n\n| Frustrated users in window | Multiplier |\n|---|---|\n| 0-9 | 1.0 (no amplification) |\n| 10-19 | 1.5 |\n| 20-49 | 2.0 |\n| 50+ | 2.5 |\n\nThis is applied after volume normalization but before baseline normalization:\n\n```\nscore = rawScore * coOccurrenceMultiplier\n```\n\nThe logic: if 50 users are all hitting friction on the same page at the same time, something is probably broken. That's qualitatively different from 50 users hitting friction over a week. The multiplier captures that distinction.\n\n## Baseline computation\n\nA baseline is the page's historical \"normal.\" It's computed from score history once a page has 7+ days of data.\n\nThe algorithm collects all score history entries, then computes:\n\n- **Mean:** arithmetic average of all scores in the history\n- **Standard deviation:** sample standard deviation (divides by `n-1`)\n- **Time-of-day means:** average score per UTC hour (0-23), so the engine knows this page is normally noisier at 2pm than 3am\n- **Day-of-week means:** average score per weekday (0=Sunday through 6=Saturday)\n\n```typescript\nconst mean = scores.reduce((a, b) => a + b, 0) / scores.length;\nconst variance = scores.reduce((sum, s) => sum + (s - mean) ** 2, 0) \n / (scores.length > 1 ? scores.length - 1 : 1);\nconst stdDev = Math.sqrt(variance);\n```\n\nThe minimum data requirement is 7 days of elapsed time between the oldest and newest score entries. This isn't 7 data points; it's 7 calendar days of coverage. A page could have 672 data points (one per 15 minutes) or 14 (twice a day), but the span must be at least 7 days.\n\n### Time-adjusted baselines\n\nThe `getTimeAdjustedBaseline` function shifts the baseline mean based on the current hour and day:\n\n```\nadjusted = mean + (hourMean - mean) + (dayMean - mean)\n```\n\nIf the page's overall mean is 25 but its Monday-2pm mean is 35, the adjusted baseline for a Monday at 2pm is 35. Anomaly detection uses this adjusted value, so a spike that's normal for Monday afternoon won't trigger a false alarm.\n\n## Baseline normalization (z-score mapping)\n\nWhen a page has a mature baseline (7+ samples), the raw score is mapped to a 0-100 scale using z-score normalization:\n\n```\nnormalizedScore = ((score - baselineMean) / max(baselineStdDev, 1)) * 25 + 50\n```\n\nThe `max(std_dev, 1)` floor prevents division by zero when a page has constant scores (zero variance). Without it, a page that always scores exactly 20 would produce infinity when it scores 21.\n\nWhat this formula produces:\n\n| Raw score vs baseline | Normalized score |\n|---|---|\n| 2 std devs below mean | 0 |\n| 1 std dev below mean | 25 |\n| At the mean | 50 |\n| 1 std dev above mean | 75 |\n| 2 std devs above mean | 100 |\n\nThe result is clamped to [0, 100] after normalization. A page that's 3 standard deviations above its baseline still reads as 100.\n\n## Deviation and trend\n\nDeviation is the z-score before clamping. It tells you how many standard deviations the current score is from the baseline mean:\n\n```typescript\ndeviation = (normalizedScore - 50) / 25;\n```\n\nTrend uses deviation to classify the direction:\n\n| Deviation | Trend |\n|---|---|\n| > 1.0 | `up` (friction is increasing) |\n| < -1.0 | `down` (friction is decreasing) |\n| -1.0 to 1.0 | `stable` |\n\nPages without a mature baseline always report `stable`. The engine won't guess at a trend without enough history.\n\n## Signal breakdown\n\nEvery page score includes a breakdown array sorted by weighted contribution (highest first). Each entry contains:\n\n```typescript\n{\n signal: 'rage_click', // which signal\n count: 12, // how many fires\n weight: 25, // the weight used (default or custom)\n weighted_score: 300, // count * weight * confidence\n percentage: 64 // share of totalWeightedScore\n}\n```\n\nThe `dominant_signal` on the PageScore is the first entry in this sorted breakdown: the signal contributing the most to this page's score right now.\n\nPercentages are rounded integers and sum to approximately 100. They tell you which signal type is driving the score. If `rage_click` is at 64% and `dead_click` is at 22%, the score is mostly about rage clicks.\n\n## Budget evaluation\n\nConfusion budgets work independently from the baseline system. A budget defines an absolute ceiling for a specific page pattern:\n\n```typescript\n{\n page_pattern: '/checkout*',\n target_score: 50,\n period: 'weekly',\n escalation_action: 'alert'\n}\n```\n\nThe budget check counts how much time within the current period the page spent above the target score. It walks the score history, calculates the milliseconds over budget, and reports consumption as a percentage:\n\n```typescript\nconst consumptionPercent = Math.round((overBudgetMs / totalPeriodMs) * 100);\n```\n\n| Consumption | Status |\n|---|---|\n| < 70% | Within budget |\n| 70-99% | Warning |\n| 100% | Exhausted |\n\nBudgets also track the number of calendar days the page exceeded the target, reported in the alert message.\n\nPeriod bounds are computed in UTC: daily resets at midnight UTC, weekly resets on Sunday, monthly resets on the first of the month.\n\n## Anomaly detection\n\nThe `isAnomaly` function compares the current score against the time-adjusted baseline plus a configurable standard deviation multiplier (default 2):\n\n```typescript\nfunction isAnomaly(currentScore, baseline, stdDevMultiplier = 2) {\n const adjustedBaseline = getTimeAdjustedBaseline(baseline);\n const threshold = adjustedBaseline + baseline.std_dev * stdDevMultiplier;\n return currentScore > threshold;\n}\n```\n\nAt the default multiplier, a score needs to exceed the adjusted baseline by 2 standard deviations to qualify as an anomaly. This means roughly 2.5% of natural variation would trigger it, assuming normal distribution.\n\n## Trend detection\n\nThe `isTrending` function checks whether recent scores represent a sustained increase over the baseline:\n\n```typescript\nfunction isTrending(recentScores, baseline, increasePercent = 30) {\n if (recentScores.length < 7) return false;\n const recentMean = recentScores.reduce((a, b) => a + b, 0) / recentScores.length;\n const percentIncrease = ((recentMean - baseline.mean) / max(baseline.mean, 1)) * 100;\n return percentIncrease >= increasePercent;\n}\n```\n\nAt least 7 recent data points are required. The default threshold is a 30% increase in the recent mean compared to the historical mean. This catches slow drift that wouldn't trigger a spike alert but represents a sustained regression.\n\n## Score labels\n\nScores map to human-readable labels and duck states:\n\n| Score | Label | Duck state |\n|---|---|---|\n| 0-20 | Calm. Everything is normal. | `calm` |\n| 21-40 | Mild friction. Worth watching. | `watching` |\n| 41-60 | Noticeable confusion. Something changed. | `alert` |\n| 61-80 | Significant frustration. Investigate. | `flustered` |\n| 81-100 | Critical. Something is broken. Fix it now. | `critical` |\n\n## Worked example\n\nA checkout page has 100 active users. Five users rage-clicked the submit button (confidence 0.9 each), and eight users abandoned the form (confidence 1.0 each). No co-occurrence spike. The page's baseline mean is 30 with a standard deviation of 8.\n\nStep 1, weighted contributions:\n\n```\nrage_click: 5 * 25 * 0.9 = 112.5\nform_abandonment: 8 * 20 * 1.0 = 160.0\ntotal = 272.5\n```\n\nStep 2, volume normalization:\n\n```\nrawScore = 272.5 / 100 = 2.725\n```\n\nStep 3, co-occurrence (no spike, multiplier is 1.0):\n\n```\nscore = 2.725 * 1.0 = 2.725\n```\n\nStep 4, baseline normalization:\n\n```\nnormalizedScore = ((2.725 - 30) / 8) * 25 + 50\n = (-27.275 / 8) * 25 + 50\n = -3.409 * 25 + 50\n = -85.23 + 50\n = -35.23\n```\n\nStep 5, clamp:\n\n```\nscore = max(0, min(100, -35.23)) = 0\n```\n\nThe page scores 0. That raw activity level is far below the page's normal friction. The baseline mean of 30 represents much heavier historical signal volume, so current activity looks calm by comparison.\n\nIf the same page had no baseline yet, the raw score of 2.725 would clamp directly to 2.7. Without historical context, the engine reports the absolute value.\n\n## Edge function vs package\n\nThe scoring package (`@flusterduck/scoring`) and the `compute-scores` edge function implement the same algorithm with one difference in how they aggregate confidence.\n\nThe package's `computePageScore` takes pre-aggregated signal counts with an averaged confidence per signal type. The edge function aggregates raw signal events from the database, summing per-fire confidence values directly. Both produce `confidenceSum * weight` as the weighted score for a signal type, which equals `count * weight` when all fires have confidence 1.0.\n\nThe edge function also writes scores to the `page_scores` table (upserted by site and page) and appends to `score_history` for baseline computation.\n"
420
+ },
407
421
  {
408
422
  "slug": "issues",
409
423
  "title": "Issues",
@@ -411,6 +425,13 @@ All plans include a 7-day free trial. No credit card required.
411
425
  "group": "Product",
412
426
  "content": '# Issues\n\nAn issue is what Flusterduck produces when enough users hit the same friction pattern on the same element. Not a raw signal count. A clustered, evidence-backed problem with a specific location, a root cause hypothesis, and a lifecycle that tracks whether it got fixed.\n\nIssues work like tickets. They get statuses, triage notes, assignees, and verification after you ship a fix.\n\n## How issues are created\n\nThe scoring engine watches for signal clusters: the same signal type on the same element, repeated across sessions from different users. When a cluster crosses the confidence threshold, an issue is created.\n\nOne user rage-clicking a button 40 times doesn\'t create an issue. Forty different users rage-clicking the same button over three days does.\n\nThe clustering algorithm weighs:\n- Signal type match (same type, or co-occurring types on the same element)\n- Element selector match (fuzzy-matched to handle minor DOM changes between deploys)\n- Session diversity (signals from distinct sessions, not a single frustrated user)\n- Time window (7 days for initial creation, longer for ongoing patterns)\n\n## Issue fields\n\n| Field | Description |\n|---|---|\n| `id` | Unique identifier with `iss_` prefix |\n| `title` | Generated description of the problem |\n| `page` | Page path where the issue occurs |\n| `selector` | CSS selector of the affected element |\n| `signal_type` | Dominant signal type driving the cluster |\n| `signal_count` | Total signals in the cluster |\n| `severity` | 0-100 score based on signal tier, volume, page importance, and revenue exposure |\n| `status` | Current lifecycle state |\n| `hypothesis` | Engine-generated root cause guess |\n| `sessions` | Session IDs containing this friction pattern |\n| `verifications` | Deploy-correlated verification records |\n\n## Issue lifecycle\n\n**`open`**: the issue exists and nobody has acted on it. New issues land here automatically.\n\n**`triaged`**: someone reviewed it and confirmed it\'s real. Add a note with context before moving it here. "Confirmed on mobile Safari. The submit button is obscured by the cookie banner at 375px." is more useful than just a status change.\n\n**`in_progress`**: someone is actively working a fix. This prevents duplicate effort when multiple engineers are looking at the same issue list.\n\n**`verified`**: the issue was resolved and the scoring engine confirmed the fix held after the next deploy. The friction pattern didn\'t return.\n\n**`regressed`**: the issue was resolved, but the confusion pattern came back. The engine re-opens it automatically. This happens when a fix gets reverted, a related change reintroduces the problem, or the original fix was partial.\n\n**`ignored`**: known issue, won\'t fix. Alert rules won\'t fire for ignored issues, and they won\'t count toward your open issue total. Use this deliberately, not as a way to clear your queue.\n\n### Moving issues forward\n\nVia the API:\n\n```bash\ncurl -X POST https://api.flusterduck.com/v1/issues/iss_xxxxxxxxxxxx \\\n -H "Authorization: Bearer fd_sec_xxxxxxxxxxxx" \\\n -H "Content-Type: application/json" \\\n -d \'{\n "status": "triaged",\n "note": "Confirmed on mobile. Submit button overlaps cookie banner at 375px.",\n "assigned_to": "maya"\n }\'\n```\n\nVia MCP:\n\n```\nTriage the open issues and tell me what to fix first.\nMark issue iss_xxxxxxxxxxxx as in_progress and assign it to alex.\n```\n\n## Severity scoring\n\nSeverity (0-100) reflects how much a specific issue is hurting your users. Higher severity means more sessions affected, higher-tier signals, more revenue exposure.\n\nIt incorporates:\n- Signal tier of the dominant signal type\n- Signal volume and percentage of sessions affected\n- Page importance (checkout and pricing have higher baseline weights)\n- Revenue exposure (whether conversion events were tracked near this element in affected sessions)\n- Recency (signals from the last 48 hours weight more than older ones)\n\nUse severity for relative prioritization within your open issue list. Don\'t treat it as an absolute scale. A severity 40 issue on /checkout probably matters more than a severity 70 issue on your admin changelog page.\n\n## Verification\n\nAfter each deploy, the scoring engine runs verification on all open and recently-resolved issues.\n\nFor **resolved** issues: checks whether the signal cluster has declined. If signals dropped, it marks the issue `verified`. If signals are still at pre-fix levels, it marks it `regressed`.\n\nFor **open** issues: recalculates evidence, updates `signal_count` and `severity`, and checks whether the pattern is intensifying or fading.\n\nVerification needs deploy records to work correctly. Without them, the engine can\'t know when to run the verification cycle. See [Deploy Correlation](./deploy-correlation).\n\n## Evidence sessions\n\nEvery issue includes a `sessions` array: session IDs where the friction pattern appears. These are the most useful thing in an issue for diagnosing root cause.\n\n```bash\ncurl "https://api.flusterduck.com/v1/session?session_id=ses_xxxxxxxxxxxx" \\\n -H "Authorization: Bearer fd_sec_xxxxxxxxxxxx"\n```\n\nThe session endpoint returns the full chronological event timeline: page views, signal types, selectors, timestamps. You can see exactly what the user did before and after the friction moment.\n\nVia MCP:\n\n```\nInvestigate session ses_xxxxxxxxxxxx.\nWhich sessions show rage clicks on the upgrade button?\n```\n\n## Root cause hypotheses\n\nThe engine generates a hypothesis for every issue based on signal type and element context:\n\n- Dead clicks on a button: "Element appears clickable but navigates to an unexpected destination or produces no visible response."\n- Rage clicks on a form field: "Field appears interactive but input is blocked or significantly delayed."\n- Form abandonment at a specific step: "Required information may be unclear, the step label may not match user expectation, or a validation message is obscured."\n\nHypotheses are starting points. They\'re generated from behavioral signals, not from visual inspection of your UI. Confirm or reject them with the session evidence and your own knowledge of the page.\n\n## Getting all issues via the API\n\n```bash\ncurl "https://api.flusterduck.com/v1/issues?status=open" \\\n -H "Authorization: Bearer fd_sec_xxxxxxxxxxxx"\n```\n\n```json\n{\n "issues": [\n {\n "id": "iss_xxxxxxxxxxxx",\n "title": "Dead clicks on complete purchase button",\n "page": "/checkout",\n "selector": "button[type=\'submit\']",\n "signal_type": "dead_click",\n "signal_count": 47,\n "severity": 84,\n "status": "open",\n "created_at": "2026-06-08T11:30:00Z"\n }\n ],\n "total": 1\n}\n```\n\nFilter by any status: `open`, `triaged`, `in_progress`, `verified`, `resolved`, `regressed`, `ignored`.\n\n## Getting full issue detail\n\n```bash\ncurl "https://api.flusterduck.com/v1/issues/iss_xxxxxxxxxxxx" \\\n -H "Authorization: Bearer fd_sec_xxxxxxxxxxxx"\n```\n\nReturns the full issue object including `hypothesis`, `sessions`, and `verifications`.\n\n## What issues don\'t cover\n\nIssues require signal clusters from multiple users. Single-session bugs, one-off failures, and problems that affect a small percentage of users on a specific device or browser may not generate enough signal volume to create an issue automatically.\n\nFor those cases, use `signal()` manually to attach extra metadata to auto-detected signals. The more context you give the engine, the faster it can cluster related signals:\n\n```ts\nsignal(\'error_recovery_loop\', {\n form: \'payment\',\n step: \'card-entry\',\n error_code: \'card_declined\',\n})\n```\n\nCustom metadata gets attached to the issue evidence when a cluster forms.\n'
413
427
  },
428
+ {
429
+ "slug": "heatmaps",
430
+ "title": "Heatmaps",
431
+ "description": "Three friction visualizations: per-element click heatmap, page-level viewport heatmap, and element friction map.",
432
+ "group": "Product",
433
+ "content": '# Heatmaps\n\nFlusterduck heatmaps don\'t show where users click. They show where users struggle.\n\nTraditional heatmaps paint every click the same color. A confident tap on "Add to Cart" and a frustrated rage click on a broken button both render as orange blobs. That\'s not useful. You already know people click your buttons. The question is which clicks represent confusion.\n\nFlusterduck only renders friction signals: rage clicks, dead clicks, and disabled element attempts. Normal interactions don\'t appear. What you see is purely the spatial distribution of frustration on your page.\n\nThere are three heatmap views, each at a different zoom level. The element heatmap shows where clicks land on a single element. The page heatmap shows where friction concentrates across the viewport. The friction map ranks every element on a page by signal volume. Together they answer: where is the problem, how bad is it, and what else on this page is struggling too.\n\n## Element heatmap\n\nThe element heatmap zooms into one CSS selector and plots exactly where frustration clicks landed within that element\'s bounding box. Every dot is a rage click, dead click, or disabled element attempt.\n\nThis matters because position within an element tells you things the signal type alone can\'t. If every rage click on a wide navigation bar clusters in the right 10%, users are probably aiming for something that isn\'t there, or a submenu that doesn\'t open. If dead clicks scatter evenly across a hero image, users think the whole image is clickable. If they cluster on text inside the image, users are trying to select or click a phrase they think is a link.\n\n### How positions are captured\n\nThe SDK records click position as a 0-to-1 fraction relative to the element\'s bounding box. For rage clicks, the SDK computes the centroid of the click burst (the average x/y of all clicks in that burst) and normalizes it against the element\'s width and height. For dead clicks and disabled element attempts, it\'s the single click position.\n\nThe raw values emitted are `hx` and `hy`, integers from 0 to 1000 (where 500,500 is the center). The API normalizes these to `rx` and `ry` floats from 0 to 1 before returning them.\n\nNo pixel coordinates leave the browser. The SDK only stores the relative position within the element, so there\'s nothing to reconstruct about viewport size, screen resolution, or device type from this data.\n\n### Reading the panel\n\nThe element heatmap panel shows up on the issue detail page when the issue has an `element_selector`. It renders a rectangular box representing the element, with colored dots at their relative positions:\n\n| Dot color | Signal type |\n|---|---|\n| Red | Rage click |\n| Orange | Dead click |\n| Purple | Disabled element attempt |\n\nBrighter clusters (dots stacked on top of each other) mean repeated frustration at the same spot. A crosshair overlay marks the center of the element for reference.\n\nThe legend at the bottom shows counts per signal type. If you see "14 rage click, 6 dead click", that\'s 20 total friction events on this one element, with most of them being repeated rapid clicking.\n\n### What patterns to look for\n\n**Cluster in one corner.** Users are aiming for something specific that either doesn\'t exist or doesn\'t respond. Common on navigation bars, tab groups, and cards with multiple clickable regions.\n\n**Even spread across the whole element.** Users think the entire element is interactive. Common on images, banners, and cards that lack a clear CTA. The fix is usually adding a visible button or making the whole surface clickable.\n\n**Cluster on text inside a non-interactive element.** Users are trying to click a link that isn\'t a link. Check whether the text is styled like a hyperlink (blue, underlined) without actually being one.\n\n**Two separate clusters.** Two different user intentions hitting the same element. Maybe one group wants to expand it and another wants to select it. Check whether the element needs to be split into distinct interactive regions.\n\n### API\n\n```\nGET /query/element-heatmap?site_id={id}&page=/checkout&selector=button.submit\n```\n\nResponse:\n\n```json\n{\n "page": "/checkout",\n "selector": "button.submit",\n "count": 23,\n "points": [\n { "rx": 0.482, "ry": 0.511, "signal": "rage_click" },\n { "rx": 0.91, "ry": 0.33, "signal": "dead_click" }\n ]\n}\n```\n\nEach point has `rx` and `ry` (0 to 1 fractions of the element\'s width and height) and a `signal` string. The API caps the response at 300 points per request, pulling the most recent signals first.\n\nThe `selector` parameter must be URL-encoded. CSS selectors with brackets, spaces, or special characters need proper encoding:\n\n```bash\ncurl "https://rhwhnkrqjlzyzcdhvyky.supabase.co/functions/v1/query/element-heatmap?site_id=YOUR_SITE_ID&page=%2Fcheckout&selector=button.submit" \\\n -H "Authorization: Bearer fd_sec_xxxxxxxxxxxx"\n```\n\n## Page heatmap\n\nThe page heatmap zooms out to the full viewport. Instead of showing clicks on one element, it plots every friction event on the page as a viewport-relative dot. You see where on the screen users are struggling, regardless of which element they\'re hitting.\n\nThis is the view that answers "which region of my page is generating the most confusion?" It\'s particularly useful for pages with complex layouts where multiple elements compete for attention.\n\n### How viewport positions work\n\nThe SDK captures viewport-relative coordinates alongside every click-based signal. When a rage click fires, the SDK records `vx` and `vy` as integers from 0 to 1000, where (0,0) is the top-left corner of the visible viewport and (1000,1000) is the bottom-right. The API normalizes these to 0-to-1 floats before returning.\n\nThese coordinates represent where the click happened relative to the browser viewport at that moment, not relative to the document. A click at the very bottom of a long scrolled page still maps to the viewport position the user saw. This means the heatmap reflects the user\'s visual experience, not the document\'s scroll position.\n\n### Reading the panel\n\nThe page heatmap panel appears on every issue detail page. It renders a viewport-shaped box with a subtle grid overlay, dots colored by signal type (same red/orange/purple scheme as the element heatmap), and crosshairs marking the center.\n\nBright clusters mean repeated friction in the same viewport region. Scattered dots with no clustering mean friction is distributed across the page, which usually points to a general layout or navigation problem rather than a single broken element.\n\n### What patterns to look for\n\n**Cluster in the top-right corner.** Users are hitting the area where close buttons, account menus, or navigation items live. If you see rage clicks here, something in that region isn\'t responding.\n\n**Band of friction across the middle.** Probably a sticky element (floating CTA, cookie banner, toolbar) that\'s intercepting clicks meant for content underneath. Very common on mobile.\n\n**Dense cluster surrounded by empty space.** One element is generating all the friction. Cross-reference with the friction map to identify which selector.\n\n**Scattered dots everywhere, no clusters.** The page itself is the problem. Layout is confusing, nothing is clear, users are clicking around trying to find something. Check your information hierarchy.\n\n### API\n\n```\nGET /query/page-heatmap?site_id={id}&page=/checkout\n```\n\nResponse:\n\n```json\n{\n "page": "/checkout",\n "count": 47,\n "points": [\n { "vx": 0.512, "vy": 0.234, "signal": "rage_click", "el": "button.submit" },\n { "vx": 0.891, "vy": 0.044, "signal": "dead_click", "el": "div.header-logo" }\n ]\n}\n```\n\nEach point has `vx` and `vy` (viewport fractions), a `signal` type, and an optional `el` field with the CSS selector of the element that was clicked. The API returns up to 500 points.\n\n## Friction map\n\nThe friction map isn\'t a spatial visualization. It\'s a ranked list of every element on a page that\'s generating friction signals, ordered by signal count. A leaderboard of broken and confusing elements.\n\nWhile the element and page heatmaps answer "where," the friction map answers "what" and "how much." It shows you every element the scoring engine has flagged on a given page, with bar charts sized proportionally to signal volume.\n\n### Reading the panel\n\nThe friction map panel appears on issue detail pages. Each row in the list shows:\n\n- An icon for the dominant signal type on that element\n- The CSS selector (e.g., `button.cta-primary`, `div.pricing-toggle`)\n- A horizontal bar colored by score severity (green through red), scaled relative to the highest-signal element on the page\n- The dominant signal type and number of affected users\n- The raw signal count\n\nThe element that belongs to the current issue is highlighted with a "This issue" badge so you can see it in context. If the current issue\'s element is third on the list, the two elements above it are generating even more friction and might deserve their own issues.\n\nThe panel shows up to 12 elements. The API returns up to 100.\n\n### What patterns to look for\n\n**One element dominates.** Most of the friction is concentrated on a single element. Fix that one thing and the page score will drop.\n\n**Top 4-5 elements have similar counts.** Friction is distributed. No single fix will move the score much. This usually means a structural or layout problem rather than a broken control.\n\n**The current issue\'s element is far down the list.** Other elements on the page are worse. Consider whether the higher-ranked elements should be prioritized first.\n\n**Multiple elements share the same dominant signal type.** If every top element shows dead clicks, users are clicking on things that look interactive but aren\'t. That\'s a visual design issue, not a bug.\n\n### API\n\nThe friction map uses the elements endpoint:\n\n```\nGET /query/elements?site_id={id}&page=/checkout\n```\n\nResponse:\n\n```json\n{\n "elements": [\n {\n "selector": "button.submit",\n "page": "/checkout",\n "score": 42,\n "dominant_signal": "rage_click",\n "signal_count": 31,\n "affected_users": 18\n },\n {\n "selector": "div.promo-banner",\n "page": "/checkout",\n "score": 28,\n "dominant_signal": "dead_click",\n "signal_count": 14,\n "affected_users": 11\n }\n ]\n}\n```\n\nElements are sorted by score descending. The `dominant_signal` is whichever signal type appeared most frequently on that element. `affected_users` counts distinct sessions, not total clicks.\n\n## How these differ from traditional heatmaps\n\nTraditional heatmap tools (Hotjar, Clarity, FullStory) record every mouse movement, every click, every scroll event. They show aggregate behavior. That\'s useful for understanding user flows, but it drowns signal in noise. A button with 10,000 healthy clicks and 15 rage clicks looks like a hot spot in a traditional heatmap. In Flusterduck it shows 15 dots.\n\n| | Traditional heatmap | Flusterduck heatmap |\n|---|---|---|\n| Data source | All clicks and mouse movement | Only friction signals (rage, dead, disabled) |\n| Privacy | Often requires session replay | No replay, no PII, no DOM recording |\n| Signal-to-noise | Low. Every click is equal. | High. Only confused users appear. |\n| Granularity | Page level only | Element level, viewport level, and per-element ranked list |\n| Purpose | "Where do users click?" | "Where do users struggle?" |\n| Volume needed | Hundreds of sessions for a readable heatmap | A handful of friction events is enough |\n\nThe trade-off is coverage. Flusterduck heatmaps won\'t show you general click patterns or scroll depth. That\'s on purpose. If you need "where do people click," use a traditional tool. If you need "where are people confused," this is it.\n\n## Combining heatmap views\n\nThe three views work best together. Start with the friction map to see which elements are struggling. Pick the worst one. Open its element heatmap to see where on the element users are clicking. Then zoom out to the page heatmap to see if the problem is isolated or part of a broader spatial pattern.\n\nA typical investigation flow:\n\n1. Friction map shows `div.pricing-toggle` at the top with 40 rage clicks\n2. Element heatmap shows all clicks clustered on the right side of the toggle, near the "Annual" label\n3. Page heatmap shows the same cluster plus a secondary cluster near the plan cards below\n\nThat tells you users are rage-clicking the annual/monthly toggle (it\'s probably not responding fast enough or not giving visual feedback) and then rage-clicking the plan cards below (probably because they\'re still showing monthly prices after the user thought they switched to annual).\n\nOne view gives you the what. Two views give you the where. All three give you the why.\n'
434
+ },
414
435
  {
415
436
  "slug": "alerts",
416
437
  "title": "Alerts",
@@ -460,6 +481,27 @@ All plans include a 7-day free trial. No credit card required.
460
481
  "group": "Integrations",
461
482
  "content": '# Slack Integration\n\nConnect once and your team gets friction alerts in Slack, interactive buttons to triage them without opening a dashboard, and a slash command to pull live scores and open issues from any channel.\n\n## Setup\n\n1. Go to Settings > Integrations > Slack in your Flusterduck dashboard.\n2. Click "Connect Slack."\n3. Authorize the Flusterduck app for your workspace and select the default alert channel.\n\nAfter connecting, you can route individual alert rules to specific channels. The `/flusterduck` slash command becomes available workspace-wide.\n\nSlack incoming webhooks are tied to the default channel selected during install. Per-rule channel routing uses the Flusterduck bot token and Slack\'s `chat.postMessage` API instead.\n\n## Slash commands\n\n### /flusterduck scores\n\nReturns your top pages ranked by current confusion score with trend indicators:\n\n```\n/flusterduck scores\n\n/checkout 72 \u2191 (was 58, +14)\n/pricing 41 \u2192\n/onboarding 38 \u2191 (was 31, +7)\n/account 24 \u2193 (was 29, -5)\n/dashboard 19 \u2192\n```\n\n### /flusterduck issues\n\nLists open and triaged issues ranked by severity:\n\n```\n/flusterduck issues\n\n[HIGH] Dead clicks on #place-order /checkout 47 signals\n[HIGH] Form abandonment at billing step /checkout 31 signals\n[MED] Rage clicks on upgrade CTA /pricing 22 signals\n[LOW] Navigation loop on /settings/team 8 signals\n```\n\n### /flusterduck issues [page]\n\nScopes the list to a specific page:\n\n```\n/flusterduck issues /checkout\n```\n\n### /flusterduck help\n\nPrints available commands and syntax.\n\n## Alert messages\n\nWhen an alert rule fires, Flusterduck posts to the configured channel:\n\n```\nALERT: Checkout confusion spike\nPage: /checkout\nScore: 72 (was 46, +26)\nTrigger: spike (+20 threshold)\nRule: Post-deploy spike\nTop signal: rage_click (31 new)\n\n[Acknowledge] [Start investigating] [View in dashboard]\n```\n\nEach message includes interactive buttons to move the alert through its lifecycle without leaving Slack.\n\n## Interactive buttons\n\n**Acknowledge**: marks the alert `acknowledged`. Escalation stops. Use this the moment someone takes ownership, even before you know the cause.\n\n**Start investigating**: moves the alert to `investigating`. Signals to the rest of the team that it\'s actively being worked.\n\n**Resolve**: closes the alert. If a deploy fixed the underlying issue, the scoring engine will verify it on the next cycle and mark related issues `verified`.\n\nThese buttons require the Flusterduck bot to be a member of the channel the alert routes to. If you add a new private channel or a public channel where your workspace requires membership, invite it: `/invite @Flusterduck`.\n\n## Alert routing\n\nRoute individual alert rules to specific channels. Most teams separate noise from urgency:\n\n| Channel | Alert types |\n|---|---|\n| `#incidents` | Spike alerts on critical pages (`/checkout`, `/pricing`) |\n| `#product` | New issue alerts, weekly summaries, positive (improvement) alerts |\n| `#eng` | Budget alerts, anomaly alerts, regression alerts |\n\nSet the channel per-rule when creating or editing in Settings > Alert Rules, or via the API:\n\n```json\n{\n "name": "Checkout spike",\n "trigger_type": "spike",\n "threshold": 20,\n "channels": ["slack"],\n "page_pattern": "/checkout*",\n "slack_channel": "#incidents"\n}\n```\n\nPositive alerts should never go to `#incidents`. Route them to a wins channel so they don\'t get lost in incident noise.\n\nIf a rule has `slack` as a channel but no `slack_channel`, Flusterduck falls back to the Slack incoming webhook and posts to the install-selected default channel.\n\n## Bot permissions\n\nThe Flusterduck Slack app requests three permissions:\n\n- `chat:write`: post alert messages and slash command responses\n- `commands`: respond to `/flusterduck`\n- `reactions:write`: react to alert messages when their status changes (a checkmark when an alert is resolved)\n\nIt doesn\'t read message history, access DMs, or join channels it hasn\'t been invited to.\n\n## Disconnecting\n\nSettings > Integrations > Slack > Disconnect.\n\nAlert rules that had `slack` as a channel will stop delivering to Slack. The rules themselves stay active. If you have other channels configured (email, webhook), those keep firing.\n\nReconnecting re-enables Slack delivery for all rules that had it configured. No rule changes needed.\n'
462
483
  },
484
+ {
485
+ "slug": "posthog-trigger",
486
+ "title": "PostHog trigger layer",
487
+ "description": "Cut PostHog ingestion cost 70-90% by sampling events intelligently and recovering context around friction signals.",
488
+ "group": "Integrations",
489
+ "content": "# PostHog trigger layer\n\nPostHog bills by event volume. A typical session emits 60-200 events, and 95-98% of those sessions have zero friction. You're paying full price for data that confirms everything worked fine.\n\nThe trigger layer wraps `posthog.capture` and cuts ingestion 70-90% while keeping 100% of the events that explain why a user got frustrated. It ships in the `flusterduck` SDK as `initPostHogTrigger`.\n\n## Why per-event sampling fails\n\nDropping 90% of events with `Math.random()` breaks two things.\n\n**Funnels.** PostHog funnels need every step from a single user. Per-event sampling keeps step 1 for one user and step 3 for another. Funnel numbers become garbage.\n\n**Context.** The events that explain a rage click happen *before* the rage click. If the drop decision happens at capture time, the lead-up is gone by the time you know you needed it.\n\n## How it works\n\n### Per-user deterministic sampling\n\nThe keep/drop decision hashes the PostHog `distinct_id` with FNV-1a, so a user is either fully in or fully out of the sampled cohort. Kept users keep every event. Funnels, paths, and session groupings stay internally coherent. Aggregate counts scale by `1 / sampleRate`, the standard sampled-cohort model.\n\n### Retroactive capture\n\nDropped events aren't discarded. They sit in a rolling buffer (default: last 30 seconds, max 50 events). When Flusterduck detects a friction signal (rage click, dead click, error encounter, form validation loop, thrash cursor), the buffer flushes to PostHog. Every recovered event carries:\n\n- `fd_recovered: true`\n- `fd_recovered_reason`: the signal that triggered recovery (e.g. `\"rage_click\"`)\n- `fd_original_ts`: the original capture timestamp\n\nYou pay for the lead-up events only when a user actually got frustrated. Full context on the 2% of sessions that matter, 10% sampling on the 98% that don't.\n\n### Boost window\n\nAfter a critical signal fires, everything is captured for 10 seconds (configurable) so the aftermath is preserved alongside the lead-up. Boosted events carry `fd_boosted: true`.\n\n### Priority tiers\n\n| Tier | Events | Behavior |\n|---|---|---|\n| P0 (critical) | `$rageclick`, `$dead_click`, `$exception`, `$identify`, `$create_alias`, `$groupidentify`, `$survey_sent`, `$survey_dismissed`, `$feature_flag_called` | Always captured |\n| P1 (conversions) | Your `conversionEvents` list, plus pattern-matched names: signup, checkout, purchase, payment, upgrade, trial, order completed | Always captured |\n| P2 (general) | Pageviews, custom events, everything else | Sampled at `baselineSampleRate` (default 10%) |\n| P3 (noise) | Scroll and heatmap events (`$$heatmap*`, `$scroll*`) | Sampled at `scrollSampleRate` (default 1%) |\n\nP0 and P1 events always pass. P2 and P3 events are sampled unless a boost window is active or the buffer flushes them retroactively.\n\n### Signal annotation\n\nEvery captured event carries the user's recent Flusterduck signals as properties:\n\n- `fd_recent_signals`: array of signal names from the last 60 seconds\n- `fd_signal_count`: how many signals fired recently\n\nYou can segment any PostHog insight by frustration without leaving PostHog. Filter `fd_signal_count > 0` to see only frustrated-session data.\n\n## Setup\n\nCall `initPostHogTrigger` after `init`. It waits for PostHog to load on its own, so order doesn't matter and you don't need to coordinate script loading.\n\n```typescript\nimport { init, initPostHogTrigger } from 'flusterduck';\n\ninit({ key: 'fd_pub_...' });\n\ninitPostHogTrigger({\n conversionEvents: ['plan_changed', 'seat_added'],\n});\n```\n\nThat's the whole integration. Two lines after your existing Flusterduck init.\n\n### With React\n\n```typescript\nimport { useEffect } from 'react';\nimport { initPostHogTrigger, destroyPostHogTrigger } from 'flusterduck';\n\nfunction App() {\n useEffect(() => {\n initPostHogTrigger({\n conversionEvents: ['plan_changed'],\n });\n return () => destroyPostHogTrigger();\n }, []);\n\n return <>{/* your app */}</>;\n}\n```\n\n### With Next.js\n\nIf you're using `@flusterduck/next`, call `initPostHogTrigger` in a client component or in a `useEffect` in your root layout. The `FlusterduckScript` component handles the core SDK. The trigger layer is a separate call because not every Flusterduck customer uses PostHog.\n\n```typescript\n'use client';\n\nimport { useEffect } from 'react';\nimport { initPostHogTrigger, destroyPostHogTrigger } from 'flusterduck';\n\nexport function PostHogTrigger() {\n useEffect(() => {\n initPostHogTrigger();\n return () => destroyPostHogTrigger();\n }, []);\n return null;\n}\n```\n\nDrop `<PostHogTrigger />` in your root layout alongside `<FlusterduckScript />`.\n\n## Options\n\n| Option | Type | Default | What it does |\n|---|---|---|---|\n| `baselineSampleRate` | `number` | `0.1` | Keep rate for P2 events (general pageviews, custom events). Range: 0 to 1. |\n| `scrollSampleRate` | `number` | `0.01` | Keep rate for P3 events (scroll and heatmap noise). Range: 0 to 1. |\n| `conversionEvents` | `string[]` | `[]` | Event names added to the always-keep P1 tier, on top of the built-in conversion pattern match. |\n| `criticalSignals` | `string[]` | `['rage_click', 'rage_click_repeat_target', 'dead_click', 'error_encounter', 'form_validation_loop', 'thrash_cursor']` | Flusterduck signals that trigger retroactive buffer flush and the boost window. |\n| `bufferWindowMs` | `number` | `30000` | How far back retroactive capture reaches, in milliseconds. |\n| `bufferMaxEvents` | `number` | `50` | Max events held in the retroactive buffer. |\n| `boostWindowMs` | `number` | `10000` | How long everything is captured after a critical signal fires, in milliseconds. |\n\n### Tuning the sample rates\n\nThe defaults (10% baseline, 1% scroll) work well for most sites. If you want to tune:\n\n**Lower `baselineSampleRate`** to cut cost further. At `0.05` (5%), you're keeping conversions, critical events, and friction context but paying half as much for clean-session data. Funnel analysis still works because sampling is per-user, not per-event.\n\n**Raise `scrollSampleRate`** if you rely on PostHog heatmaps. At `0.05` you'll get 5x more heatmap data while still cutting 95% of scroll noise.\n\n**Add to `conversionEvents`** any custom event name that PostHog funnels depend on. The built-in pattern catches common names (signup, checkout, purchase, upgrade, trial, order placed), but your product-specific events like `workspace_created` or `first_query_run` need to be listed explicitly.\n\n## Cost math\n\nA site doing 100,000 sessions/month with an average of 120 PostHog events per session:\n\n| Scenario | Monthly events | Cost at $0.00045/event |\n|---|---|---|\n| No trigger layer | 12,000,000 | $5,400 |\n| Trigger layer, defaults | 1,800,000 | $810 |\n| Trigger layer, 5% baseline | 1,200,000 | $540 |\n\nThe savings scale linearly with session volume. Heatmap-heavy sites see larger cuts because P3 dominates their event volume.\n\nThe 1.8M figure comes from: 100% of P0/P1 events (roughly 5% of total), 10% of P2 events, 1% of P3 events, plus retroactive flushes on the ~2-5% of sessions with friction signals.\n\n## PostHog properties reference\n\nProperties the trigger layer adds to captured events:\n\n| Property | Type | When present |\n|---|---|---|\n| `fd_recent_signals` | `string[]` | Any event captured while recent Flusterduck signals exist (last 60 seconds) |\n| `fd_signal_count` | `number` | Same as above |\n| `fd_recovered` | `boolean` | Events flushed retroactively from the buffer after a critical signal |\n| `fd_recovered_reason` | `string` | The Flusterduck signal name that caused the flush (e.g. `\"rage_click\"`) |\n| `fd_original_ts` | `number` | Original capture timestamp (Unix ms) on recovered events |\n| `fd_boosted` | `boolean` | Events captured during the boost window after a critical signal |\n\n### Using these in PostHog\n\n**Segment by frustration.** Create a cohort where `fd_signal_count > 0`. Apply it to any insight to see only frustrated-session behavior.\n\n**Find recovered context.** Filter events where `fd_recovered = true` to see the lead-up to friction moments. The `fd_recovered_reason` tells you which signal triggered the recovery.\n\n**Measure boost windows.** Filter `fd_boosted = true` to see what users did immediately after a friction moment. Useful for understanding whether frustrated users retry, leave, or contact support.\n\n## Cleanup\n\nCall `destroyPostHogTrigger()` to remove the wrapper and restore the original `posthog.capture`. The SDK checks that its wrapper is still installed before restoring, so if PostHog re-initialized in the meantime, it won't overwrite the new capture function.\n\n```typescript\nimport { destroyPostHogTrigger } from 'flusterduck';\n\ndestroyPostHogTrigger();\n```\n\n## How it handles the PostHog stub\n\nThe posthog-js snippet installs a queueing stub on `window.posthog` before the real library loads. The real library then replaces the stub's methods. Wrapping the stub would be silently undone.\n\nThe trigger layer polls every 500ms until `posthog.__loaded` is `true`, then wraps the real `capture`. Polling stops after 30 seconds if PostHog never loads. The page is never affected: no errors thrown, no visible behavior change, no console noise.\n\n## What this doesn't do\n\nIt doesn't replace PostHog. It assumes you keep PostHog and want it cheaper.\n\nIt's not lossless for aggregate counts. Sampled tiers undercount by design. Multiply by `1 / sampleRate` or segment on the kept cohort.\n\nIt does nothing without PostHog on the page. No `window.posthog`, no behavior, no errors.\n"
490
+ },
491
+ {
492
+ "slug": "trainai-bridge",
493
+ "title": "TrainAI bridge",
494
+ "description": "Annotate TrainAI traces with friction signals so you can correlate UX problems with AI interactions.",
495
+ "group": "Integrations",
496
+ "content": "# TrainAI bridge\n\nYour AI features generate traces in TrainAI. Your users generate friction signals in Flusterduck. The `@flusterduck/trainai-bridge` package connects the two: when a friction signal fires in the browser, the bridge annotates the active TrainAI trace with the signal type, element, weight, and timing.\n\nThe result is friction data attached directly to the AI interaction that caused it. A rage click on a generation button, form abandonment mid-prompt, a dead click on a suggested action. Each one lands on the trace that was running when it happened.\n\n## Install\n\n```bash\nnpm install @flusterduck/trainai-bridge\n```\n\nThe bridge is a thin layer between Flusterduck's `onSignal` callback and TrainAI's trace annotation API. It has peer dependencies on both `flusterduck` and `@trainai/sdk`.\n\n## Quick setup\n\n```ts\nimport { init, onSignal } from 'flusterduck'\nimport { createTrainAIBridge } from '@flusterduck/trainai-bridge'\n\nconst bridge = createTrainAIBridge({\n // Your TrainAI API key (client-safe publishable key)\n apiKey: process.env.NEXT_PUBLIC_TRAINAI_KEY!,\n // Optional: only bridge certain signal types\n signals: ['rage_click', 'dead_click', 'form_abandonment', 'error_recovery_loop'],\n // Optional: minimum weight to annotate (0-100, default 0)\n minWeight: 10,\n})\n\ninit({\n key: process.env.NEXT_PUBLIC_FLUSTERDUCK_KEY!,\n onSignal: bridge.handler,\n})\n```\n\nThat's it. Every friction signal that passes your filters is sent to TrainAI as a trace annotation.\n\nIf you're already using `onSignal` for something else, compose them:\n\n```ts\ninit({\n key: process.env.NEXT_PUBLIC_FLUSTERDUCK_KEY!,\n onSignal(signal) {\n bridge.handler(signal)\n yourOtherHandler(signal)\n },\n})\n```\n\nOr subscribe after init:\n\n```ts\nimport { onSignal } from 'flusterduck'\n\nonSignal(bridge.handler)\n```\n\n## How it works\n\nWhen Flusterduck detects a friction signal, the bridge:\n\n1. Checks the signal against your `signals` filter (if set) and `minWeight` threshold.\n2. Looks up the currently active TrainAI trace via `TrainAI.getCurrentTrace()`.\n3. If a trace is active, annotates it with a `flusterduck.signal` event containing the signal name, element selector, weight, and timestamp.\n4. If no trace is active, the signal is silently dropped. No error, no queue.\n\nThe bridge never buffers or retries. Signals that fire outside an active trace are ignored because there's no trace to attach them to. This keeps memory usage at zero and avoids stale data.\n\n## Annotation format\n\nEach annotation lands on the TrainAI trace as a structured event:\n\n```json\n{\n \"type\": \"flusterduck.signal\",\n \"name\": \"rage_click\",\n \"element\": \"button#generate-response\",\n \"weight\": 25,\n \"timestamp\": 1718900425000,\n \"meta\": {}\n}\n```\n\n| Field | Type | Description |\n|---|---|---|\n| `type` | `string` | Always `flusterduck.signal` |\n| `name` | `string` | The signal type: `rage_click`, `dead_click`, `form_abandonment`, etc. |\n| `element` | `string` | CSS selector of the element involved. Empty string if not element-bound. |\n| `weight` | `number` | Severity contribution, 0 to 100. Higher means more friction. |\n| `timestamp` | `number` | Unix milliseconds when the signal fired. |\n| `meta` | `object` | Detector metadata (counts, distances, timings). Empty for built-in signals. |\n\nThe `meta` field carries the same data as `EmittedSignal.meta` from the Flusterduck SDK. Built-in detectors keep it empty. If you emit custom signals with `signal()`, whatever metadata you pass shows up here.\n\n## Configuration\n\n### `createTrainAIBridge(options)`\n\n| Option | Type | Default | Description |\n|---|---|---|---|\n| `apiKey` | `string` | required | Your TrainAI publishable API key. |\n| `signals` | `string[]` | all signals | Allowlist of signal types to bridge. Signals not in this list are ignored. |\n| `minWeight` | `number` | `0` | Minimum weight threshold. Signals below this weight are skipped. |\n| `traceProvider` | `object` | TrainAI global | Custom trace provider with a `getCurrentTrace()` method. For testing or custom setups. |\n| `annotationType` | `string` | `flusterduck.signal` | Override the annotation type string if your TrainAI pipeline expects a different event name. |\n| `onError` | `(err: Error) => void` | silent | Called if the TrainAI annotation call fails. By default, errors are swallowed so they can't break your app. |\n\n### Filtering by signal type\n\nPass the signal names you care about. The full list is in the [signals reference](/signals).\n\n```ts\nconst bridge = createTrainAIBridge({\n apiKey: process.env.NEXT_PUBLIC_TRAINAI_KEY!,\n // Only care about high-severity interaction signals\n signals: [\n 'rage_click',\n 'dead_click',\n 'error_recovery_loop',\n 'silent_failure_retry',\n 'form_abandonment',\n 'dead_end_submit',\n ],\n})\n```\n\n### Filtering by weight\n\nWeight filtering is useful when you want to ignore low-severity signals that would create noise in your traces. Tier 1 signals (rage clicks, dead clicks, form abandonment) have weights of 12-38. Tier 3 signals (jerky scrolling, passive drift) are 6-15.\n\n```ts\nconst bridge = createTrainAIBridge({\n apiKey: process.env.NEXT_PUBLIC_TRAINAI_KEY!,\n minWeight: 15, // Skip anything below weight 15\n})\n```\n\n## React setup\n\nIf you're using `@flusterduck/react`, pass the bridge handler through the provider:\n\n```tsx\nimport { FlusterduckProvider } from '@flusterduck/react'\nimport { createTrainAIBridge } from '@flusterduck/trainai-bridge'\n\nconst bridge = createTrainAIBridge({\n apiKey: process.env.NEXT_PUBLIC_TRAINAI_KEY!,\n})\n\nfunction App() {\n return (\n <FlusterduckProvider\n publishableKey={process.env.NEXT_PUBLIC_FLUSTERDUCK_KEY!}\n onSignal={bridge.handler}\n >\n {/* your app */}\n </FlusterduckProvider>\n )\n}\n```\n\n## Next.js setup\n\nWith `@flusterduck/next`, use the `onSignal` prop on the script component, then subscribe client-side:\n\n```tsx\n// app/layout.tsx\nimport { FlusterduckScript } from '@flusterduck/next'\n\nexport default function RootLayout({ children }: { children: React.ReactNode }) {\n return (\n <html>\n <body>\n {children}\n <FlusterduckScript publishableKey={process.env.NEXT_PUBLIC_FLUSTERDUCK_KEY!} />\n </body>\n </html>\n )\n}\n```\n\n```tsx\n// components/trainai-bridge.tsx\n'use client'\n\nimport { useEffect } from 'react'\nimport { onSignal } from 'flusterduck'\nimport { createTrainAIBridge } from '@flusterduck/trainai-bridge'\n\nconst bridge = createTrainAIBridge({\n apiKey: process.env.NEXT_PUBLIC_TRAINAI_KEY!,\n})\n\nexport function TrainAIBridgeInit() {\n useEffect(() => {\n const off = onSignal(bridge.handler)\n return off\n }, [])\n return null\n}\n```\n\nDrop `<TrainAIBridgeInit />` anywhere in your component tree. It subscribes once on mount and cleans up on unmount.\n\n## What to look for in TrainAI\n\nOnce the bridge is running, you'll see `flusterduck.signal` annotations on your traces. Filter traces in TrainAI by annotation type to find AI interactions that caused user frustration.\n\nPatterns worth investigating:\n\n- **Rage clicks during generation**: the user clicked a generate button repeatedly because nothing happened. Check whether the trace shows a slow model call or a dropped response.\n- **Dead clicks on AI suggestions**: the model returned a suggestion the user tried to interact with, but it wasn't wired up. The trace shows what was generated; the annotation shows what the user tried to do with it.\n- **Form abandonment on AI-powered forms**: the user started filling in an AI-assisted form and gave up. The trace shows what the AI pre-filled or suggested; the annotation shows where the user stopped.\n- **Error recovery loops**: the user submitted the same form repeatedly after AI validation kept rejecting their input. The trace shows the validation logic; the annotations show the submission cadence.\n\n## Custom signals\n\nIf you emit custom signals with `signal()`, they flow through the bridge too:\n\n```ts\nimport { signal } from 'flusterduck'\n\n// Your app detects a user retrying an AI generation\nsignal('ai_generation_retry', {\n element: '#generate-btn',\n metadata: { attempt: 3, model: 'claude-sonnet' },\n weight: 20,\n})\n```\n\nThis shows up on the active TrainAI trace as a `flusterduck.signal` annotation with `name: \"ai_generation_retry\"` and the metadata you passed.\n\n## Destroying the bridge\n\nThe bridge has no internal state, so there's nothing to clean up. If you subscribed via `onSignal`, the unsubscribe function handles teardown. If you passed the handler through `init({ onSignal })`, it's torn down when the SDK's `destroy()` runs.\n\n```ts\n// If you used onSignal:\nconst off = onSignal(bridge.handler)\noff() // done\n\n// If you used init({ onSignal: bridge.handler }):\nimport { destroy } from 'flusterduck'\ndestroy() // tears down everything including signal subscriptions\n```\n\n## Privacy\n\nThe bridge sends the same data Flusterduck collects: signal type, CSS selector, weight, and timing. No form values, no text content, no PII. The CSS selector identifies the element structurally (e.g. `button#generate-response`, `form.checkout [name=\"email\"]`) but never captures what the user typed into it.\n\nIf you emit custom signals with metadata, whatever you put in `metadata` is forwarded to TrainAI. Keep PII out of your custom signal metadata the same way you would for any client-side telemetry.\n"
497
+ },
498
+ {
499
+ "slug": "rest-api",
500
+ "title": "REST API",
501
+ "description": "Read and write everything in Flusterduck with a secret key. Query scores, issues, heatmaps; manage sites, alerts, members.",
502
+ "group": "Integrations",
503
+ "content": '# REST API\n\nEvery piece of data in Flusterduck is accessible through two HTTP endpoints. The query endpoint reads data. The manage endpoint writes it. Both accept a secret key in the Authorization header, so you can call them from scripts, CI pipelines, internal tools, or any language with an HTTP client.\n\n## Authentication\n\nCreate a secret key in Settings > API Keys. It starts with `fd_sec_`. Pass it as a Bearer token:\n\n```\nAuthorization: Bearer fd_sec_your_key_here\n```\n\nThe key must have the right scopes. A key with `query:read` can read scores, issues, alerts, deploys, and raw data. A key with `manage:write` can create sites, update alert rules, triage issues, and manage members. You set scopes when creating the key.\n\nMCP keys (`fd_mcp_`) also work for read operations.\n\n## Base URL\n\n```\nhttps://rhwhnkrqjlzyzcdhvyky.supabase.co/functions/v1\n```\n\nAll routes are under `/query` (read) or `/manage` (write).\n\n## Reading data\n\nAll query routes accept `site_id` as a required query parameter.\n\n### Scores\n\n```\nGET /query/scores?site_id={id}\n```\n\nReturns confusion scores for every page on the site, sorted by score descending.\n\n### Issues\n\n```\nGET /query/issues?site_id={id}\nGET /query/issues?site_id={id}&status=open&limit=20\nGET /query/issues/{issue_id}?site_id={id}\n```\n\nList or fetch individual UX issues. Filter by status: `open`, `triaged`, `in_progress`, `verified`, `resolved`, `ignored`.\n\n### Alerts\n\n```\nGET /query/alerts?site_id={id}\nGET /query/alerts?site_id={id}&status=active\nGET /query/alerts/{alert_id}?site_id={id}\n```\n\n### Deploys\n\n```\nGET /query/deploys?site_id={id}\nGET /query/deploys/{deploy_id}?site_id={id}\n```\n\nReturns deploys with confusion_before and confusion_after scores, plus related issues.\n\n### Page detail\n\n```\nGET /query/page?site_id={id}&page=/checkout\n```\n\nFull detail for a single page: score, history, top elements, open issues, recent deploys, active alerts.\n\n### Elements\n\n```\nGET /query/elements?site_id={id}&page=/checkout\n```\n\nTop elements by friction signal count on a page. Includes dominant signal type, affected users, and recommendation.\n\n### Element heatmap\n\n```\nGET /query/element-heatmap?site_id={id}&page=/checkout&selector=button.submit\n```\n\nReturns up to 300 click positions on a single element, collected from `rage_click`, `dead_click`, and `disabled_element_attempt` signals. Both `page` and `selector` are required.\n\nEach point uses a **0 to 1 coordinate system** relative to the element\'s bounding box. `rx: 0, ry: 0` is the top-left corner. `rx: 1, ry: 1` is the bottom-right. No pixel coordinates ever leave the browser.\n\nThe SDK records these positions in two ways. Newer builds emit `hx`/`hy` as integers from 0 to 1000 (divided by 1000 on the server to get the 0-1 fraction). Older builds emit a center-relative offset (`click_dx`/`click_dy`) plus the element dimensions (`el_w`/`el_h`), and the server recovers the fraction from those. You don\'t need to worry about which format your SDK uses. The API always returns normalized `rx`/`ry` values.\n\n```json\n{\n "data": {\n "page": "/checkout",\n "selector": "button.submit",\n "count": 47,\n "points": [\n { "rx": 0.512, "ry": 0.483, "signal": "rage_click" },\n { "rx": 0.891, "ry": 0.102, "signal": "dead_click" },\n { "rx": 0.334, "ry": 0.721, "signal": "disabled_element_attempt" }\n ]\n },\n "error": null\n}\n```\n\n| Field | Type | Description |\n|---|---|---|\n| `rx` | number | Horizontal position within the element, 0 (left edge) to 1 (right edge) |\n| `ry` | number | Vertical position within the element, 0 (top edge) to 1 (bottom edge) |\n| `signal` | string | The signal type that generated this point |\n\nTo render a heatmap overlay, multiply `rx` by the element\'s rendered width and `ry` by its rendered height. The positions stay accurate at any zoom level or screen size because they\'re fractions, not pixels.\n\n### Page heatmap\n\n```\nGET /query/page-heatmap?site_id={id}&page=/checkout\n```\n\nReturns up to 500 friction positions across the entire viewport for a page. Same signal types as the element heatmap (`rage_click`, `dead_click`, `disabled_element_attempt`), but coordinates are relative to the viewport instead of a single element.\n\nThe SDK records `vx`/`vy` as integers from 0 to 1000 (the click\'s position as a fraction of the viewport width and height). The server normalizes these to 0-1 before returning them.\n\n```json\n{\n "data": {\n "page": "/checkout",\n "count": 183,\n "points": [\n { "vx": 0.724, "vy": 0.312, "signal": "rage_click", "el": "button.submit" },\n { "vx": 0.501, "vy": 0.887, "signal": "dead_click", "el": "a.terms-link" },\n { "vx": 0.223, "vy": 0.145, "signal": "dead_click" }\n ]\n },\n "error": null\n}\n```\n\n| Field | Type | Description |\n|---|---|---|\n| `vx` | number | Horizontal position in the viewport, 0 (left) to 1 (right) |\n| `vy` | number | Vertical position in the viewport, 0 (top) to 1 (bottom) |\n| `signal` | string | The signal type that generated this point |\n| `el` | string or absent | CSS selector of the element that was clicked, when available |\n\nPoints without an `el` field come from signals where the element couldn\'t be identified. This is rare but possible with dynamically removed DOM nodes.\n\n### Friction map\n\n```\nGET /query/journeys/friction?site_id={id}\nGET /query/journeys/friction?site_id={id}&min_friction_weight=5&signal_type=rage_click&limit=500\n```\n\nReturns page-to-page navigation edges weighted by the friction signals that occurred along them. This powers the friction map panel in the dashboard, showing where users hit trouble as they move through your site.\n\nThe endpoint joins session navigation paths with signals that qualify as edge decorators (the friction-relevant subset of all signal types). Each edge accumulates a `friction_weight` from the weight and confidence of every qualifying signal on either the source or destination page.\n\n| Parameter | Default | Description |\n|---|---|---|\n| `limit` | 250 | Max sessions to analyze (1 to 1000) |\n| `min_friction_weight` | 1 | Minimum combined friction weight for an edge to appear (0 to 1000) |\n| `signal_type` | all | Filter to a single signal type, e.g. `rage_click` |\n\n```json\n{\n "data": {\n "site_id": "your-site-id",\n "edges": [\n {\n "from": "/pricing",\n "to": "/checkout",\n "sessions": 34,\n "friction_weight": 127.4,\n "signals": {\n "rage_click": 18,\n "dead_click": 12,\n "form_restart": 4\n },\n "examples": [\n {\n "session_id": "sess_abc123",\n "page": "/checkout",\n "signal_type": "rage_click",\n "element_selector": "button.submit",\n "occurred_at": "2026-06-21T14:32:01Z"\n }\n ]\n },\n {\n "from": "/checkout",\n "to": "/checkout",\n "sessions": 12,\n "friction_weight": 89.2,\n "signals": {\n "form_restart": 9,\n "error_encounter": 3\n },\n "examples": []\n }\n ]\n },\n "error": null\n}\n```\n\n| Field | Type | Description |\n|---|---|---|\n| `from` | string | Source page path |\n| `to` | string | Destination page path. Same as `from` when users reload or loop on one page. |\n| `sessions` | number | Distinct sessions that traversed this edge with friction |\n| `friction_weight` | number | Combined weight of all qualifying signals, rounded to one decimal |\n| `signals` | object | Count of each signal type observed on this edge |\n| `examples` | array | Up to 5 concrete signal instances for debugging. Each has `session_id`, `page`, `signal_type`, `element_selector`, and `occurred_at`. |\n\nResults are sorted by `friction_weight` descending and capped at 100 edges. Edges with a `friction_weight` below `min_friction_weight` are excluded before sorting.\n\n### Trends\n\n```\nGET /query/trends?site_id={id}&days=7\nGET /query/trends?site_id={id}&page=/checkout&days=30\n```\n\nScore history over time. Defaults to 7 days.\n\n### Revenue\n\n```\nGET /query/revenue?site_id={id}\n```\n\nRevenue at risk across all open issues, based on the revenue config you set in Settings.\n\n### Recommendations\n\n```\nGET /query/recommendations?site_id={id}\n```\n\nRanked list of fix recommendations derived from the current issue set.\n\n### Raw data\n\n```\nGET /query/raw?site_id={id}&table=signals&limit=100&sort=occurred_at&order=desc\nGET /query/raw?site_id={id}&table=events&limit=50\nGET /query/raw?site_id={id}&table=sessions\nGET /query/raw?site_id={id}&table=page_scores\nGET /query/raw?site_id={id}&table=ux_issues\n```\n\nDirect table access with sorting and pagination. Available tables: `events`, `signals`, `sessions`, `page_scores`, `score_history`, `ux_issues`, `deploys`, `alerts`.\n\n### CSV export\n\n```\nGET /query/export/events.csv?site_id={id}\n```\n\nDownloads raw events as CSV.\n\n### MCP context\n\n```\nGET /query/mcp/context?site_id={id}\n```\n\nA single-call summary designed for AI assistants: top scores, open issues, recent deploys, active alerts, and recommendations in one response.\n\n## Writing data\n\nManage routes use POST, PATCH, or DELETE. All require a key with `manage:write` scope.\n\n### Sites\n\n```\nPOST /manage/sites { name, url }\nPATCH /manage/sites/{id} { name, url }\nDELETE /manage/sites/{id}\n```\n\n### Alert rules\n\n```\nGET /manage/alert-rules?site_id={id}\nPOST /manage/alert-rules { site_id, trigger_type, threshold, channels, config }\nPATCH /manage/alert-rules/{id} { threshold, channels, enabled }\nDELETE /manage/alert-rules/{id}\n```\n\nTrigger types: `spike`, `anomaly`, `new_page`, `trend`, `co_occurrence`, `positive`, `budget`.\nChannels: `email`, `slack`, `webhook`, `mcp`, `pagerduty`.\n\n### Issues\n\n```\nPATCH /manage/issues/{id} { status, assigned_to, severity }\n```\n\nTriage, assign, and resolve issues.\n\n### Members\n\n```\nGET /manage/members\nPOST /manage/members/invite { email, role }\nPATCH /manage/members/{id} { role }\nDELETE /manage/members/{id}\n```\n\nRoles: `owner`, `admin`, `member`, `viewer`.\n\n### Webhooks\n\n```\nGET /manage/webhooks?site_id={id}\nPOST /manage/webhooks { site_id, url, events, secret }\nPATCH /manage/webhooks/{id} { url, events, enabled }\nDELETE /manage/webhooks/{id}\n```\n\n### SDK config\n\n```\nGET /manage/sdk-config?site_id={id}\nPATCH /manage/sdk-config/{environment} { revenue_config, ... }\n```\n\n### Integrations\n\n```\nGET /manage/integrations?site_id={id}\nPOST /manage/integrations { site_id, provider, config }\nDELETE /manage/integrations/{id}\n```\n\nProviders: `slack`, `pagerduty`, `linear`, `github`.\n\n### API keys\n\n```\nGET /manage/keys?site_id={id}\nPOST /manage/keys { site_id, key_type, scopes }\nDELETE /manage/keys/{id}\n```\n\nKey types: `secret`, `mcp`. Scopes: `ingest:write`, `query:read`, `manage:write`, `mcp:read`, `webhook:write`.\n\n## Rate limits\n\n| Endpoint | Limit |\n|---|---|\n| query | 100 RPM per org |\n| manage | 30 RPM per org |\n| guide | 200 RPM per site |\n| ingest | 10,000 RPM per publishable key |\n\n## Response format\n\nAll responses wrap data in `{ "data": { ... }, "error": null }`. On error: `{ "data": null, "error": { "code": "...", "message": "..." } }` with the appropriate HTTP status code.\n'
504
+ },
463
505
  {
464
506
  "slug": "privacy",
465
507
  "title": "Privacy and data collection",
@@ -479,91 +521,161 @@ All plans include a 7-day free trial. No credit card required.
479
521
  "title": "Security",
480
522
  "description": "Collection rules, data access boundaries, hashing, input limits, and the OWASP checklist.",
481
523
  "group": "Privacy & security",
482
- "content": "# Security\n\n## Collection rules\n\nFlusterDuck measures behavior, not private content.\n\n- No session replay.\n- No DOM recording.\n- No form values.\n- No text content.\n- No passwords or tokens.\n- No raw IP storage.\n- No direct browser access to analytics tables.\n\n## Data access\n\nBrowser code only sends telemetry to the ingest edge function with `fd_pub_` publishable keys. Reads and writes go through authenticated edge functions.\n\n- `ingest` accepts publishable site keys.\n- `query` accepts user JWTs, `fd_sec_` keys with `query:read`, or `fd_mcp_` keys with `mcp:read`.\n- `manage` accepts user JWTs only.\n- Clients do not query Supabase tables directly.\n\n## Hashing and keys\n\n- API keys are stored as HMAC-SHA256 hashes with `KEY_HASH_SALT`.\n- IP addresses are hashed at the edge with `IP_HASH_SALT` and truncated before storage.\n- Secret keys are compared with timing-safe comparison.\n- Webhooks use HMAC signatures with timestamp replay checks.\n\n## Input limits\n\n- Edge request bodies are limited to 64KB.\n- Gzip payloads are decompressed server-side and checked again after decompression.\n- User-controlled strings are stripped of dangerous characters and truncated.\n- UUIDs, trigger types, alert channels, thresholds, and config keys use allowlists.\n\n## Browser SDK hygiene\n\nUse attributes or stable selectors for context. Do not send private user data in metadata.\n\n```ts\nsignal('dead_click', {\n page: '/checkout',\n selector: '[data-action=\"continue\"]',\n})\n```\n\nNever do this:\n\n```ts\ntrack('checkout_note', {\n email: user.email,\n message: form.message,\n})\n```\n\n## OWASP checklist\n\n- Broken access control: every read route verifies org and site access.\n- Cryptographic failures: raw keys and raw IP addresses are not stored.\n- Injection: user input is sanitized and query params are validated before database access.\n- Security misconfiguration: Vercel apps ship security headers and no powered-by header.\n- Identification failures: middleware uses `getUser()` for protected routes.\n- Logging and monitoring: edge functions record degradation and delivery failures without PII.\n"
524
+ "content": "# Security\n\nFlusterduck collects behavioral signals, not private content. This page covers the full security model: how authentication works, what the database enforces, how keys are hashed, where rate limits sit, and what input validation looks like at the edge. If you're running a security review, this is your reference.\n\n## What we don't collect\n\n- No session replay\n- No DOM recording\n- No form values or text content\n- No passwords, tokens, or credentials\n- No raw IP addresses (hashed at the edge before storage)\n- No direct browser access to analytics tables\n\nThe SDK captures behavioral patterns only: clicks, scroll depth, cursor movement, timing between interactions. It never reads input values or DOM text.\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` edge function validates the key by computing its HMAC-SHA256 hash and looking up the matching `site_environments` row. If the site is inactive, the org's billing has lapsed, or the trial has expired, ingest rejects the batch.\n\nPublishable keys can only write events. They can't read scores, issues, or any other data.\n\n### User JWTs\n\nThe dashboard and `manage` edge function authenticate via Supabase Auth JWTs. Middleware calls `supabase.auth.getUser()` (not `getSession()`) to validate the token server-side on every request. `getUser()` hits the auth server, so stolen session cookies with expired JWTs are caught.\n\nAfter JWT validation, every route checks org membership through `requireOrgRole()`. Admin-only operations (creating sites, managing keys, inviting members) require the `owner` or `admin` role.\n\n### Secret keys (`fd_sec_`)\n\nServer-side integrations use secret keys with the `query` and `manage` edge functions. Each key carries a set of scopes (`query:read`, `manage:write`, `webhook:write`), a per-key rate limit, and an optional IP allowlist.\n\nAuthentication works the same way as publishable keys: HMAC-SHA256 hash lookup against the `api_keys` table. If the key is revoked, expired, or missing the required scope, the request fails. If an IP allowlist is configured and the caller's IP doesn't match (supports CIDR notation), the request fails with `ip_not_allowed`.\n\n### MCP keys (`fd_mcp_`)\n\nMCP keys work like secret keys but carry the `mcp:read` scope. They're issued by the `mcp-auth-bridge` edge function and used by the Cloudflare MCP Worker to give AI assistants read access to scores and issues.\n\n### How `authenticateQuery` picks the path\n\nThe `query` edge function's auth dispatcher checks the Bearer token prefix:\n\n```ts\nif (token.startsWith('fd_sec_')) return authenticateSecretKey(req, db, 'query:read');\nif (token.startsWith('fd_mcp_')) return authenticateSecretKey(req, db, 'mcp:read');\nreturn authenticateUser(req, db);\n```\n\n`manage` follows the same pattern but requires the `manage:write` scope for API keys.\n\n## Authorization\n\nAuthentication proves identity. Authorization proves access.\n\n- Every `query` route requires a `site_id` parameter. `ensureSiteAccess()` loads the site's `org_id`, then verifies the caller belongs to that org (for JWTs) or owns that org (for API keys). Site-scoped API keys can only access their specific site.\n- Every `manage` route calls `requireManageAccess()`, which checks org membership with the appropriate role. Viewers can't modify anything. Members can update issues but can't create sites or keys.\n- Org creation is capped at 5 per user. Admin count is capped at 10 per org.\n- Users can't change their own role or remove themselves from an org.\n- The `audit_logs` table records who did what, with IP hashes and user-agent hashes for forensics.\n\n## Rate limits\n\nEvery edge function enforces rate limits through an atomic Postgres RPC (`consume_rate_limit_bucket`) that increments a counter within a time-bucketed window. When the limit is hit, the function returns HTTP 429 with a `reset_at` timestamp.\n\n| Surface | Limit | Window |\n|---------|-------|--------|\n| `ingest` | 10,000 requests | 60 seconds, per environment |\n| `query` | 100 requests (or per-key custom RPM) | 60 seconds, per org + caller |\n| `manage` | 30 requests | 60 seconds, per actor |\n| `waitlist` | 5 requests | 15 minutes, per IP |\n| `webhooks` | 60 requests | 60 seconds |\n\nAPI keys can have a custom `rate_limit_rpm` value. The `query` function uses that value instead of the default 100 when the caller authenticates with a key.\n\n## Cryptography\n\n### API key hashing\n\nKeys are never stored in plaintext. When a key is created, its HMAC-SHA256 hash is computed using the `KEY_HASH_SALT` environment secret and stored in the `key_hash` column. On every request, the same HMAC is recomputed from the raw key and matched against the stored hash.\n\n```ts\nexport async function hashAPIKey(key: string): Promise<string> {\n return hmacSha256Hex(env('KEY_HASH_SALT'), key);\n}\n```\n\n`KEY_HASH_SALT` is a required secret with no hardcoded fallback. If it's missing, the function throws immediately.\n\n### IP address hashing\n\nIP addresses are hashed with HMAC-SHA256 using `IP_HASH_SALT` and truncated to 32 hex characters before storage. The original IP is never written to any table.\n\n```ts\nexport async function hashIP(ip: string | null): Promise<string | null> {\n if (!ip) return null;\n return (await hmacSha256Hex(env('IP_HASH_SALT'), ip)).slice(0, 32);\n}\n```\n\n### Timing-safe comparison\n\nAll key and signature comparisons use constant-time XOR to prevent timing attacks:\n\n```ts\nexport function safeEqual(a: string, b: string): boolean {\n const max = Math.max(a.length, b.length);\n let diff = a.length ^ b.length;\n for (let index = 0; index < max; index += 1) {\n diff |= (a.charCodeAt(index) || 0) ^ (b.charCodeAt(index) || 0);\n }\n return diff === 0;\n}\n```\n\nThis function is used for webhook signature verification, Slack signature verification, and API key comparison. Length differences are caught by the initial XOR without short-circuiting.\n\n### Webhook signatures\n\nOutbound webhooks are signed with HMAC-SHA256. The signature covers a timestamp concatenated with the payload body (`{timestamp}.{payload}`), providing replay protection. Verification rejects signatures older than 300 seconds.\n\n### Slack signatures\n\nSlack requests are verified using the `SLACK_SIGNING_SECRET` with Slack's `v0:` signature format and a 5-minute timestamp tolerance window.\n\n### Webhook secret encryption\n\nWebhook endpoint secrets are encrypted at rest using AES-256-GCM. The encryption key is derived from a dedicated environment secret via SHA-256. Each encrypted value carries its own random 12-byte IV.\n\n## Row-level security\n\nRLS is enabled on every table in the public schema. But the policies don't grant access to `anon` or `authenticated` roles for product data. Instead, a blanket revocation prevents any direct database queries from browser clients.\n\n### The REVOKE ALL strategy\n\nThe `deny_direct_database_policies` migration runs these statements:\n\n```sql\nrevoke all on all tables in schema public from anon;\nrevoke all on all tables in schema public from authenticated;\nrevoke all on all sequences in schema public from anon;\nrevoke all on all sequences in schema public from authenticated;\nrevoke all on all routines in schema public from public;\nrevoke all on all routines in schema public from anon;\nrevoke all on all routines in schema public from authenticated;\ngrant execute on all routines in schema public to service_role;\n```\n\nThen `ALTER DEFAULT PRIVILEGES` ensures future tables and functions inherit the same restrictions. Every table gets a deny-all RLS policy for `anon` and `authenticated`:\n\n```sql\ncreate policy deny_client_access_<table> on public.<table>\n for all to anon, authenticated using (false) with check (false);\n```\n\nEdge functions use the `service_role` key, which bypasses RLS. Browser clients use the `anon` key for Supabase Auth calls only. They can't touch product data.\n\n### Private tables\n\n21 tables are explicitly listed in the hardened migration as having deny-all policies regardless of any other policy: `alert_delivery_queue`, `api_keys`, `audit_logs`, `degradation_events`, `deploy_correlations`, `integration_connections`, `issue_signal_evidence`, `issue_verifications`, `mcp_oauth_clients`, `mcp_oauth_grants`, `mcp_oauth_tokens`, `processed_stripe_events`, `rate_limit_buckets`, `scheduled_tasks`, `sdk_configs`, `site_environments`, `slack_messages`, `ux_issues`, `waitlist`, `webhook_deliveries`, `webhook_endpoints`.\n\n### Security definer functions\n\nFive functions in the `private` schema run with `SECURITY DEFINER` and `SET search_path = public` to prevent search-path injection:\n\n| Function | Purpose |\n|----------|---------|\n| `private.user_org_ids()` | Returns array of org IDs the current auth user belongs to |\n| `private.user_site_ids()` | Returns array of site IDs across the user's orgs |\n| `private.user_role(org_id)` | Returns the user's role in a specific org |\n| `private.has_org_role(org_id, roles[])` | Checks if the user has one of the specified roles |\n| `private.can_join_org(org_id, user_id)` | Checks if a user can be added to an org |\n\nThese functions back the RLS policies on org-scoped tables. The `organizations` table uses `id = any(private.user_org_ids())` for SELECT. The 26 org-scoped data tables (sites, events, signals, page_scores, alerts, etc.) use `org_id = any(private.user_org_ids())`. Write policies on admin-gated tables also check `private.has_org_role(org_id, array['owner', 'admin'])`.\n\n### Safe views\n\nTwo views hide sensitive columns from clients:\n\n- `api_keys_safe` exposes key metadata (prefix, scopes, rate limit, last used, expiry) but omits `key_hash`.\n- `sites_safe` exposes site metadata (name, URL, status) but omits `secret_key_hash` and internal fields.\n\nBoth views use `security_invoker = true`, so the caller's RLS policies still apply.\n\n## Input validation\n\n### Request body limits\n\nAll edge functions cap request bodies at 64KB (65,536 bytes). Gzip payloads are decompressed server-side and checked against the same limit after decompression. Requests that claim gzip encoding but don't contain valid gzip data get a 400 error.\n\n### String sanitization\n\nUser-controlled strings pass through `sanitize()`, which strips `< > \" ' & `` ` characters and truncates to 500 characters:\n\n```ts\nexport function sanitize(value: unknown, maxLength = 500): string {\n if (typeof value !== 'string') return '';\n return value.replace(/[<>\"'&`]/g, '').slice(0, maxLength).trim();\n}\n```\n\n### UUID validation\n\nEvery UUID parameter is validated against a strict regex before any database query:\n\n```ts\n/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i\n```\n\n### Enum allowlists\n\nThe `manage` function validates these values against hardcoded `Set` objects:\n\n- `trigger_type`: spike, anomaly, new_page, trend, co_occurrence, positive, budget\n- `channels`: email, slack, webhook, mcp, pagerduty\n- `member_roles`: owner, admin, member, viewer\n- `dom_modes`: off, metadata, snapshot\n- `issue_statuses`: open, triaged, in_progress, verified, resolved, ignored\n- `scopes`: ingest:write, query:read, manage:write, mcp:read, webhook:write\n\n### Numeric clamping\n\n`intParam()` clamps numeric query parameters to a safe range with a fallback default. Thresholds are clamped to 0-1000, cooldown minutes to 1-1440.\n\n## Content Security Policy\n\nThe web app generates a per-request CSP with a cryptographic nonce. Key directives:\n\n- `script-src 'self' 'nonce-{random}' 'strict-dynamic'` blocks inline scripts without the nonce. `strict-dynamic` lets nonced scripts load their dependencies.\n- `connect-src 'self' https://rhwhnkrqjlzyzcdhvyky.supabase.co` restricts network calls to same-origin (the `/api/v1` proxy) and the Supabase auth endpoint. No wildcards.\n- `frame-ancestors 'none'` and `frame-src 'none'` prevent clickjacking.\n- `object-src 'none'` and `worker-src 'none'` block plugin and worker execution.\n- `upgrade-insecure-requests` is set in production.\n\nThe nonce is generated from 16 cryptographically random bytes per request. No nonce reuse across responses.\n\n## Audit logging\n\nThe `audit_logs` table records management actions with:\n\n- Actor identity (user ID or API key ID)\n- Action name and target (e.g., `create` on `alert_rule`)\n- Hashed IP address and hashed user-agent\n- Arbitrary metadata for the specific operation\n\nIP addresses in audit logs are hashed with the same `hashIP()` function used everywhere else. User agents are SHA-256 hashed and truncated to 64 hex characters.\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\nThe SDK scrubs JSON payloads at the edge with `scrubJson()` before storage. But the safest approach is to never send sensitive data in the first place.\n\n## Network-level controls\n\n- API keys support IP allowlists with CIDR notation. Requests from IPs outside the allowlist get a 403.\n- Webhook endpoint URLs are validated by `assertPublicWebhookUrl()` to prevent SSRF against internal networks.\n- The web app's middleware distinguishes browser navigations from API fetches using `Sec-Fetch-Dest`. Unauthenticated browser requests redirect to `/login`. Unauthenticated API fetches get a 401 JSON response.\n- Auth callback validates redirect targets against a `SAFE_REDIRECTS` allowlist to prevent open redirects.\n"
483
525
  },
484
526
  {
485
527
  "slug": "pkg-sdk",
486
528
  "title": "flusterduck",
487
529
  "description": "The core browser SDK that detects friction signals.",
488
530
  "group": "Packages",
489
- "content": "# flusterduck\n\nThe browser SDK. It detects friction signals in the browser and sends them to Flusterduck. This is the core package every integration depends on.\n\n## Install\n\n```bash\nnpm install flusterduck\n```\n\n## Usage\n\n```ts\nimport { init, signal, track, identify, onSignal } from 'flusterduck'\n\ninit({\n publishableKey: 'fd_pub_xxxxxxxxxxxx',\n})\n\n// Identify the current session with safe, non-PII properties.\nidentify({ plan: 'scale', cohort: 'beta' })\n\n// Track a conversion event for revenue attribution.\ntrack('checkout_completed', { value: 49 })\n\n// React the instant a friction signal fires.\nonSignal((s) => {\n console.log('friction detected:', s.type)\n})\n\n// Emit a custom signal when you detect friction yourself.\nsignal('search_no_results', { query: 'pricing' })\n```\n\nUse `setConsent(true)` to gate collection behind consent, or `optOut()` to stop collection for the current session.\n\n## Links\n\nPublished on npm as `flusterduck`, version 0.5.2.\n"
531
+ "content": "# flusterduck\n\nThe browser SDK. It detects friction signals in the browser and sends them to Flusterduck. This is the core package every integration depends on.\n\n## Install\n\n```bash\nnpm install flusterduck\n```\n\n## Usage\n\n```ts\nimport { init, signal, track, identify, onSignal } from 'flusterduck'\n\ninit({\n publishableKey: 'fd_pub_xxxxxxxxxxxx',\n})\n\n// Identify the current session with safe, non-PII properties.\nidentify({ plan: 'scale', cohort: 'beta' })\n\n// Track a conversion event for revenue attribution.\ntrack('checkout_completed', { value: 49 })\n\n// React the instant a friction signal fires.\nonSignal((s) => {\n console.log('friction detected:', s.type)\n})\n\n// Emit a custom signal when you detect friction yourself.\nsignal('search_no_results', { query: 'pricing' })\n```\n\nUse `setConsent(true)` to gate collection behind consent, or `optOut()` to stop collection for the current session.\n\n## Links\n\nPublished on npm as `flusterduck`. Install pulls the latest published version.\n"
490
532
  },
491
533
  {
492
534
  "slug": "pkg-cli",
493
535
  "title": "flusterduck-cli",
494
536
  "description": "CLI that detects your framework, installs, and wires up init.",
495
537
  "group": "Packages",
496
- "content": "# flusterduck-cli\n\nThe command line installer. It detects your framework, installs the right packages, and injects the init call automatically.\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## Usage\n\n```bash\n# Detect framework, install packages, inject init.\nnpx flusterduck-cli init\n\n# Provide your publishable key non-interactively.\nnpx flusterduck-cli init --key fd_pub_xxxxxxxxxxxx\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## Links\n\nPublished on npm as `flusterduck-cli`, version 0.5.2.\n"
538
+ "content": "# flusterduck-cli\n\nThe command line installer. It detects your framework, installs the right packages, and injects the init call automatically.\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## Usage\n\n```bash\n# Detect framework, install packages, inject init.\nnpx flusterduck-cli init\n\n# Provide your publishable key non-interactively.\nnpx flusterduck-cli init --key fd_pub_xxxxxxxxxxxx\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## Links\n\nPublished on npm as `flusterduck-cli`. Install pulls the latest published version.\n"
497
539
  },
498
540
  {
499
541
  "slug": "pkg-create",
500
542
  "title": "create-flusterduck",
501
543
  "description": "Scaffolder for a new Flusterduck-instrumented project.",
502
544
  "group": "Packages",
503
- "content": "# create-flusterduck\n\nThe project scaffolder. It creates a new app preconfigured with Flusterduck so signals flow from the first run.\n\n## Install\n\n```bash\nnpm create flusterduck@latest\n```\n\nYou can also invoke it directly with your package manager of choice:\n\n```bash\nnpm create flusterduck@latest my-app\npnpm create flusterduck my-app\nyarn create flusterduck my-app\n```\n\n## Usage\n\n```bash\n# Scaffold into a new directory and answer the prompts.\nnpm create flusterduck@latest my-app\n\ncd my-app\nnpm install\nnpm run dev\n```\n\nThe scaffolder asks for your framework and publishable key, generates the project, and includes the matching Flusterduck wrapper already initialized.\n\n## Links\n\nPublished on npm as `create-flusterduck`, version 0.5.2.\n"
545
+ "content": "# create-flusterduck\n\nThe project scaffolder. It creates a new app preconfigured with Flusterduck so signals flow from the first run.\n\n## Install\n\n```bash\nnpm create flusterduck@latest\n```\n\nYou can also invoke it directly with your package manager of choice:\n\n```bash\nnpm create flusterduck@latest my-app\npnpm create flusterduck my-app\nyarn create flusterduck my-app\n```\n\n## Usage\n\n```bash\n# Scaffold into a new directory and answer the prompts.\nnpm create flusterduck@latest my-app\n\ncd my-app\nnpm install\nnpm run dev\n```\n\nThe scaffolder asks for your framework and publishable key, generates the project, and includes the matching Flusterduck wrapper already initialized.\n\n## Links\n\nPublished on npm as `create-flusterduck`. Install pulls the latest published version.\n"
504
546
  },
505
547
  {
506
548
  "slug": "pkg-react",
507
549
  "title": "@flusterduck/react",
508
550
  "description": "React provider and useFlusterduck hook.",
509
551
  "group": "Packages",
510
- "content": "# @flusterduck/react\n\nThe React wrapper. Wrap your app root with `FlusterduckProvider` and signal detection starts across the whole tree. Read the SDK from any component with `useFlusterduck`.\n\n## Install\n\n```bash\nnpm install @flusterduck/react flusterduck\n```\n\n## Usage\n\n```tsx\n// main.tsx\nimport { FlusterduckProvider } from '@flusterduck/react'\nimport App from './App'\n\nexport function Root() {\n return (\n <FlusterduckProvider publishableKey=\"fd_pub_xxxxxxxxxxxx\">\n <App />\n </FlusterduckProvider>\n )\n}\n```\n\n```tsx\n// Any component.\nimport { useFlusterduck } from '@flusterduck/react'\n\nexport function CheckoutButton() {\n const { track } = useFlusterduck()\n return (\n <button onClick={() => track('checkout_completed', { value: 49 })}>\n Pay\n </button>\n )\n}\n```\n\n## Links\n\nPublished on npm as `@flusterduck/react`, version 0.5.2.\n"
552
+ "content": "# @flusterduck/react\n\nThe React wrapper. Wrap your app root with `FlusterduckProvider` and signal detection starts across the whole tree. Read the SDK from any component with `useFlusterduck`.\n\n## Install\n\n```bash\nnpm install @flusterduck/react flusterduck\n```\n\n## Usage\n\n```tsx\n// main.tsx\nimport { FlusterduckProvider } from '@flusterduck/react'\nimport App from './App'\n\nexport function Root() {\n return (\n <FlusterduckProvider publishableKey=\"fd_pub_xxxxxxxxxxxx\">\n <App />\n </FlusterduckProvider>\n )\n}\n```\n\n```tsx\n// Any component.\nimport { useFlusterduck } from '@flusterduck/react'\n\nexport function CheckoutButton() {\n const { track } = useFlusterduck()\n return (\n <button onClick={() => track('checkout_completed', { value: 49 })}>\n Pay\n </button>\n )\n}\n```\n\n## Links\n\nPublished on npm as `@flusterduck/react`. Install pulls the latest published version.\n"
511
553
  },
512
554
  {
513
555
  "slug": "pkg-next",
514
556
  "title": "@flusterduck/next",
515
557
  "description": "Next.js script component and hook.",
516
558
  "group": "Packages",
517
- "content": "# @flusterduck/next\n\nThe Next.js wrapper. Add `FlusterduckScript` to your root layout and signal detection starts app-wide. Use `useFlusterduck` in client components for tracking, consent, and opt-out.\n\n## Install\n\n```bash\nnpm install @flusterduck/next flusterduck\n```\n\n## Usage\n\n```tsx\n// app/layout.tsx\nimport { FlusterduckScript } from '@flusterduck/next'\n\nexport default function RootLayout({ children }: { children: React.ReactNode }) {\n return (\n <html lang=\"en\">\n <body>\n <FlusterduckScript publishableKey=\"fd_pub_xxxxxxxxxxxx\" />\n {children}\n </body>\n </html>\n )\n}\n```\n\n```tsx\n// A client component.\n'use client'\nimport { useFlusterduck } from '@flusterduck/next'\n\nexport function ConsentToggle() {\n const { setConsent, optOut } = useFlusterduck()\n return (\n <>\n <button onClick={() => setConsent(true)}>Allow</button>\n <button onClick={() => optOut()}>Opt out</button>\n </>\n )\n}\n```\n\n## Links\n\nPublished on npm as `@flusterduck/next`, version 0.5.2.\n"
559
+ "content": "# @flusterduck/next\n\nThe Next.js wrapper. Add `FlusterduckScript` to your root layout and signal detection starts app-wide. Use `useFlusterduck` in client components for tracking, consent, and opt-out.\n\n## Install\n\n```bash\nnpm install @flusterduck/next flusterduck\n```\n\n## Usage\n\n```tsx\n// app/layout.tsx\nimport { FlusterduckScript } from '@flusterduck/next'\n\nexport default function RootLayout({ children }: { children: React.ReactNode }) {\n return (\n <html lang=\"en\">\n <body>\n <FlusterduckScript publishableKey=\"fd_pub_xxxxxxxxxxxx\" />\n {children}\n </body>\n </html>\n )\n}\n```\n\n```tsx\n// A client component.\n'use client'\nimport { useFlusterduck } from '@flusterduck/next'\n\nexport function ConsentToggle() {\n const { setConsent, optOut } = useFlusterduck()\n return (\n <>\n <button onClick={() => setConsent(true)}>Allow</button>\n <button onClick={() => optOut()}>Opt out</button>\n </>\n )\n}\n```\n\n## Links\n\nPublished on npm as `@flusterduck/next`. Install pulls the latest published version.\n"
518
560
  },
519
561
  {
520
562
  "slug": "pkg-vue",
521
563
  "title": "@flusterduck/vue",
522
564
  "description": "Vue plugin and composable.",
523
565
  "group": "Packages",
524
- "content": "# @flusterduck/vue\n\nThe Vue wrapper. Register the plugin once in your app entry and signal detection starts immediately. Access the SDK in any component with the `useFlusterduck` composable.\n\n## Install\n\n```bash\nnpm install @flusterduck/vue flusterduck\n```\n\n## Usage\n\n```ts\n// main.ts\nimport { createApp } from 'vue'\nimport { FlusterduckPlugin } from '@flusterduck/vue'\nimport App from './App.vue'\n\ncreateApp(App)\n .use(FlusterduckPlugin, { publishableKey: 'fd_pub_xxxxxxxxxxxx' })\n .mount('#app')\n```\n\n```vue\n<!-- Any component. -->\n<script setup lang=\"ts\">\nimport { useFlusterduck } from '@flusterduck/vue'\n\nconst { track } = useFlusterduck()\n</script>\n\n<template>\n <button @click=\"track('checkout_completed', { value: 49 })\">Pay</button>\n</template>\n```\n\n## Links\n\nPublished on npm as `@flusterduck/vue`, version 0.5.2.\n"
566
+ "content": "# @flusterduck/vue\n\nThe Vue wrapper. Register the plugin once in your app entry and signal detection starts immediately. Access the SDK in any component with the `useFlusterduck` composable.\n\n## Install\n\n```bash\nnpm install @flusterduck/vue flusterduck\n```\n\n## Usage\n\n```ts\n// main.ts\nimport { createApp } from 'vue'\nimport { FlusterduckPlugin } from '@flusterduck/vue'\nimport App from './App.vue'\n\ncreateApp(App)\n .use(FlusterduckPlugin, { publishableKey: 'fd_pub_xxxxxxxxxxxx' })\n .mount('#app')\n```\n\n```vue\n<!-- Any component. -->\n<script setup lang=\"ts\">\nimport { useFlusterduck } from '@flusterduck/vue'\n\nconst { track } = useFlusterduck()\n</script>\n\n<template>\n <button @click=\"track('checkout_completed', { value: 49 })\">Pay</button>\n</template>\n```\n\n## Links\n\nPublished on npm as `@flusterduck/vue`. Install pulls the latest published version.\n"
525
567
  },
526
568
  {
527
569
  "slug": "pkg-svelte",
528
570
  "title": "@flusterduck/svelte",
529
571
  "description": "SvelteKit wrapper.",
530
572
  "group": "Packages",
531
- "content": "# @flusterduck/svelte\n\nThe SvelteKit wrapper. Initialize once in your root layout behind a browser guard, then import the helpers you need anywhere.\n\n## Install\n\n```bash\nnpm install @flusterduck/svelte flusterduck\n```\n\n## Usage\n\n```svelte\n<!-- src/routes/+layout.svelte -->\n<script lang=\"ts\">\n import { browser } from '$app/environment'\n import { initFlusterduck } from '@flusterduck/svelte'\n\n if (browser) {\n initFlusterduck({ publishableKey: 'fd_pub_xxxxxxxxxxxx' })\n }\n</script>\n\n<slot />\n```\n\n```ts\n// Any module.\nimport { track } from '@flusterduck/svelte'\n\ntrack('checkout_completed', { value: 49 })\n```\n\n## Links\n\nPublished on npm as `@flusterduck/svelte`, version 0.5.2.\n"
573
+ "content": "# @flusterduck/svelte\n\nThe SvelteKit wrapper. Initialize once in your root layout behind a browser guard, then import the helpers you need anywhere.\n\n## Install\n\n```bash\nnpm install @flusterduck/svelte flusterduck\n```\n\n## Usage\n\n```svelte\n<!-- src/routes/+layout.svelte -->\n<script lang=\"ts\">\n import { browser } from '$app/environment'\n import { initFlusterduck } from '@flusterduck/svelte'\n\n if (browser) {\n initFlusterduck({ publishableKey: 'fd_pub_xxxxxxxxxxxx' })\n }\n</script>\n\n<slot />\n```\n\n```ts\n// Any module.\nimport { track } from '@flusterduck/svelte'\n\ntrack('checkout_completed', { value: 49 })\n```\n\n## Links\n\nPublished on npm as `@flusterduck/svelte`. Install pulls the latest published version.\n"
532
574
  },
533
575
  {
534
576
  "slug": "pkg-nuxt",
535
577
  "title": "@flusterduck/nuxt",
536
578
  "description": "Nuxt module.",
537
579
  "group": "Packages",
538
- "content": "# @flusterduck/nuxt\n\nThe Nuxt module. Add it to your modules list and configure your publishable key. All functions are SSR-safe and only run in the browser.\n\n## Install\n\n```bash\nnpm install @flusterduck/nuxt flusterduck\n```\n\n## Usage\n\n```ts\n// nuxt.config.ts\nexport default defineNuxtConfig({\n modules: ['@flusterduck/nuxt'],\n flusterduck: {\n publishableKey: 'fd_pub_xxxxxxxxxxxx',\n },\n})\n```\n\n```vue\n<!-- Any component. -->\n<script setup lang=\"ts\">\nconst { track } = useFlusterduck()\n</script>\n\n<template>\n <button @click=\"track('checkout_completed', { value: 49 })\">Pay</button>\n</template>\n```\n\n## Links\n\nPublished on npm as `@flusterduck/nuxt`, version 0.5.2.\n"
580
+ "content": "# @flusterduck/nuxt\n\nThe Nuxt module. Add it to your modules list and configure your publishable key. All functions are SSR-safe and only run in the browser.\n\n## Install\n\n```bash\nnpm install @flusterduck/nuxt flusterduck\n```\n\n## Usage\n\n```ts\n// nuxt.config.ts\nexport default defineNuxtConfig({\n modules: ['@flusterduck/nuxt'],\n flusterduck: {\n publishableKey: 'fd_pub_xxxxxxxxxxxx',\n },\n})\n```\n\n```vue\n<!-- Any component. -->\n<script setup lang=\"ts\">\nconst { track } = useFlusterduck()\n</script>\n\n<template>\n <button @click=\"track('checkout_completed', { value: 49 })\">Pay</button>\n</template>\n```\n\n## Links\n\nPublished on npm as `@flusterduck/nuxt`. Install pulls the latest published version.\n"
539
581
  },
540
582
  {
541
583
  "slug": "pkg-mcp-server",
542
584
  "title": "@flusterduck/mcp-server",
543
585
  "description": "Local stdio MCP server for AI assistants.",
544
586
  "group": "Packages",
545
- "content": '# @flusterduck/mcp-server\n\nThe local stdio MCP server. It connects an AI assistant to your Flusterduck data so it can read scores, issues, journeys, and revenue leakage on your behalf.\n\n## Install\n\n```bash\nnpx -y @flusterduck/mcp-server\n```\n\nNo install step is required. The `npx` command runs the server over stdio. The published binary is `flusterduck-mcp`.\n\n## Usage\n\nAdd it to your assistant\'s MCP configuration. The server authenticates with a scoped MCP key:\n\n```json\n{\n "mcpServers": {\n "flusterduck": {\n "command": "npx",\n "args": ["-y", "@flusterduck/mcp-server"],\n "env": {\n "FLUSTERDUCK_API_KEY": "fd_mcp_xxxxxxxxxxxx"\n }\n }\n }\n}\n```\n\nOnce connected, your assistant can query confusion scores, open issues, user journeys, and revenue impact through the server\'s tools.\n\n## Links\n\nPublished on npm as `@flusterduck/mcp-server`, version 0.5.2.\n'
587
+ "content": '# @flusterduck/mcp-server\n\nThe local stdio MCP server. It connects an AI assistant to your Flusterduck data so it can read scores, issues, journeys, and revenue leakage on your behalf.\n\n## Install\n\n```bash\nnpx -y @flusterduck/mcp-server\n```\n\nNo install step is required. The `npx` command runs the server over stdio. The published binary is `flusterduck-mcp`.\n\n## Usage\n\nAdd it to your assistant\'s MCP configuration. The server authenticates with a scoped MCP key:\n\n```json\n{\n "mcpServers": {\n "flusterduck": {\n "command": "npx",\n "args": ["-y", "@flusterduck/mcp-server"],\n "env": {\n "FLUSTERDUCK_API_KEY": "fd_mcp_xxxxxxxxxxxx"\n }\n }\n }\n}\n```\n\nOnce connected, your assistant can query confusion scores, open issues, user journeys, and revenue impact through the server\'s tools.\n\n## Links\n\nPublished on npm as `@flusterduck/mcp-server`. Install pulls the latest published version.\n'
546
588
  },
547
589
  {
548
590
  "slug": "pkg-vite-plugin",
549
591
  "title": "flusterduck-vite-plugin",
550
592
  "description": "Vite plugin.",
551
593
  "group": "Packages",
552
- "content": "# flusterduck-vite-plugin\n\nThe Vite plugin. It handles deploy tagging and source map upload at build time so issue evidence resolves to your original source.\n\n## Install\n\n```bash\nnpm install -D flusterduck-vite-plugin\n```\n\n## Usage\n\n```ts\n// vite.config.ts\nimport { defineConfig } from 'vite'\nimport { flusterduck } from 'flusterduck-vite-plugin'\n\nexport default defineConfig({\n plugins: [\n flusterduck({\n secretKey: process.env.FLUSTERDUCK_SECRET_KEY,\n release: process.env.VITE_APP_VERSION,\n environment: 'production',\n }),\n ],\n})\n```\n\nThe plugin records a deploy when the build completes and uploads source maps so stack traces in issue evidence map back to original file names and line numbers. Keep `FLUSTERDUCK_SECRET_KEY` in your build environment, never in client code.\n\n## Links\n\nPublished on npm as `flusterduck-vite-plugin`, version 0.5.2.\n"
594
+ "content": "# flusterduck-vite-plugin\n\nThe Vite plugin. It handles deploy tagging and source map upload at build time so issue evidence resolves to your original source.\n\n## Install\n\n```bash\nnpm install -D flusterduck-vite-plugin\n```\n\n## Usage\n\n```ts\n// vite.config.ts\nimport { defineConfig } from 'vite'\nimport { flusterduck } from 'flusterduck-vite-plugin'\n\nexport default defineConfig({\n plugins: [\n flusterduck({\n secretKey: process.env.FLUSTERDUCK_SECRET_KEY,\n release: process.env.VITE_APP_VERSION,\n environment: 'production',\n }),\n ],\n})\n```\n\nThe plugin records a deploy when the build completes and uploads source maps so stack traces in issue evidence map back to original file names and line numbers. Keep `FLUSTERDUCK_SECRET_KEY` in your build environment, never in client code.\n\n## Links\n\nPublished on npm as `flusterduck-vite-plugin`. Install pulls the latest published version.\n"
553
595
  },
554
596
  {
555
597
  "slug": "pkg-webpack-plugin",
556
598
  "title": "flusterduck-webpack-plugin",
557
599
  "description": "webpack plugin.",
558
600
  "group": "Packages",
559
- "content": "# flusterduck-webpack-plugin\n\nThe webpack plugin. It handles deploy tagging and source map upload at build time so issue evidence resolves to your original source.\n\n## Install\n\n```bash\nnpm install -D flusterduck-webpack-plugin\n```\n\n## Usage\n\n```js\n// webpack.config.js\nconst { FlusterduckPlugin } = require('flusterduck-webpack-plugin')\n\nmodule.exports = {\n plugins: [\n new FlusterduckPlugin({\n secretKey: process.env.FLUSTERDUCK_SECRET_KEY,\n release: process.env.APP_VERSION,\n environment: 'production',\n }),\n ],\n}\n```\n\nThe plugin records a deploy when the build completes and uploads source maps so stack traces in issue evidence map back to original file names and line numbers. Keep `FLUSTERDUCK_SECRET_KEY` in your build environment, never in client code.\n\n## Links\n\nPublished on npm as `flusterduck-webpack-plugin`, version 0.5.2.\n"
601
+ "content": "# flusterduck-webpack-plugin\n\nThe webpack plugin. It handles deploy tagging and source map upload at build time so issue evidence resolves to your original source.\n\n## Install\n\n```bash\nnpm install -D flusterduck-webpack-plugin\n```\n\n## Usage\n\n```js\n// webpack.config.js\nconst { FlusterduckPlugin } = require('flusterduck-webpack-plugin')\n\nmodule.exports = {\n plugins: [\n new FlusterduckPlugin({\n secretKey: process.env.FLUSTERDUCK_SECRET_KEY,\n release: process.env.APP_VERSION,\n environment: 'production',\n }),\n ],\n}\n```\n\nThe plugin records a deploy when the build completes and uploads source maps so stack traces in issue evidence map back to original file names and line numbers. Keep `FLUSTERDUCK_SECRET_KEY` in your build environment, never in client code.\n\n## Links\n\nPublished on npm as `flusterduck-webpack-plugin`. Install pulls the latest published version.\n"
602
+ },
603
+ {
604
+ "slug": "guide",
605
+ "title": "What is Guide?",
606
+ "description": "A user-triggered help layer that explains UI elements on hover, powered by friction data.",
607
+ "group": "Guide",
608
+ "content": `# Flusterduck Guide
609
+
610
+ Flusterduck already knows where your users get stuck. Guide tells them what to do about it.
611
+
612
+ It's a user-triggered help layer that lives inside your product. When someone gets confused, they press a button or hit a keyboard shortcut, and Guide explains the element they're looking at: what it does, what happens if they click it, what comes next. Plain language, generated from real friction data, specific to the exact screen and state they're in right now.
613
+
614
+ Not a tooltip. Not a chatbot. Not a product tour someone authored six months ago. A live explanation of whatever the user is pointing at, informed by what Flusterduck knows about where people actually struggle.
615
+
616
+ ## Why this exists
617
+
618
+ Powerful software loses users at the moment of confusion. Someone opens a dashboard, a settings panel, an admin console, and freezes. They don't know what a button does. They're scared to click because they can't tell whether the next action charges their card, deletes something, or triggers a notification they can't undo.
619
+
620
+ That fear is the thing. The task isn't hard. The not-knowing is.
621
+
622
+ Flusterduck's detection engine already identifies exactly where this happens. It watches 34 behavioral signals across thousands of sessions and clusters them into ranked friction points. Guide takes that data and does something about it on the spot: explains the confusing thing before the user gives up.
623
+
624
+ ## How it fits into what you already run
625
+
626
+ Flusterduck's existing cycle:
627
+
628
+ 1. **Detect** friction from real user behavior
629
+ 2. **Cluster** repeated signals into issues
630
+ 3. **Rank** by revenue at risk
631
+ 4. Your team fixes it
632
+ 5. **Verify** the fix recovered revenue
633
+
634
+ Guide inserts a new step between rank and fix. Many friction points don't need a redesign and a sprint. They need four sentences: "This button creates a new environment. It won't affect your production data. You can delete it later from Settings. Most people start here."
635
+
636
+ Instead of filing a ticket and waiting, you deploy an explanation at that exact spot. The verify step you already have proves whether it worked. That collapses the loop from weeks to minutes for every friction point that's really a confusion problem, not a true bug.
637
+
638
+ ## The read-only constraint
639
+
640
+ Guide never touches anything itself. It only explains. It can't click, can't fill forms, can't run actions, can't change state. This is deliberate.
641
+
642
+ A wrong action inside a customer's product is a support incident. A wrong explanation is a minor annoyance you can fix with a cache clear. The risk profile is completely different, and it means Guide can ship without the reliability and liability burden that "do it for me" agents carry.
643
+
644
+ Users keep full control. They just stop being afraid, because they can see what's behind a button before pressing it.
645
+
646
+ ## What users see
647
+
648
+ The user triggers Guide mode. Their cursor changes. They hover over an element. A small card appears near the cursor with:
649
+
650
+ - What the element does
651
+ - What would happen if they clicked it
652
+ - What the next step typically looks like
653
+ - If Flusterduck has friction data: a note like "87 users clicked this expecting it to save, but it only previews. Hit the green button below to actually save."
654
+
655
+ The card disappears when they move to a different element or exit Guide mode. No overlays. No modals. No interruptions.
656
+
657
+ ## What makes it different from tooltips
658
+
659
+ WalkMe, Pendo, Appcues, and every onboarding tool on the market show guidance someone wrote ahead of time. That guidance is finite, goes stale, and only covers what the author thought to write. The weird button in the advanced settings that confuses 12% of users? Nobody wrote a tooltip for that.
660
+
661
+ Guide generates explanations on the fly from the actual interface state and the friction data Flusterduck already has. It has no edge. It can explain the one control nobody anticipated, because it reads the screen and knows from behavioral data what specifically confuses people about it.
662
+
663
+ A pre-written tooltip tells you what the author thought you'd need to know. Guide tells you what real users actually struggled with.
664
+ `
665
+ },
666
+ {
667
+ "slug": "guide-setup",
668
+ "title": "Setting up Guide",
669
+ "description": "Enable Guide in init(), configure the floating button and keyboard shortcut, scope to pages.",
670
+ "group": "Guide",
671
+ "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## 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## 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 OpenAI 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 same POST body the built-in endpoint does:\n\n```json\n{\n \"fingerprint\": \"a1b2c3d4\",\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\nReturn a JSON response with an `explanation` field:\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"
560
672
  },
561
673
  {
562
- "slug": "demo",
563
- "title": "Local development",
564
- "description": "Run the product stack locally with live query API wiring.",
565
- "group": "Resources",
566
- "content": "# Local development\n\nRun the product stack locally:\n\n```bash\npnpm --filter @flusterduck/web dev\npnpm --filter @flusterduck/docs dev\n```\n\n- Product site: `http://localhost:3000`\n- Docs: `http://localhost:3003`\n\n## Local API env\n\nSet server-only API keys in local server environments only. Never expose `fd_sec_` or `fd_mcp_` keys in client env vars.\n\n```bash\nFLUSTERDUCK_SITE_ID=your-site-uuid\nFLUSTERDUCK_SECRET_KEY=fd_sec_your_read_key\nSUPABASE_URL=https://rhwhnkrqjlzyzcdhvyky.supabase.co\n```\n\nWhen keys are set, local server tools can pull scores, trends, revenue, issues, and live signal data through the query API.\n\n## Verify the install\n\n1. Add the SDK to your app with a publishable key.\n2. Trigger friction on pricing and checkout flows.\n3. Query scores through the API or MCP tools and confirm metrics, journeys, revenue leakage, and notes update.\n"
674
+ "slug": "guide-explanations",
675
+ "title": "How explanations work",
676
+ "description": "AI generation, three-layer caching, friction-aware context, cost budgeting, and prefetching.",
677
+ "group": "Guide",
678
+ "content": "# How Guide explanations work\n\nGuide generates plain-language explanations for UI elements on the fly. When a user hovers over something in Guide mode, the SDK captures the element's identity, sends it to the explanation engine, and renders the response in a card near the cursor. Most of this happens in under 200ms because of aggressive caching.\n\n## What the engine sees\n\nGuide doesn't screenshot your page or read its full DOM. It sends a narrow context packet per element:\n\n- **Selector and label.** The CSS selector Flusterduck already computes for signal tracking, plus the element's accessible name (from `aria-label`, `aria-labelledby`, visible text, or `title`).\n- **Role and state.** The ARIA role (explicit or implicit from the tag), plus whether it's disabled, expanded, checked, or required.\n- **Parent context.** The role and label of the nearest named ancestor. A \"Submit\" button inside a `<form aria-label=\"Payment details\">` is more meaningful than a \"Submit\" button inside a generic `<div>`.\n- **Friction data.** If Flusterduck has signals on this element (rage clicks, dead clicks, disabled taps), the engine knows what specifically confused other users. This is the piece that makes Guide precise instead of generic.\n\nNo form values. No user-typed text. No page content beyond what's needed to identify the control and its purpose.\n\n## Friction-aware explanations\n\nA generic screen reader could tell you a button says \"Deploy.\" Guide tells you what Flusterduck's data tells it:\n\n> \"Pushes your current changes to production. 43 users clicked this expecting a preview, but it deploys immediately. There's no confirmation dialog. If you want to preview first, use the 'Staging' button to the left.\"\n\nThat second sentence comes from the dead-click and rage-click signals Flusterduck has already clustered on this element. The explanation engine weaves behavioral evidence into the response so users get the warning before they make the same mistake.\n\nElements with no friction data still get explanations. They're just simpler: what the element does and what to expect when you interact with it.\n\n## The cache\n\nMost hovers don't trigger an AI call. Three cache layers sit between the hover event and the model.\n\n### Layer 1: In-memory (client-side)\n\nA map in the SDK's JavaScript. Instant. Holds the last 500 explanations the user has seen in this session. If the user hovers the same button twice, the second hover is free. Cleared on full page navigation, preserved across SPA route changes.\n\n### Layer 2: Local storage (client-side, persistent)\n\nExplanations persist in the browser's IndexedDB across sessions. When a returning user hovers over an element they saw last week, the explanation loads from local storage with no network request. Entries expire after 7 days or when a deploy invalidates them.\n\n### Layer 3: Edge cache (server-side, shared)\n\nExplanations are cached at the edge, keyed by site + page + element fingerprint. When user A hovers over the \"Submit\" button and gets an explanation, user B hovering the same button on the same page hits the edge cache. No new AI call.\n\nAfter a handful of users explore a page, every element on it is cached. Expected edge hit rate after warm-up: 90-95%. The AI model only fires for new elements it hasn't seen before or freshly deployed pages.\n\n### Invalidation\n\nCaches invalidate on two triggers:\n\n- **Deploy.** If you use Flusterduck's deploy correlation, explanations for the affected site are cleared automatically. The first user after a deploy re-primes the cache.\n- **TTL.** Explanations expire after 7 days regardless of deploys. This catches sites that don't use deploy correlation.\n\nThe `data-fd-guide` attribute bypasses all caching. Its content is always used verbatim.\n\n## Prefetching\n\nOn page load, the SDK scans the visible viewport for interactive elements, batches their fingerprints, and requests cached explanations in a single call. This pre-warms the in-memory cache so the first hover on any visible element is instant, not just the second.\n\nPrefetching only requests elements that already have cached explanations on the edge. It doesn't trigger AI generation. The cost is one lightweight network request per page load.\n\n## Cost and budgeting\n\nGuide uses a fast, cost-effective model for generation. Each explanation is roughly 300 input tokens (element context + friction data) and 100-150 output tokens (the explanation itself).\n\nEvery Flusterduck plan includes a monthly Guide allocation. The allocation scales with your plan tier because higher tiers serve more sessions, which means more hovers, which means more AI calls. The included amount is designed to cover normal usage with room to spare.\n\nWhen the allocation is spent, Guide degrades gracefully: cached explanations still work (they're free), but new AI generations pause until the next billing cycle or until you add overage capacity. The floating button shows a subtle indicator when the budget is low.\n\n### What counts against the budget\n\nOnly live AI generations count. Cache hits at any layer (in-memory, local storage, edge) are free. `data-fd-guide` attribute explanations are free. Prefetch requests are free (they only return already-cached content).\n\nIn practice, the cache handles 90%+ of hovers after the first few sessions on each page. The budget covers the cold starts and the long tail of rarely-visited elements.\n\n## Monitoring usage\n\nThe dashboard shows Guide usage alongside your other Flusterduck metrics:\n\n- Total hovers this billing period\n- Cache hit rate (broken down by layer)\n- AI generations used vs. allocation remaining\n- Top elements by hover count (which elements users ask about most)\n- Friction reduction on Guide-explained elements vs. unexplained ones\n\nThe last metric is the one that matters. It closes the loop: Flusterduck detected friction, Guide explained it, and the verification engine confirms whether confusion dropped.\n"
567
679
  }
568
680
  ];
569
681
 
@@ -590,7 +702,7 @@ function docExcerpt(content, query, length = 240) {
590
702
  }
591
703
  function createFlusterduckMCPServer(config) {
592
704
  const api = new FlusterduckAPI(config);
593
- const server = new McpServer({ name: "flusterduck-local", version: "0.3.0" });
705
+ const server = new McpServer({ name: "flusterduck-local", version: "0.5.3" });
594
706
  const readOnly = { readOnlyHint: true, destructiveHint: false, openWorldHint: true };
595
707
  const writeHint = { readOnlyHint: false, destructiveHint: false, openWorldHint: true };
596
708
  const mutateHint = { readOnlyHint: false, destructiveHint: true, openWorldHint: true };
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  createFlusterduckMCPServer
4
- } from "./chunk-EWAM6KBJ.js";
4
+ } from "./chunk-5PJCE3ED.js";
5
5
 
6
6
  // src/cli.ts
7
7
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  FlusterduckAPI,
3
3
  createFlusterduckMCPServer
4
- } from "./chunk-EWAM6KBJ.js";
4
+ } from "./chunk-5PJCE3ED.js";
5
5
  export {
6
6
  FlusterduckAPI,
7
7
  createFlusterduckMCPServer
package/package.json CHANGED
@@ -1,20 +1,32 @@
1
1
  {
2
2
  "name": "@flusterduck/mcp-server",
3
- "version": "0.5.3",
4
- "description": "Local stdio MCP server that connects AI assistants to your Flusterduck UX friction data via the Model Context Protocol.",
3
+ "version": "0.5.5",
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
- "license": "UNLICENSED",
6
+ "license": "SEE LICENSE IN LICENSE.md",
7
7
  "type": "module",
8
8
  "sideEffects": false,
9
9
  "keywords": [
10
- "flusterduck",
11
10
  "mcp",
11
+ "mcp-server",
12
12
  "model-context-protocol",
13
- "ux",
14
- "ai",
15
13
  "claude",
16
- "confusion",
17
- "analytics"
14
+ "claude-code",
15
+ "cursor",
16
+ "windsurf",
17
+ "copilot",
18
+ "cline",
19
+ "aider",
20
+ "ai-tools",
21
+ "ai-coding",
22
+ "ai-assistant",
23
+ "developer-tools",
24
+ "ux-analytics",
25
+ "rage-click",
26
+ "dead-click",
27
+ "friction",
28
+ "flusterduck",
29
+ "behavioral-analytics"
18
30
  ],
19
31
  "engines": {
20
32
  "node": ">=18"
@@ -37,25 +49,36 @@
37
49
  "publishConfig": {
38
50
  "access": "public"
39
51
  },
52
+ "scripts": {
53
+ "generate:docs": "node scripts/generate-docs.mjs",
54
+ "prebuild": "pnpm generate:docs",
55
+ "build": "tsup src/index.ts src/cli.ts --format esm --dts --clean",
56
+ "predev": "pnpm generate:docs",
57
+ "dev": "tsup src/index.ts src/cli.ts --format esm --dts --watch",
58
+ "typecheck": "tsc --noEmit",
59
+ "test": "vitest run",
60
+ "clean": "rm -rf dist",
61
+ "prepublishOnly": "pnpm build"
62
+ },
40
63
  "dependencies": {
41
64
  "@modelcontextprotocol/sdk": "1.29.0",
42
65
  "zod": "3.25.76"
43
66
  },
44
67
  "devDependencies": {
68
+ "@flusterduck/tsconfig": "workspace:*",
45
69
  "@types/node": "22.19.17",
46
70
  "tsup": "8.5.1",
47
71
  "typescript": "5.9.3",
48
- "vitest": "4.1.8",
49
- "@flusterduck/tsconfig": "0.2.0"
72
+ "vitest": "4.1.8"
50
73
  },
51
- "scripts": {
52
- "generate:docs": "node scripts/generate-docs.mjs",
53
- "prebuild": "pnpm generate:docs",
54
- "build": "tsup src/index.ts src/cli.ts --format esm --dts --clean",
55
- "predev": "pnpm generate:docs",
56
- "dev": "tsup src/index.ts src/cli.ts --format esm --dts --watch",
57
- "typecheck": "tsc --noEmit",
58
- "test": "vitest run",
59
- "clean": "rm -rf dist"
60
- }
61
- }
74
+ "repository": {
75
+ "type": "git",
76
+ "url": "https://github.com/creayo-dev/flusterduck.git",
77
+ "directory": "packages/mcp-server"
78
+ },
79
+ "homepage": "https://flusterduck.com",
80
+ "bugs": {
81
+ "url": "https://github.com/creayo-dev/flusterduck/issues"
82
+ },
83
+ "author": "Creayo LLC <admin@creayodev.com>"
84
+ }
package/LICENSE.md DELETED
@@ -1,23 +0,0 @@
1
- PROPRIETARY LICENSE FOR FLUSTERDUCK
2
-
3
- Copyright (c) 2026 Creayo
4
-
5
- 1. DEFINITIONS
6
- "Software" refers to the Flusterduck source code, compiled binaries, SDKs, libraries, and documentation.
7
- "Package" refers to the officially distributed compiled artifacts (e.g., via NPM).
8
-
9
- 2. USAGE GRANT
10
- Subject to the terms of this License, Creayo hereby grants you a non-exclusive, worldwide, royalty-free license to use, incorporate, and bundle the compiled Packages into your own applications for any purpose, including commercial use.
11
-
12
- 3. SOURCE CODE RESTRICTIONS
13
- You are strictly prohibited from:
14
- a) Copying, modifying, or distributing the Software's original source code.
15
- b) Reverse engineering, decompiling, or disassembling the Software or any of its components.
16
- c) Creating derivative works based on the Software's source code.
17
- d) Removing or altering any copyright notices or branding from the Software.
18
-
19
- 4. OWNERSHIP
20
- Creayo retains all rights, title, and interest in and to the Software, including all intellectual property rights.
21
-
22
- 5. DISCLAIMER OF WARRANTY
23
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.