Catalog3D

Catalog3D for developers

One small API.
A complete room experience.

Add Catalog3D to a product page while Catalog3D owns room upload, processing, presentation, and object targeting inside its isolated application.

The public, zero-runtime-dependency browser loader for embedding Catalog3D in a product page. Catalog3D owns room selection, upload, processing, object removal, and presentation inside an isolated Catalog3D application. The merchant page controls the application's placement, dimensions, responsive layout, and bounded visual theme.

Five-minute integration

<div id="catalog3d-room" style="height:680px"></div>

<script src="https://catalog3d.ai/embed/v1/catalog3d.js"></script>
<script>
  Catalog3D.mount({
    target: "#catalog3d-room",
    siteId: "your-publishable-site-id",
    productId: "your-product-id",
    locale: "en",
    appearance: {
      theme: "auto",
      accentColor: "#274d3d",
      fontFamily: "Inter, system-ui, sans-serif",
    },
  }).then((handle) => {
    window.catalog3dRoom = handle;
  });
</script>

The promise resolves when the Catalog3D application is ready. Catalog3D owns the upload interface; the host does not select or load room files. Mounting replaces the target's children, so keep any poster or fallback markup in a sibling element.

Bundler-based apps can skip the tag and import the package instead:

npm install @catalog3d/embed
import { mount } from "@catalog3d/embed";

After catalog3d:room-ready, a host chatbot can submit a plain-language removal intent:

await window.catalog3dRoom.requestRemoval({
  description: "remove the floor lamp beside the sofa",
});

Product identity is immutable. To display another product, call destroy() and mount a new instance.

Product-page example

examples/product-page is the canonical integration and visual debugging surface. It demonstrates:

  • a responsive merchant-owned product page;
  • sizing and placing Catalog3D inside a media gallery;
  • appearance tokens;
  • ready, room-ready, and error events;
  • a host-owned assistant using requestRemoval({ description });
  • cleanup with destroy(); and
  • ordinary merchant recommendation links without private scene-item APIs.

Run it locally:

npm install
npm run build
npm run example

Then open http://127.0.0.1:4174/examples/product-page/.

Documentation

Declarative alternative

<script src="https://catalog3d.ai/embed/v1/catalog3d.js"></script>
<catalog3d-room
  site-id="your-publishable-site-id"
  product-id="your-product-id"
  locale="en"
  theme="auto"
  accent-color="#274d3d"
  font-family="Inter, system-ui, sans-serif"
  style="display:block;height:680px">
</catalog3d-room>

Attributes are initial configuration. Replace the element to change product identity.

Development

npm install
npm run verify   # typecheck, tests, build, package-consumer checks

dist/ is committed because it is the source for the served tag. Rebuild and commit it with any source change; a test and CI both fail on a stale build. Design decisions records why the loader is built the way it is, and PUBLISHING.md is the release checklist.

The public package is intentionally small. It does not expose backend URLs, tokens, model URLs, room files or jobs, renderer controls, scene collections, room-picker commands, or mode setters.

License

Code is available under the MIT License. Product-page demo media is Catalog3D example content; see the example's asset notice.

Integration guide

View source

Catalog3D is embedded as a Catalog3D-owned iframe. A merchant supplies public site and product identity, chooses bounded appearance tokens, and controls the outer container with normal page CSS. Room selection, upload, processing, and the room interface remain inside Catalog3D.

1. Register the merchant site

Ask Catalog3D for a publishable siteId. Registration connects that id to the merchant's allowed HTTPS origins and allowed published products. Never place an API secret in browser code.

For local development, use an explicitly registered loopback origin such as http://127.0.0.1:4174. Origin matching includes the scheme and non-default port.

2. Add a sized target

<section class="product-media">
  <img class="product-poster" src="/media/chair.webp" alt="Marlow armchair" />
  <div id="catalog3d-room"></div>
</section>

Mounting replaces the target's children, so keep any poster image or <noscript> fallback in a sibling element rather than inside the target.

.product-media {
  width: min(100%, 760px);
}

