UI Engine

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.

142
CSS properties
18
pseudo-classes
7
attribute operators
4
at-rules
26
animatable properties
12
builtin controls

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 text

How 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.

  1. 01

    Author

    Write a .uix screen (or a .uic component) — a Vue 3 single-file component whose template uses engine primitives instead of DOM tags.

  2. 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.

  3. 03

    Diagnose

    Every unknown property, unusable value and unsupported selector comes back as a warning with line and column. uix_validate runs the same path without touching the project.

  4. 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.

  5. 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.

  6. 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.

FieldDefaultWhat it does
Root ScreenThe .uix asset this canvas mounts. Setting it to nothing unmounts the screen; swapping it at runtime swaps the screen.
Scale ModeScale With HeightScale 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 Height1080The logical viewport height Scale With Height designs against. Author at this height and the UI scales, rather than reflowing, on other screens.
Pixel PerfecttrueSnaps rect edges to physical pixels. Leave it on for crisp borders and hairlines; it is also what makes text-rendering: crisp do anything.
AntialiastrueSmooths rect and border edges. Turn it off for hard pixel-art edges. Text is always antialiased regardless.
Sort Order0Canvases draw in ascending order, and hit-testing walks them top-most first. This is how a pause menu covers a HUD.
VisibletrueHides 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.

TagWhat it isNotes
<view>Flex container / boxThe default box. Transparent containers are click-through unless they paint or carry a handler.
<text>MSDF textThe only place text renders. Elements nested inside a <text> are inert.
<image>Textured quadSized by the box; object-fit and -ui-slice control the mapping. A hit target by default.
<button>Focusable boxA <view> that is focusable and a hit target out of the box — gamepad and arrow-key nav find it.
<MyThing />An imported .uicPascalCase resolves to a component import: import HealthBar from './HealthBar.uic'.

Vue features that work

v-if / v-else / v-showv-forv-model:class / :styleslots (named + scoped)provide / injectref() / computed() / watch()<Teleport to="overlay">defineProps / withDefaultsdefineEmitsonMounted / onBeforeUnmountsetTimeout / setInterval

<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

AttributeEffect
: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.
focusableOpts 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 propReadable from CSS with an attribute selector — [variant="ghost"]. The name is matched verbatim, not lowercased.

Template refs give you the node

MemberMeaning
layoutX / layoutY / layoutW / layoutHAbsolute logical px, post-layout.
scrollX / scrollYCurrent scroll offset of an overflow: scroll container.
contentW / contentHFull 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.

CallReturnsNotes
engine.ui.getScreen(entity)a handle, or nullThe 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({ … })voidA 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 functionSubscribes to what the screen root emits. Returns the unsubscribe — register it on the script’s scope and teardown is automatic.
engine.ui.pointerOverUI()booleanTrue 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.

EventBubblesNotes
@clickyese.stopPropagation() is honoured.
@pointerdown / @pointerup / @pointermoveyese.x / e.y are logical canvas coords. A pointerdown captures: subsequent moves and the up route to the captured node.
@wheelyesScrolls the nearest overflow: scroll ancestor, with chaining.
@pointerenter / @pointerleavenoFire along the hover path.
@focus / @blurnoFocus is driven by pointer press, Tab-less spatial nav and gamepad.
@cancelnoEsc, or gamepad B, on the focused node.
@navnoA captured arrow axis. e.dir is 'up' | 'down' | 'left' | 'right'. Requires nav-capture.
@scrollnoFires 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

IntentDefault bindingFrom a UI action map
NavigateArrow keys · D-pad · left stickNavigate — a Vector2 action. Up is +y.
SubmitEnter / NumpadEnter · gamepad ASubmit
CancelEscape · gamepad BCancel

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();
  }
}
CSS spec

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.

ConstructSupportNotes
.a, .a.b.cyesA class name is a CSS identifier — non-ASCII is fine, escapes are resolved.
.a .b (descendant)yesMatching is right-to-left with backtracking, so .a .a .t matches an a > a > t tree.
>, +, ~yesParsed with or without surrounding whitespace — .p>.t works.
view, text, image, buttonyesType selectors, matched against the element kind. The only source of specificity’s d component.
*yesMatches anything, contributes nothing to specificity.
.a, .b { … }yesSplit on top-level commas only, so :not(.a, .b) stays one selector. Each gets its own declaration block.
:hover :active :focus :focus-within :focus-visibleyesDriven 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 :checkedyesSemantic 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()yes1-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()yesArguments 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().
:rootyesThe 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 flagsyesAll 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\.5yesCSS Syntax §4.3.7 escapes. This is what makes a real Tailwind build parse at all.
& (CSS Nesting)yesFlattened at parse time: & becomes :is(<parent list>), which is exactly CSS’s specificity rule for it. Nested at most 24 levels.
#idnoDiagnosed. There is no id→style channel; id is read only by the nav system.
::before ::after ::placeholder ::selection ::markernoDiagnosed. There is no generated-box step — the paint walk renders exactly the retained node tree.
:has()noDiagnosed. 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/:targetrefusedEach 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|EnoNo namespace concept anywhere.
CSS spec

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.

