# Cheldrop — Functional Specification

This document describes how Cheldrop is expected to work. Its purpose is to prevent regressions when writing new code. Read it before touching any existing functionality.

---

## Overview

Cheldrop is an Electron desktop app (Mac + Windows). Users publish files to their own Cloudflare account via either Workers static assets (free tier) or Cloudflare R2 + Workers (Business Class). Files are served on a custom subdomain of a domain the user already owns and manages in Cloudflare.

**What Cheldrop does not do:** host anything itself. Every file lives in the user's Cloudflare account.

---

## Architecture at a glance

```
renderer/src/          → React UI (Vite, runs in BrowserWindow)
main/preload.js        → exposes window.pubit IPC bridge (contextBridge)
main/ipc.js            → all IPC handlers; owns electron-store and lowdb
main/cloudflare.js     → all Cloudflare API calls
main/renderer.js       → file rendering pipeline (HTML wrappers, TOC)
main/sitefolder.js     → site folders: Cheldrops root, cheldrop.yaml, theme, ignore globs
main/livesync.js       → live sync: chokidar watchers, debounce, rate cap, retry backoff
main/tray.js           → menu bar agent: status glyph, per-site menu, failure notifications
main/reconcile.js      → Cloudflare reconciliation: pure comparison of registry vs account
main/r2-worker.js      → source of the Cloudflare Worker that serves R2 (routing + auth)
main/license.js        → Lemon Squeezy activate/validate/deactivate
main/updater.js        → electron-updater against GitHub Releases
main/menu.js           → application menu (hosts "Check for Updates…")
templates/             → HTML template files read by renderer.js
```

**IPC bridge is `window.pubit`** — not `window.cheldrop`. This is intentional and must not be renamed; it would break all IPC calls in the renderer. It is not user-visible.

**State is split between two stores:**
- `electron-store` — settings (API token, zones, theme, license data). Survives app reinstall.
- `lowdb` (JSON file at `~/Library/Application Support/cheldrop/sites.json` on Mac) — published sites list. User-facing data.

---

## Data model

### Settings (electron-store key: `settings`)
```js
{
  cfToken: string,          // Cloudflare API token
  zones: [                  // fetched from CF after token validation
    { id, name, accountId }
  ],
  defaultBackend: 'workers' | 'r2',   // 'pages' is the retired free-tier value
  r2BucketName: string,     // not currently used in publish flow
  defaultSessionLength: '1h' | '8h' | '24h' | '7d',
  saveAsSiteFolder: boolean, // default true; the default of Publish's site-folder toggle
}
```

### Site entry (lowdb, `sites.json`)
```js
{
  id: 'subdomain.domain',          // primary key
  subdomain: string,
  domain: string,
  projectName: string,             // CF resource name: 'chel-{subdomain}-{domain-dashes}'
  liveUrl: string,                 // 'https://subdomain.domain'
  fileCount: number,
  backend: 'workers' | 'r2',       // 'pages' on entries written before Phase 3.5
  publishMode: 'files' | 'folder',
  linkedFolder: { path, name } | null, // folder mode only; `path` is absolute
  live: boolean,                   // cache of cheldrop.yaml's `live`; the file is the source
  importedAt: ISO string | null,   // set when imported from Cloudflare (Phase 6); registry-only entry
  workerVersion: number | null,     // R2 only — R2_WORKER_VERSION of the deployed router Worker
  passwordProtected: boolean,      // R2 only; enforced server-side by the Worker
  showToc: boolean,
  showToolbar: boolean,
  toolbarOptions: { date, rawFile, download, copyLink }, // all boolean
  publishedAt: ISO string,         // first publish timestamp
  updatedAt: ISO string,           // most recent publish timestamp
}
```

### Resource naming
All Cloudflare resources (Worker script name, R2 bucket name) share one derived name:
```
chel-{subdomain}-{domain-with-dashes}
```
Example: `preview.myclient.com` → `chel-preview-myclient-com`

This name must be consistent across create/update/delete operations. Never change this derivation formula.

---

## Site folders (Phase 4.1, 13 August 2026)

**A site is a folder.** The managed root is `~/Documents/Cheldrops/`, created lazily, one subfolder per site named by site ID. `main/sitefolder.js` owns everything below.

**`cheldrop.yaml` in the folder root is the source of truth for site configuration.** The lowdb entry demotes to a registry: which folders Cheldrop knows about, plus runtime data and secrets. Secrets never go in the yaml — folders end up in iCloud/Dropbox, so treat their contents as semi-public.

```yaml
schema_version: 1        # a higher number than the app knows refuses to publish
site: preview.myclient.com
backend: workers         # workers | r2
live: true               # watch this folder and republish on change (Phase 4.2); default on
password_protected: false # the hash lives in the Worker, never in this file
toc: index               # index | sidebar | none — see "TOC modes"
show_toc: true           # kept in step with `toc:`, for older Cheldrop versions
show_toolbar: true
toolbar: { date: true, raw_file: true, download: true, copy_link: true }
theme: cheldrop.css      # optional; resolved inside the folder only
theme_user_html: false   # opt-in: also inject the theme into user-supplied HTML
header: Acme Docs        # optional site chrome — see "Header and footer"
footer: © 2026 Acme AS   # optional; both absent by default
ignore: ["*.tmp"]        # optional extra publish excludes
```

- **Merge rule.** At publish, settings the caller supplies win; settings it leaves undefined come from the file. Unknown keys round-trip untouched, so hand-added keys survive. The merge is written back after a successful publish, with `password_protected` set to what the publish actually did.
- **Invalid yaml never publishes a guess.** A parse error, a non-mapping document, or a newer `schema_version` fails the publish with the reason; the folder keeps its file and the app keeps its last-good settings.
- **Reading it back.** Selecting a folder site in Publish re-reads the yaml from disk, which is what makes a hand edit (or one made by Claude) show up in the UI. UI changes write back, debounced.
- **Adoption.** Any folder with a valid `cheldrop.yaml` can be adopted: the `site:` value supplies the site ID. Adopting creates or relinks the registry entry with `publishedAt: null` and no secrets. Folders dropped into the Cheldrops root that no entry claims are offered on the Sites page.
- **Materializing.** A files-mode publish becomes a site folder: the published originals are copied into `Cheldrops/<site-id>/`, a yaml is written from current settings, and the entry flips to `publishMode: 'folder'`. Since plan C1 (16 August 2026) this is the **default and it is agreed to before publishing** — Publish shows a "Site folder" toggle in the site-settings group, defaulted from `settings.saveAsSiteFolder` (default on), and a successful files-mode publish materializes without a second click. Opting out leaves the one-click "Save as site folder" button in the result panel. An existing site that has already published as loose files defaults the toggle **off**: converting it is a choice, not a side effect of re-publishing.
- **The Live default (decision 15 August 2026, shipped 16 August).** A folder created by Cheldrop — `createSiteFolder` or `materializeSiteFolder` — is written with `live: true`, so the normal case is folder + Live with no configuration. A folder whose path is inside a cloud-synced tree is written with `live: false`: `sitefolder.isCloudSyncedPath()` matches iCloud (`Library/Mobile Documents`, which covers Desktop & Documents sync), Dropbox, Google Drive, OneDrive, Creative Cloud, pCloud and Sync.com, and `defaultLiveFor()` is the one definition of the rule. It decides a **default only**: a caller that passes `live` explicitly (the MCP server does) and any `cheldrop.yaml` that already has the key both win. Live still does nothing until the site's first publish.
- **Deleting a site** asks keep-or-trash for the folder; keep is the default, and trashing uses `shell.trashItem` (never an unlink).
- **License gates are enforced in `ipc.js`, not only in the UI**, because a backend can now arrive from a hand-edited yaml and adoption creates entries without publishing: free tier refuses `backend: r2`, and refuses a first publish of a second site.

---

## Live sync (Phase 4.2, 13 August 2026)

A folder site with `live: true` in its `cheldrop.yaml` is watched; changes republish the site by themselves. `main/livesync.js` owns it. Live is available on the free tier's single site.

