# Cheldrop Build Plan — v3, 12 August 2026

Supersedes v2 (2 August). Two changes: (1) status — Phases 0–3 are **built, committed, and verified against the code** (see per-phase DONE markers); (2) a new Phase 3.5 inserted: decide and, if confirmed, execute the free-tier backend switch from Pages Direct Upload to **Workers static assets**, before the folder architecture is built on top of the publish path. Background: Cloudflare develops new features for Workers only and de-emphasizes Pages; no sunset date exists and existing sites are safe, but Cheldrop's acquisition funnel (free tier) currently rides the path Cloudflare steers people away from, while the paid R2+Worker path already sits right. Pre-launch is the cheapest moment to switch.

v2 preamble follows; per-phase status markers are inline.

Supersedes v1 (1 August). The change: site folders moved from "a feature" to the product's architecture, live sync ships before beta, and the app becomes a background agent. Core reframe, in Chris's words: each subfolder of `~/Documents/Cheldrops/` is a site, with its own config, settings, and theme carried in files inside the folder — and the app runs all the time, watching.

This is the feature-by-feature plan from verified Mac build to launch. Work through it phase by phase with Claude Code. Each feature is a mini-brief: why, what, where, what not to break, done-when.

## How to use this plan with Claude Code

Start each feature in a fresh Claude Code session in `~/Developer/Cheldrop/app`. Reference `docs/cheldrop-functional-spec.md` and the relevant section of this plan; have Claude Code read the spec's "What must not break" section before writing code. After each feature: test manually, run `npm run build:mac` + `npm run verify:mac` when main-process code changed, commit. One feature per session.

## The architecture shift (read once, applies everywhere)

**A site is a folder.** The managed root is `~/Documents/Cheldrops/` (decided 2 August). One subfolder per site, named by site ID (`preview.myclient.com/`). The folder carries its own configuration in `cheldrop.yaml` and optional theme in `cheldrop.css`, both living in the folder root, both excluded from publish by exact name.

**`cheldrop.yaml` is the source of truth for site configuration.** lowdb demotes to a registry: which folders Cheldrop knows about, plus runtime data and secrets that must never live in a folder (folders end up in iCloud/Dropbox sync — treat their contents as semi-public). Split:

- In `cheldrop.yaml`: site ID, backend, live on/off, password on/off, TOC/toolbar settings, theme reference, publish-ignore globs. `schema_version: 1` from day one.
- In electron-store/lowdb only: Cloudflare token (safeStorage-encrypted), `adminSecret`, password hashes (until the Worker holds them), `projectName` cache, timestamps.

Draft schema (finalize in Phase 4.1):

```yaml
# cheldrop.yaml — managed by Cheldrop; editable by humans and Claude
schema_version: 1
site: preview.myclient.com
backend: r2              # workers | r2
live: true               # auto-publish on change
password_protected: true # the hash lives in the Worker, never in this file
toc: index               # index | sidebar (docs site) | none
show_toc: true           # written in step with `toc:` so older versions still read it
show_toolbar: true
toolbar: { date: true, raw_file: true, download: true, copy_link: false }
theme: cheldrop.css      # optional; injected into generated pages
theme_user_html: false   # opt-in: also inject theme into user-supplied HTML
header: Acme Docs        # optional site chrome (title/logo/link)
footer: © 2026 Acme AS   # optional (text/links); both absent by default
ignore: ["*.tmp"]        # optional extra publish excludes
```

**Conflict rules:** UI edits write the yaml. External edits (human or Claude) are picked up by the watcher and applied; a config change can trigger a republish. Invalid yaml → macOS notification + keep last-good config, never crash, never publish a broken guess. A folder with a valid `cheldrop.yaml` can be *adopted* by any Cheldrop install (point the app at it, or drop it into the Cheldrops root) — settings travel with the folder; only secrets need re-establishing. This is site portability and disaster recovery in one move.

**`cheldrop.css` is a rendering input, not a passthrough file.** It gets injected (link tag) into everything Cheldrop generates — markdown wrappers, TOC, gallery, auth page. Injection into the user's own HTML files is opt-in via `theme_user_html`, because silently rewriting user HTML is how you break sites. This file is also the seed of the brandable-output story: a Claude SKILL that edits `cheldrop.css` + `cheldrop.yaml` is how "Claude reconfigures your site" works with zero API surface.

**Spec collision to fix explicitly:** the current renderer passes hidden files through to published output. `cheldrop.yaml` and `cheldrop.css` must be excluded by exact name at publish, and the `ignore` globs honored, in `renderer.js` — mode detection AND output.

---

## Phase 0 — Housekeeping + smoke test (no code, ~1 hour + ongoing) — MOSTLY DONE 2026-08-12