CSS spec

At-rules #

Four are carried out. The rest are consumed correctly and named in a diagnostic, rather than swallowed.

At-ruleSupportBehaviour
@mediayesTypes 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.
@supportsyes(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.
@layeryesBoth 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.
@keyframesyesfrom → 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 @namespacereportedEach 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.
@charsetsilentThe 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.
CSS spec

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

UnitSupportNotes
px and bare numbersfullA bare number is px — the house default, everywhere a length is accepted.
%partialWorks 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.
empartialResolved against the node’s own font-size, or the inherited one; on font-size itself it is parent-relative. Not accepted inside transform arguments.
rempartialResolves 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 / vhpartialPercent of the logical viewport. Any use makes a setViewport() re-style the tree. No vmin / vmax / svh / lvh / dvh.
deg rad turn gradfullIn transform functions and hsl() hue. Gradient angles are the one exception — deg only.
s / mspartialIn 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 dpinoThe suffix list is exactly px % rem em vw vh.

Functions

FunctionSupportNotes
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])partialAnywhere in a value, including inside shorthands. Fallback may contain one nesting level of parens.
asset('path')fullA 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()partialcircle 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()partialChannels 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()partialHue 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()noNo branch exists in the value layer.

Colours

FormSupportNotes
#rgb #rgba #rrggbb #rrggbbaafullCase-insensitive. Channels are sRGB-encoded 0..1 floats with no linearisation — UI is LDR, drawn after post-process.
rgb() / rgba() / hsl() / hsla()partialSee the function table.
Named keywordspartial — exactly 13transparent 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.
CSS spec

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.

flex-direction
row | row-reverse | column | column-reverse

Default column.

flex-wrap
nowrap | wrap | wrap-reverse
flex-flow
<direction> || <wrap>
flex
none | <grow> [<shrink>] [<basis>]

The first token must be a number, and omitted parts are left unsetflex: 1 does not zero the basis.

flex-grow / flex-shrink
any finite number

flex-shrink defaults to 0 — items never shrink unless you say so.

flex-basis
auto | <Dim>
justify-content
flex-start | center | flex-end | space-between | space-around | space-evenly
align-items / align-self
auto | flex-start | center | flex-end | stretch | baseline
align-content
the same six

Default flex-start; the space-* values are rejected here.

gap / row-gap / column-gap
<Len> (px, em, rem, vw, vh, calc)

% is rejected.

order
<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.

width / height
<Dim>

No min-content / max-content / fit-content.

min-width / min-height / max-width / max-height
<Dim>, plus none on the maxima
aspect-ratio
<a> [/ <b>]

Digits and dots only — no sign, no auto, no calc().

margin + the 4 physical longhands
1–4 <Dim>, auto per side

margin: auto centring works.

padding + the 4 physical longhands
1–4 <Dim>
margin-inline / -block (+ -start / -end)
<Dim>

LTR / horizontal-tb assumed, in one place. There is no writing-mode model.

padding-inline / -block (+ -start / -end)
<Dim>

Same assumption.

position
relative | absolute | static

static folds onto relative — the same box here. fixed and sticky are refused.

top / right / bottom / left / inset
<Dim> incl. auto

auto resets the edge rather than meaning CSS’s static position.

display
flex | none | block | inline | inline-block | inline-flex | flow-root | list-item

Eight spellings, two behaviours: everything but none folds onto flex, exactly. grid, table* and contents are refused.

overflow
visible | hidden | scroll | auto | clip

auto = scroll (no scrollbars are drawn anyway), clip = hidden. overflow-x / -y are refused.

box-sizing
border-box

Paint — 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.

background / background-color / background-image
<color> · linear/radial/conic-gradient() (2–6 stops) · asset('…')

One layer. A gradient and an image are different slots.

object-fit
fill | contain | cover | none | scale-down

none / scale-down crop an oversized image at the box instead of overflowing it.

background-repeat / -clip / -position
no-repeat · border-box · center

Each accepts only the value that states what the engine already does; every other value is rejected rather than silently ignored.

border + the 4 edge shorthands
<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.

border-width + the 4 longhands
1–4 <Len>

Layout reserves the widest edge on all four sides; paint is exact per edge.

border-radius + the 4 corner longhands
1–4 <Len>

Per-corner, with relative units. A % radius and the elliptical a / b form are refused — the corner SDF is circular.

border-block/-inline-start/-end (+ -width)
as the physical edges

LTR resolution, same single assumption.

border-style / outline-style
the CSS keyword set