**Pacing.** Publish is full replacement, so it is paced: changes are debounced 5 s after the last one, and a site publishes at most once per 60 s. Changes that arrive during a publish are queued — the latest state publishes once, not once per save. Constants: `DEBOUNCE_MS`, `MIN_INTERVAL_MS` in `livesync.js`.

**One publish path.** The watcher calls `performPublish()` in `ipc.js` — the same function the `publish` IPC handler calls — and passes **no UI state**: `backend`, `showToc`, `showToolbar`, `toolbarOptions` and `passwordProtected` are all left undefined, so the 4.1 merge rule takes them from `cheldrop.yaml` every run. It passes no password either: a protected site republishes without one because the Worker holds the auth record, and a content-only publish never re-deploys the Worker.

**Which sites are watched.** Folder mode, the folder still exists, `publishedAt` is set, and the yaml says `live: true`. The publish-once rule is deliberate — the watcher republishes, it does not provision, and an adopted folder has no Cloudflare resources yet. `livesync.sync()` reconciles the whole set; it runs at launch and after publish, adopt, config write, live toggle and delete.

**`cheldrop.yaml` is watched too**, and a change to it is applied on arrival rather than at the next allowed publish: `live: false` stops the watcher within seconds, a parse error becomes an error status and publishes nothing, and new `ignore` globs apply before the next run.

**What does not trigger a publish:** dotfiles (which covers the iCloud `.name.icloud` placeholder — the materialized file arrives as an ordinary add), `~$…`, and `.tmp`, `.temp`, `.part`, `.partial`, `.crdownload`, `.download`, `.swp`, `.swx` suffixes, plus the folder's own `ignore` globs. Files are read after `awaitWriteFinish` settles (2 s), so a half-written file never publishes. Note that a `.tmp` file sitting in the folder is still *published* — it is ignored as a trigger only, because a manual publish would publish it too; use `ignore` to exclude it from both.

**An empty folder publishes nothing** — it is nearly always a move in progress, and full replacement would wipe the live site. The status says so instead.

**Failure handling.** A failed publish retries on a 30 s → 1 m → 2 m → 5 m → 10 m backoff and resets the ladder on success, so network loss recovers unattended. Watch errors and yaml errors surface as status, not crashes.

**Manual publish wins.** The `publish` IPC handler pauses all watchers for its duration and resets the published site's rate cap afterwards, so the two paths never race.

**Status** is pushed to every window on the `live-status` event: `{ siteId, state: 'off' | 'live' | 'syncing' | 'error', detail, at, lastPublishAt? }`. `get-live-status` returns the current map for a window that opened late. Shown per site in Sites and in Publish's folder section.

**One machine per site.** Two machines sharing a folder with Live on both would publish ping-pong. The UI says so; nothing enforces it.

**Scope:** watchers run while the app runs — which, since Phase 4.3, means until the user quits from the menu bar, not until they close the window.

---

## Background agent (Phase 4.3, 14 August 2026)

Cheldrop is a menu bar agent that happens to have a window. `main/tray.js` owns the tray; `main/index.js` owns window lifecycle, the single-instance lock and the Dock icon.

**The tray exists from launch**, whether or not anything is syncing. Its glyph is a template image (black + alpha, inverted by macOS): hexagon idle, hexagon with a centre dot while syncing, hexagon with an exclamation on error. Files live in `build/tray/` (16 px + `@2x`) and ship via `extraResources` to `Resources/tray/`; `tray.js` resolves `process.resourcesPath/tray` when packaged and `build/tray` in dev.

**Menu:** one row per Live folder site — `glyph  site.id — last publish 4 minutes ago`, or the error text, or what it is publishing right now; clicking a row opens the site. Then pause-all / resume, Open Cheldrop, a "Launch at login" checkbox, and Quit. The overall glyph and tooltip take the worst state across sites (error > syncing > idle).

**It listens to `livesync.onStatus()`**, a main-process listener registry, not to the renderer's `live-status` IPC event. The tray must keep reporting when no window exists — that is the whole point of it.

**Notifications.** One macOS notification per site per error episode: the first failure speaks, its retry ladder is silent, a successful publish clears the episode so the next failure speaks again. Success is never notified. `shouldNotify()` is the state machine; clicking a notification opens the dashboard.

**Pause-all** (`livesync.setUserPaused`) stops publishing but keeps watching, so changes made while paused publish once on resume. It is not persisted: a pause that survived a restart would be a site that silently stopped syncing. Available in the tray and in Settings.

**Window lifecycle.** Closing the window does not quit on macOS — the agent keeps running, and `app.on('activate')` or the tray's "Open Cheldrop" brings the dashboard back through `showWindow()`, which focuses the existing window or creates a new one. On Windows the app still quits with its last window (Phase 9). Quitting stops all watchers and destroys the tray in `before-quit`; the tray's Quit item is a plain `app.quit()` click handler, **not** `role: 'quit'`, so those handlers run.

**Single-instance lock.** A second launch focuses the running copy. Two copies would mean two watchers on one folder publishing over each other.

**Launched at login, Cheldrop comes up with no window** — `wasOpenedAtLogin`/`wasOpenedAsHidden` skips `createWindow()`, and every login-item write passes `openAsHidden: true`. The tray, the watchers and the license check all still run; the dashboard is one tray click away.

**Agent settings** live under `agent.*` in electron-store: `hideDockIcon` (macOS only — `app.dock.hide()/show()`) and `loginItemPrompted`. "Launch at login" itself is **not** stored — `app.getLoginItemSettings()` is the source of truth, and the app only remembers whether it has asked. It asks once, by dialog, the first time any site is set Live; Settings → Background agent has all three toggles permanently.

---

## Cloudflare reconciliation (Phase 6, 14 August 2026)

The site list is a local registry; the truth is the user's Cloudflare account, and the two drift — a site deleted in the dashboard, or one published from another machine. `main/reconcile.js` compares them. **It is read-only against Cloudflare and always will be**: it never creates, changes or deletes a remote resource.

**Where the work lives.** `reconcile.js` is pure — no network, no Electron — so its rules are unit-testable. `ipc.js` (`reconcile-cloudflare`) does the fetching via three read-only listers in `cloudflare.js`: `listWorkerScripts`, `listR2Buckets` (cursor-paginated), `listPagesProjects`.

**The join key** is `resourceNameFor(subdomain, domain)` in `cloudflare.js` — the single definition of `chel-{subdomain}-{domain-with-dashes}`, used by publish, adopt and reconciliation alike.

**"Cannot look" ≠ "not there."** Each lister returns `forbidden` separately from `ok`. A resource kind the token cannot read is marked `known: false` and produces **no finding**. A token without R2 permission must never make a healthy R2 site look deleted. If one account of several cannot be read, the whole kind is unknown — a name absent from the accounts that answered proves nothing about the one that did not.

**Site states:** `ok` · `partial` (some resources gone, e.g. Worker deleted but bucket left) · `missing` (nothing of it remains) · `unpublished` (adopted or imported; nothing is expected yet) · `unknown` (unanswerable). Only `missing` and `partial` reach the UI, each with **Re-publish…** (opens it in Publish) and **Forget this site**.

**`forget-site`** removes the local entry and stops its watcher. It makes no Cloudflare calls — there is nothing left to call about — and never touches the site folder. It is distinct from `delete-site`, which does delete remote resources.

**Orphans** are `chel-`-named resources no entry claims, grouped by resource name (a Worker and its bucket are one offer). `import-remote-site` creates a **registry-only** entry: `importedAt` set, `publishedAt` null, no folder, no secrets — so the free-tier one-site gate and the Live "publish once first" rule keep their meaning. Importing exists so a site is visible, openable and deletable from the app, and so Phase 7's `list_sites` tells the truth.

**Reverse-deriving a site ID** from a resource name is ambiguous alone (`chel-my-project-example-com` could split at several dashes). `siteIdFromResourceName` resolves it against the account's zone list, longest domain first, and accepts a split only if it round-trips through `resourceNameFor`. A name that matches no zone is listed but not importable.

