Kite is a minimal, zero-friction markdown scratchpad.
- No login, no landing page, no ads, no onboarding.
- You hit the URL and are immediately in a markdown editor.
- Notes are stored in Cloudflare KV behind a single Worker, with optional protection via Cloudflare Access / Zero Trust (outside the app).
The entire design is intentionally boring and obvious so it can run for years with minimal maintenance.
- Framework: React + Vite.
- Editor:
@uiw/react-codemirrorwith markdown language support, configured as a minimal, full-height text area (no gutter, no line numbers). - Styling: Tailwind CSS (via PostCSS), dark theme only:
bg/sidebar/border/text/muted/accentcolors defined intailwind.config.ts.
- Layout:
- Left sidebar listing notes (title + updated time).
- Right editor pane for a single active note.
- Thin status bar at the top of the editor showing save status / recovered draft / delete.
- Runtime: Cloudflare Worker (configured as the
mainentry). - Storage: Cloudflare KV:
- Binding name:
KV(configured inwrangler.jsoncandworker-configuration.d.ts).
- Binding name:
- Static assets:
- Bound as
ASSETS, served viaenv.ASSETS.fetch(request)for all non-/api/*routes. - Wrangler is configured with SPA-style not-found handling so
/n/<id>routes serveindex.html.
- Bound as
The Worker is both:
- API for notes (
/api/*). - Router for the SPA frontend (falls through to
env.ASSETS.fetchfor everything else).
- Key:
id: string– a UUID v4 (opaque, unguessable). - Value:
content: string– raw markdown text. - Metadata:
{
title: string; // Derived from first line, first 20 chars, fallback "Untitled"
updatedAt: number; // Unix timestamp (ms since epoch)
deleted?: boolean; // Soft delete flag
}There is no additional structure (no tags, folders, or relational modeling).
All API routes live under /api/* and are implemented inside the Worker.
- Purpose: List notes for the sidebar (lightweight, metadata only).
- Behavior:
- Iteratively calls
env.KV.list({ cursor })to collect all keys (handles pagination; does not silently stop at 1000). - For each key, reads metadata and builds:
{ id, title, updatedAt }.
- Filters out notes with
metadata.deleted === true. - Sorts the array by
updatedAtdescending on the server.
- Iteratively calls
- Response:
Array<{
id: string;
title: string;
updatedAt: number;
}>- Purpose: Fetch a single note’s full content.
- Behavior:
- Uses
env.KV.getWithMetadata<NoteMetadata>(id). - If value is
nullormetadata.deleted === true, returns 404.
- Uses
- Response (200):
{
id: string;
content: string;
updatedAt: number;
deleted?: boolean;
}- Input:
{
"id": "uuid",
"content": "markdown text"
}- Behavior:
- Validates that both
idandcontentare strings. - Derives
titlefromcontentserver-side:- First line only.
- Strips leading
#and whitespace. - Truncates to 20 chars.
- Fallback
"Untitled".
- Calls
env.KV.getWithMetadatafirst to preserve any existingdeletedflag. - Writes with:
- Validates that both
env.KV.put(id, content, {
metadata: {
title,
updatedAt: Date.now(),
deleted: existing.metadata?.deleted ?? false
}
});- Response:
{
"success": true,
"updatedAt": 1730000000000
}- Input:
{
"id": "uuid"
}- Behavior:
- Reads the current value and metadata via
getWithMetadata. - If value is
null, returns{ success: true }(idempotent, no error). - Otherwise, re-writes the same value with metadata
{ ...metadata, deleted: true }.
- Reads the current value and metadata via
- Response:
{
"success": true
}No actual KV keys are deleted; this is soft delete only.
- L1 (in-memory): React state –
notes,activeId,content, flags. - L2 (local resilience):
localStorage:- Per-note drafts:
draft:<id>. - Last open note:
lastActiveNoteId.
- Per-note drafts:
- L3 (persistence): Cloudflare KV.
- State:
notes: Note[]– fromGET /api/notes.activeId: string | null– currently open note id.content: string– current editor markdown content.isPersisted: boolean– has this id ever been successfully saved to KV.status: 'saving' | 'saved' | 'error' | ''– tiny status indicator.recovered: boolean– whether a local draft was used to override server content.
-
Fetch sidebar notes:
- Call
GET /api/notes, populatenotes. - If it fails, show an empty list but still allow local drafts.
- Call
-
Determine
activeIdwith this priority:- If
window.location.pathnameis/n/<uuid>, use thatuuid. - Else read
localStorage.lastActiveNoteId:- If present, set as
activeIdandhistory.replaceStateto/n/<id>.
- If present, set as
- Else:
- Generate
uuidwithuuidv4(). - Set as
activeId. history.replaceStateto/n/<id>.
- Generate
- If
-
Persist choice:
localStorage.setItem('lastActiveNoteId', id).
At this point there may be no server data for the chosen id—this is the “ghost note” case.
Whenever activeId changes:
-
Update URL:
history.replaceState(null, '', '/n/' + activeId).- Update
lastActiveNoteIdinlocalStorage.
-
Load local draft:
const localDraft = localStorage.getItem('draft:<activeId>').
-
Fetch server content:
GET /api/note/:id.
-
Conflict resolution:
-
If 404:
- If
localDraftexists → uselocalDraft,isPersisted = false. - Else →
content = '',isPersisted = false(new, empty ghost note).
- If
-
If 200:
- If
localDraftexists andlocalDraft !== api.content:- Prefer
localDraft. - Set
recovered = true. - Set
isPersisted = true(the note exists in KV; the draft is “newer” but we still treat it as an existing note id).
- Prefer
- Else:
- Use
api.content. isPersisted = true.
- Use
- If
-
If network error:
- If
localDraftexists → use it;isPersisted = false. - Else →
content = '';isPersisted = false.
- If
-
This obeys the “never trust the network over the user’s local input” rule.
On every editor change:
- Update
contentstate. - If
activeIdexists, immediately:localStorage.setItem('draft:<activeId>', content).
This ensures that:
- A tab close / crash / network drop still leaves a recoverable draft.
- Recover logic above can override KV content if drafts differ.
A custom useDebouncedEffect hook is used to debounce saves by 1000 ms after typing stops:
useDebouncedEffect(
() => {
if (!activeId) return;
const trimmed = content.trim();
// 1. Fully empty + never saved: do nothing (don't spam KV).
if (trimmed === "" && !isPersisted) {
setStatus("");
return;
}
// 2. Otherwise, save (including "clearing" real notes to empty).
setStatus("saving");
// POST /api/save ...
},
1000,
[content, activeId, isPersisted],
);Rules:
- New note + empty content + never persisted:
- No save. KV stays clean.
- Existing note + cleared content (
''):- Save the empty content. The user truly wanted to delete the text.
- Non-empty content:
- Debounced save to
/api/save. - On success:
- Update
noteslist (derive title client-side for instant UI). - Sort notes by
updatedAtdesc. - Set
isPersisted = true,status = 'saved'. - Clear
draft:<id>fromlocalStorage.
- Update
- Debounced save to
On save error, status is set to "error", but content and drafts are never discarded.
- Delete button is a small text control in the top-right status bar of the editor.
- On click:
- Call
POST /api/deletewith{ id: activeId }. - On success:
- Remove the note from
notesstate. - Remove
draft:<id>fromlocalStorage. - If
lastActiveNoteIdmatches this id, clear or overwrite it. - Pick the next active note:
- If other notes exist → the first in sorted
notes. - Otherwise → create a new ghost note id and switch to it.
- If other notes exist → the first in sorted
- Remove the note from
- Call
There is no Trash view yet; deleted notes simply stop appearing in the sidebar while still existing in KV.
Because wrangler.jsonc sets:
{
"main": "worker/index.ts",
"assets": {
"not_found_handling": "single-page-application"
}
}the Worker is responsible for both API and frontend.
To avoid breaking hard refreshes on /n/:id:
-
The Worker now:
- Handles only
/api/*in custom logic. - For everything else, calls
env.ASSETS.fetch(request)so that the built React app is served. - Unknown API endpoints return a proper
404with"API endpoint not found".
- Handles only
This ensures:
GET /n/<uuid>→index.html+ SPA boot, not a naked 404.- Browser refresh on a note page works seamlessly.
- Node.js + pnpm.
- Wrangler installed (for deploys / types):
pnpm dlx wrangler --help.
pnpm installFor local development with the current setup:
pnpm dev- Vite dev server serves the React app.
- When using Wrangler’s worker dev flow, ensure your KV binding and
ASSETSare configured as inwrangler.jsonc.
pnpm run build- Runs TypeScript build + Vite build for both the Worker bundle and the client bundle.
pnpm run deploy- Uses
wrangler deploywithmain: "worker/index.ts". - After deploy, you can gate the URL with Cloudflare Access for private use; the app itself remains unaware of any authentication.
- By default, notes are local-only and stored in
localStorage. - To enable sync across devices, open the command palette (
Ctrl/Cmd + K) and run “Enable sync”. - You’ll be prompted for a passphrase; the browser hashes this passphrase (SHA-256) and sends only the hash as
X-Bucket-Idto the Worker. - Notes for that bucket are stored in KV under keys of the form
<bucketId>:<noteId>. - On first connect, local and remote notes for the bucket are merged:
- If an ID exists locally and remotely, the newer
updatedAtwins. - Local-only notes are uploaded.
- Remote-only notes are downloaded into
localStorage.
- If an ID exists locally and remotely, the newer
-
No login, no landing, no multi-user semantics:
- If you can open the URL, you are “logged in”.
-
Single, boring storage model:
- KV key = id, value = markdown, metadata =
{ title, updatedAt, deleted }. - No attempts to model folders, tags, or complex relationships.
- KV key = id, value = markdown, metadata =
-
Soft delete over hard delete:
- Users eventually delete the wrong thing; disks are cheap.
- A single
deleted: trueflag prevents UI clutter without losing data.
-
No empty-note spam:
- Creating a new note only allocates a UUID and updates the URL.
- KV is not touched until there is actual, non-empty content OR a real note is cleared.
-
Local drafts > network:
- On conflict, local drafts override server content.
- Offline creation and save failures do not lose user text.
-
Minimal coupling, acceptable duplication:
- Title derivation exists in both client (for instant UI) and server (for canonical metadata).
- This is a pragmatic trade-off between “pure DRY” and latency/user experience.
- Not a note-taking platform with accounts, teams, or sharing.
- Not a WYSIWYG editor; it’s raw markdown text.
- Not an AI product; no summarization, tagging, or smart features.
- Not a framework playground; it uses React, CodeMirror, and Tailwind in the most straightforward way possible.
It’s a scratchpad: open browser → type → close tab → come back later → it’s still there.