Expand description
The webview renderer for makeover_layout.
§The renderer that needs no palette
makeover-immediate and makeover-tui both take a Palette, because egui
and a terminal need an actual colour before they can put anything on
screen. A webview does not: var(--surface-raised) is the late binding,
and the browser resolves it against whatever themes.js last wrote onto
:root.
So this crate emits text naming intents, and never learns a colour. It is the deferral rule with no adapter in the way, and it is why the webview was always the wrong renderer to derive a vocabulary from: it can express anything, so it never pushes back.
§Phase A: the stylesheet
This module emits component CSS and no markup, deliberately. GoingsOn has
145 innerHTML sites and Balanced Breakfast 175 createElement sites, so
moving markup is a migration where adopting a generated stylesheet is not.
The apps keep every line of their markup and gain the classes.
It is not a deletion either, which this header claimed until the measurement came in. Adoption across goingson removed 49 declarations net and added 25 lines: a rule loses its depth declarations and gains a variant selector next to it, so the file stays the same size. What phase A moves is where depth is defined, not how much CSS exists. Numbers and method in the wiki note under “The deletion test, run”.
The bevel properties are byte-identical to what both apps already hand-write, which is asserted below.
Some of what phase A emits is not a look but the withdrawal of one. A
renderer that picks its element from the description inherits that element’s
user-agent chrome, and reset is where a primitive says which parts of it
were never asked for.
§Phase B: the markup, one description at a time
form renders makeover_layout::Field, which is the half of phase B
whose description is settled. It emits strings, because both apps
interpolate their fields into larger string-built forms and returning nodes
would rewrite those too. It owns its own escaping, on the reasoning in that
module: a Rust encoder can cover element text and attribute values with one
function, where the apps need four and have to choose correctly at every
call site.
facet renders makeover_layout::Facet: a dimension a set is narrowed by,
and the one phase-B emitter whose markup an app is not keeping, because the
markup it replaces was two mechanisms rather than one. A tag’s selection and
a tag’s browse position were separate state on MNW’s discover page, which is
why every filter row there carries a tick box and a chevron; one gesture
doing both is what lets the second one go.
list is the other half: column tracks, the narrowing rules, and the cell
containers a row is made of. It stops at the cell boundary and does not
render what goes inside one, on the reasoning in that module. So phase B is
now the frame around content in both directions, and what an app still owns
is the content itself.
§What phase A settled, and what it costs
Measured against goingson’s styles.css rather than against a component
list: .btn, .card and .tag, .badge each hand-write the same
composition, so three quarters of phase A is one rule with several names.
Two of the four decisions change how goingson looks, and adoption should not be described as a pure deletion:
- Pressed carries its fill.
interactive_rulesemitsDepth::pressedwhole. goingson presses to--surface-sunkentoday and will press to--surface-well, and hovers to--surface-overlaytoday and will hover to--hover-surface. Sincesurface-wellinverts by theme wheresurface-sunkendoes not, a dark theme presses lighter than it hovers. That falls out ofmakeover’s own derivation, which says outright thatsurface-sunkencannot serve as a well, so if it reads wrong the answer is there and not here. - Badges go flat. See [
token_rules].
The other two: the progress trough is renderer-local and the scrollbar
track was dropped (component_rules), and no class prefix ships by
default, so adoption means deleting the app’s hand-written rule in the same
commit that adds the generated one. .card, .badge and the tab classes
all already exist in goingson, and while both rules exist the cascade order
decides which wins. That is the one real risk in adopting this, and it is
why the migration lands per component rather than in one commit.
§Interaction states
interactive_rules emits four states, in emission order, and the order is
load-bearing: they are all specificity (0,2,0), so disabled beats hover by
coming last and by nothing else. Nothing here reaches for :not(:disabled),
which would raise a selector this crate wraps in its own layer.
Emitting the states here is what keeps an app from completing the primitive
from outside, by out-specifying a rule it does not own. Those overrides are
also what breaks under cascade layers: an app that declares @layer puts
its own rules in a named layer, and unlayered declarations outrank every
named layer regardless of specificity.
Hover sits inside a capability query. makeover-touch answers whether a
fingertip has hover and makeover-geometry spells the condition; this crate
asks and does not decide, so no app has to take the hover state back on
touch.
§The layer contract
stylesheet emits into the makeover cascade layer (CSS_LAYER, which
lives in makeover-geometry because that is the one crate every CSS emitter
in the family already depends on). makeover-geometry does the same for
geometry.css.
The cascade resolves origin and importance, then layer, then specificity, then source order, and unlayered normal declarations outrank every named layer. An unlayered generated file therefore beats every rule an app owns, regardless of specificity and regardless of loading last. Nothing errors when that happens: the CSS is valid, the minifier is happy, and buttons and badges look subtly wrong. The layer belongs here rather than in each app, because an app cannot fix it from its own stylesheet: the fix is to layer the file it does not own.
An app should declare the order once, or the layer’s position is decided by whichever generated file the browser happens to see first:
@layer makeover, base, components, responsive;in_css_layer is re-exported for an app that assembles its own stylesheet
from this crate’s pieces. Rules an app generates from
list::narrowing_css and list::grid_template_columns are as generated
as the ones here, so they belong in the same layer and this crate cannot put
them there on the app’s behalf.
§Suggestions
Outcome::Suggestions carries Candidate rather than Choice, and a
candidate has no unavailable: a suggestion that cannot be picked is a row
a route should not have offered. What it has instead is a detail, the line
that tells it from a row reading the same, and it is drawn in
--content-muted rather than in the disabled token. A detail orients rather
than refuses, and every other secondary line in this crate reads the same
way. The class is .form-suggestion-detail.
§An interval is one question with two ends
makeover_layout::FieldKind::Interval emits a role="group" named by the
field’s label, holding one <input type="number"> per end.
- The group carries the error and the descriptions, on the split
makeover_layout::FieldKind::Radioalready uses here: what is wrong is the answer, and a crossed interval is not the fault of either end. - Both boxes take the whole extent.
min,maxandstepdescribe the axis, so they are written twice. The crossing rule is not emitted, because HTML has no attribute for it and the description does not carry it: it comes back as an error on the group, like every other refusal. - Which end is which is
aria-labeland nothing more. The description states direction structurally, by which member holds which name, and never in words. Visible Min and Max captions are a page’s own and reach the group throughform::Filling::trailing.
form::Value::Between is the second value. A separator inside one string
would make this crate the owner of a delimiter that either end could contain.
§A number’s unit is adjacent text
HTML has no unit attribute and inventing one would be markup nothing reads,
so Field::unit is a <span> after the control. It is named in
aria-describedby rather than left as decoration, because a number and what
it is measured in are one fact and reading the first without the second is
reading it wrong. What that buys is a unit a consumer can read back rather
than a suffix on a label it would have to parse.
§A curve this renderer can carry, and one it declines
A range takes its granularity from the curve (Field::curve.step()), every
other kind keeps Field::step, and Curve::Linear emits a plain range.
A constant-ratio curve emits a linear track, and that is the answer, not a
debt. HTML has no logarithmic range input, so a described screen asking
for one is asking the browser for something it does not have, the same class
of request as makeover_layout::FieldKind::Date on a host with no
calendar. The renderer answers with the nearest control the host really
offers and keeps every fact that survives the translation: the extent, the
granularity, and the value’s own units. What does not survive is resolution
at the small end. The value submitted is still a value in the field’s own
units, which is what every handler on this path reads.
The alternatives are worse in the specific way this stack exists to avoid. Shipping JS that maps thumb position to value puts app code back in the renderer. Changing what the control submits from a value to a fraction moves the mapping to whoever reads the form, and a server reading these forms with its own handlers would take a fraction where a value is expected, silently.
When this reopens: the day a described screen on the webview path asks for a
non-linear range. The answer then is mapping in quasi-router, where one
implementation serves every host, not JS here.
§A markdown field gets a preview
A makeover_layout::FieldKind::Rich field is marked
data-format="markdown", and [form::editor_rules] is what spends that
mark. The Write/Preview pair is a segmented control, so it takes the depth,
the focus ring and the chosen state from rules that already exist; the
preview pane is a well, because it stands where the control stood. Both are
gated on the attribute rather than on a class, which is what the attribute is
for. A permission taken and not spent turns every conversion into a
regression.
This crate renders no markdown. The pane arrives empty and is filled by whatever binds the editor, which is where the host’s sanitiser already is. A converter here would move that guarantee into a crate with no view of the host’s content-security posture.
§Ranges, ghost text, and an option that cannot be picked
FieldKind::Rangeemits<input type="range">, andField::stepemitsstep. The step is emitted only when the description carries one: the browser’s own default isstep="1", which is what a description means by saying nothing, and is also what turns a 0-to-1 threshold into a two-position control.- A select with nothing chosen emits a disabled, selected, valueless first
option carrying
Field::placeholder. HTML has no placeholder attribute on<select>; this is the idiom, andrequiredkeeps working through it because the option’s value is empty. Choice::unavailableemitsdisabledplus the reason. Where it goes differs by control and the difference is forced: a radio group gets a.form-option-reasonspan beside the label, and a<select>option has room for no element at all, so the reason runs into its text.Choice::detailtakes the same split for the same reason: a.form-option-detailspan in a radio group, run into the text of a<select>’s option. An option carrying both reads what it is before why it cannot be picked.
§A cell says what it holds
CellPart names the four things a cell holds,
and [table_rules] turns them into .cell-value, .cell-tokens,
.cell-actions and .cell-link. Only the first takes a colour: a token
carries its own tone, an action is a control rather than text, and a link
takes the action colour from the anchor it is.
The colour goes on .cell-value rather than on .cell. On the container it
cascades into the parts that are not text, and a control in a cell is painted
as text, which is the drift
RowPart::intent prevents for list rows.
list::Cell::part is Option<CellPart> and never Option<RowPart>: the
two answer different questions, and only one of them is about a cell.
§A table lays itself out
list::narrowing_css emits the track list and has to be called with the
columns, so it works where the columns are known at build time. A table a
description produced knows its columns at render time, and the rules would
have to travel with the markup: a <style> element per table, which needs
style-src 'unsafe-inline', or the head, which an htmx fragment swap does
not carry. [table_rules] lays a table out with display: table instead,
which aligns columns across rows knowing nothing about how many there are.
Priority hiding is one rule per drop class,
and list::column_classes is what puts those classes on a cell. A header
row emitted by a renderer’s own code has to call it too, or the header and
the body disagree about which column just dropped.
.button takes the four tones as colour, off data-tone, the way the badge
does, so a destructive button has somewhere for its tone to land. A list is
reset rather than left as a bulleted list.
RowPart::revealed_on_hover is not honoured. Hiding a row’s actions until
hover hides them from pointer users alone, who are the ones scanning a list
to learn what can be done to a row, and every escape the rule grows
(focus-within for the keyboard, a capability gate for a fingertip) is a
report that hiding was wrong for somebody.
§The depth classes are not controls
.raised is a statement about shape and carries no interactive set, so the
vocabulary has a raised surface that is merely an object. An app that wants
one does not have to take a control class and cancel the control half.
.card and .button are the same depth and controls, and they take their
states from [surface_rules], which is where a state belongs: on the thing
that claims to answer a pointer.
§Substitution, three ways
A theme with no surface-well is answered differently by each renderer,
which is why substitution belongs to a renderer and not to the description:
makeover-immediatesubstitutes the page in Rust.makeover-tuirefuses to substitute and draws an edge instead, because a terminal would quantise the two together.- here, CSS already has the mechanism:
var(--surface-well, var(--surface-page))falls back in the browser, and nothing in Rust decides anything.
Modules§
- chart
- A run of magnitudes against one axis, rendered as bars.
- facet
- A dimension a set is narrowed by, rendered as a list of values.
- figure
- A figure with a caption, and a strip of them.
- form
- Phase B, the forms half:
makeover_layout::Fieldrendered to HTML. - list
- Column layout and row structure for lists and tables.
- meter
- A proportion, rendered as a bar.
- placeholder
- What a region shows when it is not showing its content.
- reset
- What an HTML element brings uninvited, and how a primitive gives it back.
- vocabulary
- Every class this crate is responsible for, as a set rather than one name at a time.
Structs§
- Emit
- How the emitted CSS is shaped.
Constants§
- CSS_
LAYER - The cascade layer every stylesheet the make-family generates is wrapped in.
- RUN_
CLASSES - Every class
fallback_classcan return, plus the run itself. - VERSION
- This crate’s version, as the generated stylesheet reports it.
Functions§
- bevel_
properties - The custom properties both bevels resolve through.
- bevel_
shadow - The two-tone edge as a
box-shadowvalue. - bevel_
var - The CSS custom property holding a bevel’s composition.
- class
- A prefixed class name.
- component_
rules - The component layer: every named thing phase A emits.
- depth_
class - The class name for a depth.
- depth_
declarations - The fill and edge declarations for a depth, as a rule body.
- depth_
rule - One rule giving a selector a depth, or nothing when the depth declares nothing.
- depth_
rules - One rule per depth: its fill and its edge, together.
- disabled_
rule - Present, visible, and not answering.
- fallback_
class - The class a run carries for what it does when it is tight.
- fill_
var - A
var()reference to a fill intent, with the browser’s own fallback where the intent may be absent. - focus_
rule - The keyboard focus ring, placed by the depth it lands on.
- in_
css_ layer - Wrap generated CSS in
CSS_LAYER. - interactive_
rules - Every state a selector that answers a click implies: hover, pressed, focus and disabled, in that order.
- option_
class - The class an option of a selector carries, which is what the rules key off.
- push_
class - A prefixed class name, written into a buffer the caller already has.
- stylesheet
- The whole phase-A stylesheet: properties, depth rules and components, in
CSS_LAYER, under a generated-file banner.