# `MishkaCmsCore.Runtime.PageBuilder.EditorSession`
[🔗](https://github.com/mishka-group/mishka_cms_core/blob/v0.0.1/lib/mishka_cms_core/runtime/page_builder/editor_session.ex#L5)

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 beside `Containment`, 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 in `EditorHistoryState` and the durable rows in `History`.

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.

# `t`

```elixir
@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
}
```

# `announce`

```elixir
@spec announce(String.t(), term()) :: :ok
```

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.

# `apply_drop`

```elixir
@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.

# `apply_op`

```elixir
@spec apply_op(String.t(), map(), keyword()) :: {:ok, t()} | {:error, term()}
```

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.

# `apply_ops`

```elixir
@spec apply_ops(String.t(), [map()], String.t(), keyword()) ::
  {:ok, t()} | {:error, term()}
```

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.

# `breakpoints`

```elixir
@spec breakpoints() :: [atom()]
```

The breakpoints an override may be recorded against.

# `build`

```elixir
@spec build(map()) :: {:ok, t()} | {:error, term()}
```

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`.

# `builder`

# `bulk`

```elixir
@spec bulk(String.t(), String.t()) :: {:ok, t()} | {:error, term()}
```

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.

# `chosen`

```elixir
@spec chosen(t()) :: [String.t()]
```

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.

# `clear_marks`

```elixir
@spec clear_marks(String.t()) :: {:ok, t()} | {:error, term()}
```

Back to one element.

# `close`

```elixir
@spec close(String.t()) :: :ok
```

Closes a session and drops its state.

# `context_action`

```elixir
@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.

# `copy`

```elixir
@spec copy(String.t(), String.t()) :: {:ok, t()} | {:error, term()}
```

Copies a subtree to the clipboard — the prototype's "Holding …" status.

# `copy_styles`

```elixir
@spec copy_styles(String.t(), String.t()) :: {:ok, t()} | {:error, term()}
```

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.

# `cut`

```elixir
@spec cut(String.t(), String.t()) :: {:ok, t()} | {:error, term()}
```

Copies a subtree and removes it.

# `drain_pending`

```elixir
@spec drain_pending(String.t()) :: {:ok, [map()]} | {:error, term()}
```

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.

# `drop_field?`

```elixir
@spec drop_field?(String.t(), map()) :: boolean()
```

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.

# `drop_hints`

```elixir
@spec drop_hints(String.t(), map()) ::
  {:ok, %{required(String.t()) =&gt; 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.

# `edit_draft`

```elixir
@spec edit_draft(String.t(), map(), String.t()) :: {:ok, t()} | {:error, term()}
```

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.

# `enforce_keys`

# `enforce_keys`

# `entry_tree`

```elixir
@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".

# `example`

A sample %MishkaCmsCore.Runtime.PageBuilder.EditorSession{} populated from defaults + type-based fallbacks.

# `fetch`

```elixir
@spec fetch(String.t()) :: {:ok, t()} | :error
```

Fetches an open session.

# `find_text`

```elixir
@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.

# `generate_token`

```elixir
@spec generate_token() :: String.t()
```

A fresh session token — 32 lowercase base32 characters, also the canvas iframe's URL segment.

# `hidden?`

```elixir
@spec hidden?(t(), MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t()) :: boolean()
```

Whether a node is folded away on the canvas — `Node.hidden?/2` explains what the set holds.

# `history_counter`

```elixir
@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.

# `hover`

```elixir
@spec hover(String.t(), String.t() | nil) :: {:ok, t()} | {:error, term()}
```

Marks the hovered node — drives the canvas outline and the layers highlight.

# `keys`

# `keys`

# `locked?`

```elixir
@spec locked?(t(), String.t()) :: boolean()
```

Whether a node is locked.

# `mark`

```elixir
@spec mark(String.t(), String.t()) :: {:ok, t()} | {:error, term()}
```

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.

# `mark_saved`

```elixir
@spec mark_saved(String.t(), keyword()) :: {:ok, t()} | {:error, term()}
```

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.

# `merge_forms`

```elixir
@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`.

# `move_to_form`

```elixir
@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`.

# `open`

```elixir
@spec open(map()) :: {:ok, t()} | {:error, term()}
```

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.

# `paste`

```elixir
@spec paste(String.t(), keyword()) ::
  {:ok, t()} | {:relocated, t(), String.t()} | {:error, term()}
```

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}`.

# `paste_styles`

```elixir
@spec paste_styles(String.t(), String.t()) :: {:ok, t()} | {:error, term()}
```

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.

# `pick`

```elixir
@spec pick(String.t(), String.t() | nil) :: {:ok, t()} | {:error, term()}
```

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.

# `pinned?`

```elixir
@spec pinned?(t(), String.t()) :: boolean()
```

Whether an overlay is being held open on the canvas.

# `place`

```elixir
@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.

# `place_at`

```elixir
@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.

# `place_template`

```elixir
@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.

# `pseudos`

```elixir
@spec pseudos() :: [atom()]
```

The element states an override may be recorded against.

# `put_components`

```elixir
@spec put_components(String.t(), list()) :: {:ok, t()} | {:error, term()}
```

Replaces the palette metadata containment consults — re-read when a component is installed.

# `put_tree`

```elixir
@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.

# `put_wizard`

```elixir
@spec put_wizard(String.t(), map()) :: {:ok, t()} | {:error, term()}
```

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.

# `redo`

```elixir
@spec redo(String.t()) :: {:ok, t()} | {:error, term()}
```

Steps one entry forward.

# `replace_text`

```elixir
@spec replace_text(String.t(), String.t(), String.t()) ::
  {:ok, t()} | {:error, term()}
```

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.

# `requeue`

```elixir
@spec requeue(String.t(), [map()]) :: :ok
```

Put entries back after a failed write, without disturbing anything queued since.

# `sections`

```elixir
@spec sections() :: [String.t()]
```

The resource sections the builder can edit.

# `select`

```elixir
@spec select(String.t(), String.t() | nil, :canvas | :panel) ::
  {:ok, t()} | {:error, term()}
```

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`.

# `set_breakpoint`

```elixir
@spec set_breakpoint(String.t(), atom()) :: {:ok, t()} | {:error, term()}
```

Switches the breakpoint being edited. Overrides written afterwards land in that bucket.

# `set_dir`

```elixir
@spec set_dir(String.t(), String.t()) :: {:ok, t()} | {:error, term()}
```

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.

# `set_element_css`

```elixir
@spec set_element_css(String.t(), String.t()) :: {:ok, t()} | {:error, term()}
```

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.

# `set_preview`

```elixir
@spec set_preview(String.t(), boolean()) :: {:ok, t()} | {:error, term()}
```

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.

# `set_pseudo`

```elixir
@spec set_pseudo(String.t(), atom()) :: {:ok, t()} | {:error, term()}
```

Switches the element state being edited (`:base`, `:hover`, `:focus`, `:active`).

# `set_stage`

```elixir
@spec set_stage(String.t(), :draft | :published) :: {:ok, t()} | {:error, term()}
```

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.

# `stage_element_css`

```elixir
@spec stage_element_css(String.t(), String.t(), String.t()) ::
  {:ok, t()} | {:error, term()}
```

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.

# `subscribe`

```elixir
@spec subscribe(String.t()) :: :ok | {:error, term()}
```

Subscribes the calling process to a session's updates.

# `toggle_hidden`

```elixir
@spec toggle_hidden(String.t(), String.t()) :: {:ok, t()} | {:error, term()}
```

Flips a node's eye: draws one that was folded away, folds one that was drawn.

Hidden is **session state, not document state**: it is the layers panel's eye toggle, a way to get
a fixed header out of the way while working underneath it. It is deliberately not serialized, so
saving never silently drops an element an author only meant to stop looking at.

The set holds the ids whose eye differs from the default for their kind — see
`MishkaCmsCore.Runtime.PageBuilder.Tree.Node.hidden?/2`. Membership therefore reads as "folded" for
an element and "drawn" for a comment, which is why nothing outside that predicate should reach into
the set directly.

# `toggle_lock`

```elixir
@spec toggle_lock(String.t(), String.t()) :: {:ok, t()} | {:error, term()}
```

Locks or unlocks a node. A locked node refuses selection-driven edits and moves.

# `toggle_pinned`

```elixir
@spec toggle_pinned(String.t(), String.t()) :: {:ok, t()} | {:error, term()}
```

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.

# `toggle_visibility`

```elixir
@spec toggle_visibility(String.t(), String.t()) :: {:ok, t()} | {:error, term()}
```

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.

# `topic`

```elixir
@spec topic(String.t()) :: String.t()
```

The PubSub topic both LiveViews of a session subscribe to.

# `undo`

```elixir
@spec undo(String.t()) :: {:ok, t()} | {:error, term()}
```

Steps one entry back. Returns the session unchanged when there is nothing to undo, so a bound
keystroke never errors.

# `unstage_element_css`

```elixir
@spec unstage_element_css(String.t()) :: {:ok, t()} | {:error, term()}
```

Drops the hold, which only the publish that has written it may do.

# `update_tree`

```elixir
@spec update_tree(
  String.t(),
  (MishkaCmsCore.Runtime.PageBuilder.Tree.t() -&gt;
     {: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.

# `write_bucket`

```elixir
@spec write_bucket(t()) :: atom()
```

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.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