1. Copy `mcp-plan.md` and this plan from Dropbox `…/Prosjekter/Cheldrop/docs/` into `~/Developer/Cheldrop/app/docs/`, fix the stale `~/Documents/Prosjekter/…` paths in mcp-plan.md's embedded prompt, commit.
2. Delete the retired Dropbox `app/` folder and `~/Developer/Cheldrop/_to_delete/`.
3. Install the verified DMG into `/Applications`.
4. **Smoke test, not beta:** give the current build to 2–3 friendly users now — purpose is onboarding only (token creation, permission warnings, first publish on unfamiliar Cloudflare accounts). That surface doesn't change with the folder work, and strangers' accounts break things yours can't. The real beta moves to Phase 5, after the flagship exists — beta feedback on the pre-folder product would validate the wrong product.
5. Lemon Squeezy paperwork (tax form, store approval) runs in parallel throughout — it's the long pole.

**Status 12 Aug:** docs copied into repo ✓, `_to_delete/` removed ✓, DMG installed in /Applications ✓. Still open: the 2–3 person onboarding smoke test, and Lemon Squeezy paperwork (tax form + store approval — still the long pole, still blocking payouts).

---

## Phase 1 — Small fixes (2–3 days) — DONE 2026-08-02

All four shipped: 1.1 password toggle (`fc73e8e`), 1.2 settings autosave (`84b2756`), 1.3 safeStorage token encryption with idempotent plaintext migration + fallback warning (`e5a91a2`, `1443caa`), 1.4 token onboarding deep-link with pre-filled permissions (`c487395` + follow-ups). The deep-link's Pages permission was removed in Phase 3.5 (`b42aa63`); the floor is now Zone:Read, DNS:Read, Workers:Edit, R2:Edit.

### 1.1 Password toggle bug
For a password-protected site re-selected in Publish: restore the toggle to on, leave the password field empty and required, never store the password (decision 1 Aug). Where: `Publish.jsx`, the same restore block as `showToc`/`showToolbar` (~lines 76–78). Don't break the `66ec00a` per-site restore. Done when publishing unprotected-by-accident is impossible. (Note: Phase 3 changes password handling for R2; this fix still applies to the interim and to the publish-time UX.)

### 1.2 Settings autosave
Autosave on token validate, license activate/deactivate, `defaultBackend`/`defaultSession` change; "Settings updated" toast; remove the Save button. Where: `Settings.jsx` via existing `save-settings` IPC. Don't fire a save mid-way through the validate → zones → permissions chain.

### 1.3 Encrypt the API token with safeStorage
Encrypt `cfToken` via Electron `safeStorage` in the `get-settings`/`save-settings` handlers; idempotent migration from plaintext on first run; fallback with warning if encryption unavailable. Renderer sees no change. Matters more in v2: a background agent holds this token 24/7.

### 1.4 Token onboarding deep-link
Quick Start button opening Cloudflare's dashboard token page pre-filled (name + exact permission set) via the undocumented URL format (parameter encoding per cfdata.lol's generator); guided manual flow remains as visible fallback; `cf-check-permissions` verifies after paste. Floor permissions: Pages edit, Workers edit, R2 edit, DNS edit (the Pages CNAME upsert is mandatory), zone-scoped. *(Superseded by Phase 3.5: Workers edit, R2 edit, DNS read, zone read.)*

---

## Phase 2 — Auto-update (1–2 days + testing) — DONE 2026-08-02

Shipped (`4e37008`); full update loop verified end to end with the 1.0.1 release (`0ea1e64`). `main/updater.js` exists; electron-updater in dependencies.

`electron-updater` + GitHub Releases. Check on launch (non-blocking) + menu item; quiet download; install on quit. Add the mac `zip` target (electron-updater requires it) to the existing sign/notarize pipeline; `latest-mac.yml` in the release. If the repo stays private, publish releases from a public releases-only repo or generic HTTPS feed — the updater needs unauthenticated access.

**Elevated in v2:** this app becomes a permanently-running background agent on other people's machines (Phase 4.3). An always-on process with no update channel is not shippable. Test the full loop with a dummy 1.0.1 before trusting it — auto-update is the one feature you can't fix remotely if it ships broken.

Don't break: `validateStoredLicense()` non-blocking at launch; updater must never block window creation.

---

## Phase 3 — R2: real password protection + purge fix (3–4 days) — DONE 2026-08-03

Shipped (`2d6a040`). Verified in code: `main/r2-worker.js` implements PBKDF2-HMAC-SHA256 password hash + HMAC-signed session cookie (server-side auth, no request reaches an object without a valid session); `main/cloudflare.js` `purgeR2Bucket()` purges via the R2 API instead of the live domain (DNS race fixed) in both re-publish and delete paths. Remaining from "done when": the curl test matrix belongs to the Phase 9 test pass.

---

## Phase 3.5 — Free-tier backend: Pages → Workers static assets — DONE 2026-08-13

Decision confirmed by Chris and shipped the same day (`b42aa63`). Cloudflare builds new features for Workers only; Pages is de-emphasized with no sunset date. The free tier — the acquisition funnel — was the last thing publishing via the Pages Direct Upload API; the paid path (R2+Worker) already sat on Workers. Done before Phase 4 deliberately: folders and live sync build straight on the publish path, so this was the last cheap moment.

**What landed:**

