feat(builder): B2 — preset demo maps + in-browser previews (#899) - #987
Merged
Conversation
`RgbaFrame` — the in-memory RGBA8888 `DrawTarget` a wasm host blits to a `<canvas>` — lived inside `obc-web-demo`. B2 (#899) adds a second browser host that needs exactly the same buffer with exactly the same clipping and alpha-opacity invariants, so it moves next to the other shared host glue rather than being copied. No behaviour change: the file is unmodified apart from its header, and its own unit test moves with it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The hosted tier has no style editor, so presets are the only styling a visitor ever sees. They now show what they draw. **One demo map per preset, one box for all of them.** `builder/bake-previews.sh` iterates `builder/presets/*.json` and packs each one over the *same* Geofabrik extract at the *same* bounding box — a 1.2 × 1.6 km crop of Meiringen in the Haslital, shaped to the panel's 3:4 portrait so a 240×320 frame fits it at ≈4.9 m/px. A preset that got a prettier valley would win the comparison without being a better style, so the config file is the only variable. Adding a preset needs no edit to the script and no code change: one run and it has a card. The maps are **committed artifacts** (~120–810 KB each, 1.4 MB total), regenerated by the script — the same call the simulator fixtures make. Baking at site-build time would put libGEOS, a 600 MB extract download and minutes of CPU into a static-site deploy, for bytes that change only when a preset does. The crop handed to the packer is the framed box **padded ~600 m**. That is a correctness fix, not slack: `--bbox` completes ways that leave the box but drops a landcover polygon whose vertices all lie outside it, and the valley's forest and meadow are exactly that shape — crop tight and the preview loses ground cover the device would draw. **The renderer runs in the tab.** `apps/obc-web-preview` is a new wasm-pack library over `obc-reader` + `obc-render` — the same code the nRF54L runs, at the panel's own resolution, through the same RGB565 → RGB222 quantization, so the preview cannot flatter a preset with colours the display cannot make. It is a map layer and a camera, not an app: no screens, no replay, no planner, which is why it is 125 KB raw / 60 KB gzipped. The camera frames the box the bake pinned rather than a map's header bbox — the packer widens that by a different amount per preset, which would hand each card its own view. **Nothing loads until it is seen.** `PresetPreview.svelte` reaches the module and the map through dynamic imports inside an IntersectionObserver callback; `lib/preview/bundle.test.ts` asserts against the chunks Rollup emits that none of it is in any tier's entry chunk, and that the copy still is. The selected card is draggable, zoomable and keyboard-navigable (arrows / +- / 0); the rest are pictures. Cards live in both the catalog tier's step and the build tier's, since a preset is a preset. **Copy** lives site-side in `lib/preview/copy.ts`: one plain line per preset on what it is *for*, next to the config's own line on what it *draws*. Site copy has no business going through `obc-pack schema` and its pinning tests. A preset with no line falls back to its description, so a new preset's card still says something. CI: the `wasm-convert` job becomes `wasm-bridges` and builds both modules with a size budget each (`wasm_size_guard.py --module`); `web` and `desktop-frontend` take both artifacts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The site's builder page said presets are complete packer configs and that the GPX converter is the one thing the firmware runs in the tab. Both moved with #899, and the second more than the first: the renderer compiles to wasm the same way, which is what makes a preset card a real rendering rather than a screenshot someone has to remember to retake. - `packer-routing.md` — a new "a preset shows what it draws" bullet (the one extract, the one box, and why the camera frames it rather than a map's own header bounds), and the client-side-wasm bullet widened from routes to both bridges. - `architecture.md` — the parenthetical listing the wasm crates that are deliberately *not* hosts now has two of them. - `index.md` — a stack-table row for `obc-web-preview`. `python3 docs/build_docs.py --check-links` passes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lickable Two things the first pass got wrong. `firmware/tools/check_dependencies.py` requires every workspace package to sit in exactly one dependency group; the new crate was unclassified, which failed the `test` job. It belongs in `host` with the other two wasm shims — it is reachable from no device image. And the picture is most of a preset card, so a card you can only choose by clicking its caption reads as broken. Every non-selected preview is now a button; the selected one still is not, because it is live and a drag inside a button is a click. Switching selection re-creates both cards, so the fetched demo maps are memoized for the life of the page rather than re-downloaded to redraw something the visitor was just looking at. `data-phase` on the preview root, for reading the loading state out of the DOM: "waiting to be scrolled into view" and "loading" draw the same skeleton, and a background tab legitimately stays on the first one forever (IntersectionObserver does not fire while a tab is hidden — which is the lazy path working). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A pointer reports up to 120 moves a second and one render of a dense preset is tens of milliseconds, so painting per event queued renders behind a drag that had already moved on. The camera still moves per event — only the drawing is coalesced through `requestAnimationFrame`, so nothing is dropped, it is drawn once. The pending frame is cancelled on unmount. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Pages deploy built one wasm bridge and then ran `npm run build:web`, which imports the bindings of both — so the site build would have failed on the first push after #899 rather than shipping a builder without previews. The demo maps themselves need nothing here: they are committed under `builder/app/public/`, so vite copies them into the artifact like any other static file. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CLAUDE.md and firmware/README.md both enumerate what lives in `apps/`; the new wasm bridge was missing from each. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
timohueser
force-pushed
the
feat/bakery-b2-899
branch
from
July 29, 2026 16:17
08df1be to
151b15b
Compare
Wheel-to-zoom applied unconditionally, so a mouse wheel anywhere over the selected card zoomed the map instead of scrolling past it — the single most irritating thing an embedded map does, and this one is a 260 px card halfway down a page. It now zooms only when the visitor has aimed at the card: it holds focus (a click or Tab puts it there), or ctrl/meta says the gesture is meant for it. Dragging, the arrow keys and +/- are unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-detail Timo's call (2026-07-29): two really good presets, not three. Removed: the preset config, its demo map + previews.json entry, its tagline. Tests that copied the real minimal.json as a fixture now copy default.json; the restyle test's version bump is no longer a pinned literal (it silently no-opped the moment the fixture preset's version differed — now it bumps _meta.version from wherever it stands). The published catalog still lists bremen×minimal; the next publish drops it via --allow-shrink (deliberate), and the orphaned objects can be deleted from the bucket by hand. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
timohueser
enabled auto-merge
July 29, 2026 17:16
Missed in 2bc79cb: obc-pack's own guards walk builder/presets and pinned minimal by name. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The third and last preset-count pin (obc-desktop is workspace-excluded, so neither the workspace test run nor obc-bake's caught it — only its own CI leg did). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #899. Part of #894 (phase B — the bakery).
With the style editor locked to desktop-only, presets are the only style content on the hosted site. They now sell themselves: each card renders that preset's own demo map, through the device's own render path, at the panel's own 240×320.
What's here
One demo map per preset, one box for all of them.
builder/bake-previews.shiteratesbuilder/presets/*.jsonand packs each over the same Geofabrik extract at the same bbox — a 1.2 × 1.6 km crop of Meiringen in the Haslital (valley town, the Aare and the Alpbach, the Brünig rail line, the pass road, farmland, forest on the flanks), shaped to the panel's 3:4 portrait so a 240×320 frame fits it at ≈4.9 m/px. The issue is explicit that this must not be a contest of who got the prettier valley, so the config file is the only variable in the row. Adding a preset needs no edit to the script and no code change — one run and it has a demo map, an index entry and a card.Two provenance decisions worth reviewing, both pinned in the script header:
--bboxkeeps ways with a node inside the box and completes them, but drops a landcover polygon whose vertices all lie outside it, and this valley's forest and meadow polygons are exactly that shape. Cropped tight, the preview loses ground cover the device would draw at the same spot, which is the one thing a preview may not do. The pad costs ~1.7× the bytes.Committed vs built at site-build time — committed, and regenerated by the script. Repo precedent is
apps/obc-sim/assets/repack.sh, and the arithmetic agrees: baking needs libGEOS, a 600 MB extract download and minutes of CPU, none of which belongs in a Pages workflow, for bytes that change only when a preset does. The cost is 1.4 MB in the repo (minimal116 KB,high-detail466 KB,default807 KB) — the low end of the ~1–4 MB per preset the issue anticipated, because the crop is small and the framed scale is the panel's own..gitignoregets a negation next to the existing ones, with the reason.apps/obc-web-preview— a new wasm-pack library overobc-reader+obc-render: bytes in, device-resolution RGBA frames out, plus a clamped camera (pan, zoom, reset). The same code the nRF54L runs, through the same RGB565 → RGB222 → RGB888 quantization, so a preview cannot flatter a preset with shades the panel cannot make. Deliberately not a host: noApp, no screens, no replay, no planner — which is why it is 125 KB raw / 60 KB gzipped, a hair over the conversion bridge. Nine unit tests cover the framing, the clamps, the dirty flag and the two ways a deploy goes wrong; thedefaultpreset's committed demo map is the fixture, so a missing or unbaked map failscargo testrather than a visitor's page.The frontend. Every card's picture is its main hit target — a button around the preview on each unselected card, and nothing around the selected one, because that one is live and a drag inside a button is a click.
PresetPreview.svelterenders it;lib/preview/{bridge,demoMaps,copy}.tsare the loader, the index reader and the copy. Cards appear in both the catalog tier'sPresetStepand the build tier'sPresetCards— a preset is a preset. The catalog's optionalpreset.preview(OBCC §2, B1's to publish) stays honoured as a fallback for a preset with no demo map; the live render wins where there is one, because it cannot go stale when a preset is restyled.Copy is site-side in
lib/preview/copy.ts— one plain line per preset on what it is for, beside the config's own line on what it draws. No packer-schema change, so no ripple intoobc-pack schemaand theschema_*pinning tests. A preset with no line falls back to its description, so a new card always says something.Acceptance criteria
obc-web-previewrenders the committed.obcmthe packer produced frombuilder/presets/<id>.json, throughobc-render, at 240×320. Nothing is drawn by hand and no screenshot is stored.bake-previews.shglobs the preset directory and writes<id>.obcm+ an index entry; the card reads the index by id.demoMaps.test.tsfails if the index andbuilder/presets/disagree, or if a map was baked from an older_meta.version.import()s inside an IntersectionObserver callback.lib/preview/bundle.test.tsasserts against the chunks Rollup actually emits — for all three hosts — that none of it is in any entry chunk, and that the copy still is.+-/0); the wheel zooms only once the card holds focus or ctrl/meta is down, so a card halfway down a page never swallows the page's scroll. The others are static renders: drag-to-pan on every card in a grid fights page scrolling on a phone for no benefit. Verified in the browser — dragging the selected card repaints it and leaves the other two untouched.CI
The
wasm-convertjob becomeswasm-bridgesand builds both modules, each gated by its own size budget (wasm_size_guard.py --module convert|preview);webanddesktop-frontenddownload both artifacts.deploy-site.ymlgains the secondwasm-packinvocation too — it built one bridge and then rannpm run build:web, which imports the bindings of both, so the site build would have failed on the first push after this rather than shipping a builder without previews.npm run build:wasmbuilds both (:convert/:previewbuild one). Those workflow changes are why this branch was pushed via a token URL.Verified
cargo test --workspace(all green),cargo clippy --workspace --all-targets -- -D warnings,cargo fmt --all --checkbuilder/app:npm run check(0 errors, 0 warnings),npm test(535 tests, 49 files),npm run build:allpython3 firmware/tools/wasm_size_guard.py --module preview→ 124,857 B raw / 59,556 B gzipped, 88 % of budgetpython3 docs/build_docs.py --check-linksDocs
Doc-relevant, and landed as a separate
docs:commit: the builder page claimed presets are configs and nothing more, and that the GPX converter is the one thing the firmware runs in the tab. Both moved. Updatedpacker-routing.md(a new "a preset shows what it draws" bullet plus the client-side-wasm bullet widened to both bridges),architecture.md(the parenthetical listing the non-host wasm crates now lists two), andindex.md(a stack-table row).Follow-up
obc-bake's runner.bake-previews.shis deliberately standalone here: it was written while B1 (B1 — The bakery: curated region list, matrix bake, publish #898) was in flight, and this PR touches none of that territory. B1 — The bakery: curated region list, matrix bake, publish #898 has since merged, so the follow-up is now actionable — the demo maps are three more (region, preset) pairs at a fixed tiny bbox and should ride the same runner, andpreviews.jsoncould then be dropped in favour of the catalog'spreset.preview, which the card already honours as a fallback. (Rebased onto the mergeddevelop;obc-pack catalogpasses_meta.previewstraight through, and the real presets do not set it, so the live render is what a card shows today.)Deliberately not here
obc-pack schemaand its pinning tests are untouched.minimal's card is sparse, because at 4.9 m/pxminimalgenuinely draws major roads, rail and water and nothing else. That is the comparison working, not a bug — and it is the sentence its copy makes.🤖 Generated with Claude Code