# `MishkaCmsCoreWeb.Live.Admin.Builder.Panels`
[🔗](https://github.com/mishka-group/mishka_cms_core/blob/v0.0.1/lib/mishka_cms_core_web/live/admin/builder/panels.ex#L5)

Every region of the page-builder chrome as a function component: the 56px top bar, the two
resizable side panels, the crumb bar, the canvas frames, the code drawer, the command palette and
the overlay sheets.

Kept apart from `MishkaCmsCoreWeb.Live.Admin.Builder.BuilderLive` so the LiveView reads as the
editor's behaviour — mount, events, session — and this reads as its surface. The measurements come
straight from the design prototype: 56px bar, a 272px left panel clamped to 230–460, a 300px right
panel clamped to 220–480, 6px drag grips, a 120–620px drawer, and the 900px breakpoint below which
both panels collapse into a tab strip.

The core's own rails, inspector sections and commands are declared here as
`MishkaCmsCore.Builder.Descriptor` seeds, so the core enters its own registries through the same
door a plugin does. The canvas toolbar's seed is not among them: that bar is drawn inside the
frame, so it is declared where it is rendered, in `MishkaCmsCoreWeb.Live.Admin.Builder.CanvasLive`.

# `ago`

```elixir
@spec ago(DateTime.t() | term()) :: String.t()
```

How long ago, in the reader's language — "2h ago", "yesterday" — and past two days the day and
time, "Jul 3, 18:04".

A draft picker is a list of moments, and a bare timestamp makes the reader do the arithmetic.
Anything that is not a `DateTime` answers `""`, so a row with no timestamp shortens rather than
raising. Shared with `MishkaCmsCoreWeb.Templates.DraftList`, which stamps the same rows.

# `aimed_opener`

```elixir
@spec aimed_opener(
  map(),
  String.t() | nil,
  MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t() | nil
) ::
  map()
```

What the target currently being aimed at says it can do, as `%{verb => attribute}`.

`chosen` is the id in the target select RIGHT NOW — which is not yet the saved one, because the
author is picking it. Without that the verb list would describe the previous target and go stale
the moment somebody changed their mind, which is the state a form is in for the whole time it is
being filled in.

Falls back to what the saved command points at, so a panel rendered fresh describes the aim the
document already holds. `%{}` for "aimed at this element" and for a target that declares nothing —
both mean "offer everything", which is what `Interactions.actions/1` does with it.

# `attr_addable?`

```elixir
@spec attr_addable?(
  map(),
  MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t() | nil,
  String.t()
) ::
  boolean()
```

Whether a free-text add box may put `name` on this node — asked by Advanced's box and by the
Attributes list's on an element, of the same rules the list above each box is built from, so the
add and the list cannot disagree.

A name the node already carries is refused. An add has no value to offer that the row it would
land on does not already hold: Advanced sends none at all, and the Attributes box sends whatever
was typed beside it, empty included. An empty one is worse than nothing — it either beats the
component's own default, or is taken for a removal by the funnel every write goes through, and
`class=""` throws away everything the style panel wrote while `:if={}` reads as nil, which takes
the element and all of its content off the published page.

A HEEx special — `:if`, `:for`, `:let` — is refused whether the node carries it or not: it must be
an expression between braces, so `:if=""` is not a condition that never matches, it is a template
that will not compile at all, and a box that can only produce a string has no way to write one.
The Conditions section above is where a `:if` is written, through
`MishkaCmsCore.Runtime.PageBuilder.Conditions`.

A name the component declares is refused because Advanced is the attributes it does NOT declare —
that property has a control of its own above, and a row written here would never appear.

# `avatar_tint`

```elixir
@spec avatar_tint(String.t() | nil) :: String.t()
```

A stable colour for a name, for the Review thread's avatar discs.

Public because `MishkaCmsCoreWeb.Templates.ReviewComments` draws the same discs — one answer, so
the same person is the same colour in the sheet and anywhere else that shows them.

# `avatar_tint_class`

```elixir
@spec avatar_tint_class(String.t() | nil) :: String.t()
```

The same disc colour as a whole Tailwind class, for a surface that paints it without a `style`.

A class assembled around `avatar_tint/1` would be source text no Tailwind build can match, so the
five colours are written out one clause each and the hash still chooses between them.

# `behavior_on?`

```elixir
@spec behavior_on?(MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t() | nil, String.t()) ::
  boolean()
```

Whether a node already carries every class one of the switches adds.

# `behavior_toggles`

```elixir
@spec behavior_toggles() :: [{String.t(), String.t(), [String.t()]}]
```

The Behavior switches: a label and the classes each one adds.

Every switch is a real class on the element, so it reads the document rather than a flag kept
alongside it — turn one on, save, reopen, and it is still on because the class is still there.

# `blocks`

Whole sections, one click each.

The palette lists components and the Studio's Presets tab offers whole-page themes nothing on disk
supplies. Between them there was nothing, so a landing page meant placing a grid, a heading, a
paragraph and a button and repeating it for every band. Same click-to-place flow the palette uses,
so there is nothing new to learn — see `MishkaCmsCore.Runtime.PageBuilder.Blocks`.

## Attributes

* `picked_block` (`:any`) - Defaults to `nil`.
* `block_cat` (`:string`) - Defaults to `"all"`.
* `block_query` (`:string`) - Defaults to `""`.
* `block_library` (`:list`) (required)
* `element_library` (`:list`) (required)

# `center`

The centre column: crumb bar, the canvas frames, and the code drawer under them.
## Attributes

* `editor` (`:any`) (required)
* `tree` (`:any`) (required)
* `token` (`:any`) - Defaults to `nil`.
* `record` (`:any`) - Defaults to `nil`.
* `crumbs` (`:list`) - Defaults to `[]`.
* `selection` (`:string`) - Defaults to `nil`.
* `selected_node` (`:any`) - Defaults to `nil`.
* `picked` (`:string`) - Defaults to `nil`.
* `device` (`:any`) - Defaults to `nil`.
* `zoom` (`:integer`) - Defaults to `100`.
* `frame_row` (`:any`) - Defaults to `nil`.
* `frame_zoom` (`:integer`) - Defaults to `100`.
* `breakpoint` (`:atom`) - Defaults to `:base`.
* `status` (`:any`) - Defaults to `nil`.
* `statuses` (`:list`) - Defaults to `[]`.
* `resumable` (`:any`) - Defaults to `nil`.
* `inherited_draft` (`:any`) - Defaults to `nil`.
* `original_changed?` (`:boolean`) - whether the document the open draft was made from has changed since. Defaults to `false`.
* `stage` (`:atom`) - Defaults to `:draft`.
* `dirty?` (`:boolean`) - Defaults to `false`.
* `keeping_draft?` (`:boolean`) - Defaults to `false`.
* `code_open` (`:boolean`) - Defaults to `false`.
* `code_tab` (`:atom`) - Defaults to `:heex`.
* `drawer_h` (`:integer`) - Defaults to `300`.
* `marked` (`:any`) - Defaults to `nil`.
* `element_css` (`:string`) - Defaults to `""`.
* `element_css_error` (`:string`) - Defaults to `nil`.
* `element_css_advice` (`:string`) - Defaults to `nil`.
* `heex_error` (`:string`) - Defaults to `nil`.

# `clear_field`

The ✖ inside a text field, shown only while there is something to clear.

## Why there is no hook

WHETHER IT IS SHOWN IS A CSS QUESTION. `:placeholder-shown` is true exactly while an input is
empty — that is what the pseudo-class means — so `peer-placeholder-shown:hidden` on a button that
follows the input answers it in the browser, on the keystroke, with no listener, no state and no
round trip. A hook would have to bind `input`, unbind it on destroy, and re-run itself on every
LiveView patch to stay right; this cannot fall out of step because there is nothing to fall out of
step with.

The one requirement it puts on the field is a non-empty `placeholder`, which every search box in
the editor already has because an empty one is unlabelled.

## Why the click still reaches the server

What these fields hold is not private to the browser: the palette's list, the block sheet's list,
the registry's list and the find sheet's hit count are all rendered from the query the SERVER
holds. Clearing the input alone would blank the box and leave the results filtered by a word no
longer written anywhere — the worst of the two states. So the button pushes the same event the
field's own typing pushes, with an empty value, and the field is blanked by the re-render that
answers it.

## Why the caret is not put back

The obvious finish is `JS.focus/1` on the field, since the reason to clear a search box is to type
a different word. It was written that way and it is wrong, which only driving it showed:
**LiveView never overwrites the value of a FOCUSED input.** `mergeFocusedInput` copies every
attribute except `value`, so focusing the field before the server's answer arrives left the box
still reading "drop" over a list that had gone back to all 135 — the exact half-cleared state the
paragraph above exists to avoid, produced by trying to be helpful.

`JS.set_attribute` cannot stand in for it either: once a person has typed, the input's value
property is dirty and no longer follows its attribute. Measured, not assumed.

So the field clears and the caret does not return. Clicking into a box you just cleared is one
click; a box that lies about what is filtering the list is a bug.

## Attributes

* `field` (`:string`) (required)
* `event` (`:string`) (required)
* `params` (`:map`) - Defaults to `%{}`.

# `command_palette`

⌘K — actions first, then the admin routes to jump to.

# `conditions`

When this element is on the page at all — HEEx's own `:if`, chosen from a list.

Sits above On click because it is the earlier question: whether the element is there, then what it
does. See `MishkaCmsCore.Runtime.PageBuilder.Conditions` for what each choice writes.

## Attributes

* `node` (`:any`) (required)
* `state` (`:any`) (required)

# `context_actions`

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

Every action the right-click menu can send — the list both LiveViews validate against.

# `context_menu`

The right-click menu, for a canvas node or a layers row.

Nothing in here is dynamic, deliberately: the hook stamps the node it was opened on and pushes the
action itself, so opening the menu costs no round trip and no patch can arrive mid-gesture and
replace the row under the pointer. See `Hooks.PbContextMenu`.

## Attributes

* `id` (`:string`) (required)
* `target` (`:string`) (required) - the selector for a row or node this menu belongs to — what a right-click has to land on.
* `key` (`:string`) (required) - the dataset key on that element holding the node id — `nodeId` or `layerRow`.

# `core_commands`

The core's ⌘K commands — the seed of `builder:commands`.

# `core_inspector_sections`

The core's inspector sections — the seed of `builder:inspector:sections`.

# `core_rail_keys`

The keys the core draws itself; everything else is drawn by its own descriptor.

# `core_rails`

The core's right-rail tabs — the seed of the `builder:rails` registry.

# `element_css`

One element's own CSS.

Behind a disclosure, next to Advanced, for the same reason: it is a real need and a rare one, and
the panel above it answers the common case. `&` means this element — plain CSS nesting rather
than Elementor's `selector` keyword or Bricks' `%root%`, so what is typed is real CSS and can be
checked by reading it. See `MishkaCmsCore.Runtime.PageBuilder.ElementCss`.

A COMPONENT IS ONE NODE AND MANY ELEMENTS. The rule is keyed on a class written into the node's
`class` attribute, which reaches the DOM only if the component declares `class` and puts it
somewhere — a kit component that does not is unreachable this way, and offering the box anyway
would take CSS that could never match. Those say so instead, and name the way round.

The movable list carries NO `phx-no-curly-interpolation`. That attribute is a parse-time switch
over the template source and everything nested in it; a declaration's text arrives at runtime,
where braces are never interpolated and so need no protecting. Wearing it anyway cost the row its
content — the author read the interpolation's own characters back, never the line they had just
written. The `&` and `&:hover` shown above the box keep theirs: those ARE template source, and
read as literal text either way.

THE ADVICE IS SHOWN WHERE THE CSS WAS TYPED. `MishkaCmsCore.Runtime.PageBuilder.ElementCss.advice/1`
catches the one mistake the validator cannot refuse — `main { … }` is valid CSS that quietly aims
at a `main` INSIDE this element rather than at the element — and the reply only ever reached the
code drawer. An author working in this box wrote a rule, watched the page not change, and was
told nothing: `inspector/1` took `element_css_advice` and never passed it on. It is drawn next to
`@error` and hidden behind it, because a body that will not parse has no aim to comment on yet.

## Attributes

* `node` (`:any`) (required)
* `entry` (`:any`) - Defaults to `nil`.
* `css` (`:string`) - Defaults to `""`.
* `error` (`:string`) - Defaults to `nil`.
* `advice` (`:string`) - Defaults to `nil`.
* `typed` (`:map`) - what the forms hold, typed and not yet applied. Defaults to `%{}`.

# `empty_studio_data`

```elixir
@spec empty_studio_data() :: map()
```

What the Studio shows before its data has arrived, and when the read fails.

One definition so the two cannot drift: a key added to the loader and forgotten here is a `KeyError`
in a render, which takes the whole editor down rather than one sheet.

# `entry_icon`

```elixir
@spec entry_icon(map()) :: atom()
```

The glyph a palette card draws.

A component may name its own in `extra.icon` — that is the authored answer and always wins. When it
does not, one is SUGGESTED from the name, because "chelekom-checkbox-field" plainly wants the input
glyph and making someone fill in 183 rows before the palette looks right is not a real option. The
category is the last resort.

# `examples_modal`

The examples modal: every variant the kit ships for a component, its source, and one button that
inserts the whole parsed subtree.

The kit carries roughly 3,000 of these. The old editor rendered twelve per component to build the
palette, used one, and dropped every attribute on insert; here nothing is pre-rendered and the
example arrives intact.

# `find_modal`

Find and replace over the page's WORDS.

Deliberately not over the markup: 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 class names, attributes and
expressions are out of reach here — the code drawer is where those are edited, by someone who
means to.

## Attributes

* `find` (`:string`) - Defaults to `""`.
* `replace` (`:string`) - Defaults to `""`.
* `hits` (`:list`) - Defaults to `[]`.

# `fix_id`

```elixir
@spec fix_id(map()) :: String.t()
```

The DOM id of one finding's Fix button.

A rule is not a finding. `field-no-form` fires once per loose field and `form-no-destination` once
per form, so naming the button after the rule alone put two elements with one id in the document as
soon as a page had two forms — reached on a stock install in three clicks. A duplicate id makes
`document.getElementById` answer whichever came first, so the second row's Fix drove the first
row's node.

A document-level finding carries no node and there is at most one of it, so the rule alone is
already unique there and the id it has always had is kept. `data-rule` is on every button either
way, for anything that wants to find them by rule rather than by finding.

# `folder_modal`

A new folder, without leaving the page you are building.

The same Gervaz form the Media library's Categories panel mounts —
`MishkaCmsCore.Runtime.MediaCategory`'s own — so a folder made here is a folder made there, with
the same actions, the same validation and the same `master_create` / `create` pair. Shorter by one
field for the reason the upload form is: the page belongs to a site, so `hidden_fields` drops Site
and `defaults` carries the answer. It starts inside the folder the rail has open, `parent_id`.

Nothing here puts the new folder in the rail. `MediaCategory` publishes on `media_category:created`
and `BuilderLive` re-reads the rail when it hears it — the same route a folder added in another tab
arrives by, so there is one path to be right rather than two.

## It is drawn INSIDE the sheet, and that is load-bearing

`fixed inset-0` puts it over everything regardless of where it sits in the DOM, but `phx-click-away`
reads the DOM, not the screen. The sheet closes on click-away, so while this was a sibling of the
sheet every click in it — including its own Close button — was a click AWAY from the sheet, and
shutting the form shut the sheet behind it. Rendered inside, those clicks are contained.

Escape needs the other half, and two handlers reach it: this one, and the editor's keyboard hook
through `shortcut("escape", …)`. That is fine BECAUSE THEY AGREE — both close this form and
neither touches the sheet. It was not fine when they disagreed: the sheet used to bind escape too,
so one press pushed two events, the form's landed first and cleared the flag, and the sheet's
arrived to find nothing stopping it. Escape shut both.

Two are needed because neither covers the other's case. The hook stays quiet while the caret is in
a field — correct for ⌘Z, wrong for escape in this form's Name box — and `phx-window-keydown` here
answers wherever the caret is.

And it is drawn LAST. Inserted as the sheet's first child it shifted every sibling by one, and
morphdom pairs children by position — so opening the form destroyed and rebuilt the upload
component and the gallery beside it, losing anything staged and re-running the query. Appended,
the siblings keep their places and the new node is simply added.

## Attributes

* `site_id` (`:string`) - Defaults to `nil`.
* `parent_id` (`:string`) - Defaults to `nil`.
* `current_user` (`:map`) (required)

# `grouped_tokens`

# `hook_picker`

Attaches this element to a runtime JS hook.

A picker, not a code box. Writing a hook stays on Runtime → JS hooks, which is the right amount of
friction for shipping executable JavaScript; this only selects from what is already compiled into
the site's bundle, and saves someone hand-typing `phx-hook` into Advanced and getting the
capitalisation wrong. See `MishkaCmsCore.Runtime.PageBuilder.Hooks`.

## Attributes

* `node` (`:any`) (required)
* `entry` (`:any`) - Defaults to `nil`.
* `hooks` (`:list`) - Defaults to `[]`.
* `attached` (`:any`) - Defaults to `nil`.

# `icon`

One inline SVG from the prototype's icon set — no icon font, no runtime lookup.
## Attributes

* `name` (`:atom`) (required)
* `size` (`:integer`) - Defaults to `15`.
* `class` (`:string`) - Defaults to `nil`.
* `filled` (`:boolean`) - solid rather than stroked — the drag grip's dots. Defaults to `false`.

# `icon_name`

```elixir
@spec icon_name(String.t() | nil) :: atom()
```

The icon this panel draws for a descriptor's plain-string `icon`, so a plugin can name one without
depending on this module's atoms. A name the set does not hold falls back to the chevron.

# `interactions`

What this element does when it is clicked, without anyone writing JavaScript.

Sits under Custom CSS because the two are one idea: toggle `is-open` here, write `&.is-open { … }`
there, and that is an accordion with no code in it. See
`MishkaCmsCore.Runtime.PageBuilder.Interactions` for why a `<script>` is the wrong shape under
LiveView.

## Attributes

* `node` (`:any`) (required)
* `entry` (`:any`) - Defaults to `nil`.
* `state` (`:any`) (required)
* `aimed` (`:string`) - the scope class the saved action points at. Defaults to `nil`.
* `opener` (`:map`) - what the target being aimed at says it can do — see `MishkaCmsCore.Builder.PaletteEntry.opens/1`. Defaults to `%{}`.
* `aiming` (`:string`) - the node id picked in the target select but not saved yet, which outranks `aimed`. Defaults to `nil`.
* `openable` (`:list`) - the nodes in this document that hide their own content — see `openable/1`. Defaults to `[]`.
* `acting` (`:string`) - the verb picked in the action select but not saved yet, which outranks the saved one. Defaults to `nil`.

# `interface_hidden_fields`

```elixir
@spec interface_hidden_fields(module()) :: [atom()]
```

Every field of a resource's form except the two that ARE the interface.

Public because it is the whole contract between this panel and the resource, and a test that reads
a hidden-field list from the markup is testing Gervaz rather than the decision made here.

Asked of the form rather than listed: a list would be a second place to remember when the resource
grows a column, and the one that forgets is this one.

# `kit_prefixes`

```elixir
@spec kit_prefixes([map()]) :: MapSet.t(String.t())
```

The kit prefixes worth dropping from a card's label.

The prototype's cards read "Card", "Grid", "Navbar" while the components themselves are
"chelekom-card" and friends — the kit's own name is on all 157 of them, so printing it on every
card spends the label on the one word that never varies, and pushes the part that does off the
card. A first segment shared by three or more components is that kind of prefix; a one-off like
"hero-banner" keeps both words. The full name stays in the tooltip and everywhere else.

# `leave_modal`

The question asked when someone leaves the editor in publish mode with changes.

Every other mode saves as you work, so leaving is free and nothing asks. Publish mode deliberately
writes nothing — that is what makes "refresh and the original comes back" true — which leaves one
state where walking away and discarding are the same gesture.

So the question is not "are you sure": it is the two things the work could become. Keeping it puts
it in a draft under this sitting's name; publishing puts it in front of visitors. Both then leave.
Throwing it away is still possible, but it is the quiet third option rather than the loud one — a
discard that is easier to press than either save is a discard people hit by accident.

# `left_panel`

The left panel — Components and Inspector — with its collapse rail and 6px resize grip.

Collapsing leaves a 34px rail carrying a chevron and the panel's name set vertically, so the panel
is one click from coming back. Preview mode does not touch either: it cleans the CANVAS, and the
editor around it stays where the author left it.

## Attributes

* `editor` (`:any`) (required)
* `palette` (`:any`) (required)
* `picked` (`:string`) - Defaults to `nil`.
* `component_presets` (`:map`) - Defaults to `%{}`.
* `palette_query` (`:string`) - Defaults to `""`.
* `palette_cat` (`:string`) - Defaults to `"all"`.
* `block_query` (`:string`) - Defaults to `""`.
* `block_cat` (`:string`) - Defaults to `"all"`.
* `block_library` (`:list`) - Defaults to `[]`.
* `element_library` (`:list`) - Defaults to `[]`.
* `left_hidden` (`:boolean`) - Defaults to `false`.
* `left_tab` (`:atom`) - Defaults to `:components`.
* `left_w` (`:integer`) (required)
* `mob_panel` (`:atom`) - Defaults to `:canvas`.
* `selected_node` (`:any`) - Defaults to `nil`.
* `attr_form` (`:any`) - Defaults to `nil`.
* `typed` (`:map`) - what the panels' forms hold, typed and not yet applied, by form id — see `typed_in/4`. Defaults to `%{}`.
* `breakpoint` (`:atom`) - Defaults to `:base`.
* `pseudo` (`:atom`) - Defaults to `:base`.
* `focus_attr` (`:string`) - Defaults to `nil`.
* `group_folds` (`:map`) - Defaults to `%{}`.
* `style_tokens` (`:list`) - Defaults to `[]`.
* `icon_picker` (`:string`) - Defaults to `nil`.
* `icon_query` (`:string`) - Defaults to `""`.
* `icon_library` (`:any`) - Defaults to `nil`.
* `icon_variant` (`:string`) - Defaults to `nil`.
* `element_css` (`:string`) - Defaults to `""`.
* `element_css_error` (`:string`) - Defaults to `nil`.
* `element_css_advice` (`:string`) - Defaults to `nil`.
* `js_hooks` (`:list`) - Defaults to `[]`.
* `fragments` (`:map`) - the page's stored code: `events`, `infos` and `helpers` entries. Defaults to `%{}`.
* `rename_origin` (`:any`) - the selected form as it was when its rename began, which the rename warning reads. Defaults to `nil`.
* `retagged` (`:map`) - the stored handler names a pending rename moves onto its form's tagged names. Defaults to `%{}`.
* `code_allowed?` (`:boolean`) - whether the author may write the page's code. Defaults to `false`.
* `section` (`:string`) - which of the four documents is being edited. Defaults to `"page"`.
* `record` (`:any`) - Defaults to `nil`.
* `current_user` (`:any`) - Defaults to `nil`.
* `aim_target` (`:string`) - the node id in the On-click target select right now, before it is saved. Defaults to `nil`.
* `act_action` (`:string`) - the verb in the On-click action select right now, which decides what box sits under it. Defaults to `nil`.

# `loading_rows`

The shape of the answer while the answer is still coming — a panel that opens instantly and fills
in beats one that opens when the database is ready.

## Attributes

* `rows` (`:integer`) - Defaults to `3`.

# `loading_tiles`

The assets grid's own waiting shape — the tile, not a row.
## Attributes

* `tiles` (`:integer`) - Defaults to `4`.

# `media_preview`

The file, full size, without leaving the page you are building.

The Media library's eye opens a preview there; here it used to open nothing — `BuilderLive`
answered the row action and dropped it, because the editor had no overlay to put a file in. An
action that is drawn and does nothing is worse than one that is not drawn.

Escape is not bound here. `shortcut("escape", …)` closes this first, and one owner is what keeps
the sheet from leaving with it.

## Attributes

* `media` (`:map`) (required)

# `media_scope_params`

```elixir
@spec media_scope_params(map()) :: map()
```

What the Assets gallery is scoped by: the site, the chosen folder, and — while something is waiting
for a file — which node asked and what it can accept.

Public because it is the whole contract between the editor and the gallery, and a test that reads
it is asking the same question the table is.

# `mobile_bar`

Below 900px both panels collapse into this horizontally scrolling tab strip.
## Attributes

* `mob_panel` (`:atom`) - Defaults to `:canvas`.
* `dir` (`:string`) - Defaults to `"ltr"`.

# `new_document`

THE ESSENTIALS, BEFORE THE STRUCTURE. What the editor needs before there is a document to edit.

"Create empty & open editor" is a link, so a reader who takes it has said they do not want the list
page's form — and answering them with that form's validation would be answering a question they
declined. This asks for the few things a record cannot exist without, and nothing else.

IT IS THE RESOURCE'S OWN FORM, with everything but those few fields hidden. That is what makes the
Site field a searchable relation picker rather than a `<select>` nobody could use against a hundred
thousand sites, and what makes "required" mean what the resource says it means — a second little
form of our own would have been a second set of rules to keep in step.

A TENANT IS NEVER ASKED WHICH SITE, and this does nothing to arrange that: the resource declares
the field `restricted true`, so Gervaz draws it for a master and for nobody else. Whether a site is
REQUIRED is the resource's answer too — a page may be global, and its picker says so.

IT ARRIVES INACTIVE. A document made here has nothing on it yet, and `active` is what puts it in
front of visitors — a page that goes live the moment it is named is live while it is empty. The
flag is one of the hidden fields, so `defaults` is what carries it: `hidden_fields` drops a field
from the form but `merge_defaults/2` still fills the param it would have sent.

On save the resource announces `{:form_saved, :create, record}` like any other Gervaz form, and the
editor reopens on it — which is when the starter chooser appears, because from then on this is an
ordinary empty document.

## Attributes

* `section` (`:string`) (required)
* `current_user` (`:any`) (required)
* `hidden_fields` (`:list`) - Defaults to `[]`.

# `node_class_list`

```elixir
@spec node_class_list(MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t() | nil) :: [
  String.t()
]
```

A node's authored classes as a list, in the order they were written.

`Tree.classes/1` rather than the text of the attribute, because an expression's TEXT is not a list
of classes: reading `class={[@variant, "pb-…"]}` that way answered `["[@variant,", …]` and the
Behavior switches went on to write those fragments back as the class.

# `node_label`

```elixir
@spec node_label(MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t()) :: String.t()
```

The short name a node goes by in the layers tree, the crumb bar and a review byline.

Public because a review note captures the label of the element it was written about at the time it
was posted — see `MishkaCmsCore.Runtime.PageBuilder.Comment`.

# `openable`

```elixir
@spec openable(map()) :: [{String.t(), String.t(), String.t() | nil}]
```

Every node in this document a click can be pointed AT, as `{id, label, scope}`.

## What earns a place here

A DECLARATION, not a guess. `PaletteEntry.opens/1` — the component's own `data-pb-open` and its
two siblings — and nothing else.

It was `concealed?/1` before, which asks a different question: does this render its own content
closed? That is the right question for the layers glyph, where a wrong yes costs a glyph on a row
that did not need one. It is the wrong question here, where a wrong yes costs the author a control
that saves a flash reading "Click action set." and then does nothing when the page is clicked.

The five it removes are not oversights: `chelekom-tabs` and `chelekom-carousel` open one of MANY —
"open this" is not a whole sentence for them without naming which — and `chelekom-stepper`,
`chelekom-rating` and `chelekom-scroll-area` have no open state at all and were only ever caught
by a markup scan being deliberately generous. Every one of the eleven that remain was verified to
open from a button on a rendered page.

A third-party component joins this list by saying so, in one attribute on its own root; see
`MishkaCmsCore.Builder.PaletteEntry.opens/1`.

`scope` is the node's `pb-xxxxxxxx` class WHEN IT ALREADY HAS ONE, which is only how a saved
action is matched back to the row that wrote it. A node with none is still offered; the scope is
minted when the interaction is saved, not before, so browsing the list changes nothing.

# `overlay?`

```elixir
@spec overlay?(map() | nil) :: boolean()
```

Whether this row's component renders its own content closed — a dialog, a drawer, a dropdown.

# `pinned?`

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

Whether this overlay is being held open on the canvas.

# `quiet_scroll`

```elixir
@spec quiet_scroll(:dark | :light) :: String.t()
```

The scrollbar treatment for a pane that holds code, on a dark or a light surface.

# `recover`

What the editor shows for a document it cannot parse.

A CRASH LOOP IS THE WORST SHAPE A FAILURE CAN TAKE, because the page that has to be opened to fix
the problem is the page that will not open. So: what the tokenizer objected to, where it stopped,
the stored text itself, and a box to correct it in. Saving from here goes through the same action
the editor saves with, so a correction that still will not parse is refused the same way.

## Attributes

* `fault` (`:map`) (required)
* `error` (`:string`) - Defaults to `nil`.
* `section` (`:string`) - Defaults to `"page"`.
* `record` (`:any`) - Defaults to `nil`.
* `typed` (`:map`) - what the forms hold, typed and not yet applied. Defaults to `%{}`.

# `reload_modal`

The centred confirmation shown before reloading the canvas with unsaved work in the session.

Reloading the frame does not throw an edit away — the canvas re-renders from the same editing
session — but it does discard whatever is mid-flight in the browser: an inline text edit still
focused, a drag in progress. Asking is cheap; guessing wrong is not.

# `resize_bounds`

```elixir
@spec resize_bounds(:left | :right | :drawer) :: {pos_integer(), pos_integer()}
```

How far a panel may be dragged — `{min, max}` in pixels.

One source, two readers. The grip carries these into the DOM as `data-min`/`data-max` so the drag
can stop at the right place without asking the server, and `BuilderLive` clamps the value it is
finally told with the same pair — a bound only the browser enforces is not a bound, and a bound
only the server enforces makes the panel overshoot and snap back.

# `right_panel`

The right panel — Layers, Assistant and Audit — as `builder:rails` contributions.
## Attributes

* `rails` (`:list`) (required)
* `right_tab` (`:atom`) - Defaults to `:layers`.
* `right_hidden` (`:boolean`) - Defaults to `false`.
* `right_w` (`:integer`) (required)
* `mob_panel` (`:atom`) - Defaults to `:canvas`.
* `editor` (`:any`) (required)
* `tree` (`:any`) (required)
* `selection` (`:string`) - Defaults to `nil`.
* `expanded` (`:any`) (required)
* `selected_node` (`:any`) - Defaults to `nil`.
* `ai_messages` (`:list`) - Defaults to `[]`.
* `ai_prompt` (`:string`) - Defaults to `""`.
* `ai_backend` (`:any`) - Defaults to `nil`.
* `ai_attachment` (`:any`) - Defaults to `nil`.
* `record` (`:any`) - Defaults to `nil`.
* `section` (`:string`) - Defaults to `nil`.
* `current_user` (`:any`) - Defaults to `nil`.

# `selection_tag`

```elixir
@spec selection_tag(MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t()) :: String.t()
```

How a node is named in the chrome: `<header>` for an element, the component's own name for a
component. Public because `BuilderLive` labels an AI attachment with the same words the composer's
scope chip shows — one name for one thing.

# `settings?`

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

Whether this section has an address to change — which is what the settings modal is for.

Asked of the resource's own form rather than of a list of section names, so a fifth section
arrives with the right answer and no list to remember.

# `settings_hidden_fields`

```elixir
@spec settings_hidden_fields(String.t(), module() | nil) :: [atom()]
```

Every field of the section's form except its name, its address and its description — and, for a
page, its description, which its SEO tab edits, what each of its addresses shows, its layout and
who may open it.

Public because it is the whole contract between the modal and the resource: a test that read the
hidden list out of the rendered markup would be testing Gervaz rather than the decision made here.

# `settings_modal`

A document's own name, address and description, changed without leaving the editor. A page's
description is the one search engines and link previews show; a page also picks here what each of
its addresses shows, its layout and which permission names may open it.

## Why it exists

A page is named and given a path once, on the way in — on the list page's form, or on the
essentials step — and until now that was the only time. Changing either afterwards meant leaving
the editor for the list, finding the row and opening its modal: a long way round for the two
fields most likely to be wrong on a first draft, and one that costs the reader the editor they
were working in.

## It is the resource's own form

The same `MishkaGervaz.Form.Web.Live` the essentials step mounts, in edit mode over this record,
with every other field hidden. The label, the validation, the uniqueness message and the action it
saves through are the resource's — a small form of our own would be a second set of rules to keep
in step with the list page's, and the one that drifts is always the second.

## Only where there is an address

`settings?/1` is the gate, and it asks the resource rather than naming sections: a section has
settings when its form declares a `path`. A component has none — it is reached by name from the
markup that calls it, and its name is the key its compiled module is hashed from, so renaming one
is not a settings change but a rename of every call site.

## What it does not offer

The site. `update` accepts no `site_id` on any of these resources, so this could not move a
document between sites even if it drew the field — and an editor left open on one that had just
moved would be holding another site's palette, tokens and media.

## The SEO tab

A section that stores SEO gets a second tab (`MishkaCmsCore.Runtime.PageBuilder.SeoSettings.mode/1`):
a Gervaz form over `SeoSettings`, drawn by `MishkaCmsCoreWeb.Templates.SeoForm`. It mounts the
first time the tab opens and stays, hidden, until the modal closes, so moving between the tabs
loses nothing typed in either; it saves with its own Save. While the Media sheet is open for its
share image the modal is `hidden?`.

## Attributes

* `section` (`:string`) (required)
* `record` (`:map`) (required)
* `draft` (`:map`) - the draft the editor is writing, whose settings these are instead of the record's. Defaults to `nil`.
* `current_user` (`:any`) (required)
* `tab` (`:atom`) - `:general` or `:seo`. Defaults to `:general`.
* `seo_form?` (`:boolean`) - whether the SEO tab's form is mounted. Defaults to `false`.
* `hidden?` (`:boolean`) - whether a sheet the SEO tab opened is over it. Defaults to `false`.

# `sheet`

The overlay sheets — Studio, Assets, Review and Record history.

# `short_label`

```elixir
@spec short_label(map(), MapSet.t(String.t())) :: String.t()
```

A card's label with its kit prefix removed. Falls back to the full label.

# `source_modal`

The page's real source, in a window big enough to work in.

## What it shows

`Serializer.to_heex/1` — the exact bytes a save writes, formatted — and not the unformatted
`serialize/1` output the bottom drawer shows. That difference is the whole point: this answers
"what is actually stored", so it has to be the stored string and not something that merely looks
like it. When formatting fails, the raw serialisation is shown with the reason above it; a page
whose source you cannot see is worse than an ugly one.

## Why it is editable

Reading is the common case, but the fastest way to fix a stray attribute, paste a section from
somewhere else, or undo a mess is to edit the markup. Applying re-parses the text and replaces the
working tree through `EditorSession.put_tree/3` — the same call a draft restore makes — so it is
one undo step and every open frame follows. Nothing reaches the database until Save, exactly as
with any other edit.

Unparseable text is refused with the parser's own message and the window stays open with the text
still in it. Losing what someone just typed is not an acceptable way to report a typo.

This is the TOP bar's Code. The drawer under the canvas is a different surface with a different
job and is deliberately left alone.

## Attributes

* `source` (`:string`) (required)
* `error` (`:string`) - Defaults to `nil`.
* `label` (`:string`) (required)
* `tab` (`:atom`) - Defaults to `:template`.
* `fragments` (`:map`) - Defaults to `%{}`.
* `fault` (`:any`) - Defaults to `nil`.
* `rules` (`:map`) - Defaults to `%{}`.
* `tree` (`:any`) - Defaults to `nil`.
* `code_allowed?` (`:boolean`) - Defaults to `false`.
* `section` (`:string`) - Defaults to `"page"`.
* `record` (`:any`) - Defaults to `nil`.
* `current_user` (`:any`) - Defaults to `nil`.
* `code_focus` (`:any`) - what the Handlers tab puts in front of the reader: `{:entry, index}` or `{:form, node_id}`. Defaults to `nil`.
* `retagged` (`:map`) - the stored handler names a pending rename moves onto its form's tagged names. Defaults to `%{}`.

# `studio_options`

```elixir
@spec studio_options() :: [{String.t(), String.t(), String.t()}]
```

The Studio's three theme options: setting key, label and the sentence under it.

Public because `BuilderLive` writes them through `MishkaCmsCore.Settings` and has to know the same
keys this draws — one list, two readers.

# `studio_tabs`

The Studio's tabs `user` may see, in the prototype's order — Classes sits beside Tokens
  because both are things a site defines once and every page then resolves through.

# `studio_token_list`

```elixir
@spec studio_token_list(String.t() | nil) :: [
  MishkaCmsCore.Runtime.PageBuilder.Marketplace.Token.t()
]
```

The site's own design tokens, falling back to whatever the marketplace adapter offers.

Public because `BuilderLive` reads it off the click that opens the Studio rather than during the
sheet's render — parsing every stylesheet the site has is not something to do while painting.

# `token_categories`

# `top_bar`

The 56px top bar: identity and save status on the left, every global control on the right.

THE IDENTITY BLOCK IS THE ONLY CHILD THAT GIVES WAY. Everything on its right is `flex-none`, so it
is the controls that must stay whole; the block takes what is left and truncates — the document's
name, the status, the scope badge and the draft chip each carry `min-w-0` for it. A block that was
itself `flex-none` grew with whatever the record was called and pushed Publish off the screen, and
the status line is longest exactly when the stage is Published. Below 1180px the status wording
goes and its dot, with the words as a tooltip, stays — the same ladder the avatars and the labels
follow.

## Attributes

* `editor` (`:any`) (required)
* `record` (`:any`) - Defaults to `nil`.
* `section` (`:string`) (required)
* `back_path` (`:string`) (required)
* `current_user` (`:any`) - Defaults to `nil`.
* `editors` (`:list`) - Defaults to `[]`.
* `draft` (`:any`) - Defaults to `nil`.
* `drafts_enabled?` (`:boolean`) - Defaults to `false`.
* `dirty?` (`:boolean`) - Defaults to `false`.
* `saved_at` (`:any`) - Defaults to `nil`.
* `stage` (`:atom`) - Defaults to `:draft`.
* `applied_stage` (`:atom`) - Defaults to `:draft`.
* `device` (`:string`) - Defaults to `nil`.
* `dir` (`:string`) - Defaults to `"ltr"`.
* `zoom` (`:integer`) - Defaults to `100`.
* `preview?` (`:boolean`) - Defaults to `false`.
* `source_modal` (`:boolean`) - Defaults to `false`.
* `dev_mode?` (`:boolean`) - Defaults to `false`.
* `open_bugs` (`:integer`) - Defaults to `0`.
* `studio_badge` (`:integer`) - Defaults to `0`.
* `history_position` (`:integer`) - Defaults to `0`.
* `history_total` (`:integer`) - Defaults to `0`.

# `write_bucket`

# `zoom_bounds`

```elixir
@spec zoom_bounds(:single | :frame) :: {pos_integer(), pos_integer()}
```

How far a canvas zoom may go — `{min, max}` in percent, for `:single` (one device frame) or
`:frame` (the all-breakpoints row).

The zoom controls carry these into the DOM as `data-min`/`data-max`, where the `PbZoom` hook
clamps every click with them, and `BuilderLive` clamps the value it is told with the same pair.

---

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