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
- Which site, which page
- Attributes
- Sites with client-side routing
- Appearance
- Language
- Why a comment gets refused
- One hundred comments
- Moderation
- What is stored
- Content Security Policy
- When nothing shows up
- What Diane does not do
Setting it up
- Sign in at admin.diane.to with Google.
- Add your domain. One row per hostname:
www.example.comandexample.comare the same site,blog.example.comis registered on its own. - 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:
- the
post-slugattribute, if you set one, - the pathname of
<link rel="canonical">, - 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
| Attribute | What it does |
|---|---|
post-slug | The thread identifier, when it should not be derived from the address. Any string up to 200 characters. |
lang | The 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;
} | Property | Default | What it covers |
|---|---|---|
--td-font | system sans stack | type throughout |
--td-gap | 16px | spacing |
--td-radius | 8px | fields and the button |
--td-bubble-radius | 14px | comment bubbles |
--td-surface | 11% of your text colour | comment background |
--td-border | 24% of your text colour | field and button borders |
--td-muted | 62% of your text colour | names, dates, secondary text |
--td-button-bg | 13% of your text colour | the button |
--td-button-bg-hover | 20% of your text colour | the button, hovered |
--td-button-fg | your text colour | the button's label |
--td-danger | #e5484d | error 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:
- A hidden trap. The form carries a field a human never sees and cannot tab into. If it comes back filled, the server answers as though the comment was saved and stores nothing — a bot that is told it failed learns how to stop failing.
- A signed one-time token. Issued when the thread loads, accepted from 3 seconds to 2 hours old. Submitting the instant the page appears gets “give it a second”; a tab left open since yesterday gets “please reload”.
- A rate limit. At most 3 comments from one IP address per 10 minutes.
- Limits. 80 characters of name, 4000 of comment, at most 3 links, 32 kB of request body.
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.
- “… is not registered” — the Origin matches no
registered domain. Check subdomains:
blog.is a different site from the bare domain,www.is not. - Nothing in the console and nothing on the page — the script never loaded. Usually a CSP or an ad blocker.
- Works in production, not on your machine — plain http
is accepted only for
localhostand127.0.0.1. Register those like any other domain and local development works. - A comment refused right after the page loads — the three-second rule on the token.
- The wrong thread in a single-page app — the address
changes in a way the widget cannot see. Set
post-slug.
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.