**What each lister actually asks (verified against a real account, 16 August 2026).** Every list endpoint paginates differently, and every one of them was a way to see half an account:

- **Workers** — `GET /accounts/{id}/workers/scripts`. Returns every script in one response and sends no `result_info`; `per_page`/`page` are accepted and ignored. `listPaged` therefore stops after one round.
- **R2** — `GET /accounts/{id}/r2/buckets?per_page=100`, cursor-paginated via `result_info.cursor`.
- **Pages** — `GET /accounts/{id}/pages/projects?page=N`. **Ten per page, always**: `per_page` at any value is rejected with HTTP 400 "Invalid list options provided", but the endpoint does paginate and reports `result_info.total_pages`. The loop follows the page count. Asking once returned the first ten projects of nineteen and read as the whole account.

**"R2 is not enabled here" is an answer, not a silence.** An account that never switched R2 on replies 403 with code `10042`. That is authoritative — there are no buckets — so `listR2Buckets` returns `ok` with an empty list and `notEnabled: true`. Treating it as unreadable marked the whole R2 kind `known: false` across every account, which is the "one unanswerable account poisons the set" rule firing on a question that *was* answered: a second, unrelated account with no R2 hid every R2 site and every R2 orphan in the main one.

**Accounts scanned** are those derived from `settings.zones`. That is complete by construction — a Cheldrop site needs a custom domain, so it needs a zone, and `/zones` returns every zone the token can see. An account with no zones cannot host one. Stale `settings.zones` is the only gap, and re-validating the token in Settings closes it.

**Orphan claiming is by resource name, not by name-and-kind.** The name is derived from the site ID, so nothing else in the account can legitimately hold it, and a site that changed backend leaves the old kind behind — a Workers site that used to be R2 still has its bucket. Claiming per kind offered those leftovers as "import a site you already have", which the import handler then refused.

**An orphan's backend** comes from `backendForKinds()`: `r2` needs a Worker *and* a bucket; a bare bucket beside a Pages project is leftover storage and the Pages project is what serves the address. Importing that as `r2` would point a later delete at the wrong resources.

**The scan says what it covered.** The handler returns `scan: { accounts, zones, workers, r2, pages }` — counts per kind, `null` where the token could not look — and Sites renders it as a sentence under the check button. A silent partial scan reads exactly like a complete one.

**Caching.** The result is cached in the main process for five minutes; Sites runs it on mount and "Check Cloudflare" forces a fresh look. The cache is cleared on publish, delete, adopt, import and forget.

---

## Licensing

**Statuses:** `free` | `active` | `invalid` | `version_cutoff`

**Stored in electron-store:** `licenseKey`, `licenseInstanceId`, `licenseStatus`, `licenseLastChecked`, `licenseLastFailure`

**`licenseStatus`** is `free` | `active` | `invalid` | `unverified` (checks have been inconclusive past the grace window). Everything gated on a licence tests `=== 'active'`.

**`VERSION_CUTOFF`** in `license.js` is currently `'2026-03-21'`. Keys purchased before this date cannot **activate** the current version. It is deliberately not checked on validation — re-checking it every launch retroactively revoked installs that were entitled when they were set up. When shipping a new major version, update this date. Do not change it otherwise.

**Gated behind active license:**
- R2 backend
- Password protection (R2-only, so the R2 gate covers it)
- Publishing more than 1 site

**Background validation** runs on every launch via `validateStoredLicense()` called from `index.js`. It does NOT block window creation. Offline grace period: 7 days, after which the status becomes `unverified` — never `invalid`, which is reserved for a licence Lemon Squeezy actually rejected.

