Reading RoomBorn Between 2 Generals Get the widget →

The widget

One script tag, two directions.

Outbound: your published research, on your own domain, so readers land with you. Inbound: a finished request handed to the reading room as a draft, with the redaction scan’s findings shown before anything is public. No dependencies, no build step, no third-party requests.

An author’s work should live on the author’s site. A reading room that only worked as a destination would be asking every author to send their readers somewhere else and hope they come back.

So the widget goes the other way: it renders published research into an author’s own page, on their own domain, under their own design. And it goes the second way too, because the same author needs a path from the request they just closed to a draft in the reading room — one that shows them what is in the documents before anything is public.

Outbound — your research on your own site

One tag, wherever the list should appear:

<script src="https://readingroom.bornbetween2generals.com/assets/widget.js"
        data-handle="yourhandle"
        data-topic="policing/use-of-force"
        data-limit="6"
        data-theme="auto" defer></script>

The widget mounts where the tag sits. To place it somewhere else — or several times on one page — put the attributes on containers instead and load the script once:

<div data-readingroom data-handle="yourhandle" data-limit="4"></div>
<div data-readingroom data-topic="spending/settlements" data-limit="8" data-theme="dark"></div>
<script src="https://readingroom.bornbetween2generals.com/assets/widget.js" defer></script>

Live, on this page

The panel below is the real widget, running, with placeholder records instead of a network call. Every card is labelled as an example because it is one — inventing sample research on the marketing page of a platform about documentary evidence would be the exact failure this thing exists to prevent.

A demo of the widget, on an imaginary author’s site

If the placeholder mode is switched off and the reading room cannot be reached, this box renders empty — not a spinner, not an error. That is the intended behaviour, and there is a way to check it below.

Every option

Set them on the script tag, on a data-readingroom container, or both — a container’s own attribute wins, and the script tag supplies the default for every container that does not set one.

AttributeDefaultWhat it does
data-handlenoneShow one author’s published research. The leading @ is optional. Letters, digits, dot, dash and underscore, up to 39 characters; anything else is dropped rather than sent.
data-topicnoneA topic id (policing) or a topic and subject (policing/use-of-force) from the shared vocabulary. Values that do not match the vocabulary’s shape are dropped.
data-limit6How many posts to render. Clamped to 1–25; the ceiling is there so a misconfigured page cannot pull a thousand rows onto somebody’s homepage.
data-themeautolight, dark, or auto. auto follows the reader’s prefers-color-scheme.
data-heading“From the reading room”The small label above the list. Set it to whatever your page calls this section.
data-originthis reading roomPoint the widget at a different instance. Must be an http: or https: URL; anything else falls back to the default.
data-examplenone1 renders the labelled placeholder cards, send renders a placeholder ingest result. Neither makes a network call. This exists for demos and for testing your own layout.

From JavaScript

ReadingRoom.render('#mount', { handle: 'yourhandle', limit: 4, theme: 'dark' });
// resolves either way — "the reading room was down" is not an error to handle,
// because the correct response to it is to render nothing.

Inbound — send to the reading room

The other direction. A tool that already holds the request — the records console — hands it over. This is a single call with a bearer token carrying the publish scope:

ReadingRoom.send({
  token: '<a publish-scoped token>',
  into:  '#result',
  record: {
    requestId: 'REQ-2026-0184',
    agency: '…', jurisdiction: '…',
    topic: 'policing', subject: 'use-of-force',
    filedOn: '2026-02-03', respondedOn: '2026-03-17',
    outcome: 'partial',
    summary: '…',
    documents: [ { name: '…', text: '…', url: '…' } ],
    canonicalUrl: '…'
  }
}).then(draft => { /* { id, status: "draft", pii: [...], editUrl } */ });

It sends publish: false, and there is no option that changes it

The widget writes that field as a literal. There is no parameter, no flag and no scope that makes this call publish something. What comes back is a draft, the redaction scan has already run over everything that was sent, and the findings are in the response. The panel below is what the widget draws with them.

