Weave language reference
Weave is Sindri's responsive presentation language for screen UI. A stylesheet selects existing scene entities and resolves ordinary Sindri UI component data for a viewport. It does not create gameplay entities, replace Decay, or introduce a browser document model.
This reference describes the currently implemented language. Unsupported CSS features are listed explicitly so authors do not have to discover them by trial and error.
Project setup
Add one or more entry .weave files to the project's asset set:
[assets]
include = ["ui/game.weave"]
Imported stylesheets do not also need to be listed. The exporter follows the
entry file's @use graph and ships every referenced .weave source.
The scene remains the source of entity identity, hierarchy, components, initial
content, and gameplay bindings. Use weave.style.classes for reusable
presentation roles:
"weave.style": {
"classes": ["hud-card", "danger"]
}
Removing the stylesheet restores the authored presentation because Weave resolves a disposable copy of the world.
Stylesheet composition
A stylesheet may import focused presentation files with top-level @use
directives before the first rule or media query:
@use "shared/theme.weave";
@use "hud/status.weave";
.screen {
width: 100%;
}
Both single and double quoted paths are accepted. Import paths are resolved
relative to the importing stylesheet asset ID, use forward slashes on every
platform, and may contain . or .. so long as they do not escape the
stylesheet root.
Imported rules are inserted before the importing file's own rules. Specificity and source order therefore behave exactly as though the imported rules had been written first in the entry stylesheet. A source imported more than once in one composition graph is emitted once.
Multiple independent entry stylesheets remain supported. The runtime identifies
which fetched .weave files are roots, composes each root, and does not apply an
imported file a second time.
Composition errors include malformed @use directives, missing source IDs,
paths that escape the stylesheet root, and circular imports. A cycle reports the
chain, for example ui.weave -> shared.weave -> ui.weave.
Rule syntax
selector {
property: value;
}
A declaration ends with a semicolon. A rule may contain multiple declarations. Selectors are written as in CSS:
| Form | Example | Matches |
|---|---|---|
| Element | text, button, shape, slider |
An entity with that UI component |
| Entity ID | #hud-score |
The stable scene entity ID |
| Class | .hud-value |
An entry in weave.style.classes |
| Universal | * |
Any entity |
| State | :hover, :active (or :pressed), :focus, :disabled, :checked, :open |
An entity in that state |
| Compound | button.primary:hover |
All of its parts at once |
| Descendant | .menu text |
The right-hand entity anywhere inside the left |
| Child | .menu > button |
The right-hand entity directly inside the left |
| Next sibling | text + slider |
The right-hand entity straight after the left, under the same parent |
| Later sibling | .primary ~ button |
The right-hand entity anywhere after the left, under the same parent |
| Position | :first-child, :last-child, :only-child, :nth-child(2n+1), :nth-last-child(2), :root |
An entity by where it sits among its siblings, in scene order |
| Negation | button:not(.primary, :first-child) |
An entity none of the list matches |
| Any of | :is(#a, .b), :where(#a, .b) |
An entity any of the list matches |
| List | .title, .subtitle |
Either |
An element name is a UI component's short name: text for sindri.ui.text,
image, shape, button, slider, layout, and likewise sprite for
sindri.sprite. The full name still works, so sindri.ui.text { … } means
what it always did.
Siblings are an entity's parent's children in the scene's order, and the
scene's top-level entities for one with no parent; :root is an entity with
no parent. :nth-child() takes CSS's an+b, odd and even. :not() and
:is() are as specific as their most specific argument, and :where() is
not specific at all, as in CSS.
:disabled and :checked come from the entity's own data: a component with
"disabled": true makes its entity :disabled, and a toggle with
"checked": true makes it :checked (as does a dropdown's selected option),
and a dropdown whose list is showing is :open. :hover, :active and :focus come
from input, which a host passes in when it resolves presentation
(PresentationWorld::resolve_with_states). :focus is whichever button,
toggle, text field or scroll region has the keyboard, by Tab or by a press.
Cascade
For each property independently, as in CSS:
- A matching rule with greater specificity wins. Specificity counts IDs, then
classes and states, then element names:
#playbeats.menu .row button.primary:hover, which beatsbutton:hover, which beatsbutton. - At equal specificity, the declaration appearing later in the stylesheet wins.
- A declaration inside an active media rule participates in the same cascade.
- Text properties inherit:
color,font-size,font-weight,font-style,line-height,letter-spacing,text-align,text-transformandtext-wrap. Colour a panel and every label inside it takes that colour unless it says otherwise. Box properties such aswidthdo not inherit. inherittakes the parent's value for any property;initialreturns a property to the entity's authored value;unsetinherits a text property and resets any other.
.hud { color: #cbd5e1; font-size: 18px; } /* every label in the HUD */
.hud .warning { color: #f87171; } /* except warnings */
#hud-hp-text { color: #f8fafc; } /* and this one */
Custom properties
A property whose name starts with -- is a variable: it inherits like text
does, and var(--name) stands for its value wherever a value is written. A
fallback follows a comma. Put a theme's tokens on the outermost element and use
them anywhere inside it:
.screen {
--accent: #f97316;
--panel: #111827;
--radius: 12px;
}
.card { background: var(--panel); border-radius: var(--radius); }
.card:hover { border-color: var(--accent); }
.badge { background: var(--badge, var(--accent)); }
A var() naming nothing, with no fallback, makes its declaration invalid, and
the property keeps the entity's authored value, as in CSS. Variables that
refer to each other in a circle are treated the same way.
Lengths
| Unit | Meaning |
|---|---|
| no unit | Sindri overlay units |
px |
CSS-style logical pixels converted for the viewport |
vw |
Percentage of viewport width |
vh |
Percentage of viewport height |
% |
Percentage of the parent's resolved content box |
Sindri's overlay is two units tall. Prefer pixels for stable desktop controls, viewport units for deliberate screen-relative composition, and percentages for children that should track a parent.
Percentage descendants resolve after the parent. Uniform padding reduces the
parent content box, so width: 100% fills the padded interior rather than
covering the outer border.
min-* and max-* constraints apply to the final resolved size.
calc() mixes units wherever a length goes: width: calc(100% - 40px),
padding: calc(var(--gap) * 2) 8px. Lengths add and subtract, and multiply
or divide by plain numbers; u names the overlay's own unit inside one.
min(), max() and clamp() are not read yet.
Layout properties
Every property, with the values it accepts, what it writes and an example, is listed in the generated Weave property reference, read from the table Sindri applies declarations by. A property not listed there is ignored.
Layout is hierarchical. A child must be parented beneath the layout entity in the scene; visual overlap does not establish layout membership. Explicit child boxes are important because text metrics alone do not currently provide intrinsic layout sizing.
Layout is CSS flexbox. Children are put in order, broken into lines when
the layout wraps, and each line's spare room is shared by flex-grow or its
shortfall taken back by flex-shrink (weighted by size, and one by default,
as in CSS), within min-/max- limits; then the line is justified and each
child aligned across it. Wrapped lines share the layout's leftover height, as
CSS's default align-content does. What the layout decides is what is drawn
and what is clicked. flex-start and flex-end are accepted for start and
end. Two differences from a browser: limits are applied in one pass rather
than by re-sharing what a clamped child could not take, and a child that is
not itself a layout has no automatic minimum, because text is not measured
yet. A layout's automatic minimum is its children and padding, so a badge
does not shrink into its own label.
A grid (display: grid, or a sindri.ui.grid component in the scene) places
its children in columns and rows. Tracks are fixed lengths, auto (as big as
the largest item alone in the track) or fr shares of the room those leave;
an fr track is never smaller than what is in it, as CSS's 1fr means
minmax(auto, 1fr). Items with a grid-column and grid-row are placed
first, then items held to a row, then the rest flow into the next free cell
row by row, and rows or columns past the template are added as auto
tracks. Items fill their cells unless justify-items, align-items or an
item's align-self says otherwise, and width: auto sizes a grid to its
tracks. Not yet: minmax(), named lines and areas, auto-fill/auto-fit,
dense packing, and spanning items sizing the tracks they cross.
Text is measured by its font. A text element with width: auto or
height: auto is sized to its words, measured by the same shaping that draws
them (with no width limit when the width fits, so a label is one line), plus
its padding. That size is also its minimum when a layout shrinks it. So a
button that fits its content grows with its label:
.badge { width: auto; height: auto; padding: 12px 18px; }
.badge-label { width: auto; height: auto; }
Measuring needs the font, so it is the host's to do: the game, the browser
export, the editor's views and the showcase's capture all measure each draw
(sindri_scene::measure_ui_text) and hand the sizes to layout, drawing and
hit-testing. A wrapping text's minimum is still its whole measured width, not
its longest word.
padding and margin take one to four values in CSS order (all; vertical
and horizontal; top, horizontal and bottom; top, right, bottom and left), and
the language expands them into their per-side longhands where they are
written, so a later padding-top overrides one side and keeps the rest. A
percentage on any side is of the containing element's content width, as in
CSS. Both are written to the element's sindri.ui.box, which the engine's
layout reads, so an authored scene gets the same box model without Weave.
As in flexbox, margins do not collapse: neighbours' margins and the gap add
up. Negative margins are accepted and currently count as none.
gap measures edge-to-edge space, not distance between child centres.
space-between places the first and last child at the content edges and
distributes remaining space between the interior gaps.
Shape properties
background, border-color, border-width, border-radius and box-shadow
are listed in the property reference.
These declarations target sindri.ui.shape.
box-shadow draws the shape's silhouette behind it, grown by the spread,
moved by the offset (positive y is down, as in CSS) and blurred over the
blur radius, with corners rounded to match. With no colour it is black at
half opacity. One shadow per element: a comma-separated list and inset are
refused rather than half drawn. A button can carry both
sindri.ui.button and sindri.ui.shape, allowing its hit target and visual
box to remain the same entity.
Colors accept transparent, black, white, and 3-, 4-, 6-, or 8-digit
hex. Hex colors are authored in sRGB and converted to the renderer's linear
space; alpha is unchanged.
Text properties
color, font-size, line-height, letter-spacing, font-weight,
font-style, text-align, text-wrap and text-transform are listed in the
property reference.
Wrapping requires a finite text width and enough height for the expected lines.
Use word for ordinary copy and glyph only when a long unbroken token must
break.
Responsive rules
@media (orientation: portrait) {
.actions {
direction: column;
width: 100%;
}
}
@media (max-width: 700px) {
.title { font-size: 28px; }
}
Supported conditions are:
orientation: portraitorientation: landscapemax-width: <length>,min-width: <length>max-height: <length>,min-height: <length>
Combine them as in CSS: and requires every condition, a comma accepts any
of the alternatives, and a @media block nested in another applies only
where both do.
@media (min-width: 900px) and (orientation: landscape) { … }
@media (max-height: 500px), (max-width: 400px) { … }
Put shared declarations in a normal rule and override only what changes inside the media rule.
Runtime behavior
The host loads the complete .weave source graph, composes each independent
entry stylesheet, computes cascade winners, then resolves a presentation world
for the viewport. Parents settle before descendants so percentages, padding,
and constraints have stable inputs. Rendering, pointer hit-testing, editor
placement, and generic UI layout consume the resulting ordinary Sindri
components.
A viewport change resolves a fresh presentation world from the authored scene. Weave does not progressively mutate the previous resolved result.
Decay should continue referring to stable entity IDs:
Ui.set_number("hud-score", score)
Ui.set_fill("hud-hp", health_ratio)
Presentation changes do not require those bindings to change.
Transitions
transition eases a property from the value it is showing to a new one when
the rule that sets it changes, as in CSS:
button { background: #334155; transition: background 150ms ease-out; }
button:hover { background: #475569; }
button:active { background: #1e293b; transition: background 50ms linear; }
The shorthand takes the property (or all), a duration, an optional easing
and an optional delay, and a comma-separated list for several properties.
Durations are s or ms. Easings are linear, ease, ease-in,
ease-out, ease-in-out and cubic-bezier(x1, y1, x2, y2). Colours, lengths
and plain numbers ease; other values change at once. As in CSS, the
transition that applies is the one on the rule being changed to, and an
interrupted transition starts again from the value it had reached.
Pointer states in the running game
:hover and :active follow the pointer in the editor's Play, native games
and browser exports. :hover matches the element under the pointer and every
container it is inside, and :active matches the pressed element while the
pointer stays on it (and a slider for as long as it is dragged), as in CSS.
Hit-testing uses what was drawn, so a button a media query moved or resized
is clicked where it is drawn. :focus follows the keyboard in the same hosts.
:disabled and :checked come from the entity's own data.
A running game has two layers, as a page in a browser does. The stylesheet's rules are settled into the game's world when it starts and whenever the screen changes shape, so scripts read styled values, and a value a script then writes stays written, as an inline style beats a stylesheet. Each frame only what pointer states and running transitions change is laid over that for the draw, and taken off again before the scripts run; with nothing hovered and nothing easing, a frame does no styling work at all.
Inspecting styles in the editor
Select a UI element and the inspector's Styles section shows what Weave did to it, as a browser's devtools do:
- the box model as drawn: margin, padding and size, in the stylesheet's pixels at the size the scene is presented at;
- every rule that matched, strongest first, with the file and line it was written on; a declaration a stronger rule, or a later stylesheet, overrode is struck through;
- the computed values the element ends up with, inherited ones included, and the custom properties in scope.
It follows the Game view's size, so switching the device preset shows the
rules its media queries choose. Click a declaration's value to change it:
Enter or clicking away writes the new value into the stylesheet on the
rule's line, leaving the rest of the file as it was, and the styles reload
from the file; Escape leaves it alone. A value that comes from a shorthand
(padding-top from padding) is refused rather than guessed at; edit the
shorthand. While the game is playing or paused, the Game view's Pick
toggle turns the next click in the game into a selection: the frontmost
element under it is selected, and the game does not receive the click.
sindri_weave::inspect answers the same questions for other tools, and
weave::set_declaration makes the same edit to a stylesheet's source.
Authoring guidance
The editor recognizes manifest-listed .weave roots, previews their source,
authors weave.style.classes, and applies the composed result to Scene and Game
views. It hot-reloads saved changes throughout each root's @use graph. A
broken reload keeps the last good presentation and reports the source path,
line, and column. The Game view's device presets choose the viewport, and a
single value can be changed from the Styles section; writing stylesheets
otherwise happens in a text editor.
- Keep semantic identity in entity IDs and reusable appearance in classes.
- Keep one small entry stylesheet and split large surfaces with
@use. - Use scene parenting to describe layout ownership.
- Give layout children explicit boxes.
- Prefer content-driven authored dimensions over filling the viewport merely
because
vhis available. - Test at least one wide, one narrow landscape, and one portrait viewport.
- Preserve touch targets when shrinking visuals; a readable mobile layout is more important than fitting a desktop composition onto a phone.
- Treat the authored component values as a safe fallback.
Errors and unsupported declarations
Invalid values for known properties return an ApplyError containing the
entity, property, and value. Unknown properties are currently ignored.
The following CSS concepts are not implemented:
- a wrapping text's longest word as its minimum, and text wrapping to the width a layout gave it
- borders per side, negative margins, and more than one or an
insetshadow align-contentother than its default,row-gap/column-gap, and reversed directions- grid's
minmax(), named lines and areas,auto-fill, and dense packing - scrolling and clipping regions
min(),max()andclamp()- attribute selectors, and the
-of-typepseudo-classes :focusin the running game: nothing takes focus until keyboard and gamepad navigation land@keyframesanimation- accessibility mapping
- integrated stylesheet source editing and named viewport preset controls
Do not silently imitate these features with unexplained fixed coordinates. Document the required workaround or add the missing primitive at the generic Sindri UI layer when a real game proves it is needed.
Diagnostic checklist
When an element is misplaced:
- Confirm the selector matches the intended entity ID, class, or element, and that every compound in a descendant selector has an ancestor to match.
- Confirm the entity is parented under the expected layout entity.
- Check whether a more specific or later rule overrides the property.
- Give the child an explicit
widthandheight. - Check the parent's resolved size, padding, direction, gap, and alignment.
- Verify that the active media condition matches the actual layout viewport.
When text escapes its box:
- Set a finite width.
- Enable
text-wrap: word. - Provide height for every expected line.
- Use a deliberate
line-height. - Test the longest runtime string, not only the authored placeholder.
When a button looks stretched or has malformed corners:
- Keep the shape and button hit target on the same entity.
- Use an absolute border radius for a rounded rectangle.
- Avoid a radius near half the height unless a pill is intentional.
- Verify that responsive rules change both dimensions consistently.
Relationship to gameplay tweening
Weave animation authoring follows CSS: computed-style changes trigger
transition, and future authored animations should use CSS-style @keyframes
and animation declarations. Decay gameplay uses managed Tween handles and
playback controls, documented in scripting.
Both use the engine's named easing curves. Sharing that math does not make UI
stylesheets depend on gameplay or turn Decay tweens into CSS declarations.
@keyframes remains unimplemented in this slice.