- `main/cloudflare.js`: the Pages flow is gone. `uploadWorkerAssets` registers a manifest of `SHA-256(base64+ext)` content hashes at `/workers/scripts/:name/assets-upload-session`, uploads only the buckets Cloudflare asks for, and redeems the completion token in `deployAssetsWorker`. An empty bucket list is the unchanged-files case — a re-publish of an untouched folder uploads nothing.
- **Assets-only Worker** (no `main_module`): every request is served from the asset store, where requests are free and unlimited, rather than spending the 100k/day Worker request budget. `html_handling: 'auto-trailing-slash'` preserves the URL shapes Pages produced, so published links keep working.
- Custom domain via `addWorkerDomain` — the R2 path's existing code, reused as planned.
- **The permission floor did shrink**, as hoped: Zone:Read, DNS:**Read**, Workers Scripts:Edit, R2:Edit. Pages:Edit is gone and DNS:Edit fell away entirely — a Worker custom domain makes Cloudflare create the record itself (verified: a proxied `AAAA` to `100::`). DNS:Read is kept only for the "this hostname is already taken" warning, which matters more now: Cloudflare refuses a custom domain on a hostname that already has a CNAME. Deep-link (1.4) and `cf-check-permissions` both updated.
- Backend id is now `workers`. `pages` survives only on site entries written before this change and normalises on the next publish — which also retires that site's Pages project and hand-made CNAME first, so "just re-publish" actually works. Deleting a legacy entry still takes the Pages delete path.
- Untouched, as required: the `chel-{subdomain}-{domain-with-dashes}` derivation, the free-tier gates (1 site, branding footer, no password), and the whole R2 path.

**Verified:** end to end on a throwaway site — TOC index at `/`, markdown wrapper, raw `.md` served as `text/markdown`, branding footer present; re-publish took the unchanged-files path; delete removed Worker, domain and DNS record. `npm run build:mac` + `npm run verify:mac` all green.

### 3.1 Server-side auth in the R2 Worker — now a hard prerequisite for live vaults

**Why (v2 reason):** live sync publishes unattended. The never-store-the-password decision means nobody is present to re-enter it at sync time — so on the client-side-guard model, a protected live site is *impossible*. Server-side auth dissolves the contradiction: the Worker keeps a salted hash (PBKDF2 via Web Crypto) and HMAC session secret as Worker secrets; content republishes never touch auth. Plus the original reason: current client-side protection leaves every file publicly fetchable by direct URL, which is not a thing you can sell as "password protection."

**What:** Worker checks every request for a valid HMAC-signed HttpOnly session cookie; otherwise serves the login page; login POST verifies against the hash and sets the cookie with the configured session length. Password set/changed in the app (or at protect-time) provisions the Worker secret — an allowed exception to the re-publish-skips-Worker-deploy rule; keep the skip for all other republishes. Stop injecting the client-side guard on R2 sites. `/__cheldrop_admin__/purge` stays exempt (adminSecret header).

**DECISION — resolved 2 August:** password protection is **R2-only**. UI copy: "Password protection requires the R2 backend." Remove the client-side guard path entirely (Pages sites are simply public); selecting password protection switches/requires backend `r2`, which is Business Class — the license gate does the upsell work.

**Done when:** curl of any file URL on a protected R2 site returns login/401, never content; sessions expire per config; purge/delete flows work; a Live protected site republishes with no password prompt.

### 3.2 R2 purge via API
Replace the live-domain purge with R2 API list + delete (paginated, batched) in the re-publish path. Fixes the DNS-propagation race — which live sync would otherwise hit constantly on fresh sites. Keep the Worker purge endpoint for delete-site (or migrate it too).

---

## Phase 4 — The flagship: folder architecture, live sync, background agent (6–9 days)

Build in this order — each step is testable alone.

### 4.1 Site folders + cheldrop.yaml (the data-model inversion) — DONE 2026-08-13

**What landed:**

- **`main/sitefolder.js`** — new module owning the whole surface: the `~/Documents/Cheldrops/` root (created lazily), `cheldrop.yaml` read/write via `js-yaml`, config ⇄ app-settings mapping, theme reading, `ignore` globs, folder creation, materialization and adoption.
- **The merge rule:** settings the caller supplies win; settings it leaves undefined come from the file; unknown keys round-trip untouched. That is what makes hand-editing work *and* what will let an unattended publish (4.2) carry no UI state at all.
- **Invalid yaml fails the publish** with the parse error rather than falling back to defaults — a bad guess would silently change what a client sees. A `schema_version` higher than the app knows is refused the same way.
- **Renderer** filters reserved names and `ignore` globs once on entry, so mode detection, wrappers, TOC and `fileMap` never see them. The theme publishes as `cheldrop-theme.css` (the source name is never published) and is linked into every generated page, last, so it wins. User HTML stays byte-for-byte unless `theme_user_html: true`.
- **UI:** folder mode now uses a native picker — "Create site folder in Cheldrops", "Choose a folder…", "Adopt a site folder…". Selecting a folder site re-reads its yaml from disk; toggling TOC/toolbar/protection writes back, debounced. A files-mode publish offers one-click "Save as site folder". Sites gained a folder badge, path, Reveal, Relink for a moved folder, an adopt banner for unclaimed folders in the root, and keep-or-trash on delete (keep is the default; trashing uses `shell.trashItem`).
- **License gates moved into `ipc.js`** — free tier refuses `backend: r2` and refuses a first publish of a second site. Necessary because a backend can now arrive from a hand-edited yaml, and adoption creates entries without publishing; both walked past the UI-only gate.

