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
Functions
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.
The width and the state a bucket stands for — the inverse of bucket/2.
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.
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.
@spec buckets() :: [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.
"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.
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.
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.
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.
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.
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.
{value, title, tailwind, css}. Returns [] for anything pictured or free-form, so a panel can
ask both and draw whichever answers.
@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.
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.
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.
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.
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.
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.
@spec properties() :: [String.t()]
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.
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.
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.
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".
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.