CSS¶
Every selector, property, and value form Lumen's CSS subset accepts. For the task-level introduction see Styling; for the markup surface see Tags and attributes.
Lumen implements a subset of CSS Selectors 4, Cascade 5, and Media Queries 5, plus a few properties of its own for parts that standard CSS cannot address. Anything outside the subset is reported as a warning and the declaration is dropped; the rest of the stylesheet still applies.
Grammar¶
stylesheet := at_import* (at_media | rule)*
at_import := "@import" '"' path '"' ";"
at_media := "@media" media_query "{" rule* "}"
rule := selector_list "{" declaration* "}"
selector_list := selector ("," selector)*
selector := compound (combinator compound)*
combinator := " " | ">" | "+" | "~"
compound := tag? ("." class | "#" id | ":" pseudo_class)*
declaration := ident ":" value "!important"? ";"
/* ... */ comments are stripped anywhere. !important is matched
case-insensitively at the end of a value.
@import "other.css"; splices another stylesheet in place. Paths are
relative to the importing file. Imports must come before any rule; one
written later is an error, as is a cycle. They nest. Imported rules are
placed before the importing file's own rules, so at equal specificity the
importing file wins.
@media blocks nest up to 32 levels, as do :is(), :where(), and
:not() arguments.
@import, @media, and @font-face are the at-rules Lumen implements.
@keyframes and @font-face are kept as authored and handed to the web
target, which writes them back out; the desktop ignores @keyframes. A
url() inside a kept block names a file relative to the app directory, the
same place an <image src> names one from, and a web build copies that file
into the site and points the block at the copy.
On the desktop a @font-face reads font-family, src, font-weight, and
font-style. src is tried in order and the first TrueType or OpenType file
that loads is registered under the rule's font-family. local() entries and
woff / woff2 files, named by extension or format(), are skipped, and a
font-weight range keeps the weight the file declares. Other descriptors are
ignored. Any other at-rule is skipped with a warning along with its whole
block, and the rest of the stylesheet applies.
Pseudo-elements (::before and friends) are a parse error, not a warning.
Selectors¶
Simple selectors¶
| Form | Matches |
|---|---|
tag |
Elements with that tag name. |
.class |
Elements listing that class. |
#id |
The element with that id. |
* |
Any element. |
A compound writes the tag first: button.primary#save.
Combinators¶
| Combinator | Meaning |
|---|---|
A B |
B with an A ancestor. |
A > B |
B whose immediate parent is A. |
A + B |
B immediately preceded by a sibling A. |
A ~ B |
B preceded anywhere by a sibling A. |
Each step takes the nearest candidate that matches it and does not
reconsider: .a .b .c binds .b to the closest matching ancestor, and
.a ~ .b ~ .c binds .b to the closest matching earlier sibling. A
selector that would only match through a farther candidate misses.
Sibling steps resolve wherever the cascade runs: when the stylesheet is
applied to the document, and when an element restyles after a class change
from a script or an OS theme flip. A row a <for> adds after the app starts
is resolved on its own before it joins the tree, so a rule that needs an
ancestor or a sibling step reaches it at the next restyle of the document.
Pseudo-classes¶
State pseudo-classes select a routing slot rather than filtering elements: a rule written with one always applies, and the runtime swaps the value in when the element enters that state.
| Pseudo-class | Matches |
|---|---|
:hover |
Pointer over the element. |
:focus |
Element has keyboard focus. |
:focus-visible |
Focus arrived from the keyboard. |
:active |
Element is pressed. |
:disabled |
Element is disabled. |
:checked |
<toggle>, <switch>, or <checkbox> is on. |
:selected |
Tab strip button of the active tab. |
:drag-over |
An acceptable in-app drag hovers this drop target. |
Structural pseudo-classes filter on where the element sits in the tree:
| Pseudo-class | Matches |
|---|---|
:root |
The document root, or any element whose tag is root. |
:first-child |
First element child of its parent. |
:last-child |
Last element child. |
:only-child |
The sole child. |
:empty |
No element children and no text. |
:nth-child(an+b) |
1-based position. odd, even, n, -n, and plain integers are accepted. |
:is(list) |
Any selector in the list. |
:where(list) |
Any selector in the list, contributing zero specificity. |
:not(list) |
No selector in the list. |
Sibling position is read from the live tree, so a restyle that runs
against one element on its own still selects the same elements a
whole-document pass would: after a theme flip, a class change from a
script, or a <for> rebuild, .row:last-child is still only the last
row.
:is(), :where(), and :not() take a comma-separated list of
selectors, each of which may use combinators. The element being tested is
the subject of every argument, so .row:not(.list > .row) matches a
.row that is not a child of a .list.
Specificity¶
Specificity is the triple (a, b, c) compared lexicographically:
acounts#idselectors.bcounts classes and pseudo-classes.ccounts tag names.
:is() and :not() take the specificity of their most specific
argument. :where() contributes nothing.
Cascade¶
Declarations are ordered by, in decreasing priority:
- Origin. The built-in skin sheets are user-agent origin; your CSS is author origin and wins, whatever the specificity.
- Importance, per declaration.
!importantapplies to the one declaration it is written on, not to its siblings in the same rule. - Specificity.
- Source order, then position within a selector list. The last matching declaration wins.
Markup attributes beat CSS, for every property both surfaces can set.
<tile width="50px"/> keeps its width against .t { width: 100px }.
What a tag is by default is a user-agent default, not markup, so any
stylesheet rule overrides it: the direction of <row>,
<column>, <scroll> and the other layout tags, the position: absolute
and full inset of <overlay> and <dialog>, the centring of a
<dialog>'s content, a <spacer>'s grow, and the vertical axis of a
<scroll>. scroll="x" on an element is markup: it lays the children out
in a row and outranks the tag's direction.
An element resolves from scratch each time it restyles: after a class or id
change from a script, a set_style, a theme flip or a breakpoint crossing.
A property that no rule, attribute or inline style sets any more goes back
to its initial value, so removing a class takes with it everything only that
class set. What the runtime itself holds while it runs stays: a hidden
<if>'s display: none, an open popup's placement, a virtualized row's
slot, a hover or press tint, a :disabled patch, and a transition in
flight, which heads for the new value from wherever it is.
One exception keeps skins from repainting your surfaces: when you set a
resting bg (in CSS or as an attribute, a colour, gradient, or image), a
skin's :hover and :active background for that element is dropped. :checked, :selected,
:disabled, and :drag-over backgrounds from the skin still stand until
you name that state yourself.
Inheritance¶
These properties inherit from parent to child: text-color (color),
selection-color, selection-text-color, caret-color, font-size,
font-family, font-weight, line-height, text-align, wrap,
max-lines, image-rendering, and cursor. Custom properties inherit
through their own scope. Every other property applies only where it matches.
An inherited value acts as a zero-specificity base, so any matching rule or markup attribute on the child overrides it.
Error recovery¶
A declaration with an unknown property, an unparseable value, or an
unresolvable var() is dropped and reported as a warning naming the
selector and the property. The rest of the stylesheet applies. Duplicate
warnings are reported once.
A declaration under a state pseudo-class that is not one of the state-routable properties below is accepted and has no effect.
Custom properties¶
Any declaration whose name starts with -- is a custom property. It is
stored, not applied, and inherits to descendants.
:root {
--accent: #33c7ce;
--panel: #0c2c4a;
}
.card {
bg: var(--panel);
border: 1px solid var(--accent);
}
var(--name) substitutes the value; var(--name, fallback) uses the
fallback when the name is unset. References resolve recursively up to 16
levels; a cycle or an unknown name with no fallback drops the
declaration with a warning.
Custom properties cascade like other declarations, including
!important, and a rule matched on an ancestor puts its values in scope
for descendants. That makes :root.theme-dark { --panel: ... } a working
theme switch.
The runtime reads two custom properties directly: --lumen-tooltip-delay
(milliseconds) and --lumen-tooltip-offset (pixels), because <tooltip>
collapses at parse time and no selector can reach it. The built-in skins
define the --lumen-* token set they theme themselves with; redeclaring
those names in your own :root block retints the skin.
@media¶
Features are joined with and. Comma-separated query lists, or, and
not are not supported.
| Feature | Values | Matches when |
|---|---|---|
prefers-color-scheme |
dark, light, no-preference |
The OS color scheme equals the value. With no known scheme, only no-preference matches. |
prefers-reduced-motion |
reduce, no-preference |
Always no-preference; see below. |
prefers-contrast |
more, less, custom, no-preference |
Always no-preference; see below. |
min-width |
length in px | The viewport is at least that wide. |
max-width |
length in px | The viewport is at most that wide. |
width |
length in px | The viewport is that wide. |
Lumen reads the OS color scheme and the window size. It does not read the
motion or contrast preference from the OS yet, so prefers-reduced-motion
and prefers-contrast only ever match no-preference. Give people an
in-app switch for anything heavily animated or low-contrast.
Value forms¶
| Form | Accepted |
|---|---|
| length | auto, <n>, <n>px, <n>%. A bare number is pixels. |
| number | <n>, <n>px. The px suffix is accepted and stripped. |
| color | #rrggbb, #rrggbbaa. |
| edges | 1 to 4 terms in top-right-bottom-left rotation; each <n>, <n>px, or <n>%. |
| border width | thin (1px), medium (3px), thick (5px), or a px length. Percentages are rejected. |
| duration | <n>ms or <n>s. |
| easing | linear, ease, ease-in, ease-out, ease-in-out, cubic-bezier(a, b, c, d). |
| track size | <n>, <n>px, <n>fr, auto, min-content, max-content, minmax(<min>, <max>). |
Gradients¶
The bg property accepts three gradient functions. Each stop is a colour
with an optional position, given as a percentage or a 0..1 number. Stops
without a position are distributed evenly, and all stops are sorted by
position.
.a { bg: linear-gradient(90deg, #101014, #202028 60%, #303040); }
.b { bg: radial-gradient(#ffffff, #000000); }
.c { bg: conic-gradient(from 45deg, #ff0000, #00ff00, #0000ff); }
linear-gradient([<angle>deg,] <stop>, <stop>, ...). The angle defaults to180deg, top to bottom. At least two stops.radial-gradient(<stop>, <stop>, ...). The centre is fixed at the middle of the box and the radius covers it. At least two stops.conic-gradient([from <angle>[deg],] <stop>, <stop>, ...). The start angle defaults to0. At least two stops.
Background images¶
bg also takes an image, named with url(). The path may be
double-quoted, single-quoted, or bare, and it resolves against the app
directory the same way an <image src> does, and loads and caches the
same way. Under lumenc run, saving the image file shows the new picture
without a restart, as it does for an <image>; see
hot reload.
:root { --hero: url("art/day.png"); }
:root.night { --hero: url("art/night.png"); }
.banner {
bg: var(--hero);
bg-fit: contain;
radius: 12px;
}
The image paints as the element's background: behind its content and
children, under its border, and clipped to its corner radius. It fills the
border box, and bg-fit decides how, with the values fit takes on an
<image>: cover (the default), contain, fill, none, and
scale-down. none keeps the image's own size at the top-left corner;
the others centre it. The image never sizes its element: give the element
a size, or let its content size it.
An image replaces the colour: bg holds one value, so set the colour on a
parent if you want one showing through transparent pixels. An <image>
element ignores a bg image; its src is its one source.
Masks¶
mask-image paints an element, its border and everything inside it through
the alpha of an image: where the image is opaque the element shows, where
it is transparent nothing of it does. Paired with bg, it colors an icon
with any color, gradient, or theme token, so one icon file serves every
theme:
.tool-icon {
width: 20px;
height: 20px;
bg: var(--text);
mask-image: url("icons/pencil.svg");
mask-size: contain;
mask-repeat: no-repeat;
mask-position: center;
}
.tool.active .tool-icon { bg: var(--on-accent); }
The image is a raster image or an SVG, named with url() and resolved and
loaded the way a bg image is. It is laid out in the element's border box
and nothing of the element shows outside that box. Until the image has
loaded, or when it fails to load, nothing of the element shows, as on the
web.
mask-size:contain,cover, or a width and an optional height, each a length, a percentage of the box, orauto(oneautokeeps the image's proportions; the defaultautois the image's own size).mask-repeat:repeat(the default),no-repeat,repeat-x,repeat-y, or one ofrepeat/no-repeatper axis.mask-position: one or two ofleft,center,right,top,bottom, a length, or a percentage; the default is the top-left corner.
The masks are a subset of the web's: one image layer, used through its
alpha. Lumen has no mask shorthand, gradients as mask images, several
layers, mask-mode, mask-origin, mask-clip, mask-composite, the
space and round repeats, edge offsets such as right 4px top 2px in
mask-position, or the -webkit-mask-* spellings. A web build writes the
same four properties, which every current browser reads.
Shadows¶
inset draws the shadow inside the box. Multiple shadows are comma
separated and paint in order. shadow is an accepted synonym.
Property catalogue¶
The Lumen short names and the standard CSS names below are
interchangeable: color for text-color, background and
background-color for bg, border-radius for radius, flex-grow
for grow, flex-shrink for shrink, justify-content for justify,
object-fit for fit, and white-space for wrap.
Layout¶
| Property | Values | Default |
|---|---|---|
display |
flex, grid, none |
flex |
width, height |
length | content size |
min-width, min-height, max-width, max-height |
length | auto (see below) |
aspect-ratio |
number | none |
padding, margin |
edges | 0 |
inset |
edges; any side may be auto |
auto |
position |
relative, absolute |
relative |
box-sizing |
border-box, content-box |
border-box |
flex-direction |
row, column, row-reverse, column-reverse |
from the tag |
flex-wrap |
nowrap, wrap, wrap-reverse |
nowrap |
flex |
none, auto, initial, or <grow> [<shrink>] [<basis>] |
|
grow, flex-grow |
number | 0 |
shrink, flex-shrink |
number | 1, or 0 on a scroll container's direct child |
flex-basis |
length | auto |
gap |
<both> or <row> <column>; each may be a percentage |
0 |
row-gap, column-gap |
number or percentage | 0 |
align, align-items |
start/flex-start, end/flex-end, center, stretch, baseline |
stretch |
align-self |
same as align-items |
from the container |
align-content |
start/flex-start, end/flex-end, center, stretch/normal, space-between, space-around, space-evenly |
stretch |
justify, justify-content |
start, end, center, between/space-between, around/space-around, evenly/space-evenly |
start |
justify-items |
same as align-items |
grid only |
justify-self |
same as align-items |
from justify-items |
z-index |
integer or auto |
document order (auto is 0) |
overflow, overflow-x, overflow-y |
visible, hidden, scroll |
visible |
layout-boundary |
true, yes |
automatic |
overflow: scroll makes an element a live scroll container. overflow:
hidden cuts its descendants off at the element's box without making it
scrollable; inside a scroll container that clip travels with the element as
the content scrolls.
An element's own text lays out as an item of its own, sized to the words,
so justify-content places it along the main axis the same way it places
a child: justify-content: center on a button centres its label. It
applies on a horizontal main axis only, and an authored text-align wins
where both are set.
Minimum sizes and shrinking¶
Items on a flex line that does not fit shrink, in proportion to their
size, until they reach their minimum size. Left at the default auto,
that minimum is the smaller of the element's own content and its
authored width / height: a box with nothing in it shrinks to
nothing, a box holding a 250px child stops at 250px, and a box whose
child is wider than its authored width still stops at that width.
Two properties change it:
min-width: 0/min-height: 0drops the floor entirely, so the element shrinks past its content and the content overflows. This is what a long unbreakable label usually wants.shrink: 0takes the element off the shrinking list, so it keeps its authored size and the line overflows instead.
overflow: hidden and overflow: scroll also drop the automatic
floor to zero, since the element clips its own content. So does a
<scroll> (or any element with a scroll attribute), on both axes: a
grow: 1 scroller in a fixed-height column takes the space it is given
and scrolls the rest. An authored min-width /
min-height still sets the floor.
Positioning¶
Every element is a containing block. position: absolute takes an
element out of the flow and resolves its inset against its parent's
padding box, whichever element that parent happens to be; there is no
position: static and no search up the tree for a positioned ancestor.
So the wrapper you want an element pinned inside is the element you
write it under, and no position: relative is needed on that wrapper.
A side you leave unset is auto. With no inset at all the element
keeps its content size where it would have sat in the flow; with two
sides set, such as inset: 4 8 auto auto or inset-block-start plus
inset-inline-end, it keeps its size against that corner. Set opposite
sides, as inset: 0 does, to stretch it between them. An element with
no parent at all, such as one a Rust plugin spawns as a root of its own,
resolves its inset against the window.
An out-of-flow child contributes nothing to its parent's content size.
A parent that is content-sized on an axis and has only absolutely
positioned children therefore measures zero on that axis, and a child
with inset: 0 fills that zero. Nothing appears:
Size the parent to fix it: give it a height (or width on the
horizontal axis), grow: 1 so it takes the free space on its own line,
or an inset of its own if it is absolutely positioned too. One in-flow
child works as well, since the parent then measures that child. With
overflow: hidden on the parent, as above, the collapsed subtree is
clipped away entirely rather than spilling into view, so the symptom is
a blank window.
lumenc lint reports this shape as collapsed_containing_block and
names the child it found.
Logical properties¶
Each takes a px number (the inset-* ones also take auto) and
resolves against the element's writing direction, which comes from dir
in the markup or, with none authored, from the app's locale.
padding-inline-start,padding-inline-end,padding-block-start,padding-block-endmargin-inline-start,margin-inline-end,margin-block-start,margin-block-endinset-inline-start,inset-inline-end,inset-block-start,inset-block-endborder-inline-start-width,border-inline-end-width,border-block-start-width,border-block-end-width
Grid¶
| Property | Values | Default |
|---|---|---|
grid-template-rows, grid-template-columns |
whitespace-separated track sizes | none |
grid-row, grid-column |
<start> or <start> / <end> |
auto placement |
Track lists take plain track sizes. repeat(), named lines, and
span are not supported. Line numbers outside the range of a 16-bit
integer are clamped with a warning.
Visuals¶
| Property | Values | Default |
|---|---|---|
bg |
color, gradient, or url() image |
transparent |
bg-fit |
cover, contain, fill, none, scale-down |
cover |
radius |
1 to 4 numbers, top-left / top-right / bottom-right / bottom-left | 0 |
border-top-left-radius, border-top-right-radius, border-bottom-right-radius, border-bottom-left-radius |
number | from radius |
opacity |
number, clamped to 0..1 | 1 |
shadow, box-shadow |
shadow list | none |
fit, object-fit |
fill, cover, contain, none, scale-down |
fill; <image> only |
image-rendering |
auto, smooth, pixelated, crisp-edges |
auto |
mask-image |
none or one url() image; see masks |
none |
mask-size |
contain, cover, or 1 to 2 lengths, percentages, or auto |
auto |
mask-repeat |
repeat, no-repeat, repeat-x, repeat-y, or 2 of repeat / no-repeat |
repeat |
mask-position |
1 to 2 keywords, lengths, or percentages | 0% 0% |
disabled-opacity |
number, clamped to 0..1 | 0.5 |
knob-color |
color | built-in knob fill |
knob-inset |
number | 4 |
thumb-size |
number | 16 |
popup-gap |
number | 4 |
draggable |
true, yes |
false |
opacity fades an element together with everything inside it, as one
picture, the way CSS does: an opaque child still hides its parent's background,
and the two fade once. Nested opacities compound. An element with no visible
children fades its background, border, and text one by one, so where its own
border or text overlaps its background, the background shows through faintly.
The renderer keeps the picture of a faded element with children from one frame
to the next while nothing inside it changes, so animating its opacity does
not repaint its contents.
image-rendering picks how a raster image drawn at another size than its
own is sampled: an <image>, a bg: url(...) image, and the images and
pixel buffers drawn on a <canvas>. auto and smooth blend neighbouring pixels; pixelated and
crisp-edges take the nearest pixel, so scaled-up pixel art keeps hard
edges. It inherits, so setting it on a container reaches every image inside.
A canvas's paths and text are drawn at the element's size rather than scaled
from a bitmap, so the property changes only the images and pixel buffers
drawn on it.
disabled-opacity sets how much a disabled element dims when neither
:disabled { bg } nor :disabled { opacity } was authored.
knob-color, knob-inset, thumb-size, and popup-gap reach widget
parts that have no selector of their own.
Borders and outlines¶
| Property | Values | Default |
|---|---|---|
border |
<width> \|\| <style> \|\| <color> in any order, or none |
none |
border-width |
1 to 4 border widths | medium when a style is set |
border-style |
none, hidden, solid |
none |
border-color |
color | the element's text color, else black |
border-top-color, border-right-color, border-bottom-color, border-left-color |
color | from border-color |
border-top, border-right, border-bottom, border-left |
per-side shorthand | none |
border-top-width, border-right-width, border-bottom-width, border-left-width |
border width | 0 |
focus-outline |
<width> <#color> |
none |
outline-offset |
number | 0 |
hover-border, focus-border |
border shorthand | none |
solid and none are the supported border styles; dashed, dotted,
and the rest are rejected with a warning. A shorthand that omits the
style but names a width or a colour resolves to solid. Without a style
there is no border: the computed width is zero and nothing paints.
Borders participate in layout under box-sizing.
Text¶
| Property | Values | Default | Inherited |
|---|---|---|---|
text-color |
color | the built-in text color | yes |
font-size |
number | 16 |
yes |
font-family |
family list | platform sans-serif | yes |
font-weight |
normal (400), bold (700), 1..1000 |
400 |
yes |
line-height |
number (multiplier) or <n>px |
runtime ratio | yes |
text-align |
start/left, center, end/right |
start |
yes |
wrap |
none/nowrap, word/normal, glyph/char |
none |
yes |
max-lines |
non-negative integer | unlimited | yes |
text-overflow |
clip, ellipsis |
clip |
no |
The shaper resolves the first available family in a font-family list,
honouring the generic keywords, and falls back to the platform
sans-serif. lighter and bolder are rejected: they need the parent's
computed weight.
Any font-weight from 1 to 1000 is accepted. A variable font renders the
weight you author wherever its weight axis covers the value; a family
built from separate faces renders the nearest weight it ships, chosen the
way CSS chooses it (from 400-500 look up to 500 first, then down; below
400 look down first; above 500 look up first). So font-weight: 650
against a family with a regular and a bold face draws the bold face, and
measures as that face.
text-overflow: ellipsis elides overflowing single-line text unless you
also author wrap and max-lines, in which case your multi-line clamp
is ellipsized instead.
Text input¶
These apply to <input> and <textarea>.
| Property | Values | Default |
|---|---|---|
selection-color |
color | text fill at reduced alpha |
selection-text-color |
color | selected glyphs keep their fill |
caret-color |
color | the text fill |
caret-width |
number | 2 |
caret-blink |
duration | 530ms |
password-character |
exactly one character | a bullet |
The caret blink phase is shared by the whole app, so caret-blink sets
one period for every input; it is applied when styles are reapplied, not
at startup.
Interaction states¶
State-routable properties, and where each state's value lands:
| Property | :hover / :focus / :focus-visible |
:active |
:checked |
:selected |
:disabled |
:drag-over |
|---|---|---|---|---|---|---|
bg |
yes, one shared slot | yes | yes | yes | yes | yes |
text-color |
separate slot per state | yes | no | no | yes | yes |
opacity |
separate slot per state | yes | no | no | yes | yes |
shadow, box-shadow |
separate slot per state | yes | no | no | yes | yes |
border |
yes; :focus wins over :hover |
no | no | no | no | no |
outline |
:focus and :focus-visible only |
no | no | no | no | no |
outline-offset |
:focus and :focus-visible only |
no | no | no | no | no |
bg under a state takes a colour; a gradient or an image there is
dropped with a warning. bg under :hover, :focus, and
:focus-visible shares one slot, so the three cannot differ. outline under :focus-visible gets its own
slot and wins over :focus while focus came from the keyboard.
On a <toggle> or <switch> track, :disabled { bg } wins over
:checked { bg }, so a disabled control that happens to be on shows the
disabled fill rather than the checked one.
The same swaps are reachable as plain properties, which is often shorter:
| Property | Equivalent |
|---|---|
hover-bg |
:hover { bg } |
press-bg |
:active { bg } |
hover-border |
:hover { border } |
focus-border |
:focus { border } |
focus-outline |
:focus { outline } |
Progress¶
| Property | Values | Default |
|---|---|---|
progress-duration |
integer milliseconds, greater than zero | 1200 |
progress-chunk |
number 0..1 | 0.3 |
progress-duration is the indeterminate sweep period and
progress-chunk the fraction of the track the moving chunk covers.
Transitions¶
| Property | Values | Default |
|---|---|---|
transition |
<property> <duration> [<easing>], comma separated |
none |
transition-property |
comma-separated property names, all, or none |
none |
transition-duration |
comma-separated durations | 0 |
transition-timing-function |
comma-separated easings | ease |
Animatable properties: opacity, background-color (background,
bg), color (text-color), and border-color. all stands for all
four. Any other name in a transition list is ignored with a warning;
layout properties are deliberately excluded so a transition never re-runs
layout every frame.
An entry that names no easing gets ease. A second duration in an entry
is a delay, which Lumen warns about and ignores: a transition always
starts on the tick the value changes. transition-delay is warned about
and ignored for the same reason.
The transition shorthand resets the longhands. Otherwise
transition-property defines the list and the duration and easing lists
cycle over it; a duration list without a property list produces nothing.
Animations¶
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.spinner {
animation: spin 2s linear infinite;
}
| Property | Values | Default |
|---|---|---|
animation |
the CSS shorthand, comma separated | none |
animation-name |
comma-separated @keyframes names |
none |
animation-duration |
comma-separated durations | none |
animation-timing-function |
comma-separated easings | none |
animation-delay |
comma-separated durations | none |
animation-iteration-count |
comma-separated counts or infinite |
none |
animation-direction |
comma-separated directions | none |
animation-fill-mode |
comma-separated fill modes | none |
animation-play-state |
running or paused |
none |
These reach the web target and do nothing on the desktop, which has no
keyframe engine; transition is what animates a desktop app. Values are
written out as authored, so the browser reads the standard CSS meaning of
each one.
A @keyframes block is browser CSS, not Lumen's dialect: its body is
carried through untouched, so a keyframe declares background,
transform and opacity rather than bg. A block written inside
@media is emitted inside the same query.
Scrollbars¶
| Property | Values | Default |
|---|---|---|
scrollbar-color |
auto, or <thumb> [<track>] colors |
auto |
scrollbar-width |
auto, thin, none |
auto |
scrollbar-thickness |
number | 8 |
scrollbar-thickness-thin |
number | 4 |
scrollbar-margin |
number | 2 |
scrollbar-min-thumb |
number | 24 |
scrollbar-track-hover |
color | no track tint |
scrollbar-hover-boost |
number | 1.6 |
scrollbar-fade-delay |
duration | 1s |
scrollbar-fade-duration |
duration | 250ms |
scrollbar-color and scrollbar-width are the standard CSS properties;
the rest are Lumen's own geometry and timing controls for the overlay
scrollbar.
Scrolling and focus¶
| Property | Values | Default |
|---|---|---|
scroll |
x, y, both |
none |
sensitivity |
number | 1.0 |
inertia |
number | 0.4 |
tab-index |
integer | from the tag |
Pointer cursor¶
| Property | Values | Default |
|---|---|---|
cursor |
a keyword, or url(<image>) [<x> <y>] entries each followed by a comma, then a keyword |
auto |
The keywords are the standard set: auto, default, none,
context-menu, help, pointer, progress, wait, cell, crosshair,
text, vertical-text, alias, copy, move, no-drop, not-allowed,
grab, grabbing, e-resize, n-resize, ne-resize, nw-resize,
s-resize, se-resize, sw-resize, w-resize, ew-resize, ns-resize,
nesw-resize, nwse-resize, col-resize, row-resize, all-scroll,
zoom-in, and zoom-out. auto shows the platform arrow and none hides
the pointer.
A url() names a raster image relative to the app directory, the way a
bg: url(...) does; the two numbers are its hotspot in image pixels from the
top-left corner, which is the default. The first image that loads is shown at
its own pixel size, and the keyword stands in while it loads or when none
does. The keyword is required, as on the web.
The pointer shows the cursor of the element under it. The value inherits, so a
container's cursor reaches everything inside it until a descendant declares
its own; an element that declares one receives hover even with no background.
The user-agent sheet gives <input> and <textarea> cursor: text, <a>
cursor: pointer, and a <split>'s gutters col-resize in a row and
row-resize in a column; every other element, buttons included, shows the
arrow until a stylesheet says otherwise. The thumb of a scrollbar or a <slider>
shows grab under the pointer and grabbing while dragged; no selector
reaches those parts.
Web target mapping¶
Built for the web, an app ships its stylesheet as real CSS and the browser runs the cascade. Most of what you write arrives unchanged; this section is the rest.
Selectors¶
A tag selector becomes the class the element carries in the document,
inside :where(): button is emitted as :where(.lm-button). The
wrapper keeps the tag weighing nothing, so it still loses to a class the
way it does everywhere else.
The four states no browser selector covers are mirrored onto attributes:
| Lumen | Emitted as |
|---|---|
:selected |
[data-lm-selected] |
:checked |
:is(:checked, [data-lm-checked]) |
:disabled |
:is(:disabled, [data-lm-disabled]) |
:drag-over |
[data-lm-drag-over] |
:checked and :disabled keep the browser's own state beside the
mirror, because a <checkbox> is a real checkbox there and a <toggle>
is a button. Every other selector, :hover and the structural
pseudo-classes included, is emitted as written.
Rules are written out in the order Lumen's cascade puts them in, so where the browser cannot tell two rules apart it picks the one Lumen picks.
Property names¶
The Lumen short names are emitted under their standard spelling:
| Lumen | Emitted as |
|---|---|
bg |
background |
text-color |
color |
radius |
border-radius |
grow |
flex-grow |
justify |
justify-content |
fit |
object-fit |
shadow |
box-shadow |
align |
align-items |
wrap |
white-space and overflow-wrap |
scroll |
overflow-x, overflow-y |
max-lines |
the -webkit-line-clamp block |
justify: between, around, and evenly are emitted as
space-between, space-around, and space-evenly.
States written as properties¶
A property that names a state becomes a rule of its own, written beside the rule it came from:
| Property | Emitted as |
|---|---|
hover-bg |
:hover { background } |
press-bg |
:active { background } |
hover-border |
:hover { border } |
focus-border |
:focus { border } |
focus-outline |
:focus { outline } |
selection-color |
::selection { background } |
selection-text-color |
::selection { color } |
Lengths¶
A bare number is pixels in Lumen and nothing at all in CSS, so
padding: 8 16 is emitted as padding: 8px 16px.
A length that reaches a property through var() is var(--gap) whatever
the token holds, so the unit goes on the definition instead: a custom
property the stylesheet uses only in places where a bare number means
pixels is emitted with px on it, and --gap: 8 becomes --gap: 8px.
Writing the unit yourself is still clearer, and a token that already has
one is left alone.
A token used both where a number means pixels and where it means a plain
number cannot be both, so it is emitted as authored and lumenc web
names it in a warning. Split it into two tokens, or write the unit on the
one that needs it.
Gradient stop positions travel as written; write them as percentages.
Background image placement¶
A bg image is copied into the site's assets/ like an <image src>,
and the url() is rewritten to point at the copy, whether it is written
on bg, in a bg attribute, or in a custom property a theme swaps. A
bg that is an image, or reads a token holding one, also gains
background-size, background-position, background-repeat: no-repeat,
and background-origin: border-box, so it sits in the box the way the
desktop draws it. bg-fit is emitted as the --lm-bg-size and
--lm-bg-position custom properties those read, registered so they do
not inherit. Browsers have no scale-down for a background, so it is
emitted as contain, which also enlarges an image smaller than the box.
Properties with no browser equivalent¶
The knobs that reach widget parts no selector can address are emitted as
custom properties, --lm- plus the property name: knob-color becomes
--lm-knob-color. The browser ignores them and Lumen reads them back off
the element, so they keep working, and you can read one yourself with
var(--lm-knob-color).
This covers knob-color, knob-inset, thumb-size, popup-gap,
caret-width, caret-blink, password-character, disabled-opacity,
progress-duration, progress-chunk, sensitivity, inertia, and the
scrollbar-* geometry and timing properties. The ones measured in pixels
carry px like any other length. scrollbar-color and scrollbar-width
are standard CSS and are emitted as themselves; a scrollbar-color
naming only a thumb gains transparent for the track, which CSS
requires.
knob-color, knob-inset and thumb-size are also what the emitted
stylesheet draws a control's parts from. A <toggle>, a <switch> and a
<slider> are one element in a page and a track plus a knob on the
desktop, so the knob is drawn as a pseudo-element from those three
values, and a <checkbox>, a <radio> and a <progress> are drawn from
the state the runtime marks them with. Anything an app's own CSS says
about the control still wins.
What is not emitted¶
| Property | Why |
|---|---|
tab-index |
The document already carries it as tabindex. |
draggable |
The document already carries it as draggable. |
transition-delay |
Lumen ignores it: a transition starts on the tick the value changes. |
layout-boundary |
A hint to Lumen's layout engine; the browser lays the page out itself. |
Selector matching differs in one way worth knowing. Lumen's matcher takes the nearest candidate for each step and does not reconsider, and a browser's does; a selector that relies on that difference matches more elements on the web than it does on the desktop.