diff --git a/docs/embedding-ac-pieces.md b/docs/embedding-ac-pieces.md
new file mode 100644
index 0000000000..6653021ffa
--- /dev/null
+++ b/docs/embedding-ac-pieces.md
@@ -0,0 +1,137 @@
+# Embedding AC pieces: framing
+
+The host owns the preview rectangle. AC fills that rectangle. A 4:3 preview
+means a width-to-height ratio; it does not imply a palette, bezel, pixel density,
+or filter.
+
+## Set the viewport in the host
+
+```html
+
+
+
+```
+
+```css
+.piece-preview {
+ position: relative;
+ width: 100%;
+ aspect-ratio: 4 / 3;
+ overflow: hidden;
+}
+.piece-preview > iframe {
+ position: absolute;
+ inset: 0;
+ display: block;
+ width: 100%;
+ height: 100%;
+ margin: 0;
+ padding: 0;
+ border: 0;
+}
+```
+
+The wrapper establishes a height before the iframe fills it. `display: block`
+avoids the baseline space below inline iframes. Remove host padding and borders
+when the preview should touch its container edges. In a native app, apply the
+same ratio to the WebView's layout bounds.
+
+An iframe is a separate viewport: style its element in the host, and configure
+the AC document with its URL. Host CSS cannot reach into a cross-origin frame.
+`object-fit` on an iframe does not resize the child canvas. Piece code should
+compose against `screen.width` and `screen.height`, not the surrounding app.
+
+## Configure the runtime
+
+| Parameter | Effect | Use |
+| --- | --- | --- |
+| `nogap=true` | Sets runtime gap to zero; canvas wrapper fills its viewport and loses rounded corners/checkerboard background. | Edge-to-edge previews. |
+| `nolabel=true` | Suppresses the runtime corner label/HUD. | Host supplies its own controls. |
+| `autoreload=true` | Takes available piece updates automatically instead of showing the update badge. | Live authoring previews. |
+| `noauth=true` | Skips the embedded runtime's authentication startup. | Draft previews whose host handles account access; omit when the embedded piece needs authentication. |
+| `preview=true` | Selects the piece's `preview()` lifecycle behavior when present. | Intentional preview rendering, not an arbitrary preview identifier. |
+| `icon=true` | Selects icon rendering behavior. | Intentional icon generation. |
+| `density=…` | Changes the runtime's rendering density. | Deliberate resolution tuning, independently of aspect ratio. |
+
+These flags are read by presence in several runtime paths. Remove a flag to
+disable it; do not rely on `nogap=false` or `nolabel=false`.
+
+The ordinary embedded authoring baseline is
+`?nogap=true&nolabel=true&autoreload=true`. A blank draft runtime can start at
+`/wipe` with `noauth=true` added. Keep query parameters before any `#fragment`.
+`allow="autoplay"` permits autoplay within the iframe policy; it does not
+guarantee audio without a browser-required user gesture.
+
+Implementation: [boot parameters](../system/public/aesthetic.computer/boot.mjs),
+[BIOS framing](../system/public/aesthetic.computer/bios.mjs),
+[runtime CSS](../system/public/aesthetic.computer/style.css), and
+[piece lifecycle/label handling](../system/public/aesthetic.computer/lib/disk.mjs).
+
+## Keep framing through source changes
+
+Changing source should not require navigating the iframe again. The native
+Aesel bridge waits for runtime readiness, then injects a `dropped:piece` message
+from inside the WebView:
+
+```js
+window.acSEND({
+ type: "dropped:piece",
+ content: {
+ name: "aesel-preview",
+ source,
+ search: "nogap=true&nolabel=true&autoreload=true",
+ isKidLisp: false,
+ },
+});
+```
+
+This is an internal runtime call, not a generic cross-origin iframe messaging
+API. A browser host on another origin needs an explicit bridge; it cannot call
+`frame.contentWindow.acSEND` directly.
+
+The URL configures initial framing. The message's `search` preserves the piece
+layer's display flags during replacement. The worker remembers label suppression
+for its lifetime, and BIOS preserves view parameters when rewriting browser
+history. Carry intentional `preview` or `icon` flags too, but do not add them as
+tracking tags. A message being accepted is not proof of a successful paint;
+authoring bridges should wait for rendering evidence and report runtime errors.
+
+Existing integrations:
+
+- [Aesel URL construction](../apple/aesel/Sources/Session.swift),
+ [native injection and rendering evidence](../apple/aesel/Sources/AeselPieceView.swift).
+- [Shared “Try” page](../system/public/aesthetic.computer/lib/try/shared-page.mjs):
+ constructs embedded URLs with `nogap`, `nolabel`, and `noauth`.
+- [Aesel phone sessions](../aesel/phone/session.mjs): published-piece preview URLs.
+- [Walkieware host](../apple/walkieware/Resources/Web/engine.mjs),
+ [native source bridge](../apple/walkieware/Sources/WalkiewareAccount.swift).
+
+## Diagnose an unwanted gap
+
+Measure three rectangles separately: the host wrapper, iframe viewport, and
+AC's `#aesthetic-computer` wrapper. In `nogap` mode, the latter should start at
+`0,0` and fill the child viewport. Check screenshots after first paint, a source
+replacement, a resize/rotation, and a density change.
+
+| Symptom | Inspect |
+| --- | --- |
+| Space outside the iframe | Host margin, padding, border, inline baseline, or a fixed height competing with `aspect-ratio`. |
+| Checkerboard or rounded corners inside it | `body.nogap` and the effective BIOS gap. |
+| Label returns after an edit | Initial URL plus source message `search`; worker label persistence. |
+| Correct frame, padded drawing | Piece composition and its own `screen`/reframe calls. |
+| Tiny seam on one edge | Wrapper/canvas CSS dimensions and fractional scaling; zero-gap BIOS uses `100%` dimensions. |
+
+BIOS must resolve an omitted `frame()` gap from `lastGap`/`startGap` **before**
+toggling `body.nogap`. Otherwise a density change calling `frame()` can remove
+the class while the effective gap remains zero. A useful regression sequence
+is `?nogap=true`, followed by Cmd/Ctrl+Plus or an `ac-density-change` event:
+the class and edge-to-edge bounds must survive both.
+
+`window.acFORCE_NOGAP = true` is an internal host hook used by packed bundles
+that have no normal query string. BIOS honors it on every reframe, including
+explicit nonzero gap requests. It is not a URL parameter, and an ordinary web
+embed should use `nogap=true` rather than depend on script injection.
diff --git a/system/public/aesthetic.computer/bios.mjs b/system/public/aesthetic.computer/bios.mjs
index 823de07783..cdadf5c412 100644
--- a/system/public/aesthetic.computer/bios.mjs
+++ b/system/public/aesthetic.computer/bios.mjs
@@ -1744,13 +1744,13 @@ async function boot(parsed, bpm = 60, resolution, debug) {
}
}
+ if (gap === undefined) gap = lastGap ?? startGap;
+ lastGap = gap;
+
gap === 0
? document.body.classList.add("nogap")
: document.body.classList.remove("nogap");
- if (gap === undefined) gap = lastGap ?? startGap;
- lastGap = gap;
-
// Cache the current canvas if needed (only if not already frozen - resize handler handles the capture now)
if (
freezeFrame &&