Home / Docs / SG Meter

A library · v1.3.1

SG Meter: a reading meter you can add to any website

One web component that charges readers a few pence a page from a balance kept in their own browser, by how far down the page they read. It never blocks a page, it lets the balance go below zero, it lets a reader decline to pay for a page and say why, and it turns what they read into personas: named, drawn as a graph, curated by the reader, and as many as they want. No server, no account, no cookies, no third-party script. It is what runs on this site: the balance in the top bar is it.

What it is, and what it is not. An honesty box, not a paywall. Everything it knows is in the reader's browser, so a reader can change their own balance, and the page a payment returns to cannot check the payment. That is a choice, made because of who reads a site like this one and what cheating would cost; it is argued in the security model and, with the numbers, in who will game the reading meter. If you need a meter that holds money, this is the wrong library.

Install

Copy two folders into your site, keeping their paths: the component and the base class it extends (it imports the base by a relative path).

assets/components/base/v1/v1.0/v1.0.0/sg-component.js
assets/components/sg-meter/v1/v1.3/v1.3.1/sg-meter.js
assets/components/sg-meter/v1/v1.3/v1.3.1/sg-meter-core.js
assets/components/sg-meter/v1/v1.3/v1.3.1/sg-meter.tpl
assets/components/sg-meter/v1/v1.3/v1.3.1/sg-meter.css

The paths are versioned and immutable, as in the estate's JavaScript guidance: a fix is a new path, so a page that works today keeps loading the same files. Read them first; they are short: the rules (no rendering), the element, its styles, the base class.

Then, on every page, load the module once, give it a config, and say what the page is:

<!-- in the top bar -->
<sg-meter view="balance"></sg-meter>

<!-- at the end of the text: charges this page, shows what it cost and the "don't charge me" choice -->
<sg-meter view="page" kind="article" date="2026-10-10" topics="news pricing" title="The page's title"></sg-meter>

<script type="application/json" id="sg-meter-config">
{ "storageKey": "mysite.meter.v1", "start": 500,
  "prices": { "article_new": 10, "article": 5, "page": 1, "free": 0 },
  "topup": { "amount": 500, "link": "https://buy.stripe.com/your-link" },
  "picks": { "feed": "articles/feed.json", "base": "articles/" } }
</script>
<script type="module" src="/assets/components/sg-meter/v1/v1.3/v1.3.1/sg-meter.js"></script>

Every key has a default, so an empty config works. Put data-root on <html> (for example data-root="../") if your pages are not all at the top level; links the component writes are prefixed with it. This site loads the module with a dynamic import() from an inline script instead of a src, because its own build forbids script tags that point at files; either works.

The eleven views

One element, one shared meter: an ES module is evaluated once per page, so however many <sg-meter> elements a page has, it is charged once.

viewWhat it showsAttributes
balanceThe balance as a small link to the account. Shown in a warmer colour below zero, with a tooltip that says nothing is blocked. Nothing else: no banner, no count of articles read.
pageOpens this page on the meter and charges it by scroll depth. At the foot of the text, a reader card (v1.3): what the page has cost, the share read, the reading list, three five-step ratings (how useful, more like this, level of detail), and last, as the exception, "Don't charge me" with a reason.kind, date (YYYY-MM-DD), topics (space separated), title, quiet (charge, show nothing)
accountBalance, pages opened, spend, what was declined, picks, spend by topic, the history with the depth read, top-ups; pause, export as JSON, start again.
topupWith topup.link set, one button to the payment page. Without, a simulated cart (packs, cart, review, receipt) that says it is simulated.
topped-upThe page a payment link returns to. Adds topup.amount once per session_id in the URL. It does not verify the payment; see the security model.
picksUnread pages for the active persona. Hidden until there is something to pick from.count (default 4)
newsroomThe reader's own front page (v1.1): their personas as chips, the active one's name and description, its picks with Keep and Not for this persona on each, what was kept in it or put out, and its graph.count (default 9)
personasRename, switch and remove personas, and add one from a preset or a blank one (v1.1).
graphA persona drawn as an SVG graph (v1.1): the persona in the middle, its topics sized by weight, the pages behind each topic, and the citations between those pages, from the feed's links_out. Each page is a link, with its title and depth read as a tooltip.persona (default the active one)
linkOne line linking to the newsroom, named for the active persona (v1.1).
shareShows the reader exactly what their reading looks like as text (a summary, then the same data as JSON), copies it to the clipboard, or sends it with their name and email, encrypted in the browser to the public key in share.contact and written to that file's append lane (v1.2). Sends nothing until the reader asks and ticks consent; never includes payment references.

This site's own: newsroom, share, personas, account, top-up, topped-up (opened without a payment reference, it adds nothing and says so), and the link on the front page.

The rules it charges by

Personas (v1.1)

A reader has one or more personas and one is active. They are a way to manage focus: the same reader reads differently as a founder and as a security lead, and wants different picks for each.

This site's five presets (founder, journalist, security lead, AI builder, board member) are PERSONAS in its build, which refuses a preset that names an article that does not exist. Why personas, and why they are what a reader would pay to keep: pay to keep your persona.

Config reference

