MishkaCmsCore.Runtime.PageBuilder.Tree.Styles (Mishka CMS Core v0.0.1)

Copy Markdown View Source

Turns the inspector's recorded overrides into Tailwind utilities, and reads them back out again.

Why classes and not a stylesheet

ADR-8. The runtime Tailwind compiler only ever sees classes that appear in server-rendered HTML, so a rule invented at runtime and injected as CSS would never be generated. Writing the override as max-[900px]:py-[14px] on the element puts it exactly where the compiler looks. It also keeps the house rule intact — no <style> blocks, no static inline styles — and means an override is legible to anyone reading the template afterwards.

Round trip

to_classes/1 appends generated utilities after whatever the author wrote, in a stable order. from_classes/1 recognises the same shapes on the way back in and lifts them into meta.styles, leaving the author's own classes untouched. Parsing a template the editor saved therefore returns the same override map the inspector recorded — which is what makes the responsive panel show real state rather than a guess.

A hand-written hover:bg-[#111] is absorbed too. That is deliberate: it means an override someone typed by hand becomes editable in the panel instead of being invisible to it, and it re-serializes byte-identically.

A class is read only when writing its override back gives that same class, and only when it is the one class in its bucket that override would replace. Two of them — text-[#fff] text-[#000], or p-[20px] beside an authored px-8 — stay as the author wrote them: one value in the panel could only give one of them back.

Buckets

:base has no prefix. :tablet and :mobile are max-width queries at the prototype's breakpoints (900 and 560). :hover, :focus and :active are the state variants.

Summary

Types

One element of a class={[…]} expression: the classes a literal string holds, or the source of something the author works out at render time.

Functions

A class={…} expression's source with classes added to it as literals, or nil for one nothing can be added to.

The width and the state a bucket stands for — the inverse of bucket/2.

The bucket a width and a state name together.

The bucket a name stands for, {:ok, bucket}, or :error for a name no bucket has. For a name that came from the browser, which must never be made into an atom.

The style buckets, in the order their classes are emitted.

The style controls a hand-written CSS property overrides — [] when the panel has no say in it.

One recorded override as the CSS declaration it means, for anything that shows an author their own styling. {"py", "16px"} reads padding-block: 16px, and {"align", "items-center"} reads align-items: center.

The overrides that stop meaning anything once prop is set to value, and should go with it.

Splits a class attribute into the author's own classes and the override map the editor recorded.

One hand-written declaration as the override it would become, or :error when no control can hold it — which is what decides whether Move to controls is offered beside it.

The one-click spacing values offered beside the gap field.

Each value of a property whose options are explained by their own LABEL rather than by a picture.

The properties whose value is a CSS LENGTH, and which therefore read a bare number as pixels.

The class={[…]} expression source parts spell, or nil when nothing of them is left.

A class={[…]} expression taken apart in order: {:classes, [class]} for each literal string element and {:logic, source} for everything the author works out at render time.

The class attribute a node should serialize with: the author's classes followed by the compiled overrides. Returns nil when there is nothing to write, so an override-free node with no class keeps having no class attribute.

The fixed set of values a property accepts, or [] when it takes a free-form value.

Each value of an enumerated property with the words and the picture that explain it.

The CSS-ish property names the panel can express as a utility.

A class={[…]} expression split into the part that is ours and the part that is the author's.

Compiles an override map into a list of Tailwind classes, in bucket order.

The plain-words name of one enumerated value, or the value itself when nothing describes it.

classes with each one that sets prop under a variant made important: hover:text-white becomes hover:text-white! when prop is "color".

Types

part()

@type part() :: {:classes, [String.t()]} | {:logic, String.t()}

One element of a class={[…]} expression: the classes a literal string holds, or the source of something the author works out at render time.

Functions

add_expr_classes(source, classes)

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

A class={…} expression's source with classes added to it as literals, or nil for one nothing can be added to.

A list is extended; anything else is the author's own expression, which is not ours to take apart, so it becomes the first element of a list the classes join.

Why the splice is read back before it is kept

AN EXPRESSION IS NOT ALWAYS SOMETHING A COMMA CAN FOLLOW. class={if @x, do: "a", else: "b"} ends in a keyword list written without brackets, and [if @x, do: "a", else: "b", "pb-0167f46c"] is a syntax error: a page that no longer compiles and can no longer be saved, wearing a name that reads back as no name at all — so the element cannot be found by it, cannot give it up, and is handed a second one on every visit to its Custom CSS box.

Building the source and parsing it again is the only thing that actually knows. Bracketing it is tried first because that is the shape every list class already has; parenthesising the author's expression is what rescues the keyword-list tail; and an expression neither reaches is one this cannot write, which is nil rather than a guess.

axes(bucket)

@spec axes(atom()) :: {atom(), atom()}

The width and the state a bucket stands for — the inverse of bucket/2.

bucket(breakpoint, pseudo)

@spec bucket(atom(), atom()) :: atom()

The bucket a width and a state name together.

{:mobile, :hover} is :mobile_hover; a state at the widest breakpoint keeps its own name, because hover: with no query already applies at every width.

bucket_atom(name)

@spec bucket_atom(term()) :: {:ok, atom()} | :error

The bucket a name stands for, {:ok, bucket}, or :error for a name no bucket has. For a name that came from the browser, which must never be made into an atom.

buckets()

@spec buckets() :: [atom()]

The style buckets, in the order their classes are emitted.

controls_for(property)

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

The style controls a hand-written CSS property overrides — [] when the panel has no say in it.

"padding-block" answers ["py", "pt", "pb"]: the control that owns the property outright, plus the longhands a shorthand swallows. Used by the inspector to mark a control whose value the element's own CSS has taken over, and by from_declaration/2 in reverse.

declaration(prop, value)

@spec declaration(String.t(), String.t()) :: {String.t(), String.t()}

One recorded override as the CSS declaration it means, for anything that shows an author their own styling. {"py", "16px"} reads padding-block: 16px, and {"align", "items-center"} reads align-items: center.

A property this module names the same way CSS does, and a free-form value, pass straight through.

dependents(arg1, value)

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

The overrides that stop meaning anything once prop is set to value, and should go with it.

Lives here rather than in a panel because the vocabulary is what knows which properties depend on which — so the toolbar, the Inspector and anything added later all drop the same set, in the same single undoable step.

from_classes(class, opts \\ [])

Splits a class attribute into the author's own classes and the override map the editor recorded.

Returns {authored_classes, styles}. Anything that does not look like a generated utility stays in authored_classes untouched and in its original order.

from_declaration(property, value)

@spec from_declaration(String.t(), String.t()) ::
  {:ok, {String.t(), String.t()}} | :error

One hand-written declaration as the override it would become, or :error when no control can hold it — which is what decides whether Move to controls is offered beside it.

Exact only: padding: 20px becomes the Padding override and never the four longhands it also overrides, because moving it into pt alone would change what the page looks like.

gap_presets()

@spec gap_presets() :: [{String.t(), String.t()}]

The one-click spacing values offered beside the gap field.

labels(prop)

@spec labels(String.t()) :: [{String.t(), String.t(), String.t(), String.t()}]

Each value of a property whose options are explained by their own LABEL rather than by a picture.

{value, title, tailwind, css}. Returns [] for anything pictured or free-form, so a panel can ask both and draw whichever answers.

lengths()

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

The properties whose value is a CSS LENGTH, and which therefore read a bare number as pixels.

Public because the panel labels them: a box that takes 50% and 2rem and says only "unset" is a box people conclude is pixels-only, which is what happened.

list_expr(parts)

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

The class={[…]} expression source parts spell, or nil when nothing of them is left.

Nothing left means the attribute goes, the same as an emptied string class: a list holding only the name an element has just given up is not a class.

list_parts(source)

@spec list_parts(String.t()) :: [part()] | :not_a_list

A class={[…]} expression taken apart in order: {:classes, [class]} for each literal string element and {:logic, source} for everything the author works out at render time.

Returns :not_a_list for an expression this cannot take apart — the same expressions split_list_expr/2 refuses.

Why the classes inside a list have to be readable and writable

A CLASS IN A LIST IS A CLASS THE ELEMENT WEARS. The minted pb-xxxxxxxx that names an element to its own CSS rule is spliced in here when the class is an expression (MishkaCmsCore.Runtime.PageBuilder.Tree.ensure_scope/2) — flattening the list to a string to add it would write the literal text @variant into the page. So every reader of a scope and every writer of one comes through this and list_expr/1: a caller that instead rebuilt the attribute from the classes it could see destroyed the author's own logic, and one that could not see them minted a second name for an element that already had one.

merge_class(authored, styles, opts \\ [])

@spec merge_class(String.t() | nil, map(), keyword()) :: String.t() | nil

The class attribute a node should serialize with: the author's classes followed by the compiled overrides. Returns nil when there is nothing to write, so an override-free node with no class keeps having no class attribute.

Every authored class is kept unless an override in styles replaces it — one that would read as an override but has none is the author's, and stays.

options(prop)

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

The fixed set of values a property accepts, or [] when it takes a free-form value.

This is what tells the inspector to render a select instead of a text field.

pictures(prop)

@spec pictures(String.t()) :: [
  {String.t(), String.t(), String.t(), String.t(), pos_integer()}
]

Each value of an enumerated property with the words and the picture that explain it.

{value, title, tailwind, css, bars} — tailwind for the admin panel, css for the toolbar inside the canvas iframe, which is served the author's stylesheet and has no admin classes. Returns [] for a free-form property.

properties()

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

The CSS-ish property names the panel can express as a utility.

split_list_expr(source, opts \\ [])

@spec split_list_expr(String.t(), keyword()) :: {[part()], map()} | :not_a_list

A class={[…]} expression split into the part that is ours and the part that is the author's.

Returns {parts, styles} where parts is list_parts/1's reading of the list with the recognised utilities taken out of its classes, or :not_a_list for an expression this cannot safely take apart — class={@variant}, a function call, anything that is not a literal list.

Why a list is worth taking apart and @variant is not

A list is the shape the HEEx guidance asks for and the shape every kit component uses, so it is where an override actually lands in practice. Its STRING elements are plain classes and can be read exactly as a string class is; everything else — @flag && "x", a call, a variable — is the author's logic and passes through untouched, in place.

Without this the round trip was one-way: Serializer.computed_class/4 appended the override and the next parse could not see it, so a second edit nested another list and kept the first value — [[["p-2", …], "w-[1000px]"], "w-[900px]"] — two classes of equal specificity where the stale one can win.

to_classes(styles, opts \\ [])

@spec to_classes(map(), keyword()) :: [String.t()]

Compiles an override map into a list of Tailwind classes, in bucket order.

Returns [] for an empty map, so a node with no overrides gains no classes and its class attribute is left exactly as the author wrote it.

value_title(prop, value)

@spec value_title(String.t(), String.t()) :: String.t()

The plain-words name of one enumerated value, or the value itself when nothing describes it.

with_important_variants(classes, prop)

@spec with_important_variants([String.t()], String.t()) :: [String.t()]

classes with each one that sets prop under a variant made important: hover:text-white becomes hover:text-white! when prop is "color".

A component's override is written important, so it beats the kit's own classes, and it beats the author's variants beside it just the same: a link given a colour stopped changing on hover. With both important, Tailwind's order decides again, and it puts the variant last.