Skip to main content

crystal

Pushes live, validated CSS and web fonts from a parent application into an embedded preview over window.postMessage. This is what makes the Bezel preview panel restyle your app as you edit tokens.

Framework-agnostic, no runtime dependencies, and built security-first. Previews often run on origins you don't fully control, so every message is gated by an origin allowlist and the CSS is re-validated at every hop.

npm install --save-dev @bezel-labs/crystal

Three roles

FunctionRuns inJob
startLivePreviewReceiveryour appAccept CSS from allowed origins, re-validate, inject it
createLivePreviewSenderthe host app (Bezel)Validate CSS, post it to a specific iframe and origin
createLivePreviewRelaya nested-iframe hostForward messages one hop down to the real preview frame

Most apps only need the receiver. The relay exists because a grandparent window can only post to its direct child. Storybook needs it, since the content you see lives in a second iframe.

Receiver

import { startLivePreviewReceiver } from '@bezel-labs/crystal';

startLivePreviewReceiver({
allowedOrigins: ['https://app.bezel.new'],
styleId: 'live-preview-styles',
onReject: (reason) => console.warn(reason)
});

Keep this development-only. See the Quick Start for the guarded, tree-shakeable form.

allowedOrigins defaults to Bezel hosts (https://bezel.new and https://*.bezel.new). Pass ['*'] only to deliberately opt out during local development; an explicitly empty array throws.

Relay

For Storybook and anything else that nests the real content in a second iframe:

import { createLivePreviewRelay, DEFAULT_PARENT_ORIGINS } from '@bezel-labs/crystal';

createLivePreviewRelay({
allowedOrigins: [...DEFAULT_PARENT_ORIGINS, 'http://localhost:4200'],
getTargetWindow: () =>
(document.getElementById('preview-iframe') as HTMLIFrameElement | null)?.contentWindow,
targetOrigin: window.location.origin
});

Security model

  • Origin allowlist is required. Receiver and relay reject any message whose event.origin isn't listed. Wildcards like https://*.bezel.new stay anchored, so bezel.new.evil.com and evilbezel.new are both rejected.
  • Explicit target origin. The sender never posts to '*', so a swapped or navigated iframe can't receive your CSS.
  • CSS is validated at every hop. validateCss rejects rather than sanitizes: HTML markup, expression(), javascript:/vbscript:, -moz-binding, behavior:, control characters, @import (off by default), non-https/data url() schemes, and oversized payloads.
  • Safe injection. CSS is written with textContent into one reused <style> node, so markup can't be injected and the DOM doesn't grow.
  • Font messages carry no URLs. A fonts message is only { family, weights }; the receiver builds the href from a hardcoded Google Fonts base, so message data can never become the request host.

Protocol

Messages on the wire are live-preview-css and live-preview-google-fonts. Receivers and relays announce themselves with live-preview-ready so the host can send without racing startup.