diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index fb54641..c012926 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -8,3 +8,4 @@
- [ ] Any new runtime file/dir is added to the `cp` list in `.github/workflows/pages.yml`
- [ ] No new build step, framework, or runtime CDN dependency
- [ ] If `devices.json` changed: `python scripts/validate_registry.py` passes
+- [ ] If a firmware or config URL changed: it is the build the product repo's classic installer flashes, `config` points at the YAML that builds it, and the first (default) variant is the one most people need
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..aa5ce79
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,7 @@
+# Keep the SHA-pinned workflow actions current, like the product repos.
+version: 2
+updates:
+ - package-ecosystem: github-actions
+ directory: /
+ schedule:
+ interval: monthly
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
index 3533fde..332af81 100644
--- a/.github/workflows/pages.yml
+++ b/.github/workflows/pages.yml
@@ -10,9 +10,10 @@ permissions:
pages: write
id-token: write
+# Let an in-flight production deploy finish; a newer push queues behind it.
concurrency:
group: pages
- cancel-in-progress: true
+ cancel-in-progress: false
jobs:
deploy:
@@ -21,13 +22,23 @@ jobs:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
+ - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
+ with:
+ python-version: "3.12"
+ # Network-free gate: a malformed registry (duplicate id, missing image,
+ # manifest outside its repo's Pages site) never reaches production, even
+ # when a PR merged with red or stale checks.
+ - name: Check registry (offline)
+ run: |
+ python scripts/test_validate_registry.py
+ python scripts/validate_registry.py --offline
- name: Stage site files
run: |
mkdir _site
cp -r index.html css js images assets vendor devices.json _site/
- - uses: actions/upload-pages-artifact@v3
+ - uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1
with:
path: _site
- id: deployment
- uses: actions/deploy-pages@v4
+ uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5
diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml
index 389532a..69336ed 100644
--- a/.github/workflows/validate.yml
+++ b/.github/workflows/validate.yml
@@ -2,38 +2,76 @@ name: Validate
on:
pull_request:
+ push:
+ branches: [main] # test the merged result, not just the PR head
workflow_dispatch:
schedule:
- cron: "17 6 * * *" # nightly registry check
+permissions:
+ contents: read
+
jobs:
registry:
runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ issues: write # the nightly opens an issue when upstream firmware breaks
steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-python@v5
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
+ - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
- name: Validator unit tests (offline)
run: python scripts/test_validate_registry.py
- name: Image script unit tests (offline)
run: python scripts/test_fetch_images.py
- - run: python scripts/validate_registry.py
+ - name: Registry checks (offline)
+ run: python scripts/validate_registry.py --offline
+ - name: Registry checks (live manifests, configs and installers)
+ shell: bash
+ run: python scripts/validate_registry.py 2>&1 | tee validate.log
+ # Nightly failures otherwise only email whoever last edited the cron line.
+ - name: Report nightly failure
+ if: failure() && github.event_name == 'schedule'
+ shell: bash
+ env:
+ GH_TOKEN: ${{ github.token }}
+ RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+ run: |
+ title="Nightly registry check failed"
+ log=$(tail -n 40 validate.log 2>/dev/null)
+ body=$(printf 'The nightly registry check failed: %s\n\n```\n%s\n```\n' "$RUN_URL" "${log:-No validator output; an earlier step failed. See the run.}")
+ num=$(gh issue list --state open --search "\"$title\" in:title" --json number --jq '.[0].number // empty')
+ if [ -n "$num" ]; then
+ gh issue comment "$num" --body "$body"
+ else
+ gh issue create --title "$title" --body "$body"
+ fi
ui-tests:
- if: github.event_name == 'pull_request'
+ if: github.event_name != 'schedule'
runs-on: ubuntu-latest
defaults:
run:
working-directory: tests
steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-python@v5
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
+ - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
- - uses: actions/setup-node@v4
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "22"
+ cache: npm
+ cache-dependency-path: tests/package-lock.json
- run: npm ci
- run: npx playwright install chromium --with-deps
- run: npx playwright test
+ - name: Keep traces of failed tests
+ if: failure()
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+ with:
+ name: playwright-results
+ path: tests/test-results/
+ retention-days: 14
diff --git a/AGENTS.md b/AGENTS.md
index d4394e7..939379c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -3,11 +3,13 @@
Static site, **no build step** — do not add bundlers, frameworks, or npm deps outside `tests/`.
- Serve: `python -m http.server 8123` from repo root.
-- Test: `cd tests && npx playwright test` (fails confusingly if run from repo root). Registry-driven suite.
-- Validate registry: `python scripts/validate_registry.py` (network, ~60s).
+- Test: `cd tests && npx playwright test` (fails confusingly if run from repo root). Registry-driven suite; it stubs GitHub, manifests and configs, so it runs offline.
+- Python unit tests: `python scripts/test_validate_registry.py` and `python scripts/test_fetch_images.py` (offline).
+- Validate registry: `python scripts/validate_registry.py` (network, ~15s); `--offline` for the shape checks alone.
- `devices.json` is the single source of truth; views, scripts, and tests all read it. Device ids are lowercase-kebab and become URL hashes (`#/air-1`) and image filenames.
-- Per-variant overrides in `devices.json` all follow one shape: `channel -> variant -> value`, listing only the variants that differ from the device-level field. `repos` overrides `repo`, `installers` overrides `githubPagesInstaller` (`null` hides the link), `platforms` overrides `platform` (`esphome` default, or `wled`, which selects the step-3 wording). `variantNotes` is the same shape but adds rather than overrides: a sentence or two under the variant toggle saying when to pick that variant. The device-level field stays the fallback, so a device that ships one ecosystem today needs no map.
+- Every device needs `id`, `name`, `category` (hub filter pill), `description`, `image`, `wiki`, `repo`, and `firmware` (`channel -> variant -> manifest URL`), and normally `imageSource` (input to `fetch_images.py`) and `githubPagesInstaller`. `stable` is required and preferred; the first variant key is the default selection. Manifests must live on the variant's own repo's GitHub Pages site and be one of the builds that repo's classic installer page offers (the validator checks both, and that no two variants share a manifest; it can't catch two of the page's builds swapped between variants, so check the mapping by hand). Optional `config` has the same shape as `firmware` and points at the raw YAML that builds each manifest.
+- Per-variant overrides in `devices.json` all follow one shape: `channel -> variant -> value`, listing only the variants that differ from the device-level field. `repos` overrides `repo`, `installers` overrides `githubPagesInstaller` (`null` hides the link), `platforms` overrides `platform`, which selects the step-3 wording: `esphome` (default), `wled`, or `standalone` (no network at all, e.g. the ornaments' Basic firmware: step 3 says there is nothing to add). `variantNotes` is the same shape but adds rather than overrides: a sentence or two under the variant toggle saying when to pick that variant. The device-level field stays the fallback, so a device that ships one ecosystem today needs no map.
- **Deploy gotcha:** `.github/workflows/pages.yml` copies an explicit file list into `_site/`. Any new runtime file or directory must be added there or it 404s in production while working locally.
- `vendor/esp-web-tools/` is a pinned upstream copy — never hand-edit.
-- Security stance: registry fields are trusted repo content; runtime-fetched content (GitHub releases, manifest-derived filenames) must go through the existing escaping (`esc()`, `encodeURI`) in `js/views/device.js`.
-- Async renders in `js/views/device.js` guard against stale writes (variant/device changed mid-fetch). If you add an async fetch there, follow the same pattern — a wrong-firmware link is this site's worst failure mode.
+- Security stance: registry fields are trusted repo content (`description` and `variantNotes` are rendered as HTML); runtime-fetched content (GitHub releases, manifest-derived URLs and filenames, the URL hash) must be escaped with `esc()` or set via `textContent`, and manifest part URLs are parsed with `URL` and limited to https (see `js/views/device.js`).
+- Async renders in `js/views/device.js` guard against stale writes (variant/device changed mid-fetch). If you add an async fetch there, follow the same pattern — a wrong-firmware link is this site's worst failure mode. Release notes are shown only when the release matches the manifest's version, for the same reason.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index ae70ba2..d26f52a 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -6,7 +6,7 @@
python -m http.server 8123
```
-from the repo root, then open http://localhost:8123. No build step — there deliberately isn't one. localhost is a secure context, so real USB flashing works in Chrome/Edge.
+from the repo root, then open http://localhost:8123. No build step — there deliberately isn't one. localhost is a secure context, so real USB flashing works in Chrome, Edge, or Firefox.
## Tests
@@ -16,11 +16,25 @@ npm ci
npx playwright test
```
-Run from `tests/`, not the repo root. PRs run this suite plus the registry validator (`python scripts/validate_registry.py`) in CI.
+Run from `tests/`, not the repo root. The suite stubs GitHub, manifests and configs, so it runs offline and never spends GitHub API quota.
+
+From the repo root:
+
+```
+python scripts/test_validate_registry.py # validator unit tests (offline)
+python scripts/test_fetch_images.py # image script unit tests (offline)
+python scripts/validate_registry.py --offline # registry shape only, no network
+python scripts/validate_registry.py # plus live manifests, configs, wiki and classic installers (~15s)
+```
+
+CI runs all of these on every PR and again on `main` after merge. The deploy re-runs the offline checks, so a malformed registry never reaches production.
## Adding a device
-One entry in `devices.json` (id must be lowercase-kebab; `firmware.stable` pointing at the product repo's GitHub Pages manifest) + one image in `images/` (`python scripts/fetch_images.py` downloads it from the `imageSource` URL). The tests are registry-driven and cover new devices automatically.
+1. **Pick the firmware.** Use the manifest(s) the product repo's own classic installer page flashes. When a repo publishes several builds, that is usually the improv-enabled `firmware-factory/` build, not `firmware/`: without improv the installer can't offer Wi-Fi setup after flashing. The validator fails if the registry points at a build the classic page doesn't offer, but it can't tell which of the page's builds belongs to which variant, so check that by hand.
+2. **Add one entry to `devices.json`.** The id is lowercase-kebab; it becomes the URL hash (`#/air-1`) and the image filename. Set `config` to the YAML that builds that same manifest. The first variant listed is the one selected on load, so put support-only or legacy builds after it. For the optional per-variant fields (`variantNotes`, `platform`/`platforms`, `repos`, `installers`) see `AGENTS.md`.
+3. **Add the image.** Run `python scripts/fetch_images.py `. Shopify sources are downscaled automatically; for any other source, save a ~600px JPEG as `images/.jpg` yourself and the script keeps it.
+4. **Check it.** Run `python scripts/validate_registry.py` and the Playwright suite. The tests are registry-driven and visit every device and variant automatically.
## Rules that will save you a red X
@@ -29,4 +43,4 @@ One entry in `devices.json` (id must be lowercase-kebab; `firmware.stable` point
- Registry content (`devices.json`) is trusted; anything fetched at runtime (GitHub API, manifests) must be escaped/sanitized before touching the DOM — see `esc()` in `js/views/device.js`.
- This site never builds or hosts firmware. Firmware changes belong in the product repos.
-PRs target `main`. Merging deploys to https://apolloautomation.github.io/installer/.
+PRs target `main`. Merging deploys to https://install.apolloautomation.com.
diff --git a/README.md b/README.md
index 90597bf..b4673d2 100644
--- a/README.md
+++ b/README.md
@@ -3,9 +3,9 @@
Central browser installer for Apollo devices — https://install.apolloautomation.com
- Hub of every flashable Apollo device → guided 3-step install wizard.
-- Static site, no build step. Serve locally: `python -m http.server 8123` then open http://localhost:8123 (localhost is a secure context, so real flashing works in Chrome/Edge).
+- Static site, no build step. Serve locally: `python -m http.server 8123` then open http://localhost:8123 (localhost is a secure context, so real flashing works in Chrome, Edge, or Firefox).
- All device knowledge lives in `devices.json`. Adding a device = one JSON entry + one image.
- Firmware comes from each product repo's existing GitHub Pages manifests — this repo never builds or hosts firmware.
- Existing per-repo installer pages stay standalone; this site is the promoted front door, they are the fallback.
-Design spec: `docs/superpowers/specs/2026-07-08-apollo-web-installer-design.md`
+How to add a device and run the checks: `CONTRIBUTING.md`. Original design (historical): `docs/superpowers/specs/2026-07-08-apollo-web-installer-design.md`
diff --git a/css/style.css b/css/style.css
index 910e5bd..e8e1151 100644
--- a/css/style.css
+++ b/css/style.css
@@ -1,6 +1,7 @@
:root {
--blue: #417AAB;
- --blue-dark: #35638b;
+ --link: #35638b; /* text links: >= 4.5:1 on the ground and the tinted boxes */
+ --fill: #417AAB; /* filled controls with white text */
--green: #9ABD32;
--ink: #22303c;
--dim: #5c6b7a;
@@ -12,10 +13,12 @@
--bg-green-tint: #f5f9ec;
--bg-error: #faf0ed;
--border-error: #c0563c;
+ color-scheme: light;
}
[data-theme="dark"] {
--blue: #5b9fd4;
- --blue-dark: #4a87bb;
+ --link: #5b9fd4;
+ --fill: #2f6fa3; /* #5b9fd4 is only 2.9:1 under white text */
--green: #a8cf35;
--ink: #e4edf5;
--dim: #8fa0b3;
@@ -27,13 +30,14 @@
--bg-green-tint: #0c1a06;
--bg-error: #1e0e0a;
--border-error: #d06550;
+ color-scheme: dark;
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--ground); color: var(--ink);
font-family: "Segoe UI", system-ui, -apple-system, sans-serif; line-height: 1.55;
}
-a { color: var(--blue); }
+a { color: var(--link); }
/* Branded keyboard-focus ring (only on keyboard focus, not mouse clicks).
Outline follows each control's own border-radius, so pills stay round. */
a:focus-visible,
@@ -42,12 +46,14 @@ summary:focus-visible {
outline: 2px solid var(--blue);
outline-offset: 2px;
}
+/* Headings get programmatic focus on route changes; no ring for those. */
+[tabindex="-1"]:focus { outline: none; }
.site-header {
- display: flex; justify-content: space-between; align-items: center;
+ display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 8px 16px;
padding: 14px 24px; background: var(--panel); border-bottom: 1px solid var(--line);
}
-.brand { display: flex; align-items: center; gap: 10px; font-weight: 700; text-decoration: none; color: var(--blue); font-size: 1.05rem; }
-.site-header nav { display: flex; gap: 18px; align-items: center; }
+.brand { display: flex; align-items: center; gap: 10px; font-weight: 700; text-decoration: none; color: var(--blue); font-size: 1.05rem; white-space: nowrap; }
+.site-header nav { display: flex; flex-wrap: wrap; gap: 18px; align-items: center; }
.site-header nav a { color: var(--dim); text-decoration: none; }
.theme-btn {
position: relative; width: 30px; height: 30px; border: none; padding: 0; cursor: pointer;
@@ -67,8 +73,9 @@ summary:focus-visible {
backing it already has on the (white) light-mode header so it stays legible. */
[data-theme="dark"] .theme-btn { background-color: #fff; border-radius: 6px; }
main { max-width: 1080px; margin: 0 auto; padding: 32px 24px 64px; }
-.site-footer { text-align: center; color: var(--dim); font-size: .85rem; padding: 24px; border-top: 1px solid var(--line); }
.loading { color: var(--dim); }
+.hint { color: var(--dim); font-size: .85rem; }
+.notice { border-left: 3px solid var(--blue); background: var(--bg-blue-tint); border-radius: 0 8px 8px 0; padding: 10px 14px; margin: 0 0 18px; }
/* Hub */
.hero h1 { font-size: 1.9rem; margin: 0 0 6px; letter-spacing: -.01em; }
@@ -78,14 +85,15 @@ main { max-width: 1080px; margin: 0 auto; padding: 32px 24px 64px; }
border: 1px solid var(--line); background: var(--panel); color: var(--dim);
border-radius: 999px; padding: 5px 14px; font-size: .85rem; cursor: pointer;
}
-.filters button.active { background: var(--blue); border-color: var(--blue); color: #fff; font-weight: 600; }
+.filters button.active { background: var(--fill); border-color: var(--fill); color: #fff; font-weight: 600; }
.device-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 14px; }
.device-card {
display: block; background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
padding: 14px; text-decoration: none; color: inherit; transition: border-color .15s, box-shadow .15s;
}
.device-card:hover { border-color: var(--blue); box-shadow: 0 4px 14px -8px rgba(65,122,171,.5); }
-.device-card img { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 6px; background: var(--img-bg); }
+/* contain, not cover: a few product shots aren't square and carry spec text at the edges. */
+.device-card img { width: 100%; aspect-ratio: 1; object-fit: contain; border-radius: 6px; background: var(--img-bg); }
.device-card h3 { margin: 10px 0 2px; font-size: 1rem; }
.device-card p { margin: 0; color: var(--dim); font-size: .82rem; }
.device-card .go { color: var(--blue); font-weight: 600; font-size: .85rem; margin-top: 8px; display: inline-block; }
@@ -93,24 +101,28 @@ main { max-width: 1080px; margin: 0 auto; padding: 32px 24px 64px; }
/* Device wizard */
.device-page .back { display: inline-block; margin-bottom: 16px; text-decoration: none; color: var(--dim); }
.device-head { display: flex; gap: 20px; align-items: center; margin-bottom: 20px; }
-.device-head img { width: 110px; height: 110px; object-fit: contain; background: var(--img-bg); border: 1px solid var(--line); border-radius: 10px; }
+.device-head img { width: 110px; height: 110px; flex: none; object-fit: contain; background: var(--img-bg); border: 1px solid var(--line); border-radius: 10px; }
.device-head h1 { margin: 0; font-size: 1.6rem; }
.device-head p { margin: 2px 0 6px; color: var(--dim); }
.device-head .links { font-size: .85rem; }
.step { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 18px 20px; margin-bottom: 14px; }
.step h2 { margin: 0 0 10px; font-size: 1.05rem; display: flex; align-items: center; gap: 10px; }
.step h2 .num {
- width: 26px; height: 26px; border-radius: 50%; background: var(--blue); color: #fff;
+ width: 26px; height: 26px; border-radius: 50%; background: var(--fill); color: #fff;
display: inline-grid; place-items: center; font-size: .85rem; flex: none;
}
.picker { display: flex; gap: 24px; flex-wrap: wrap; }
-.picker .group label { display: block; font-size: .78rem; color: var(--dim); margin-bottom: 5px; text-transform: uppercase; letter-spacing: .04em; }
+.picker .seg-label { display: block; font-size: .78rem; color: var(--dim); margin-bottom: 5px; text-transform: uppercase; letter-spacing: .04em; }
.seg { display: inline-flex; flex-wrap: wrap; max-width: 100%; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }
.seg button { border: 0; background: var(--panel); color: var(--dim); padding: 7px 16px; font-size: .88rem; cursor: pointer; }
-.seg button.active { background: var(--blue); color: #fff; font-weight: 600; }
+.seg button.active { background: var(--fill); color: #fff; font-weight: 600; }
+/* .seg clips overflow for its rounded corners, so draw the focus ring inside the button. */
+.seg button:focus-visible { outline-offset: -4px; outline-color: currentColor; }
.variant-note { color: var(--dim); font-size: .88rem; margin: 8px 0 4px; max-width: 60ch; }
+.single-note { margin: 0 0 4px; font-size: inherit; }
+.install-hint { margin: 10px 0 0; }
esp-web-install-button button, .install-btn {
- background: var(--blue); color: #fff; border: 0; border-radius: 8px;
+ background: var(--fill); color: #fff; border: 0; border-radius: 8px;
padding: 10px 22px; font-size: .95rem; font-weight: 700; cursor: pointer;
}
.fallback { border-left: 3px solid var(--blue); background: var(--bg-blue-tint); border-radius: 0 8px 8px 0; padding: 12px 16px; }
@@ -120,7 +132,7 @@ esp-web-install-button button, .install-btn {
.release-notes pre { white-space: pre-wrap; font-family: inherit; margin: 8px 0; }
.done-check { color: var(--green); font-weight: 700; }
.customize { margin: 4px 0 14px; font-size: .9rem; }
-.customize summary { cursor: pointer; color: var(--blue); font-weight: 600; }
+.customize summary { cursor: pointer; color: var(--link); font-weight: 600; }
.customize p { margin: 8px 0 0; color: var(--dim); }
.customize code { background: var(--bg-blue-tint); padding: 1px 5px; border-radius: 4px; }
.error-box { border-left: 3px solid var(--border-error); background: var(--bg-error); border-radius: 0 8px 8px 0; padding: 12px 16px; }
@@ -133,6 +145,20 @@ esp-web-install-button button, .install-btn {
.config-yaml { margin: 0; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px; overflow: auto; max-height: 340px; }
.config-yaml code { font-family: ui-monospace, Consolas, monospace; font-size: .8rem; line-height: 1.5; white-space: pre; color: var(--ink); }
.config-actions { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-top: 12px; }
-.config-download { background: var(--blue); color: #fff; border: 0; border-radius: 8px; padding: 9px 18px; font-size: .9rem; font-weight: 700; cursor: pointer; }
+.config-download { background: var(--fill); color: #fff; border: 0; border-radius: 8px; padding: 9px 18px; font-size: .9rem; font-weight: 700; cursor: pointer; }
.config-download:disabled { opacity: .55; cursor: default; }
-.config-github { font-size: .86rem; font-weight: 600; }
+.config-github, .config-folder { font-size: .86rem; font-weight: 600; }
+
+/* Phones: tighter gutters, two device cards per row, and a header that wraps
+ instead of pushing the page sideways. */
+@media (max-width: 480px) {
+ .site-header { padding: 10px 16px; }
+ .site-header nav { gap: 14px; font-size: .92rem; }
+ main { padding: 24px 16px 48px; }
+ .hero h1 { font-size: 1.6rem; }
+ .device-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 10px; }
+ .device-card { padding: 10px; }
+ .device-head { gap: 14px; }
+ .device-head img { width: 84px; height: 84px; }
+ .step { padding: 16px; }
+}
diff --git a/devices.json b/devices.json
index f690574..25f6e5c 100644
--- a/devices.json
+++ b/devices.json
@@ -84,6 +84,12 @@
"WiFi": "https://raw.githubusercontent.com/ApolloAutomation/R_PRO-1/main/Integrations/ESPHome/R_PRO-1_W.yaml",
"Ethernet": "https://raw.githubusercontent.com/ApolloAutomation/R_PRO-1/main/Integrations/ESPHome/R_PRO-1_ETH.yaml"
}
+ },
+ "variantNotes": {
+ "stable": {
+ "WiFi": "Connects over Wi-Fi. After installing, the installer asks for your Wi-Fi network.",
+ "Ethernet": "For a wired or PoE connection, with no Wi-Fi. Connect the Ethernet cable; the installer won't ask for a Wi-Fi network."
+ }
}
},
{
@@ -272,6 +278,13 @@
"stable": {
"WLED 16.0.1 (Rev6 only)": null
}
+ },
+ "variantNotes": {
+ "stable": {
+ "WLED 16.0.1 (Rev6 only)": "For Rev6 controllers. The revision is printed on the back of the board; on a Rev4, pick WLED-MM 14.5.1 (Rev4) instead.",
+ "WLED-MM 14.5.1 (Rev6)": "The earlier WLED-MM firmware, for Rev6 controllers.",
+ "WLED-MM 14.5.1 (Rev4)": "For Rev4 controllers, which can't run WLED 16.0.1."
+ }
}
},
{
@@ -296,7 +309,7 @@
},
"variantNotes": {
"stable": {
- "Smart (Wi-Fi)": "Joins your Wi-Fi and Home Assistant: change songs and LED effects, run automations, use it as a Bluetooth proxy. Sleeps between button presses to save battery."
+ "Smart (Wi-Fi)": "Joins your Wi-Fi and Home Assistant: change songs and LED effects and run automations. It stays awake by default; turn off Prevent Sleep in Home Assistant to save battery between button presses."
}
}
},
@@ -324,8 +337,13 @@
},
"variantNotes": {
"stable": {
- "Smart (Wi-Fi)": "Joins your Wi-Fi and Home Assistant: change songs and LED effects, run automations, use it as a Bluetooth proxy. Sleeps between button presses to save battery.",
- "Basic (No Wi-Fi)": "The firmware the ornament ships with. No Wi-Fi or Home Assistant; the buttons play the built-in tunes. Install this to put the ornament back to stock."
+ "Smart (Wi-Fi)": "Joins your Wi-Fi and Home Assistant: change songs and LED effects and run automations. After installing, restart it (remove power for a moment), then join the open Apollo H2 Hotspot Wi-Fi network from your phone to pick your Wi-Fi. It stays awake by default; turn off Prevent Sleep in Home Assistant to save battery between button presses.",
+ "Basic (No Wi-Fi)": "The firmware the ornament ships with. No Wi-Fi or Home Assistant: the buttons play the built-in tunes, and it sleeps between presses to save battery. Install this to put the ornament back to stock."
+ }
+ },
+ "platforms": {
+ "stable": {
+ "Basic (No Wi-Fi)": "standalone"
}
}
},
@@ -353,8 +371,13 @@
},
"variantNotes": {
"stable": {
- "Smart (Wi-Fi)": "Joins your Wi-Fi and Home Assistant: change songs and LED effects, run automations, use it as a Bluetooth proxy. Sleeps between button presses to save battery.",
- "Basic (No Wi-Fi)": "The firmware the ornament ships with. No Wi-Fi or Home Assistant; the buttons play the built-in tunes. Install this to put the ornament back to stock."
+ "Smart (Wi-Fi)": "Joins your Wi-Fi and Home Assistant: change songs and LED effects, run automations, and turn on its Bluetooth Proxy switch to use it as a Bluetooth proxy. It stays awake by default; turn off Prevent Sleep in Home Assistant to save battery between button presses.",
+ "Basic (No Wi-Fi)": "The firmware the ornament ships with. No Wi-Fi or Home Assistant: the buttons play the built-in tunes, and it sleeps between presses to save battery. Install this to put the ornament back to stock."
+ }
+ },
+ "platforms": {
+ "stable": {
+ "Basic (No Wi-Fi)": "standalone"
}
}
}
diff --git a/docs/m1-wled-migration.md b/docs/m1-wled-migration.md
index 6725296..2e7d679 100644
--- a/docs/m1-wled-migration.md
+++ b/docs/m1-wled-migration.md
@@ -10,15 +10,17 @@ The M-1 offers two WLED firmwares in the installer, both served from GitHub Page
WLED 16.0.1 is the default. It is a single merged full-install image (bootloader, partition
table, otadata, application, and LittleFS factory config) built from ApolloAutomation/WLED-M1.
-Its manifest sets new_install_prompt_erase, so esp-web-tools prompts a full erase, which is what
-guarantees a factory-fresh 64x64 boot. It is offered for Rev6 only, so Rev4 owners must switch
-to the WLED-MM 14.5.1 (Rev4) variant.
+The full image is what gives a factory-fresh 64x64 boot: it covers flash up to 0xff0000, leaving
+only the coredump partition. Its manifest sets new_install_prompt_erase, which makes esp-web-tools
+*offer* a full erase as an unticked checkbox rather than always erasing. It is offered for Rev6
+only, so Rev4 owners must switch to the WLED-MM 14.5.1 (Rev4) variant; the `variantNotes` under the
+toggle say where the revision is printed.
The M-1 entry sets `"platform": "wled"`, so step 3 of the wizard gives the WLED onboarding
(open Apollo M-1 hotspot at 4.3.2.1, discovered WLED integration, WLED web UI for effects and
-manual OTA) instead
-of the ESPHome Dashboard "Take control" flow. The hardware can run ESPHome, we just do not offer
-an ESPHome build yet. When we do, add that manifest as another variant and mark it in a
+manual OTA) instead of the ESPHome Device Builder "Take control" flow. Step 3 does not name an OTA
+file, because the only `_ota.bin` published (WLED-M1's M-1_ota.bin) is a Rev6 image. The hardware
+can run ESPHome, we just do not offer an ESPHome build yet. When we do, add that manifest as another variant and mark it in a
`platforms` map (`"platforms": { "stable": { "": "esphome" } }`); step 3 then switches
back to the ESPHome instructions whenever that variant is selected, and stays WLED for the rest.
@@ -26,8 +28,14 @@ Because the default variant has no classic installer page of its own, the M-1 pa
the "Classic installer" link hidden and the header GitHub link pointing at WLED-M1; both come
back when a 14.5.1 variant is selected.
-Customers already on WLED-MM 14.5.1 can OTA to 16.0.1 with the M-1_ota.bin app-only image
-(same partition table preserves settings); the installer only serves the first-flash full image.
+Rev6 owners already on WLED-MM 14.5.1 can OTA to 16.0.1 with the M-1_ota.bin app-only image
+(same partition table preserves settings). Rev4 owners must not: M-1_ota.bin uses the Rev6
+(MOONHUB) pinout. The installer only serves the first-flash full image.
+
+Release notes: WLED-MM-M1's only GitHub release (25.7.14.1) predates both builds its manifests
+serve (25.8.19.1 for Rev4, 25.10.9.1 for Rev6), so the installer shows "Installs firmware version
+..." for those variants instead of that release's notes. Publishing releases for those versions
+brings the notes back with no installer change.
Hosting: WLED-MM-M1 serves the 14.5.1 manifests from committed files via legacy branch Pages.
WLED-M1 serves the 16.0.1 manifest through an Actions Pages workflow
@@ -36,9 +44,11 @@ from the latest GitHub release, so the 16MB image never enters git history.
## Setup hotspot (verified against the shipped images, not WLED defaults)
-Both the 16.0.1 and 14.5.1 Apollo builds broadcast an **open** hotspot, not the stock WLED
-`WLED-AP` / `wled1234` pair. The string `wled1234` is absent from `M-1_full_install.bin` and from
+Both the 16.0.1 and 14.5.1 Apollo builds broadcast an **open** hotspot named `Apollo M-1`, not
+the stock WLED `WLED-AP` / `wled1234` pair. The string `wled1234` is absent from `M-1_full_install.bin` and from
`Apollo_M-1_Rev6_14.5.1.bin`, while the sibling default `wledota` is present in both, so `apPass`
is empty. The m1-b8 release notes state it directly: "STEP 1: join the open Apollo M-1 WiFi", and
list "cleaner hotspot name: the setup WiFi is now just Apollo M-1 (no serial-number suffix)" as new
-in that build, which is why step 3 says older builds carry a suffix. Setup address is 4.3.2.1.
+in that build. That compares b8 with the earlier 16.0.1 bundles (b1-b7); the WLED-MM 14.5.1 build
+never added a suffix (its env sets `WLED_AP_SSID` without `WLED_AP_SSID_UNIQUE`), so step 3 names
+plain `Apollo M-1`. Setup address is 4.3.2.1.
diff --git a/docs/superpowers/specs/2026-07-08-apollo-web-installer-design.md b/docs/superpowers/specs/2026-07-08-apollo-web-installer-design.md
index 83aa18c..fc3903d 100644
--- a/docs/superpowers/specs/2026-07-08-apollo-web-installer-design.md
+++ b/docs/superpowers/specs/2026-07-08-apollo-web-installer-design.md
@@ -1,5 +1,11 @@
# Apollo Web Installer — Design
+> **Historical.** This is the original design. For current behavior see AGENTS.md, CONTRIBUTING.md
+> and the code. Since then: Firefox flashes too (it ships WebSerial), TEMP_PRO-1 is in the
+> registry, the first-install build is whichever one the product repo's classic installer flashes
+> (often `firmware-factory/`, not `firmware/`), and `apolloautomation.github.io/installer/` now
+> redirects to production, so it is no longer a staging URL.
+
Date: 2026-07-08
Status: Approved by Brandon (direction: A+B hybrid — Fleet Grid hub + per-device mini-wizard)
diff --git a/docs/superpowers/specs/2026-07-09-yaml-view-download-design.md b/docs/superpowers/specs/2026-07-09-yaml-view-download-design.md
index 25df585..2f92394 100644
--- a/docs/superpowers/specs/2026-07-09-yaml-view-download-design.md
+++ b/docs/superpowers/specs/2026-07-09-yaml-view-download-design.md
@@ -1,5 +1,9 @@
# YAML View / Download on Device Pages — Design
+> **Historical.** Every product config `!include`s sibling files (`Core.yaml` and others), so a
+> downloaded single file can't be built on its own. The page now says to build from the whole
+> folder and links it.
+
Date: 2026-07-09
Status: Approved by Brandon (2026-07-09)
Issue: #3
diff --git a/js/app.js b/js/app.js
index a6e4c39..16592d9 100644
--- a/js/app.js
+++ b/js/app.js
@@ -4,28 +4,44 @@ import { renderDevice } from './views/device.js';
import { initThemeToggle } from './theme.js';
const app = document.getElementById('app');
+const siteTitle = document.title;
-async function route() {
+function hashId() {
+ const raw = location.hash.replace(/^#\/?/, '').replace(/\/$/, '');
+ try { return decodeURIComponent(raw).toLowerCase(); } catch { return raw.toLowerCase(); }
+}
+
+async function route(e) {
let registry;
try {
registry = await loadRegistry();
} catch (err) {
app.innerHTML = `
-
Something went wrong loading the installer. ${err.message}
+
Something went wrong loading the installer.
Try reloading. If it keeps happening, every device also has a standalone installer
linked from its GitHub repository.
`;
+ app.querySelector('.error-detail').textContent = err.message;
return;
}
- const id = location.hash.replace(/^#\/?/, '').replace(/\/$/, '');
+ // Ids are lowercase-kebab; #/AIR-1 and #/air-1/ reach the same page.
+ const id = hashId();
const device = registry.devices.find((d) => d.id === id);
if (device) {
renderDevice(app, device);
+ document.title = `${device.name} · ${siteTitle}`;
} else {
- renderHub(app, registry);
+ renderHub(app, registry, id);
+ document.title = siteTitle;
}
window.scrollTo(0, 0);
+ // On in-app navigation, move focus to the new page's heading so keyboard and
+ // screen-reader users start at what changed instead of a detached link.
+ if (e) {
+ const h1 = app.querySelector('h1');
+ if (h1) { h1.tabIndex = -1; h1.focus({ preventScroll: true }); }
+ }
}
window.addEventListener('hashchange', route);
diff --git a/js/registry.js b/js/registry.js
index 85e4e11..e8e187a 100644
--- a/js/registry.js
+++ b/js/registry.js
@@ -2,9 +2,16 @@ let cache = null;
export async function loadRegistry() {
if (cache) return cache;
- const res = await fetch('devices.json');
+ // no-cache revalidates (cheap, ETag) instead of trusting Pages' 10-minute
+ // max-age, so a registry fix reaches the next page load.
+ const res = await fetch('devices.json', { cache: 'no-cache' });
if (!res.ok) throw new Error(`Could not load the device list (HTTP ${res.status}).`);
- const reg = await res.json();
+ let reg;
+ try {
+ reg = await res.json();
+ } catch {
+ throw new Error('The device list could not be read.');
+ }
if (!Array.isArray(reg.devices) || reg.devices.length === 0) {
throw new Error('The device list is empty or malformed.');
}
diff --git a/js/release-notes.js b/js/release-notes.js
index 5fc7381..53ef5b5 100644
--- a/js/release-notes.js
+++ b/js/release-notes.js
@@ -1,4 +1,19 @@
-export async function fetchReleaseNotes(repo, channel) {
+// One GitHub API lookup per repo+channel for the page session: variants that share
+// a repo reuse it, which keeps well under the unauthenticated 60 requests/hour/IP.
+// A failed lookup is evicted so a later render can retry.
+const memo = new Map();
+
+export function fetchReleaseNotes(repo, channel) {
+ const key = `${repo}|${channel}`;
+ if (!memo.has(key)) {
+ const p = load(repo, channel);
+ p.catch(() => memo.delete(key));
+ memo.set(key, p);
+ }
+ return memo.get(key);
+}
+
+async function load(repo, channel) {
const url = channel === 'stable'
? `https://api.github.com/repos/${repo}/releases/latest`
: `https://api.github.com/repos/${repo}/releases?per_page=15`;
@@ -7,5 +22,5 @@ export async function fetchReleaseNotes(repo, channel) {
const data = await res.json();
const rel = channel === 'stable' ? data : data.find((r) => r.prerelease);
if (!rel) throw new Error('no release found for channel');
- return { name: rel.name || rel.tag_name, body: rel.body || '', url: rel.html_url };
+ return { name: rel.name || rel.tag_name, tag: rel.tag_name, body: rel.body || '', url: rel.html_url };
}
diff --git a/js/theme.js b/js/theme.js
index 267c5cb..0539dc2 100644
--- a/js/theme.js
+++ b/js/theme.js
@@ -38,7 +38,8 @@ export function initThemeToggle() {
function updateBtn(pref) {
// The dog is the CSS background; the small badge shows the current mode.
if (badge) badge.textContent = ICONS[pref];
- btn.setAttribute('aria-label', LABELS[pref]);
+ // Name the current mode and what a click does; the title alone is not announced.
+ btn.setAttribute('aria-label', `${LABELS[pref]}. ${NEXT_LABEL[pref]}`);
btn.title = NEXT_LABEL[pref];
}
diff --git a/js/views/device.js b/js/views/device.js
index a5c915c..d9f87c3 100644
--- a/js/views/device.js
+++ b/js/views/device.js
@@ -10,6 +10,12 @@ function rawToBlob(raw) {
const m = raw.match(/^https:\/\/raw\.githubusercontent\.com\/([^/]+)\/([^/]+)\/([^/]+)\/(.+)$/);
return m ? `https://github.com/${m[1]}/${m[2]}/blob/${m[3]}/${m[4]}` : raw;
}
+// The folder a raw config lives in, as a GitHub tree link. Configs !include their
+// sibling files (Core.yaml and friends), so a rebuild needs the whole folder.
+function rawToTree(raw) {
+ const m = raw.match(/^https:\/\/raw\.githubusercontent\.com\/([^/]+)\/([^/]+)\/([^/]+)\/(.+)\/[^/]+$/);
+ return m ? `https://github.com/${m[1]}/${m[2]}/tree/${m[3]}/${m[4]}` : rawToBlob(raw);
+}
function configBasename(url) {
return (url.split('/').pop() || 'config.yaml').replace(/[^\w.\-]/g, '_');
}
@@ -21,6 +27,40 @@ function downloadText(text, filename) {
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
+function fileName(url) {
+ const last = url.pathname.split('/').pop();
+ try { return decodeURIComponent(last); } catch { return last; }
+}
+
+// Manifests are read by the release-notes check and by the no-WebSerial file
+// list. One request per URL for the page session; a failure is evicted so a
+// later render can retry.
+const manifests = new Map();
+function fetchManifest(url) {
+ if (!manifests.has(url)) {
+ const p = fetch(url).then((res) => {
+ if (!res.ok) throw new Error('HTTP ' + res.status);
+ return res.json();
+ });
+ p.catch(() => manifests.delete(url));
+ manifests.set(url, p);
+ }
+ return manifests.get(url);
+}
+
+// Release notes describe the flashed build only if the release is tagged with the
+// manifest's version or names it (WLED-M1 tags bundles like m1-b8 but its notes
+// say "v16.0.1"). Otherwise "What's new" would show some other build's notes.
+// URLs are ignored: GitHub's "Full Changelog" compare link names the previous
+// version, and 16.0.1 must not match 16.0.10 or 16.0.1-beta.
+function releaseMatches(rel, version) {
+ if (!version) return true;
+ const v = String(version);
+ if (rel.tag === v || rel.tag === `v${v}`) return true;
+ const e = v.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ const re = new RegExp(`(^|[^\\w.])v?${e}(?![.-]?\\w|\\.\\.)`);
+ return re.test(`${rel.name}\n${rel.body}`.replace(/https?:\/\/\S+/g, ''));
+}
function selectedManifest(device, channel, variant) {
return device.firmware[channel][variant];
@@ -35,10 +75,6 @@ function classicInstallerFor(device, channel, variant) {
return map && variant in map ? map[variant] : device.githubPagesInstaller;
}
-// Firmware ecosystem for a variant. Mirrors `repos`/`installers`: an optional
-// per-variant `platforms` map wins, then the device-level `platform`, then
-// esphome. A device offering both WLED and ESPHome builds (the M-1 could) marks
-// only the variants that differ from its device-level default.
// Optional per-variant explanation shown under the variant toggle, same
// `channel -> variant -> text` shape as `repos`/`installers`. Registry content,
// so it is trusted HTML like `description`.
@@ -47,37 +83,51 @@ function variantNoteFor(device, channel, variant) {
return (map && map[variant]) || '';
}
+// Firmware ecosystem for a variant. Mirrors `repos`/`installers`: an optional
+// per-variant `platforms` map wins, then the device-level `platform`, then
+// esphome. A device offering both WLED and ESPHome builds (the M-1 could) marks
+// only the variants that differ from its device-level default.
function platformFor(device, channel, variant) {
const map = device.platforms && device.platforms[channel];
if (map && variant in map) return map[variant];
return device.platform || 'esphome';
}
-// Step 3 differs by ecosystem: ESPHome devices are adopted through the ESPHome
-// integration and Device Builder, WLED devices through the WLED integration and the
-// WLED web UI.
+// Step 3 differs by what the firmware does once installed: ESPHome devices are
+// adopted through the ESPHome integration and Device Builder, WLED devices through
+// the WLED integration and the WLED web UI, and standalone builds (no network at
+// all, like the ornaments' stock firmware) have nothing to add.
function stepThreeHtml(device, platform) {
- if (platform === 'wled') {
+ if (platform === 'standalone') {
return `
+
3 You're done
+
This firmware doesn't use Wi-Fi, so there's nothing to add to Home Assistant.
+ Unplug the ${device.name} from the computer and restart it: if it has batteries in,
+ take one out for a few seconds. After that it works on its own. To use it with
+ Home Assistant later, come back and install a Wi-Fi firmware.
+ ✓
If you did not set Wi-Fi during install, join the open
- Apollo ${device.name} Wi-Fi hotspot the device broadcasts (builds before
- 16.0.1 add a serial-number suffix to the name), then open 4.3.2.1 in a
- browser and pick your network. On 16.0.1 the panel itself shows a setup card with that
- address and a QR code. Once it is on your Wi-Fi, go to
+ Apollo ${device.name} Wi-Fi hotspot the device broadcasts, then open
+ 4.3.2.1 in a browser and pick your network. On 16.0.1 the panel itself
+ shows a setup card with that address and a QR code. Once it is on your Wi-Fi, go to
Settings → Devices & services in Home Assistant, where it appears as a
discovered WLED device. Click Configure, and you're done.
✓
Want to customize the firmware?
The ${device.name} runs WLED, so effects and settings are customized in WLED itself.
- Open its web UI at the device's IP address (or http://wled.local),
- or use the WLED mobile app, to change effects, colors, presets and segments.
- Later firmware updates install from that UI under
- Config → Security & Updates → Manual OTA Update using the
- _ota.bin asset from the latest release.
+ Open its web UI at the device's IP address, or use the WLED mobile app, to change
+ effects, colors, presets and segments. Firmware updates install from that UI under
+ Config → Security & Updates → Manual OTA Update. Only use an
+ update file built for your firmware and board revision; check the setup guide
+ before updating.
`;
}
- return `
+ return heading + `
After installing, the device broadcasts itself on your network.
In Home Assistant go to Settings → Devices & services — it appears as a
discovered ESPHome device. Click Configure, and you're done.
@@ -87,7 +137,7 @@ function stepThreeHtml(device, platform) {
Apollo firmware ships with dashboard_import, so the device also shows up in the
ESPHome Device Builder (or the ESPHome Device Builder app in Home Assistant) under
Discovered. Click Take control to pull its configuration
- into the Device Builder, then edit it and flash updates over Wi-Fi.
+ into the Device Builder, then edit it and flash updates over the network.
`;
}
@@ -95,8 +145,8 @@ function segHtml(id, label, keys, active, dataAttr) {
if (keys.length < 2) return '';
return `
-
-
+ ${label}
+
${keys.map((k) => ``).join('')}
`;
@@ -113,7 +163,7 @@ export function renderDevice(el, device) {
@@ -212,18 +261,19 @@ export function renderDevice(el, device) {
-
+
Plug the device into this computer with a USB data cable, click the button, and pick the serial port.
`;
}
} else {
installSlot.innerHTML = `
This browser can't flash over USB.
-
Installing from the browser needs Chrome, Edge, or Firefox. You can still install manually:
+
Installing from the browser needs Chrome, Edge, or Firefox on a computer. You can still install manually:
Loading firmware file list…
Flash it with ESPHome Web (open it in Chrome, Edge, or Firefox) or
- esptool write-flash --port <port> 0x0 <file>.
+ esptool write-flash --port <port> <offset> <file>, using the offset
+ listed next to each file (only the files for your board's chip, if several are listed).
${classic ? `
`).join('');
+ // Part paths are runtime data: resolve them against the manifest, keep
+ // only https links, and escape what goes into the markup. A manifest
+ // with builds for several chips labels each file with its chip.
+ const several = m.builds.length > 1;
+ const parts = m.builds
+ .flatMap((b) => b.parts.map((p) => ({
+ chip: b.chipFamily, url: new URL(p.path, manifest), offset: Number(p.offset) || 0 })))
+ .filter((p) => p.url.protocol === 'https:');
+ if (!parts.length) throw new Error('no firmware files');
+ filesEl.innerHTML = parts.map((p) => `
`;
- } catch {
- if (epoch !== myEpoch) return; // selection changed mid-fetch
+ } else if (rel.status === 'fulfilled') {
+ // The repo's latest release is a different build; don't present its notes
+ // or assets as this firmware's.
+ slot.innerHTML = `
+
+ Installs build ${esc(version)}.
+
`;
+ } else {
slot.innerHTML = `
+ ${version ? `Installs build ${esc(version)}.` : ''}
See recent releases
for what's new.
`;
@@ -283,12 +360,14 @@ export function renderDevice(el, device) {
Build or reflash this firmware yourself
The ESPHome config for the ${variant} variant
- (${filename}). Rebuilding from this keeps the device's onboarding, so the
- ${device.name} wiki setup steps still apply.
+ (${filename}). It pulls in other files from the same folder, so build it
+ from a copy of the whole folder, not this file alone. The
+ ${device.name} wiki has the setup steps.