MishkaCmsCore.Runtime.PageBuilder.ElementCss (Mishka CMS Core v0.0.1)

Copy Markdown View Source

CSS an author writes by hand for one element.

Why this is not the style panel, and not a reusable class

The inspector writes a fixed vocabulary — padding, gap, colour — because a panel can only offer what someone thought to add to it. MishkaCmsCore.Runtime.PageBuilder.Classes covers the twenty cards that are meant to match. Neither answers "this one element needs a border-image and a :hover transform", which is the request every builder eventually gets and which Elementor, Bricks, Divi and WordPress all answer with a per-element CSS box.

What the author writes

Declarations, and & where they mean the element itself:

padding: 20px;

&:hover { transform: translateY(-2px); }
@media (max-width: 900px) { padding: 12px; }

& is plain CSS nesting rather than Elementor's selector keyword or Bricks' %root%. Three reasons, none of them aesthetic: it is real CSS, so it can be checked by reading it rather than by substituting into it; wrapping the text in .pb-xxxxxxxx { … } scopes it as long as every nested selector starts with &, which is one rule to enforce; and Tailwind v4 already flattens nesting and resolves @apply in the same pass, so there is no flattener to write.

Where it lives

The element carries a minted class — MishkaCmsCore.Runtime.PageBuilder.Tree.scope/2 — and the rule body lives in a stylesheet row keyed by it. The class is in the markup, so it survives a save and a re-parse and travels with the node on copy; the body is in a row, so it is cached CSS that the compiler already knows how to build rather than a <style> block shipped to every visitor on every request.

One row per document, rewritten whole from the scopes the document currently contains — which is also what prunes the rule for an element somebody deleted, without anything having to notice the deletion. That pruning is live_rules/2, and the builder runs it on save.

The row IS the live site: writing one recompiles the site's stylesheet. So a rule written while the builder's stage says Draft is not in it at all — it waits in the editing session beside the markup that carries its class, as place/3 rather than put/5, and the publish that writes that markup folds it back in with merge/2.

Every write starts from the row as it is now — put/5 reads it, and merge/2 is given it — because the row is SHARED. An error page's five statuses are five documents in one row, a second editor on the same document is a second browser writing it, and a caller that wrote a map it read when it opened would delete everything written since. That is one rule with no exceptions: nothing writes a snapshot.

The two places this could have lived instead

meta on the node loses the CSS on the first save: meta is derived at parse, so anything put there that is not in the markup is gone the next time the document is read back.

A data- attribute holding the rule text round-trips perfectly and was still wrong: it ships to every visitor on every request, uncached, and it forfeits @apply — the rule never reaches Tailwind, so it cannot use the design system the rest of the page is built out of.

Security

The row is written as the builder's own (page_code: :builder in the changeset context), so it is written by whoever may open a builder rather than by the stylesheet area alone — see MishkaCmsCore.Runtime.Stylesheet. valid_body/1 refuses anything that closes the wrapper early and escapes onto the rest of the site.

Deliberately not layered

Classes wraps its rules in @layer components so a per-element utility still beats a shared class. This is one tier further in: the author wrote it for this element specifically, so it is emitted unlayered and therefore last, and it beats the inspector's utilities. That ordering is what Bricks, Webflow and WordPress 7.0 all settled on.

Summary

Types

One hand-written declaration, and the bucket of the style panel it competes with.

Functions

What is worth telling an author about CSS that is valid and probably not what they meant.

Every rule this document holds, as %{scope => body}.

The CSS for a set of rules. Public because it is the whole contract with the compiler.

Every declaration a body applies to the element itself, in the order they were written.

The rules a tree still refers to. Anything else in the row belongs to an element that is gone.

Edits laid over the rules as they stand, a blank body clearing the rule it names.

Reads a row back into %{scope => body}.

One element's CSS placed in a set of rules, checked but not written anywhere.

Writes one element's CSS, or clears it when the body is blank.

The nested selectors in a body that reach past the element itself — & > .badge, & .title.

The reserved row a document's element CSS lives in.

Rewrites the row to hold exactly rules, which is what prunes a deleted element's CSS.

The body with one declaration taken out — what Move to controls leaves behind.

Types

declaration()

@type declaration() :: %{
  bucket: :base | :hover | :focus | :active,
  property: String.t(),
  value: String.t(),
  text: String.t(),
  from: non_neg_integer(),
  to: non_neg_integer()
}

One hand-written declaration, and the bucket of the style panel it competes with.

from/to are byte offsets into the body it was read from, so without/2 can take exactly the text the author typed back out again — comments, spacing and all the rest left where they were.

Functions

advice(body)

@spec advice(String.t() | nil) :: String.t() | nil

What is worth telling an author about CSS that is valid and probably not what they meant.

nil when there is nothing to say. This never refuses anything: a nested selector is real CSS and a descendant rule is a thing people write on purpose.

The one it catches is the mistake this box invites. The pane is titled with the element — main — this element's own — so an author writes what looks like the rule for it:

main { color: red }

Wrapped, that is .pb-xxxxxxxx { main { color: red } }: a main INSIDE the element, which for a main is nothing at all. It saves, it is announced as applied, and the page does not change. The author has no way to tell that from a feature that is broken.

all(actor, site_id, owner)

@spec all(map() | nil, String.t() | nil, String.t()) :: %{
  required(String.t()) => String.t()
}

Every rule this document holds, as %{scope => body}.

Read back out of the compiled row rather than kept anywhere else: the stylesheet IS the record, so a rule edited by hand on the Stylesheets page is still the rule here.

css(rules)

@spec css(%{required(String.t()) => String.t()}) :: String.t()

The CSS for a set of rules. Public because it is the whole contract with the compiler.

declarations(body)

@spec declarations(String.t() | nil) :: [declaration()]

Every declaration a body applies to the element itself, in the order they were written.

Read with a scanner rather than a regular expression for the reason parse/1 is: the body nests, and a pattern that stops at the first } would read &:hover { color: red } as a top-level declaration of color. Nested selectors that are not one of @pseudo_buckets — & > .badge, a media query — are skipped entirely: they style something else, or at a width the panel cannot name.

live_rules(tree, rules)

@spec live_rules(MishkaCmsCore.Runtime.PageBuilder.Tree.t(), %{
  required(String.t()) => String.t()
}) ::
  %{required(String.t()) => String.t()}

The rules a tree still refers to. Anything else in the row belongs to an element that is gone.

merge(rules, edits)

@spec merge(
  %{required(String.t()) => String.t()},
  %{required(String.t()) => String.t()} | nil
) :: %{
  required(String.t()) => String.t()
}

Edits laid over the rules as they stand, a blank body clearing the rule it names.

Per element rather than as one map, and that is the whole point: the publish that folds a draft's held CSS into the row is told which elements the author touched, so everything else in the row — the four statuses they were not looking at, the rule a second editor wrote a minute ago — is left exactly as it is.

parse(content)

@spec parse(String.t() | nil) :: %{required(String.t()) => String.t()}

Reads a row back into %{scope => body}.

Brace-counted rather than matched with a regular expression, because a body nests — &:hover { … } and @media (…) { … } are the point of the feature — and a pattern that stops at the first } would truncate every rule that used them. Counted by the same scanner the declarations are read with, so a brace inside a string or a comment is text: content: "}" ends nothing.

place(rules, scope, body)

@spec place(%{required(String.t()) => String.t()}, String.t() | nil, String.t() | nil) ::
  {:ok, %{required(String.t()) => String.t()}} | {:error, term()}

One element's CSS placed in a set of rules, checked but not written anywhere.

The builder's draft stage holds its rules in the editing session instead of writing them — the row is the live site — and it has to refuse exactly what put/5 refuses, or the box takes CSS in one stage that the publish then throws out.

put(actor, site_id, owner, scope, body)

@spec put(map() | nil, String.t() | nil, String.t(), String.t(), String.t()) ::
  {:ok, %{required(String.t()) => String.t()}} | {:error, term()}

Writes one element's CSS, or clears it when the body is blank.

Returns the whole map so a caller can hold the current state without reading again.

reaching_selectors(body)

@spec reaching_selectors(String.t() | nil) :: [String.t()]

The nested selectors in a body that reach past the element itself — & > .badge, & .title.

On a plain tag that is ordinary CSS about the author's own markup. On a COMPONENT it is a rule aimed at markup the kit owns and the author did not write, which the next version of the kit is free to rename; the panel says so rather than refusing it, because reaching in is sometimes the only way to style something and an author who is told can decide for themselves.

A pseudo bucket is not one of these: &:hover is still this element.

row_name(owner)

@spec row_name(String.t()) :: String.t()

The reserved row a document's element CSS lives in.

sync(actor, site_id, owner, rules)

@spec sync(map() | nil, String.t() | nil, String.t(), %{
  required(String.t()) => String.t()
}) ::
  {:ok, %{required(String.t()) => String.t()}} | {:error, term()}

Rewrites the row to hold exactly rules, which is what prunes a deleted element's CSS.

without(body, index)

@spec without(String.t() | nil, non_neg_integer()) :: String.t()

The body with one declaration taken out — what Move to controls leaves behind.

index is a position in declarations/1. Out of range, the body is returned untouched, because a stale click from a panel rendered against an older body must not delete a different rule.