KeyDefaultMeaning
storageKey"sg.meter.v1"The localStorage key. Use your own per site. (This site keeps sgit.meter.v1, the key its first meter used, so balances carried over.)
start500Starting credit, in pence or cents.
symbol"£"Shown before amounts.
prices10 / 5 / 3 / 2 / 2 / 1 / 0Price of a page read to the end, by kind: article_new, article, issue, note, collection, page, free. Add your own kinds.
newDays7How long an article is new.
depthtrueCharge by scroll depth. false charges the whole price on opening.
minDepth0.1The least share of a page that is charged.
topup{"amount": 500, "link": ""}The payment link and what it adds. Empty link: the simulated cart.
packs[{"id":"p5","price":500,"bonus":0}]The simulated cart's packs.
links{"account": "account/index.html", "topup": "account/top-up.html"}Where your account and top-up pages are, relative to data-root.
picks{"feed": "", "base": ""}A JSON feed of {"articles": [{slug, title, date, teaser, topics}], "topics": [{id, label}]}, and the folder an article's slug + ".html" is in. Empty feed: no picks. This site uses articles/graphs.json.

Config can also be given as window.SG_METER_CONFIG before the module loads.

Events

Dispatched on document, bubbling and composed, named <namespace>:<noun>.<verb>. Event names are API: renaming one is a major version.

Eventdetail
sg:meter.charged{path, depth, cost, balance}, each time a charge grows
sg:meter.declined{path, reason, balance}
sg:meter.toppedup{credit, source, balance}; source is simulated or link
sg:meter.reset{}
sg:meter.persona{active, what}; what is active, added, renamed, removed or curated (v1.1)
sg:meter.rated{path, field, value, cost, balance}; field is useful, more or detail (v1.3)
sg:meter.shared{how, pages}; how is copied or sent (v1.2)
sg:meter.restored{reads}, after a restore from an export (v1.1)
sg:meter.changed{balance}, on any saved change; the elements repaint on it
sg-meter:error{why}, if the component's markup or styles failed to load; the element also gets data-state="error"

Nothing listens to these on this site: there is no analytics. They are there so a site that wants to count, for example, how many readers decline and why, can do it in its own code and say so.

What is stored

One JSON value under storageKey in localStorage, and a map of this session's pages under storageKey + ".seen" in sessionStorage. The reader can export it from the account page.

{ "v": 3, "created": "2026-10-10T09:00:00.000Z",
  "balance": -12.5, "spent": 512.5, "reads": 91, "declined": 14, "paused": false,
  "log": [ { "id": "…", "at": "…", "path": "/articles/x.html", "title": "…", "kind": "article",
             "topics": ["news"], "price": 10, "depth": 0.55, "cost": 5.5, "persona": "me",
             "rating": { "useful": 4, "more": 5, "detail": 4 },
             "declined": { "reason": "clickbait", "at": "…", "refunded": 4 } } ],
  "topups":   [ { "ref": "cs_…", "at": "…", "credit": 500, "source": "link" } ],
  "sessions": [ "cs_…" ], "cart": [],
  "active": "security-mv2h4bb2",
  "personas": [ { "id": "me", "name": "", "on": [], "off": [] },
                { "id": "security-mv2h4bb2", "preset": "security", "name": "",
                  "on": ["footprint-and-blast-radius"], "off": ["a-mac-of-the-agents-own"] } ] }

The log keeps the last 500 pages. Earlier states (versions 1 and 2) are read and upgraded in place; an upgraded state gets the "me" persona and keeps everything else.

Theming

The component draws in a shadow root, so your stylesheet cannot reach in, but CSS custom properties cross the boundary and every colour reads one first: --fg, --dim, --line, --line2, --panel, --accent, --accent-dk, --warm (the colour of a negative balance), --mono, --serif, --sans. Each has a neutral fallback, so a site with none of them still gets a readable meter. The host element is yours to place and size: sg-meter { display: block }, and the root is exposed as ::part(root).

Taking real payments with a Stripe Payment Link

  1. In Stripe, create a product (for example "£5 of reading") and a Payment Link for it.
  2. In the link's After payment settings, choose not to show Stripe's confirmation page and to redirect to your site, with this URL (Stripe fills in the placeholder):
    https://your.site/account/topped-up.html?session_id={CHECKOUT_SESSION_ID}
  3. Put the link in the config as topup.link, and the credit it buys as topup.amount.
  4. Make a page at that path with <sg-meter view="topped-up"></sg-meter> on it.

Stripe's own guidance is that fulfilment should come from the checkout.session.completed webhook, not from the redirect, because a redirect can be skipped or faked. A static site has nowhere to receive a webhook, so this library credits on the redirect and says so. On a £5 payment with a standard UK card Stripe's listed fee is 1.5% + 20p, about 27.5p, or 5.5%; premium and international cards cost more. The arithmetic for this site is in going live with the reading meter.

Where its rules come from

The first version ran on pt.newsroom.sgit.ai as its wallet: says up front what it is, never blocks, one charge per page per session, storage allowed to fail. Its code follows coding.sgit.ai's JavaScript guidance: ES modules, native web components on a shared base class, markup and styles in sibling files, page data set only as text, events as API. And two carried over from the NFRs, whose rule is "generate or date every number": the balance and the spend are running totals, but the log of every charge that made them sits beside them in the same export (the last 500), so a reader can check one against the other; and "a restore that has never been performed is not a backup", so the export is plain JSON a reader can open and read, the only copy of their history there is.

Changelog

Next, not yet: rating a page, classifying it and keeping a note on it, all in the same local history; personas and a balance that follow a reader between devices, and to their agent, through an encrypted vault only they hold the key to. Security model · this site's account page.