The page-builder canvas: the document itself, rendered inside a same-origin iframe with the site's compiled CSS rather than the admin's.
Why this is a second LiveView and not a div
Three things fall out of the iframe that no same-document approach can give:
- Style isolation without portals. The site's stylesheet cannot reach the editor chrome and
the admin's cannot reach the page. The React editor rendered into an iframe through
createPortaland had to bridge native events back across the boundary by hand. - Real viewports. A tablet frame is an 834px-wide iframe, so the site's own media and container queries actually fire. A CSS transform on a div only looks narrower.
- Ordinary bindings. Selecting an element is
phx-click, not a synthetic event forwarded across documents.
Rendering is a function call
A component node renders through MishkaCmsCore.Runtime.LiveViewHelpers.component/1, which
apply/3s an already-compiled module. There is no Code.eval_quoted/3 anywhere on this path and
no HTTP round trip per component — the entire Phase-C re-render queue of the React editor exists
because only the server could render a component and the editor was not the server. Here it is.
Slot children become real Phoenix slot entries: %{__slot__: name, inner_block: rendered}, which
Phoenix.Component.render_slot/2 accepts directly. That is what makes the inside of a component
editable — the opaque-leaf problem the JSON encoder created, resolved at the ownership boundary.
Chrome styling
Every piece of chrome this LiveView draws — the empty-page chooser, the floating element toolbar,
the layout panel, the two placeholders — is a CSS rule in one of the nonced <style> blocks below,
keyed on an id or a data-pb-* marker, not a style attribute. A Content-Security-Policy has no
nonce to offer an attribute, so style-src-attr 'unsafe-inline' can only be closed by the
attributes not existing; a rule, unlike an attribute, can be marked as ours.
Every selector is anchored to an id, which matters more here than anywhere else in the admin: this
frame is served the AUTHOR's compiled stylesheet, so the chrome and the document being edited share
one cascade. An id-anchored rule outranks any utility that stylesheet contains, which is the
property the style attribute used to supply for free.
Two blocks, because Preview keeps half of it. #pb-canvas-chrome is switched off in preview — the
hover outline, the selection ring, the chooser and the toolbar are editing affordances — while
#pb-canvas-ui holds what is drawn in both modes: the stage itself, the empty-page well, the
expression chip and the "could not render" placeholder.
The one style attribute that stays
wrapper_style/1 still renders one, on the nodes that need it, and it is not the editor's:
- the AUTHOR's own
styleattribute, lifted off the node and re-attached to the wrapper. It is page content, arbitrary, and unbounded — no finite rule set can express it, and turning it into a per-node rule would change where it sits in the cascade, because an attribute outranks every selector and a[data-node-id="…"]rule does not. @locked_styleand@hollow_style, which are appended AFTER it precisely so they win that clash — and which two hook routines (liftandopenEmptiesinadmin_hooks.js) save and restore throughel.style, expecting to find them there.
So the canvas cannot be the surface that closes style-src-attr: a document that carries a style
attribute anywhere, or a kit component that renders one, puts it back. What this file can promise
is that the EDITOR never adds one. present/1 returns nil rather than "" and wrapper_attrs/1
splats it as a runtime map — which is what actually drops it, since HEEx renders a written-out
style={nil} as style="" — so a page with no authored styling renders through this canvas with
no style attribute anywhere in it.
Where the CSS comes from
Two sources, and both are needed.
The site's compiled bundle is LINKED from MishkaCmsCoreWeb.AssetsController when it has a
content hash to address it by, and inlined from AssetsState when it does not. Inlining was the
original answer, because a site that is inactive, undeployed or never compiled has no hash — and
that is exactly the site someone opens the editor on. But the bundle is 3.6 MB on a real site, and
inlining put all of it in the document of every preview the examples modal opened: re-sent and
re-parsed each time. Linked, it is brotli-compressed, cached immutable for a year, and blocks
rendering — so the frame paints once, already styled, instead of flashing unstyled icons. The hash
is in the URL, so a recompile is a new URL rather than a stale cache. The inline path remains for
the hashless case, which is why both still exist.
Tailwind's browser build then generates any utility the moment its class appears in the DOM,
which is what makes an edit visible immediately instead of after a compile. On its own it would not
know the kit's design tokens, so the site's @theme source travels with it.
Summary
Functions
The value of an attribute EXPRESSION, when the canvas can resolve it without executing anything.
Functions
The value of an attribute EXPRESSION, when the canvas can resolve it without executing anything.
Public so the kit-wide example sweep can hold the shipped bundle to the same rule the canvas applies, rather than restating it and drifting.