Every product ships the same interface to a first-time user and to someone who
has used it daily for three years. The usual fix is a scatter of booleans —
hasSeenExportTooltip, dismissedOnboarding — one per feature, never cleaned
up, with no notion of degree and no notion of struggle.
Mindra replaces them with one primitive: how familiar is this user with this specific element, right now?
const { expertise, friction, suggestion } = useAdaptive('export-btn')No signup. No API key. No backend. Nothing leaves the browser.
npm install @mindra.dev/reactimport { AdaptiveProvider, Adaptive } from '@mindra.dev/react'
// 1. Wrap your app once
<AdaptiveProvider appId="my-app">{children}</AdaptiveProvider>
// 2. Let an element speak differently to people who know it
<Adaptive
id="export"
novice="Export this project as a PDF"
expert="Export"
>
<button>Export</button>
</Adaptive>That is the whole setup. The runtime watches hovers, clicks, focus, hesitation and abandonment, scores each element, and swaps the copy as familiarity grows.
const state = useAdaptive('export-btn')
{
familiarity: 0.63, // 0–1, rises asymptotically with use
friction: 0.21, // 0–1, from hesitation, abandonment and errors
confidence: 0.50, // (1 − friction) × familiarity
expertise: 'proficient', // novice | learning | proficient | expert
suggestion: 'show_shortcut' // show_tutorial | inline_details | show_shortcut | silent
}Branch on it however you like:
const { expertise } = useAdaptive('delete-item')
const handleDelete = () => {
if (expertise === 'novice') return openConfirmationModal()
deleteNow()
showUndoToast() // experts get speed, with a way back
}| Metric | Definition |
|---|---|
| Familiarity | 1 − e^(−clicks / λ), λ = 8 by default. Asymptotic, never quite reaches 1. |
| Hesitation | Time between pointer entering the element and activating it, clamped to 8s. |
| Friction | 0.4·hesitation + 0.4·abandonment + 0.2·errorRate |
| Confidence | (1 − friction) × familiarity |
Tier thresholds sit at 0.28 / 0.58 / 0.85 familiarity. Tune λ via
<AdaptiveProvider config={{ lambda: 12 }}>.
<Adaptive> resolves content in this order: the variant for the user's current
tier → AI-generated copy if you enabled it → fallback → the child untouched.
Variants walk down to the nearest one you supplied, so partial specification
behaves sensibly — give only novice and expert, and a proficient user keeps
the novice copy. The interface does not simplify until you say it may.
It also sets data-adaptive-tier on the element, so you can drive transitions
from your own stylesheet instead of having a library write inline styles.
The engine is deterministic and needs no model. If you want copy generated instead of hand-written, bring your own key — it is never proxied through us, because there is no "us" in the request path.
<AdaptiveProvider
appId="my-app"
ai={{ provider: 'ollama', endpoint: 'http://localhost:11434', model: 'llama3' }}
/>Supported: ollama, openai, gemini, window.ai, a custom runner, or
server pointed at your own endpoint. Generated copy is cached per element and
tier, so a model is consulted once per variation, not once per interaction.
- Collected: interaction timing and counts only — hovers, clicks, focus, hesitation, abandonment, and errors you report. Never keystrokes, never input values, never page content.
- Stored:
localStorage, undermindra_stats_<appId>, capped at 500 elements with coldest-first eviction. - Transmitted: nothing. There is no default network call. Data leaves the
browser only if you pass your own
onSynchandler or enable a cloud AI provider. - Identifiers derived from the DOM are scrubbed of emails, UUIDs, long
hashes and digit runs before storage, so
order-1048577is recorded asorder-#.
You are still the data controller for your own site. If your jurisdiction
requires consent for non-essential local storage, gate <AdaptiveProvider>
behind it.
Being direct about the edges, because they matter more than the features:
- No cross-device sync, but there is a seam for it. State lives in this
browser. Pass
userIdto keep two people sharing a machine apart, and useinitialStats/exportStats()to carry familiarity across devices through storage you already own. Mindra ships no backend and never will — see Carrying state across devices. - No backend, dashboard or cohort analytics. Bring your own
onSyncif you want aggregation. - It does not move your UI around. Mindra emits state; you decide what changes. Only elements you explicitly wrap are ever touched.
- Clicks are not comprehension. A user who clicks the same button thirty
times may be an expert or may be stuck. Read
frictionalongsidefamiliarity.
Familiarity is stored per browser. If you want it to follow a person, you supply the storage — the library gives you both ends and stays out of the middle.
<AdaptiveProvider
appId="my-app"
userId={user.id} // separate profiles on a shared machine
initialStats={statsLoadedFromYourApi} // what you saved last time
mergeInitialStats // combine with this device's history
onSync={(events) => saveToYourApi(events)}
/>onPersist fires whenever the state settles, handing you exactly what to save:
<AdaptiveProvider
appId="my-app"
userId={user.id}
initialStats={statsLoadedFromYourApi}
mergeInitialStats
onPersist={(stats) => fetch("/api/mindra", {
method: "PUT",
body: JSON.stringify(stats),
})}
/>On your side that is one row per user with a JSON column, and two endpoints. On ours it is nothing — Mindra still stores no user data anywhere.
Prefer onPersist over onSync for this. onSync hands you raw events, so
persisting them means reimplementing the scoring accumulator in your own code;
onPersist hands you the state the runtime already computed.
For finer control, useMindraRuntime() returns the runtime itself:
const runtime = useMindraRuntime()
const stats = runtime?.exportStats() // plain JSON, store it whereverTwo details worth knowing:
userIdis hashed before it becomes a storage key, so passing an email or an account id does not write that value intolocalStorage.- Writes are coalesced, and hydration does not echo back. A burst of clicks
produces one
onPersistcall, and loading a profile does not immediately write it straight out again. - Merging takes the larger counter rather than summing. Hydrating the same history twice cannot inflate a score, so re-hydration is always safe and familiarity never moves backwards.
Without userId, everyone using the browser shares one profile — which is
usually wrong on a shared machine.
| Package | |
|---|---|
@mindra.dev/core |
Framework-agnostic runtime, scoring, storage |
@mindra.dev/react |
Provider, hooks, <Adaptive> |
packages/extension |
Chrome DevTools panel, declares no permissions |
npm install
npm run build # dual ESM/CJS, asserts the "use client" directive survives bundling
npm test # 57 tests, including assertions that the runtime makes no network calls
npm run typecheckMIT © Sidhanshu Monga