`validateLicense()` returns a `reason`, and only `expired`, `disabled`, `wrong_product` and `rejected` revoke. `offline` and `server_error` mean "could not tell" and keep the licence. `unknown_instance` (the key is fine, this machine's activation is gone) re-activates silently. Failures are written to `licenseLastFailure` — locally, never transmitted — and Settings shows a reason-specific message. Regression tests live in `test/license.test.js` (`npm run test:license`) and stub `fetch`; run them after any change to this file.

**Machine fingerprint:** `SHA-256(hostname + stored UUID)`, truncated to 32 chars. UUID is generated once and persisted in electron-store as `machineId`.

---

## User flow

### First launch (no token)
1. App opens, `App.jsx` calls `getSettings()` on mount.
2. If `cfToken` is empty, redirects to `/quickstart`.
3. Quick Start walks the user through: get a CF token → paste it → validate → select zone → save.
4. On completion, redirects to `/publish`.

### Token validation + zone setup (Settings page)
1. User pastes token, clicks Validate.
2. IPC: `cf-validate-token` → `GET /user/tokens/verify`.
3. If valid: IPC `cf-fetch-zones` → `GET /zones?status=active`. Returns `[{ id, name, accountId }]`. Account ID is extracted from zone data — **no separate `/accounts` call**.
4. IPC `cf-check-permissions` probes 3 endpoints concurrently (Workers, R2, DNS) and returns `{ warnings[], missingR2, missingDns, missingWorkers }`. Warnings surface in Settings UI.
5. Zones and token saved via `save-settings`.

### Publish flow
1. User selects or types a site ID in the site selector combobox (`subdomain.domain` format).
2. Selects publish mode: **Files** (drag-drop) or **Sync folder** (folder picker).
3. Configures the site in one **Site settings** group (plan C2, 16 August 2026), visible above the Publish button and never behind a disclosure triangle: backend, site folder, Live, TOC, toolbar, password. For a folder site every one of them writes to that folder's `cheldrop.yaml`; the theme picker (C3) and TOC mode (D1) join this group when they land.
4. Clicks Publish.

**Live is set here, before the first publish.** The toggle is no longer disabled on an unpublished site: it is a setting of the site like any other, so it is written to the yaml with the rest and starts working when the site publishes. On an already-published site the toggle still goes through `set-site-live`, which writes the yaml and reconciles the watchers in one move. When the folder is cloud-synced and Live is on, the one-machine-per-site rule is stated at the toggle.

**Per-site settings persistence (built, commit `66ec00a`, 30 March 2026):** when an existing site is selected in the combobox, `Publish.jsx` (lines 76–78) restores that site's saved `showToc`, `showToolbar` and `toolbarOptions` from the site entry. `main/ipc.js` persists these fields on the site entry at publish. Do not regress this when touching `Publish.jsx` or the publish IPC handler.

**IPC: `publish`** — a thin wrapper: it pauses live watchers, calls `performPublish()`, resets the site's live rate cap on success, and resumes and reconciles the watchers in a `finally`. `performPublish()` (also exported, and what live sync calls) resolves the zone from stored settings, looks up any existing site entry (for R2 sites: passes `existingR2Site` to skip Worker/domain re-creation; for entries still on the retired `pages` backend: passes `legacyPagesSite` so the Workers path retires them first), normalises the requested backend to `workers`/`r2`, then calls `cloudflare.publish()`.

**`cloudflare.publish()`** calls `renderer.renderFiles()` first, then routes to the Workers or R2 path.

**After successful publish:** ipc.js upserts the site entry in lowdb. If the site existed before, `publishedAt` is preserved; `updatedAt` is updated. All other fields are overwritten from the current publish payload.

### File rendering pipeline (`main/renderer.js`)
`renderFiles(files, opts)` returns `{ files: renderedFiles[], fileMap[] }`.

**Exclusions run once, at the door.** Before anything else, `renderFiles` drops files named `cheldrop.yaml` or `cheldrop.css` at the root (exact match) and anything matching the site's `ignore` globs. Nothing downstream — mode detection, wrappers, TOC, `fileMap` — ever sees them.

**Theme injection.** When `opts.themeCss` is set, its contents are published as `theme.css` (`theme-1.css`, … if a user file already claims the name) and `<link rel="stylesheet" href="/theme.css">` is inserted before `</head>` of every page Cheldrop generates — wrappers, TOC, gallery, 404 — last, so the theme wins. The source name `cheldrop.css` is never published. User-supplied HTML is only rewritten when `opts.themeUserHtml` is true; otherwise it stays a byte-for-byte passthrough.

**Branding footer.** `opts.licenseStatus === 'active'` drops the "Published with Cheldrop" footer. `ipc.js` passes it from electron-store on every publish, and every wrapper and TOC call site forwards `isBusinessClass`. Until 13 August 2026 neither was true, so paid users got the free-tier footer.

**Mode detection** (based on visible files only — hidden files like `.DS_Store` are ignored):
| Mode | Condition |
|---|---|
| `html` | Any `.html`/`.htm` file present |
| `gallery` | 2+ images, no non-images |
| `single-image` | Exactly 1 image, nothing else |
| `single-video` | Exactly 1 video, nothing else |
| `single-audio` | Exactly 1 audio file, nothing else |
| `single-doc` | Exactly 1 `.md` or `.txt` file, nothing else |
| `pdf` | Exactly 1 `.pdf` file, nothing else |
| `mixed` | Everything else |

**Wrapper naming:** Files get `.html` appended, not extension-replaced. `report.md` → `report.md.html`. This avoids collisions with user HTML files.

**TOC generation:** Attempts `index.html` first, falls back to `sitemap.html`, then `cheldrop-toc.html`. Generated in `index` and `sidebar` modes, not in `none`.

**TOC modes (`toc:` in the yaml, plan D1, 16 August 2026).** The old `show_toc` boolean became a mode:

| Mode | What it does |
|---|---|
| `index` | The generated site-map page. The default, and byte-for-byte what `show_toc: true` always produced. |
| `sidebar` | The site map, plus a navigation rail injected into every generated page — a folder of Markdown becomes a docs site. |
| `none` | Nothing generated. |

- **Both keys stay in the file.** `show_toc` is always rewritten to agree with the mode (`none` → false, otherwise true), so a folder written by this version still reads correctly in an older one. That is why `schema_version` did not move. Where the two disagree, `toc:` wins.
- **`toc:` is either the mode alone or a mapping** carrying `mode:` and an optional `items:` list. `items` fixes the order; a single-key mapping in it (`- guide: [setup.md, usage.md]`) is one group, one level deep, whose label links to that folder's index when there is one. Anything `items` omits is appended alphabetically, so a partial list promotes rather than filters.
- **The rail lists pages, not files:** anything whose published URL is an HTML page, plus one entry per subfolder pointing at that folder's index. The site title is the link to `/`. The page being viewed carries `aria-current="page"`.
- **Injection follows the theme rule:** into everything Cheldrop generates (wrappers, subfolder indexes, the site map, the 404), and into user-supplied HTML only where `theme_user_html: true`. Every colour is a theme token with the Paper default behind it, so `cheldrop.css` restyles the rail with the page.
- Sidebar mode applies to `html`/`mixed` sites. A single-file site has nothing to navigate and never gets a rail.

**Header and footer (`header:` / `footer:`, plan D2, 16 August 2026).** Optional site chrome, injected on the same terms as the sidebar and styled from the same tokens.

```yaml
header:
  title: Acme Docs          # `header: Acme Docs` is the same thing
  logo: logo.png            # folder-relative; must be a file that publishes
  link: /                   # where the title and logo point; default /
footer:
  text: © 2026 Acme AS      # `footer: © 2026 Acme AS` is the same thing
  links:
    - Contact: mailto:hi@acme.com
    - Privacy: /privacy.html
```

- **Both are absent by default and are never written back as empty shells.** A key that says nothing usable is dropped, so a two-file drop cannot grow a masthead it did not ask for.
- **Folder-only.** The chrome comes from the yaml, so a publish-and-forget site has none. `settingsToConfig` treats `undefined` as "keep the file's value" and `null` as "remove it".
- **Nothing in the yaml is trusted:** link targets must be `http(s)`, `mailto`, `tel`, or a relative/absolute path or anchor (`javascript:` and `data:` are dropped, links and all); a logo may not climb out of the folder; and the logo is only linked once it is found in the set actually being published, so an `ignore` glob or a typo leaves the title standing rather than a broken image. The logo is also kept out of the sidebar listing — it is chrome, not a page.
- **DOM order is masthead, navigation, content** — the chrome is injected after the sidebar so that is the order it is announced in, and the order the bands stack in on a narrow screen.
- The free-tier "Published with Cheldrop" credit is separate and unaffected.
- **No UI.** Plan D2 specifies yaml config only; the Publish settings group is unchanged.

**Auth:** none. The renderer does no authentication — it injects no guard and emits no login page. Password protection is R2-only and enforced server-side by the Worker (see "Password protection" below). The client-side guard was removed 3 August 2026 (build plan Phase 3.1).

**fileMap** maps each input filename to its published URL path. Used by the post-publish result panel in `Publish.jsx` to show per-file links. Shape: `[{ name, urlPath, isToc?, rawPath?, isGalleryImage? }]`.

**A `404.html` is always added** from `templates/404.html`.

### Workers static assets publish path (Phase 3.5, 13 August 2026)

Replaced the Pages Direct Upload flow. Cloudflare develops new features for Workers only; Pages is de-emphasized. Both tiers now sit on one backend family.

0. `retireLegacyPagesSite` — **only** when the stored site entry still says `backend: 'pages'`. Deletes the old project's custom domains, deletes the project, then deletes the hand-made CNAME. Both are prerequisites: the project would keep answering on the hostname, and Cloudflare refuses a Worker custom domain on a hostname that already has a CNAME.
1. `uploadWorkerAssets` — 3-step direct upload:
   - Build manifest `{ "/path": { hash, size } }`. Hash = `SHA-256(base64(content) + extension)` truncated to 32 hex chars, per Cloudflare's own API example. The hash is a client-chosen content key; it is never verified, only deduplicated against.
   - `POST /accounts/:id/workers/scripts/:name/assets-upload-session` with the manifest → `{ jwt, buckets }`. `buckets` lists only the hashes Cloudflare does not already hold, pre-batched. Empty `buckets` means nothing changed: the session JWT is already the completion token and no upload happens.
   - `POST /accounts/:id/workers/assets/upload?base64=true` per bucket, `multipart/form-data`, one part per hash whose body is the base64 text and whose part `Content-Type` becomes the asset's served content type. Bearer token is the session JWT, not the API token. The last response carries the completion token.
2. `deployAssetsWorker` — `PUT /accounts/:id/workers/scripts/:name` with a `metadata` part only: `compatibility_date`, and `assets: { jwt: completionToken, config: { html_handling: 'auto-trailing-slash', not_found_handling: '404-page' } }`. This is an **assets-only Worker** — no `main_module`, no script part.
3. `addWorkerDomain` — the same call the R2 path uses. Cloudflare creates the DNS record (a proxied `AAAA` to `100::`) and issues the certificate. **Cheldrop never writes a DNS record.** Idempotent: it returns early if the hostname is already attached.

Publish is full replacement: the deployed version serves exactly the manifest just registered.

### R2 publish path
**First publish:**
1. `getOrCreateR2Bucket` — creates bucket named `{resourceName}`.
2. `uploadToR2` — PUT each file to `/accounts/:id/r2/buckets/:name/objects/:key`.
3. `deployR2Worker` — deploys the Worker script from `main/r2-worker.js`, bound to the bucket. It serves the bucket and, if password protection is on, enforces it via a `CHELDROP_AUTH` `secret_text` binding. It exposes no administrative endpoint of any kind. The `R2_WORKER_VERSION` of the script deployed is returned and stored on the site entry.
4. `addWorkerDomain` — attaches the FQDN to the Worker via `/accounts/:id/workers/domains`. **No manual DNS record needed** — CF handles this automatically.

**Re-publish (existing R2 site):**
1. Uploads the new files first, over the existing objects.
2. `pruneR2Bucket` — lists every object via the R2 API (`GET` on `/accounts/:id/r2/buckets/:name/objects`, paginated by `result_info.cursor`) and deletes, 8 at a time, only the keys the new publish does not contain. Still a full replacement, but the bucket is never empty in between: an upload that fails part-way leaves the previous publish serving, and nothing is deleted at all.
3. **Skips** the Worker deploy and domain attach — unless password protection changed (see below), or the site's stored `workerVersion` is behind `R2_WORKER_VERSION`, in which case the Worker (not the domain) is redeployed once to carry router fixes to sites already live. A deploy replaces the bindings wholesale, so a protected site would come back public unless the auth record travels with it — and an unattended publish has no password to build one from. The upload therefore inherits the existing binding (`keep_bindings: ['secret_text']`), which is only attempted when `CHELDROP_AUTH` is confirmed bound beforehand, and is confirmed again afterwards. A site whose binding did not survive is locked with a record nobody holds the password to, and the publish fails asking for the password to be set again — the redeploy fails closed, never open.

`purgeR2Bucket` (empty the bucket outright) is now only used when a site is deleted.

Nothing in the publish path talks to the live domain, so a publish never depends on DNS having propagated.

### R2 password protection (Phase 3.1, 3 August 2026)

Password protection is **R2-only** and enforced by the Worker before any object is read.

- **The secret.** `buildAuthRecord()` derives PBKDF2-HMAC-SHA256 (random 16-byte salt, 32-byte key) over the password and pairs it with a random 32-byte session-signing key and the session TTL. The JSON record is uploaded as the Worker's `CHELDROP_AUTH` `secret_text` binding. The password itself is never stored — not in lowdb, not in electron-store, not in published files.
- **Iteration count** is `PBKDF2_ITERATIONS` in `cloudflare.js`, currently `10000`, and is recorded in the auth record so it can be raised without invalidating existing sites. It is deliberately below the textbook 100k: the derivation runs in the user's Worker, and the Workers free plan caps CPU at 10 ms per invocation.
- **The check.** Every request without a valid `cheldrop_session` cookie gets the login page and `401` — HTML, raw `.md`/`.pdf`/images, everything. Login POSTs to `/__cheldrop_auth__`; on success the Worker sets `cheldrop_session=<exp>.<HMAC-SHA256(sessionSecret, exp)>` as `HttpOnly; Secure; SameSite=Lax`. Protected responses carry `Cache-Control: private, no-store`.
- **Nothing is exempt** from the password check. The Worker used to carry `POST /__cheldrop_admin__/purge`, exempt by design and guarded by a plaintext shared secret; it was removed (review finding 5) once the purge had moved to the R2 API, and the `adminSecret` field went with it.
- **When the Worker is re-deployed:** first publish, when protection is turned on, turned off, or given a new password, or when the deployed script is behind `R2_WORKER_VERSION`. A content-only re-publish never touches auth — a version redeploy of a protected site inherits the existing `CHELDROP_AUTH` binding rather than rebuilding it, which is what makes unattended publishing of a protected site possible (build plan Phase 4.2). A blank password field on an already-protected site means "keep the current password".
- **Absent binding = public site.** Turning protection off re-deploys the Worker without the binding; the bindings list sent on upload fully replaces the previous one.

### Delete flow
**IPC: `delete-site`** calls `cloudflare.deleteSite()` then removes the entry from lowdb.

**Workers delete:**
1. Remove the Worker custom domain (this also removes the DNS record Cloudflare created for it).
2. DELETE the Worker script — the assets belong to it and go with it.
3. Delete any leftover CNAME record (best-effort, non-fatal if missing or if the token lacks DNS — Edit).

**Legacy Pages delete** (site entries with `backend: 'pages'`, written before Phase 3.5):
1. List custom domains on project, delete each.
2. DELETE the project.
3. Delete CNAME DNS record (best-effort, non-fatal if missing).

**R2 delete:**
1. `purgeR2Bucket` — empties the bucket via the R2 API. No Worker involvement and no DNS resolution required.
2. Remove Worker custom domain.
3. DELETE Worker script.
4. DELETE R2 bucket.
5. Delete CNAME DNS record (best-effort).

---

## IPC surface (`window.pubit`)

All renderer → main communication goes through this bridge. Do not add new capabilities to the renderer without a corresponding IPC handler.

| Method | Handler | Notes |
|---|---|---|
| `getSettings()` | `get-settings` | Returns settings object with defaults |
| `saveSettings(s)` | `save-settings` | Persists to electron-store |
| `validateToken(t)` | `cf-validate-token` | Hits CF verify endpoint |
| `fetchZones(t)` | `cf-fetch-zones` | Returns `{ zones }` |
| `checkPermissions(t, accountId, zoneId)` | `cf-check-permissions` | Returns `{ warnings, missingR2, missingDns }` |
| `publish(payload)` | `publish` | Main publish flow |
| `onPublishProgress(cb)` | event: `publish-progress` | Returns unsubscribe fn |
| `writeTempFile(name, data)` | `write-temp-file` | Writes base64 data to tmp dir |
| `readLinkedFolder(path, ignore?)` | `read-linked-folder` | Recursive read; skips hidden, reserved names and `ignore` globs |
| `getSites()` | `get-sites` | Returns `{ sites }` from lowdb |
| `deleteSite(id, folderAction?)` | `delete-site` | CF delete + lowdb remove; `'keep'` (default) or `'trash'` |
| `getCheldropsRoot()` | `get-cheldrops-root` | `{ root, cloudSynced }` — path of `~/Documents/Cheldrops` (not created by this call) |
| `createSiteFolder(id, settings)` | `create-site-folder` | Creates `Cheldrops/<id>/` + starter `cheldrop.yaml` |
| `materializeSiteFolder(id, files, settings)` | `materialize-site-folder` | Copies a files-mode publish into a folder, flips the entry, returns the folder's `live` |
| `pickFolder(defaultPath?)` | `pick-folder` | Native directory dialog |
| `adoptFolder(path)` | `adopt-folder` | Registers/relinks a folder carrying a valid `cheldrop.yaml` |
| `listAdoptableFolders()` | `list-adoptable-folders` | Cheldrops-root folders no site entry claims |
| `readSiteConfig(path, id)` | `read-site-config` | `{ ok, exists, settings, cloudSynced, defaultLive }`; `missing: true` if the folder is gone |
| `writeSiteConfig(path, id, settings)` | `write-site-config` | Merges over the existing file and writes it |
| `revealInFinder(path)` | `reveal-in-finder` | `shell.openPath` |
| `setSiteLive(id, live)` | `set-site-live` | Writes `live` to the yaml, caches it, reconciles watchers |
| `getLiveStatus()` | `get-live-status` | `{ statuses }` keyed by site ID |
| `onLiveStatus(cb)` | event: `live-status` | Returns unsubscribe fn |
| `getAgentSettings()` | `get-agent-settings` | `{ openAtLogin, hideDockIcon, paused, canHideDock }` |
| `setAgentSettings(patch)` | `set-agent-settings` | Applies login item / Dock icon / pause; returns the applied state |
| `reconcileCloudflare(force?)` | `reconcile-cloudflare` | Read-only compare vs the account; 5-minute cache |
| `importRemoteSite(site)` | `import-remote-site` | Registry-only entry for an orphaned `chel-` resource |
| `forgetSite(id)` | `forget-site` | Drops the local entry only — no Cloudflare calls, folder untouched |
| `checkDns(subdomain, domain)` | `check-dns` | Returns `{ exists, isCheldrop, records }` |
| `openExternal(url)` | `open-external` | `shell.openExternal` |
| `getTheme()` | sync: `get-theme` | Returns stored theme or null |
| `saveTheme(t)` | sync: `save-theme` | Persists to electron-store |
| `getLicenseStatus()` | `get-license-status` | Returns `{ status, key }` |
| `activateLicense(key)` | `activate-license` | Calls LS API, stores result |
| `deactivateLicense()` | `deactivate-license` | Calls LS API, clears store |
| `getVersion()` | sync: `get-version` | Returns `{ version, build, label }` — `label` is `"1.1.0 (2)"` |

---

## Progress events

During publish, `main/ipc.js` sends `publish-progress` events to the focused window:
```js
{ step: 'rendering' | 'project' | 'uploading' | 'dns' | 'done', percent: 0–100, detail: string }
```
These are received in the renderer via `window.pubit.onPublishProgress(cb)`. The returned function unsubscribes the listener. Always call it on component unmount.

---

## UI pages

| Route | Page | Purpose |
|---|---|---|
| `/` | `Sites.jsx` | List of published sites with open/delete actions |
| `/publish` | `Publish.jsx` | Main publish UI |
| `/settings` | `Settings.jsx` | Token, zones, license |
| `/docs` | `Docs.jsx` | In-app documentation |
| `/quickstart` | `QuickStart.jsx` | First-run onboarding |

### Site status (plan A3, 16 August 2026)

One derivation, `siteStatus()` in `Sites.jsx`, decides what a row says. **Live is about a watcher, not about being reachable.**

| State | Badge | When |
|---|---|---|
| Needs you | `tag-attention` | reconciliation found its resources gone, or the last unattended publish failed |
| Publishing… | `tag-publishing` | a publish is in flight (`live-status` says `syncing`) |
| Live | `tag-live` | `live: true` and `publishedAt` set — a folder is watched |
| Published | `tag-published` | `publishedAt` **or** `importedAt` set: an address is serving, nothing is watching |
| Draft | `tag-draft` | neither — adopted from a folder and never pushed |

A site found on Cloudflare is **Published**, never Draft. `importedAt` is what carries that: imported entries keep `publishedAt: null` on purpose (the free-tier and Live gates read that field), so any status logic that tests `publishedAt` alone reports a live site as unpublished. MCP's `siteView.published` follows the same rule; the gates in `mcp-server.js` and `ipc.js` still read `publishedAt`, because they ask a different question.

The tray lists only Live folder sites, so it echoes this model by construction.

**Navigation:** Quick Start nav item only appears when `hasToken` is false in `App.jsx`. After settings are saved, it disappears. This state is not persisted across renders — it's re-read from settings on mount.

**Theme:** `dark` (default) or `light`. Stored in electron-store via `window.pubit.saveTheme()`. Applied as `data-theme` attribute on `<html>`. Syncs to OS-level preference if no stored value. Theme toggle is in the sidebar footer.

---

## Templates

All templates live in `templates/` and are read synchronously by `main/renderer.js` via `fs.readFileSync`.

| File | Used by |
|---|---|
| `md-wrapper.html` | `wrapMarkdown()` |
| `gallery-wrapper.html` | gallery mode + subdir galleries |
| `404.html` | always added to output |

`toolbar.html` no longer exists — toolbar HTML is generated inline in `renderer.js`. Do not create it. `auth-guard.html` and `auth-wrapper.html` were deleted on 3 August 2026 with the client-side guard; the login page now lives in the Worker source in `main/r2-worker.js`.

---

## Versioning

Two numbers, and they answer different questions.

- **`version`** in `package.json` (`CFBundleShortVersionString`) is the release. It changes when a release ships, and it is what `electron-updater` compares. **1.1.0** as of 14 August 2026 — folders, live sync, the menu bar agent and reconciliation.
- **`build`** is a counter in `build/build-number` (committed), incremented by `build-mac.sh` on every build and passed to electron-builder as `buildVersion` (`CFBundleVersion`). It only ever goes up and is never reset by a version bump, so a build number identifies one build for good. Without it, every build of a version is indistinguishable — during a beta that means every bug report names the same version and means a different app each time.

`build-mac.sh` also writes `main/build-info.json` (`{ build, version }`, generated, gitignored) so the running app can read its own build number: `versionInfo()` in `ipc.js` is the single source of the label, used by the sidebar footer (`v1.1.0 (2)`) and `app.setAboutPanelOptions`, which makes the standard macOS About panel read *Version 1.1.0 (2)*. An unpackaged run reports the last stamped version with the same build number; a build that somehow lost the file says `(dev)` rather than inventing a number.

`verify-mac.sh` fails the build if `CFBundleShortVersionString` disagrees with `package.json` or `CFBundleVersion` disagrees with `build/build-number` — a build that skipped stamping must not ship.

---

## Auto-update

`electron-updater` checking a GitHub Releases feed (build plan Phase 2).

- **Feed:** `publish` in `package.json` → GitHub provider, `nordtomme/Cheldrop-releases`. That repo must be **public**: the updater fetches `latest-mac.yml` and the zip unauthenticated. The code repo (`nordtomme/Cheldrop`) is private, so releases are published to the separate releases-only repo.
- **Artifacts:** the mac target is `dmg` *and* `zip`. The DMG is what humans download; the zip is what electron-updater downloads. `latest-mac.yml` is generated by electron-builder into `dist/` and must be uploaded to the release alongside the zip (`verify:mac` checks all three exist and agree).
- **Launch check** (`initAutoUpdater()` in `main/index.js`): fires 4 s after `whenReady`, silent, never blocks window creation, never shows a dialog, swallows all errors.
- **Manual check:** menu item *Cheldrop → Check for Updates…* (`main/menu.js`). Same machinery, but always reports back: available / up to date / failed / ready to install.
- **Install:** `autoDownload` and `autoInstallOnAppQuit` are both on. The quiet path installs on quit with no interruption; the manual path offers "Restart now".
- **Dev builds:** skipped when `app.isPackaged` is false; the menu item explains why instead of erroring.

Releasing: bump `version` in `package.json`, `npm run build:mac` (which stamps the next build number), `npm run verify:mac`, then create a GitHub release on the releases repo tagged `v<version>` with the `.dmg`, the `.zip`, the `.blockmap` and `latest-mac.yml` attached. The build number is not part of the tag — `electron-updater` compares `version` only.

---

## What must not break

These are the behaviours most likely to be accidentally regressed. Check them explicitly when writing code that touches any of the files below.

### cloudflare.js
- The free-tier Worker is deployed **assets-only** — metadata with no `main_module` and no script part. Adding a script module puts Worker code in front of every non-asset request and changes both routing and billing.
- `html_handling: 'auto-trailing-slash'` is what keeps `/about` → `/about.html` and `/dir` → `/dir/index.html` working. It is the behaviour the Pages backend had; dropping it breaks every published link that omits `.html`.
- The R2 router in `r2-worker.js` has to answer the same addresses, or a folder that works on the free tier 404s the day it is upgraded. An extensionless address is tried as `<path>/index.html`, then `<path>.html`, then `<path>`, in that order — the folder index keeps precedence because that is what resolved before the `.html` step existed, and moving it would change which page a live site serves. The dot test that decides "this is a filename" reads the last segment only; on the whole path, a directory called `v1.2` stops index resolution for everything under it.
- The asset manifest hash is `SHA-256(base64(content) + extension)` truncated to 32 hex chars. It only has to be stable and 32 hex characters — but changing it re-uploads every file of every site once.
- Both multipart requests (asset upload, Worker deploy) omit `Content-Type` so fetch sets the multipart boundary. Do not add `Content-Type: application/json`.
- The asset upload's bearer token is the **session JWT** from the upload session, not the API token.
- An empty `buckets` array from the upload session is the unchanged-files case: the session JWT is already the completion token. Do not treat it as an error.
- `addWorkerDomain` is the only way a domain gets attached, on both backends. Cheldrop writes no DNS records at publish — Cloudflare creates them. Reintroducing a CNAME upsert would both need DNS — Edit back in the token floor and collide with the Worker custom domain.
- R2 re-publish skips the domain attach entirely (`if (!existingR2Site)`) and skips the Worker deploy unless password protection changed or the deployed script is behind `R2_WORKER_VERSION`. A content-only re-publish must never re-deploy the Worker for any other reason — that is what lets a protected site publish unattended.
- The Worker upload's `bindings` array replaces all existing bindings. Any code that re-deploys the Worker must include the `r2_bucket` binding, and must keep `CHELDROP_AUTH` whenever the site should stay protected — either a fresh record in `bindings`, or `keep_bindings: ['secret_text']` to inherit the deployed one. Omitting both silently makes a protected site public.
- Every `keep_bindings` redeploy is bracketed by `checkAuthBinding`: `present` before (there is something to inherit) and `present` after (it survived). `absent` or `unknown` before means do not redeploy at all; anything but `present` after means re-deploy with a random auth record to lock the site and fail the publish. Dropping either check turns a silent Cloudflare change into a silently public site.
- `PBKDF2_ITERATIONS` must stay low enough to fit the Workers free-plan CPU budget (10 ms). Raising it breaks login on free Cloudflare accounts.
- `listPagesProjects` must keep following `result_info.total_pages` with `page=` and must never send `per_page` — the endpoint 400s on it, and ten projects per page is not negotiable. Dropping the loop makes every project past the first ten invisible, which is indistinguishable from "you have no such site".
- `listR2Buckets` recognises error code `10042` as an empty account, before the 403 branch. Moving that check after the status test turns an answered question back into an unanswerable one, and one R2-less account then hides every R2 site the user has.
- `listR2ObjectKeys` follows `result_info.cursor` while `is_truncated` — dropping the pagination loop leaves stale files live on buckets over one page. Both `purgeR2Bucket` and `pruneR2Bucket` list through it.
- The deployed R2 Worker serves and authenticates; it never writes to or deletes from the bucket. A Worker endpoint that mutates the bucket is reachable by anyone who can guess or read its guard, and the app can already do anything it needs through the R2 API with the token it holds.
- An R2 publish uploads before it deletes. Restoring the old purge-then-upload order reintroduces a window where the live site serves a partial set — and under live sync, a failure inside that window can leave it there for hours.
- Nothing in publish or delete may call the live domain. The DNS-propagation race that caused is why the purge moved to the API.
- DNS lookup filter uses FQDN: `?type=CNAME&name=${fqdn}`.

### renderer.js
- Wrapper naming appends `.html`, never replaces extension. `f.name + '.html'`, not `f.name.replace(ext, '.html')`.
- Hidden files (starting with `.`) are excluded from mode detection but NOT from the output — they are passed through unchanged if present in the file list. `cheldrop.yaml` and `cheldrop.css` at the root, and anything matching the `ignore` globs, are excluded from both (Phase 4.1).
- The exclusion filter runs once on entry to `renderFiles`, not per call site. Reintroducing a downstream loop over the unfiltered list is how a config file ends up published.
- User-supplied HTML is passed through byte-for-byte (`sourcePath`, `binary: true`) unless `themeUserHtml` is on. Nothing else may rewrite it — password protection is the Worker's job.
- The theme is published under a generated name, never as `cheldrop.css`. Publishing the source name would put the site's config-adjacent file back at a predictable URL.
- Every wrapper and `buildToc` call must forward `isBusinessClass`. A call site that drops it puts the branding footer back on a paying customer's site.
- `fileMap` must be updated whenever a new output file type is added, or the result panel links will break.
- The TOC name fallback sequence is: `index.html` → `sitemap.html` → `cheldrop-toc.html`.
- `index` mode must render byte-identical output to the version before TOC modes existed — every sidebar code path is gated on `tocMode === 'sidebar'`. `test/toc.test.js` compares the two renders and fails if that drifts.
- Sidebar mode still generates the root index page. It is what answers for `/`, and the rail's title links there.
- The sidebar is injected into user HTML only under `themeUserHtml`, and never twice into the same page (`id="cheldrop-sidebar"` is the guard).
- Header and footer follow the same rules, with `id="cheldrop-header"`/`id="cheldrop-footer"` as the guard. A site that passes neither must render byte-identical output to one that passes nothing — `test/chrome.test.js` compares the two.
- A header logo is linked only after being found in the files being published. Dropping that check publishes broken images whenever a logo is ignored or misspelled.

### ipc.js
- `getDb()` lazily initialises lowdb using dynamic `import()` (ESM module). Do not convert to `require()`.
- `publish` IPC handler looks up `existingR2Site` and `legacyPagesSite` from lowdb before calling `cloudflare.publish()`. This must happen before the call, not after.
- The backend stored on a site entry is the normalised value (`workers`/`r2`), never the raw payload — a legacy `pages` entry must flip to `workers` on its first re-publish, or the retire path would run again and try to delete a project that no longer exists.
- After a successful publish, `publishedAt` is preserved from the existing entry if updating. `updatedAt` is always overwritten.
- `validateStoredLicense()` is exported and called from `index.js`. Do not make it block window creation.
- An activated licence must survive a version upgrade with zero user action — an upgrade is just a launch, so any inconclusive validation result that clears `licenseStatus` reads to the user as the update logging them out. Only a definitive rejection from Lemon Squeezy revokes.
- `showToc`, `showToolbar` and `toolbarOptions` are persisted on the site entry at publish (commit `66ec00a`). Do not drop these fields from the upsert. For folder sites these are a cache of `cheldrop.yaml`, not the source.
- A folder site whose `cheldrop.yaml` fails to parse must fail the publish, not fall back to defaults. Publishing a guess would silently change what a client sees.
- The free-tier gates (no R2, one published site) are checked in the publish handler. The UI check is a courtesy; this one is the gate.
- The config write-back happens only after `result.ok`. A failed publish must not rewrite the folder's settings.
- `performPublish()` is the whole publish flow and takes an optional `onProgress`; the `publish` handler is a thin wrapper that pauses live watchers around it. Live sync calls `performPublish()` directly — logic added to the handler instead of the function silently does not apply to unattended publishes.
- The `publish` handler's `livesync.resume()` lives in a `finally`. A publish that throws must still resume the watchers.
- `delete-site` stops the site's watcher before removing the entry. A watcher outliving its site would try to republish it.
- The launch-at-login offer (`tray.maybePromptLoginItem()`) is called from `manualPublish` and the materialize handler, never from `performPublish`. Live now defaults on, so a site can become watched without the toggle ever being touched — but `performPublish` is also the unattended path, and a dialog belongs to a button press, not to a watcher firing on someone's machine at 3am. It is ask-once, so the extra call sites cost nothing.

### livesync.js
- The watcher passes **no** rendering or protection settings to `performPublish()` — undefined is what makes `cheldrop.yaml` the source. Passing cached values from the site entry would make an unattended publish disagree with a manual one.
- It passes no password. A protected site republishes on the Worker's existing auth record; sending a password would re-deploy the Worker on every content change.
- `DEBOUNCE_MS` and `MIN_INTERVAL_MS` are the guard against Cloudflare rate limits and Worker version churn on full-replacement publishes. Do not remove the cap to make sync feel faster.
- A `cheldrop.yaml` change is applied in `applyConfigChange()` on arrival, not in the publish run. Deferring it would leave Live on for up to a minute after the file turned it off.
- Zero publishable files is a no-op with a status, never a publish. A publish would wipe the live site.
- Watched sites must have `publishedAt`. Watching an adopted-but-unpublished folder would provision a site nobody asked to publish.
- `stopAll()` runs on `before-quit`; watchers hold fs handles and timers.

### sitefolder.js
- `RESERVED_NAMES` is the single definition of the excluded names; `renderer.js` imports it rather than repeating the strings.
- Secrets never enter `cheldrop.yaml`. `settingsToConfig` has no path for the token or a password — keep it that way.
- `settingsToConfig` treats an undefined setting as "keep the file's value". Coercing undefined to a default would make every partial write erase the user's yaml.
- `defaultLiveFor()` is consulted **only** where a folder is being created (`createSiteFolder`, `materializeSiteFolder`) and only when the caller passed no `live`. Moving it into `normalizeConfig` or `settingsToConfig` would turn a default into an override and switch Live on for every adopted folder that omits the key — including the ones on two machines.
- `readTheme` resolves `theme:` inside the folder only. A `theme: ../../.ssh/id_rsa` must return null, not a file.
- `readFolderFiles` walks with `fs.lstatSync`, not `fs.statSync`, and follows a symlink only when its real path is inside the folder's own real path. `stat` resolves links silently, so a single `ln -s ~/Documents refs` publishes that tree to a public URL. Both sides of the comparison must be real paths — a site folder in Dropbox or iCloud is itself reached through a link, and comparing the paths as written rejects its own contents.
- A directory whose real path is already open above it in that walk is a cycle (`ln -s .. loop`) and is not entered. The guard is the open chain, not every directory seen: a link to a sibling folder is not a cycle, and dropping it would publish the linked name and lose the real one, depending on `readdir` order.
- A missing folder still throws out of `readFolderFiles`. Returning an empty list instead would make a moved folder look like an empty one, and live sync treats empty as "do not publish" rather than as an error worth showing.
- Trashing a folder uses `shell.trashItem`. Never `fs.rm` a user's folder.

### reconcile.js / the reconcile handler
- Reconciliation is read-only against Cloudflare. No code path in it may create, change or delete a remote resource — cleaning up an orphan goes through import + the normal delete flow, where the confirmation already exists.
- `known: false` (the token cannot read that resource kind) must never produce a finding. Treating silence as absence would flag healthy sites as deleted and invite the user to delete them for real.
- One unreadable account makes the whole resource kind unknown. Names collected from the accounts that answered say nothing about the one that did not.
- `siteIdFromResourceName` only accepts a split that round-trips through `resourceNameFor`, against the account's own zones. Guessing where the dashes belong would import a site under the wrong domain.
- `forget-site` never calls Cloudflare and never touches the folder; `delete-site` is the one that deletes. Do not merge them.
- Imported entries keep `publishedAt: null`. Setting it would hand a free-tier user a second published site and let Live run on a folder that was never published. `importedAt` is what tells the UI the site is Published — see "Site status".
- A site claims its resource **name**, across every kind. Reverting to per-kind claiming re-offers a Workers site's leftover R2 bucket as an import of a site that already exists — a button whose only outcome is an error.

### license.js
- `VERSION_CUTOFF` is checked on both activation and validation. Do not remove either check.
- `getMachineId` stores a UUID in electron-store on first call. Do not change this — it would invalidate all existing activations.
- Offline errors return `{ error: 'offline' }` specifically. The 7-day grace period in `ipc.js` depends on this exact string.

### tray.js
- The tray's Quit is a `click` handler calling `app.quit()`, never `role: 'quit'`. The role bypasses nothing, but the habit of "just use the role" here would skip nothing today and skip `before-quit` cleanup the moment anything changes.
- `livesync.onStatus()` is what the tray listens to. Switching it to the renderer's `live-status` IPC event would make the agent silent exactly when it matters — no window open.
- One notification per site per error episode (`shouldNotify`). Notifying per retry would produce five notifications for one dropped Wi-Fi connection.
- Success is never notified. An agent that announces every publish is an agent people turn off.
- Template images must stay black + alpha with `setTemplateImage(true)`, and `build/tray` must stay in `extraResources` — a coloured or missing icon is an invisible or ugly menu bar item on one of the two macOS appearances.
- `app.getLoginItemSettings()` is the source of truth for launch-at-login. Do not mirror it into electron-store; only `loginItemPrompted` is stored.

### build-mac.sh / verify-mac.sh
- `build/build-number` is committed and monotonic. Resetting it, or letting two builds share a number, undoes the reason it exists.
- `main/build-info.json` is generated, never hand-edited, and never committed. It must stay inside the `files` glob or the packaged app cannot read its own build number.
- `verify-mac.sh` checks both version stamps. Do not relax those two checks to get a build out.

### index.js / updater.js
- `validateStoredLicense()` and `initAutoUpdater()` are both fire-and-forget after `createWindow()`. Neither may be awaited, and neither may move ahead of window creation. `tray.initTray()` and `livesync.sync()` join them under the same rule.
- The single-instance lock guards the watchers: a second copy would publish over the first. `second-instance` focuses the running window.
- `before-quit` stops the watchers and destroys the tray. Both hold OS handles.
- `window-all-closed` must keep the app alive on macOS. Quitting there would end live sync every time someone closes the window.
- `initAutoUpdater()` must never show a dialog — dialogs belong to the manual check only. A launch-time modal on someone else's machine is a bug, not a feature.
- The mac build must keep the `zip` target and the `publish` block. Dropping either silently kills auto-update for every installed copy, and there is no way to fix that remotely.
- `buildMenu()` replaces the default application menu. Keep the standard roles (`editMenu`/`viewMenu`/`windowMenu`) — removing them takes copy/paste and window management with them.

### App.jsx
- `hasToken` state determines Quick Start visibility. It's initialised to `true` (not `false`) to avoid a flash of the Quick Start nav item on load.
- Theme is initialised synchronously from `window.pubit.getTheme()` using a state initialiser function — do not move it to `useEffect`.
- `Publish.jsx` restores per-site `showToc`/`showToolbar`/`toolbarOptions` when an existing site is selected (lines 76–78). Do not regress when refactoring the site selector.
- `siteStatus()` in `Sites.jsx` is the one place a row's state is decided. Adding a second `publishedAt ? … : …` anywhere in the row is how "Draft" came back onto sites that were serving traffic.

---

## Roadmap

The roadmap and all feature briefs live in `docs/cheldrop-build-plan.md` (v2, 2 August 2026). Do not implement roadmap features without reading the corresponding plan section. Do not add docs page content or landing page changes related to MCP until the MCP feature is built and working.

*Amended 16 August 2026: polish plan C1 + C2 shipped — a drag-drop publish keeps its files as a site folder by default, offered as a normal toggle before the publish button rather than as small print after it, with a global default in Settings. Everything that describes the site now sits in one visible "Site settings" group in Publish, Live among it and settable before the first publish, and Live defaults on except in cloud-synced folders. See "Site folders", "Publish flow" and the build plan's Phase 4.2 amendment.*

*Amended 16 August 2026: polish plan A2 + A3 shipped — the Cloudflare scan now covers whole accounts (Pages pagination, R2-not-enabled read as an answer), claims resources by name so a site is never offered as an import of itself, and says what it scanned. Site status became a five-state model where anything serving on Cloudflare reads Published, never Draft. See "Cloudflare reconciliation" and "Site status" above.*

*Amended 14 August 2026: build plan Phase 6 shipped, out of order (before the beta) — read-only Cloudflare reconciliation. Sites now compares itself with the account: sites whose resources are gone are flagged with Re-publish / Forget, and `chel-` resources no entry claims can be imported as registry-only entries. See "Cloudflare reconciliation" above. `resourceNameFor()` in `cloudflare.js` is now the single definition of the join key.*

*Amended 14 August 2026: build plan Phase 4.3 shipped — the menu bar agent. Cheldrop now keeps running with no window, reports each Live site's state in the tray, notifies once per failure episode, and offers launch-at-login the first time a site goes Live. See "Background agent" above.*

*Amended 13 August 2026: build plan Phase 4.2 shipped — live sync. Folder sites with `live: true` are watched by `main/livesync.js` and republish themselves through the same `performPublish()` the Publish button uses, with settings taken entirely from `cheldrop.yaml`. See "Live sync" above. The `publish` IPC handler is now a wrapper that pauses watchers around that shared function.*

*Amended 13 August 2026: build plan Phase 4.1 shipped — site folders, `cheldrop.yaml` as the config source, `cheldrop.css` theme injection, adoption, and the folder keep-or-trash on delete. See "Site folders" above. Fixed alongside it: `licenseStatus` never reached `renderFiles` and seven wrapper/TOC call sites dropped `isBusinessClass`, so Business Class sites carried the free-tier branding footer.*

*Amended 13 August 2026: build plan Phase 3.5 shipped — the free tier publishes to Workers static assets instead of Cloudflare Pages; the token permission floor shrank to Zone:Read, DNS:Read, Workers Scripts:Edit (+ R2:Edit for Business Class), because Worker custom domains make Cloudflare write the DNS records.*

*Amended 3 August 2026: build plan Phase 3 shipped — R2 server-side password protection, client-side guard removed, R2 purge moved to the Cloudflare API.*

*Amended 1 August 2026: removed "Persistent per-site settings for TOC and Toolbar" from the former backlog — already built in commit `66ec00a` (30 March 2026). Amended 2 August 2026: backlog replaced by the build plan document.*
