diff --git a/CLAUDE.md b/CLAUDE.md
index 6d038a1..60e2517 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -254,44 +254,46 @@ Sidecars work for *remote* activities too: if you include someone else's BAS fee
you can write local `.md` sidecars for their activity IDs. Your render stage applies
your overrides on top of their data. This is a natural extension of the local case.
-### Editing UX: `bincio edit --serve`
+### Editing UX: drawer in Astro + `bincio edit` write API
-A separate FastAPI server (`bincio edit --serve`, default port 4041) handles all writes.
-The static site and Astro are untouched — no hybrid mode, no dead-code API routes in prod.
+The edit UI is a **slide-in drawer** (`EditDrawer.svelte`) in the Astro site.
+The drawer fetches from and POSTs to the `bincio edit` FastAPI server (write API only —
+the server no longer serves its own HTML UI).
**How it works:**
```
-bincio edit --serve --data ~/bincio_data # starts on :4041
+bincio render --serve # Astro dev server, port 4321
+bincio edit --data-dir ~/… # write API only, port 4041
```
-- Serves a bundled Svelte UI (single compiled HTML, reuses existing Svelte investment)
-- `GET /api/activity/{id}` — returns merged BAS JSON + existing sidecar fields
-- `POST /api/activity/{id}` — writes `edits/{id}.md` to the data dir
+- Edit button appears on the activity detail page **only when `PUBLIC_EDIT_URL` is set** in `site/.env`
+- Clicking Edit opens the drawer in the same page — no navigation, no copy-pasting IDs
+- Drawer fetches `GET /api/activity/{id}` to pre-fill, `POST /api/activity/{id}` to save
+- After save: server runs `merge_all()` automatically → Astro serves updated data immediately on refresh
+- Closing the drawer applies `title` + `description` changes optimistically to the local page state
+ (no full reload required to see the text change)
+
+**`PUBLIC_EDIT_URL` as feature flag:**
+- **Unset** → no Edit button, no drawer. Works as a normal static site. Safe for public hosting.
+- **Set** (e.g. `http://localhost:4041`) → editing enabled. Lives in `site/.env` (gitignored).
+ Each deployment opts in explicitly.
+
+**Edit server API (`bincio edit --data-dir
`):**
+- `GET /api/activity/{id}` — current values (sidecar overrides layered on BAS JSON)
+- `POST /api/activity/{id}` — write sidecar `.md`, trigger `merge_all()`
- `POST /api/activity/{id}/images` — multipart upload → `edits/images/{id}/{filename}`
-- The Astro dev server's file watcher picks up `.md` writes → incremental rebuild
+- `DELETE /api/activity/{id}/images/{filename}` — remove uploaded image
-**Edit UI features:**
-- Title text input (pre-filled from BAS JSON)
-- Sport dropdown (pre-filled, shows all known sport types)
-- Markdown textarea for description, with minimal toolbar (bold, italic, link, image insert)
-- Live markdown preview panel
-- `hide_stats` checkbox group: elevation, speed, heart_rate, cadence, power
-- `highlight` toggle (feature in feed)
-- `private` toggle (suppress from feed at render time)
-- Image drag-and-drop zone → uploads to `edits/images/{id}/`, inserts `![]()` into textarea
-- Save button → POST to API → success toast
+**Edit drawer features:**
+- Title, sport dropdown, gear
+- Markdown textarea for description (images inserted as `` references)
+- Image drag-and-drop zone with chip list + delete
+- Hide stat panels (elevation, speed, heart_rate, cadence, power) — toggle buttons
+- Highlight flag (★ — sorts to top of feed, visual badge)
+- Private flag (⊘ — suppressed from index at render time)
-**Workflow (typical):**
-1. User browses the Astro dev server on :4040
-2. Activity detail page has an "Edit" button (rendered only when `PUBLIC_EDIT_URL` env var is set)
-3. Button links to `:4041/edit/{id}` — opens the FastAPI-served edit UI
-4. User fills in form, saves → sidecar written → Astro rebuilds → refreshing :4040 shows changes
-
-The `PUBLIC_EDIT_URL` env var in `.env` controls whether the Edit button appears;
-leave it unset for production builds, set to `http://localhost:4041` for local dev.
-
-### Image storage
+### Image storage and serving
```
~/bincio_data/
@@ -304,17 +306,20 @@ leave it unset for production builds, set to `http://localhost:4041` for local d
```
Images are referenced in the markdown body with relative paths: ``.
-The render stage resolves relative image paths against `edits/images/{id}/` and copies them
-to `site/public/images/activities/{id}/` so they're served from the static site.
+`merge_all()` symlinks `edits/images/{id}/` → `_merged/activities/images/{id}/` so images
+are served at `data/activities/images/{id}/{filename}` by the Astro dev server.
+`ActivityDetail.svelte` rewrites relative image paths to this URL when rendering markdown.
+
+**Note:** browsers cannot display `.HEIC` files. Convert to JPEG/PNG first:
+`sips -s format jpeg photo.HEIC --out photo.jpg` (macOS).
### Decided
-- **Sidecar location**: `edits/` subdirectory (not co-located with JSON) — cleaner, easier to
- backup/sync just your customisations independently of the extracted data
-- **`private: true`**: suppresses from `index.json` at render time (not client-side hide) —
- safer for public hosting
-- **`highlight`**: visual badge in feed + sorted before non-highlighted activities
-- **Edit UI**: `bincio edit --serve` FastAPI server (Option B) — not integrated into Astro
+- **Sidecar location**: `edits/` subdirectory — cleaner, easier to backup/sync independently
+- **Merge output**: `data/_merged/` — extracted data stays pristine; `public/data` → `_merged/`
+- **`private: true`**: suppressed from `index.json` at render time (not client-side hide)
+- **`highlight`**: sorts to top of feed; visual badge TBD
+- **Edit UI**: drawer in Astro site, `bincio edit` is a pure write API (no HTML serving)
## Known issues / next steps
@@ -337,9 +342,12 @@ to `site/public/images/activities/{id}/` so they're served from the static site.
- [ ] Map thumbnail in activity cards (SVG path from GeoJSON)
- [ ] GitHub Actions template for auto-publish
- [ ] Karoo/Garmin Connect importers beyond Strava
-- [ ] `bincio.render.merge` module: walk `edits/`, parse sidecars, produce enriched data for Astro
-- [ ] `bincio render --watch` incremental rebuild on sidecar changes
-- [ ] Sidecar `.md` format: title, sport, description, hide_stats, highlight, private, images
-- [ ] `bincio edit --serve` FastAPI server with Svelte edit UI (port 4041)
-- [ ] Edit button on activity detail pages (visible when `PUBLIC_EDIT_URL` env var set)
-- [ ] Image upload → `edits/images/{id}/`, render stage copies to `public/images/activities/{id}/`
+- [x] `bincio.render.merge` — sidecar parser, `_merged/` output, private filter, highlight sort
+- [x] `bincio edit` FastAPI write API (GET/POST activity, image upload/delete, triggers merge)
+- [x] `EditDrawer.svelte` — slide-in edit UI in the Astro site (no separate HTML from server)
+- [x] `PUBLIC_EDIT_URL` feature flag — unset = no edit UI, set = drawer enabled
+- [x] Markdown rendering in activity description with image path rewriting
+- [x] `hide_stats` support in activity detail stats panel
+- [ ] `bincio render --watch` incremental rebuild on sidecar/data changes
+- [ ] Highlight badge in activity feed cards
+- [ ] Image format warning (HEIC → JPEG conversion hint in the upload UI)
diff --git a/bincio/cli.py b/bincio/cli.py
index 796c628..fe05826 100644
--- a/bincio/cli.py
+++ b/bincio/cli.py
@@ -13,6 +13,8 @@ def main() -> None:
from bincio.extract.cli import extract # noqa: E402
from bincio.render.cli import render # noqa: E402
+from bincio.edit.cli import edit # noqa: E402
main.add_command(extract)
main.add_command(render)
+main.add_command(edit)
diff --git a/bincio/edit/__init__.py b/bincio/edit/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/bincio/edit/cli.py b/bincio/edit/cli.py
new file mode 100644
index 0000000..4db0d6f
--- /dev/null
+++ b/bincio/edit/cli.py
@@ -0,0 +1,79 @@
+"""bincio edit — local edit server for activity sidecar files."""
+
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Optional
+
+import click
+from rich.console import Console
+
+console = Console()
+
+
+@click.command()
+@click.option("--data-dir", default=None,
+ help="BAS data store directory (output of bincio extract).")
+@click.option("--port", default=4041, show_default=True,
+ help="Port for the edit server.")
+@click.option("--site-url", default="http://localhost:4321", show_default=True,
+ help="URL of the Astro dev server (for the Back link).")
+@click.option("--config", "config_path", default=None,
+ help="Path to extract_config.yaml (reads output.dir from it).")
+def edit(
+ data_dir: Optional[str],
+ port: int,
+ site_url: str,
+ config_path: Optional[str],
+) -> None:
+ """Start a local web UI for editing activity sidecar files.
+
+ Writes sidecar .md files to /edits/ which bincio render picks
+ up and applies at build time.
+
+ Run alongside the Astro dev server:
+
+ \b
+ bincio render --serve # port 4321 (or npm run dev)
+ bincio edit # port 4041
+ """
+ try:
+ import uvicorn
+ except ImportError:
+ raise click.ClickException(
+ "uvicorn is required for the edit server.\n"
+ "Install with: uv add 'bincio[edit]'"
+ )
+
+ data = _resolve_data_dir(data_dir, config_path)
+ console.print(f"Data dir: [cyan]{data}[/cyan]")
+ console.print(f"Edit UI: [cyan]http://localhost:{port}/edit/[/cyan]")
+ console.print(f"Site URL: [cyan]{site_url}[/cyan]")
+ console.print("Press [bold]Ctrl+C[/bold] to stop.\n")
+
+ import bincio.edit.server as srv
+ srv.data_dir = data
+ srv.site_url = site_url
+
+ uvicorn.run(srv.app, host="127.0.0.1", port=port, log_level="warning")
+
+
+def _resolve_data_dir(explicit: Optional[str], config_path: Optional[str]) -> Path:
+ if explicit:
+ return Path(explicit).expanduser().resolve()
+
+ if config_path and Path(config_path).exists():
+ import yaml
+ raw = yaml.safe_load(Path(config_path).read_text())
+ out = raw.get("output", {}).get("dir")
+ if out:
+ return Path(out).expanduser().resolve()
+
+ default = Path.cwd() / "bincio_data"
+ if default.exists():
+ return default
+
+ raise click.UsageError(
+ "Could not find the BAS data directory. "
+ "Run `bincio extract` first, or pass --data-dir."
+ )
diff --git a/bincio/edit/server.py b/bincio/edit/server.py
new file mode 100644
index 0000000..1d1886d
--- /dev/null
+++ b/bincio/edit/server.py
@@ -0,0 +1,447 @@
+"""FastAPI edit server — serves the activity edit UI and writes sidecar .md files."""
+
+from __future__ import annotations
+
+import json
+import shutil
+from pathlib import Path
+from typing import Any
+
+from fastapi import FastAPI, File, HTTPException, UploadFile
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
+
+# Populated by the CLI before uvicorn starts
+data_dir: Path | None = None
+site_url: str = "http://localhost:4321"
+
+app = FastAPI(title="BincioActivity Edit Server", docs_url=None, redoc_url=None)
+
+# Allow the Astro dev server (and any local origin) to call the write API
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"],
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+SPORTS = ["cycling", "running", "hiking", "walking", "swimming", "skiing", "other"]
+STAT_PANELS = ["elevation", "speed", "heart_rate", "cadence", "power"]
+
+
+# ── HTML UI ───────────────────────────────────────────────────────────────────
+
+_HTML = """\
+
+
+
+
+
+Edit Activity
+
+
+
+