diff --git a/docs/adding-a-dash-app.md b/docs/adding-a-dash-app.md
new file mode 100644
index 0000000..f5b2474
--- /dev/null
+++ b/docs/adding-a-dash-app.md
@@ -0,0 +1,211 @@
+# Adding a new dash-app
+
+This is the checklist for shipping a new web dashboard named `mytheme`. Every
+step below is required; skipping one produces a runtime warning or a build
+failure in a place you will not be looking at first.
+
+## 1. App directory
+
+Create `dash-apps/web-mytheme/` with three files:
+
+```
+dash-apps/web-mytheme/
+ index.html # the dashboard
+ manifest.json # app declaration (see below)
+ preview.jpg # landscape preview screenshot (~2:1, e.g. 2280x1080)
+```
+
+Minimal `manifest.json`:
+
+```json
+{
+ "$schema": "../../docs/dashapp-manifest.schema.json",
+ "manifestVersion": 1,
+ "id": "mytheme",
+ "settings": []
+}
+```
+
+- `id` must be lowercase, digits, and hyphens only (`^[a-z0-9-]+$`).
+- `id` must match the folder suffix: `web-` + `id` = `web-mytheme`.
+- `settings` entries must be chosen from the schema enum (see
+ `docs/dashapp-manifest.schema.json`).
+- `preview.jpg` is required by both sync scripts. Either missing file fails the
+ script.
+
+Validate before committing:
+
+```
+node scripts/validate-manifests.js
+```
+
+CI runs the same script on every push (`android-build.yml`), so a bad manifest
+fails the build either way — running it locally just fails faster.
+
+## 2. Android sync list
+
+`scripts/sync-to-android.sh` — add `web-mytheme` to the `DEFAULT_FOLDERS` array:
+
+```diff
+- DEFAULT_FOLDERS=(web-vanilla web-retro web-ambient web-analog)
++ DEFAULT_FOLDERS=(web-vanilla web-retro web-ambient web-analog web-mytheme)
+```
+
+`PREVIEW_FOLDERS` (next line) derives from `DEFAULT_FOLDERS`, so the preview
+JPEG copy is automatic: the preview loop copies
+`dash-apps/web-mytheme/preview.jpg` into
+`dashpilot-android/app/src/main/res/drawable/preview_mytheme.jpg`.
+
+## 3. Android registration
+
+`dashpilot-android/app/src/main/java/com/softwiredtech/dashpilot/datamodel/dash/DashboardConfig.kt`
+— add an entry to `availableDashboards`:
+
+```kotlin
+add(
+ DashboardConfig(
+ id = "mytheme",
+ nameRes = R.string.dashboard_name_mytheme,
+ url = "${LOCAL_ASSET_BASE_URL}web-mytheme/index.html",
+ type = DashboardType.WEB,
+ screenshotRes = R.drawable.preview_mytheme,
+ )
+)
+```
+
+The `id` must match the `id` field in `manifest.json`.
+
+## 4. Android string resource
+
+`dashpilot-android/app/src/main/res/values/strings.xml` — add the display name:
+
+```xml
+My Theme
+```
+
+The `name` attribute must match the `R.string.dashboard_name_mytheme` reference
+used in step 3.
+
+## 5. iOS sync list
+
+`scripts/sync-to-ios.sh` — add `web-mytheme` to `DEFAULT_FOLDERS`:
+
+```diff
+- DEFAULT_FOLDERS=(web-vanilla web-retro web-ambient web-analog)
++ DEFAULT_FOLDERS=(web-vanilla web-retro web-ambient web-analog web-mytheme)
+```
+
+The preview loop copies `preview.jpg` into
+`preview_mytheme.imageset/preview_mytheme.jpg` — the imageset directory must
+pre-exist (see step 7).
+
+## 6. iOS registration
+
+`dashpilot-ios/dashpilot/Models/DashboardConfig.swift` — add an entry to
+`availableDashboards`:
+
+```swift
+DashboardConfig(
+ id: "mytheme",
+ name: "My Theme",
+ url: "mytheme",
+ type: .web,
+ screenshotName: "preview_mytheme"
+),
+```
+
+- `id` must match the `id` field in `manifest.json`.
+- `url` is the folder name **without the `web-` prefix** — `WebDashView`
+ resolves it to the bundled `web-/` folder.
+- Add `"mytheme"` to `WebDashView.swift:88`'s `localApps` set — without this,
+ the iOS `app://` scheme handler skips the new dashboard and the WebView
+ silently fails to load.
+
+## 7. iOS imageset (Xcode asset catalog)
+
+Create the imageset directory:
+
+```
+dashpilot-ios/dashpilot/Assets.xcassets/preview_mytheme.imageset/
+ Contents.json
+ preview_mytheme.jpg
+```
+
+`Contents.json` — copy from an existing imageset and update the filename:
+
+```json
+{
+ "images" : [
+ {
+ "filename" : "preview_mytheme.jpg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
+```
+
+The sync script copies `dash-apps/web-mytheme/preview.jpg` into this directory
+(overwriting `preview_mytheme.jpg` on each run). The imageset **must pre-exist**
+or `sync-to-ios.sh` fails.
+
+## Performance gate
+
+New web dash-apps are benchmarked by CI by default — no `perf` field (or
+`"perf": { "gate": "standard" }`) means the app is gated the moment it exists.
+Your `index.html` must define `window.onCarStateUpdate` and read values from
+`window.NativeCarState`, and must pass the budgets in
+`dash-apps/_perf-harness/lib/budgets.json`. See "Performance requirements" in
+`dash-apps/README.md` for details, and run the identical check locally:
+
+```
+cd dash-apps/_perf-harness
+npm ci && npx playwright install chromium
+node run.js ../web-mytheme
+```
+
+## Local verification
+
+Run both sync scripts to catch missing files or bad paths before CI sees them:
+
+```
+./scripts/sync-to-android.sh web-mytheme
+./scripts/sync-to-ios.sh web-mytheme
+```
+
+Then build each platform to confirm the dashboard appears in the selector.
+
+## App types
+
+### Web app
+
+Use `DashboardType.WEB` / `DashboardType.web`. Must have a `manifest.json` and
+a three-file directory (step 1). This is the standard path.
+
+### Rive app
+
+Use `DashboardType.RIVE` / `DashboardType.rive`. Does **not** use a manifest.
+Place a `.riv` file and `preview.jpg` in `dash-apps/rive/`. Add the app to the
+registration lists (steps 3/6), skipping only the `DEFAULT_FOLDERS` sync lists:
+
+- The Android string resource (step 4) is **still required** — `nameRes` is
+ mandatory on `DashboardConfig`.
+- The Android preview copies automatically (`rive` is in `PREVIEW_FOLDERS`,
+ producing `preview_rive.jpg`). There is no iOS rive preview sync — create the
+ imageset (step 7) and copy the JPEG in manually.
+
+No production Rive app is registered today (only the debug-build `dev_rive`
+loader), so treat this path as unexercised and expect to fill gaps.
+
+## Notes
+
+- `web-expo` is a special case: it is not registered on either platform and is
+ excluded from both `DEFAULT_FOLDERS` lists, because it needs a build step
+ (`npx expo export --platform web`) before it can be served. Only its preview
+ ships, via `PREVIEW_FOLDERS` on Android. A normal production app goes in both
+ `DEFAULT_FOLDERS` lists.
+- Once `docs/dashapp-contract.md` exists, it documents the bridge API your
+ `index.html` can call.