Skip to content

Web OD App: browser composer and device registry over Web Bluetooth - #105

Open
davelee98 wants to merge 63 commits into
OpenDisplay:mainfrom
davelee98:design/web-od-app
Open

Web OD App: browser composer and device registry over Web Bluetooth#105
davelee98 wants to merge 63 commits into
OpenDisplay:mainfrom
davelee98:design/web-od-app

Conversation

@davelee98

Copy link
Copy Markdown
Contributor

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 native od-app, and it deliberately mirrors that app's composer.

Two screens:

  • Devices — a persistent registry in IndexedDB, added over the browser's Bluetooth chooser, reconnected without one where the browser remembers the permission. Immutable recordId identity with a rebindable bleId, so a re-paired tag keeps its drafts and its key.
  • Composer — a panel-shaped canvas with photo, freehand, text and QR layers, per-panel palettes, dithering via @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.js is untouched. Not one byte. It is vendored into od-app and 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:

  • Byte-parity with py-opendisplay across 11 panel configurations — the whole chain, composer → wasm dither → paint-back → encoder, compared against a Python-generated reference.
  • Cross-language dither goldens — the wasm build against the Python binding of the same Rust core.
  • Independent QR decode — rendered codes read back with OpenCV, which is how the upstream UTF-8 BOM quirk was found.
  • Several suites drive real Chromium over CDP with real IndexedDB and real pointer events. --dump-dom was not usable: its virtual time never services IndexedDB callbacks.

Known limitations

  • Chromium only. Web Bluetooth is unavailable in Safari and Firefox; the app detects this and degrades to a read-only device list with an explanation rather than failing obscurely.
  • Pinch-to-zoom has only run against synthetic pointer events in headless Chrome. Real touch hardware produces a denser stream and different capture behaviour; this is the part most likely to need adjustment.
  • docs/webapp-hardware-qualification.md is 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.
  • Persistent Bluetooth permissions need Chrome's #enable-web-bluetooth-new-permissions-backend on 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.md for the whole thing, DESIGN_CANVAS_ROTATION.md for the rotation features, FINDINGS_WEB_OD_APP_SPIKE.md for the encoder-parity spike — including two findings about the library that are worth a maintainer's eye: BWRY panels 0x1D/0x1E have 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 main but merges without conflict. Happy to rebase, split it up, or drop pieces.

…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
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant