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

Copy Markdown View Source

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.

Summary

Functions

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

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

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 stable colour for a name, for the Review thread's avatar discs.

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

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

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

Whole sections, one click each.

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

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

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

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

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

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

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

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

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

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

One element's own CSS.

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

The glyph a palette card draws.

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

Find and replace over the page's WORDS.

The DOM id of one finding's Fix button.

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

Attaches this element to a runtime JS hook.

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

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.

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

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

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

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

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

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.

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

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

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.

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

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

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

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

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

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

Whether this overlay is being held open on the canvas.

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

What the editor shows for a document it cannot parse.

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

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

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

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.

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

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.

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.

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

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

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

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

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.

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

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

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

Functions

ago(at)

@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(editor, chosen, selected)

@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?(assigns, node, name)

@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(author)

@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(author)

@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?(node, key)

@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()

@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(assigns)

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(assigns)

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(assigns)

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(assigns)

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

conditions(assigns)

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()

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

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

context_menu(assigns)

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(assigns)

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()

@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(entry)

@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(assigns)

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(assigns)

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(arg)

@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(assigns)

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(tokens, category)

hook_picker(assigns)

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(assigns)

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(name)

@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(assigns)

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(resource)

@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(entries)

@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(assigns)

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(assigns)

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(assigns)

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(assigns)

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

Attributes

  • tiles (:integer) - Defaults to 4.

media_preview(assigns)

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(assigns)

@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(assigns)

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(assigns)

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(node)

@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(node)

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(editor)

@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?(entry)

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

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

pinned?(editor, id)

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

Whether this overlay is being held open on the canvas.

quiet_scroll(arg)

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

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

recover(assigns)

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(assigns)

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(panel)

@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(assigns)

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(node)

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?(section)

@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(section, resource \\ nil)

@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(assigns)

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(assigns)

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

short_label(entry, prefixes)

@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(assigns)

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()

@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(user)

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(site_id)

@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(tokens)

top_bar(assigns)

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(editor)

See MishkaCmsCore.Runtime.PageBuilder.EditorSession.write_bucket/1.

zoom_bounds(kind)

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