Only none / hidden change anything; the rest render solid.

box-shadow
[inset] <x> <y> [blur] [spread] <color>, comma list

Unlimited layers, one quad each. Lengths are plain px and the colour is required.

outline / -width / -color / -offset
paint-only ring outside the box

Never affects layout or hit-testing. The colour defaults to the text colour.

opacity
a number or a percentage

Multiplied down the paint walk — not a compositing group. <= 0 prunes the subtree.

visibility
visible | hidden | collapse

Keeps layout; a descendant’s explicit visible re-shows it.

z-index
auto | <integer>

Siblings only. Paint and hit-testing share one comparator, so what looks topmost is what the pointer finds.

transform
translate/X/Y/Z scale/X/Y rotate/X/Y/Z skew/X/Y perspective

Paint-only — layout is never affected. Affine transforms cause no batch break.

transform-origin
keywords and/or %

px offsets and the 3-value form are rejected.

-ui-slice
1–4 px

9-slice insets in source texels. Emits up to 9 quads.

-ui-backdrop-blur / -ui-backdrop-noise
<Len> · 0..1

Acrylic. Drives a dual-kawase chain of 1–4 iterations by radius.

-ui-material
asset('mat.slang')

A .slang fragment shader over the rounded rect. On a <text> node it shades the glyphs.

tint
<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
<color>

Inherited. One flat colour per glyph run.

font
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.

font-family
comma list

Names must match a registered typographic family. No generic-family semantics, no per-glyph fallback chain.

font-size
<Len> plus %

% and em are both parent-relative here. The absolute keywords are rejected.

font-weight
normal · bold · 1..1000

Directional-nearest inside the chosen family, over the atlases the importer baked. bolder/lighter are refused.

font-style
normal | italic | oblique…

A boolean face filter. No synthetic oblique.

line-height
normal (1.2) · <n> · <n>% · <n>em · <n>px

A per-line advance. No half-leading — block height is exactly lines × advance.

text-align
left | center | right | justify

Justify distributes at interior spaces, on soft-wrapped lines only.

letter-spacing / word-spacing
normal · <Len>

% rejected. Word spacing applies at U+0020 only.

white-space
normal | nowrap | pre | pre-wrap | pre-line
word-break
normal | break-all | keep-all
overflow-wrap / word-wrap
normal | break-word | anywhere

The emergency char-break is on by default; only word-break: keep-all turns it off.

text-transform
none | uppercase | lowercase | capitalize

Applied before layout, so measure and paint agree.

text-overflow / line-clamp
clip | ellipsis · <n>

Both need a finite max width. line-clamp applies after wrapping.

text-decoration (+ -line, -color)
line keywords + optional style, colour, thickness

Inherited (a deviation). Only double alters rendering; dotted/dashed/wavy render solid.

text-shadow
<x> <y> [blur] [color], comma list

The colour is optional and defaults to opaque black. Each layer is a full extra glyph run.

text-stroke / -webkit-text-stroke
<width> <color> — both required

A distance-field centre-expand, not a true stroke.

text-rendering
crisp | smooth | auto

Engine keywords: crisp snaps each glyph quad to the physical pixel grid.

tab-size
unitless number

A constant advance, not a tab stop.

font-feature-settings / font-variation-settings
"tag" <n> lists

Pass-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.

pointer-events
auto | none

Nearest-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.

cursor
the full CSS UI 4 keyword set (36)

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.

transition (+ the 4 longhands)
<prop> <dur> [ease] [delay], comma list

In the shorthand the property must be token 0 and the duration token 1. A bare number is seconds.

animation
<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.

--custom-property
any raw token stream

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.

CSS spec

Motion #

Transitions and @keyframes animations over a closed set of 26 animatable properties and 5 named curves.

GroupPropertiesInterpolation
Numbers (5)opacity border-radius border-width outline-width outline-offsetPlain lerp.
Colours (5)color tint background-color border-color outline-colorComponentwise 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.
transformOnly when both lists carry the same functions in the same order; then per-argument.
box-shadowOnly when both are arrays of equal length.

Timing functions

linear ease ease-in ease-out ease-in-out
  • A real Newton + bisection cubic-bezier solver runs the five named curves. cubic-bezier(), steps() and linear(…) 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 a transition.

  • Not transitionable even under all: font-size, letter-spacing, gap, the flex-* 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:

