Skip to content

feat: render the canvas in a shadow root - #702

Draft
stravo1 wants to merge 12 commits into
frappe:developfrom
stravo1:canvas-shadow-dom
Draft

feat: render the canvas in a shadow root#702
stravo1 wants to merge 12 commits into
frappe:developfrom
stravo1:canvas-shadow-dom

Conversation

@stravo1

@stravo1 stravo1 commented Jul 29, 2026

Copy link
Copy Markdown
Member

Renders each canvas breakpoint inside a shadow root, so page CSS cannot reach the editor UI.

This is an alternative to the iframe approach on #656

Why a shadow root

A shadow root shares the viewport, the coordinate space, and the event loop with the editor. Rects, listeners, and drag events therefore need no translation across a frame boundary. Fonts need no extra handling, because @font-face is document scoped.

Two boundary rules need care, and canvasShadowDom.ts holds the helpers for both:

  • event.target retargets to the host element. Use getEventTarget.
  • document.querySelector does not descend into a shadow root. Use queryCanvas.

Client scripts

A shadow root gives no JavaScript isolation, so page and block scripts run against a facade in canvasScriptFacade.ts. The facade points document and window at the breakpoint's root.

Run and Stop gate the scripts, from the command palette or Ctrl+Shift+P. A tap toggles the state. A hold previews and restores the earlier state on release. Stopping remounts the block tree, because a script can mutate the DOM in ways Vue cannot undo.

A shadow root resolves @media against the editor viewport, so every breakpoint would get the same answer. pageScriptEmulation.ts rewrites width-only queries to container queries, and the canvas host becomes a size container only when the CSS needs one.

Styles

Each breakpoint adopts the editor stylesheets by reference, as constructable CSSStyleSheet objects. The document parses each sheet once, and every root shares it. A change to a sheet updates all adopters, so only a stylesheet that enters or leaves the document reassigns adoptedStyleSheets.

Block client script CSS goes to the breakpoint that registered it, not to every root.

State styles

Hover, active and focus styles now render during preview. Before this branch the canvas dropped them. A block renders its regular styles inline, and an inline style cannot carry a pseudo class, so BuilderBlock deleted every hover: key.

blockStateStyles.ts emits them as real rules instead, which mirrors append_state_style in builder_page.py. An inline declaration outranks any rule, so each state declaration carries !important.

The rules apply during preview only. To select a block the user hovers it, and a block that restyles itself under the pointer is unusable.

🤖 Generated with Claude Code

stravo1 and others added 12 commits July 29, 2026 15:17
Renders each breakpoint inside a shadow root instead of an iframe, for
much less code than full DOM isolation via iframes: CSS and DOM
queries stay encapsulated, coordinates and events need no cross-frame
translation, and fonts need no extra handling.

Includes emulation of page client scripts (CSS applied per breakpoint,
JavaScript run against a document/window facade routed at the shadow
root, since script isolation is not something shadow DOM provides).

Behind a localStorage flag (builderShadowCanvas) until the trade-offs
against the iframe approach are settled.
Removes the localStorage prototype flag: every breakpoint now renders
in a shadow root unconditionally, no opt-in needed. The block-style
selector no longer needs a canvas-id prefix, since each canvas's own
shadow root already scopes its styles.

Adds Run/Stop/Refresh controls to the canvas toolbar, mirroring the
iframe PR's canvas controls. Client scripts default to stopped: they
run in the editor's own realm (shadow DOM does not sandbox script
execution), so an editor left open should not run arbitrary page or
block JavaScript until the user asks for it. Refresh restarts already-
running scripts to pick up edits without a stop/start round trip.
Removes the canvas toolbar buttons in favor of a single command
palette entry ("Run/Stop Client Scripts"), following the existing
toggle-theme and toggle-panel command pattern. Also drops the Refresh
control and its refresh nonce — the page and block script watchers
already re-apply on script content changes, so a manual restart added
little.

Scripts still default to stopped and only start on command.
A new breakpoint's shadow root exists before Vue teleports its block
tree in, so a script run at that exact moment found no root block and
the document.body fallback picked the first child instead — an
internal style-sync element unrelated to the page. A script that
touched document.body then mutated that element, not the real page.

Defer the first script run to the next tick, after the teleport has
landed, and drop the fallback: better to fail loudly with no body
than silently mutate the wrong node.
Stopping a script cleans up its timers and listeners, but not any raw
DOM change it made, such as replacing document.body's content. Vue
has no way to detect that change on its own.

Bump a nonce on stop and fold it into the block tree's key, so Vue
unmounts and rebuilds it from the Block model, discarding whatever
the script left behind.
Rewrites comments added in the last five commits to plain, short
sentences: split run-ons, cut filler words, drop a contraction, and
switch a couple of passive constructions to active. Also corrects one
comment left stale by the block-tree remount fix — stop() no longer
leaves DOM restoration to Vue on its own.

No logic changes.
CSS from page and block client scripts applied unconditionally, only
JavaScript waited for Run. CSS now applies and clears together with
the JavaScript, under the same Run/Stop and execution-mode gate.
Cmd+Shift+P toggles Run/Stop permanently, matching the command
palette entry. Holding Opt+Shift+P forces scripts on for a quick
look and restores whatever state they were in before, on release.
…tcut

Cmd+Shift+P now covers both: a tap toggles Run/Stop, a hold forces
scripts on and restores the prior state on release. Replaces the
separate Opt+Shift+P / Ctrl+Shift+L combos with one, matching the
tap-vs-hold disambiguation added to frappe-ui's useShortcut.

Depends on that frappe-ui change to actually distinguish a tap from
a hold; without it, handler and onHold both fire on every keydown.
Each breakpoint shadow root cloned every document <style>/<link> and
watched all of document.head to re-clone them, so N breakpoints parsed
N copies of the editor CSS. Build one constructable CSSStyleSheet per
source instead and adopt it into every root by reference. Mutating a
sheet in place updates all adopters, so only a stylesheet entering or
leaving the document reassigns adoptedStyleSheets, and the head
observer narrows to childList with a per-element observer for hot
reload.

Also scope block client script CSS to the breakpoint that registered
it, rather than injecting the combined string into every root, and
cache the @media to @container rewrite, which depends on the CSS text
alone and ran once per breakpoint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
State styles were authored and stored, but the canvas dropped them: a
block renders its regular styles inline, which cannot carry a pseudo
class, so BuilderBlock deleted every hover: key before binding. Pages
with hover styles looked flat in the editor and only came alive once
published.

Emit them as real rules into the breakpoint's canvas stylesheet
instead, mirroring append_state_style in builder_page.py. An inline
declaration outranks any rule, so each state declaration carries
!important to take effect while the state holds.

The rules apply while previewing only. Editing means hovering blocks
to select them, and a block that restyles itself under the pointer is
unusable. Blocks register their rules whatever the mode, so toggling
preview flips one computed in the canvas rather than re-running every
block's watcher.

Also announce the preview toggle with a toast, since preview leaves no
lasting mark on the canvas to read the state from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@codecov

codecov Bot commented Jul 29, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 57.59%. Comparing base (fe305cb) to head (772595b).

Additional details and impacted files
@@           Coverage Diff            @@
##           develop     #702   +/-   ##
========================================
  Coverage    57.59%   57.59%           
========================================
  Files           35       35           
  Lines         4271     4271           
========================================
  Hits          2460     2460           
  Misses        1811     1811           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant