Catch users getting stuck, then help them on the spot.
Built under the product name Clarus Heal. It maps a customer's web app UI, either by reading their GitHub repo or by crawling their live site, then watches real users through a drop-in script tag. Struggle detection runs server-side against 40 named rules, and any intervention it decides to show comes back in the same HTTP response the events arrived in. The customer never edits their application code to add a hint. A PII scrubber runs in the browser before anything is sent.
Three pillars:
- Map the UI first. Framework detection across 46 registry entries in 22 families, a Babel AST parser for React and Preact, a universal template scanner for everything else, plus an LLM pass that gives each element a semantic name and intent.
- Watch from the browser. A dependency-free SDK capturing 15 event types with client-side PII masking, offline buffering, and sampling.
- Decide and intervene server-side. 40 detection rules over hydrated session history, then a bandit-driven dispatcher that returns an overlay, tooltip, or hint inline.
Open the guided live lab to create real browser struggle, inspect the detector, reveal targeted help and export the evidence. Replay the five-step tour or use Stop/Reset to explore the SDK lifecycle. The exercise runs locally after loading and makes no model or ingestion requests.
Install the released SDK: typed ESM/CommonJS or a script tag, complete accessible tours, explicit teardown, scoped ingestion keys and event hooks. The versioned package, source provenance and SHA-256 checksums are attached to Releases.
A user opens a billing settings page, clicks the same disabled button four times, retypes a tax ID three times, hits the browser back button twice, and leaves. Nobody finds out. The session-replay tool recorded it, but somebody has to watch the replay, and nobody does. The analytics dashboard shows a funnel drop from 71% to 64%, which tells you a number changed but not what a person was trying to do.
The gap is between knowing something is wrong and doing something about it while the user is still on the page. Analytics and replay tools observe and report. Product tour tools intervene, but only where a human already decided in advance that a tour should appear, and only after somebody built it.
Three constraints shaped the design, and most of the interesting code exists because of them.
1. The system has to know what the UI is, before anyone uses it. "The user rage-clicked element sh_9f3c..." is useless. "The user rage-clicked the Save Tax Settings button on /settings/billing" is actionable. So the platform ingests the customer's frontend first and builds a UIMap of every interactive element and route.
2. Struggle spans HTTP requests. Rage clicks fit in one event batch. Circular navigation, back-button thrash, and dead ends do not: the loop plays out over 30 seconds and three POSTs. A detector that only sees the incoming batch will never fire those rules and will still look like it works, because the rules that do fit in a batch keep firing. The ingest route therefore hydrates recent stored history for every session in the batch before running detection.
3. Getting it wrong is worse than doing nothing. An overlay that appears on a checkout page for a user who was not confused is a bug the customer sees before you do. So safe mode is on by default, invasive intervention types are allowlist-gated, sensitive routes have a denylist, and detection thresholds adapt per element instead of using one global constant.
flowchart TD
A[customer repo or live URL] --> B[framework detector<br/>46 entries, 22 families]
B --> C[Babel AST parser<br/>or universal template scan]
C --> D[UIElement / UIRoute rows]
D --> E[LLM enrichment<br/>semantic name, intent, help copy]
F[real user's browser<br/>SDK: 15 event types, PII scrub,<br/>offline buffer, sampling] --> G[POST /api/events]
G --> H[persist idempotently]
H --> I[hydrate recent session history]
I --> J[load per-element baselines]
E --> J
J --> K[40-rule detector]
K --> L[dispatcher: bandit, cache,<br/>denylist, safe mode]
L --> M[interventions in the SAME response]
M --> N[SDK renders overlay / tooltip / hint]
N --> O[outcome events: shown, dismissed, success]
O --> L
src/lib/parsers/ turns a frontend into a structured map.
- Framework detection (
registry.ts,detector.ts) scores a repo against 46 registry entries across 22 families using four independent signals:package.jsondependencies, config file presence, file extensions undersrc/, and npm script contents. It reports a confidence score and is deliberately biased toward under-reporting, because a wrong framework guess cascades into wrong parsing, and an honest "I don't know" is recoverable while confidently bad output is not. - React, Next, Remix and Preact go through a Babel AST parser (
react.ts, 797 lines) that walks JSX, resolves handler functions, and extracts validation attributes. - Everything else (Vue, Svelte, Angular, Astro, Solid, Qwik, Lit, Alpine, HTMX and the rest) routes to a universal template scanner (
universal-html.ts, 703 lines) with per-family route detection. This is a deliberate accuracy tradeoff, documented inparsers/index.ts: every framework gets a working extraction path on day one. - GitHub ingestion goes entirely through the Octokit tree and blob API. No
git cloneand no shell-out; fetched blobs are materialized into anos.tmpdir()scratch directory that is removed in afinallyblock, so the mapper deploys to any serverless host with a writable/tmp. - Live sites can be crawled instead, either with a plain HTML fetcher (
crawler.ts) or a Playwright-driven crawler for SPAs (playwright-crawler.ts).
Every extracted element gets a deterministic ID (sh_ plus 32 hex chars) from hashElementId() in src/lib/types/ui-map.ts, hashed over (orgId, filePath, nodeDescriptor) with the global Web Crypto API. That choice is load-bearing: the identical function runs unmodified in Node build tooling, in edge runtimes, and in the customer's browser. The file documents the invariant explicitly, because any drift between those three consumers degrades the system silently rather than failing loudly.
src/sdk/ has zero runtime dependencies and is bundled by esbuild into public/sdk.js and public/sdk.min.js. Both bundles are rebuilt from the reviewed source.
<script src="https://your-deployment/sdk.min.js"></script>
<script>
ClarusHeal.initSelfHealing({
orgId: 'org_...',
endpoint: 'https://your-deployment/api/events',
ingestKey: 'ck_...',
})
</script>There is also a one-line auto-init form: a <script> tag carrying data-org-id is picked up by readAutoInitOptions() (src/sdk/index.ts:683), so no second script block is needed.
It captures 15 event types (click, input change, submit, navigation, hover, scroll, dwell, paste, copy, focus, blur, keydown, JS error, validation error, custom), buffers to survive offline, and supports uniform, per-type, and predicate-based sampling. A dwell is checked once a second and reported when the user has been quiet for 10s, so a 15s or 30s idle threshold is detected about when it happens rather than up to half a minute late. Each report carries the quiet stretch it belongs to (meta.stretch) and grows as that stretch continues, so a long idle can still clear a baseline-raised threshold and a resumed interaction starts a fresh stretch. Because one stretch is therefore many rows, the per-element dwell baseline groups by meta.stretch and takes one sample per stretch - a single 120s stare must not out-vote 99 separate 10s stares just by having been reported 111 times.
A DWELL names the element the user was last interacting with - a click, a typed field, a submit - and only falls back to the element the pointer was left hovering when there is no interaction to name. That ordering matters more than it looks: the server keys both the per-element p95DwellMs baseline and the intervention target on the id the dwell carries, so attributing a quiet stretch to whatever the mouse happened to be resting on does not merely mislabel a row - it applies an adapted threshold to an element the user was never stuck on. Hovers are emitted with a null element id for the same reason, and the hover is held aside until a report is actually due, so a pointer gliding across the page mid-stretch cannot steal the attribution.
It does not care what the host app is built with. Listeners sit at the document level, so it needs no framework hooks. Navigation is followed through pushState, replaceState (only when the route changes) and the back/forward buttons. For hash-mode routers (#/cart, AngularJS #!/cart), the route comes from the fragment, so those screens are not all reported as /. Where the browser has the Navigation API, a forward move to a new fragment is kept apart from a real back-button press, so clicking through hash links never reads as back-button thrash. Calling initSelfHealing() during a server render is a no-op, so frameworks that render on the server can call it from shared code.
Honest wrinkle: the SDK emits 15 event types; the Prisma EventType enum persists 7. The extra types are collapsed at the persistence boundary today.
The PII scrubber runs before anything leaves the page. src/sdk/scrubber.ts holds 15 regex patterns in DEFAULT_PATTERNS, masking emails, credit-card-shaped digit runs, US SSNs, US and international phone numbers, IBANs, IPv4 and IPv6 addresses, JWTs, AWS access key IDs, GitHub tokens, Stripe keys, and Anthropic/OpenAI-shaped keys, with customer-supplied extra patterns merged in. The masking happens client-side by design: a scrubber that runs on the server has already lost.
12 of the 15 InterventionType values have an SDK renderer. DOM, BEHAVIOR and AUTO_FIX have none, by design. TOUR is a stub: it renders as a modal, because multi-step TourConfig steps are not populated by the dispatcher yet (src/sdk/renderers.ts:623).
Every renderer draws inline-styled DOM under one root container and works out its own geometry with getBoundingClientRect(). Three properties of that fall out of the tests in sdk-renderers.test.ts, and each of them was a defect before it was a property:
- Anchored panels stay inside the viewport.
TOOLTIP,INLINE_HINTandARROWplace themselves relative to their target, and clamp into the viewport when that target sits at an edge. An unclamped placement renders off-screen, which a user cannot tell apart from an intervention that never rendered at all. SPOTLIGHTuses a viewport-sized fixed overlay with an even-odd clip. Its rectangular hole follows the target's viewport geometry, including after scrolling.- Feedback carries both IDs end to end.
idis session-keyed for deduplication;rowIdis SHA-256 over organization, struggle, element and variant for population feedback. The API retainsrowIdin its SDK response and scopes outcome writes to the authenticated organization. Old unscoped rows remain historical; new rows accumulate organization-scoped statistics. Element-free interventions currently have no persisted feedback row. - Validation copy stays within its session and renders as text. The SDK removes an echoed field value and scrubs recognized PII before uploading a validation message. It remains a pattern-based scrubber, not a guarantee that every possible personal datum can be recognized.
src/app/api/events/route.ts is the hot path. Per batch it authenticates the org against a hashed ingest key, Zod-validates against a versioned wire schema (EVENT_SCHEMA_VERSION is 3 and versions 1 and 2 are still accepted, so old cached SDK bundles in customers' browsers keep working through a rollout), persists, hydrates up to 1,000 stored events from a 5-minute lookback for the sessions in the batch, loads per-element baselines, runs the detector, records outcomes from prior impressions, and dispatches interventions inline.
Idempotent ingest by construction. A (orgId, idempotencyKey) unique index plus createMany({ skipDuplicates: true }) means the SDK offline replay buffer can retry as aggressively as it likes with zero server-side dedup logic.
src/lib/struggle/detect.ts (1,182 lines) is 40 named rules, each an independently testable pure function over RuntimeEvent[]. The literal StruggleType members:
| Family | Members |
|---|---|
| Click (4) | RAGE_CLICK, DEAD_CLICK, INVALID_CLICK, MIS_CLICK |
| Form (9) | THRASH, BACKTRACK, VALIDATION_LOOP, ABANDONED_FIELD, PASTE_REPEAT, REQUIRED_MISSED, FORMAT_ERROR, PASSWORD_RETRY, SLOW_FILL |
| Navigation (6) | LOOP, SILENT_FAIL, BACK_THRASH, DEAD_END, QUICK_BOUNCE, CIRCULAR_NAV |
| Discovery (9) | HOVER_HUNT, LONG_DWELL, RAPID_SCROLL, SCROLL_OVERSHOOT, IDLE_AFTER_LOAD, EMPTY_SEARCH, REPEAT_SEARCH, ZERO_RESULTS, FAILED_FILTER |
| UI confusion (3) | MENU_THRASH, TOOLTIP_HOVER_REPEAT, TAB_HOPPING |
| Error (4) | ERROR_DISMISS, RETRY_LOOP, NOT_FOUND_BOUNCE, JS_ERROR |
| Auth (2) | LOGIN_FAILURE, LOCKED_OUT |
| Other (3) | KEYBOARD_LOST_FOCUS, COPY_BOUNCE, HELP_HUNT |
Detection thresholds are not constants. A nightly cron computes p95 click-rate, dwell, and hover baselines per element, and the detector consumes them, so a noisy game button gets a higher rage-click threshold than a Delete button. A baseline can only move a threshold in the direction of caution, and always could: Math.max against the static rule means an element whose history suggests a lower bar keeps the static one, because under-firing on a quiet element is a missed hint while over-firing on it is an intervention the customer sees on a page where the user was never stuck.
src/lib/interventions/dispatcher.ts (523 lines) picks what to show. It runs an epsilon-greedy multi-armed bandit (epsilon 0.1) over copy variants, weighted by empirical success rate with Laplace smoothing. pickVariantDeterministic takes over in two cases: below banditMinSamples (30) total impressions, and whenever the stats map is absent entirely. That guarantees both early exploration and reproducible unit tests. The RNG is injectable. The dispatcher honors a per-route denylist and per-intervention pause flags, and prefers LLM-precomputed copy from an InterventionCache over the 42 in-code templates in library.ts. The allowlist that makes this safe is enforced upstream, at cache-write time: the precompute worker only ever writes cache rows for the eight types in VALID_RENDERER_TYPES (src/lib/interventions/precompute.ts:159) - OVERLAY, HIGHLIGHT, TOOLTIP, MODAL, BANNER, INLINE_HINT, CONFIRM, ANNOUNCE - so invasive types can never be served from cache.
The loop closes: the SDK reports shown, dismissed, and success back as CUSTOM events carrying the intervention row ID, which increments counters, recomputes successRate, writes a per-session impression row, and feeds the bandit's next pick.
The three templates whose copy is only useful if it names the page - the LOOP banner, the CIRCULAR_NAV banner, and the NOT_FOUND_BOUNCE overlay - now interpolate {route}. {route} was documented as a template variable in library.ts from the start and filled in by render() the whole time; no template referenced it, so the copy told the user they had "been here a few times" about a page they had since forgotten, and that "that page is gone" without saying which page. The route is the session's known route, so it is resolved server-side from the same map the denylist uses and needs nothing from the SDK. When a batch arrives with no NAVIGATION event and no hydrated history the route is genuinely unknown, and fixing the gap with an empty substitution would produce "You've been back to a few times" - worse than the vaguer original sentence. So the phrase collapses to the part that is still true ("back here a few times", "That page is gone") rather than leaving a hole or leaking a raw {route}, with a test over the whole template library so no future template can ship a placeholder the dispatcher does not fill.
- Safe mode is time-boxed, not an indefinite flag. The schema carries both
safeMode Boolean @default(true)and asafeModeUntil DateTime?; the dispatcher documents it as the default for the first 7 days post-install. - Two independent security flags.
REQUIRE_AUTHcontrols dashboard sign-in;REQUIRE_INGEST_KEYseparately controls whether/api/eventsrejects batches with nock_key. Ingest keys are stored as SHA-256 hashes plus an 8-character display prefix, and the plaintext token is shown once and unrecoverable afterward. Customer LLM keys are encrypted at rest with AES-256-GCM, a fresh 12-byte IV per record, and the auth tag appended to the ciphertext; tamper detection is covered by tests. - Providers are concrete and readable. Every
(orgId, kind)where kind isDEEPorFASTresolves to an independentModelProvider, so rate-limit pools and usage counters stay separate. Defaults are hardcoded:claude-opus-4-7andclaude-haiku-4-5-20251001for Anthropic,gpt-4oandgpt-4o-minifor OpenAI. - Degradation is systematic. Baseline loads, cache reads, struggle persistence, intervention upserts, and usage metering are each individually caught, so a partial database failure still returns a valid intervention payload. Enrichment is cached on
(elementId, contextHash)rather thanelementIdalone, so an element is re-enriched when its siblings, route, or parent component change.
| Path | Lines | What it is |
|---|---|---|
src/lib/struggle/detect.ts |
1,182 | the 40 detection rules |
src/lib/parsers/react.ts |
797 | Babel JSX extraction |
src/app/api/events/route.ts |
639 | ingest, hydrate, detect, dispatch |
src/lib/parsers/universal-html.ts |
703 | template scan for non-React families |
prisma/schema.prisma |
666 | 23 models, 10 enums |
src/sdk/index.ts |
771 | SDK capture loop and init |
src/sdk/renderers.ts |
719 | 12 intervention renderers |
src/lib/parsers/registry.ts |
609 | 46 framework entries, 22 families |
src/lib/interventions/dispatcher.ts |
523 | variant selection and gating |
Requires Node 20 or newer (CI runs 22) and Postgres. package.json has no engines field; the floor is enforced by the setup script.
# Installs pnpm if missing, installs dependencies, writes .env from
# .env.example with generated secrets, and builds the SDK bundle.
./scripts/setup.sh # macOS / Linux
.\scripts\setup.ps1 # Windows
docker compose up -d # Postgres + Adminer on :8080, skip if you have your own
# The one value you must set by hand in .env:
# DATABASE_URL="postgresql://postgres:postgres@localhost:5432/clarus_heal?schema=public"
pnpm db:migrate
pnpm dev # http://localhost:3000Open-access mode is the default (REQUIRE_AUTH="false", REQUIRE_INGEST_KEY="false"): no sign-in, everything runs against an auto-provisioned "Demo Workspace" org. The Settings page has a demo-seed action that fills the dashboard with synthetic data. public/demo/index.html is a standalone SDK harness that runs in console mode with no server, no database, and no org, which is the fastest way to watch detection fire.
For a step-by-step walkthrough written for someone who has never set up a JavaScript project, see GETTING_STARTED.md. Registering the GitHub App (only needed for the repo-ingest path) is covered in GITHUB_SETUP.md.
| Command | Does |
|---|---|
pnpm test |
Vitest |
pnpm typecheck |
tsc --noEmit |
pnpm lint |
ESLint via next lint |
pnpm db:studio |
Prisma Studio on :5555 |
pnpm sdk:build / pnpm sdk:build:min |
esbuild the browser SDK into public/ |
pnpm build then pnpm start |
Production build |
Deployment target is Vercel. vercel.json declares three cron workers: detect-struggles every 5 minutes, compute-baselines daily at 04:00 UTC, precompute-interventions hourly. All three are gated behind CRON_SECRET.
Dependency caveats worth knowing before you install. playwright is a full runtime dependency, not a devDependency, because the SPA crawler needs it; on a serverless target that is real weight. next-auth is pinned to the prerelease 5.0.0-beta.25, so one prerelease pin sits in the critical path; react and react-dom carry caret ranges seeded from a 19.0.0 RC that the committed pnpm-lock.yaml now resolves to stable 19.2.x.
src/
app/ Next.js App Router
(marketing)/ landing page
onboarding/ three ingest paths: github / crawler / direct BYO-keys wizard
dashboard/ 11 pages: overview, install, repos, flows, friction, elements,
elements/[id], interventions, sessions, usage, settings
api/ events (hot path), auth, github, health, cron/*
lib/
types/ single sources of truth: ui-map.ts, events.ts, interventions.ts
parsers/ registry -> detector -> dispatcher -> react | universal-html
| crawler | playwright-crawler, plus persist.ts
struggle/ detect.ts (40 rules), baselines.ts (per-element p95 stats)
interventions/ library.ts (42 templates), dispatcher.ts, precompute.ts
enrichment/ LLM passes over elements and routes
providers/ ModelProvider interface + anthropic / openai
crypto/ auth/ usage/ github/ db/ access.ts
sdk/ dependency-free browser SDK
components/ hand-written shadcn-style primitives (no Radix dependency)
prisma/ schema.prisma, 4 applied migrations
tests/ Vitest unit, DOM and Chromium coverage
scripts/ setup.sh, setup.ps1
public/ sdk.js, sdk.min.js (checked-in esbuild output), demo/
22,032 lines of TypeScript and TSX across 125 files in src/ and tests/. Dashboard reads go through server components and mutations through inline server actions; there is deliberately no REST layer for the dashboard, only for SDK ingest and webhooks.
The four migration directory names read as the project's phase history: init, expand_enums, platform_config_allowlists, phase_25_events_and_sampling.
pnpm-workspace.yaml exists at the root but contains only a build flag (allowBuilds: esbuild: false). There are no workspace packages. This is one Next.js application, deliberately, not a half-finished monorepo.
Differs from readValidation in react.ts only in how the platform attributes behave. Both read the markup that genuinely exists: text, numeric range (min/max), step, minLength/maxLength, pattern, and inputType. Neither reads a validity flag, because none can: setCustomValidity, customError, badInput and the rest of the ValidityState set are runtime state a page produces by calling browser APIs on a live element, not attributes in a source file. They reach the server the only way they can - the SDK captures them off the real element and sends element.validity and element.validationMessage with the failing event. The dispatcher renders that into the {validation} slot of the copy templates, preferring the page's own message, so a field that fails only rangeUnderflow gets "needs at least 18" and a field the page gave a custom message shows that message instead of a generic one.
Run pnpm test for the current test inventory. The suite covers unit logic, SDK DOM flows and a real Chromium capture-to-dispatch flow.
| File | Covers |
|---|---|
sdk.test.ts |
PII scrubbing, buffering, sampling, route tracking |
dispatcher.test.ts |
variant selection, bandit behavior, copy substitution |
struggle.test.ts |
detection rules, sliding windows, adaptive thresholds |
react-parser.test.ts |
Babel JSX extraction, validation rules |
crawler.test.ts |
HTML crawl |
ui-map.test.ts |
ElementId determinism |
universal-parser.test.ts |
template scan across families |
playwright-crawler.test.ts |
SPA crawl |
crypto.test.ts |
AES-GCM round trip and tamper detection |
dispatcher-denylist.test.ts |
route denylist |
email-sign-in.test.ts |
magic-link delivery over SMTP, sign-in address rules |
sdk-scrubber-phone-bounds.test.ts |
phone-pattern bounds: suffix of a longer digit run is not redacted |
ingest-schema.test.ts |
over-long page text is cut, not a rejected batch |
session-payload.test.ts |
/api/auth/session never exposes the session token |
sdk-dwell-backend.test.ts |
SDK dwell timer driven against a real DOM, its events fed to the real detector |
sdk-dwell-stretch-flow.test.ts |
SDK stretch identity through the wire schema into the real baseline grouping |
baselines-dwell-grouping.test.ts |
dwell p95 weighted per quiet stretch, not per heartbeat row |
sdk-validity-flow.test.ts |
runtime setCustomValidity capture through ingest into rendered copy |
browser-evidence.test.ts |
real headless Chromium: browser constraint API, SDK capture, ingest and dispatcher |
The SDK DOM suites run against a live jsdom document, which is a DOM implementation, not a browser. jsdom implements setCustomValidity and the constraint API well enough to drive the SDK's real code path, but it does not compute badInput, and it is not proof of browser behaviour. sdk-validity-flow.test.ts therefore mocks the badInput validity state (installing both validity and checkValidity so the two agree) and says so in the test; the real-browser observation is recorded separately in docs/browser-evidence.md, captured with headless Chromium against the built SDK.
Coverage is concentrated on the pure, high-risk core: detection rules, dispatcher selection, both parser families, the crypto boundary, and the ElementId hash contract. Those are the components where a silent regression would degrade the product invisibly instead of breaking loudly.
What is not covered: there is no integration test that exercises /api/events end to end against a real database, the Chromium test uses an ingest fixture rather than a real database, and the dashboard's React pages are untested.
CI (.github/workflows/ci.yml) runs on push to main and on every PR: Node 22 and pnpm 10 with a cached store, then prisma generate, typecheck, lint, test, both SDK bundles (failing if the checked-in copies are stale), and a production pnpm build. There is no Postgres service container, the browser flow uses an ingest fixture.
| Area | Status | Notes |
|---|---|---|
| GitHub repo ingestion, URL crawl, Playwright SPA crawl | Built | Octokit tree/blob API, no git clone |
| Framework detection | Built | 46 entries, 22 families, confidence-scored |
| React / Preact AST parsing | Built | react.ts |
| All other framework parsing | Partial | regex template scanner, finds elements and labels, less accurate than AST |
StubParser / SoftStubParser |
Unreachable | present in parsers/stub.ts as the escape hatch, but parsers/index.ts routes every family to either the React parser or the universal one, so nothing imports them |
| Browser SDK capture | Built | 15 event types, PII scrub, offline buffer, sampling |
| SDK renderers | Partial | 12 of 15 InterventionType values; TOUR renders as a modal |
DOM / BEHAVIOR / AUTO_FIX interventions |
Not built | in the schema and gated in the dispatcher; no SDK renderer exists, nothing rewrites a customer's DOM |
| 40 server-side detection rules | Built | with per-element adaptive baselines |
| Bandit dispatch, denylist, pause flags, safe mode | Built | dispatcher.ts |
| LLM enrichment and intervention precompute | Built | behind a provider abstraction |
| SaaS shell | Built | magic-link auth, onboarding wizard, 11 dashboard pages, usage metering, encrypted key storage |
| Scheduled workers | Built | three Vercel crons behind CRON_SECRET |
| Event type persistence | Partial | SDK emits 15 types, the EventType enum stores 7 |
| Real database integration | Not built | Chromium capture and dispatch are exercised with an ingest fixture |
| Monorepo split | Not planned | single app; workspace file exists only for a build flag |
This repository is a sanitized copy of a private working tree. The .env file, which held live working credentials (SMTP password, GitHub App client and webhook secrets, the AES-GCM master key, the Auth.js session secret, the cron secret, and a personal tunnel hostname), was excluded. .env.example is the complete template, carrying placeholder values rather than real ones, and is what you should copy. Machine-local build state (node_modules/, .next/, tsconfig.tsbuildinfo, next-env.d.ts) and an internal agent session journal containing local absolute paths were also removed. Nothing removed affects your ability to run the project.
public/sdk.js and public/sdk.min.js are checked-in esbuild output of this repo's own src/sdk/ sources, kept deliberately so public/demo/index.html works out of the box; regenerate them with pnpm sdk:build and pnpm sdk:build:min.
The UI primitives in src/components/ui/ were hand-written in the shadcn/ui style rather than pulled from its CLI. shadcn/ui is MIT and explicitly meant to be copied into your codebase; the attribution is noted here regardless.
package.json is private: true with no exports field, so the SDK cannot be installed from a registry. Use the script tag.
This has never been deployed publicly. No customers, no traffic, no revenue. Every number in this README comes from the code and the test suite.
MIT. See LICENSE.
Built by Cade (https://github.com/csnyder256). Repository: https://github.com/csnyder256/ux-struggle-detector
Latest release · Install, deploy and upgrade
Release assets include checksums and version-specific notes.