Skip to content

Repository files navigation

Typepaper

A blank page for getting a thought out of your head. It holds text while you write and forgets it when you leave. There is no account, no cookie, no database, no analytics, and no network call of any kind.

Three controls: light/dark, copy everything, and clear the page. While you write, a word and character count sits quietly in the corner; it fades with everything else on the blank sheet.

Clearing takes two presses — the first arms the button and says so — because the second one is final: the text and its undo history go together, so a cleared page cannot be brought back by whoever sits down at the keyboard next. It is not a reload; the theme you chose stays.


Run it

npm ci
npm run dev      # http://localhost:5173
npm run build    # → dist/  (static, no server)
npm run preview

npm run build typechecks first, then builds. There is no server-side code anywhere in this project, and adding any would break the cost model below.

The typeface

The design is set in Newsreader — a screen-native face with old-style bones, which is where the "modern but classic" brief lands. It is not included here because fonts have their own licences and I would rather you fetch it yourself.

  1. Download Newsreader (OFL, free) — variable roman + variable italic, as .woff2, Latin subset.
  2. Drop them in public/fonts/ as:
    • newsreader-variable.woff2
    • newsreader-italic-variable.woff2

Until you do, the fallback stack in styles.css carries it — Iowan Old Style, Palatino, Charter — which look genuinely good on Mac and Windows respectively. Nothing breaks; you just get the local face instead.

Do not swap this for a Google Fonts <link>. That sends every visitor's IP address to Google on page load, which would quietly undo the main promise of the app. Self-hosted or system, nothing else.


Deploy

The build output is five static files. Both configs are already in the repo, so it deploys to either host with no code changes.

Cloudflare Pages — recommended

Connect the repo, set build command npm run build, output directory dist. public/_headers is copied into dist/ at build time and Cloudflare reads it from there.

This is the one that actually satisfies "zero expense at a million visitors." Per Cloudflare's own docs, requests to static assets are free and unlimited on both free and paid plans. The free tier's real limit is 500 builds/month, which is ~16 deploys a day.

Vercel

vercel.json sets framework: vite, a static output directory, and the headers. It will Just Work. But be clear-eyed about the difference:

this app @ 1M visits/month Vercel Pro includes
Bandwidth ~124 GB (~214 GB with fonts) 1 TB
Edge requests ~4M (~6M with fonts) 10M

So a million legitimate visitors fits inside what you already pay for. The problem is the shape of the downside: Vercel bills all served bandwidth past the included tier, including bot and attack traffic, and there are documented cases of a $20 Pro account landing at $700–$1,100 after a traffic spike. Spend Management is opt-in and at least one reviewer reported it did not meaningfully protect them.

If you deploy to Vercel, do both of these:

  1. Turn on Spend Management with a hard cap in the dashboard.
  2. Put the domain behind Cloudflare with the orange proxy on, so Cloudflare absorbs the traffic and caches the assets before Vercel ever sees them.

Or just use Cloudflare Pages and skip the whole question. The repo supports either; nothing in the code has to change.


What makes it secure

Not a claim — a list you can check.

Nothing is stored. No cookies, no localStorage, no sessionStorage, no IndexedDB, no service worker. Verify it yourself against the built bundle:

npm run build
grep -c "localStorage\|sessionStorage\|indexedDB\|document.cookie" dist/assets/*.js
# 0

Nothing can be sent. The bundle contains no fetch, XMLHttpRequest, WebSocket, or sendBeacon call. On top of that, the CSP sets connect-src 'none', so the browser refuses network requests from this page at the platform level. Even a compromised dependency has no egress path.

grep -c "fetch(\|XMLHttpRequest\|WebSocket\|sendBeacon" dist/assets/*.js
# 0

One window.open does survive in the bundle — so here it is before you grep for it. It's the click handler of StarterKit's Link extension, which this app disables; the handler is dead code the editor can never invoke, and it's the only navigation call in the build. verify.mjs allows exactly that one occurrence and fails the build if a second ever appears, and it bans bare location assignment outright. Navigation is the one channel a CSP can't close, so the check counts it rather than trusting it.

Paste is sanitised by the schema, not by a filter. ProseMirror parses pasted HTML into a fixed set of node and mark types and discards everything else — this is structurally stronger than blocklisting. Tested with a payload containing <script>, <iframe>, <form>, <img onerror>, onclick, inline style, and a javascript: link. Result: text preserved, every dangerous element and attribute gone, nothing executed.

Link is switched off in main.ts even though StarterKit now ships it — it's the one mark in the default set that carries a URL, and a writing pad doesn't need it.

Strict CSP with no escape hatches. default-src 'none', and no unsafe-inline anywhere, including for styles. Getting there took one real change: Tiptap injects a <style> element at runtime by default, which a strict policy correctly blocks. So injectCSS: false is set and those rules are bundled into styles.css instead. The policy is enforced by response headers and baked into the document as a <meta> tag at build time, so it survives a hosting misconfiguration.

No third-party requests at all. No CDN, no font host, no analytics. The Permissions-Policy header denies camera, mic, geolocation, sensors, USB, serial, topics, local font enumeration, clipboard reading (the app only ever writes), and synchronous XHR.

Spellcheck is off. Chrome's "enhanced spell check" and Edge's Microsoft Editor both transmit what you type to a remote server. Basic spellcheck is local, but the browser decides which one is running, not us — so the app opts out of the question. See below to change it.

Grammar extensions and page translation are opted out. Grammarly and LanguageTool run as browser extensions, outside the CSP's reach, and upload what you type to their servers. The editor carries their documented opt-out attributes, which the extensions honour. Chrome's full-page translate — which uploads the page text to Google — is likewise declined (translate="no" and a notranslate meta). These are the exfiltration paths headers can't close, fenced off by the only means available.


Check the code itself

The claims above don't ask for trust, and neither does the code.

The source is publichttps://github.com/hyperstream-pro/typepaper — and the blank sheet links to it (bottom-left, "source"; it disappears along with the promise line on your first keystroke). The link is navigation, not a request: nothing is fetched unless you click it.

The shipped JavaScript is glass-boxed. Source maps are published alongside the bundle, so DevTools → Sources shows the real annotated TypeScript — ours and every dependency's — not minified output. Maps are same-origin static files fetched only while DevTools is open; normal visits never load them.

The build is reproducible. The lockfile is committed and the output is deterministic — two builds produce byte-identical files, because the lockfile pins the whole toolchain. Reproduce on the current Node LTS (Node 22; Node 20 is end-of-life); a different Node major can shift the hash, so match versions before reporting a mismatch. Source maps let you read the code; this lets you prove the served bundle came from it:

npm ci && npm run build
shasum -a 256 dist/assets/*
# compare against what the site serves — the hashes are in the filenames

The invariants are enforced, not remembered. npm run verify checks the built output for every promise on this page — no storage APIs, no network APIs, no external origins, no unreviewed URLs in the bundle (its only URLs are inert W3C XML-namespace strings and one ProseMirror docs link, allowlisted by name in verify.mjs), strict CSP in all three places it lives — and fails the build if any of them slips. The CSP check is structural, not a substring match: a directive widened with a scheme source or a bare host fails, as does a security header deleted from either host file. npm test backs that with mutation tests — it deliberately breaks the built site each way and asserts the checker catches it — plus a serializer suite that pins what "copy everything" produces. Both run in the build.


Known limits

A web page cannot promise what the rest of your machine does. The threat model, by who controls each layer:

Layer Controlled by The edges
The page This code + the CSP None, by design — every claim above is scoped to this layer and enforced by the build. A leak here is a bug: report it.
The browser Your browser vendor Extensions with page access can read anything you can see (the editor ships the grammar-checker opt-outs — the strongest available fence). Crash/session restore is browser-owned — tested July 2026 on macOS (type, force-kill the browser process, relaunch, restore): neither Chrome nor Firefox brings the text back. Chrome never writes the editor's content to disk; Firefox's session store records the tab but captures none of its text. The restored tab reloads blank.
The host The CDN serving the files Sees each request like any website does — IP, user agent, timestamp — and never a keystroke, because nothing is ever sent.
The OS Your operating system Copy hands text to the system clipboard — clipboard history managers (Win+V, Maccy) keep it, and Universal Clipboard / Cloud Clipboard may sync it across devices. Mobile keyboards and IMEs learn from what's typed. RAM, swap, hibernation files, and accessibility APIs are OS business.

An edge found in the page's row is a bug — please report it. One found in the other rows is a contribution — it belongs in this table.


Decisions you might want to reverse

Each of these is deliberate and each is a small edit. I would rather flag them than have you find them.

The page opens in light mode, and the theme is not remembered. The toggle switches to dark for the session; on reload it's light again. Persisting it would mean localStorage, and the empty screen promises nothing survives the tab — keeping the promise literally true beats saving you one click. Following the OS preference instead is a small change in main.ts (activeTheme) plus a prefers-color-scheme block in styles.css; persisting is ~3 more lines you shouldn't write.

Spellcheck is off. src/main.tseditorProps.attributes.spellcheck. Change 'false' to 'true' if you want squiggles more than you want the guarantee.

There is a "you have unsaved work" prompt on close. A native browser dialog, no storage involved — a stray ⌘W shouldn't cost you an hour. If you want the page to close without argument, delete the beforeunload block at the bottom of main.ts.

The empty screen states the promise. "Nothing is saved. Close the tab and it's gone." It disappears on your first keystroke. A first-time visitor has no other way to know what the app is, but if you want the sheet truly bare, delete <p class="promise"> from index.html.

Clearing the page needs two presses. One press arms the button, the next one wipes. If you would rather it went in a single click, delete the arm() branch from the clear handler in main.ts — but keep the history flush underneath it, or ⌘Z turns "cleared" back into "still there".

Underline survives in the HTML flavour of a copy, not the plain-text one. Markdown has no underline syntax. Everything else round-trips.


The name

Typepaper — a typewriter's paper is the only memory the machine has, and when you pull the sheet out, nothing stays behind.

It lives at typepaper.app. The TLD is part of the design: all of .app is HSTS-preloaded, so every browser refuses to load it over anything but HTTPS — enforced at the TLD level, before this app's own headers are even consulted. The same shape as everything else here: a guarantee the platform enforces rather than a promise we make.


Structure

index.html               shell + inline SVG icons (no icon dependency)
src/main.ts              editor, theme, clipboard, clear, ephemerality guards
src/serialize.ts         ProseMirror doc → Markdown-ish plain text, hand-written
src/styles.css           palette, type, layout, bundled ProseMirror base styles
scripts/verify.mjs       enforces the invariants against the built bundle
scripts/serialize.test.mjs  serializer output tests (node --test)
scripts/verify.test.mjs  mutation tests: proves verify catches each violation
vite.config.ts           static build + CSP meta injection + brand switch
vercel.json              Vercel headers and cache policy
public/_headers          the same, for Cloudflare Pages
public/brand/            two marks: t/ (default) and lines/ — favicon,
                         social card, touch icon each. Build with VITE_BRAND=lines
                         to switch every reference; both ship either way.

Four production dependencies (43 packages transitively), all from the Tiptap monorepo. No framework, no icon library, no CSS library, no Markdown library.

Weight

~124 kB gzipped on a cold visit, ~214 kB once the two font files are in place. Repeat visits are a single revalidation of a 1 kB HTML file; the hashed assets are immutable.

If you want it smaller, the lever is StarterKit: disabling link removes it from the schema but not from the bundle, because StarterKit imports linkifyjs statically. Importing the ~14 extensions individually instead drops that weight, at the cost of a longer dependency list to maintain. I chose the maintainable side; the trade is yours to make.

Contributing

Bug fixes are welcome — especially anything in the "page" layer, where a way to persist or leak text is a real bug. New features start as an issue, because the scope (type, copy) is deliberate. CONTRIBUTING.md lays out the invariants every change has to keep and the npm run build gate that enforces them; CLAUDE.md is the long-form "why" behind each one. Security issues go to SECURITY.md, privately — not a public issue. Participation is governed by the Contributor Covenant.

License

MIT © 2026 ghostsinthemachine ltd. Read it, fork it, verify it.

About

A blank page for getting a thought out of your head. Nothing is saved, nothing is sent.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages