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
| Function | Runs in | Job |
|---|---|---|
startLivePreviewReceiver | your app | Accept CSS from allowed origins, re-validate, inject it |
createLivePreviewSender | the host app (Bezel) | Validate CSS, post it to a specific iframe and origin |
createLivePreviewRelay | a nested-iframe host | Forward 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.originisn't listed. Wildcards likehttps://*.bezel.newstay anchored, sobezel.new.evil.comandevilbezel.neware 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.
validateCssrejects rather than sanitizes: HTML markup,expression(),javascript:/vbscript:,-moz-binding,behavior:, control characters,@import(off by default), non-https/dataurl()schemes, and oversized payloads. - Safe injection. CSS is written with
textContentinto 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.