One open editing session: the working tree plus everything around it — selection, hover, undo history, clipboard, per-node locks, the breakpoint and state being edited.
Why the server holds this
The editor is two LiveViews — the chrome and the canvas inside its iframe — and they must agree on
one document. They share a token, not a copy: every mutation goes through apply_op/3 here, lands
in ETS, and is announced on the session's PubSub topic, so each LiveView re-renders what it owns.
That single funnel is also where containment is re-checked, where the undo history is pushed, and
where the builder:node:* plugin seams fire.
It is also what makes undo, clipboard and shortcuts real. The React editor shipped a complete history implementation watching a store nothing ever wrote to, so every one of its thirteen advertised shortcuts was inert. Here the history is the same data structure the canvas renders from, so a working undo is not a feature to add later — it falls out.
What lives here, and what does not
This module is the funnel and the state: it reads the session, applies a change, checks containment, writes it back and announces it. Two things it used to also be were lifted out, because each is a whole question of its own and neither needs the token or the table to answer it:
MishkaCmsCore.Runtime.PageBuilder.Placement— WHERE a thing lands. A click, a drag and a template arriving on its own all ask it the same question, and it answers from the tree alone. It sits besideContainment, which answers whether a child may go in a parent at all;MishkaCmsCore.Runtime.PageBuilder.EditorSession.Journal— WHAT HAPPENED, in the words a person would use, and the undo stack it sits on. It owns coalescing, trimming and the step rows; the documents themselves stay inEditorHistoryStateand the durable rows inHistory.
Both take and return plain data, so they can be read and tested without opening a session — which is what they were missing.
Two more answer for the page's forms, and both are consulted from the funnel rather than owned by
it: MishkaCmsCore.Runtime.PageBuilder.PageForms.FormPlacement puts a form around a field that
lands without one, and MishkaCmsCore.Runtime.PageBuilder.PageForms.Naming renumbers a form name
that a copy, a paste or a pasted document has just made ambiguous.
History semantics (from the v2 prototype)
As deep as its memory budget allows rather than a fixed number of steps — see
MishkaCmsCore.Runtime.States.EditorHistoryState, which holds each step as what changed rather
than as a whole document, and drops the oldest when a session has kept too much. A new edit
truncates the redo stack. Consecutive text edits on the same node inside
600 ms coalesce into one
entry, so typing a headline is one undo, not forty.
Concurrency
Two admins on the same page see each other through Phoenix.Presence on this topic, and edits are
last-write-wins at the op level. Per-node locking is the escape hatch. A real CRDT is explicitly
out of scope; op-level granularity keeps the damage from a genuine collision to a single element.
One session is written by more than one process as well — the chrome, the canvas, and the tasks
each of them starts to flush the history, autosave and load the palette. None of those writes may
put back what another one changed, so every change here is worked out on the session as stored and
landed with a compare-and-swap; see MishkaCmsCore.Runtime.States.EditorSessionState.update/2.
One change, or many
apply_op/3 is one change: one read of the session, one write, one broadcast, one undo step. That
is the right shape for a person, and the wrong shape for anything that produces a document —
an assistant writing a section, an import, a paste of forty elements. Each op there costs a full
copy of the session out of ETS and back, a history snapshot, and a re-render in every LiveView
watching, so forty ops is forty of everything and the editor spends the whole time drawing
intermediate states nobody asked to see.
Anything writing more than one thing should call apply_ops/4. It folds the run over one tree
in memory, stores it once, broadcasts once, and keeps one undo entry — which is also what a reader
means by "undo what it just did". Measured on a 302-node page inserting forty elements: 15.7 ms
and forty history entries, against 3.6 ms and one.
Summary
Functions
Tells everyone editing this session that something changed, without changing the session.
Applies a drop, and when the position asked for is refused, puts the node beside the target rather than dropping the gesture on the floor.
The one mutation funnel: validate the op, re-check containment, apply it, record history, store and announce.
Applies a RUN of ops as one change: one read, one write, one broadcast, one undo step.
The breakpoints an override may be recorded against.
The same session WITHOUT registering it.
One action over everything selected, as a single undo step.
Everything selected, in document order.
Back to one element.
Closes a session and drops its state.
One right-click menu action, whichever surface asked for it.
Copies a subtree to the clipboard — the prototype's "Holding …" status.
Copies how a node LOOKS, without what it says.
Copies a subtree and removes it.
Take the settled entries waiting to be written, and clear them from the session.
Whether the thing being dragged is a field on a Page — a template whose controls are one field, or a node that is part of one — so the canvas says which form it joins while it is dragged.
Which nodes could take this drag as a child — the map the canvas paints its drop line from.
Switches what the editor is editing to a draft: its content becomes the working tree, and every save from here — including autosave — goes to that draft.
The document a history entry would restore.
A sample %MishkaCmsCore.Runtime.PageBuilder.EditorSession{} populated from defaults + type-based fallbacks.
Fetches an open session.
Every place a phrase appears in the document's words.
A fresh session token — 32 lowercase base32 characters, also the canvas iframe's URL segment.
Whether a node is folded away on the canvas — Node.hidden?/2 explains what the set holds.
How many steps back and forward are available — the {n}/{m} counter in the top bar.
Marks the hovered node — drives the canvas outline and the layers highlight.
Whether a node is locked.
Adds a node to the selection, or takes it out again — a ⌘-click.
Marks the session saved — clears dirty? and stamps saved_at.
Moves every field of the form from_id into the form into_id and removes from_id, as one undo
step, then selects into_id. Answers the sentence to show. See FormPlacement.merge_ops/3.
Moves the field node_id belongs to into the form target (a node id, or
FormPlacement.new_target/0 for a new form with a Send button), rebinding it there, as one undo
step. A form it leaves with no fields is removed. Answers the sentence to show. See
FormPlacement.move_ops/3.
Opens a session over a template and stores it.
Pastes the clipboard after the current selection, or into parent when one is given. Refuses an
empty clipboard rather than doing nothing silently.
Puts a copied look onto another node, replacing whatever it had.
Arms (or disarms) click-to-place with a palette component's name.
Whether an overlay is being held open on the canvas.
Places the armed component into parent_id at index, then disarms.
Places the armed thing where a click on node_id means.
Places a template that arrives without being armed first — a saved snippet, an example lifted out of the library, a component the wizard composed.
The element states an override may be recorded against.
Replaces the palette metadata containment consults — re-read when a component is installed.
Replaces the working tree wholesale — what a save, a draft restore or a history revert does.
Records the wizard's choices for a component, so its preview canvas can render them.
Steps one entry forward.
Replaces a phrase everywhere it appears, as one undo step.
Put entries back after a failed write, without disturbing anything queued since.
The resource sections the builder can edit.
Selects a node, or clears the selection with nil.
Switches the breakpoint being edited. Overrides written afterwards land in that bucket.
Switches the writing direction the CANVAS is rendered in.
Hands the canvas the compiled per-element CSS, so the frame paints it immediately.
Turns the canvas's editor overlays off and on.
Switches the element state being edited (:base, :hover, :focus, :active).
Records which way the author means to save — draft or published.
Holds one element's CSS for the publish to write, a blank body holding the clearing of it.
Subscribes the calling process to a session's updates.
Flips a node's eye: draws one that was folded away, folds one that was drawn.
Locks or unlocks a node. A locked node refuses selection-driven edits and moves.
Holds an overlay open on the canvas, or lets it shut again.
Hides a node from visitors, or shows it again — see MishkaCmsCore.Runtime.PageBuilder.Visibility.
The PubSub topic both LiveViews of a session subscribe to.
Steps one entry back. Returns the session unchanged when there is nothing to undo, so a bound keystroke never errors.
Drops the hold, which only the publish that has written it may do.
Replaces the working tree with one worked out from the tree as stored — what a condition, a hook, a click action or an element's scope class does, none of which is an op.
The bucket a style edit is written to — the width AND the state the panel is showing.
Types
@type t() :: %MishkaCmsCore.Runtime.PageBuilder.EditorSession{ actor_id: String.t() | nil, breakpoint: atom() | nil, clipboard: any() | nil, clipboard_label: String.t() | nil, components: list() | nil, dir: String.t() | nil, dirty?: boolean() | nil, draft_id: String.t() | nil, draft_label: String.t() | nil, element_css: String.t() | nil, future: list() | nil, hidden: any() | nil, hover: String.t() | nil, last_entry: any() | nil, locked: any() | nil, marked: any() | nil, opened_at: any() | nil, past: list() | nil, pending: any() | nil, picked: String.t() | nil, pinned: any() | nil, preview?: boolean() | nil, pseudo: atom() | nil, resource_id: String.t() | nil, revision: integer() | nil, saved_at: any() | nil, section: String.t(), selection: String.t() | nil, site_id: String.t() | nil, site_name: String.t() | nil, stage: atom() | nil, staged_element_css: any() | nil, status: MishkaCmsCore.Runtime.ErrorStatus.t() | nil, step_run: any() | nil, styles: any() | nil, styles_label: String.t() | nil, token: String.t(), tree: any() | nil, wizard: any() | nil }
Functions
Tells everyone editing this session that something changed, without changing the session.
For state that lives OUTSIDE the session and is shared by everyone in it — review notes, which are
rows in MishkaCmsCore.Runtime.PageComment rather than a field here. Both editors already listen
on this topic for every op, so a note posted by one appearing for the other is the existing
collaboration channel doing what it is for, rather than a second one beside it.
@spec apply_drop(String.t(), map()) :: {:ok, t()} | {:relocated, t(), String.t()} | {:error, term()}
Applies a drop, and when the position asked for is refused, puts the node beside the target rather than dropping the gesture on the floor.
A drag paints its line from drop_hints/2, so reaching a refusal here means the hint was stale or
the payload was made up. Either way the person dragging has already said where they want this, and
"immediately after the thing you aimed at" is the answer they would pick themselves — a <span>
that cannot hold a section still tells us which section the new one belongs next to.
Returns {:ok, session}, {:relocated, session, sentence} where the sentence says what happened
and is meant to be shown, or {:error, reason} when there is no room beside the target either. It
retries once: a second refusal is a real one.
The one mutation funnel: validate the op, re-check containment, apply it, record history, store and announce.
An op that leaves the tree exactly as it was is applied but never recorded — see
MishkaCmsCore.Runtime.PageBuilder.EditorSession.Journal.record/4.
params is raw client input. Returns {:ok, session} or {:error, reason}, where reason is
either a validation error list or a user-facing containment message.
announce: how announces builder:form:after_create with that how for each form the op made
work.
Applies a RUN of ops as one change: one read, one write, one broadcast, one undo step.
apply_op/3 is the right shape for a person, who makes one change at a time and wants each of them
undoable on its own. It is the wrong shape for anything producing a document — an assistant writing
a section, an import, a paste of forty elements — because every op there costs a full copy of the
session out of ETS and back, a history snapshot, a broadcast, and a re-render in every LiveView
watching. Forty of those is forty of everything, and the editor spends the whole time redrawing
intermediate states nobody asked to see.
Here the ops are folded over one tree in memory and stored once. The undo step is the whole run, which is also what a reader means by "undo what it just did".
Stops at the first op that will not apply and returns its error, leaving the session untouched —
a half-written section is worse than a refused one. label names the single history entry.
opts may also say whose run this was, which is what the record needs to tell an assistant's work
from a person's: author_type: :ai, plus the note it wrote about what it did, the prompt it
was given and the backend that answered. An :ai run is also allowed a larger restore point —
see MishkaCmsCore.Runtime.PageBuilderSnapshot. announce: how announces
builder:form:after_create with that how for each form the run made work.
@spec breakpoints() :: [atom()]
The breakpoints an override may be recorded against.
The same session WITHOUT registering it.
A LiveView mounts twice — once for the static render, once when the socket connects — and
registering on both left an orphaned session in ETS on every page load, with a different token, so
the canvas iframe pointed at one URL and then at another and loaded twice. The static render uses
this; only the connected mount calls open/1.
One action over everything selected, as a single undo step.
DESCENDANTS ARE DROPPED FIRST. Holding a card and something inside it and pressing Delete would otherwise delete the card, and then fail on a child that went with it — halting the run and leaving the rest of the selection untouched.
Everything selected, in document order.
Order matters for every caller: wrapping three cards has to keep them in the order they appear on the page rather than the order they were clicked, and deleting has to start from a stable list.
Back to one element.
@spec close(String.t()) :: :ok
Closes a session and drops its state.
@spec context_action(String.t(), String.t(), String.t()) :: {:ok, t()} | {:open, :snippet} | {:error, term()}
One right-click menu action, whichever surface asked for it.
The canvas and the layers rail are separate LiveViews, and a menu implemented twice is a menu that drifts — the same entry deleting a branch in one place and one node in the other. Both hand the action here instead.
{:open, :snippet} is the one entry that is not an edit: saving a selection for reuse is a form,
and the form already exists in the Studio. Each surface opens it its own way, which is the only
part they legitimately differ on.
Copies a subtree to the clipboard — the prototype's "Holding …" status.
Copies how a node LOOKS, without what it says.
A second clipboard on purpose. Styling four cards to match by copying the first one and retyping its words is not the same gesture as duplicating it, and one clipboard cannot hold both — copying a subtree to paste its look would have thrown away whatever was waiting to be pasted.
What travels is the class attribute, expression and all, and the recorded per-breakpoint and
per-state overrides. Nothing else: not the text, not the children, not the id — and not the minted
pb- class, which names the element it was copied from rather than anything about its look.
Copies a subtree and removes it.
Take the settled entries waiting to be written, and clear them from the session.
Called by the flush, off the LiveView process. Returning them and clearing in one write is what stops two overlapping flushes writing the same row twice — and if the write then fails, the ids are stable, so re-queuing them costs nothing.
Whether the thing being dragged is a field on a Page — a template whose controls are one field, or a node that is part of one — so the canvas says which form it joins while it is dragged.
@spec drop_hints(String.t(), map()) :: {:ok, %{required(String.t()) => boolean()}} | {:error, term()}
Which nodes could take this drag as a child — the map the canvas paints its drop line from.
Answered once per drag, at dragstart, rather than once per hover: the geometry of where the
pointer is inside a box is the browser's to compute, but whether a drop is legal is this server's,
and asking it per pixel of pointer travel is what a round trip per hover would mean. One call, one
map, and the line is instant for the rest of the gesture.
A locked node answers false — authorize/2 would refuse the drop, and a hint that disagrees with
the refusal is worse than no hint. Dragging a locked node answers false everywhere, for the same
reason.
The map is a hint, and can go stale: a collaborator can move the tree under a drag in flight.
apply_drop/2 is what actually decides.
Switches what the editor is editing to a draft: its content becomes the working tree, and every save from here — including autosave — goes to that draft.
The history is deliberately kept. Opening a draft is a move within one editing session, not a new
one, so undo still walks back past it; what it must NOT do is silently continue writing to
whatever was open before, which is why draft_id and draft_label move together.
@spec entry_tree(t(), map()) :: {:ok, MishkaCmsCore.Runtime.PageBuilder.Tree.t()} | :error
The document a history entry would restore.
The entry itself carries only a reference — see MishkaCmsCore.Runtime.States.EditorHistoryState
— so anything offering "go back to this", like the history rail's Revert, asks here. :error when
the tree is no longer held, which a caller should treat as "that step is gone" rather than "the
document is empty".
A sample %MishkaCmsCore.Runtime.PageBuilder.EditorSession{} populated from defaults + type-based fallbacks.
Fetches an open session.
@spec find_text(t(), String.t()) :: [ %{id: String.t(), text: String.t(), count: pos_integer()} ]
Every place a phrase appears in the document's words.
TEXT ONLY, and that is the whole point: a page says "Contact Us" in four places and the fifth is
a class called contact-us. Renaming the company should not touch the CSS, so attributes, class
names, expressions and comments are not searched — what comes back is what a reader would call
the page's words.
Each hit carries the node it is in and enough of the line to be recognised, in document order.
@spec generate_token() :: String.t()
A fresh session token — 32 lowercase base32 characters, also the canvas iframe's URL segment.
@spec history_counter(t()) :: {non_neg_integer(), non_neg_integer()}
How many steps back and forward are available — the {n}/{m} counter in the top bar.
Marks the hovered node — drives the canvas outline and the layers highlight.
Whether a node is locked.
Adds a node to the selection, or takes it out again — a ⌘-click.
The FIRST one becomes the ordinary selection rather than a mark, so holding ⌘ from the start
behaves the way clicking does. Selecting something new plainly means "and now this instead", so
select/3 clears the marks; only this and clear_marks/1 write them.
Marks the session saved — clears dirty? and stamps saved_at.
:as_of is the revision the caller serialized before it wrote. A session that has moved past it
holds work that write did not carry, so it keeps dirty? and only takes the draft the write made
— the editor stays clean about what is really saved instead of reporting the author's newer change
as kept. Asked here rather than by the caller because the answer has to be read in the same commit
that stores it. Omit it when nothing can come between the serialize and this call.
@spec merge_forms(String.t(), String.t(), String.t()) :: {:relocated, t(), String.t()} | {:error, term()}
Moves every field of the form from_id into the form into_id and removes from_id, as one undo
step, then selects into_id. Answers the sentence to show. See FormPlacement.merge_ops/3.
@spec move_to_form(String.t(), String.t(), String.t()) :: {:relocated, t(), String.t()} | {:error, term()}
Moves the field node_id belongs to into the form target (a node id, or
FormPlacement.new_target/0 for a new form with a Send button), rebinding it there, as one undo
step. A form it leaves with no fields is removed. Answers the sentence to show. See
FormPlacement.move_ops/3.
Opens a session over a template and stores it.
:template is the HEEx to edit; a malformed one returns {:error, %{line:, column:, message:}}
so the editor can show it rather than failing the mount.
Pastes the clipboard after the current selection, or into parent when one is given. Refuses an
empty clipboard rather than doing nothing silently.
A pasted field joins the form it lands in, or starts one, as a dropped field does; that answers
{:relocated, session, sentence}.
Puts a copied look onto another node, replacing whatever it had.
ONE UNDO STEP. Replacing a look is a clear of every bucket followed by a write of each override,
which is a dozen ops for one gesture — through apply_ops/4 they collapse into a single entry, so
undo takes the card back to how it looked rather than unwinding it one property at a time.
Arms (or disarms) click-to-place with a palette component's name.
Session state rather than chrome state for the same reason set_preview/2 is: the click that
places the component happens inside the CANVAS, which is a different LiveView. Left in the chrome,
the palette card would light up and clicking the page would select an element instead of placing
anything — which is exactly what it did before this moved.
Whether an overlay is being held open on the canvas.
@spec place(String.t(), String.t(), non_neg_integer()) :: {:ok, t()} | {:relocated, t(), String.t()} | {:error, term()}
Places the armed component into parent_id at index, then disarms.
Lives here rather than in either LiveView because both of them need it: the chrome places from the structure tree, the canvas from a click on the page itself, and they must not be able to disagree about what "the picked component" means or where it ends up.
@spec place_at(String.t(), String.t() | nil) :: {:ok, t()} | {:relocated, t(), String.t()} | {:error, term()}
Places the armed thing where a click on node_id means.
A LEAF — a kit component, a heading, an image — goes inside that element when it will take one, and
immediately after it when it will not. The fallback is the whole point. Clicking a <.component>
that declares no slot, an <img> or an <input> is not a mistake — it is someone pointing at the
place they want the new thing to appear — and answering it with "that element cannot hold children"
puts the burden of the containment rules on them. Webflow, Elementor and Bricks all land here too.
A BAND does not follow that rule. A band is a horizontal slice of the page and belongs beside the
other slices, so a click pointing into one climbs out and lands after that whole branch. Without
this, a call-to-action clicked while the page header was selected became a third item in the
header's flex row and rendered a quarter of the width. Bricks documents Section as root-level and
will not offer it inside a layout element; Elementor's containers refuse section outright.
Returns {:relocated, session, note} when the landing place is neither the clicked element nor
beside it, because that is the case the selection ring does not explain on its own. Passing nil
places at the end of the document.
@spec place_template(String.t(), String.t(), String.t() | nil) :: {:ok, t()} | {:relocated, t(), String.t()} | {:error, term()}
Places a template that arrives without being armed first — a saved snippet, an example lifted out of the library, a component the wizard composed.
Same rule as place_at/2, and that is the point: three routes that all mean "put this where I am
looking" used to disagree, because this one always dropped its template beside the selection. A
<section> snippet inserted while a heading deep inside a hero was selected landed inside that
hero — the bug place_at/2 exists to prevent, reached by another door.
EVERY ELEMENT CLASS IN IT IS MINTED AGAIN, alongside every other placement. All three were written
in ANOTHER document — the page a snippet was cut from, the component an example documents — so the
pb-xxxxxxxx classes they carry are keys into that document's CSS row, which this one neither
owns nor prunes (MishkaCmsCore.Runtime.PageBuilder.ElementScopes). The rules do NOT travel: a
snippet stores markup and nothing else, and what the element looked like in the other document was
that document's rule showing through the one site stylesheet. Fresh classes are what make the
element styleable from here. Copy, paste and Duplicate are the ones that keep theirs: they stay
inside one document, where sharing the class is the point.
DRAGGING THE SAME CARD IS THE OTHER DOOR, and it does not come through here: a drag carries its
markup in the payload and lands in apply_drop/2, which mints there for this reason.
@spec pseudos() :: [atom()]
The element states an override may be recorded against.
Replaces the palette metadata containment consults — re-read when a component is installed.
@spec put_tree(String.t(), MishkaCmsCore.Runtime.PageBuilder.Tree.t(), keyword()) :: {:ok, t()} | {:error, term()}
Replaces the working tree wholesale — what a save, a draft restore or a history revert does.
Whatever arrives is renumbered first. A document typed into the code drawer, imported, or restored from a draft written before this rule existed can hold two forms answering to one name, and the page that comes out of it routes both forms' submissions to one handler.
Records the wizard's choices for a component, so its preview canvas can render them.
Announced like everything else: the preview is a second LiveView in an iframe, and the session is the only thing the two share.
Steps one entry forward.
Replaces a phrase everywhere it appears, as one undo step.
A replace-all is one gesture and has to undo like one — forty separate entries would mean forty presses to get back to where you were, and no way to tell which of them was the accident.
Put entries back after a failed write, without disturbing anything queued since.
@spec sections() :: [String.t()]
The resource sections the builder can edit.
Selects a node, or clears the selection with nil.
origin rides along in the broadcast because the two LiveViews answer a selection differently:
clicking an element ON THE CANVAS is a request to work on it, so the chrome swings the left panel
to the Inspector, while clicking a row in the layers tree is navigation and must leave the panel
where the reader put it. Both end at the same selection; only the chrome's reaction differs.
A plain click means "this instead", so anything held with ⌘ is let go — see mark/2.
Switches the breakpoint being edited. Overrides written afterwards land in that bucket.
Switches the writing direction the CANVAS is rendered in.
Session state rather than chrome state, because the thing that has to flip is the document being edited — an author checking a Persian or Arabic page wants the page mirrored, not the editor around it. Riding on the session is what gets it into the iframe without reloading the canvas.
Hands the canvas the compiled per-element CSS, so the frame paints it immediately.
The rules themselves live in a stylesheet row — see
MishkaCmsCore.Runtime.PageBuilder.ElementCss — and reach the published page through the
compiler. This is the editor's copy, and nothing reads it but the canvas.
Turns the canvas's editor overlays off and on.
Session state for the same reason set_dir/2 is: the thing that has to change is what the canvas
draws — selection outlines, the floating toolbar, the expression and comment chips, the drop tail.
Left in the chrome, the button would light up and the page would look exactly the same.
Switches the element state being edited (:base, :hover, :focus, :active).
Records which way the author means to save — draft or published.
Kept on the session because it decides whether a reload picks the work back up: a draft is written as it is made and resuming it loses nothing, while publish-mode work was never written anywhere.
Holds one element's CSS for the publish to write, a blank body holding the clearing of it.
Read out of the session rather than merged in by the caller, because two browsers share one session: a caller that put its own map back would drop whatever the other one applied between.
Subscribes the calling process to a session's updates.
Locks or unlocks a node. A locked node refuses selection-driven edits and moves.
Holds an overlay open on the canvas, or lets it shut again.
An overlay opens on its own while it is selected — see the data-pb-overlay rules in
MishkaCmsCoreWeb.Live.Admin.Builder.CanvasLive — so pin one only to keep it open while working
on something else, which is the case selection alone cannot express.
Session state like hidden, and never serialized: a dialog left pinned is still a dialog that
opens from its trigger on the published page.
Hides a node from visitors, or shows it again — see MishkaCmsCore.Runtime.PageBuilder.Visibility.
Not the eye above: this is document state, one attribute in the markup, so it goes through
apply_op/3 like any other edit — undo, the history, the lock and the plugin hooks come with it.
The PubSub topic both LiveViews of a session subscribe to.
Steps one entry back. Returns the session unchanged when there is nothing to undo, so a bound keystroke never errors.
Drops the hold, which only the publish that has written it may do.
@spec update_tree( String.t(), (MishkaCmsCore.Runtime.PageBuilder.Tree.t() -> {:ok, MishkaCmsCore.Runtime.PageBuilder.Tree.t()} | term()), keyword() ) :: {:ok, t()} | {:error, term()} | term()
Replaces the working tree with one worked out from the tree as stored — what a condition, a hook, a click action or an element's scope class does, none of which is an op.
change is handed the stored tree and answers {:ok, tree} to store tree the way put_tree/3
does, with the same options. Anything else is answered as it is and nothing is stored. It is asked
again whenever another write lands first, so it must do nothing it cannot repeat.
The bucket a style edit is written to — the width AND the state the panel is showing.
It used to be one or the other: the breakpoint while Normal was selected, the state otherwise. So a
hover colour set with the panel on Mobile was written to :hover, which is every width, and
changed the desktop with it. There was no way at all to say "on a phone, when touched".
Styles.bucket/2 composes them. A state at the widest breakpoint keeps its own name, because
hover: with no media query already applies everywhere — so the cheap case stays cheap: set a
hover on Desktop and it holds on a phone unless the phone says otherwise.