Game UI that gotta go fast
@engine/ui is a retained-mode UI drawn by the engine's own GPU renderer — rounded-rect SDFs, MSDF text, gradients, 9-slice, acrylic backdrop blur and custom .slang materials, batched down to a handful of draw calls. You author screens as Vue single-file components and style them with redcss: a genuine CSS engine, with a real tokenizer, a real cascade and real specificity.
Overview #
Two file types, one renderer, and a CSS front end that is honest about what it does not do.
.uix
A screen. Mounted by a core.UICanvas component's root field on an entity. Its defineProps are driven from gameplay code, and its emits come back the same way.
.uic
A reusable component. Imported by PascalCase tag into any screen or other component. The twelve builtin controls are ordinary .uic files you can read.
There is no DOM
Templates use engine primitives — view, text, image, button — not HTML tags. Layout is flexbox, text is drawn from font atlases baked at import, and styles are a typed CSS subset. Vue is the authoring language and the reactivity system; nothing about the browser comes along with it.
How fonts are baked, and what that means for your textHow a screen is built #
CSS is parsed exactly once, on your machine, at import time. The shipped game never tokenizes a stylesheet to draw a frame.
- 01
Author
Write a
.uixscreen (or a.uiccomponent) — a Vue 3 single-file component whose template uses engine primitives instead of DOM tags. - 02
Compile at import
The editor compiles the SFC and hands every
<style>block to redcss. Selectors, specificity, at-rules and all 142 property mappers resolve here, once, and the result is embedded in the produced module. - 03
Diagnose
Every unknown property, unusable value and unsupported selector comes back as a warning with line and column.
uix_validateruns the same path without touching the project. - 04
Bake
The build folds every screen into the shipped bundle. The game loads resolved data — it never parses a stylesheet to draw a frame.
- 05
Resolve & lay out
Matching, the cascade,
var()substitution and unit baking run per node; the flexbox solver sizes the boxes; the text stack measures the glyph runs. - 06
Batch & paint
Rounded-rect SDF fills, rings, gradients, 9-slice, shadows, backdrop blur and glyph runs merge into as few draw calls as the tree allows.
One parser, everywhere
The editor, the web player and the desktop player all run the same CSS front end, and the same code path produces a stylesheet declaration and an inline one — so width: 10px cannot mean two different things depending on where you wrote it, or which target you shipped to. Two things are still resolved live rather than baked: an inline style prop, and a var() declaration once its variables are known.
Quick start #
A health bar, from source file to a live screen.
1 · Write /content/Hud.uix
<template>
<view class="hud">
<text class="label">{{ hp }} / {{ maxHp }}</text>
<view class="bar">
<view class="fill" :style="{ width: fillPx }" />
</view>
<Button variant="ghost" @click="emit('pause')">PAUSE</Button>
</view>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import Button from 'builtin/ui/controls/Button.uic';
const props = withDefaults(defineProps<{ hp?: number; maxHp?: number }>(), {
hp: 100,
maxHp: 100,
});
const emit = defineEmits<{ (e: 'pause'): void }>();
const BAR_W = 240;
// Bind px, not %: the interpolator animates plain px numbers, and a raw % snaps.
const fillPx = computed(() =>
Math.round(BAR_W * Math.max(0, Math.min(1, props.hp / props.maxHp))),
);
</script>
<style>
:root {
--hud-accent: #ef4444;
--hud-surface: #0a0a0acc;
}
.hud {
position: absolute;
top: 16px;
left: 16px;
gap: 8px; /* flex-direction defaults to column here */
padding: 12px 16px;
background-color: var(--hud-surface);
border-radius: 12px;
-ui-backdrop-blur: 18px;
}
.label {
font-size: 14px;
color: #f5f5f5;
letter-spacing: 0.08em;
}
.bar {
width: 240px;
height: 10px;
border-radius: 999px;
background-color: #ffffff1a;
overflow: hidden;
}
.fill {
height: 100%;
background-image: linear-gradient(90deg, var(--hud-accent), #f97316);
transition: width 180ms ease-out;
}
@media (max-width: 720px) {
.bar { width: 140px; }
}
</style> 2 · Drive it from a behaviour
Add a core.UICanvas to an entity and point its root at the asset. Then reach the mounted screen from any behaviour on that same entity:
import { Behaviour, field } from '@engine/core';
/** Sits on the same entity as the core.UICanvas whose root is Hud.uix. */
export default class HudDriver extends Behaviour {
@field({ type: 'f32', label: 'Max HP', min: 1 })
maxHp = 100;
private hp = 100;
/** Read by the rest of the game — an emit is an INTENT, the game decides what it means. */
paused = false;
override onStart(): void {
// The handle survives canvas remounts, so subscribe once, here.
const unsubscribe = this.engine.ui?.getScreen(this.entity)?.on('pause', () => {
this.paused = !this.paused;
});
if (unsubscribe) this.scope.register(unsubscribe); // torn down with the script
}
override onUpdate(dt: number): void {
// setProps is a shallow-reactive merge — the screen re-renders itself.
this.engine.ui?.getScreen(this.entity)?.setProps({
hp: this.hp,
maxHp: this.maxHp,
});
}
} 3 · Iterate
Editing the file and reimporting refreshes live canvases in place — the screen handle is keyed by the entity, not by the mounted Vue app, so your subscriptions survive the remount. Every parse diagnostic shows up with a line and a column; nothing you wrote fails silently except the three cases listed under Gotchas.
Mounting a screen #
A .uix file is inert until a core.UICanvas puts it on an entity. That component is also where resolution independence is configured.
| Field | Default | What it does |
|---|---|---|
| Root Screen | — | The .uix asset this canvas mounts. Setting it to nothing unmounts the screen; swapping it at runtime swaps the screen. |
| Scale Mode | Scale With Height | Scale With Height lays out in a fixed logical height and scales to the viewport — one layout that holds at any resolution. Constant Pixel makes one logical unit one physical pixel. |
| Reference Height | 1080 | The logical viewport height Scale With Height designs against. Author at this height and the UI scales, rather than reflowing, on other screens. |
| Pixel Perfect | true | Snaps rect edges to physical pixels. Leave it on for crisp borders and hairlines; it is also what makes text-rendering: crisp do anything. |
| Antialias | true | Smooths rect and border edges. Turn it off for hard pixel-art edges. Text is always antialiased regardless. |
| Sort Order | 0 | Canvases draw in ascending order, and hit-testing walks them top-most first. This is how a pause menu covers a HUD. |
| Visible | true | Hides the canvas without unmounting it — state, subscriptions and the screen’s own script all survive. |
Everything you write is logical px
CSS lengths, layoutX/layoutW on a node handle, and e.x/e.y on a pointer event are all in logical units. The scaler converts to physical pixels once, at the edge. You never multiply by a DPI factor yourself.
vw / vh are the logical viewport
Percent of the logical viewport, not the physical one — so a 50vh panel is half the screen at every resolution and DPI. Using either makes a viewport change re-style the tree.
One canvas per entity, many canvases per game
A core.UICanvas allows one per entity, so a HUD, a pause menu and a debug overlay are three entities with three Sort Orders. They hit-test in that order, which means an open menu naturally swallows clicks meant for the HUD underneath.
Template #
Four primitives, your own components, and the Vue features that survive the trip out of the browser.
| Tag | What it is | Notes |
|---|---|---|
<view> | Flex container / box | The default box. Transparent containers are click-through unless they paint or carry a handler. |
<text> | MSDF text | The only place text renders. Elements nested inside a <text> are inert. |
<image> | Textured quad | Sized by the box; object-fit and -ui-slice control the mapping. A hit target by default. |
<button> | Focusable box | A <view> that is focusable and a hit target out of the box — gamepad and arrow-key nav find it. |
<MyThing /> | An imported .uic | PascalCase resolves to a component import: import HealthBar from './HealthBar.uic'. |
Vue features that work
<Teleport to="overlay"> portals to the canvas top layer, so dropdowns, tooltips and modals escape ancestor clips — the overlay paints on top and hit-tests first. It is also the documented alternative to position: fixed.
Attributes
| Attribute | Effect |
|---|---|
:disabled="…" / :checked="…" | State props: a plain boolean that sets both the pseudo-class and the matching boolean attribute, with false removing the attribute so the two can never disagree. This is why you write :checked="modelValue" rather than hand-rolling :data-checked, where false would be present and match. |
disabled (event blocking) | Blocks events for the whole subtree — but the :disabled *state* is only on the node you set it on. To style a disabled panel’s children, write .panel[disabled] .child, not .child:disabled. |
focusable | Opts a non-<button> into focus and gamepad navigation. |
id + nav-up/down/left/right="<id>" | Explicit navigation override — point a direction at a specific node instead of letting spatial nav decide. |
nav-capture="x | y | both" | The focused node consumes those arrow axes as @nav events instead of moving focus. This is the slider pattern. |
| Any other prop | Readable from CSS with an attribute selector — [variant="ghost"]. The name is matched verbatim, not lowercased. |
Template refs give you the node
| Member | Meaning |
|---|---|
layoutX / layoutY / layoutW / layoutH | Absolute logical px, post-layout. |
scrollX / scrollY | Current scroll offset of an overflow: scroll container. |
contentW / contentH | Full scrollable content size. |
scrollBy(dx, dy) / scrollTo(x, y) | Programmatic scrolling on an overflow: scroll container. |
Script #
Sandboxed TypeScript. Imports are limited to vue and other .uic files.
Inside the screen
<script setup lang="ts"> with defineProps, withDefaults and defineEmits. console.* goes to the game log. setTimeout and setInterval work — clean them up in onBeforeUnmount.
From gameplay code
engine.ui.getScreen(entity) returns a handle with setProps() (a shallow-reactive merge) and on() (subscribe to what the screen root emits; returns an unsubscribe). engine.ui.pointerOverUI() is how you stop a click on the HUD from firing into the world.
Driving it from gameplay #
Values go in as props, intents come out as emits. That is the entire contract between a screen and the game — a screen never reaches into gameplay, and gameplay never reaches into the component tree.
| Call | Returns | Notes |
|---|---|---|
engine.ui.getScreen(entity) | a handle, or null | The screen of the core.UICanvas on that entity. Valid before the canvas finishes mounting — props applied now are applied once it does, so there is no ready-callback to wait on. |
screen.setProps({ … }) | void | A shallow merge into the root component’s props. Vue reactivity does the rest: the screen re-renders itself exactly like any parent-driven prop change. |
screen.on(event, fn) | an unsubscribe function | Subscribes to what the screen root emits. Returns the unsubscribe — register it on the script’s scope and teardown is automatic. |
engine.ui.pointerOverUI() | boolean | True while the pointer is over a hit target, or while a UI drag is captured. Advisory — see the Input section. |
import { Behaviour, field } from '@engine/core';
export default class PauseMenu extends Behaviour {
@field({ type: 'asset', assetType: 'level' })
menuLevel = '';
private open = false;
private unsubscribe: (() => void) | null = null;
override onStart(): void {
const screen = this.engine.ui?.getScreen(this.entity);
// Subscribe ONCE, here — the first listener for an event becomes live after
// one flush, so onStart puts that safely before anything can emit.
// scope.register tears it down with the script.
this.unsubscribe = screen?.on('resume', () => this.setOpen(false)) ?? null;
if (this.unsubscribe) this.scope.register(this.unsubscribe);
screen?.on('quit', () => this.engine.level?.load(this.menuLevel));
// Valid before the canvas has mounted — these apply once it does.
screen?.setProps({ open: false, volume: 0.8 });
}
private setOpen(open: boolean): void {
this.open = open;
// A shallow merge. Keep props FLAT: mutating a nested object in place and
// passing the same reference back changes nothing the screen can see.
this.engine.ui?.getScreen(this.entity)?.setProps({ open });
}
} The handle is keyed by the entity, not the app
Editing a .uix and reimporting re-creates the Vue app underneath, but the bridge survives — its props re-apply and your on() subscriptions keep firing. Subscribe once in onStart, not every frame.
Subscribe in onStart, not onUpdate
The first subscription for a given event becomes visible after one scheduler flush, so an event emitted in that same tick is missed; from the next frame on it is live. Subscribing in onStart puts the flush safely before anything can emit. Later subscriptions to an event that already has one are immediate.
setProps is shallow
It merges top-level keys. Mutating a nested object in place and calling setProps with the same reference changes nothing the screen can see — pass a new object for the branch you changed, or keep the props flat, which is what a HUD wants anyway.
Props are the whole contract
A screen never reaches into gameplay, and gameplay never reaches into the component tree. Values go in as props, intents come out as emits. That is why a screen can be developed against fake props and dropped into a level unchanged.
A handler that throws does not break the screen
Each subscriber is called in its own try/catch and a failure is logged with the event name. One bad listener cannot stop the others, or take the UI down.
onUpdate only ticks in play mode
An engine-wide rule worth repeating here, because it is the usual reason a HUD “does nothing” in the editor: behaviour updates run while playing. Rendering happens every frame regardless, so the screen is visible but nobody is pushing props into it.
Events & hit-testing #
DOM-shaped events, bubbling where CSS authors expect them to, plus the two rules that decide what is clickable.
| Event | Bubbles | Notes |
|---|---|---|
@click | yes | e.stopPropagation() is honoured. |
@pointerdown / @pointerup / @pointermove | yes | e.x / e.y are logical canvas coords. A pointerdown captures: subsequent moves and the up route to the captured node. |
@wheel | yes | Scrolls the nearest overflow: scroll ancestor, with chaining. |
@pointerenter / @pointerleave | no | Fire along the hover path. |
@focus / @blur | no | Focus is driven by pointer press, Tab-less spatial nav and gamepad. |
@cancel | no | Esc, or gamepad B, on the focused node. |
@nav | no | A captured arrow axis. e.dir is 'up' | 'down' | 'left' | 'right'. Requires nav-capture. |
@scroll | no | Fires on an overflow: scroll container after a wheel scroll moved it. |
The hit rule
A node is clickable only if it is interactive or painted — a button or image, anything carrying a handler, or anything with an opaque background, border, material or acrylic. Transparent layout containers are click-through. Override per subtree with pointer-events: auto | none.
Paint order is hit order
Painting and hit-testing share one comparator — z-index, then order, then document order — so what looks topmost is what the pointer finds. The overlay layer hit-tests before everything else.
Input, focus & gamepad #
Menus are keyboard- and gamepad-navigable the moment they mount, with no configuration. What you do have to decide is how UI input and gameplay input share a frame.
Pointer
The host polls, the system decides
Once a frame the host hands over a plain snapshot — pointer position, three buttons, wheel — and the UI does all edge detection, hit-testing and dispatch itself. That is why the editor and a shipped game cannot disagree about what a click means.
Hit-testing order
Top-most canvas first by Sort Order, then reverse paint order inside the tree, so what looks on top is what the pointer finds. overflow: hidden clips its children out of the test, and pointer-events resolves by nearest ancestor.
Interactive or painted
A node is a hit target only if it is a <button> or <image>, carries any handler, or visibly paints — an opaque background, a border, a 9-slice, a material, acrylic. A transparent layout container is click-through, so a full-screen HUD root does not swallow the world underneath it without you opting out.
Press captures
A primary press captures the node: every subsequent move and the release route to it, wherever the pointer goes. click fires when the release lands on that node or a descendant. This is what makes drag-to-scrub sliders work without any drag bookkeeping of your own.
Enter and leave walk the path
pointerenter/pointerleave are non-bubbling and fire along the ancestor chain as it changes, so :hover applies to the whole path — a button and every node inside it — the way CSS does.
Focus & navigation
What can hold focus
A <button>, or any node with a truthy focusable attribute, as long as it is not disabled. One node is focused at a time, across every mounted canvas.
How focus moves
A pointer press focuses the nearest focusable node in the hit chain, and a press on empty space blurs. Directional navigation moves it spatially. focus and blur are non-bubbling.
:focus-visible means "arrived by navigation"
It is set only when focus came from a directional move — arrow keys or a gamepad — and not from a pointer press. That is exactly the CSS UI 4 intent, and it is the distinction the input system can actually make, so a mouse user never gets a focus ring they did not ask for.
Spatial navigation, with an override
Directional moves run a Unity-style spatial search across all visible canvases. When geometry gets the answer wrong, give the nodes ids and point nav-up / nav-down / nav-left / nav-right at them — an explicit target always wins.
Hold to repeat
Holding a direction repeats after 0.4 s, then every 0.14 s. submit dispatches a click on the focused node — so a gamepad A button goes through the same @click handler a mouse does — and cancel dispatches @cancel.
nav-capture for sliders
A focused node with nav-capture="x" consumes the horizontal axis as @nav events instead of letting it move focus, so left/right adjusts the value and up/down still leaves the control. This is how the builtin Slider works, and the pattern for anything value-like.
Bindings
| Intent | Default binding | From a UI action map |
|---|---|---|
| Navigate | Arrow keys · D-pad · left stick | Navigate — a Vector2 action. Up is +y. |
| Submit | Enter / NumpadEnter · gamepad A | Submit |
| Cancel | Escape · gamepad B | Cancel |
Sharing a frame with gameplay
UI navigation works before you configure anything
The defaults above are always live, whatever your game’s input asset says — a menu is keyboard- and gamepad-navigable the moment it mounts, with no setup.
A UI action map replaces the defaults
Define an action map named UI with Navigate, Submit or Cancel and it takes over entirely — the built-in bindings stop applying. That is the seam for remappable controls, alternate schemes, or a build where Escape must not close menus.
Gameplay input is a separate path
Gameplay actions come from an input.InputReceiver on an entity, referencing an input asset and activating action maps on enable; a behaviour receives phase transitions through onInputAction instead of polling every frame. The UI never touches that path and it never touches the UI’s.
Receivers can block each other by priority
An input.InputReceiver carries Priority and Exclusive — a higher-priority exclusive receiver blocks the ones below it, which is the authored way to model a modal. Note the scope: that arbitrates between *receivers*, not between the UI and gameplay. The UI is not a receiver.
Consumption is advisory — this is the one you must handle
The UI does not silently eat input from gameplay. A click on your pause button also reaches the world unless you check. Gate world-facing pointer actions on engine.ui.pointerOverUI() — that is the supported answer, and skipping it is the classic “shooting through the HUD” bug.
Keyboard text entry is not here yet
There is no text-input model — no caret, no selection, no field primitive — which is why :placeholder-shown and friends are refused by name rather than quietly never matching. Navigation, activation and pointer input are complete; typing into the UI is not.
override onUpdate(): void {
// The UI does NOT eat gameplay input for you. Without this check, a click on
// the pause button also fires into the world.
if (this.engine.ui?.pointerOverUI()) return;
// isInputDown = went down this frame (isInputPressed = held, isInputUp = released).
if (this.engine.input?.isInputDown('Fire')) {
this.shoot();
}
} Selectors #
Matching is right-to-left with backtracking, over the retained node tree. Escaped identifiers work, which is what lets a real Tailwind build parse at all.
| Construct | Support | Notes |
|---|---|---|
.a, .a.b.c | yes | A class name is a CSS identifier — non-ASCII is fine, escapes are resolved. |
.a .b (descendant) | yes | Matching is right-to-left with backtracking, so .a .a .t matches an a > a > t tree. |
>, +, ~ | yes | Parsed with or without surrounding whitespace — .p>.t works. |
view, text, image, button | yes | Type selectors, matched against the element kind. The only source of specificity’s d component. |
* | yes | Matches anything, contributes nothing to specificity. |
.a, .b { … } | yes | Split on top-level commas only, so :not(.a, .b) stays one selector. Each gets its own declaration block. |
:hover :active :focus :focus-within :focus-visible | yes | Driven by the input system — that is the admission bar, and a pseudo-class that could never be true is refused instead. :focus-visible is set only when focus arrived by directional nav, not by a pointer press. |
:disabled :checked | yes | Semantic states a control declares about itself, written from a plain boolean prop: :checked="modelValue" sets the pseudo-class and the matching [checked] attribute, and false removes the attribute — so [checked] and :checked can never disagree. Note the asymmetry on disabled: it blocks events for the whole subtree, but the :disabled state sits only on the node you set it on, so style a disabled panel’s children with .panel[disabled] .child. |
:first-child :last-child :only-child :empty :first-of-type :last-of-type :only-of-type :nth-child() :nth-last-child() :nth-of-type() :nth-last-of-type() | yes | 1-based over element siblings in document order (so order does not disturb them). Argument is An+B | odd | even | N; the Selectors 4 of S clause is diagnosed, not silently misread. |
:not() :is() :where() | yes | Arguments are complex selectors, matched by the same right-to-left walk — :where(.list > :not(:last-child)) means what it says. :not() may not nest inside :not(). |
:root | yes | The node with no parent — where a theme puts its custom properties. |
[a] [a=v] [a~=v] [a|=v] [a^=v] [a$=v] [a*=v] + i / s flags | yes | All seven operators. The store is the prop map, so the attribute name is matched verbatim; values follow CSS (case-sensitive unless i). |
.hover\:bg-red, .w-1\/2, .p-1\.5 | yes | CSS Syntax §4.3.7 escapes. This is what makes a real Tailwind build parse at all. |
& (CSS Nesting) | yes | Flattened at parse time: & becomes :is(<parent list>), which is exactly CSS’s specificity rule for it. Nested at most 24 levels. |
#id | no | Diagnosed. There is no id→style channel; id is read only by the nav system. |
::before ::after ::placeholder ::selection ::marker | no | Diagnosed. There is no generated-box step — the paint walk renders exactly the retained node tree. |
:has() | no | Diagnosed. In a retained tree it forces ancestor-chain invalidation on every child mutation. |
:indeterminate :enabled :valid/:invalid :required :placeholder-shown :read-only :default :host :lang() :link/:target | refused | Each diagnosed with its own blocker named, not a shared excuse: nothing is tri-state, there is no control/non-control distinction (:enabled → write :not(:disabled)), no form-validity model, no text-input model, no memory of a control’s initial value, no shadow tree, no language model, no document URL. |
ns|E | no | No namespace concept anywhere. |
Cascade #
The cascade is the part people assume a game engine fakes. This one does not.
Real specificity
The parser computes the CSS (a, b, c, d) tuple. a = inline, b = ids (always 0, since #id is rejected), c = classes + attributes + pseudo-classes, d = type selectors. :is() and :not() take their most specific argument, :where() takes none, * takes none.
Ties break by source order
A global counter increments per rule across every sheet in the order sheets were added; later wins. Sheets are collected deps-first, so a component’s own rules outrank the rules of the components it imports.
!important is a layer, not a tiebreak
Every important declaration beats every normal one, and specificity orders only *within* each bag. It applies to custom properties too. Inline styles sit between normal rules and important rules.
@layer sorts above specificity
A later layer beats an earlier one regardless of selector weight, and an unlayered rule beats every layer. Both forms work — @layer a, b, c; to declare order, @layer name { … } to declare and fill. Nested layers are named outer.inner.
var() resolves at its own cascade position
A declaration carrying var() is deferred raw and re-parsed after substitution where it sits in the cascade — not after the cascade. Up to 8 substitution passes, so var(--a, var(--b, 10px)) resolves. Unresolvable with no fallback drops the declaration, as CSS says.
CSS-wide keywords work
inherit, initial, unset and revert are valid on every longhand (revert folds onto unset — there is no UA origin here). They also reach the homogeneous shorthands margin, padding, inset, border-radius and border-width. On a heterogeneous shorthand like border they are refused by name, because a single answer would be a guess.
At-rules #
Four are carried out. The rest are consumed correctly and named in a diagnostic, rather than swallowed.
| At-rule | Support | Behaviour |
|---|---|---|
@media | yes | Types all / screen. Features: width, height (incl. min-/max- and the < <= > >= = range forms), aspect-ratio, orientation, hover / any-hover, pointer / any-pointer, prefers-reduced-motion. and / or / not, parenthesised groups, comma lists. Evaluated per style resolve against a live environment, so a viewport resize flips a breakpoint with no re-parse. Adds nothing to specificity. Any other feature makes its query invalid → never matches, diagnosed once per sheet. |
@supports | yes | (prop: value) is answered by asking the value layer whether that declaration parses. and / or / not and nesting compose. Folded to a constant at parse time — unlike @media, it asks about the engine, which cannot change between parse and paint. selector(…) evaluates false. |
@layer | yes | Both forms. A layer body is walked as a stylesheet in its own right, so @layer u { @media … { … } } and its mirror both work. A layer nobody declared sorts after every declared layer. |
@keyframes | yes | from → 0, to → 1, <n>% → n/100, comma lists allowed. Walked by the real tokenizer, so a brace or semicolon inside a quoted value cannot truncate a track. Duplicate names: later wins, document-globally. |
@container @property @font-face @import @page @scope @namespace | reported | Each is consumed correctly and named in one warning — their inner rules do not leak into the sheet. They used to be silent, which was the one place where something an author wrote did nothing and said nothing. |
@charset | silent | The source is already decoded UTF-8 by the time the parser sees it, so there is nothing to implement and nothing an author loses. Bootstrap opens with one; a warning there would be pure noise. |
Units, colours, functions #
Six length units and calc() over all of them. Keywords, units and property names are ASCII case-insensitive, exactly as CSS specifies.
Units
| Unit | Support | Notes |
|---|---|---|
px and bare numbers | full | A bare number is px — the house default, everywhere a length is accepted. |
% | partial | Works on width / height / min- / max-, margin*, padding*, the four insets, flex-basis, font-size and transform translate/origin. Rejected on gap, border-radius, border-width, outline-*, letter-/word-spacing — those slots are plain numbers and a percentage needs a box that does not exist yet at style time. |
em | partial | Resolved against the node’s own font-size, or the inherited one; on font-size itself it is parent-relative. Not accepted inside transform arguments. |
rem | partial | Resolves against a document-level root font size, deliberately not a read of the root node (that would be circular while the root is still resolving). |
vw / vh | partial | Percent of the logical viewport. Any use makes a setViewport() re-style the tree. No vmin / vmax / svh / lvh / dvh. |
deg rad turn grad | full | In transform functions and hsl() hue. Gradient angles are the one exception — deg only. |
s / ms | partial | In transition a bare number means seconds; in animation a unit is mandatory, because a bare number there is the iteration count. |
ch ex pt pc cm mm in Q lh fr vmin vmax dpi | no | The suffix list is exactly px % rem em vw vh. |
Functions
| Function | Support | Notes |
|---|---|---|
calc() | partial | + - between dimension terms, * with a unitless operand, / by a unitless non-zero, parentheses, nested calc(). Operands: px % em rem vw vh and bare. No var() / min() / max() / clamp() inside. Mixing % with absolute units parses, but the % term is dropped at layout with one console warning. |
var(--name[, fallback]) | partial | Anywhere in a value, including inside shorthands. Fallback may contain one nesting level of parens. |
asset('path') | full | A project asset reference — used by background / background-image, font and -ui-material. The editor rewrites the specifier to a guid at import. |
linear-gradient() | partial | [<n>deg | to top|right|bottom|left ,] <stop>, <stop>…, 2–6 stops, default 180deg. Angle is deg only; two-keyword corners (to top right) are invalid. |
radial-gradient() | partial | circle anywhere → circle, else ellipse; at <pos> supported. Size keywords are ignored. Ellipse radii are always half the box; circle radius is max(w,h)/2. |
conic-gradient() | partial | [from <n>deg] [at <pos>], sweeping clockwise from 12 o’clock. deg only. No repeating form. |
rgb() / rgba() | partial | Channels 0..255 or %; separators are commas and/or whitespace interchangeably; alpha via / <n|%> or a 4th channel. Nested parens are impossible, so no calc() inside a colour. |
hsl() / hsla() | partial | Hue as a bare number or an angle; saturation and lightness must end in %. |
min() max() clamp() env() attr() url() color-mix() oklch() lab() hwb() color() | no | No branch exists in the value layer. |
Colours
| Form | Support | Notes |
|---|---|---|
#rgb #rgba #rrggbb #rrggbbaa | full | Case-insensitive. Channels are sRGB-encoded 0..1 floats with no linearisation — UI is LDR, drawn after post-process. |
rgb() / rgba() / hsl() / hsla() | partial | See the function table. |
| Named keywords | partial — exactly 13 | transparent white black red green lime blue yellow cyan magenta gray grey orange. No steelblue, no purple, no currentColor. The literals are the engine’s own: green is exactly 0.5, not 128/255. |
Property reference #
The registry holds exactly 142 property names: 129 CSS-shaped, 8 vendor or legacy spellings aliased to the same mapper, and 5 engine-only. Anything outside it produces a named warning and is dropped.
Layout — flexbox
Every flex property is here, order included. Note the two defaults that are not the CSS ones: flex-direction starts at column, and flex-shrink at 0.
row | row-reverse | column | column-reverseDefault column.
nowrap | wrap | wrap-reverse<direction> || <wrap>none | <grow> [<shrink>] [<basis>]The first token must be a number, and omitted parts are left unset — flex: 1 does not zero the basis.
flex-shrink defaults to 0 — items never shrink unless you say so.
auto | <Dim>flex-start | center | flex-end | space-between | space-around | space-evenlyauto | flex-start | center | flex-end | stretch | baselineDefault flex-start; the space-* values are rejected here.
<Len> (px, em, rem, vw, vh, calc)% is rejected.
<integer>Re-seats the node among its siblings rather than going through the layout solver. Paint and hit-testing follow the same order.
Layout — box, sizing, position
Always border-box. box-sizing: border-box is accepted as a statement of fact; content-box is refused.
<Dim>No min-content / max-content / fit-content.
<Dim>, plus none on the maxima<a> [/ <b>]Digits and dots only — no sign, no auto, no calc().
<Dim>, auto per sidemargin: auto centring works.
<Dim><Dim>LTR / horizontal-tb assumed, in one place. There is no writing-mode model.
<Dim>Same assumption.
relative | absolute | staticstatic folds onto relative — the same box here. fixed and sticky are refused.
<Dim> incl. autoauto resets the edge rather than meaning CSS’s static position.
flex | none | block | inline | inline-block | inline-flex | flow-root | list-itemEight spellings, two behaviours: everything but none folds onto flex, exactly. grid, table* and contents are refused.
visible | hidden | scroll | auto | clipauto = scroll (no scrollbars are drawn anyway), clip = hidden. overflow-x / -y are refused.
border-boxPaint — background, border, effects
One rounded-rect SDF does the fill, the ring, the outline and the clip. z-index orders siblings only — there are no stacking contexts.
<color> · linear/radial/conic-gradient() (2–6 stops) · asset('…')One layer. A gradient and an image are different slots.
fill | contain | cover | none | scale-downnone / scale-down crop an oversized image at the box instead of overflowing it.
no-repeat · border-box · centerEach accepts only the value that states what the engine already does; every other value is rejected rather than silently ignored.
<width> || <style> || <color>Per-edge widths are exact. There is one shared border-color for the ring — the border-*-color longhands are refused by name.
<Len>Layout reserves the widest edge on all four sides; paint is exact per edge.
<Len>Per-corner, with relative units. A % radius and the elliptical a / b form are refused — the corner SDF is circular.
LTR resolution, same single assumption.
Only none / hidden change anything; the rest render solid.
[inset] <x> <y> [blur] [spread] <color>, comma listUnlimited layers, one quad each. Lengths are plain px and the colour is required.
Never affects layout or hit-testing. The colour defaults to the text colour.
Multiplied down the paint walk — not a compositing group. <= 0 prunes the subtree.
visible | hidden | collapseKeeps layout; a descendant’s explicit visible re-shows it.
auto | <integer>Siblings only. Paint and hit-testing share one comparator, so what looks topmost is what the pointer finds.
translate/X/Y/Z scale/X/Y rotate/X/Y/Z skew/X/Y perspectivePaint-only — layout is never affected. Affine transforms cause no batch break.
%px offsets and the 3-value form are rejected.
9-slice insets in source texels. Emits up to 9 quads.
<Len> · 0..1Acrylic. Drives a dual-kawase chain of 1–4 iterations by radius.
asset('mat.slang')A .slang fragment shader over the rounded rect. On a <text> node it shades the glyphs.
<color>Multiplies the background image / SDF icon colour.
Text
Baked MSDF atlases: one font per text node, weight and style pick a face. No runtime shaping, no bidi, no per-glyph fallback. Which glyphs exist at all is decided when you import the font.
Fonts & text — the full pipeline and its gotchas<color>Inherited. One flat colour per glyph run.
asset('path/Font.ttf')Repurposed — a font-asset reference, not the CSS font shorthand. Falls back to the font-family stack when the guid is not registered.
Names must match a registered typographic family. No generic-family semantics, no per-glyph fallback chain.
<Len> plus %% and em are both parent-relative here. The absolute keywords are rejected.
normal · bold · 1..1000Directional-nearest inside the chosen family, over the atlases the importer baked. bolder/lighter are refused.
normal | italic | oblique…A boolean face filter. No synthetic oblique.
normal (1.2) · <n> · <n>% · <n>em · <n>pxA per-line advance. No half-leading — block height is exactly lines × advance.
left | center | right | justifyJustify distributes at interior spaces, on soft-wrapped lines only.
normal · <Len>% rejected. Word spacing applies at U+0020 only.
normal | nowrap | pre | pre-wrap | pre-linenormal | break-all | keep-allnormal | break-word | anywhereThe emergency char-break is on by default; only word-break: keep-all turns it off.
none | uppercase | lowercase | capitalizeApplied before layout, so measure and paint agree.
clip | ellipsis · <n>Both need a finite max width. line-clamp applies after wrapping.
Inherited (a deviation). Only double alters rendering; dotted/dashed/wavy render solid.
<x> <y> [blur] [color], comma listThe colour is optional and defaults to opaque black. Each layer is a full extra glyph run.
<width> <color> — both requiredA distance-field centre-expand, not a true stroke.
crisp | smooth | autoEngine keywords: crisp snaps each glyph quad to the physical pixel grid.
A constant advance, not a tab stop.
"tag" <n> listsPass-through. Exactly two things are honoured: "liga" 0 and the wght axis as a face-selection override.
Interaction & motion
Three interaction properties and six motion ones — the whole surface.
auto | noneNearest-ancestor lookup: none makes a subtree click-through, and a descendant auto re-enables it and force-opts that node in as a hit target.
Inherited, so cursor: pointer on a <button> covers the <text> inside it. Honoured in the editor and the web player; the desktop player keeps the system cursor for now. url(…) is refused — a cursor image has no import path.
<prop> <dur> [ease] [delay], comma listIn the shorthand the property must be token 0 and the duration token 1. A bare number is seconds.
<name> <dur> [ease] [delay] [count|infinite] [direction] [fill-mode]Name and duration are both required, and a duration unit is mandatory. Auto-plays on mount or class match. No animation-* longhands.
Cascades, inherits, honours !important. Names are case-sensitive, per CSS Variables §2.
Knowingly ignored, on purpose
A separate list parses to nothing and says nothing, because the engine has no such concept and the declaration asks for what already happens: vertical-align, the list-style* family, the table properties, appearance, resize, user-select and the browser scroll chrome. A real Tailwind preflight trips eleven of them, and eleven meaningless warnings are how the warnings that matter stop being read. A property the engine could plausibly grow — text-indent, clip-path, background-size — is deliberately not on that list and stays diagnosed.
Motion #
Transitions and @keyframes animations over a closed set of 26 animatable properties and 5 named curves.
| Group | Properties | Interpolation |
|---|---|---|
| Numbers (5) | opacity border-radius border-width outline-width outline-offset | Plain lerp. |
| Colours (5) | color tint background-color border-color outline-color | Componentwise RGBA lerp, straight (non-premultiplied). |
| Dimensions (14) | width height top right bottom left · the 4 margin-* · the 4 padding-* | Plain px only. em / rem / vw / vh are baked to px first so they animate; a raw % never does — it snaps. |
transform | — | Only when both lists carry the same functions in the same order; then per-argument. |
box-shadow | — | Only when both are arrays of equal length. |
Timing functions
-
A real Newton + bisection cubic-bezier solver runs the five named curves.
cubic-bezier(),steps()andlinear(…)are not reachable — their presence invalidates the whole declaration rather than being approximated. -
A transition never runs on a node’s first style resolve, because there is no previous value to leave. Entry animations must use an
animation, not atransition. -
Not transitionable even under
all:font-size,letter-spacing,gap, theflex-*trio,min-/max-sizes,aspect-ratio, the backdrop properties, gradient stops,visibility,background-image,z-index,order.
Theming #
Custom properties cascade, inherit and honour !important — so a theme is a handful of declarations on an ancestor, not a rebuild.
/* Every builtin control reads these, and every var() has a default.
Put them on any ancestor — :root, a screen class, one panel. */
.my-screen {
--rp-accent: #ff5fb0;
--rp-accent-hover: #ff7ec1;
--rp-surface: #ffffff1a;
--rp-border: #ffffff38;
--rp-radius: 14px;
}
/* Cascade features you can lean on */
@layer base, components, overrides;
@layer components {
.card {
padding: 16px;
border-radius: var(--rp-radius);
background-color: var(--rp-surface);
/* CSS Nesting: & becomes :is(.card) at parse time */
&:hover { background-color: var(--rp-surface-hover, #ffffff26); }
& > .title { font-size: 1.25em; }
}
}
@layer overrides {
/* A later layer wins regardless of specificity. */
.card { border-radius: 4px; }
} Control tokens
Every builtin control reads these, and every var() in them carries a default — so setting one is optional and setting all of them is a full reskin:
Custom property names are case-sensitive, per CSS Variables §2 — --rpAccent and --rpaccent are two different properties. Every other property name folds case.
Builtin controls #
12 read-only .uic components, gamepad-ready out of the box: focusable, spatially navigable, with a focus ring driven by --rp-focus.
Button
variant?: 'primary' | 'ghost' | 'danger', disabled?<Button variant="ghost" @click="save">SAVE</Button>
Checkbox
v-model: boolean · label?, disabled?<Checkbox v-model="muted" label="Mute audio" />
RadioGroup / Radio
v-model: string | number · horizontal? · Radio: value, label?<RadioGroup v-model="mode" horizontal><Radio value="solo" label="Solo" /></RadioGroup>
Slider
v-model: number · min?, max?, step?, disabled?<Slider v-model="volume" :max="100" :step="5" style="width: 240px" />
ProgressBar
value, max?, showLabel?<ProgressBar :value="loaded" :max="total" show-label />
Tabs
v-model: number · tabs: string[]<Tabs v-model="tab" :tabs="['AUDIO', 'VIDEO']" />
Panel
title?, acrylic?<Panel title="SETTINGS" acrylic>…rows…</Panel>
ScrollView
wheel + draggable scrollbar<ScrollView style="height: 200px">…tall content…</ScrollView>
Dropdown
v-model · options: (string | number | {value,label})[], placeholder?<Dropdown v-model="res" :options="['1080p', '4K']" />
Tooltip
text, delay?=350ms<Tooltip text="Writes user://settings.json"><Button>APPLY</Button></Tooltip>
Modal
v-model: boolean · title?, acrylic?<Modal v-model="confirmQuit" title="QUIT?">…</Modal>
Composition
class and style on a control tag fall through to its root, so sizing is just <Slider class="grow" />. A Slider consumes left/right while focused via nav-capture; a Dropdown's menu portals to the overlay so it escapes ancestor clips.
Patterns #
Three shapes that come up in every game UI, each written the way the engine wants rather than the way a browser would.
A gamepad-scrubbable slider
nav-capture turns an axis into @nav events instead of a focus move, and pointer capture makes dragging outside the track keep working.
<template>
<!-- nav-capture="x": while focused, left/right become @nav events instead of
moving focus, so the gamepad scrubs the value. Up/down still leaves. -->
<view
class="slider"
focusable
nav-capture="x"
@nav="onNav"
@pointerdown="startDrag"
@pointermove="onDrag"
>
<view class="track"><view class="fill" :style="{ width: fillPx }" /></view>
</view>
</template>
<script setup lang="ts">
import { computed } from 'vue';
const props = withDefaults(defineProps<{ value?: number; step?: number }>(), {
value: 50,
step: 5,
});
const emit = defineEmits<{ (e: 'update:value', v: number): void }>();
const WIDTH = 200;
const fillPx = computed(() => Math.round(WIDTH * props.value / 100));
function onNav(e: { dir: 'up' | 'down' | 'left' | 'right' }) {
if (e.dir === 'left') emit('update:value', Math.max(0, props.value - props.step));
if (e.dir === 'right') emit('update:value', Math.min(100, props.value + props.step));
}
// A press captures the node: moves and the release route here wherever the
// pointer goes, so dragging outside the track keeps working with no bookkeeping.
let dragging = false;
function startDrag(e: { x: number }) { dragging = true; apply(e.x); }
function onDrag(e: { x: number }) { if (dragging) apply(e.x); }
function apply(x: number) { /* … map logical x → value … */ }
</script>
<style>
/* Only shows for focus that ARRIVED BY NAVIGATION — a mouse user never
gets a ring they did not ask for. */
.slider:focus-visible { outline: 2px solid var(--rp-focus); outline-offset: 2px; }
</style> A dropdown that escapes its clip
The overlay layer is the answer to both “z-index will not lift this out of the clip” and “position: fixed is refused”.
<template>
<view class="row">
<button class="trigger" @click="open = !open">
<text>OPTIONS</text>
</button>
<!-- z-index orders SIBLINGS only — it cannot lift this out of .row's clip.
The overlay layer can: it paints on top of everything and hit-tests
first, and it is also the documented answer to position: fixed. -->
<Teleport to="overlay">
<view v-if="open" class="scrim" @click="open = false" />
<view v-if="open" class="menu">
<slot />
</view>
</Teleport>
</view>
</template>
<style>
.row { flex-direction: row; overflow: hidden; }
.scrim {
position: absolute;
inset: 0;
background-color: #00000080; /* painted, so it eats the outside click */
}
.menu {
position: absolute;
top: 64px;
left: 24px;
width: 220px;
border-radius: 10px;
background-color: var(--rp-menu-bg, #161616);
-ui-backdrop-blur: 16px;
/* Entry must be an ANIMATION: a transition never runs on a node's first
style resolve, because there is no previous value to leave. */
animation: pop 140ms ease-out;
}
@keyframes pop {
from { opacity: 0; transform: translate(0, -6px); }
to { opacity: 1; transform: translate(0, 0); }
}
</style> An infinite-scroll list
A template ref on a primitive hands you the node: post-layout box, scroll offsets, content size and programmatic scrolling.
<template>
<view ref="list" class="list" @scroll="onScroll">
<view v-for="item in items" :key="item.id" class="row">
<text>{{ item.label }}</text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue';
defineProps<{ items: { id: string; label: string }[] }>();
// A template ref on a primitive gives you the UINode itself.
const list = ref<{
layoutH: number; scrollY: number; contentH: number;
scrollTo(x: number, y: number): void;
} | null>(null);
function toBottom() {
const el = list.value;
if (el) el.scrollTo(0, el.contentH - el.layoutH);
}
function onScroll() {
// @scroll is non-bubbling and fires after a wheel scroll moved this container.
const el = list.value;
if (el && el.scrollY + el.layoutH >= el.contentH - 4) loadMore();
}
function loadMore() { /* … */ }
</script>
<style>
/* auto folds to scroll and clip folds to hidden — no scrollbars are drawn
either way, and hidden was never a scroll container. */
.list { height: 240px; overflow: scroll; gap: 4px; }
.row { flex-direction: row; padding: 8px 12px; }
</style> Deviations from CSS #
Where the engine deliberately behaves differently from a browser. If you are bringing a stylesheet over from the web, this is the section that will save you the afternoon.
| You write | A browser does | The engine does |
|---|---|---|
width: 10 | Drops the declaration — a unitless non-zero length is invalid. | Bare numbers are px, everywhere a length is accepted. A deliberate authoring convenience. |
flex: 1 | Sets grow 1, shrink 1, basis 0%. | Sets grow 1 only and leaves shrink and basis alone — so flex: 1 on a box that already has a width sizes from that width. The single most common porting surprise. |
| Any shorthand | Resets every longhand in its family, including the ones it does not mention. | Does not reset unmentioned longhands. The three explicit exceptions are background: none, text-decoration and transition. |
A container with no flex-direction | Lays out as a row. | Lays out as a column — that is the engine default. flex-shrink starts at 0 too, so items never shrink unless you ask them to. |
display: block | A block formatting context. | Folds onto flex, exactly: every box is already a flex item of its parent, and a box here defaults to column + stretch, which *is* a block box. |
width: calc(100% - 20px) | Resolved by layout against the real containing block. | The % term is dropped before layout with one console warning — it becomes -20px. For the "N% minus half my own size" shape, write left: N% + transform: translate(-50%, 0) instead. |
z-index: 10 under opacity: 0.9 | The opacity creates a stacking context. | There are no stacking contexts. z-index orders siblings and can never lift a node out of its parent’s paint slot — use <Teleport to="overlay"> for that. |
border-top-color: red | Colours one edge. | Refused by name. The border ring is one quad with one shared colour; a colour per edge needs a per-edge lane in the vertex format. |
text-decoration: underline on a parent | Not inherited — it propagates to in-flow descendants by a separate rule. | Inherited through the normal cascade. |
opacity: 5 | Clamped to 0..1 at computed-value time. | Not clamped in the parser — the raw number rides into the compiled data and is clamped where it is used. |
calc(100px-20px) | Invalid — whitespace is required on both sides of -. | Also invalid, but calc(100px+20px) parses: a + can never start a number token, while the - is absorbed into -20px. Reproduced from the retired parser rather than fixed, because fixing it would change how existing sheets resolve. |
green | rgb(0 128 0) = 0.50196… | Exactly 0.5. Thirteen named colours exist and their literals are the engine’s own, kept so a stylesheet renders the colour it always did. |
ASSET('x.slang') | CSS function names are case-insensitive. | asset() is the one function name that is not — calc, rgb, hsl and the gradients all fold case. Reported, not fixed. |
Refused, by name #
There are three answers, not two. Supported. Knowingly ignored, silently. And refused — the engine understood the declaration, will not honour it, and says why on every occurrence.
The third one exists because the first two cannot say "no, and here is what is missing". Before it, display: grid came back as Invalid value 'grid' for 'display' — which reads like a typo report, and an author who reads it as one goes looking for the typo instead of for another layout.
| Declaration | What is missing |
|---|---|
display: grid | inline-grid | There is no track-sizing algorithm. Folding a grid onto flex lays it out as one column and reports success — a wrong screen, not a partial one. |
display: table*, inline-table, ruby* | No table or ruby boxes. Row and cell boxes folded onto flex items would collapse the table into a column. |
display: contents | The layout node is the tree node; contents removes the box and promotes children into the parent’s flow. |
position: fixed | No viewport containing block — and an absolute child’s percentage sizes resolve against its parent, so overriding only the final x/y would leave width: 50% measured against the wrong box. Use <Teleport to="overlay"> + position: absolute; inset: 0, which is viewport-fixed, escapes ancestor clips and paints on top. |
position: sticky | A different gap: sticky needs a scrollport-relative re-solve every time the scroll offset changes, and there is no such pass. |
overflow-x / overflow-y | Not the scissor — the slot. overflow is a single style key, and per-axis needs it to become a shorthand writing two longhands, or the cascade goes wrong. Neither layout backend has a per-axis input either. |
float, clear | No float model, and no inline formatting context for content to flow around. |
border-top-color and its three siblings | The ring is one quad with one borderColor. A colour per edge is a per-edge lane in the vertex format, not a mapper. |
border-radius: <%> | A percentage radius resolves against the laid-out border box, and radii bake to px before layout runs. Even a post-layout resolve would only be right on square boxes — the corner SDF is circular. |
box-sizing: content-box | The layout engine is border-box only. Laying a content-box element out as a border-box one is a wrong box, not a missing feature. |
font-weight: bolder | lighter | Relative to the parent’s computed weight, which the value layer cannot see. The side channel that could carry it exists, and the diagnostic names it. |
cursor: url(…) | A cursor image is an asset, and there is no import path resolving one to something a host can load. |
| A CSS-wide keyword on a heterogeneous shorthand | border: inherit would have to inherit four widths and the one shared colour — either answer clobbers something. Name the longhand instead. |
Not supported #
Each row names the concrete architectural blocker, not a TODO.
| Feature | The concrete blocker |
|---|---|
CSS Grid — grid-template-*, fr, minmax(), subgrid, place-* | The layout solver is flexbox-only, so there is no track model to expose. This is a layout-engine decision rather than a parser gap, and it is the one open question on the roadmap that would change how screens are authored. |
| Inline flow, floats, tables, multicol | Every node is a flex container/item. The display keywords parse and fold; what is absent is the formatting *model* they name. |
Stacking contexts, isolation | z-index exists as a sibling sort. A real model means the paint walk stops being a tree recursion and starts collecting descendants across levels — which is also where the "painter’s order = batch order" guarantee that keeps draw calls low would go. |
min-content / max-content / fit-content / stretch | The dimension grammar takes auto, calc() or a number with a unit; the layout solver has no intrinsic-keyword sizing behind it either. |
| Pseudo-elements | There is no generated-box step: the paint walk renders exactly the retained node tree. |
filter, backdrop-filter, mask, clip-path, blend modes | Each needs a render-target hop the UI pass does not take. -ui-backdrop-blur is the one acrylic case that was built explicitly. |
@container, @property, @font-face, @import, @scope | Reported by name, not applied. Fonts are project assets imported through the content pipeline, not fetched by the stylesheet. |
| Text selection, carets, text input | There is no selection model at all — which is also why user-select is knowingly ignored rather than diagnosed. The typed field primitive is the design of record for closing it. |
| Bidi, complex-script shaping, per-glyph font fallback | MSDF atlases are baked per face at import; there is no runtime shaper. Only glyphs baked into the project’s atlases render. |
writing-mode, direction, RTL | The logical properties resolve LTR / horizontal-tb under an assumption made in exactly one place, so RTL has one place to change — but no writing-mode model was built. |
The three text rows above — shaping, bidi and per-glyph fallback — all follow from one decision: glyphs are baked at import rather than rasterised at runtime. That decision buys the text stack its speed and costs it exactly those features.
What a bake-first text stack can and cannot doStandards coverage #
CSS has not had a version number since CSS 2.1 — it is a set of independently levelled modules. These are the ones the engine implements, and the ones it does not.
A real tokenizer with escapes, comments, error recovery and forward-compatible parsing. A bad line can no longer delete the next rule.
Compounds, all four combinators, all seven attribute operators with i/s, :is()/:where()/:not() over complex selectors, 17 pseudo-classes. No #id, no pseudo-elements, no :has().
Real (a,b,c,d) specificity, @layer, !important as a layer, inherit/initial/unset/revert. Shorthands do not reset their unmentioned longhands.
Custom properties cascade, inherit and honour !important; var() resolves at its own cascade position with fallbacks.
& is flattened at parse time into :is(<parent list>) — CSS’s own specificity rule for it. Nothing downstream knows nesting exists.
Evaluated live against a host environment, so a resize flips a breakpoint with no re-parse. Nine features; the rest make their query invalid, once per sheet.
@supports (prop: value) answers by asking the value layer whether the declaration parses. Folded at parse time.
Every flex property, order included. Two initial values differ from CSS — see Deviations.
Border-box only. Full 1–4-value margin/padding/inset. No intrinsic sizing keywords, no margin collapsing (flex does not collapse).
12 logical margin/padding properties and 8 logical border edges, all resolved LTR / horizontal-tb.
Eight spellings fold onto one box type; grid, table* and contents are refused with the reason.
relative, absolute, static and the four insets. fixed and sticky are refused — the overlay layer is the documented alternative.
One overflow slot with wheel scrolling and chaining. No per-axis values, no scrollbar styling.
Per-corner radii, per-edge border widths, unlimited box-shadow layers. One shared border colour; no background-size, no multi-layer backgrounds.
Linear, radial and conic gradients with 2–6 stops, and all five object-fit values.
Translate, scale, rotate, skew, translateZ and perspective, plus transform-origin. Paint-only — layout never sees them.
The shorthand plus all four longhands, over a closed set of 26 animatable properties and 5 named easings.
@keyframes with from/to/N% and the animation shorthand. No animation-* longhands, no animation events.
Wrapping, breaking, clamping, ellipsis, decorations, shadow, stroke, transforms, spacing. Baked MSDF faces: no runtime shaping, no bidi, no @font-face.
calc() over six length units. No min(), max(), clamp(), env() or attr().
Modern rgb()/hsl() syntax with / alpha and space separators. sRGB only — no oklch(), lab(), color-mix() or relative colour syntax.
cursor, with the full 36-keyword set. resize and appearance are knowingly ignored — the UI draws no native widgets.
Gated on the layout-backend decision, not on parser work.
No generated-box step exists.
Each needs a render-target hop the UI pass does not take.
@container and @property are consumed and reported by name.
Measured, not claimed
Coverage is graded against a pinned corpus of stylesheets people actually ship — version and sha256 per sheet, fetched on demand, reported by a test. A benchmark that cannot be re-run is an anecdote, so the numbers below are reproducible bytes rather than a mood.
- Bootstrap 5.3.3 volume — 2 787 rules, vendor-prefixed longhand pairs, deep selector lists
- Pico 2.0.6 nested @media, :where() chains, @supports
- Tailwind v4.1.18 preflight @layer, nested @media (hover: hover), multi-line values
- Open Props 1.7.7 a :root custom-property theme and almost nothing else
- modern-normalize 3.0.1 dense element-selector baseline; the classic reset shape
Edge cases #
Behaviour that is correct, documented, and still not what you expected the first time. Grouped by where it bites.
Layout
flex: 1 on a box that already has a width
It sets grow only and leaves the basis alone, so the item sizes from its width instead of from the free space. Write flex-basis: 0 beside it when you meant CSS’s flex: 1.
A percentage inside calc() disappears
calc(100% - 20px) resolves to -20px with one console warning, because lengths bake before layout knows the parent. For “N% minus half my own size”, write left: N% plus transform: translate(-50%, 0).
auto on an inset resets the edge
left: auto does not mean CSS’s “use the static position” — it clears the constraint. inset: auto clears all four. Usually what you wanted anyway, but it is not the CSS meaning.
order moves paint and hit-testing, not selectors
Structural pseudo-classes stay on document order, exactly as CSS specifies — so :first-child is not necessarily the one drawn first once order is in play.
Paint
z-index cannot escape its parent
There are no stacking contexts: a z-index is compared against siblings only. A tooltip that must cover an ancestor’s clip goes in <Teleport to="overlay">, which paints on top of everything and hit-tests first.
A rotated scroll container still clips to a rectangle
Clipping is a scissor rect, so a transformed overflow: hidden box clips axis-aligned. Rotate the contents, not the clipping box.
Two border edges cannot have different colours
Per-edge widths are exact, but the ring shares one colour — the second edge asking for a different one wins and you get a diagnostic. For a two-tone frame, nest two boxes.
opacity is not a group
It multiplies down the paint walk rather than compositing the subtree, so overlapping children inside a semi-transparent parent show through each other. opacity: 0 prunes the subtree from paint entirely — it is not a cheap way to keep something interactive.
Motion
A transition never runs on the first resolve
There is no previous value to leave, so entry animations must be an animation, not a transition. This catches everyone once: a v-if element with transition: opacity 200ms simply appears.
Percentages snap instead of animating
The interpolator takes plain px. em/rem/vw/vh bake to px first so they animate; a raw % jumps. Animate px, or use transform: scaleX().
transition: opacity 300 is a five-minute fade
A bare number in transition means seconds, so that is 300 s. In animation a unit is mandatory instead, because a bare number there is the iteration count.
Input
Your transparent HUD root is click-through, and that is deliberate
It also means a panel you expected to block clicks does not, if it paints nothing. Give it a background — even #00000001 — or pointer-events: auto.
disabled blocks a subtree but styles one node
Events stop for the whole subtree; the :disabled state sits only on the node you set it on. Style a disabled panel’s children with .panel[disabled] .child, never .child:disabled.
The UI does not consume gameplay input for you
Check engine.ui.pointerOverUI() before acting on a world click, or turn the gameplay action map off while a menu is open.
Scripting
Nothing tells you an inline style was rejected
A style string or :style object parses through the same grammar but has no diagnostics channel. Move the declaration into a <style> block to see the warning, then move it back.
<style> is global — scoped is ignored
Two components with a .title class collide. Namespace with a root class per component. v-bind() in a style block is unsupported; use a custom property and :style instead.
A screen in the editor renders but is not driven
Behaviour onUpdate only ticks in play mode, so a HUD sits at its default props until you press play. Give props sensible withDefaults values and the screen stays designable.
Text
Elements inside <text> are inert
Mixed formatting is several sibling <text> nodes in a row container, not markup inside one. And a codepoint your font did not bake takes zero width rather than showing a box.
Gotchas #
The short list of things that will bite you once, and only once.
An inline style throws its diagnostics away
A style string or :style object has no diagnostics channel — it parses through the same grammar, but nothing tells you when a declaration was dropped. Retype it into a <style> block to see the warning, then move it back.
<style> is global — there is no scoped
A scoped attribute is treated as global. Namespace your rules with a root class per component instead. v-bind() in a style block is unsupported.
Percentages do not transition
The dimension interpolator takes plain px numbers. em / rem / vw / vh are baked to px first so those animate, but a raw % snaps. Animate a px width, or use transform: scaleX().
Only baked glyphs render
Coverage is fixed when the font is imported, and a codepoint outside the bake takes zero width rather than showing a replacement box — so a missing character reads as tight spacing, not as an error. Build icons out of views — rotated squares, bars, rings — rather than out of glyph art.
How coverage worksA transformed scroll container clips axis-aligned
The clip is a scissor rect, so a rotated overflow: hidden box still clips to an axis-aligned rectangle. A stencil clip is future work.
Transparent containers are click-through
A node is a hit target only if it is interactive or painted — a button, an image, anything with a handler, or anything with an opaque background, border, material or acrylic. Override per subtree with pointer-events: auto.
REJECTED and REFUSED are different words on purpose
Invalid value 'x' for 'p' means the engine could not read it — look for a typo. Unsupported value/property means it read the declaration fine and will not honour it; that message carries the reason and, where one exists, what to write instead.
Case folds, but three things keep yours
Property names, value keywords and units are all ASCII case-insensitive, as in CSS. Custom property names, any string (font families, @keyframes names, OpenType tags) and asset('path') payloads keep the author’s casing, because there the text *is* the value.
Build a screen in an afternoon
The UI engine ships with the editor — the twelve builtin controls, the MSDF text stack, the acrylic backdrop and the whole CSS front end are in the box.