Skip to content

feat(embed): MountHandle.getPayload() reads the current payload - #116

Merged
schlomo merged 5 commits into
mainfrom
feat/issue-104-getpayload
Aug 16, 2026
Merged

feat(embed): MountHandle.getPayload() reads the current payload#116
schlomo merged 5 commits into
mainfrom
feat/issue-104-getpayload

Conversation

@schlomo

@schlomo schlomo commented Aug 15, 2026

Copy link
Copy Markdown
Owner

Summary

Adds getPayload() to MountHandle (both mount() and mountStandaloneApp()) so a host can read the designer's current drawcustom YAML payload directly — the same string onSaveRequest would receive if the user hit Save right now — instead of DOM-scraping the shadow root for the Save button and simulating a click. That scrape is what the upstream OpenDisplay HA integration (PR #100) does today, and it hangs silently whenever a YAML error has disabled the Save button (see ADR-018, gap 1).

Closes #104.

Semantics (documented in docs/embedding.md)

getPayload() reuses onSaveRequest's own serializer path — no second serializer to drift out of sync — and resolves three edge cases so it can never disagree with what Save would send:

  • Pre-registration window (synchronous call right after mount()/mountStandaloneApp() returns, before React has committed/run effects): returns the bootstrap payload. Never throws, never returns undefined.
  • Mid-keystroke, pending debounce: the YAML editor commits typed text to the canvas model on an 80ms debounce (or on blur). getPayload() forces that flush before reading — a real Save click always blurs the editor first, so this matches exactly what a click would send instead of lagging behind it.
  • YAML editor blocked by a parse/schema error (Save itself disabled): returns the last valid payload — same as Save would have sent last, and the only way to read anything while Save is unusable.

Implementation

  • src/embed/host.tsDesignerHost.registerPayloadSource? mirrors registerPushTarget, but read-direction (ADR-018 seam grammar: typed read of designer output, no bidirectional shared state).
  • src/embed/mount.tsx — a payloadSource closure variable (parallel to the existing push queue) backs MountHandle.getPayload(); falls back to serializeYamlPayload(bootstrap?.elements ?? []) before anything registers.
  • src/ui/hooks/useProjectState.ts — exposes getElementsSnapshot(), a stable accessor onto the hook's own synchronously-updated elementsRef (updated before the debounce-triggered setElements, unlike a render-derived ref).
  • src/ui/components/YamlPanel.tsx — exposes its live flushYamlElementsSync through an optional flushPendingRef so the registered payload getter can force the same flush a real blur/timeout would trigger.
  • src/ui/App.tsx — wires the ref, registers host.registerPayloadSource(() => { flush(); return serializeYamlPayload(getElementsSnapshot()) }).
  • src/embed/types.tsMountHandle.getPayload(): string, documented.

Additive only: no existing MountOptions/MountHandle member changes shape or behavior.

Tests (TDD red-first)

tests/embed/get-payload.test.tsx — confirmed all 7 cases failed with getPayload is not a function before implementing, then green after:

  1. Matches onSaveRequest's payload via a real Save click.
  2. Reflects a setPayload() push once registered.
  3. Returns the bootstrap payload synchronously, before any React effect flush (mount deliberately not wrapped in act()).
  4. Never throws / never undefined for a standalone handle called immediately after mountStandaloneApp().
  5. A standalone handle's getPayload() works once loaded.
  6. Returns the last valid payload while the editor is blocked by a broken doc (Save disabled) — never the broken text.
  7. Forces a flush of a pending debounced valid edit so getPayload() never lags a Save click.

Gate

npm test        # 204 files / 1493 tests passed
npm run lint     # clean
npm run build    # ok
npm run build:lib  # ok
npm run test:e2e   # 49/49 passed

One tests/embed/standalone-host.test.tsx flake was observed on the first full-suite npm test run under this session's CPU contention (documented, pre-existing class per docs/testing.md's CI-runner-timeout note, commit 4ce984e) — reproduced-clean in isolation and on a clean re-run of the full suite; unrelated to this diff, not introduced by it.

🤖 Generated with Claude Code

