A consent bar and the analytics loaders behind it: a first-party Plausible count by default; Google Analytics 4, Microsoft Clarity and PostHog each only under a purpose's yes, on the device and the account. One package holds the framework-free core, a React renderer, a plain DOM renderer, a static-site entry, server rules with a store on @framers/sql-storage-adapter, and the Playwright suite every consumer runs.
0.0.x versions are the build in progress; 0.1.0 is the first version with the contract below. In a 0.0.x version, an entry whose part has not landed exports VERSION alone.
| Entry | What it holds | Runs in |
|---|---|---|
. (the core) |
AnalyticsConfig, the purposes and the device's answer, the runtime, bootstrapSource, the device store, the reconciliation with the account, the account send (sendToAccount), the words' shape and hash rules, and the bar's controller |
Browser (ES2022), and Node for the string functions |
./react |
AnalyticsLoaders, Pageview, ConsentBar, PurposeRows, AnalyticsChoiceStatus, AnalyticsChoices, taking the product's words, purposes, paths and endpoint as props |
React 18.2 or later, or React 19 |
./dom |
The same bar, status line and choices rendered without a framework on the controller (mountConsentBar, mountChoiceStatus, mountAnalyticsChoices), the markup functions, and the page views for a page without React (watchPageviews, sendPageview) |
Browser |
./static |
For a generated site, at build time: bootstrapScript (the inline script and its CSP hash), consentBarMarkup, choiceStatusMarkup, optionsElement, and the path of the client file dist/static/consent-client.js (an IIFE bundle of ./dom and the core) |
Node, at build |
./server |
The account answer's pure rules (requestedPurposes, purposesOfRow, whoOf, deviceHashOf, judge, STALE_SENTENCE, consentAttributes) and a store on @framers/sql-storage-adapter (createConsentChoicesStore); it re-exports ./count |
Node (it imports node:crypto) |
./count |
The count's forwarding rules and forwarder (pageOf, siteOf, eventToForward, forwardCountEvent) |
Node and the Workers runtime (Fetch API only, no node: import) |
./testing |
consentSuite(options): the real-path Playwright cases every product runs |
@playwright/test, in CI |
./consent.css |
The bar's, rows', status line's and choices' stylesheet on --ac-* custom properties |
Imported once by the product |
import { bootstrapSource, scopeOf, type AnalyticsConfig } from '@framers/analytics-consent';
const config: AnalyticsConfig = {
product: 'Example',
plausible: { domain: 'example.com', script: '/js/count.js', endpoint: '/api/event', where: 'open' },
ga4: 'G-XXXXXXXXXX',
clarity: null,
posthog: { key: 'phc_xxx', host: 'https://us.i.posthog.com', cookieless: true },
tokenPaths: ['/signin'],
publicPaths: ['/', '/pricing'],
scopeBefore: null,
};
// The root layout's inline script. `words` is the product's AnalyticsWords.
const source = bootstrapSource(config, gpcHeader, signedIn, account, scopeOf(config, words));A layer set to null is off. The purposes that run follow the layers: count where plausible is set, measure where ga4 or posthog is, record where clarity is.
| Field | What it sets |
|---|---|
plausible.endpoint |
Where the tracker posts: the product's own event route, or the shared instance's event address on a site with no server route. |
plausible.where |
'everywhere', the default, loads the tracker on every page while count is on. 'open' loads it only on a public path, signed out, off the token paths, which a product that holds health data sets; window.__analytics.count says whether it loaded. |
posthog.cookieless |
true passes cookieless_mode: 'always' and request_batching: false to PostHog's init, for a product that holds health data. It needs the project's cookieless server hash mode (PostHog's config reference). |
tokenPaths |
Paths whose addresses carry a token: the recorders never load on them, and each layer counts them by path alone. |
publicPaths |
The only paths where Google Analytics, Clarity and PostHog may load, for a signed-out visitor, each under its purpose's yes. |
scopeBefore |
The scope an answer stored with no mark was given to, as a literal written once, or null for a new product. |
A privacy signal (Global Privacy Control, Do Not Track or a request with Sec-GPC: 1) turns every purpose off, the count included, in every configuration.
The bootstrap decides once per page load, from the address and the session as they stand at that load. A layer it loaded stays loaded across a client-side move, and the runtime keeps PostHog off a token path alone. So every link from a public page to a private page or a token address is a plain anchor, a full load after which the bootstrap decides again.
scopeOf(config, words) writes the running layers in one fixed form: each running purpose in the fixed order, its row's revision, and its vendors by name, sorted.
count:1:plausible;measure:1:ga4,posthog;record:1:clarity
The person's own answer stores that value under analytics_consent_scope beside the purposes. The bootstrap, and scopedPurposes for a caller holding the same values, read it before anything loads:
| What moved since the answer | What the answer means now |
|---|---|
A vendor added under a purpose, or its row's revision raised |
The yes to that purpose alone is void: nothing under it loads until the next answer. |
| A vendor removed | The yes stands. |
| A purpose the answer's scope does not list, its layer added after the answer | It is unanswered, and nothing under it loads until the next answer. |
| The answer refused a listed purpose | The refusal stands, whatever the scope. |
| The mark is absent or does not parse | The answer is read as given to scopeBefore. Under null its yes is void and its refusals stand. |
The count runs without a yes, so a voided or unlisted count stays on as unanswered, unless the answer holds it off. voidedByScope names what an answer leaves unanswered, the count included.
bootstrapSource throws on a scope that parses but lists other purposes, or other vendors under a purpose, than the configuration runs, since a layer the scope leaves out would load under any stored yes to its purpose. Pass scopeOf(config, words) for the same configuration, never a literal.
A row's revision starts at 1 and stays at or below Number.MAX_SAFE_INTEGER; scopeOf throws past it. Raise it by one in the change that alters what a person agrees to under that row: a vendor's role, what it sets, what reaches it, where or how long it runs. A spelling or style edit leaves it. The revision sits outside the words' three hashed texts, so raising it changes no hash.
The contract, whose change is breaking:
- the purposes (
count,measure,record) and their order; - the storage keys (
analytics_consent_purposes,analytics_consent,analytics_consent_written,plausible_ignore,analytics_consent_at,analytics_consent_who,analytics_consent_words,analytics_consent_carried,analytics_consent_scope); AnalyticsConfig's shape,scopeOf's format, the scope rule and the words'revision;- the account route's body and answer;
- the
<html>attributes (data-consent-who,data-consent-purposes,data-consent-choice,data-consent-at,data-consent-version,data-consent-unreadable); - the bar's region attribute
data-consent-barand the--consent-barand--consent-roommeasures; window.__analytics;- the words' shape and hash rules;
- the
ac-class names and--ac-*properties.
Pin an exact version ("@framers/analytics-consent": "0.1.3", never a caret or a range) and take a newer one at your own release. A breaking change raises the minor while the package is below 1.0.0.
A product maps these custom properties once in its own stylesheet. Each fallback is listed here from the version whose stylesheet sets it.
| Property | What it sets |
|---|---|
--ac-panel, --ac-panel-border, --ac-panel-shadow |
The bar's surface |
--ac-ink, --ac-ink-2 |
Text |
--ac-line, --ac-edge, --ac-well |
Rules, edges and the switch's well |
--ac-accent, --ac-on-accent |
The checked switch's border and fill, and what sits on it |
--ac-key-bg |
The keys' background |
--ac-radius-card, --ac-radius-pill |
Corners |
--ac-font-ui |
Type |
--ac-space-4, --ac-space-8, --ac-space-12, --ac-space-16, --ac-space-20 |
Spacing |
--ac-room-above, --ac-room-below |
The room the bar leaves above and below itself |
On SQLite, open one handle per database file and send every write of the store through it. A reader holding a read transaction (a backup, an inspection) or a second process can still make a commit fail busy, which the store answers as a failed send: nothing is stored, the device keeps its answer, and the next signed-in load reconciles it.
Install this repository's dev dependencies with npx npm@11.20.0 install: npm 10.9, which Node 22 ships, crashes resolving their peer sets. Installing the published package is not affected; CI installs the packed tarball with Node's own npm.
CI runs npx vitest run --coverage and fails below the thresholds in vitest.config.ts, which hold the measured figure less two points and only move up. Each green run on master writes the figure to the badges branch, which CI alone owns and the coverage badge reads.
Every push to master that changes what the package ships publishes its next patch to npm with provenance. RELEASING.md holds the rules.