# Static builds and embedding

> Bundle a gallery into a hostable SPA with loom build, then deep-link one scene per iframe using the target/chrome/theme URL contract.

Source: https://docs.astra-void.xyz/loom/guides/static-builds-and-embedding/

`loom build` runs Vite's production build over the same gallery the dev server shows, and emits a
self-contained client-only SPA. Nothing on the server side is required to host it. Its reason for
existing is embedding: a docs site can iframe a single scene, and the reader gets a real interactive
Roblox UI instead of a screenshot.

```bash title="Build a gallery"
pnpm exec loom build ~/code/my-ui --targets src/scenes --out dist-preview --base ./
```

> **Or plain `vite build`**
>
> The command is `loomPreview({ targets })` under `vite build` and nothing else. A project whose
> `vite.config.ts` passes `targets` gets the identical bundle from `pnpm vite build`, with `outDir` and
> `base` coming from its own config. See [Vite
> integration](https://docs.astra-void.xyz/loom/guides/vite-integration.md#6-gallery-mode-from-the-plugin).

## Flags

```text
loom build [dir] --targets [glob] [--out <dir>] [--base <path>]
```

| Flag | Default | Notes |
| --- | --- | --- |
| `--targets` | **required** | Same semantics as `preview`. Without it the command exits: `loom: build requires --targets [glob] (the static gallery is target-driven)`. |
| `--out` | `dist-preview` | Resolved against the current working directory, not the project dir. |
| `--base` | `./` | Public path. The relative default is what makes the output host-anywhere. |

Unlike `preview`, `build` does **not** read `loom.config.ts`. Pass `--targets` explicitly every time.

> **`--out` is emptied before writing**
>
> The build runs with `emptyOutDir: true`, because the output directory normally sits outside Vite's
> root and Vite would otherwise refuse to clear it. Point `--out` at a directory that belongs to loom
> and nothing else. Aiming it at, say, your site's whole `public/` would delete the rest of that
> directory's contents.

## What comes out

```text title="dist-preview/"
index.html
assets/index-<hash>.js          # shell + runtime + adapter
assets/index-<hash>.css         # gallery chrome
assets/loom_layout_wasm_bg-<hash>.wasm
assets/CardScene.loom-<hash>.js     # one async chunk per target
assets/CounterScene.loom-<hash>.js
assets/roboto-latin-400-normal-<hash>.woff2   # …and ~110 more, since 0.9.0
```

Each target becomes its own lazily-imported chunk, so a gallery with fifty scenes still loads one
scene's code per view. The `.wasm` binary dominates the transfer size — about 178 kB, roughly 68 kB
gzipped, from the `wasm-opt`-processed build published packages ship — and it is fetched once and
cached. A binary built locally with the default `dev` profile skips `wasm-opt` and is roughly four
times larger.

As of `0.9.0` the [engine's typefaces](https://docs.astra-void.xyz/loom/guides/fonts.md) are part of that output: ~2.8 MB of
`woff2` across the whole set, in about 110 files. What a *page* downloads is unchanged — Fontsource
declares per-script `unicode-range` subsets, so a browser fetches only the families and scripts a
scene actually paints with.

Under the hood the plugin hands Rollup a genuine module graph rather than the dev server's virtual
modules: a generated `index.html` as the build input, whose script is a generated entry that imports
the globals installer *first*, then a generated target map of relative `import()`s — one async chunk
each — then the shell. Globals-first is why a built gallery never throws `UDim2 is not defined`.

## `rbxassetid://` in a static build

A page served from a bucket has no dev server to resolve asset ids for it, so until `0.7.0` they
simply did not paint outside `loom preview`. The build now does the work up front: it works out
which ids the page will ask for, resolves each one through Roblox's thumbnail API, downloads the
image into the output, and writes a manifest the page reads on first use.

```text title="dist-preview/"
__loom/assets.json              # { "6031075938": "__loom/asset/6031075938.png" }
__loom/asset/6031075938.png     # the downloaded image
```

Nothing else changes: `Image="rbxassetid://6031075938"` paints from your own origin, with no runtime
request to Roblox.

Working out *which* ids takes two passes, because reading the bundle is not enough on its own.

- **The scan** reads the emitted output for `rbxassetid://<digits>`, which finds every id a source
  spells out.
- **The prerender** *(`0.9.5`)* covers the ids a source builds at runtime. A component library
  composes them — `` `rbxassetid://${iconId}` `` over a table of ids — and after bundling that is a
  prefix and a few hundred bare numbers no scan can tell from any other number, so a gallery built
  from a real UI library used to paint none of its icons. Instead, the build *runs* your scenes:
  every gallery target is mounted in node and its live tree is read for the images it actually
  holds. A 700-icon set contributes the dozen icons your scenes render, not all 700.

Three things are worth knowing before you rely on it:

- **The first render is what gets baked.** An image the initial mount never reaches — one behind a
  hover state, or supplied by a later fetch — stays unresolved. Give those a real URL, or install
  your own `setImageResolver`.
- **The prerender needs targets.** It runs for gallery builds — `loom build`, a plugin configured
  with `targets`, the Astro and Next embeds. A build configured with a single client `entry` keeps
  the scan alone, since that entry mounts itself on import rather than exposing a scene to mount.
- **A build never fails over an image.** An id that will not resolve — offline, deleted, moderated —
  is warned about and left out of the manifest, and so is a target that will not render during the
  prerender. Composition the prerender could not account for is warned about as well, rather than
  leaving a blank image unexplained.

To keep a build off the network entirely, turn the bake off wherever the build is configured —
`loomPreview({ assets: false })`, `loom build --no-assets`, or `assets: false` on `buildGallery` and
`withLoomGallery` *(`0.7.1`; earlier versions only had the plugin option)*.

## The URL contract

The built page reads six query parameters. This is the whole embedding API.

| Parameter | Values | Effect |
| --- | --- | --- |
| `target` | a target's relative path, URL-encoded | Selects and mounts that target on load, without a hash. |
| `chrome` | `none` | Hides the sidebar and renders one target full-bleed. Any other value (or absent) keeps the full chrome. |
| `theme` | `light` \| `dark` | Sets the stage background and seeds `PlayerGui.LoomTheme`. Anything other than `light` is treated as `dark`. |
| `background` | a CSS colour | *(`0.6.2`)* Paints the stage backdrop, overriding the one `theme` would have used and leaving the rest of the palette alone. |
| `base` | px (bare `?base` is 960), or `none` / `off` / `0` | *(`0.6.4`)* Keeps this logical viewport and scales the stage down to fit, instead of letting the scene reflow into the real one. Off unless asked for as of `0.10.2` — see [Previews on a phone](https://docs.astra-void.xyz/loom/guides/mobile-previews.md#base-keeps-a-wide-composition). |
| `debug` | present, or `0`/`false`/`off`/`no`/`none` for off | *(`0.10.0`)* Opens the [debug panel](https://docs.astra-void.xyz/loom/guides/debug-panel.md) on load. A bare `?debug` counts as on, so a host page can template a boolean straight through. |

```text title="Deep-link one scene, chromeless, light"
/loom-preview/index.html?target=src%2Fscenes%2FCounter.loom.tsx&chrome=none&theme=light
```

In chromeless mode there is no hash routing — the mount is fixed to `?target=`. In full-chrome mode
the shell uses `#/<relPath>` and `?target=` only seeds the initial selection.

`?debug=1` is the only way a debug panel appears in a `chrome=none` embed: the toggle is remembered
per tab so an HMR reload does not close it, and an embed deliberately opts out of that memory so a
panel never turns up unasked on somebody's docs page.

### `?background=` for a backdrop that is not either theme

`theme` picks a whole palette — chrome, text, and one of loom's two backdrops (`#14161a` or
`#f6f9fc`). `background` overrides just the backdrop, so a plain white stage under the light palette
is `?theme=light&background=white`, and `transparent` lets the host page show through the iframe. It
applies in both gallery modes and to the static build.

> **Hex, without the `#`**
>
> A literal `?background=#ffffff` never reaches the gallery: `#` opens the URL fragment, which is also
> where the gallery keeps its route. Both spellings that survive the trip are accepted — percent-encoded
> (`%23ffffff`) and bare digits (`ffffff`). No CSS named colour is spelled with hex digits alone, so
> the bare form is unambiguous.

Only colours are accepted, through an allowlist: hex, a bare identifier, and the functional forms
(`rgb()`, `hsl()`, `oklch()`, `color()`, `light-dark()`, …) with arguments that cannot open a nested
function. A gradient, a `url(...)` — anything that could turn a query parameter into a network fetch
— is ignored and the theme's own backdrop stands. A misspelled colour name passes the shape test and
simply fails to apply in the browser, which lands in the same place.

The backdrop is decided in the page's `<head>` from `location.search` alone, before the first paint,
so an embed no longer flashes the dark default while the bundle, the WASM engine and the target chunk
load. That was up to half a second of black on a light or custom-coloured frame, repeated every time
a host control changed a parameter and reloaded the iframe.

## Live theme switching

A host page can flip an already-loaded frame's theme without reloading it, by posting a message:

```js title="From the embedding page"
// GALLERY_ORIGIN is where the built gallery is served from — same-origin
// embeds can use window.location.origin.
frame.contentWindow.postMessage(
  { type: "loom-theme", theme: "dark" },
  GALLERY_ORIGIN,
);
```

The backdrop moves the same way, next to it — so a docs page that switches theme at runtime need not
reload the frame to re-point the stage:

```js title="Re-point the backdrop live"
frame.contentWindow.postMessage(
  { type: "loom-background", background: "#0b0d10" },
  GALLERY_ORIGIN,
);

// With no colour, the backdrop goes back to whatever the theme paints.
frame.contentWindow.postMessage({ type: "loom-background" }, GALLERY_ORIGIN);
```

Both write to the same element the first-paint script did, which makes the shell's own first pass a
no-op rather than a second paint.

The shell listens for exactly `{ type: "loom-theme", theme: "light" | "dark" }` and
`{ type: "loom-background", background?: string }`, and ignores everything else. Applying a theme
does two things: it toggles a class on the gallery's root element,
and it writes `LoomTheme` onto `Players.LocalPlayer.PlayerGui`. That second part is the useful one —
a scene can read the host page's theme through plain Roblox APIs, including
`GetPropertyChangedSignal("LoomTheme")`, with no DOM access from roblox-ts code.

> **Name the target origin, not a wildcard**
>
> `postMessage(msg, "*")` delivers to whatever document currently occupies that frame. A theme string
> is not worth stealing, but `"*"` is a habit that costs nothing to avoid and gets copied into
> messages that *are* worth stealing. Pass the origin you actually built the gallery for.
>
> The shell's own listener does **not** check `event.origin` — it accepts a well-formed `loom-theme` or
> `loom-background` message from any parent. That is deliberate for a dev tool meant to be iframed by
> arbitrary hosts, and it is a reason to keep the message vocabulary at exactly these two, both of
> which only repaint. If you fork the shell and add messages with side effects, add an origin check
> with them.

```tsx title="A scene that follows the host page's theme"
const playerGui = Players.LocalPlayer.WaitForChild("PlayerGui");
const [theme, setTheme] = useState(playerGui.LoomTheme ?? "dark");

useEffect(() => {
  const conn = playerGui
    .GetPropertyChangedSignal("LoomTheme")
    .Connect(() => setTheme(playerGui.LoomTheme));
  return () => conn.Disconnect();
}, []);
```

## A working embed pipeline

Shelling out to `loom build` before every site build works, but it buys a generated artifact that
goes stale the moment a scene changes, and no HMR while you edit one. If the host is a Vite-based
framework, mount the gallery instead: [`loom-dev/embed`](https://docs.astra-void.xyz/loom/reference/cli.md#loom-devembed--the-programmatic-api)
exposes both pipelines as functions — a middleware-mode server for dev, a static build for the
output directory. (A Next.js host gets the same two pipelines pre-wired through one config
wrapper — see [Next.js integration](https://docs.astra-void.xyz/loom/guides/nextjs-integration.md).)

These docs do exactly that, from a ~40-line Astro integration:

```ts title="src/integrations/loom-preview.ts (abridged)"
import { buildGallery, createGalleryServer, findGalleryTargets } from "loom-dev/embed";

export default function loomPreview(): AstroIntegration {
  let gallery;
  return {
    name: "loom-preview",
    hooks: {
      // Dev: Loom's own Vite server, mounted under the site's base.
      "astro:server:setup": async ({ server }) => {
        gallery = await createGalleryServer({
          root: latticeApp,
          targets: "src/preview-targets",
          base: "/loom-preview/",
        });
        server.middlewares.use(gallery.middleware);
      },
      "astro:server:done": async () => await gallery?.close(),

      // Build: the same gallery, emitted next to the site's own output.
      "astro:build:done": async ({ dir }) => {
        await buildGallery({
          root: latticeApp,
          targets: "src/preview-targets",
          outDir: fileURLToPath(new URL("loom-preview/", dir)),
        });
      },
    },
  };
}
```

Editing a scene in the library checkout now hot-reloads the frame in the docs page — no regeneration
step, nothing to keep in sync, and no generated bundle in the repo.

Three details are worth stealing:

- **Mount under the host's own base**, not a hard-coded `/loom-preview/`, so the previews travel with
  the site when it moves to a subpath.
- **Make a missing checkout a graceful skip**, not a hard failure — CI and fresh clones should still
  build the site, just without previews. `findGalleryTargets()` answers "is there anything to serve?"
  without starting anything. Gate strictness behind an env var for deploy builds.
- **Pin the checkout path.** These docs link the component library into `.preview-src/`, which is
  also where CI checks it out, so one path convention covers local and CI alike. (Loom itself is an
  ordinary dependency — `@loom-dev/layout` ships its wasm engine prebuilt, so the host needs no Rust
  toolchain.)

The component that renders each frame sets `src` lazily through an `IntersectionObserver` (a gallery
page can hold a dozen frames), appends `&theme=` from the site's current theme at load, and pushes
later theme toggles into loaded frames with the `postMessage` above.

> **Non-Vite hosts**
>
> `loom build` is still the right tool when the host isn't a Vite app (a Hugo/Jekyll site, a CI job
> that publishes the gallery on its own). The output is identical — `buildGallery()` is the same
> pipeline behind a function call.

## Error containment survives the build

The per-target error boundary is part of the shell, not the dev server. A target that throws in a
built, deployed gallery still renders an inline error panel with its stack while the rest of the page
keeps working — which is what you want in a docs site, where one broken example should not take down
the page it lives on.