**Deliberate call, worth knowing:** the plan says the theme is injected as a link tag but not what URL it points at. Publishing it under the source name `cheldrop.css` would contradict "excluded by exact name", so it publishes as `theme.css` (Chris's call, 13 Aug) and is linked absolutely (`/theme.css`), which also works at any subdirectory depth. A user file already named `theme.css` pushes the generated one to `theme-1.css`.

**Fixed in the same session** (found while wiring the render options, fixed on Chris's go-ahead): `licenseStatus` was never passed into `renderFiles`, and seven wrapper/TOC call sites dropped `isBusinessClass` — so Business Class sites carried the free-tier "Published with Cheldrop" footer on every page.

**Still open from this section:** `live` is read, preserved and cached on the site entry, but nothing watches yet — that is 4.2 by design.



**What:** Create `~/Documents/Cheldrops/` lazily. New folder-mode sites are created here by default; a drag-drop (Files mode) publish offers one-click "Save as site folder" that materializes the published set into `Cheldrops/<site-id>/`, writes a `cheldrop.yaml` from current settings, and converts the site to folder mode. On every publish of a folder site, read `cheldrop.yaml` as the config source (lowdb entry becomes cache + secrets). UI settings changes write back to the yaml. "Adopt folder" flow: point Cheldrop at any folder containing a valid `cheldrop.yaml` (or drop it into Cheldrops root) → site appears in the app; prompt to (re)provision anything requiring secrets. Add "Reveal in Finder" per site; missing-folder → clear relink dialog. Deleting a site asks keep-or-trash the folder (default keep).

**Renderer changes:** exclude `cheldrop.yaml`/`cheldrop.css` by exact name from mode detection AND output; honor `ignore` globs; inject `cheldrop.css` into generated pages (and into user HTML only when `theme_user_html: true`).

**Where:** `main/ipc.js` (folder create/materialize/adopt, yaml read/write — add `js-yaml`), `main/renderer.js` (exclusions + theme injection), `Publish.jsx`/`Sites.jsx` (UI). Site entry keeps `linkedFolder`/`publishMode` — no schema break.

**Don't break:** existing sites with `linkedFolder` elsewhere keep working, no forced migration; `read-linked-folder` recursion; `publishedAt` preservation; the resource-naming derivation.

**Done when:** a site folder moved to a second machine and adopted publishes identically (minus secrets re-link); editing `cheldrop.yaml` by hand changes app-visible settings; yaml/css never appear on the published site.

### 4.2 Live sync — DONE 2026-08-13

**What landed:**

- **`main/livesync.js`** — chokidar watchers, one per live site. Debounce 5 s after the last change, hard rate cap of one publish per 60 s per site, and changes arriving mid-publish are queued so the *latest* state publishes once rather than each save publishing in turn.
- **The publish path is the publish path.** The watcher calls the same `performPublish()` the Publish button calls — extracted out of the `publish` IPC handler for exactly this — and passes **no UI state at all**: every setting is undefined, so the 4.1 merge rule takes all of them from `cheldrop.yaml`. Rendering, TOC, theme, branding and auth are therefore identical by construction, not by duplication.
- **A protected site republishes unattended**, as 3.1 promised: no password is passed, the Worker keeps the auth record it already holds, and a content-only publish never re-deploys the Worker.
- **`cheldrop.yaml` is watched too, and applied immediately** — not on the next allowed publish. Turning `live: false` in the file stops the watcher within seconds, invalid yaml surfaces as an error status and publishes nothing, and changed `ignore` globs take effect before the next run. This is what makes the Claude SKILL work end to end.
- **Manual publish pauses every watcher** for its duration and then resets that site's rate cap, so the two paths never race each other's full replacement.
- **Failures back off** 30 s → 1 m → 2 m → 5 m → 10 m and retry themselves; a success resets the ladder. Network loss recovers on its own.
- **Cloud-sync noise is ignored**: dotfiles (which covers the iCloud `.name.icloud` placeholder — the real file arrives as an ordinary add when it materializes), `~$…`, and `.tmp/.temp/.part/.partial/.crdownload/.download/.swp` suffixes, plus the site's own `ignore` globs.
- **An empty folder publishes nothing.** Publish is full replacement and an empty folder is nearly always a move in progress; wiping a client's live site unattended is not Cheldrop's call to make. The status says so instead.
- **UI:** a Live toggle and status line per site in Sites, and in Publish's folder section. States are Live / Syncing / error-with-reason, pushed from the main process over a new `live-status` event.

**Deliberate calls, worth knowing:** (1) Live requires the site to have been published once — the watcher republishes, it does not provision, and an adopted folder has no Cloudflare resources yet. The toggle says so. *(Amended 16 August, plan C2: the toggle is no longer disabled before that first publish — Live is a setting of the site, so it is set with the others at creation and written to the yaml; it simply starts working when the site publishes.)* (2) `.tmp`-style files are ignored as *triggers* but are still published if they sit in the folder, because a manual publish would publish them too; the folder's `ignore` globs are the way to exclude them from both. (3) Watchers run while the app runs — closing the window still ends them until the menu bar agent lands in 4.3.

**Verified:** headless Electron smoke test over a real folder — debounce holds, the rate cap holds, two saves inside the cap collapse into one publish carrying the latest state, `cheldrop.yaml` never enters the publish set, invalid yaml errors without publishing, and `live: false` stops the watcher. `npm run build:mac` + `npm run verify:mac` green.

**Still open:** the several-day soak with sleep/wake and real network drops, and the "window closed, agent keeps syncing" half of the done-when — that is 4.3.

**What:** Per-site `live: true` (folder sites only). **Default on, superseding the "default off" this brief was written with — decided 15 August 2026, shipped with polish plan C1 + C2 on 16 August.** A new site folder is created with `live: true`, so the normal case is folder + Live with no configuration; a folder whose path is inside a cloud-synced tree (iCloud, Dropbox, Google Drive, OneDrive) is created with `live: false`, because that is where the two-machines-publishing-over-each-other risk lives. The rule is one function, `sitefolder.defaultLiveFor()`, and it decides the default only — it never overrides what a `cheldrop.yaml` already says. Live still starts working only after the site's first publish: the watcher republishes, it does not provision. Main process watches live folders (chokidar): debounce ~5 s after last change, hard rate cap ~1 publish/60 s/site, queue latest state. Reuse the existing `publish` path end-to-end so rendering, TOC, auth, theme all apply identically. Watcher also watches `cheldrop.yaml` itself — config edits apply live (this is what makes the Claude SKILL work end-to-end). Pause watcher during any manual publish. Status in Sites list (Live / syncing / error).

**Watch out:** publish is full-replacement — debounce and rate cap are mandatory, and check current Cloudflare limits (Workers static assets: 25 MB/file, 20,000 files). Unchanged files are not re-uploaded (Phase 3.5), which makes frequent small edits cheap, but each publish still deploys a new Worker version. Folders inside iCloud/Dropbox: ignore dotfiles/`.tmp`/partial-download patterns; treat dataless iCloud placeholders as changed-when-materialized; document "enable Live on one machine per site" (two machines + shared folder + Live on both = publish ping-pong).

**DECISION — resolved 2 August:** Live is available on the free tier's single site. The gates stay: site count (1 free), R2/vaults (Business Class), branding footer (free). Live is the wow that sells the upgrade.

**Done when:** save a file in any editor → change is live on the domain in ~10 s with the app window closed (see 4.3); network loss mid-sync recovers with backoff; toggling Live off stops the watcher instantly.

### 4.3 Background agent (menu bar) — DONE 2026-08-14

**What landed:**

- **`main/tray.js`** — a menu bar presence that exists from launch: glyph (idle / syncing / error), per-site rows reading `✓ site.id — last publish 4 minutes ago` (or the error, or what it is publishing right now), pause-all, Open Cheldrop, Launch at login, and a Quit that stops the watchers first. Clicking a site row opens it.
- **The tray listens to the watchers directly** through a new `livesync.onStatus()`, not to the IPC event windows use — it is the only surface that reports on live sync when there is no renderer at all.
- **Failure notifications are per-episode.** One macOS notification the first time a site's publish fails; the retry ladder underneath it is silent; a successful publish closes the episode so the next failure speaks again. Success never notifies.
- **Pause-all** stops publishing without stopping watching: changes accumulate and publish once on resume. Deliberately not persisted — a pause that survived a restart is a site that quietly stopped syncing.
- **Window lifecycle:** closing the window leaves the agent running (already the macOS default here, now the point of it); the Dock icon is an opt-in hide (`Menu bar only`); a single-instance lock focuses the running copy instead of starting a second set of watchers on the same folders.
- **Launch at login** is offered once, by dialog, the first time any site goes Live, and lives permanently in Settings → Background agent alongside the Dock-icon and pause toggles. The OS is the source of truth for it; only "have we asked" is stored. A login-item launch comes up **with no window** — tray, watchers and license check, nothing else — which is the done-when's first line.
- **Tray icons** are generated template images (`build/tray/*Template.png`, 16 + 32 px, black + alpha) shipped as an extra resource: a hexagon, with a centre dot while syncing and an exclamation on error. Phase 8 redraws them as part of the reskin.

**Verified:** headless Electron test — 15 checks covering icon loading, tray creation with no window, menu building against the real registry, the worst-state glyph rule, one-notification-per-episode with silent recovery and a fresh episode after it, pause/resume, the ask-once login prompt, and the relative-time strings. `npm run build:mac` + `npm run verify:mac` green, tray resources present in the bundle.

**Still open:** the login/logout and multi-day soak runs belong to the Phase 9 test pass, and the Windows differences (tray behaviour, login items, `window-all-closed`) stay a Phase 9 item — on Windows the app still quits with its last window.

**Why:** "An app that runs all the time, watching folders" is a different app posture. Live sync while the window happens to be open is a demo; live sync as an ambient guarantee is the product.

**What:** Menu bar (tray) presence: status glyph (idle / syncing / error), menu with per-site status + last publish time, pause-all, open dashboard, quit. Closing the main window keeps the agent running (standard Mac behavior; Dock icon optional setting). "Launch at login" toggle (`app.setLoginItemSettings`), prompted once when the user enables their first Live site. macOS notifications for unattended failures — an agent that fails silently is worse than no agent; batch/throttle them (one per site per error episode, not one per retry). Real quit from the tray menu stops all watchers cleanly.

**Where:** `main/index.js` (window lifecycle, tray, login item), new tray module; notifications via Electron `Notification`.

**Don't break:** theme init and license validation on window re-open; single-instance lock (second launch focuses the running instance instead of spawning a twin — add if absent).

**Done when:** log in → agent is in the menu bar with no window; edit a file in a Live folder → published, notification-silent on success; unplug network → one clear failure notification, auto-recovery on reconnect; quit actually quits.

---

## Phase 5 — Beta (the real one)

Now the beta tests the actual product: Cheldrops folder, Live sites, background agent, R2 vaults. 5–10 real freelancers, free tier + a comped Business Class each (you want R2/vault feedback more than $49). Structured ask: set up one real client-facing site, run it Live for a week, report every moment of confusion. Auto-update is in place, so shipping them fixes is cheap. Triage: ship-blockers fixed now, everything else to backlog.

---

## Phase 6 — Cloudflare sync — DONE 2026-08-14 (built out of order, before Phase 5)

**What landed:**

- **`main/reconcile.js`** — the comparison, and nothing but the comparison: pure functions, no network, no Electron, so the rules are testable directly. `ipc.js` does the fetching and hands it three sets of names.
- **Three read-only listers in `cloudflare.js`** (`listWorkerScripts`, `listR2Buckets`, `listPagesProjects`). Nothing in this phase writes to Cloudflare, ever.
- **"I could not look" is never "it is not there."** Each lister reports `forbidden` separately from `ok`, and a resource kind the token cannot read produces no finding at all. A token without R2 permission must not turn a healthy R2 site into a scary red badge — that is how someone deletes a working site.
- **Site states:** `ok`, `partial` (half-deleted — e.g. the Worker is gone but the bucket remains), `missing`, `unpublished` (adopted or imported, nothing expected yet), `unknown` (unanswerable). Only `missing` and `partial` are surfaced, with a plain sentence and two actions: **Re-publish…** (opens the site in Publish) and **Forget this site** (drops the local entry only — no Cloudflare calls, no touching the folder).
- **Orphans** — `chel-`-named resources no entry claims — are offered for import as registry-only entries (`importedAt` set, `publishedAt` null, so the free-tier gate and the Live gate keep their meaning). A Worker and its bucket group into one offer. Names that do not resolve to a domain in the account are listed but not importable, because Cheldrop cannot say what URL they serve.
- **Reverse-deriving the site ID** from a resource name is ambiguous on its own (`chel-my-project-example-com` splits several ways) — resolved against the account's actual zone list, longest domain first, and only accepted if it round-trips through `resourceNameFor`.
- **One definition of the join key.** `resourceNameFor()` now lives in `cloudflare.js` and is used by the publish path, the adopt path and reconciliation; the formula is unchanged and the two hand-rolled copies are gone.
- **UI:** a "Check Cloudflare" button on Sites with the last check time and the sentence "Cheldrop only reads — nothing is deleted there", broken-site notes inline on the row, an import banner for orphans, and warnings when the token cannot answer. Runs automatically on page load, cached five minutes in the main process; the cache clears on publish, delete, adopt, import and forget.

**Verified:** 25 pure-logic checks (name→site-ID splitting including dashed domains and longest-match zones, ok/partial/missing/unpublished/unknown, orphan grouping and importability, legacy Pages sites) plus a **live read-only run against a real Cloudflare account**: 5 Workers listed, R2 and Pages correctly reported as unreadable rather than empty, 2 orphaned `chel-` Workers found — which is exactly the drift this phase exists to surface. `npm run build:mac` + `npm run verify:mac` green.

**Out of scope, deliberately:** nothing here deletes a remote resource. Cleaning up an orphan is import-then-delete through the normal delete flow, where the confirmation and the consequences are already spelled out.

**Original brief follows.**

## Phase 6 — Cloudflare sync (1–2 days, scope reduced in v2)

Folder adoption (4.1) now covers local recovery and portability, so this shrinks to *remote reconciliation*: list `chel-`-named Workers (and any leftover Pages projects from before Phase 3.5), flag local sites whose remote resources vanished (broken badge + repair/remove), list remote `chel-` resources with no local site (offer import as registry-only entries). Read-only against Cloudflare; never deletes remote resources. Join key is the naming derivation — do not vary it. Matters before MCP so `list_sites` tells the truth.

---

## Phase 7 — MCP server + the Cheldrop SKILL — DONE 2026-08-14

**What landed:**

- **`main/mcp-server.js`** — Streamable HTTP JSON-RPC on `127.0.0.1:3847/mcp`, started with the app, authenticated by a shared secret (`x-cheldrop-mcp-secret`, or `Authorization: Bearer`) generated on first use. Bound to loopback only, `Origin` checked before auth so a web page cannot rebind its way in, 1 MB body cap, and a busy port is reported in Settings instead of thrown at startup.
- **Seven tools, exactly the ones this plan lists:** `get_settings` (with `app_version` and the Cheldrops root), `list_sites` (folder-aware: `folder_path`, `live`, `live_status`), `create_site`, `adopt_folder`, `publish_now`, `set_password`, `delete_site`. `publish_files`/`publish_folder` from mcp-plan.md were deliberately not built: with Live folders, writing files *is* the publish interface.
- **Nothing is reimplemented.** Every tool calls the same function the UI calls. `ipc.js`'s handler bodies were lifted into named functions (`manualPublish`, `adoptFolderAt`, `deleteSiteById`, `setSiteLive`) and the `ipcMain.handle` wrappers now call those — so an MCP publish and a button press cannot drift apart, and live sync's pause/rate-cap interplay applies to both.
- **`create_site` is the whole "set up a vault for client X" move:** folder in `~/Documents/Cheldrops/`, starter `cheldrop.yaml`, a placeholder `index.md` (an empty folder would publish a site that serves nothing), provision + first publish, then Live on — in that order, because Live requires a published site. A failed publish keeps the folder and says so.
- **Secrets never cross the boundary.** No API token, no `adminSecret`, no password in any response. `set_password` takes a password, derives the Worker's auth record, and reports only that it applied — plus the sentence that it cannot be recovered, only replaced.
- **The guards are the product here.** `delete_site` describes the damage on the first call and only deletes on a second with `confirmed: true`; publishing an empty folder or one with invalid yaml is refused rather than wiping a live site; free-tier limits (one site, no R2, no password protection) answer with the plan's `upgrade` nudge; a domain outside the account is refused with the list of domains that would work.
- **Settings → MCP server**: on/off, running state, the secret (masked, reveal, copy), a copy-ready `claude_desktop_config.json` snippet, and **New secret**, which revokes every configured client immediately.
- **The SKILL** (`docs/cheldrop-skill/SKILL.md`) teaches the split first — folder for content and config, tools for what needs the token — then the folder convention, the yaml schema key by key, the css injection model, the rules (never a secret in the yaml, never edit `site:`, invalid yaml publishes nothing, respect `ignore`), the seven tools, and the four working patterns. Docs page **Working with Claude** ships in-app with it.

**Verified:** 81 offline checks against the real modules with Electron stubbed — tool surface and schemas, JSON-RPC (initialize/version negotiation/notifications/unknown method and tool/malformed request), the origin guard, every token-needing tool before setup, domain and subdomain validation, the free-tier gates and their nudges, files-mode and moved-folder refusals, the empty-folder and invalid-yaml stops, password refusals on free/Workers/files-mode with the password never echoed, the delete confirmation gate leaving both registry and folder untouched, adoption, and the HTTP surface (no/wrong/short secret, Bearer, foreign origin, notification 202, parse error, GET 405, unknown path 404, revocation, enable/disable). `npm run build:mac` + `npm run verify:mac` green.

**Still open:** the clean-Claude-Desktop end-to-end (create a real vault, curl it without a cookie for the 401) is a live run against a real domain — it belongs to the Phase 9 test pass, alongside the landing-page "Works with Claude" section, which is the one thing mcp-plan.md defers until after the feature works.

**Original brief follows.**

## Phase 7 — MCP server + the Cheldrop SKILL (4–5 days)

Two integration surfaces now, and they split cleanly:

**Files are the content/config interface.** With Live folders, Claude updates a site by writing files into it and reconfigures it by editing `cheldrop.yaml`/`cheldrop.css` — no API needed. Ship a **Cheldrop SKILL** (SKILL.md, distributed from cheldrop.com/docs and bundled where useful) that teaches Claude: the Cheldrops folder convention, the yaml schema, the css injection model, and the rules (never write secrets to yaml, respect `ignore`, one Live machine per site). This is a docs artifact, not app code — cheap to build, huge leverage.

**The MCP is the provisioning/action interface** — the things files can't do because they need the token: `get_settings`, `list_sites` (now folder-aware: path, live status), `create_site` (provision subdomain/backend + create the folder + write starter yaml — the end-to-end "set up a vault for client X" move), `adopt_folder`, `publish_now` (force immediate publish, bypass debounce), `delete_site` (double-confirm, per mcp-plan.md), `set_password` (provisions the Worker hash per 3.1; never stored, never echoed). Build on `docs/mcp-plan.md`'s transport/auth/guardrails (localhost:3847, shared secret, confirm-overwrite, upgrade nudges) — the tool list above supersedes its original tool list where they differ. `get_settings` gains `app_version`.

**Done when:** from Claude Desktop: "set up a password-protected vault at vault.client.com for the files I'm about to give you" → MCP creates folder + provisions site + sets password → Claude writes files into the folder → Live publishes them → link works, curl without cookie gets 401. Docs page + landing section ship with the feature (per mcp-plan.md's "when this ships" rules).

---

## Phase 8 — Reskin (2–3 days, floats — start the design exploration now)

Claude Design brief: keep the layout; replace the espresso/terracotta palette ("too brown") with something easier to like on first contact but still with personality; dark + light; WCAG AA body text. Implementation is a CSS-custom-property token swap plus component polish. Propagate in one pass: app tokens → landing → DMG background → icon accent → brand skill doc — a half-reskinned brand reads as abandoned. New in v2: the tray icon needs designing (template image, dark/light menu bar, idle/syncing/error states) — **partly done 2026-08-14, out of order, at Chris's request**: the hexagon was replaced by the brand mark (disc + four-point flare, knocked out) with the state language unchanged, generated by `build/tray/make-tray-icons.py` from the app icon's geometry. It is committed source, so the reskin re-runs it with new proportions rather than redrawing by hand. **The palette swap itself landed 14 August as Stillwater+** (`218509a`) across app tokens, the published output (wrapper, TOC, 404, gallery), the app icon and the tray glyph; default `cheldrop.css` starter themes are part of the design language now — **their mechanism shipped 16 August with polish plan C3**: three themes live as files in `templates/themes/` (Pond, Ember, Ultraviolet, plus "Cheldrop default" = no file), picked in the publish form and copied into the folder as `cheldrop.css`. Adding or replacing a theme is editing a file in that folder; this phase revisits the CSS, not the plumbing. Can land any time after Phase 1; before Phase 5 beta is ideal so screenshots show the final face.

**Propagation status (17 August).** The install window is done: `build/brand/dmg-background.svg` is the source, `bash build/make-dmg-background.sh` renders `build/dmg-background.png` + `@2x` (electron-builder pairs them into a retina tiff with `tiffutil` at package time), and `package.json`'s `dmg` block pins the layout the artwork is drawn for — app at (140,180), Applications at (400,180), 128px icons in a 540×380 window. The drop lands on the app icon and the wave carries it right; the ripple is `--accent` on `--bg`, the app's own tokens. It carries no text on purpose: Finder draws both icon labels anyway, and text would bind the file to a font that is not installed on a clean machine — the woff2 in `node_modules` cannot be rendered by `rsvg-convert`, so a lettered version could not be regenerated without a new build dependency. **Still open in the propagation pass: the cheldrop.com landing page and the brand skill doc.** Both live outside this repo; until they follow, the brand is half-reskinned in exactly the way the paragraph above warns about.

---

## Phase 9 — Pre-release tests, launch, Windows

1. **Test pass:** the original three (R2 re-publish via API purge, R2 per-file links, permission warnings) plus: auto-update loop; Worker auth curl-tests on every file type; live sync soak (a Live site with real edits over several days, sleep/wake, network drops); background agent across login/logout; yaml hand-editing including invalid yaml; adoption on a second Mac; MCP + SKILL end-to-end from clean Claude Desktop.
2. **Launch when:** LS checkout live + tax cleared, auto-update proven, R2 auth shipped, folders/Live/agent stable through beta, MCP listed. Launch story leads with the folder: "a folder on your Mac that is your website — on your domain, in your Cloudflare account, operable by Claude." Surfaces: cheldrop.com, MCP directories, Product Hunt, your network. Launch execution gets its own plan.
3. **Windows after Mac launch.** Note v2 raises Windows effort: tray/background/notification behavior and login items differ meaningfully on Windows. Still a distribution multiplier, not a validation multiplier.
4. **Post-launch backlog:** starter theme gallery for `cheldrop.css`, brandable TOC/toolbar building on the theme system, "did the client view it" (Worker-side counter in the user's own account — keeps the we-host-nothing promise), menu-bar-only mode refinements, expiring vault links, versioning/rollback (folder history), interactive Markdown checkboxes, site locking with countdown.

---

## What changed from v1, and why (for the record)

Site folders went from feature to architecture: `cheldrop.yaml` in-folder becomes the config source of truth (lowdb → registry + secrets), which buys portability, disaster recovery, hand-editability, and a file-based Claude interface for free. Live sync + background agent moved ahead of beta, because beta on the pre-folder product would validate the wrong product; a 2–3 person onboarding smoke test stays in Phase 0 to keep stranger-account learning flowing. R2 server-side auth was promoted from "honesty" to "prerequisite": unattended republish + never-store-the-password is only coherent when the Worker owns auth. Cloudflare sync shrank (folders now handle local recovery). The MCP split into MCP-for-provisioning + SKILL-for-files. Cost: build time goes from ~3–4 weeks to ~5–6; realistic launch slides from September toward October 2026. Chosen deliberately: depth over date — "a folder that is your website" is a launch story, "a nicer Cloudflare GUI" is not.
