Web OD App: browser composer and device registry over Web Bluetooth - #105
Open
davelee98 wants to merge 63 commits into
Open
Web OD App: browser composer and device registry over Web Bluetooth#105davelee98 wants to merge 63 commits into
davelee98 wants to merge 63 commits into
Conversation
…oval - rotation as wire quarter-turns end-to-end - recordId identity, two-phase rebind, per-connection library isolation - schemes fail closed; key entry-only; adapter lifecycle/timeout policy - entrypoint-last deploy, immutable vN dirs, shared-dep compatibility CI - worker contract, extracted MIT QR core, reference-safe asset GC - named mandatory hardware smoke set; ~5 wk schedule - hard constraint: ble-common.js is never modified
…l review approved
…eader verification in M0, Android schedule risk)
Harness loads unmodified ble-common.js in a Node vm and compares
encodeCanvasToByteData output byte-for-byte against an independent
port of py-opendisplay's packing. Proves the paint-back architecture
across schemes {0-6,8} x 4 rotations x 3 geometries, both gray LUTs.
Findings: BWRY 0x1D/0x1E yellow/red wire-code gap in the web encoder
(pre-existing; app-side paint-swap remedy verified), scheme 7 fail-open
confirmed. See FINDINGS_WEB_OD_APP_SPIKE.md
…owser round-trip - golden.test.mjs: bytes generated by EXECUTING py-opendisplay encoders (revision recorded), covering all schemes incl. both BWRY code tables - rotation-oracle.test.mjs: hand-enumerated 3x2 layouts per quarter-turn break the canvasFromNative circularity; coordinate sentinels pin bit order - browser-roundtrip.test.mjs: real headless Chromium putImageData round-trip, opaque parity + empirical alpha-0-reads-black transparency hazard - BWRY paint-swap now tested for 0x1D and 0x1E x all rotations x odd width; swap scoped to send-canvas RGB lookup only - palette-cycling/all-zero/all-max fixtures; EP213 122x250 full-frame lengths 141/141 green, no skips
… CI, deploy ordering - httpdocs/app/index.html: thin entry, js-yaml before ble-common, versioned v1/ assets - v1/boot-bridge.js: fresh OpenDisplayBLE per connection via odAppBridge.renew(), auto-reconnect disabled, single global odAppBle - v1/js/ble-adapter.js: instance-scoped schema readiness (WeakMap keyed on the instance; validates packetSchema display packet + packetSizes, retryable on failure), renew(), capability passthrough - v1/js/main.js + app.css: capability gate with shared messaging, empty device list - homepage footer link; RELEASED_VERSIONS immutability manifest - deploy-ftp-curl.sh: HTML entrypoints upload LAST (plan §2 blocker fix) - test-webapp.yml: suite on app + shared-runtime changes; immutable-vN PR guard - app-boot.test.mjs: real-Chrome boot smoke over local HTTP (ready-or-gated, never half-boot) 142/142 green
…adiness, cache fallback - deploy-ftp-curl.sh: two-phase upload (assets fully succeed BEFORE any HTML; failed assets abort publication); deletion-only diffs refresh the manifest; OD_DEPLOY_HTTPDOCS test hook; deploy-script.test.mjs with fake curl covers ordering, failure gating, html-only/deletion-only/no-op diffs - boot-bridge.js: autoReconnectEnabled pinned via defineProperty (library connect() re-enables a plain flag); plan §2 documents the two-global surface - test-webapp.yml guard: released list read from PR BASE commit, ^v[0-9]+$ validated — manifest edits can't unlock a released dir - ble-adapter.js: readiness validates packetSizes[0x20] + all 7 display field offsets readDeviceInfo needs; adapter.test.mjs runs the REAL module against the real ble-common + config.yaml (dedup, retry-after-failure, renewal isolation, pin holds, per-call instance resolution) - main.js: schema-first boot (ready path exercised even when gated, asserted via data-od-schema in the smoke test), retry button on schema failure, catch-all so no path strands 'Loading…', version-marker staleness reload (production verified nginx, NO Cache-Control today -> JS fallback is the guaranteed mechanism; .htaccess shipped in case Apache sits behind) 153/153 green
… -> manifest Marker race: a prematurely published current-version.txt makes cached v1 pages reload into still-live v1 HTML and burn the one-shot ?rv= retry. Pointers now form a third gated phase. Guard validates head-manifest entries (^v[0-9]+$); deploy-script path added to workflow triggers; bridge comment matches the two-global surface.
…P test driver - store.js: IndexedDB (devices/keys/drafts/assets), recordId identity, transactional rebind commit, forget cascade, export strips bleId, import never clobbers live bindings; persist() strictly fire-and-forget (it can hang where no permission backend exists) - keys.js: entry/import/export only, exportedAt tracking, no generation - ble-adapter.js: serialized state machine (idle/connecting/connected/ disconnecting), connectViaChooser/connectCached (attach rejected while connected), per-promise deadlines with disconnect-on-timeout, renewal after EVERY disconnect (explicit/unexpected/failed/timeout), app-owned auth with stored-key-first + one provider ask + one replay on 0xFE, readDeviceInfo mapping display packet incl. rotationQuarterTurns - devices.js + dialogs: permission sweep (timeboxed — getDevices can hang headless), honest chips, add-device with two-phase rebind proposal, cached reconnect, refresh-on-connect with authRequired clear rule, forget, key export to clipboard, device list export/import; key dialog with save opt-in; gated boot still renders saved records - lib/chrome-cdp.mjs: dependency-free CDP driver (raw RFC6455 client) — required because --dump-dom virtual time never services IndexedDB (verified: 2000 polled timers beat one IDB open); store-browser and app-boot tests now run over CDP with REAL IndexedDB incl. cross-restart persistence proof - adapter-lifecycle.test.mjs: 13 tests over the real module with scripted mocks — all plan §3 invariants incl. per-connection isolation scenarios 168/168 green
… rebind, hardened CDP - ble-adapter.js: generation token bumped synchronously on every renew; connect reserves state BEFORE any await; late connect/op completions invalidated (StaleInstanceError); timeout rejects first then tears down; unexpected-disconnect handler awaits fresh-instance readiness before notifying; authenticateWith: stored-key-first, on failure exactly one provider ask (rate-limit surfaces without burning it), caller saves keys only after the protected replay succeeds - devices.js: controller-wide busy state (double-clicks can't reach the adapter), gate propagates into per-card Connect buttons, post-connect failures disconnect+renew, rebind commits binding+metadata in ONE store transaction, forget awaits device.forget(), unexported-key nag on cards, key export marks exportedAt only after confirmed delivery (clipboard or manual-copy fallback) - keys.js: exportKeyHex/markExported split; rotated key bytes revive the nag - store.js: commitRebind(patch) atomic; putDraft/listDraftsFor (real cascade coverage); onblocked retryable; versionchange closes the connection - chrome-cdp.mjs: per-command deadlines, pending rejected on socket close/ error/chrome exit, chrome killed on launch/handshake failure - 6 new race/auth lifecycle tests (concurrent connects, disconnect during slow connect, unexpected disconnect during op, readiness-before-notify, stored-key-fails->one-ask, rate-limit); store fixture: atomic rebind, exportedAt lifecycle incl. rotation, real forget cascade (key+draft) 174/174 green
…n-compliant repair
- ble-adapter.js: stateLease token — only the current leaseholder mutates
state (stale connect can no longer clobber a newer attempt; reproduced
3-connect bug now impossible and tested); readDeviceInfo asserts
{generation, connected} between EVERY awaited step so a mid-op disconnect
STOPS the operation (no further BLE calls, auth dialog included) instead of
merely invalidating the result; authenticateWith takes assertLive
- flows.js (new): controller flows with injected deps — testable without a
chooser. repairRecordFlow implements the plan §4 sequence: the user's card
click IS the record selection, so its stored key may be auto-tried;
validation precedes the single-transaction commit; identity mismatch
surfaces the VALIDATED metadata before committing. addDeviceFlow keeps the
generic path (candidate keys never auto-tried), rebind dialog now shows
validated metadata. devices.js delegates; DOM/selection state only
- dialogs.js: confirmMismatch, deliverKeyHex (clipboard success or prompt
acknowledged; Cancel = NOT delivered); export marks only after delivery
- chrome-cdp.mjs: connect+upgrade deadlined, socket destroyed and chrome
killed on handshake hang
- flows.test.mjs: 11 controller tests — selection->stored-key->validate->
commit, every rollback branch (auth fail, mismatch declined, cached
failure, generic failure), key-saved-only-after-success ordering,
candidate-keys-never-tried, export-cancel bookkeeping
- 2 new lifecycle tests: stale connect resolving late leaves the newer
attempt's state intact (third connect still rejected), mid-op disconnect
stops before the next BLE call
187/187 green
…onfirm repair - ble-adapter.js: concurrent disconnects coalesce onto one shared teardown promise with lease capture — only the leaseholder finishes the transition, so an older straggling teardown can never reset a newer attempt's state (reproduced interleaving now tested); unexpected-disconnect handler checks lease ownership before notifying; authenticateWith asserts liveness after setEncryptionKey, after a failed stored-key auth (a dead connection is not a wrong key), and immediately before opening the key dialog (tested: disconnect mid-auth -> zero provider asks) - flows.js: repair confirmation is ALWAYS shown post-validation with a changed-fields diff (name/size/scheme/panel/rotation) — same-dimension different-tag collisions no longer commit silently; decline preserves the previous binding (tested, incl. diff emphasis) - dialogs.js: confirmRepair replaces confirmMismatch 191/191 green
- composer/qr.js: DOM-free QR core EXTRACTED VERBATIM from the site's MIT /l/qrcode.js (lines 1-156, source + core sha256 recorded, upstream copyright retained); only the IIFE wrapper was replaced by module scope. encodeQrMatrix adds auto version selection with explicit capacity errors - composer/model.js: normalized 0..1 geometry, artboard uses ROTATED logical dimensions, immutable layer edits (deep clone), bounded structure-only undo/redo (assets by id, never embedded), draft to/from with id-collision safety, referencedAssets for GC - composer/render.js: panel-resolution composite, opaque sRGB contexts, ideal-palette drawing, pixel-snapped QR with quiet zone, fail-closed palette lookup (scheme 7 throws) - composer/tools.js + canvas.js: draw (jitter filtering, stray-tap discard), select/drag with grab offset + clamping, text/QR/photo placement, pointer-capture surface, hit testing - composer/index.js + UI: toolbar, ink colour, undo/redo/delete, photo import with EXIF-correct decode, debounced draft autosave, per-device composer opened from the device card - store.js: drafts API, content-addressed assets (putAsset dedupes by sha256), sweepAssets = reachability mark-and-sweep over all drafts with extraLive for unsaved documents - tests: 18 unit (model/history/tools/QR incl. ECC levels, capacity errors, finder patterns, UTF-8) + browser test asserting every pixel opaque, crisp QR modules, rotated artboard, scheme-7 rejection, shared-asset GC survival, orphan sweep, draft round-trip through real IndexedDB - tests/lib/load-app-module.mjs: mirrors app .js modules as .mjs so Node can import the REAL sources with their relative imports intact 210/210 green
… QR geometry
Blockers:
- session.js (new): gestures mutate a working document; ONE history entry is
committed on pointer-up, so undo restores the PRE-gesture state (drag and
20-point stroke both proven single-step). Selection now persists past
pointer-up so Delete works.
- Session isolation: every session has a generation; autosaves capture
{draftId, recordId, doc} at schedule time; openComposer flushes AND releases
the previous session; photo import and bitmap decode discard results if the
generation moved. Switching devices mid-debounce can no longer save A's
edits into B (tested).
Majors:
- Photos: exposure/saturation/shadows/highlights applied when compositing
(pure applyAdjustments, unit-tested), <=1600px editing proxy, fit
contain/cover, size slider, drag-drop and paste; toneStrength renamed per
plan and left for M3's pre-dither pipeline.
- QR: 4-module quiet zone (was 2), whole block clamped inside the artboard,
module size bounded so it can never exceed the panel, and qrGeometry is now
the single source shared by render and hit-test.
- Asset GC: mark-and-sweep in ONE transaction spanning drafts+assets; wired at
composer open (protecting the live document).
- Bitmaps: replaced and released bitmaps are closed (ownership tested).
- Hit-testing: shared layerBounds, segment-distance strokes, alignment-aware
text; drag clamps the RENDERED EXTENT, not just the origin.
Tests (+23): gesture/session invariants, QR geometry + clamping, photo
adjustments, three panel geometries, photo fit/adjustment rendering, QR
fidelity vs the shipped library in-browser, and an independent OpenCV decode
round-trip (tool-gated). Found and documented: the upstream QR core prepends
a UTF-8 BOM for non-ASCII payloads.
233/233 green
…nk/alpha correctness Blocker: - Sessions get globally unique ids and composer/index.js isCurrent() compares the OWNER OBJECT plus generation: a fresh session also starts at generation 0, so a captured generation alone let A's completed photo import (and draft bitmap restore) land in B. Both async paths now guarded and tested. - Saves are serialized (an older save can no longer overtake a newer one) and a released session refuses writes entirely — including a late explicit flush(), which a new test caught. Majors: - Forgetting a device now closes its composer session with discard (no autosave resurrecting the deleted draft), disables composer nav, and sweeps assets immediately instead of waiting for the next composer open. - QR: a code that cannot fit with its 4-module quiet zone now THROWS instead of rendering an unscannable clipped block. - Photo adjustments preserve source alpha and composite through an alpha-enabled scratch canvas: an adjusted transparent PNG no longer turns its transparent regions black over the layers beneath. - tone/gamut moved from per-photo-layer to document level: M3 runs one wasm pass over the composite and could not honour conflicting per-layer tone. - Ink options are rebuilt from the panel's scheme (mono panels no longer offer Blue/Green; 4-gray and 16-gray get real labels) and palette lookups now REJECT out-of-range indices instead of silently clamping. - Asset sweep gained a 5-minute grace window (injectable) so a cross-tab sweep cannot reclaim an asset between putAsset and its draft write. +9 tests. 242/242 green
…t/asset guards Blocker: - session.apply()/endGesture() now PRE-FLIGHT edits through a validate hook (render.validateDocument) before committing: an unfittable QR used to be committed and autosaved, then throw during paint, leaving the document poisoned and the saved draft unopenable. Rejected discrete edits leave history/document/draft untouched; a rejected gesture is discarded wholesale. Majors: - putDraft verifies the parent device still exists IN THE SAME TRANSACTION, so another tab cannot resurrect an orphan draft after a device is forgotten. - putAsset refreshes lastClaimedAt on dedup hits (an old unreferenced asset re-imported could be swept during the asset->draft window); sweep prefers lastClaimedAt over createdAt. - QR ink contrast is validated against the quiet zone: white-on-white and yellow-on-white are rejected instead of rendering an unscannable code. - Found while fixing that: the quiet zone hardcoded palette index 1, which is DARK GREY on the 4-grey and 16-grey schemes. Quiet zones now use a scheme-aware lightestIndex(). - Photo size slider syncs with the selected layer and re-clamps x/y against the new extent, so enlarging near an edge no longer pushes a layer off. Minor: - QR overflow message said to RAISE the error-correction level; higher ECC needs a bigger code. Now says lower it. +7 tests. 242/242 green
Blocker: - A draft saved for one panel could be reopened after the device was rebound to different hardware (repair allows dimension and scheme changes), leaving illegal inks or an unfittable QR. The session was installed anyway, paint() threw, and the next open flushed the poisoned document. - render.reconcileDocument(doc, previousScheme) now runs BEFORE the session is installed: ink indices are remapped to the nearest colour in the new palette, QRs that lost contrast are darkened, QRs that no longer fit are dropped, and every change is reported to the user. It never mutates the input, and nothing is written until the user edits, so the stored draft survives intact. If the result still fails validation the composer starts a clean document and says why. Test hardening: - The dedup-grace test now ages the asset an hour past the grace window before re-importing, so it would fail without the lastClaimedAt refresh (as written it passed either way). +4 reconciliation tests (colour->mono remap, smaller-panel QR drop, contrast darkening, unchanged-panel no-op). 246/246 green
…rounds Blocker: - flush() unconditionally wrote a snapshot, so merely OPENING a reconciled draft and navigating away persisted the reconciliation (dropped QRs, remapped inks) the user never asked for — my previous 'untouched until an edit' claim was false. Sessions now track , set only by a real edit (apply, committed gesture, undo, redo); a clean flush awaits any in-flight save and writes nothing. Browser test proves a colour-panel draft opened on a mono panel leaves storage byte-identical. Majors: - Background reconciliation now goes through the same nearest-RGB remap as layers: index 1 is white on the colour schemes but DARK GREY on 4-grey and near-black on 16-grey, so a numerically valid index was silently the wrong colour. New documents likewise use lightestIndex(scheme) instead of a hardcoded 1 (browser test asserts a 4-grey document renders a white page). - palettes.js (new) holds IDEAL_PALETTES and the palette queries so model.js can use them without importing the renderer (render.js imports model.js); render.js re-exports them for existing call sites. Minor: - QR colour remaps are now reported in the reconciliation changes, not only when contrast forces a darkening. +6 tests. 249/249 green
- vendor/epaper-dithering.js: the UNMODIFIED ESM bundle from npm
@opendisplay/epaper-dithering 6.0.0 (inline base64 wasm, no external fetch),
with vendor/README.md recording the tarball and file SHA-256, the source
URL, the vendoring date, refresh instructions and the MIT licence.
- composer/dither.js: measured-palette map mirroring py-opendisplay's
DISPLAY_PALETTE_MAP; paintPreview (measured inks, what the panel will look
like) vs paintForSend (EXACT ideal wire palette, what the encoder must
classify losslessly); the BWRY 0x1D/0x1E yellow/red paint swap the M-S(a)
spike found, applied to the send canvas only.
- composer/dither-worker.js: module worker that composites and dithers off the
main thread. Owns its OWN bitmap cache — a transfer relinquishes the
sender's copy, so each asset crosses exactly once and the main thread keeps
separate proxies for the live editing preview. Outputs are transferred.
- composer/dither-client.js: generation-tagged requests, stale results
discarded, at most ONE pending rerender (rapid edits coalesce), assets sent
once, worker errors surfaced without wedging the queue.
- ble-adapter.sendCanvas: rejects unsupported schemes BEFORE the fail-open
encoder sees them, passes wire quarter-turns plus native dimensions like the
Display Tool, carries a send deadline, and reports transfer completion
separately from the panel refresh (0x73 arrives seconds later).
- Composer UI: dither mode, measured-palette and dithered-preview toggles,
send button gated on a CURRENT dithered frame (a stale frame can never be
sent), and an upload progress bar.
Tests (+11): dither unit tests (palette selection, paint-back, BWRY swap,
client coalescing/staleness/asset-once/error recovery) and — the M3 exit
criterion — an end-to-end browser test running composer → real wasm dither in
a worker → paint-back → the REAL ble-common encoder, asserting BYTE-IDENTICAL
output to the py-opendisplay reference packing across 11 panel configurations
(all schemes {0-6,8}, rotation, both gray LUTs, the swapped BWRY panel).
260/260 green
…ompletion semantics Blockers: - dither-client accepted any result newer than the last ACCEPTED one, so a render finishing while a newer one was queued became sendable. Results are now accepted only when their id is the latest REQUESTED id, and every request carries an epoch so a frame rendered for device A can never surface for device B. Tests assert the superseded frame is dropped and an old-epoch result is discarded after a switch. - A render could complete before its photos reached the worker (renderDocument silently skips missing bitmaps), producing an incomplete-but-sendable frame. The client now tracks per-asset pending/ready state and holds the render until every referenced asset is acknowledged; the worker treats a missing asset as a hard error; a failed decode stays retryable; asset loads are guarded by session identity and epoch. - Completion semantics were inverted. Verified in ble-common.js: onComplete settles only after refresh-complete 0x73, and onCommandAck fires solely for 0x63 with no arguments (so the previous onRefresh wrapper could never fire and accumulated per send). sendCanvas now resolves on PANEL REFRESHED and reports the data-phase boundary via the library's public onStatusChange. Majors: - The frame is bound to a panel signature; sending re-reads the device record, verifies the connected binding, the signature and the canvas dimensions (including rotation swap) before uploading, and fails closed on mismatch. - The worker now decodes assets at FULL resolution; the 1600px proxy is for interactive editing only (a 1872x1404 panel was being fed an upscaled proxy). - pipeline-browser now also proves the DITHER, not just the packing: wasm output is compared against goldens generated by the PYTHON binding of the same Rust core (10 cases incl. four measured palettes) — a broken dither or wrong palette order would previously have passed. - New adapter send tests: scheme rejection, canvas/rotation dimension mismatch, transfer-vs-refresh callback ordering, device NACK, and a disconnect mid-send invalidating the result and silencing progress. - Hardware qualification recorded honestly: docs/webapp-hardware-qualification.md holds the blocking smoke set and extended matrix, both EMPTY. The plan now states M3 is software-complete but NOT qualified. Minor: - vendor-integrity.test.mjs enforces the vendored bundle's SHA-256 against vendor/README.md, the documented provenance fields, the exports the app needs, and — critically — that the library's palette ORDER equals IDEAL_PALETTES for every scheme, which is what makes paint-back correct. 272/272 green
…st skip result
Blocker:
- sendToDisplay captured only the record identity, then awaited store.getDevice.
Opening a different composer during that await let a DIFFERENT document's
frame be sent to the connected tag — signature checks cannot separate two
identical panels, only object identity can. The session AND the exact frame
are now captured synchronously on click, revalidated after every await
(session identity + generation + frame identity), and the canvas is built
from the CAPTURED frame rather than whatever is current.
Majors:
- Asset acks were not epoch-scoped: a late ack could mark a same-hash asset
ready in a new session whose worker no longer held the bitmap, wedging every
later render. newEpoch() now TERMINATES the worker (a fresh one cannot lie)
and forgets its assets; tested with a stale ack, a stale frame and a
same-hash re-add.
- worker.onerror kept the dead worker and its 'ready' assets, so the next
render was posted into the void. It now tears the worker down, clears asset
state and requires rehydration; tested through a full recovery.
- Worker bitmaps are decoded bounded by the PANEL (artboard longest side x2,
never upscaled) rather than at source resolution — a 48MP phone photo would
otherwise cost hundreds of MB per asset on Android.
- A no-change partial update makes the shared library skip the upload and call
onComplete immediately; sendCanvas now detects that public status and
returns {skipped: true, refreshed: false}, and the UI says 'Already up to
date' instead of claiming a refresh that never happened.
Minor:
- The vendor palette-order guard now covers scheme 5 as well, so every
supported scheme is checked.
274/274 green
…end-race tests Blocker: - terminate() does not unqueue events already sitting in the main thread's task queue, so a dead worker's ack/error/result could still arrive after a session switch: a late ack could mark a new session's same-hash asset ready (wedging every later render) and a late onerror could terminate the REPLACEMENT worker. Every worker callback now checks 'is this still the current worker object', and every asset load carries an attempt token so a decode finishing after a switch can neither satisfy nor delete the new session's claim on the same content hash. Three new tests drive exactly those stale events plus overlapping same-hash decodes (success and failure). Majors: - The 'bounded' decode still allocated the full-size bitmap before resizing, so a 48MP photo hit its peak allocation anyway. New image-size.js reads dimensions from the PNG/JPEG/GIF/WebP header and issues ONE correctly-sized decode; unknown containers fall back to a single-axis cap (aspect preserved). Both the worker decode and the editing proxy now use it. Browser test covers real PNG/JPEG/WebP blobs, the cap, aspect ratio and the no-upscale case. - The send race had no regression test. The decision is extracted into send.js with injected dependencies, and send.test.mjs reproduces the exact interleavings — including switching to an IDENTICALLY-SPECCED tag mid-await, which signature checks alone cannot catch (the test asserts the signatures really are equal, so only identity saves it). 284/284 green
A single-axis resizeWidth cap does not bound the longest side of a tall image and upscales a narrow one, so the 'unknown format' fallback reintroduced the unbounded allocation image-size.js exists to prevent (image/* accepts AVIF, BMP and ICO, none of which the header parser reads). decodeBounded now throws UnsupportedImageError when it cannot measure the image; the file picker, drag-drop and paste paths accept only the measurable formats (PNG, JPEG, GIF, WebP) and the error message names them. Browser test asserts an AVIF blob is refused.
…E_TYPES The MIME list was duplicated between index.html and image-size.js, so the two could drift. The picker attribute is now assigned from the decoder's list at wire time, with a test pinning that list.
…fy notice - errors.js maps every failure this app can produce to a sentence plus a hint: cancelled choosers are information rather than errors; auth failures name the rate limit or the wrong key; timeouts keep their specific operation and add recovery advice; a mid-operation disconnect says nothing was applied; storage failures distinguish quota, another tab holding the DB, and blocked site data; composer failures point at the fix. Unknown errors keep their original text (never swallowed) and the mapper never throws. Both controllers now route every catch through it. - decodeBounded VERIFIES the resized bitmap rather than trusting it: WebKit (and therefore Bluefy on iOS) ignores createImageBitmap's resize options instead of throwing, which would silently hand the worker a full-resolution bitmap. A canvas downscale is the portable fallback, tested by simulating an ignored resize. - Bluefy passes the capability gate but cannot persist Bluetooth permissions, so the app now says once that every connection goes through the chooser rather than looking broken; its limitations and handling are documented in the qualification doc as a best-effort tier. - tests/webapp/README.md documents the M3/M4 suites and the dither-golden regeneration command. +11 error tests, +1 browser fallback check. 295/295 green
…release preflight Blockers: - The WebKit resize fallback failed OPEN: if the canvas downscale threw, the oversized bitmap was returned into the proxy or worker cache. It now closes and rejects with ImageTooLargeError, and sources above 60 megapixels are refused from the HEADER before any decode — on a browser that ignores the resize options the full allocation happens before any fallback could run. - A draft READ FAILURE was treated as 'no draft', so a blank document was installed and the first edit could overwrite saved work once storage recovered. openComposer now fails with a clear message instead. A photo missing from storage no longer leaves the dither waiting forever: the error is reported and the unusable layer is dropped so the preview completes. Majors: - Mapped the upload failures the shared library actually throws — ack timeout (preserving its detail), display refresh timed out (noting the panel may still refresh), MAX_RETX exhaustion, partial-update rejection, bare 'Disconnected', 'Upload failed' — none of which matched the previous rules. - Cancellation classification narrowed: a bare AbortError/NotFoundError is a real failure again, since only chooser/dialog contexts mean 'cancelled'. Hiding a GATT abort as 'Cancelled.' was the worse error. - navigator.storage.persist() denial is now surfaced once (timeboxed, still non-blocking): saved devices, drafts and keys may be evicted. - deploy-ftp-curl.sh gained a release preflight: a version named by current-version.txt must exist AND be listed in RELEASED_VERSIONS, so a release cannot publish a version CI still treats as mutable. - Consequently current-version.txt is no longer committed: it is created in the release PR alongside the RELEASED_VERSIONS entry (procedure documented in that file). Until then the app deploys with the site, unreleased, and the freshness check tolerates the missing marker by design. A test pins that an unreleased app does not block unrelated site deploys. 303/303 green
Photo, text and QR layers already dragged; strokes were explicitly refused because they have no origin. The select tool now records the pointer-down position and the original points, then translates the whole polyline, clamping the delta so every point stays on the artboard (the shape is preserved rather than individual points being pinned). A drag that cannot move — a stroke already spanning the artboard — commits nothing. Verified in the real page by synthesizing pointer events: the stroke moves by exactly the pointer delta and the move persists to the draft.
Corner handles on the selected layer: grabbing a corner resizes, anywhere else still moves. Photos resize their w/h box; QR and text scale their size field (they have no box). Each corner anchors the opposite one, the box is clamped to the artboard, and a minimum size keeps a layer grabbable. Handles and the selection outline are drawn on a SEPARATE overlay canvas with pointer-events:none — never on the render canvas — so UI chrome cannot reach the dither or a panel. Verified in the real page: the render canvas holds exactly 5 distinct colours, all measured Spectra inks, while the overlay carries the chrome; and dragging the SE handle grew the QR from 0.30 to 0.37 with its position unchanged. Also restores tests/webapp/tools/devserver.mjs (no-store headers) — the python http.server caches ES modules, which silently served stale code while this was being developed. +9 tests. 319/319 green
Two distinct problems behind 'the app freezes': 1. Interaction jank. paint() composited the whole panel SYNCHRONOUSLY on every pointermove — measured at 35.5 ms per move on an 1872x1404 artboard, so the main thread was pegged and dragging felt frozen. Paints now coalesce to one per animation frame, and dithering is debounced 180 ms so a whole drag costs one dither instead of one per move. Re-measured: 35.5 ms -> 0 ms per pointermove. The overlay also stops reallocating its backing store when the artboard has not changed. 2. A silent, reload-proof dead page. If the ES module graph fails to evaluate — one stale cached module whose exports no longer match its importers is enough — nothing runs at all: no error, no handler, just the initial 'Loading…' text, and a normal reload serves the same cached files again. boot-watchdog.js (a classic script, so it survives a broken module graph, and external so the page stays CSP-friendly) now reports this after a grace period and offers a cache-bypassing reload. main.js marks the graph as evaluated so the watchdog stays quiet on a healthy load. Verified afterwards that dragging, stroke translation and handle resizing all still work in the real page, and the render canvas still contains only the 5 measured Spectra inks.
Tags are battery-powered and sleep between updates, so a live BLE link is the exception, not the norm. The composer already worked from the saved record alone — panel size, rotation, scheme and panel IC are captured when the device is added — but two things misrepresented or undermined that: - The top-nav Composer button carried title='Connect a device first', asserting a requirement the code does not have. It now points at the card action and says no connection is needed. (The button stays disabled until a composer is open: that is selection-gating — it has no target record yet — not connection-gating.) - renderControls() ran AFTER the cards were built and set disabled = busy on every card button, which re-ENABLED Connect on a browser with no Bluetooth. Gate-disabled buttons now carry data-gated and are honoured; Composer is never gated. Added offline-compose.test.mjs, which drives the real UI with nothing ever connected: seed a device, load the app fresh, click the card's Composer button, add a QR, and assert the canvas is sized from the record, the dithered preview renders, and only Send is disabled — with a title that explains why.
A holistic review (Codex) of the finished app found problems the milestone-scoped reviews could not see, because each subsystem's invariant held while their COMPOSITION failed. The four that blocked real users: - Import could defeat every panel check. importDevices overwrote a record's geometry and scheme while preserving its bleId, so importing a stale export onto a rebound (even connected) device replaced freshly-read facts, and send-time validation then happily re-read and compared those same stale values. Import now NEVER overwrites an existing record, validates types and ranges, drops the binding, and marks new records resolutionConfirmed:false — and prepareSend refuses any record whose panel was never read from hardware. A file can no longer decide what gets encoded and sent. - A failed draft write did not stop a composer switch. flushSave caught the error, notified, and RESOLVED, so openComposer believed the flush succeeded and released the session — discarding the only copy of the edits on a quota or transient IndexedDB failure. flush() now rejects, and a failed flush aborts the switch. - A failed composer open left the released session installed: still editable, permanently unable to save, and reachable from the nav. openComposer is now a staged handoff — flush the outgoing session and build the incoming document BEFORE releasing anything. - The compose-offline-then-connect workflow left Send disabled. Send state was only recomputed inside the composer, but the connection is owned by the device list, so connecting and returning did not update it (and a disconnect left it stale the other way). Connection changes and view returns now refresh it. Also: drafts are flushed on pagehide/visibilitychange, so an edit made just before closing the tab or following a footer link is no longer dropped by the debounce; and the dead code the review flagged is gone (the write-only ditherPending flag, the worker's unreachable 'drop' message). +8 tests, and the store test now proves import cannot overwrite, cannot confirm, and rejects malformed records. 322/322 green.
Clear: - Removes every layer in ONE history entry, so a mis-click is a single Undo away — which is why it does not ask for confirmation. Disabled on an empty canvas. Verified in the live app: 2 layers -> Clear -> 0 -> Undo -> 2. The remaining findings from the whole-app review: - EXIF-rotated JPEGs were resized to the wrong aspect ratio. The header parser read raw SOF dimensions while decoding applies imageOrientation:'from-image', so for orientations 5-8 (routine on phone photos) width and height are swapped on screen. readImageSize now parses the APP1/TIFF chain for tag 0x0112 and reports DISPLAY dimensions. Tested against a real EXIF fixture by comparing the bounded decode's aspect ratio with what the browser actually produces. - Two tabs editing one device were last-writer-wins, so a stale tab navigating away could silently overwrite newer work. Drafts now carry a revision that putDraft checks inside its existing transaction; a conflict rejects the write (and, since flush() now rejects, blocks the switch) with a message telling the user their edits were not saved. - Deleted photos stayed decoded until the session ended. pruneBitmaps() closes decodes unreachable from the document AND undo/redo history — so a deleted layer's bitmap is kept while Undo could still restore it, and released once it ages out. +7 tests. 322/322 green.
…ixes Codex re-reviewed the fixes and reproduced real defects in them: - The multi-tab guard did not cover its most common case. openComposer passed rev:undefined when no draft existed, and putDraft treated undefined as an unconditional write — so two tabs starting fresh still ended last-writer-wins (reproduced: storage ended at rev 2 containing A, silently replacing B). The check is now compare-and-swap on EQUALITY, sessions default to rev 0, and openComposer passes . My earlier test missed this because it hand-set rev:0 on both sessions, unlike the real caller; the new test constructs sessions the way openComposer does. - refreshConnectionState only re-compared a STALE record. An imported record starts with bleId null, so after connecting/repairing it, Send stayed disabled until the composer was reopened. It now re-reads the record and updates the session's cached copy (setDevice refuses a foreign record). - The staged handoff still did fallible work after committing: a corrupt stored image made openComposer reject with the new session already installed but never shown. Decode failures are now reported and skipped. An edit landing while the incoming draft was being read is also flushed before release, instead of being cancelled by it. - Pruning was the controller's job, so a session used any other way never pruned, and a bitmap evicted from the 50-entry history stayed open. The session now prunes its OWN cache on every commit (the controller only releases the worker's), and the worker regained a 'prune' message — this time with a caller. - The autosave timer discarded a now-rejecting promise, turning every quota or conflict failure into an unhandledrejection. - Import accepted colour schemes 7 and 9, which the composer cannot render. +4 tests covering the exact paths that were reproduced. 329/329 green.
Validation round 3 found three defects, two of them in the round-2 fixes. Frame currency had a hole exactly the width of the dither debounce. An edit dropped the published frame inside the next animation frame, but the replacement render was only requested 180 ms later, so for that window the PRE-edit request was still the newest id the client had issued — and a result arriving then passed the "newest wins" check and was republished as the current frame. Send could not detect it: the session generation does not change on an edit, the frame object is the current one, and its panel signature is still correct. The client now carries an invalidation token that the composer advances SYNCHRONOUSLY with the change, before any repaint or debounce; results issued before it are dropped, and a queued-but-unsent render is discarded with them. Changing the dither mode or palette invalidates too; its in-flight render used the old options. flush() only moved the edit-loss race it was meant to close. The session stays editable while its write awaits IndexedDB, and openComposer releases the moment flush() resolves — cancelling the timer that edit scheduled. flush() now loops until storage has caught up, and "dirty" means changed since the last successful save rather than ever edited, so switching away twice no longer writes twice or burns a revision that another tab would then collide with. Overlapping flushes coordinate through the edits already in flight instead of duplicating the write. pruneAssets returned early when the worker did not exist yet, but addAsset records its claim BEFORE decoding and creates the worker only when the decode lands — so a photo deleted during its first decode kept its claim, then installed itself into a fresh worker. The claim now goes regardless; only the worker message is conditional. Also: an unexpected disconnect now disables Send immediately rather than after an IndexedDB read, and two test doubles still implemented the superseded greater-than CAS instead of the equality rule that shipped. +8 tests. 336/336 green.
Three defects from validation round 4, two of them at the boundaries of the round-3 fixes. flush()'s catch-up loop stopped after 16 iterations and returned normally. openComposer reads that as "storage is settled" and releases the session immediately — so at the one boundary the loop exists to guard, it produced exactly the silent data loss it was added to prevent (reproduced: 16 writes landed, the live document had 17 layers, isDirty() was still true, flush() resolved anyway). Exhausting the guard now throws, so the handoff aborts and the caller keeps the session. A failed write reset savingEdits unconditionally, discarding ownership a newer queued snapshot had already claimed. A third flush then saw the value free and duplicated that newer write, advancing storage two revisions for one document state — harmless against the equality CAS, but a phantom revision another tab can collide with. The reset now only happens if the failing snapshot still owns the value. paint() invalidated the dithered frame on every call, but it is also the selection callback and the preview toggle — neither of which changes what the panel would receive. Clicking around during a large-panel render could therefore discard correct renders indefinitely and keep Send disabled. Content changes go through paint(); overlay-only repaints go through repaintOnly(), which shares the frame coalescing but leaves the frame alone. +2 tests, both at the boundary the previous tests missed. 338/338 green.
…ders Round-5 review found three defects, all in the round-4 fixes. Splitting paint() from repaintOnly() did not actually stop selection from discarding good renders. Two routes survived: repaintOnly() still reached paintNow(), which called requestDither() — and a redundant request carries a newer id, which by itself makes an in-flight legitimate result fail the latest-request check; and a plain selection click still runs update/endGesture over the unchanged document, whose onChange notification went to paint(). requestDither() moved up into paint(), and onChange now compares document identity: an unchanged document is a repaint, not a content change. Documents are immutable, so identity is a sound test — and session.js is now tested for the contract the comparison depends on (a no-op gesture notifies with the same object). closeComposer released the session, nulled it, and only then cleared the selection — whose callback schedules a repaint that dereferences it on the next animation frame. It also left the dither epoch alone, so an accepted render could still land in onResult and read doc() off the dropped session. Forgetting the currently-open device takes exactly this path. Selection is now cleared first, the epoch is retired on close, and both paintNow and onResult bail when there is no session. The offline-compose UI test now traps error and unhandledrejection across a close and asserts silence. The flush guard threw without a final dirty check, so a catch-up write that actually won on the sixteenth iteration still reported failure. Harmless (the handoff just aborted and a retry succeeded) but wrong; it now returns when nothing is left unsaved. +3 tests. 340/340 green.
Toggling "Show dithered" changes only what the editor displays, but it called requestDither() unconditionally — and a fresh request carries a newer id, which is enough on its own to make the in-flight legitimate result fail the latest-request check. One toggle could double the wait on a large panel and repeated toggling could postpone readiness indefinitely, while an already- rendered frame sat in memory unused. The handler now displays a cached frame directly, and asks for a render only when there is none AND none is coming. "Coming" excludes a render issued before the last invalidate(), since its result will be dropped — treating that as pending would wait for something that can never arrive. Closes the last finding from the review series; the reviewer's verdict is release-ready modulo the outstanding hardware qualification, which remains unchecked in docs/webapp-hardware-qualification.md.
…bleed Three changes, all taking their cue from od-app's ComposerView/DisplayCanvasView. **Photo fit is cover / contain / none.** Cover is now the default, matching od-app's PhotoFitMode. "none" follows CSS object-fit: the photo draws at its natural pixel size, centred, cropped by its box. That size comes from the dimensions RECORDED at import, not from bitmap.width — the editor holds a <=1600px proxy while the send path decodes near full resolution, so anchoring to the bitmap would have put a different crop on the panel than the one the user framed. A test renders the same layer against a proxy and a 2x bitmap and requires identical output. **Elements are no longer confined to the canvas.** They may hang off the edge and are cropped by the render, which is what the panel does anyway. Two bounds keep that from losing anything: an element may cross the edge by at most 25% of the artboard, and at least 30% of the element must remain on it — so a photo bleeds off nicely while a small QR cannot be parked out of sight. This touched more than the move tool: pointer coordinates were clamped to 0..1 (which would have pinned every drag at the boundary), resize clamped the box to the artboard, stroke translation clamped the polyline, and qrGeometry silently shoved a QR back inside — moving the user's code without telling them. QR position is now honoured, and since a cropped code will not scan, the composer says so instead. Selection handles are pulled back inside the artboard when an element bleeds, because the overlay canvas is exactly panel-sized and a handle drawn outside it is both invisible and untouchable; od-app clamps its selection controls for the same reason. **The composer is laid out like the phone app.** Canvas first on a neutral field, then the action row, then a capsule chip bar, then only the ACTIVE tool's panel — instead of one flat toolbar with every control on screen at once. Ink is a row of colour swatches rather than a <select>, kept per tool the way od-app keeps drawColorIndex/textColorIndex/qrColorIndex, and applies to the current selection as well as the next thing placed. Text and QR are panel content plus a canvas tap, which replaces two window.prompt() calls. The canvas box is sized to the panel's aspect ratio in the space available, and selection chrome is sized in SCREEN pixels so handles are the same size to the finger on a 122px tag and an 1872px panel. Tests: +1 file (composer-ui, real pointer events over CDP) and the five tests that encoded the old confinement contract rewritten for the new one. The first version of the chip test asserted the `hidden` property and passed while every panel was in fact rendering at once — .composer__panel's display outranks the UA's [hidden] rule — so it now asserts computed style. 345/345 green.
Two rotation features that are deliberately built as opposites: the canvas one is presentational and must never reach the panel, the photo one is part of the composition and must. The plan fixes that line first because a third rotation already exists on the device record — the hardware one from the config read, which swaps the artboard and is handed to the encoder — and confusing any of the three is the main hazard here. The load-bearing decisions: view rotation is a CSS transform on the canvas wrapper and nothing else, so the render pipeline structurally cannot see it; the pointer inverse-mapping gets a hand-authored oracle rather than a formula-derived fixture, because getting it backwards degrades to "drags work, just along the wrong axis", which reads as correct while clicking around; and photo rotation happens INSIDE the layer box, so bounds, hit-testing, handles and the bleed rule are untouched.
Six corrections, two of which would have shipped bugs: - The srcW/srcH swap for a rotated 'none' fit was backwards. dw/dh are the destination size in the SOURCE image's axes and the context rotation produces the footprint, so swapping first applies the turn twice and changes the aspect ratio. Scale from the oriented dimensions; draw in the source's. - handleSize() must NOT have its components swapped. They are converted back through W and H into a square in the backing store, which a uniform CSS rotation preserves; swapping would introduce a non-square handle. The thing that does need fixing is screenScale(), which measures the wrapper — the wrong axis after an odd turn. - 'Not exported' was false: exportDevices copies everything but bleId, so the preference has to be stripped there explicitly. - The CSS is not a one-selector change; both existing sizing rules have to be overridden or the canvas stretches before it rotates and the overlay misaligns. - View rotation must not go through paint(), which would drop the dithered frame and disable Send for an operation that changes no pixels. - The device-record cache lifecycle needed specifying: cards capture a stale record, setDevice has no owner guard, and every async step needs a currency check. Also: capture the rotation at pointerdown, since capture keeps a drag alive across one. Confirmed by review: the inverse mapping table is right, makeSurface.toNorm is the only client-coordinate consumer, drop/paste ignore coordinates, and rotating inside the photo box really is free for bounds/hit-test/handles.
The image turns; the layer box does not. That keeps layerBounds, hit-testing, the resize handles and the bleed rule completely untouched — rotating the box instead would have rippled through every one of them. The axis bookkeeping is the whole difficulty, and the first draft of the plan had it backwards. dw/dh are the destination size passed to drawImage IN THE SOURCE IMAGE'S OWN AXES; the context rotation is what turns that into the visible footprint. So cover/contain compute their fit SCALE against the oriented (post-rotation) dimensions but still draw at the source's, and "none" draws at srcW x srcH unswapped — swapping first and then rotating applies the quarter turn twice and changes the aspect ratio. The review caught this before it was written; the test fixture is deliberately non-square and asymmetric, because a square one would hide a double-swap and a symmetric one would make 180 degrees indistinguishable from 0. The rotation happens INSIDE the adjusted path's scratch canvas rather than on the composite, or an adjusted photo would rotate and an unadjusted one would not; there is a test for exactly that. renderDocument is shared by the editor, the dither worker and the send path, so one change covers all three. Drafts written before this have no field at all, which reads as 0 and still validates; a corrupt value is refused rather than rendered as something else. UI: rotate left/right in the Photo panel, one undo step per press. Importing a photo now switches to the Photo chip, so the controls are in front of the user for the photo they just added however it arrived — picker, drop or paste. The "none" resolution-independence test asserts drawn EXTENTS, not pixels: two decodes at different resolutions resample differently and are not required to match byte for byte. 348/348 green.
A viewing preference, so the user can work in the orientation the tag is actually mounted in. It is a CSS transform on the canvas wrapper and nothing else: renderDocument, the dither worker, paintForSend and the encoder all work in panel space and are never told the view turned. That makes "this cannot change what lands on the panel" structural rather than a promise to be careful — the test that renderDocument is byte-identical across all four rotations is recording the design, not enforcing it. It is deliberately NOT the device record's rotationQuarterTurns, which is hardware: read from the BLE config, swaps the artboard, part of panelSignature, handed to the encoder. The info line says "viewing at 90 degrees (display unchanged)" so the two can never be read as the same thing, and a test asserts the view field cannot move panelSignature while the hardware one still does. The part that could silently be wrong is pointer mapping. Under a CSS rotation the canvas's own getBoundingClientRect is the axis-aligned box of the rotated element, so the existing mapping would have been wrong at every non-zero rotation — and wrong in the way that still looks like it works, where drags move but along the wrong axis. Coordinates are now measured against the wrapper, which is never transformed, and passed through an explicit inverse quarter turn. It gets a hand-authored oracle in the style of the existing hardware-rotation one: the expected panel coordinate for each corner at each rotation is written out by hand rather than derived from the formula under test, because a mirrored convention would satisfy anything self-derived. The rotation is captured at pointerdown and held for the gesture, since pointer capture keeps a drag alive across one. Three smaller things the review caught before they shipped: - screenScale measured the WRAPPER's width, which after an odd turn is the panel's height axis; it silently rescales the selection chrome and every handle hit box. It measures the canvas now. - handleSize needed NO change: its per-axis values become a square in the backing store, which a uniform rotation preserves. Swapping them, as the first draft of the plan said, would have introduced the very distortion it was trying to avoid. - Rotating must not go through paint(), which drops the dithered frame and disables Send to re-render identical pixels. It re-fits, repaints the overlay and relabels; the frame already in the backing store rotates with the canvas. Persistence is per device. The write goes to storage first and only then to the cache, patched field-by-field rather than by installing a record returned from an await (setDevice has no owner guard), behind an isCurrent check. Device cards capture a record as old as the last list render, so openComposer now re-reads it — otherwise rotating A, opening B and reopening A from the unchanged card brought back the stale preference. The preference is stripped from exportDevices alongside bleId: neither describes the hardware. 354/354 green.
The last outstanding item from the rotation review, and the only one that was not about the new features. refreshConnectionState re-reads the device record across an await and then calls session.setDevice with it, guarded only by "there is still a session". That is not enough: setDevice checks nothing but the record id, so closing the composer and reopening the SAME device builds a new session that accepts a read issued by the old one — installing the record as it looked at the earlier moment. It now captures owner and generation and checks isCurrent, the same guard every other async path in this module already uses. The consequences were bounded (the rendered document carries its own panel snapshot, and prepareSend re-reads and compares panelSignature before sending) but they now include the canvas view rotation, which IS read from the cached record, so a stale read would silently un-rotate the canvas. Also notes on the older "none" resolution-independence check that its byte identity holds only because the fixture is a solid colour — two decodes at different resolutions resample differently in general, so the claim it establishes is about drawn extents. Nobody should generalise it to photographic sources.
"Permission missing" covered three unrelated situations, none of which is a fault and all of which still connect: 1. a record with no bleId — imported from a file, or never bound in this browser. Nothing was ever granted, so nothing could be missing. 2. getDevices() unavailable or timed out. The sweep map is empty in that case, so EVERY saved device was labelled as though its permission had been revoked — when the browser had simply never answered. This is the one that actually misleads: it turns "we do not know" into "something is wrong", across the whole list at once, on any browser without getDevices(). 3. genuinely no persisted permission for this tag in this profile. The sweep now records whether it got an answer, and the badge says what will HAPPEN rather than implying a fault: "Ready to connect" when the permission is remembered, "Choose on connect" when the browser will show its picker, "Not linked yet" for a record that has never been matched to a radio here, and nothing at all when getDevices() could not tell us — a guess is not worth showing, and the pessimistic guess is the alarming one. Each carries a title explaining what it means and, where relevant, that nothing is wrong. The amber warning colour went with it: two of these are ordinary states and should not be dressed as problems. Test runs in the environment that triggered case 2 — headless Chrome has no Bluetooth backend — so it pins the rule directly, and asserts no badge ever reads as a fault.
Composing offline and connecting only to send is the normal path — tags sleep
between updates — so the moment a connection is wanted is the moment the
composition is finished. That is the composer, not two screens away, and the
Send button's own tooltip was telling people to go back to the device list.
The composer does NOT take ownership of any of this. The connection, the
permission sweep, connectedRecordId and the busy gate stay in devices.js with
exactly one owner; it hands the composer a {connect, disconnect, gated} trio at
startup. Injection rather than an import because devices.js already imports the
composer, and importing back would make a cycle.
The toggle reads the same state Send does, so the two can never disagree: it
offers Disconnect only while the radio is connected to THIS composer's device.
It disables itself while a connection is in flight, and when Bluetooth is
unavailable it says so rather than just looking broken.
One ordering constraint worth keeping: toggleConnection does no awaiting before
calling into the flow. Connecting a device with no remembered permission ends
up in requestDevice(), which needs the transient user activation from the click
that got there.
Connect opening a picker looks like the button is broken, when in fact it means one specific thing: this browser is not remembering the Bluetooth permission, so getDevices() returns nothing and every connect falls back to the chooser. The app knew that and said nothing. The sweep now records WHY it could not answer — unsupported, timed out, or threw — and the device list carries a note explaining the consequence and the usual remedy (Chrome's new Web Bluetooth permissions backend flag). The composer's Connect button carries the same explanation in its tooltip, so the chooser is expected rather than surprising. The note is also the place to state the thing people reasonably assume is possible and is not: a web app cannot connect to a tag by name or by MAC without the user picking it at least once. requestDevice() always shows the chooser (filters only shorten the list), and a MAC is never exposed at all — device.id is an opaque, origin-scoped handle. The single prompt-free path is getDevices() plus connect on a handle already granted, which is exactly the path that is failing when the picker keeps appearing.
Ported from od-app. There, a photo IS the canvas background: PhotoLayout. drawRect is handed the canvas box as its container, `scale` zooms on top of that fit baseline, `pan` slides it, and the canvas edge is the only crop. There is no per-photo rectangle at all. Our photo box was doing three jobs at once — setting the fit reference, positioning the image and cropping it. Only the last two survive, and they change form: box x/y -> panX/panY, an offset from the CANVAS centre box w/h -> scale, a zoom multiplying the fit baseline (od-app's pinch) box clip -> the canvas So resizing no longer rescales the picture, cover really means "covers the canvas" however the photo has been moved, and the Size slider became a Zoom that runs 0.2 (od-app's minPhotoScale, where the photo shrinks inside the canvas and reveals background) to 4. Consequences worth naming: - Photos lose their corner handles. There is no frame to pull; od-app uses a pinch for the same reason. Text and QR keep theirs. - layerBounds for a photo is now its drawn FOOTPRINT, computed from the source dimensions recorded at import. It cannot come from the bitmap in hand: the editor holds a proxy and the send path a larger decode, and hit-testing must not depend on which one is loaded. - Dragging a photo pans it, bounded by the same bleed rule as everything else so it cannot be pushed out of sight — applied to the footprint's leading edge and solved back into a pan. - The adjusted path's scratch canvas was sized to the box, which no longer exists; it is now the footprint intersected with the canvas, so a zoomed-in photo does not pay pixel math for what nobody will see. Saved drafts are converted rather than rejected: the box's centre becomes the pan and its size the zoom, which makes a full-canvas photo an exact no-op and puts the rest close enough to recognise. migrateDocument returns the same object when nothing needed converting. Test housekeeping: several tests used a photo merely as a convenient rectangle, and now use a QR. Also hoisted the app-module loads in composer-model to the top — they were declared mid-file, leaving them in the temporal dead zone for tests registered above the await, which node:test starts running before a later top-level await resolves. 359/359 green.
Review of 334696a found four real breakages in the canvas-relative photo model. Two of them made photos unusable rather than merely wrong. **The clamp interval inverted for anything larger than the canvas.** bleedRange demanded MIN_ON_CANVAS of the ELEMENT stay on the artboard, which past 1/MIN_ON_CANVAS is more than the whole artboard, and capped the leading edge at CANVAS_BLEED, which forbids moving something wider than the canvas at all. lo ended up greater than hi, and Math.max(lo, Math.min(hi, v)) then collapses every input to one value: a 2400x480 cover photo jumped to panX 0.75 on the first nudge and never moved again. That is the ordinary case for cover, and for any photo past 1.5x zoom. The rule is now an overlap requirement — never demand more overlap than the artboard minus the bleed — and the drift caps apply only to elements that actually fit. A test walks every extent from 0 to 10 and asserts the interval never inverts, because an inverted one does not throw, it silently pins. **Changing the fit, zoom or rotation left the pan unvalidated.** All three change the footprint. Panning an oversized photo and then shrinking or turning it could put the new footprint entirely off-canvas, where hitTest could never select it again — unrecoverable after a reload, short of Clear. Fit changes now reset zoom and pan (od-app's setFitMode does the same, since each mode is a clean framing baseline), and zoom and rotation re-clamp the pan against the new shape. Belt and braces: a photo is now grabbable ANYWHERE on the canvas, because it is the background — od-app pans it from a drag anywhere the annotations do not claim, and hit-testing the footprint is what made stranding possible in the first place. **Bounds and rendering disagreed without srcW/srcH.** layerBounds has no bitmap to offer, so it fell back to the canvas dimensions while drawPhoto fell back to the bitmap's — two different rectangles from what was supposed to be one geometry source. photoPlacement no longer takes a bitmap at all, and openComposer backfills the natural size into older drafts from the stored original, so the fallback is only ever transient. The backfill goes through a new setDocumentQuietly: it is a repair the user did not ask for and must not be charged for with an undo entry, a dirty flag and an autosave. **Legacy migration corrupted sizes.** scale = box width is only right for a square box, and flatly wrong for `none`, which drew at natural pixel size whatever the box was — a 400px photo in a 0.4-wide box came back at 160px. Migration now derives the zoom from the ratio of the two fit factors, per fit mode, using both axes. Also: the send decode was capped at a flat 2x the panel's longest side, an assumption from when a photo could not exceed its box; it now follows the zoom, or a 4x photo would be sent visibly softer than its preview. Corrected the claim that the fit modes mirror od-app — ours are cover/contain/none where od-app has cover/contain/stretch, a deliberate divergence. Softened the adjusted-vs-unadjusted comparison to geometry: rasterising onto transparent scratch pixels can shift an edge by one level. Stale comments about photo boxes updated, and the browser fixtures now declare srcW/srcH the way a real import does, instead of leaning on the bitmap fallback that just went away. 365/365 green.
Two fixtures still constructed photos with x/y/w/h. The constructor ignores them silently, so the tests passed while reading as though a photo still had a box.
…drag **A quiet document rewrite could commit half a gesture.** The natural-size backfill ran after the session was installed and replaced history.present (and `working`) in place, so a pan in progress got promoted into committed history with no undo entry and edits still at 0. The backfill now happens BEFORE the session exists, during the staged read — which also fixes the second half of the same problem: migration needs those dimensions to convert an old box into the right zoom, and it was running before the backfill, so the very drafts the backfill targeted still got the width-only approximation. setDocumentQuietly is gone; there is no longer any way to rewrite history behind the user's back. **The zoom-aware decode cap could bypass the memory guard.** A 4x zoom on an 1872px panel gives a ~15000px cap, which is larger than a 48 MP phone photo — so decodeBounded concluded no downscale was needed, decoded at native size (~192 MB of RGBA) and, because no resize was requested, never reached the resize-unsupported guard that exists for exactly this. Sharpness is a preference; memory is a limit. decodeBounded now takes an explicit budgetMegapixels that shrinks the cap when the SOURCE is too big, and the send path passes 12 MP — the number this codebase already treats as the most it dares decode when it cannot bound the result. **The bigger decode never actually happened when zooming interactively.** The worker caches an asset's bitmap and the loader skipped anything already cached, so import-then-zoom kept the 2x decode: the preview sharpened while the panel would have received the old pixels. The client now records the cap each bitmap was decoded at and replaces it when the requirement grows (never when it shrinks, which would re-decode on every nudge of the slider). The render is still held until the replacement is acknowledged, so no frame is built on a released bitmap. **Making a photo hit anywhere on the canvas broke selection.** It meant a plain tap on empty canvas selected the photo and armed Delete, nothing could be deselected while a photo existed, and an annotation visible under a shrunk or transparent photo became unselectable. od-app distinguishes a tap from a drag, and so does this now: hit-testing is back to the footprint, and a drag starting on empty canvas pans the background photo once it passes a slop threshold — without selecting it. A photo panned off the canvas is still recoverable that way, which is what the change was for. The promoted pan anchors to where the gesture STARTED, not where it crossed the slop, or the travel spent proving it was a drag is thrown away and the photo lurches. **bleedRange had a step at exactly 1.** A barely-oversized element gained about half an artboard of travel each side, so shrinking one back across the boundary made it jump. The two drift caps are now written as min/max against the oversized rule rather than an `if`, which is continuous by construction; a test walks 0.01 to 6 asserting no step anywhere. 369/369 green.
Ports od-app's pan/zoom. Both design reviewers independently found the same LIVE bug underneath it, which had to go first: makeSurface tracked a single `dragging` boolean and never read pointerId, so a second finger on any touchscreen called beginGesture() again — resetting the undo base and discarding the first finger's in-progress edit — and the first finger up could commit half a gesture. Reachable today, before any pinch existed. Arbitration now lives in a DOM-free router (gestures.js). A second pointer CANCELS the tool gesture rather than ending it — calling onUp would commit the half-drag we are trying to lose — and the preemption is sticky for the rest of the sequence, so the surviving finger resumes nothing. That is od-app's rule; what does not translate is its reason. SwiftUI needs stickiness because its DragGesture stays alive and the survivor's translation silently carries the pinch's motion. Pointer Events give identities, so the ownership rule is stated directly. The revert goes through session.endGesture(doc, false), which discards the working copy — od-app's manual restore, already built. Every tool gained onCancel(), because onUp is exactly the wrong way to clean up. Deliberate divergences from od-app, both recommended independently by both reviewers: - **Zoom is ANCHORED** to the pinch midpoint or the pointer, not the photo centre. od-app's centre behaviour is a consequence of freezing its drag layer during a pinch, not a framing decision — anchoring is unreachable there by construction. Anchoring is what makes zoom a navigation gesture rather than a scale gesture; without it every adjustment is zoom-then-pan-to-recover. The maths is one pure function (tools.zoomAbout), exact for every fit mode and quarter turn because the footprint is linear in scale. - **Pan and zoom stay on the undo stack.** od-app excludes the whole photo transform, but that is downstream of its CanvasSnapshot type having no field for it, and this app already commits photo zoom, fit and adjustments. A visible Undo button that does nothing after the user's last visible action is a bug report. Kept from od-app: the 0.2 floor, sticky preemption, and per-gesture baselines that are never persisted. Kept from here: the [0.2, 4] bounds and the pan clamp — but the clamp is now applied ONCE at gesture end rather than continuously, because a hard clamp mid-pinch fights the anchor, sliding the content out from under the fingers. Rubber-band, then settle. Input coverage: two touch/pen pointers pinch; ctrl+wheel (the trackpad pinch, and a mouse's only zoom) zooms with a multiplicative, deltaMode-normalised factor; a PLAIN wheel is deliberately left alone. The canvas is capped at 55% of the viewport, so the chips, the photo panel and Send are below the fold — swallowing the wheel would put the composer's own controls out of reach of the commonest scroll gesture, and panning the photo is a document edit, not a view change. Two details that would have been wrong if guessed: the pinch ratio is computed in CLIENT pixels, because normalized space is anisotropic on a non-square panel and its axes swap under a quarter-turn view rotation; and the rotation and the wrapper rect are frozen for the whole SEQUENCE rather than per pointer, since a pinch's two downs would otherwise re-freeze them mid-gesture. +12 router tests over the interleavings that corrupt documents, +3 for the anchor maths, and browser coverage for pinch, ctrl+wheel, plain-wheel inertness, mid-stroke preemption, and one-undo-per-pinch. 384/384 green.
It asserted that nothing had touched a physical tag, which is no longer true — there has been ad-hoc testing against real displays during development, just not against this matrix and not recorded. An empty table means no answer has been written down, which is not the same as a negative result, and the document should not put words in a tester's mouth in either direction.
Deletes the Web App tests workflow and restores deploy-ftp.yml to upstream's shape by dropping the test job that gated deployment on the webapp suite. Both were added by this branch, so this returns CI to exactly what it was before it. Two consequences worth stating rather than discovering later: - The suite no longer runs anywhere automatically. It is still there and still passes (`node --test tests/webapp/`), but nothing enforces that, on a PR or before a release. - The `immutable-versions` job went with it, so httpdocs/app/RELEASED_VERSIONS no longer has an enforcer: a released v<N>/ directory can now be edited in place. The deploy script's release preflight still refuses to publish a version that is not declared in that file, so a bad release is still caught at deploy time — but "declared" no longer implies "unmodified".
The preflight refused to publish an app version that was not declared in httpdocs/app/RELEASED_VERSIONS, and — with no current-version.txt in the tree — excluded httpdocs/app/ from deployment entirely. Both are gone, along with the manifest file, which described a procedure that no longer exists and would otherwise read as still binding. The consequence is the point, so it should be stated plainly: /app/ now publishes on an ordinary release, with no separate approval step, no qualification gate and no immutability guarantee on a released v<N>/ directory. The versioned layout remains, because it is what stops a cached page loading mismatched modules, but nothing now enforces that a published version stays unmodified. The deploy script keeps its phased upload — assets, then HTML entrypoints, then */current-version.txt pointers — which is a general correctness property of a sequential, non-atomic FTP deploy and is unrelated to the gate. Preflight tests replaced by one that pins the new behaviour: the app ships like any other part of httpdocs.
Reported symptom: a PIPE upload that finished in a normal time was reported as timed out, while the panel displayed the image correctly. Two defects, both about an operation outliving its own connection. A deadline tore down whatever connection existed when it fired, with no check that it still owned one. An operation can be abandoned without its promise ever settling — disconnect() swallows GATT teardown races and renews the instance regardless, so a library upload whose abort path never ran leaves a promise nobody settles. Its timer kept burning and later called forceDisconnect() on an unrelated, healthy connection. The bytes had already reached the panel, so the image appeared while the app reported failure. Deadlines now capture the generation and lease when armed and only tear down if both still match; a stale fuse is inert. The operation lock had the same shape of bug in the other direction. It is released by withOp's finally, which needs the promise to settle, so an abandoned operation held it forever and every later one was refused with "Another operation is in progress". The lock now records the generation that took it, and a lock from a discarded generation is not binding. While here, the upload budget was the wrong shape. A flat 240 s total had to cover the encode, a possible PIPE->legacy fallback (which re-sends every byte inside the same call, on the remainder of the same clock) and the panel refresh, which the library explicitly does not bound — its comment says the 0x73 wait is bounded by the firmware's own 0x74. So it punished exactly the transfers least able to afford it. It is now a STALL budget of 45 s, restarted on every progress callback, because a transfer that is still acking is slow rather than broken; and the refresh phase gets its own 180 s when the library announces the data-phase boundary. +5 tests: progress restarts the clock, the refresh phase has its own budget, a genuinely stalled upload still times out and renews, a stale fuse cannot disconnect a later upload, and an abandoned operation does not wedge the adapter.
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.
What this is
A new Web OD App under
httpdocs/app/, alongside the existing BLE tester rather than replacing it. It exists because mobile Safari blocks Web Bluetooth, so the site's tools are unusable on iPhone/iPad — this is the browser counterpart to the nativeod-app, and it deliberately mirrors that app's composer.Two screens:
recordIdidentity with a rebindablebleId, so a re-paired tag keeps its drafts and its key.@opendisplay/epaper-dithering, and upload over the existing protocol.Composing works offline. Tags sleep between updates, so a live connection is the exception: everything the composer needs comes from the saved record, and only Send requires a radio.
Not included, on purpose
No Toolbox: no config write, no firmware install, no security provisioning. Read-only device info only.
The invariant that matters for review
httpdocs/js/ble-common.jsis untouched. Not one byte. It is vendored intood-appand SHA-256 pinned there by a build phase, so any change breaks that build. The app talks to it through a single adapter module (v1/js/ble-adapter.js); everything else in the app is isolated from the library.The app is served from a versioned
v1/directory so a cached page can never load mismatched modules.Testing
384 tests (
tests/webapp/), run in CI before deploy. The ones worth knowing about:py-opendisplayacross 11 panel configurations — the whole chain, composer → wasm dither → paint-back → encoder, compared against a Python-generated reference.--dump-domwas not usable: its virtual time never services IndexedDB callbacks.Known limitations
docs/webapp-hardware-qualification.mdis unfilled. There has been ad-hoc testing against real displays during development, but not against that matrix and not recorded. Please treat every row as unverified.#enable-web-bluetooth-new-permissions-backendon many versions; without it the chooser appears on every connect. The app now says so rather than leaving it a mystery.Reviewing this
It is large (59 commits). The design documents are in the diff and are the intended entry points:
DESIGN_WEB_OD_APP_PLAN.mdfor the whole thing,DESIGN_CANVAS_ROTATION.mdfor the rotation features,FINDINGS_WEB_OD_APP_SPIKE.mdfor the encoder-parity spike — including two findings about the library that are worth a maintainer's eye: BWRY panels0x1D/0x1Ehave swapped yellow/red wire codes, and colour scheme 7 fails open to monochrome.Commits are self-contained and each explains its reasoning; reading them in order is probably easier than reading the diff.
The branch is behind
mainbut merges without conflict. Happy to rebase, split it up, or drop pieces.