Find the questions you never answered.
Zettel reads a conversation archive as a track you can drag, mark and annotate — three years of messages as one gesture instead of an infinite scroll.
Live demo → https://spaciousmtness.github.io/zettel/ The demo carries a synthetic archive of invented people. No real conversation is ever on a server.
Annotation is usually a mark on a record. Here it is a second sheet laid over it, in exact register, that you can lift.
Press z. The record drops back to a whisper and a translucent plane comes
up carrying its own marks. Lower it and there is nothing but your own
conversation again.
Depth is carried without a single shadow — the design system bans them — using three things instead: vellum material, registration ticks like a drafting overlay pinned by crosses, and parallax on the labels. One scalar raises the sheet and recedes the ground together. There is no second mode and no modal.
On e-ink the lift steps rather than tweening, because interpolating between two planes leaves ghost trails on the panel.
GET /api/candidates proposes where a reader might want to look, using
structure alone — no model, no network, no spend:
- unanswered — a question, then half a day or more of quiet
- rupture — the thread's longest silences, and who ended them
It answers with who spoke and how long the quiet lasted, and never a word of content:
{ "kind": "unanswered",
"reason": "they asked, then 2 weeks quiet",
"gap": 1213980,
"anchor": { "from": "…", "to": "…", "from_ts": 0, "to_ts": 0 } }That shape is the point. The route is content-blind by construction, which is exactly what makes it safe to publish. The overlay proposes; it never reads. Candidates render only when the sheet is lifted, so nothing appears unless you raise it.
In the demo archive it finds this, unprompted:
"are you happy though. like actually?" — asked in 2023, unanswered for 14 hours. The same sentence, asked back in 2026, unanswered for 3 days.
Arithmetic over timestamps and question marks found both.
- The archive is opened through exactly one
sqlite3connection,mode=ro, and is never written. - The intelligence route is content-blind by shape, so there is no configuration in which a real message reaches the internet.
- The public build is driven by a generated synthetic archive. The real application reads your own archive locally and speaks to nothing.
- Message text lives in
attributedBodytypedstream blobs, notmessage.text, on modern macOS. Question detection decodes blobs — but only at the ~120 gap boundaries the SQL already selected, so it stays cheap on a 200,000-message archive. - Dates are nanoseconds since 2001-01-01, occasionally seconds on ancient
rows. Pagination uses composite
(date, ROWID)row-value cursors, neverOFFSET, because restores and edits break the ROWID≈date correlation. - One person is many chat rows — iMessage/SMS split plus spelling variants of
the same number. Merging preserves country codes, because truncating every
number to ten digits collides unrelated
+1and+91recipients. - The static demo is driven by a
window.fetchshim that composes with the app's existing CSRF fetch shim, so 215KB of reviewed client logic needed zero changes. Message paging is reimplemented in JS against the server's exact cursor semantics.
One hue (amber). No shadows, anywhere. Every meaning-bearing distinction
survives filter: grayscale(1) — form carries it, never colour. Silence: no
interface sounds. The only living light is an ember pulse on a single 2.6s
tempo. Tokens live in forme-tokens.css; components may consume them and may
not invent their own.
Built to be read on paper-like screens, including a Daylight DC-1 e-ink
tablet, where ?calm=1 steps every transition and stops every pulse.
A manifest, dimension-verified icons, and a service worker that precaches the
whole ES-module graph but never an /api/ response — so offline degrades
honestly instead of showing stale data. Add to Home Screen on any phone.
Made at a Night Hack with Claude Code. zettel.ink