MishkaCmsCoreWeb.Live.Admin.Builder.CanvasLive (Mishka CMS Core v0.0.1)

Copy Markdown View Source

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 createPortal and 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 style attribute, 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_style and @hollow_style, which are appended AFTER it precisely so they win that clash — and which two hook routines (lift and openEmpties in admin_hooks.js) save and restore through el.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

literal(source)

@spec literal(String.t()) :: {:ok, term()} | :error

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.