#catalog3d-room {
  width: 100%;
  min-height: 420px;
  aspect-ratio: 1 / 1;
  overflow: hidden;
  border-radius: 22px;
}

The merchant controls placement and size. Catalog3D fills the target and applies a 420-pixel minimum height.

3. Load and mount

data-catalog3d-host is a development override for the Catalog3D iframe host; it does not register the merchant page's origin. The standalone example uses a local loader with data-catalog3d-host="https://catalog3d.ai". When the loader and iframe are both served by Catalog3D in production, omit the override. See Security and privacy.

<script src="https://catalog3d.ai/embed/v1/catalog3d.js"></script>
<script type="module">
  const target = document.querySelector("#catalog3d-room");

  target.addEventListener("catalog3d:ready", () => {
    console.info("Catalog3D mounted");
  });

  target.addEventListener("catalog3d:room-ready", () => {
    console.info("The shopper's room is ready");
  });

  target.addEventListener("catalog3d:error", (event) => {
    console.error(event.detail.code, event.detail.message);
  });

  const handle = await window.Catalog3D.mount({
    target,
    siteId: "your-publishable-site-id",
    productId: "your-product-id",
    locale: "en",
    appearance: {
      theme: "auto",
      accentColor: "#274d3d",
      fontFamily: "Inter, system-ui, sans-serif",
    },
  });
</script>

Attach listeners before mounting so early errors are observable.

4. Connect a host chatbot

After catalog3d:room-ready, pass a shopper's plain-language removal request:

await handle.requestRemoval({
  description: "remove the small table beside the window",
});

Resolution confirms that Catalog3D accepted the intent. It does not return a job, target, mask, room file, or progress object. Catalog3D owns those details.

5. Clean up or change products

handle.destroy();

Configuration is immutable. Destroy and mount again to show another product or variant. This starts a new Catalog3D room experience. For normal product pages, full page navigation is often the simplest lifecycle.

Product-page architecture

Keep merchant commerce UI outside the iframe: title, price, variants, quantity, cart, recommendations, and navigation. Keep Catalog3D room UI inside the iframe. Recommendation cards should remain ordinary product links unless a future public API explicitly adds a higher-level catalog interaction.

See examples/product-page for the complete reference.

API reference

View source

The stable browser global is window.Catalog3D, installed by the browser tag. The same API is available as named exports for bundlers:

import { mount, version, Catalog3DError } from "@catalog3d/embed";

TypeScript declarations are published in dist/index.d.ts.

Catalog3D.version

The loader's semantic version string. The current version is 1.3.0.

Catalog3D.mount(options)

function mount(options: Catalog3DMountOptions): Promise<Catalog3DHandle>;

Mounts one Catalog3D iframe in the target. The promise resolves after Catalog3D reports ready and rejects with Catalog3DError on validation, authorization, frame loading, or timeout failure.

type Catalog3DMountOptions = {
  target: Element | string;
  siteId: string;
  productId: string;
  variantId?: string;
  locale?: "en" | "de" | "fr";
  appearance?: {
    theme?: "light" | "dark" | "auto";
    accentColor?: string;
    fontFamily?: string;
  };
};
  • target is an element or selector for exactly one merchant-owned container. Mounting replaces the target's existing children. Put a poster image or a <noscript> fallback in a sibling element, not inside the target.
  • siteId is the publishable merchant registration id: 3-64 characters matching [a-z0-9][a-z0-9_-]*.
  • productId identifies an authorized published product: 1-192 characters matching [A-Za-z0-9][A-Za-z0-9._:-]*.
  • variantId optionally identifies a variant in that product family, in the same format as productId.
  • locale defaults to en.
  • appearance is immutable and bounded; see Appearance.

Only one active mount may own a target. Product and appearance configuration cannot be mutated after mounting.

Unknown options are rejected, not ignored. { local: "de" } or { appearance: { accentcolor: "#639" } } fails with INVALID_CONFIG rather than mounting silently with the wrong configuration. The public error does not echo unknown names or values.

mount() rejects with INVALID_CONFIG when called without a browser environment, so importing the loader into a server-rendered page is safe.

Catalog3DHandle

type Catalog3DHandle = {
  destroy(): void;
  requestRemoval(request: { description: string }): Promise<void>;
};

destroy()

Removes the iframe and loader-owned wrapper, event listeners, timers, and pending intent requests. Calling it more than once is safe.

requestRemoval({ description })

Submits a plain-language object-removal intent after catalog3d:room-ready. Descriptions are trimmed and must contain 1-500 characters. The promise resolves when the private Catalog3D experience accepts the request.

Only one removal may be in flight at a time. A second call while one is pending rejects immediately with BUSY. If the iframe reloads under a pending intent, that intent rejects with INTERNAL_ERROR rather than waiting for its timeout.

Catalog3DError

class Catalog3DError extends Error {
  readonly code: Catalog3DErrorCode;
}

The message is safe to log or display. Use code for application decisions. See Events and errors.

Declarative element

<catalog3d-room
  site-id="merchant-site"
  product-id="product-123"
  variant-id="variant-456"
  locale="en"
  theme="auto"
  accent-color="#274d3d"
  font-family="Inter, system-ui, sans-serif">
</catalog3d-room>

Attributes are initial configuration. The element does not observe identity or appearance mutations; replace it to reconfigure.

Child content is treated as a placeholder and is replaced when the embed mounts, so <catalog3d-room><p>Loading your room…</p></catalog3d-room> works. Moving the element in the DOM — a carousel, a tab panel, a framework reorder — keeps the mount; removing it destroys the mount.

Deliberately absent APIs

Public v1 has no setProduct, setItems, addItem, removeItem, loadRoom, openRoomPicker, setMode, generic update, direct iframe DOM access, or job API. These are not accidental omissions: Catalog3D owns room workflow and private runtime evolution.

Events and errors

View source

Catalog3D dispatches DOM CustomEvent objects on the mount target.

Events

catalog3d:ready

The iframe and private experience are ready. Catalog3D.mount() resolves at the same lifecycle point. The event has no detail payload (detail is null).

It fires once per mount. If the iframe reloads afterwards the loader re-initializes it silently; the event does not fire again.

catalog3d:room-ready

The shopper's room is ready for room-dependent intents such as object removal. The event intentionally contains no room image, file, scene, job, geometry, or artifact payload (detail is null).

It may fire more than once, so handlers should be idempotent. A room-dependent request can still reject with ROOM_NOT_READY if private room state changes after an earlier event; treat the command result as authoritative.

catalog3d:error

type Catalog3DErrorDetail = {
  code: Catalog3DErrorCode;
  message: string;
};

The error event is used for both lifecycle failures and rejected commands.

Stable error codes

  • BUSY: another incompatible request is active. The loader raises this itself when requestRemoval() is called while one is already in flight.
  • FRAME_LOAD_FAILED: the Catalog3D iframe could not load. Browsers fire a load event rather than an error event for HTTP 4xx/5xx responses and for X-Frame-Options refusals, so most real load failures surface as TIMEOUT instead. Handle both.
  • INTERNAL_ERROR: Catalog3D could not complete the operation safely.
  • INVALID_CONFIG: public mount configuration failed validation.
  • INVALID_REQUEST: a command payload failed validation.
  • ORIGIN_DENIED: the real parent origin is not registered for the site.
  • PRODUCT_NOT_FOUND: the product is unavailable or not authorized.
  • ROOM_NOT_READY: a room-dependent command was sent too early.
  • SITE_NOT_FOUND: the publishable site registration is unavailable.
  • TARGET_IN_USE: the target already owns a Catalog3D mount.
  • TARGET_NOT_FOUND: the target element or selector did not resolve.
  • TIMEOUT: the frame or command did not acknowledge within its public limit.

Treat messages as shopper-safe explanations, but branch on stable codes:

try {
  await room.requestRemoval({ description });
} catch (error) {
  if (error.code === "ROOM_NOT_READY") {
    showMessage("Upload a room before requesting an edit.");
  } else if (error.code === "BUSY") {
    showMessage("Catalog3D is already working on another edit.");
  } else {
    showMessage(error.message);
  }
}

Appearance, sizing, and placement

View source

The merchant controls the outer container. Catalog3D controls the iframe's internal DOM, responsive layout, interaction styling, accessibility, and room workflow.

Container sizing

Use ordinary CSS on the target or its parent:

.catalog3d-product-media {
  width: 100%;
  max-width: 760px;
  aspect-ratio: 1 / 1;
  min-height: 420px;
  overflow: hidden;
  border-radius: 22px;
}

@media (max-width: 460px) {
  .catalog3d-product-media {
    height: 420px;
    aspect-ratio: auto;
  }
}

Catalog3D fills the target's width and height and enforces a 420-pixel minimum height. The elements the loader creates inside the target declare their geometry !important, so a store-wide reset such as iframe { width: auto } or * { margin: 8px } cannot collapse the embed. Size and place the target itself; do not try to restyle the loader's own wrapper or iframe. On a viewport narrower than 420 pixels, use a fixed 420-pixel height instead of combining the minimum height with a square aspect ratio; otherwise the aspect ratio can force the target wider than its column. The merchant can place the target in a grid, carousel, dialog, product gallery, or full-width section. Do not attempt to style through the iframe.

Appearance tokens

appearance: {
  theme: "auto",
  accentColor: "#274d3d",
  fontFamily: "Inter, system-ui, sans-serif",
}
  • theme is light, dark, or auto and defaults to auto.
  • accentColor is an opaque three- or six-digit hex color. Catalog3D derives readable foreground, hover, focus, selection, and translucent states.
  • fontFamily is a bounded CSS family stack up to 200 characters. The named font must already be available inside the Catalog3D frame. This option does not load external font files or URLs.

Tokens are immutable. Arbitrary CSS, selectors, class names, HTML, JavaScript, font URLs, and alpha colors are rejected or unsupported.

Responsive integration

Let the product page decide when columns collapse. Keep the Catalog3D target mounted while temporarily showing another gallery image; use visibility and opacity rather than deleting the target if room state must survive the gallery switch. The reference product page demonstrates this pattern.

React and Next.js

View source

Catalog3D is framework-independent. Framework integrations should wrap the same immutable mount lifecycle instead of creating another API.

Bundler-based apps can install the package and import mount directly instead of loading the browser tag:

npm install @catalog3d/embed

The examples below use the browser tag, which keeps the loader out of the application bundle. Either route is supported; the API is identical. Importing the package during server rendering is safe — it registers nothing without a document, and mount() rejects with INVALID_CONFIG rather than throwing.

React component

import { useEffect, useRef } from "react";
import type {
  Catalog3DHandle,
  Catalog3DMountOptions,
} from "@catalog3d/embed";

type Catalog3DRoomProps = Omit<Catalog3DMountOptions, "target"> & {
  sdkReady: boolean;
};

export function Catalog3DRoom({ sdkReady, ...options }: Catalog3DRoomProps) {
  const targetRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const target = targetRef.current;
    if (!sdkReady || !target || !window.Catalog3D) return;

    let disposed = false;
    let handle: Catalog3DHandle | undefined;

    window.Catalog3D.mount({ ...options, target })
      .then((mounted) => {
        if (disposed) mounted.destroy();
        else handle = mounted;
      })
      .catch((error) => {
        if (!disposed) console.error("Catalog3D failed to mount", error);
      });

    return () => {
      disposed = true;
      handle?.destroy();
    };
  }, [
    sdkReady,
    options.siteId,
    options.productId,
    options.variantId,
    options.locale,
    options.appearance?.theme,
    options.appearance?.accentColor,
    options.appearance?.fontFamily,
  ]);

  return <div ref={targetRef} className="catalog3d-room" />;
}

Changing an immutable option runs cleanup and mounts a new instance. Avoid passing a newly created options object as the only effect dependency.

Next.js App Router

Load the browser script with next/script, and keep the mount component on the client:

"use client";

import { useState } from "react";
import Script from "next/script";

export default function ProductPage() {
  const [sdkReady, setSdkReady] = useState(false);

  return (
    <>
      <Script
        src="https://catalog3d.ai/embed/v1/catalog3d.js"
        strategy="afterInteractive"
        onReady={() => setSdkReady(true)}
      />
      <Catalog3DRoom
        sdkReady={sdkReady}
        siteId="your-publishable-site-id"
        productId="your-product-id"
        locale="en"
      />
    </>
  );
}

The iframe is browser-only. Product metadata, pricing, structured data, and commerce controls can remain server-rendered.

Strict Mode

Development Strict Mode may run an effect setup-cleanup-setup cycle. Always destroy the first handle when its promise resolves after disposal. Never disable cleanup or retain a handle for a target that React has removed.

Security and privacy

View source

The public loader is an intentionally narrow trust boundary.

Parent-page boundary

The merchant page receives no Catalog3D API secret, backend token, model URL, room file, room image, scene description, job id, revision id, geometry artifact, or renderer control. Product identity is delivered to a generic frame through a versioned browser message rather than the frame URL.

The frame validates:

  • the exact parent window;
  • the browser-reported parent origin;
  • the protocol version;
  • the random mount instance id;
  • the registered site and allowed origin; and
  • the authorized published product and optional variant.

Where the loader's trust anchor comes from

The loader accepts frame messages from exactly one origin, and rejects every message whose event.source is not its own iframe. That origin is the origin the loader script itself was served from — not a constant compiled into the bundle. A page that loads the tag from https://catalog3d.ai therefore trusts only https://catalog3d.ai.

A data-catalog3d-host attribute on the script tag overrides it, accepting any HTTPS origin or an http://127.0.0.1/http://localhost origin for local development. This is what makes the bundled example and local integration work. It is set by whoever writes the page's markup, who already controls the page completely, so it grants no capability an attacker would not already have — but it does mean the trust anchor is page-controlled, not pinned. Treat the script tag as security-relevant markup and apply the same review as any other third-party tag.

Iframe isolation

The loader creates a sandboxed iframe with only the capabilities needed by the room experience: allow-downloads allow-forms allow-same-origin allow-scripts. The parent cannot style or inspect the private iframe DOM, and the frame session endpoint does not grant cross-origin read access.

allow-same-origin alongside allow-scripts is safe here because the iframe host remains cross-origin to the merchant. A classic browser tag derives the iframe host from its own script URL unless data-catalog3d-host explicitly overrides it; the npm module defaults to https://catalog3d.ai. Do not self-host the classic tag without also setting the intended trusted Catalog3D host.

Delegated browser permissions

The current loader does not delegate camera, sensor, WebXR, microphone, or fullscreen permissions. The room experience currently uses file upload rather than direct camera capture. Any future delegated feature requires corresponding runtime use, production policy support, tests, and a security review.

Object-removal intent

requestRemoval() sends only a bounded plain-text description. The acknowledgement contains only an opaque request id internally and resolves the public promise without returning target coordinates, masks, jobs, or room state.

Site registration

Catalog3D operators register each merchant's allowed origins and product policy. Origins include scheme and non-default port. HTTPS subdomain wildcards may be configured narrowly; a bare wildcard is never valid. Publishable site ids are not secrets, but API secrets must never appear in browser configuration.

Content Security Policy

Merchants normally need to allow:

script-src https://catalog3d.ai
frame-src https://catalog3d.ai

Apply the site's existing nonce or hash policy to inline integration code, or place mount code in a merchant-owned external script.

The loader injects no <style> element. It applies inline declarations only to the wrapper and iframe it creates; those declarations are marked !important so a store-wide reset cannot collapse the embed's box. Size and placement stay under merchant control through the target element. Merchants with a restrictive style-src-attr policy should include the embed in their own CSP verification.

Versioning and migration

View source

The loader follows semantic versioning. The stable browser URL is versioned by major API generation:

https://catalog3d.ai/embed/v1/catalog3d.js

Backward-compatible fixes and additions may ship within v1. Breaking changes require a new major URL and migration guide.

The v1 URL is mutable by design and currently has no immutable per-release counterpart, so it cannot be combined with a stable Subresource Integrity hash. Stores that require a pinned dependency should use the npm package and commit their lockfile:

npm install @catalog3d/embed

Compatibility commitments

  • Existing valid mount configuration retains its meaning within v1.
  • Events and error codes documented for v1 remain stable.
  • New optional configuration or handle methods may be added in a minor release.
  • Private wire messages, iframe DOM, internal styles, backend routes, and room implementation details are not public compatibility surfaces.

Product changes

V1 configuration is immutable. Use destroy() and mount again:

let room = await Catalog3D.mount(firstProduct);

async function showProduct(nextProduct) {
  room.destroy();
  room = await Catalog3D.mount(nextProduct);
}

Remounting starts a new room experience. Prefer normal product-page navigation when the shopper should not carry the current room session between products.

Changelog

See CHANGELOG.md for release-level additions.

Design decisions

View source

Catalog3D Embed is a third-party tag. It is dropped into stores this repository has never seen, next to CSS it does not control, loaded by tag managers it cannot configure, and it has to survive all of that without the merchant debugging it. Every decision below follows from that.

This page records what the loader does and why, including the changes made in the 1.3.0 pre-publication hardening pass and the things that were deliberately left alone.

The shape of the public API

One iframe. Product identity is delivered by a versioned postMessage handshake, not in the frame URL. The handle is frozen and has two methods. Configuration is immutable after mounting.

The reason is blast radius. Everything the merchant page can reach is everything an attacker who compromises the merchant page can reach, and everything Catalog3D can never change without breaking stores. A room picker, a scene collection, a job API, or a generic update() would each turn a private implementation detail into a permanent public commitment. docs/api-reference.md lists the absent APIs explicitly so their absence reads as a decision rather than an oversight.

Packaging

The npm package now ships a module build. main pointed at the IIFE bundle while type was module, so the package parsed as ESM with no exports and import { mount } from "@catalog3d/embed" failed with does not provide an export named 'mount'. scripts/build.mjs now emits both artifacts:

  • dist/catalog3d.js — minified IIFE for the <script> tag. This is what a store puts in its page.
  • dist/catalog3d.mjs — unminified ESM for bundlers, reachable through exports. Left unminified so a downstream stack trace is readable; the consumer's bundler minifies it anyway.

files now includes docs, because the README links to those pages and every one of those links was a 404 on npmjs.com. publishConfig.access is set because a scoped package does not publish publicly without it.

The browser build no longer uses esbuild's globalName. The source installs window.Catalog3D itself and refuses to replace a loader that is already there. Tag managers, A/B tools, and storefront apps inject the same third-party tag twice as a matter of routine. Each copy carries its own mountedTargets registry, so a second copy overwriting the global would silently defeat TARGET_IN_USE and let two loaders fight over one container. The first loader on the page wins. A globalName wrapper assignment would have undone that guard after the module body ran, which is why it had to go.

Surviving the host page

Loader-owned geometry is declared !important. Inline !important outranks author !important, which is the only thing that survives a store's global reset — iframe { width: auto }, * { margin: 8px }, a CSS framework normalizing every block element. Merchants still control size and placement completely, through the target element they own; the loader only defends the box it created inside that target.

The iframe continues to delegate no unused device permissions. The current extension uses file upload and ordinary pointer interaction; repository searches find no camera stream, sensor, WebXR, or fullscreen API use. Granting those features would expand the iframe's capability without enabling current behavior. If a future room feature needs one, add only that feature together with its runtime implementation, production Permissions Policy, tests, and security docs.

loading="eager", deliberately. Lazy loading would be the better citizen for a below-the-fold embed, but the mount promise and the 20-second ready timeout both depend on the handshake starting immediately. A lazily loaded embed below the fold would time out before the shopper ever scrolled to it.