A demo of the result panel — placeholder findings, not a real submission

The panel says what happened in the order that matters: it is a draft, nothing is public, here is what the scan raised, here is the link to go and deal with it. It does not congratulate anyone on a successful publish, because nothing was published. The five stages →

What it sends, and what it does not

It sends

One GET, with the options you setOutbound: author, topic, subject and limit as query parameters, to /api/feed on the reading-room origin. Nothing else. The browser adds a Referer, as it does for any cross-origin request; the reading room does not log it against a reader.

It sends

One POST, only when you call send()Inbound: the ingest body above, with an Authorization header. That happens only from an explicit call in your own code — it never fires from a page load.

Never

Cookies, in either directionEvery request is made with credentials: 'omit'. The widget sets no cookie, reads none, and writes nothing to localStorage.

Never

Anything about your readerNo identifier, no fingerprint, no page URL, no scroll or click reporting, no analytics of any kind — ours or anybody’s. There is no second request.

Never

A third-party assetNo fonts, no images, no frames, no other scripts. One file, this one, from the origin you pointed it at.

Never

Your token, anywhere but that one headersend() puts the token in one Authorization header on one request. It is not stored, not logged, and never written into the DOM. It is still a credential in a browser page — treat it as one, and mint a token scoped to publish and nothing else.

How it is built, and why that way

It renders into a shadow root
Your CSS cannot reach into the widget and the widget’s CSS cannot leak onto your page. Its stylesheet is installed through the CSSOM rather than as a <style> element, so a host page with a strict style-src gets a styled widget instead of a blocked one.
Nothing from the server is ever injected as HTML
Every value that came off the wire reaches your page through textContent. The only markup this file writes is markup it wrote itself. There is no innerHTML in it, and a title containing a script tag renders as the characters of a script tag.
Every URL is scheme-checked before it becomes a link
Anything that is not http: or https: is dropped and the card renders as plain text instead. Relative URLs resolve against the reading-room origin, never against your page — a relative path in a server response must not be able to point at your own admin.
Strings are bounded
Titles, summaries and every other field are capped before they render. A four-megabyte “title” is a layout attack on your page, not a bug in ours.
It has a six-second timeout
And an aborted, refused, timed-out, malformed or empty response all lead to the same place: the container is removed from the page.

Degrading to nothing

This is the property worth testing yourself, because it is the one that costs you if it is wrong. Point the widget at an origin that does not answer and load the page:

<div data-readingroom data-origin="http://127.0.0.1:9" data-handle="yourhandle"></div>
<script src="https://readingroom.bornbetween2generals.com/assets/widget.js" defer></script>

Port 9 refuses every connection, so this is a real network failure rather than a simulated one. On an https page an http origin is blocked as mixed content instead, which fails the same silent way.

The container is removed from the document. Not hidden, not emptied — removed, so that your own CSS cannot leave a bordered gap where it used to be. No error box, no skeleton, no “could not load”. Your page reads exactly as it would if the tag were not there.

The point

Your readers should land on your domain.

A reading room that only worked as a destination would be asking every author to send their audience somewhere else. The widget is the argument that the research belongs to whoever did it.

How this works

0:54 · narrated · captioned · served from this site, and nothing is sent anywhere when you press play. Prefer to read it? The transcript is below.

Transcript
AtSectionWhat it says
0:02OutboundOne script tag renders an author's published research on their own site, on their own domain, so their readers land with them rather than somewhere else.
0:11InboundThe other direction hands a finished request to the reading room. It sends publish false as a literal, and shows what the redaction scan found in the response.
0:20OptionsHandle, topic, limit and theme, set on the script tag or on a container. Values that do not match the vocabulary are dropped rather than sent.
0:30What it sendsOne request with those options, no cookies, nothing about your reader, no analytics, and no third-party asset. Your token goes in one header and is never stored.
0:41DegradingIf the reading room is unreachable, the container is removed from the page. No error box, no spinner. Your site must never break because our server did.