Verified

  • 14+ behavior tests red-first, including real CodeMirror typing with userEvent-annotated transactions.
  • Semantics pinned by test: getPayload == what Save delivers (compared against onSaveRequest output); mid-debounce force-flush; last-valid payload under broken YAML; post-destroy throw; pre-registration fallback including queued setPayload (last-wins).
  • Real pre-existing race fixed: an external payload push now invalidates the pending editor draft (ADR-009 echo-contract reasoning documented in the fix commit); stale-draft overwrite reproduced red before the fix.
  • Two reviewer passes + Copilot findings all fixed; CI green on 6ae6226.
  • Commit f3a3892: integration testing against PR fix(embed): register the host push target at commit, not after paint #117's branch (registerPushTarget -> useLayoutEffect) found a commit-window defect that will manifest once both merge — registerPayloadSource stayed a passive useEffect, so a host push could apply live while getPayload() still read the stale bootstrap. Fixed by co-timing registerPayloadSource (and YamlPanel's usePublishedCallback) onto useLayoutEffect; red-before/green-after ordering-assertion test plus a self-contained MutationObserver regression guard for after the fix(embed): register the host push target at commit, not after paint #117 rebase. Full gate green (npm test, lint, build, build:lib, test:e2e).

Maintainer validation

Copilot AI balanced review requested due to automatic review settings August 15, 2026 22:26
@github-actions

github-actions Bot commented Aug 15, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-08-16 11:29 UTC

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds MountHandle.getPayload() for direct, synchronized payload reads without DOM-scraping Save controls.

Changes:

  • Adds payload-source bridge and public API.
  • Flushes pending YAML edits before serialization.
  • Documents and tests lifecycle edge cases.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/embed/types.ts Defines public API.
src/embed/host.ts Adds payload-source registration.
src/embed/mount.tsx Implements payload reads and fallback.
src/ui/App.tsx Registers synchronized serializer.
src/ui/hooks/useProjectState.ts Exposes current elements snapshot.
src/ui/components/YamlPanel.tsx Exposes pending-edit flush.
tests/embed/get-payload.test.tsx Covers payload-read behavior.
docs/embedding.md Documents API semantics.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/embed/mount.tsx Outdated
Comment on lines +274 to +278
// Nothing registered yet (pre-registration window, or the initial
// bootstrap load is still in flight for an async host): report the
// bootstrap payload — the same `elements` the shell is about to seed
// its state from — rather than throwing or returning nothing.
return serializeYamlPayload(bootstrap?.elements ?? [])

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed — fixed in 6ae6226: the pre-registration fallback now tracks the latest queued setPayload's parsed elements (last-wins, synchronous regression tests incl. destroy-before-registration).

Comment thread src/embed/types.ts Outdated
Comment on lines +121 to +124
* - **Never throws, never returns `undefined`** — including the brief
* window right after `mount()`/`mountStandaloneApp()` return but before
* React has committed and run its effects, when it reports the bootstrap
* payload the designer is about to render.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already fixed in 060971d — the guarantee is scoped to mounted handles, post-destroy throw documented and pinned by test.