referrerPolicy="no-referrer". Catalog3D does not need the merchant's page URL, and the origin check it does need arrives through postMessage, which the browser attests to and the page cannot forge.

Failure modes that were leaking

The initialization retry loop stopped leaking. The loader re-posts init every 500ms until the frame answers. A ready arriving after the frame had already been ready hit an early return before the clearInterval, so any frame reload left the loop running for the lifetime of the host page — measured at five posts every three seconds, forever. Iframes reload more than expected: moving a node in the DOM reloads it, and the renderer restores crashed frames. The interval is now cleared before the already-ready check.

A frame reload now rejects in-flight removal intents. They were addressed to frame state that no longer exists and could never be answered, so each one sat until its 10-second timeout. They fail immediately with INTERNAL_ERROR instead.

requestRemoval enforces one intent at a time. The API reference always said so; the loader did not, and three concurrent calls put three intents on the wire. A second call now rejects synchronously with BUSY, which is the code the docs already described for this case.

The declarative element

<catalog3d-room> had two lifecycle defects, both from the same guard — connectedCallback returned early whenever the element had any child.

Re-parenting no longer blanks the element. Moving a node fires disconnectedCallback immediately followed by connectedCallback. The old code tore down on the first and then refused to remount on the second because the pending mount's wrapper was still a child, leaving a permanently empty element after any carousel, tab switch, or framework reorder inside the 20-second handshake window. Teardown is now deferred by one task and cancelled if the element reconnects, which is the standard move-versus-remove idiom.

Placeholder content no longer disables the embed. <catalog3d-room><p>Loading your room…</p></catalog3d-room> — the obvious thing to write — meant no iframe, ever, with no error. The element now tracks its own mount state instead of inferring it from childElementCount.

Mount attempts are serialized because a pending mount owns the target registration until its promise settles. A real removal cancels that registered pending handle immediately; if the element is added again later, the next mount does not wait for the 20-second ready timeout. The first mount still runs synchronously so the iframe exists the moment the element connects and the host page's layout does not shift.

Strictness at the boundary

Unknown options are rejected. { local: "de" } used to mount silently in English; { accentcolor: "#639" } silently dropped the accent. On a third-party embed a silent misconfiguration is far worse than a loud one: the merchant sees a working-but-wrong widget and has nothing to search for. INVALID_CONFIG at integration time is easier to diagnose. The public message does not echo unknown names or values, so the error cannot become an attacker-controlled reflection channel.

Server rendering no longer throws. The custom element class extended HTMLElement at module scope, so importing the loader in Node died with ReferenceError: HTMLElement is not defined — a real path, since the documented Next.js integration server-renders the surrounding page. The class definition is now guarded, and mount() rejects with a public error instead of throwing when there is no document.

The iframe title follows locale. It is the accessible name the host page's screen reader announces, and leaving it English on a German or French store is a defect in the store's accessibility, not just ours.

Deliberately unchanged

  • The 20-second ready timeout is not configurable. Adding an option is cheap; removing one after stores depend on it is not. If real-world telemetry says it is too tight for 3D on mobile, changing the constant is a patch release, and adding an option remains available later.
  • data-catalog3d-host still accepts any HTTPS origin. It is what makes local development and the bundled example work, and it is set by the page author, who already controls the page. It is now documented rather than implicit, and the security page no longer claims the loader pins a constant Catalog3D origin when what it actually pins is the origin its own script came from.
  • FRAME_LOAD_FAILED is kept even though it rarely fires. Iframes fire load, not error, for HTTP 4xx/5xx and for X-Frame-Options refusals, so in practice a failed frame surfaces as TIMEOUT. The handler costs nothing and covers the network-level cases that do fire; the docs now say which code a merchant should actually expect.
  • The version string stays hand-written in five places. Injecting it at build time would fix two of them and leave the docs and changelog to drift. A test asserts all five agree, which covers every copy.
  • dist/ stays committed. The repository doubles as the source for the served tag. A test now asserts the committed build matches the current version and that the published types match the source types, and CI fails if the build is stale.