Diane, note for the file. Everything the box can be told.

Docs.

Two lines of HTML, and then a dozen things worth knowing when you want the box to behave slightly differently. Everything after the first section is optional.

Setting it up

  1. Sign in at admin.diane.to with Google.
  2. Add your domain. One row per hostname: www.example.com and example.com are the same site, blog.example.com is registered on its own.
  3. Paste these two lines into the page:
<script async defer src="https://diane.to/w.js"></script>
<to-diane></to-diane>

The script can sit in the head or at the end of the body — it is async defer either way, so it never holds up rendering. The element goes exactly where the box belongs.

There is no key in that snippet, no site id and no per-page setting, and that is deliberate: nothing in public HTML is a secret, so an id would be routing dressed up as security. The site comes from the Origin header, the page from its own URL.

Which site, which page

The site comes from the Origin header, which the browser attaches to every cross-origin request and which page JavaScript cannot forge. It is matched exactly against what you registered, except that one leading www. is stripped. Every other subdomain is a separate site with its own registration.

The page is looked for in three places, in order:

  1. the post-slug attribute, if you set one,
  2. the pathname of <link rel="canonical">,
  3. the current location.pathname.

Whatever comes out is normalized by the same code on both sides: query string, fragment, doubled and trailing slashes are removed, and anything over 200 characters is refused. So /post, /post/ and /post?utm_source=rss are one thread.

Which gives you both of the things you occasionally need: two elements on one page with different post-slug values keep two separate threads, and a canonical link pointing elsewhere merges syndicated copies of an article into one thread without any configuration at all.

Attributes

AttributeWhat it does
post-slugThe thread identifier, when it should not be derived from the address. Any string up to 200 characters.
langThe language of the labels, when it should not come from <html lang>.
<to-diane post-slug="/blog/twin-peaks"></to-diane>

Both are optional, and there are no others.

Sites with client-side routing

The widget notices a route change by itself: it listens for popstate, uses the Navigation API, and where the browser has none it compares location.href once a second. history.pushState is deliberately not patched — analytics scripts do it and it works, but reaching into a page it is a guest on is exactly what this widget promises not to do.

So an ordinary single-page app needs nothing. If your thread is not identified by the address, set the attribute when the route changes:

document.querySelector("to-diane").setAttribute("post-slug", nextSlug)

A reply that arrives after the reader has moved to another post is discarded, so fast navigation cannot leave the previous post's comments on screen. A half-written comment is cleared on the way, because it belonged to the post it was written under.

Appearance

The widget takes exactly one colour from your page — its text colour — and mixes everything else from it. It ignores prefers-color-scheme on purpose: an embedded box has to match the page it sits in, not the visitor's operating system, or a light-only site looks broken to everyone whose machine is set to dark. On most sites that means there is nothing to theme.

When there is, set custom properties on the element:

to-diane {
  --td-font: Georgia, serif;
  --td-radius: 4px;
  --td-gap: 24px;

  /* A solid brand button instead of the default tinted one */
  --td-button-bg: #7c2d12;
  --td-button-fg: #fff;
}
PropertyDefaultWhat it covers
--td-fontsystem sans stacktype throughout
--td-gap16pxspacing
--td-radius8pxfields and the button
--td-bubble-radius14pxcomment bubbles
--td-surface11% of your text colourcomment background
--td-border24% of your text colourfield and button borders
--td-muted62% of your text colournames, dates, secondary text
--td-button-bg13% of your text colourthe button
--td-button-bg-hover20% of your text colourthe button, hovered
--td-button-fgyour text colourthe button's label
--td-danger#e5484derror text

Two parts are exposed: ::part(root) is the whole block and ::part(powered) is the “Powered by diane.to” line under the form. It inherits the muted colour, and it can be restyled or dropped entirely:

to-diane::part(powered) {
  display: none;
}

Everything else lives inside the shadow root and cannot be reached from the page, which is what keeps the widget looking like itself on every site that embeds it. Its size on the page is yours: the element is display: block, so give it a max-width.

Language

The labels come from your page's <html lang>, the same way the colour does. An attribute on the element overrides it:

<to-diane lang="cs"></to-diane>

English and Czech ship today. An unsupported language falls back to English labels while dates stay in the page's own locale, so a German page gets German dates and English labels rather than both of them wrong. Counts go through Intl.PluralRules, because Czech needs three forms where English needs two. Missing a language? Write to hello@diane.to — it is one entry in a table.

Why a comment gets refused

No CAPTCHA. Four cheap layers instead, and each has a symptom you can recognise:

Error messages are shown to the visitor in the widget's language.

One hundred comments

A free account holds one hundred live comments per site. Past that the server refuses new ones and the box says it is full — reading keeps working, because a full box is not a reason to hide what is already written.

Deleted comments do not count towards it, so moderating makes room again. And since deletion is reversible, that is not a one-way decision. A thread displays at most 500 comments.

Moderation

It all happens in the console at admin.diane.to: one inbox with every comment newest first, a per-site view broken down by thread, a toggle for showing deleted ones, delete and restore, renaming a site, and a switch that silences a whole site — the widget then renders nothing on that domain and the visitor sees no error.

There is no approval queue. A comment appears the moment it is posted and is moderated afterwards.

What is stored

The name the visitor typed, the text, the time, and an unsalted SHA-256 hash of their IP address. The hash is only ever compared for equality for rate limiting, and no endpoint returns it. On your site the widget sets and reads no cookie at all, and loads nothing from a third party.

Comments are stored exactly as they arrived and rendered as text rather than HTML: no Markdown, no auto-linking, and a < b stays a < b. The details are on the privacy page.

Content Security Policy

If your site sends a CSP, three directives concern the widget:

script-src   https://diane.to
connect-src  https://diane.to
style-src    'unsafe-inline'

script-src for the file, connect-src for the API call, and style-src because the widget's stylesheet is a <style> element inside its shadow root, where a nonce cannot reach it. Nothing else has to be opened up: it loads no fonts, no images and nothing third-party.

When nothing shows up

The widget never shows a configuration error to your visitors. It renders nothing and writes to the browser console instead, prefixed [to-diane]. So the first step is to open the console.

What Diane does not do

No reply threads, no votes, no editing after posting, no approval queue, no notifications, no Markdown, no avatars, no reader accounts. Each one would be more code than everything else put together, and the widget is 24 kB. If one of them is the difference between using this and not, write to hello@diane.to — worth knowing, even when the answer is no.