--rp-accent --rp-accent-hover --rp-accent-ink --rp-accent-soft --rp-surface --rp-surface-hover --rp-border --rp-ink --rp-ink-dim --rp-focus --rp-radius --rp-radius-lg --rp-danger --rp-danger-hover --rp-panel-bg --rp-panel-acrylic --rp-menu-bg

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>
Read before porting

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 writeA browser doesThe engine does
width: 10Drops the declaration — a unitless non-zero length is invalid.Bare numbers are px, everywhere a length is accepted. A deliberate authoring convenience.
flex: 1Sets 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 shorthandResets 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-directionLays 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: blockA 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.9The 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: redColours 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 parentNot inherited — it propagates to in-flow descendants by a separate rule.Inherited through the normal cascade.
opacity: 5Clamped 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.
greenrgb(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 notcalc, 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.

DeclarationWhat is missing
display: grid | inline-gridThere 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: contentsThe layout node is the tree node; contents removes the box and promotes children into the parent’s flow.
position: fixedNo 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: stickyA different gap: sticky needs a scrollport-relative re-solve every time the scroll offset changes, and there is no such pass.
overflow-x / overflow-yNot 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, clearNo float model, and no inline formatting context for content to flow around.
border-top-color and its three siblingsThe 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-boxThe 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 | lighterRelative 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 shorthandborder: 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.

FeatureThe 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, multicolEvery node is a flex container/item. The display keywords parse and fold; what is absent is the formatting *model* they name.
Stacking contexts, isolationz-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 / stretchThe dimension grammar takes auto, calc() or a number with a unit; the layout solver has no intrinsic-keyword sizing behind it either.
Pseudo-elementsThere is no generated-box step: the paint walk renders exactly the retained node tree.
filter, backdrop-filter, mask, clip-path, blend modesEach 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, @scopeReported by name, not applied. Fonts are project assets imported through the content pipeline, not fetched by the stylesheet.
Text selection, carets, text inputThere 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 fallbackMSDF atlases are baked per face at import; there is no runtime shaper. Only glyphs baked into the project’s atlases render.
writing-mode, direction, RTLThe 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 do

Standards 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.

full css-syntax-3

A real tokenizer with escapes, comments, error recovery and forward-compatible parsing. A bad line can no longer delete the next rule.

partial selectors-4

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().

partial css-cascade-5

Real (a,b,c,d) specificity, @layer, !important as a layer, inherit/initial/unset/revert. Shorthands do not reset their unmentioned longhands.

partial css-variables-1

Custom properties cascade, inherit and honour !important; var() resolves at its own cascade position with fallbacks.

full css-nesting-1

& is flattened at parse time into :is(<parent list>) — CSS’s own specificity rule for it. Nothing downstream knows nesting exists.

partial mediaqueries-5

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.

partial css-conditional-3

@supports (prop: value) answers by asking the value layer whether the declaration parses. Folded at parse time.

partial css-flexbox-1

Every flex property, order included. Two initial values differ from CSS — see Deviations.

partial css-box-3 / css-sizing-3

Border-box only. Full 1–4-value margin/padding/inset. No intrinsic sizing keywords, no margin collapsing (flex does not collapse).

partial css-logical-1

12 logical margin/padding properties and 8 logical border edges, all resolved LTR / horizontal-tb.

partial css-display-3

Eight spellings fold onto one box type; grid, table* and contents are refused with the reason.

partial css-position-3

relative, absolute, static and the four insets. fixed and sticky are refused — the overlay layer is the documented alternative.

partial css-overflow-3

One overflow slot with wheel scrolling and chaining. No per-axis values, no scrollbar styling.

partial css-backgrounds-3

Per-corner radii, per-edge border widths, unlimited box-shadow layers. One shared border colour; no background-size, no multi-layer backgrounds.

partial css-images-3

Linear, radial and conic gradients with 2–6 stops, and all five object-fit values.

partial css-transforms-2

Translate, scale, rotate, skew, translateZ and perspective, plus transform-origin. Paint-only — layout never sees them.

partial css-transitions-1

The shorthand plus all four longhands, over a closed set of 26 animatable properties and 5 named easings.

partial css-animations-1

@keyframes with from/to/N% and the animation shorthand. No animation-* longhands, no animation events.

partial css-text-3 / css-fonts-4

Wrapping, breaking, clamping, ellipsis, decorations, shadow, stroke, transforms, spacing. Baked MSDF faces: no runtime shaping, no bidi, no @font-face.

partial css-values-4

calc() over six length units. No min(), max(), clamp(), env() or attr().

partial css-color-4

Modern rgb()/hsl() syntax with / alpha and space separators. sRGB only — no oklch(), lab(), color-mix() or relative colour syntax.

partial css-ui-4

cursor, with the full 36-keyword set. resize and appearance are knowingly ignored — the UI draws no native widgets.

none css-grid-1

Gated on the layout-backend decision, not on parser work.

none css-pseudo-4

No generated-box step exists.

none filter-effects-1 / css-masking-1

Each needs a render-target hop the UI pass does not take.

none css-contain-3 / css-properties-values-api-1

@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.

3 364
rules parsed
3 163
carry a live declaration
810
diagnostics, over 155 distinct pairs
  • 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
The long tail

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 works

A 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.