Comment thread docs/embedding.md Outdated
Comment on lines +79 to +85
- **Before React has committed anything** (a synchronous call right after
`mount()`/`mountStandaloneApp()` returns, before the mount's internal push/read
registration effect has run): reports the **bootstrap payload** — the same
`elements` the shell is about to seed its state from for a synchronous host
(`mount({ payload })`), or a safe empty-list default while an async
bootstrap (the standalone SPA's IndexedDB/share-hash load) is still in
flight. Never throws, never returns `undefined`.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already fixed in 060971d — the guarantee is scoped to mounted handles, post-destroy throw documented and pinned by test.

@schlomo

schlomo commented Aug 15, 2026

Copy link
Copy Markdown
Owner Author

Merge order for the three useProjectState PRs (#117#114#116): all three edit the same registerPushTarget effect body. #117 goes first (lifecycle guarantee the others' tests rely on); after EACH merge the remaining branches get rebased locally + force-pushed-with-lease and the full gate rerun before the next merge. Conflict-resolution invariants for whoever rebases: the effect must stay useLayoutEffect (#117 — do not let a resolution silently revert it to useEffect), and the dependency array must union all three changes (#114 diff-cache semantics, #116 yamlDiscardPendingRef). Verified via git merge-tree: #117#116 is a real content conflict; #117#114 line-merges cleanly but is the same function — treat both as manual-verify.

schlomo and others added 5 commits August 16, 2026 13:15
Adds getPayload() to MountHandle (mount() and mountStandaloneApp()) so a
host can read the designer's current drawcustom YAML directly, instead of
DOM-scraping the shadow root for the Save button and simulating a click —
the upstream OpenDisplay HA integration (PR #100) did exactly that and hung
silently whenever a YAML error had disabled the button.

getPayload() reuses onSaveRequest's own serializer (no second source of
truth) and resolves three edge cases so it can never disagree with what
Save would send:

- Before React has committed anything (a synchronous call right after
  mount()/mountStandaloneApp() returns): reports the bootstrap payload —
  never throws, never returns undefined.
- Mid-keystroke, while the YAML editor's 80ms sync debounce is still
  pending: forces that flush before reading, matching what a real Save
  click sends (a click always blurs the editor first, flushing the
  debounce).
- While the YAML editor is blocked by a parse/schema error (Save itself is
  disabled): returns the last valid payload, same as Save would have sent
  last.

Implementation: extends the mount lifecycle's push-queue registration seam
(mount.tsx) with a read mirror (registerPayloadSource), the inverse
direction of registerPushTarget — a typed read of designer output per the
ADR-018 seam grammar, no bidirectional shared state. useProjectState exposes
a synchronous elements snapshot; YamlPanel exposes its live debounce-flush
function via a ref so the registered getter can force it before serializing.

Additive only — MountOptions/existing MountHandle members are unchanged.

Tests: tests/embed/get-payload.test.tsx (red-first — all 7 failed with
"getPayload is not a function" before this change): matches onSaveRequest
via a real Save click, reflects a setPayload push, returns the bootstrap
payload synchronously pre-effect-flush, never throws for a standalone
handle called immediately, returns the last valid payload while blocked,
and forces the debounce flush for a pending valid edit.

Docs: docs/embedding.md documents the getPayload() semantics and the three
edge-case decisions above.

Closes #104

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A `setPayload()` push replaces the payload wholesale, but a YAML edit still
parked in the editor's 80ms debounce survived it and was committed by the
next flush — over the payload the host had just pushed. `getPayload()`,
documented as a pure read, forces exactly that flush, so a host doing
`setPayload(next)` then `getPayload()` deterministically read back the
pre-push draft instead of `next`.

Make the push authoritative: `applyPayload` calls YamlPanel's new
`discardPendingYamlEdit` in the same synchronous path, before committing the
pushed elements, so the parked parse is gone and its timer cancelled before
any render or effect can observe them. Clearing the parse also lets the
existing (unannotated, ADR-009) external sync serialize the pushed payload
into the editor instead of deferring to the draft, and clearing the
self-echo suppression keeps a push that lands right after a flush from being
mistaken for the designer's own echo.

Also correct the `getPayload()` docs: it throws after `destroy()` like every
other handle method, and never resurrects a pre-push draft.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d push

The pre-registration `getPayload()` fallback serialized the original
bootstrap even when a `setPayload()` had already been accepted into
the pending-push queue during that same window — so
`mount(...); handle.setPayload(next); handle.getPayload()` returned
stale bootstrap YAML even though `next` is what the drained queue will
apply as the designer's payload once `registerPushTarget` runs.

Track the elements from the most recently queued `setPayload()` in the
mount closure and serialize those in the fallback when present,
falling back to bootstrap otherwise. Parsing/validation still happens
in `setPayload` itself, so throw semantics on invalid YAML are
unchanged.

Adds regression coverage: a single pre-registration setPayload,
several queued (last wins), and setPayload immediately followed by
destroy() before registration (no leak, no throw weirdness).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Integration testing against PR #117 (fix/issue-115-push-race) surfaced a
commit-window defect: #117 makes registerPushTarget (useProjectState)
a useLayoutEffect, but registerPayloadSource (App.tsx) stayed a passive
useEffect. Between commit and the passive flush, a host push already
applies live (pushTarget registered, so setPayload applies directly and
the pendingPayloadElements fallback never records it) while getPayload()
still falls back to the original bootstrap — stale YAML even though the
DOM already shows the pushed payload.

Fix: promote registerPayloadSource to useLayoutEffect, co-timed with
registerPushTarget. Safe pre-#117 too: on this branch registerPushTarget
is still passive, so registering the (pure, read-only) payload source
earlier cannot misorder anything — it only closes the window.

Same class in YamlPanel's usePublishedCallback (flushPendingRef /
discardPendingRef): also promoted to useLayoutEffect. Benign today (no
YAML draft can exist at the very first commit), but the same
un-co-timed-registration pattern.

Adds a red-before/green-after ordering-assertion test: React fires every
layout effect in a tree before any passive effect, so promoting only
registerPayloadSource flips the observed registration order even while
registerPushTarget stays passive on this branch. The direct
MutationObserver reproduction from the bug report needs both effects
mismatched in *type* (i.e. after #117 lands), so it's kept here as a
self-contained forward-looking regression guard instead.

docs/embedding.md: note the co-timed registration contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…elative order

Both channels are layout effects after the issue #115 fix landed on main, so
the pre-rebase premise (layout beats passive) is obsolete; the invariant is
that no microtask can observe the push channel without the read channel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@schlomo
schlomo force-pushed the feat/issue-104-getpayload branch from 07e9687 to dae67fc Compare August 16, 2026 11:16
@schlomo
schlomo merged commit 5be72ad into main Aug 16, 2026
4 of 5 checks passed
@schlomo
schlomo deleted the feat/issue-104-getpayload branch August 16, 2026 11:29
schlomo added a commit that referenced this pull request Aug 17, 2026
… scoping (#138)

Adversarial review of the layer-1 resolver found that several of the PR's
claims were only true of the helpers, not of what the user sees. Each fix
below comes with a test the reviewer's own sabotage fails.

Major:

- A synchronous mount failure left the resolver installed. The tier is
  installed before any DOM exists, so it now lives under the same pristine
  guarantee as the container (issue #116): `mountDesigner` keeps ONE
  `teardown` list, unwound by `destroy()` and by any failure before the first
  committed frame. Retrying a doomed mount no longer stacks orphan tiers.
- A host that took a call and never settled it left the referencing elements
  mid-load forever — no asset and no error, the one outcome issue #10
  forbids. Resolver calls now time out (`HOST_ASSET_TIMEOUT_MS`, 15s) and
  settle as the ordinary explicit render-error, with the retry window measured
  from there. No AbortSignal in this layer, deliberately.

Medium:

- `destroy()` forgot the mount's cache but not the bytes it produced: the
  parsed opentype font, the core font registry entry and the CSS `@font-face`
  outlived it, so a second host on the page inherited the first host's file.
  Caches now register with `registerHostAssetEvictor` and are pruned per
  `(kind, name)` on dispose; an evicted font is marked unavailable so text
  falls back to the explicit error, not to wrong metrics.
- Host-added `FontFace` objects are tracked and removed from `document.fonts`
  on dispose — add count equals delete count. Content-map faces pre-date this
  tier and keep their existing document-lifetime behaviour.
- Cache keys and `hasHostSuppliedAsset` are `(kind, name)`-scoped end to end
  (probe, Content Manager badge, missing-asset banner, dlimg prune), so a font
  answer can no longer badge or retain an image of the same name. The joiner
  is a JSON key, which also removes the literal NUL byte that made
  `host-resolver.ts` a binary blob in git (the issue #125 lesson); the other
  literal NULs in the repo, in `useStableAssetKeys.test.ts`, are escaped too.
- Docs no longer imply a self-healing TTL: a declined asset is retried on the
  next asset-affecting load pass that runs after the 30s window — there is no
  background timer and nothing wakes the designer.

Minor:

- Two-mount semantics are pinned: most-recent-serves for new resolutions, and
  an asset either live mount was supplied stays recognised, so a second mount
  appearing cannot prune an image the first is painting. Docs state the lossy
  edge.
- A host URL answer is fetched to an `ArrayBuffer` instead of interpolated
  into a CSS `url()`, so a media route serving `logo (1).ttf` loads instead of
  silently failing.
- Badge wording matches the label the code renders (`Host`, `Missing`).
- The demo host font is emitted from `src/assets/fonts/rbm.ttf` at build time
  (`tools/demoHostAssets.ts`) rather than committed as a second 165 KB copy.
- `AssetKind` is exported from `src/embed`, so a host adapter can name the
  discriminator it switches on.

Coverage added for the properties the reviewer disproved: tier order (an
uploaded or bundled asset never reaches the resolver), first-frame install
position, and standalone parity as a microtask probe rather than a helper's
return value.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
schlomo added a commit that referenced this pull request Aug 18, 2026
The fourth ADR-018 seam (issue #109): a host renders the payload itself —
a real server-side render, not another client approximation — and the
designer shows that image in place of its own preview. This is also the
ADR-007 pixel-parity reference the ADR promised: a true Pillow render
available inside the designer.

Contract (additive, `src/embed/types.ts`):

    renderPreview?: (payload, { targetId, service }) => Promise<Blob | string>

- The ids travel in a context object, like the actions seam, so issue
  #105's service-options seam lands additively. `service` is that slice,
  minimal today (`dither`, in the drawcustom service option's own
  `0 | 1 | 2` domain) and required rather than optional: the designer
  always knows its own dither mode, so a host never guards for it.
- A stable closure fixed at mount — there is no `setRenderPreview()`
  (ADR-018: data is pushed, functions are not).

UI (maintainer rulings 2026-08-16):

- A **Display preview** toggle immediately right of the Canvas heading,
  rendered only where a provider exists — the same conditional-chrome
  rule actions and targets follow, so standalone gains nothing.
- What shipped is a *mode*, not the ADR's sketched overlay: the host
  render replaces the paper's content, inheriting the existing zoom
  system instead of carrying a second one.
- Editing pauses. Add-element buttons, canvas selection/drag/nudge,
  undo/redo, property fields, element reordering, Clear all and Load
  Demo are disabled exactly as they are while the YAML doc is blocked
  (issue #35) — one disabled-mutation concept, two reasons for it. The
  YAML editor goes read-only (new CodeMirror compartment) rather than
  hidden, so it stays selectable and copyable.
- Copy PNG, Download PNG, zoom and the dither control stay live, acting
  on the host render; a dither change re-requests it, so the preview can
  never contradict the designer's own dither setting.
- Re-requests are debounced (250ms) with a subtle loading chip, and each
  answer is matched to its request by token: a superseded slow render is
  discarded, never painted (the #115/#116 lesson, applied to responses).
- A rejection (or synchronous throw) is stated in the preview area with
  the host's own message and drops the image — a clear error beats a
  stale or wrong render, and there is no silent fall back to the
  designer's own rasterization.

The demo page implements the provider with a deliberately crude
host-side rasterizer (`demo/preview-render.js`: its own monospace font,
its own template resolution, its own 1-bit quantization) plus an
artificial delay and a "Simulate preview failure" button. It is
deliberately not the designer's renderer — the visible difference is the
point of the seam, and it keeps the ADR-018 litmus test honest.

Tests: tests/embed/host-preview.test.tsx (provider-gated chrome, request
payload/context, inertness and restore, Copy PNG against the host
render, dither re-request, stale-response discard, error state) and
tests/e2e/embed-preview.spec.ts (real PNG round trip on the demo page,
pixel signature changing with dither, editing live again on exit).

Closes #109

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
schlomo added a commit that referenced this pull request Aug 18, 2026
* feat(embed): host-rendered Display preview seam (renderPreview)

The fourth ADR-018 seam (issue #109): a host renders the payload itself —
a real server-side render, not another client approximation — and the
designer shows that image in place of its own preview. This is also the
ADR-007 pixel-parity reference the ADR promised: a true Pillow render
available inside the designer.

Contract (additive, `src/embed/types.ts`):

    renderPreview?: (payload, { targetId, service }) => Promise<Blob | string>

- The ids travel in a context object, like the actions seam, so issue
  #105's service-options seam lands additively. `service` is that slice,
  minimal today (`dither`, in the drawcustom service option's own
  `0 | 1 | 2` domain) and required rather than optional: the designer
  always knows its own dither mode, so a host never guards for it.
- A stable closure fixed at mount — there is no `setRenderPreview()`
  (ADR-018: data is pushed, functions are not).

UI (maintainer rulings 2026-08-16):

- A **Display preview** toggle immediately right of the Canvas heading,
  rendered only where a provider exists — the same conditional-chrome
  rule actions and targets follow, so standalone gains nothing.
- What shipped is a *mode*, not the ADR's sketched overlay: the host
  render replaces the paper's content, inheriting the existing zoom
  system instead of carrying a second one.
- Editing pauses. Add-element buttons, canvas selection/drag/nudge,
  undo/redo, property fields, element reordering, Clear all and Load
  Demo are disabled exactly as they are while the YAML doc is blocked
  (issue #35) — one disabled-mutation concept, two reasons for it. The
  YAML editor goes read-only (new CodeMirror compartment) rather than
  hidden, so it stays selectable and copyable.
- Copy PNG, Download PNG, zoom and the dither control stay live, acting
  on the host render; a dither change re-requests it, so the preview can
  never contradict the designer's own dither setting.
- Re-requests are debounced (250ms) with a subtle loading chip, and each
  answer is matched to its request by token: a superseded slow render is
  discarded, never painted (the #115/#116 lesson, applied to responses).
- A rejection (or synchronous throw) is stated in the preview area with
  the host's own message and drops the image — a clear error beats a
  stale or wrong render, and there is no silent fall back to the
  designer's own rasterization.

The demo page implements the provider with a deliberately crude
host-side rasterizer (`demo/preview-render.js`: its own monospace font,
its own template resolution, its own 1-bit quantization) plus an
artificial delay and a "Simulate preview failure" button. It is
deliberately not the designer's renderer — the visible difference is the
point of the seam, and it keeps the ADR-018 litmus test honest.

Tests: tests/embed/host-preview.test.tsx (provider-gated chrome, request
payload/context, inertness and restore, Copy PNG against the host
render, dither re-request, stale-response discard, error state) and
tests/e2e/embed-preview.spec.ts (real PNG round trip on the demo page,
pixel signature changing with dither, editing live again on exit).

Closes #109

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(embed): preview needs valid YAML, carries canvas geometry, leaks nothing

Maintainer rulings on the #109 preview seam review (2026-08-17).

- Entering preview requires a parseable document: the toggle is disabled
  with a stated reason ("Fix the YAML errors to preview"), the same
  disabled-with-a-reason pattern payload-carrying host actions use.
  Previewing a blocked doc would render the last-valid payload while the
  editor shows something else. The converse is impossible — the editor is
  read-only inside preview and `setPayload()` parses or throws — so the
  YAML-error overlay can never explain a host render; the canvas now
  guards that structurally.
- `HostPreviewContext` gains `display: { width, height, rotation }` — the
  oriented logical surface the payload's coordinates are authored against
  (issue #139). A resolution pick or re-orientation re-requests through
  the existing debounce and token machinery, and the demo host renders at
  that geometry instead of its own stored capabilities.
- The request effect's cleanup retires its token, so a response arriving
  after unmount no longer mints an object URL nothing can revoke.
- `renderPreview` must be a function at `mount()`, like a malformed
  actions/targets list.
- A download taken in preview mode is named `display-preview-<session>.png`
  so it can sit beside the designer's own export and be diffed.
- Docs: the debounce covers host pushes, display config, target and
  dither — not typing, which preview mode makes impossible; host action
  buttons stay enabled deliberately; the conditional-chrome claim is
  stated as visual parity, not DOM parity (the heading's flex wrapper
  ships unconditionally); the demo's frozen clock is documented as a
  request-time snapshot.
- Tests: regression pins for payload-push and target re-requests, the
  URL-string path, a non-image answer, destroy-mid-request, the
  download name and the mount-time validation; `testTimeout: 30_000` on
  every full-mount embed file (the documented 5s-default gotcha).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(embed): lock indicator, single-transition rotation, real demo preview, one-button add-display

Four maintainer manual-validation findings on the Display preview seam
(issue #109, ADR-018), fixed on PR #143's branch:

1. YAML header shows "YAML — locked by Display preview" while preview mode
   is active (readOnly), muted-token suffix, gone on exit.

2. A geometry change (resolution pick, re-orientation) while previewing
   now clears the image immediately — blank paper + loading chip — before
   the debounced re-request even fires, instead of letterboxing the stale
   image into the new canvas box for ~250ms (the video-evidence double
   resize). Dither/payload/target changes are unaffected: nothing about
   them can change the image's dimensions, so the old render still stays
   up in place until the new one lands.

3. Root cause of the demo's "| -" garbage: demo/preview-render.js parsed
   drawcustom YAML with a line-by-line `key: value` regex that could not
   handle a block scalar (`value: |-` / `>-`), which the real serializer
   emits for any multi-line or long text value — it rendered the scalar's
   own marker as the text. Replaced per maintainer ruling: the demo's fake
   `renderPreview` now round-trips the designer's own PNG export via a new
   `MountHandle.getPngBlob()` (full font/renderer fidelity, the same "read
   access" shape `getPayload()` established) and stamps a small
   deterministic info strip on top, so the image is still unmistakably the
   host's own render. Deletes the crude rasterizer.

4. Demo UX: the "Push display list" and garage-only "Add a display"
   buttons are one repeatable "Add a display" button — each press extends
   the targets list by the next fixture display; once all four are in,
   the button disables ("All fixture displays added"). Remove-selected and
   the other demo affordances are untouched.

New: `MountHandle.getPngBlob()` / `DesignerHost.registerRenderSource`,
wired the same way `getPayload()`/`registerPayloadSource` are (a
parent-owned ref published from DesignerCanvas, now shared via the new
`usePublishedCallback` hook alongside YamlPanel's flush/discard refs).

Tests: yaml-panel-preview-lock-indicator, use-display-preview-geometry-clear
(hook-level, fake timers), get-png-blob (full mount, stubbed 2D context),
embed-targets.spec.ts updated for the consolidated button. All red-checked
against pre-fix code before the fix landed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
schlomo added a commit that referenced this pull request Aug 18, 2026
… scoping (#138)

Adversarial review of the layer-1 resolver found that several of the PR's
claims were only true of the helpers, not of what the user sees. Each fix
below comes with a test the reviewer's own sabotage fails.

Major:

- A synchronous mount failure left the resolver installed. The tier is
  installed before any DOM exists, so it now lives under the same pristine
  guarantee as the container (issue #116): `mountDesigner` keeps ONE
  `teardown` list, unwound by `destroy()` and by any failure before the first
  committed frame. Retrying a doomed mount no longer stacks orphan tiers.
- A host that took a call and never settled it left the referencing elements
  mid-load forever — no asset and no error, the one outcome issue #10
  forbids. Resolver calls now time out (`HOST_ASSET_TIMEOUT_MS`, 15s) and
  settle as the ordinary explicit render-error, with the retry window measured
  from there. No AbortSignal in this layer, deliberately.

Medium:

- `destroy()` forgot the mount's cache but not the bytes it produced: the
  parsed opentype font, the core font registry entry and the CSS `@font-face`
  outlived it, so a second host on the page inherited the first host's file.
  Caches now register with `registerHostAssetEvictor` and are pruned per
  `(kind, name)` on dispose; an evicted font is marked unavailable so text
  falls back to the explicit error, not to wrong metrics.
- Host-added `FontFace` objects are tracked and removed from `document.fonts`
  on dispose — add count equals delete count. Content-map faces pre-date this
  tier and keep their existing document-lifetime behaviour.
- Cache keys and `hasHostSuppliedAsset` are `(kind, name)`-scoped end to end
  (probe, Content Manager badge, missing-asset banner, dlimg prune), so a font
  answer can no longer badge or retain an image of the same name. The joiner
  is a JSON key, which also removes the literal NUL byte that made
  `host-resolver.ts` a binary blob in git (the issue #125 lesson); the other
  literal NULs in the repo, in `useStableAssetKeys.test.ts`, are escaped too.
- Docs no longer imply a self-healing TTL: a declined asset is retried on the
  next asset-affecting load pass that runs after the 30s window — there is no
  background timer and nothing wakes the designer.

Minor:

- Two-mount semantics are pinned: most-recent-serves for new resolutions, and
  an asset either live mount was supplied stays recognised, so a second mount
  appearing cannot prune an image the first is painting. Docs state the lossy
  edge.
- A host URL answer is fetched to an `ArrayBuffer` instead of interpolated
  into a CSS `url()`, so a media route serving `logo (1).ttf` loads instead of
  silently failing.
- Badge wording matches the label the code renders (`Host`, `Missing`).
- The demo host font is emitted from `src/assets/fonts/rbm.ttf` at build time
  (`tools/demoHostAssets.ts`) rather than committed as a second 165 KB copy.
- `AssetKind` is exported from `src/embed`, so a host adapter can name the
  discriminator it switches on.

Coverage added for the properties the reviewer disproved: tier order (an
uploaded or bundled asset never reaches the resolver), first-frame install
position, and standalone parity as a microtask probe rather than a helper's
return value.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
schlomo added a commit that referenced this pull request Aug 18, 2026
…ayer 1) (#144)

* feat(embed): host asset resolver for payload fonts and images (#138 layer 1)

Hand-written drawcustom payloads name fonts and images the way the host
names them (`Ubuntu-R.ttf`, `logo.png` — the integration's own font/media
directories). The designer had no way to resolve those, so such references
broke in embed mode. `MountOptions.resolveAsset(kind, name)` closes the gap
as the LAST resolution tier, behind the local content map and the bundled
assets (ADR-002 amended):

- `src/core/assets/host-resolver.ts` — install/dispose per mount, per-mount
  cache keyed by (kind, name), in-flight de-duplication, and a synchronous
  "already supplied" probe for the render-time code paths.
- The three asset loaders (opentype, CSS font-face, dlimg images) consult the
  tier only after local resolution fails, and only when a resolver is
  installed — with none, they take exactly their previous code path.
- Anything the host cannot supply (null, rejection, out-of-contract value)
  settles as the existing explicit render-error state, naming the asset and
  the host. No substituted font, no silent gap.
- Cache policy: supplied assets for the mount's lifetime (loading re-runs on
  every payload asset-key change and must not cost a round trip per edit);
  unsupplied ones for 30s and then retried, so a host store that comes back
  needs no remount.
- Content Manager badges a host-supplied key HOST instead of MISSING, and the
  "Missing local assets" banner no longer fires for it.
- Search paths stay host-side: the contract is name -> asset, with no domain
  vocabulary in the designer (ADR-018).

The demo host serves one font (URL answer) and one image (Blob answer) from
demo/assets/ and declines a third name on purpose, so the round trip and the
error state are both visible on the page and in e2e.

Layer 2 (asset catalog + select-or-upload) stays deferred.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(embed): harden the host asset resolver — timeouts, eviction, kind scoping (#138)

Adversarial review of the layer-1 resolver found that several of the PR's
claims were only true of the helpers, not of what the user sees. Each fix
below comes with a test the reviewer's own sabotage fails.

Major:

- A synchronous mount failure left the resolver installed. The tier is
  installed before any DOM exists, so it now lives under the same pristine
  guarantee as the container (issue #116): `mountDesigner` keeps ONE
  `teardown` list, unwound by `destroy()` and by any failure before the first
  committed frame. Retrying a doomed mount no longer stacks orphan tiers.
- A host that took a call and never settled it left the referencing elements
  mid-load forever — no asset and no error, the one outcome issue #10
  forbids. Resolver calls now time out (`HOST_ASSET_TIMEOUT_MS`, 15s) and
  settle as the ordinary explicit render-error, with the retry window measured
  from there. No AbortSignal in this layer, deliberately.

Medium:

- `destroy()` forgot the mount's cache but not the bytes it produced: the
  parsed opentype font, the core font registry entry and the CSS `@font-face`
  outlived it, so a second host on the page inherited the first host's file.
  Caches now register with `registerHostAssetEvictor` and are pruned per
  `(kind, name)` on dispose; an evicted font is marked unavailable so text
  falls back to the explicit error, not to wrong metrics.
- Host-added `FontFace` objects are tracked and removed from `document.fonts`
  on dispose — add count equals delete count. Content-map faces pre-date this
  tier and keep their existing document-lifetime behaviour.
- Cache keys and `hasHostSuppliedAsset` are `(kind, name)`-scoped end to end
  (probe, Content Manager badge, missing-asset banner, dlimg prune), so a font
  answer can no longer badge or retain an image of the same name. The joiner
  is a JSON key, which also removes the literal NUL byte that made
  `host-resolver.ts` a binary blob in git (the issue #125 lesson); the other
  literal NULs in the repo, in `useStableAssetKeys.test.ts`, are escaped too.
- Docs no longer imply a self-healing TTL: a declined asset is retried on the
  next asset-affecting load pass that runs after the 30s window — there is no
  background timer and nothing wakes the designer.

Minor:

- Two-mount semantics are pinned: most-recent-serves for new resolutions, and
  an asset either live mount was supplied stays recognised, so a second mount
  appearing cannot prune an image the first is painting. Docs state the lossy
  edge.
- A host URL answer is fetched to an `ArrayBuffer` instead of interpolated
  into a CSS `url()`, so a media route serving `logo (1).ttf` loads instead of
  silently failing.
- Badge wording matches the label the code renders (`Host`, `Missing`).
- The demo host font is emitted from `src/assets/fonts/rbm.ttf` at build time
  (`tools/demoHostAssets.ts`) rather than committed as a second 165 KB copy.
- `AssetKind` is exported from `src/embed`, so a host adapter can name the
  discriminator it switches on.

Coverage added for the properties the reviewer disproved: tier order (an
uploaded or bundled asset never reaches the resolver), first-frame install
position, and standalone parity as a microtask probe rather than a helper's
return value.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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.

MountHandle.getPayload(): expose the current payload to hosts

2 participants