QORMQORM v0.9.8 docs Get started

QSS — QORM Style Sheets

QSS is QORM's stylesheet language: a CSS-like rule syntax for sharing styles across scenes, without repeating inline style blocks on every node.

A stylesheet lives in styles/<id>.qss and is loaded by the runtime at load time. The Tetris example (examples/tetris/styles/app.qss) is a complete, runnable reference — a game's entire look driven by one stylesheet.

Rule syntax

# This is a comment

/* selectors: type, class, id */
button { borderRadius: 12 }
.accent { background: var(--primary); color: var(--on-primary) }
#submit { fontSize: 16 }

/* nested objects stay inline on the node, like style values */
.card { margin: { top: 8, bottom: 8 } }

/* {{bindings}} evaluate like inline style values */
.statValue { color: {{ state.dark ? "#fff" : "#111" }} }

name (space-separated; a class named later in the prop wins).

like inline style values.

Cascade

Style resolution is a cascade, later sources winning key by key:

theme component default < type rule < class rule < id rule < inline `style`

order wins between classes.

Using a stylesheet

Scene nodes reference rules with a class prop:

{ "type": "text", "text": "TETRIS", "class": "title" }

Structure · style · logic (with canvas FX)

The three layers share the same style vocabulary:

LayerWhereRole
Structurescenes/*.jsonNode tree, class, one-off inline style
Stylestyles/*.qssShared rules — including every canvas FX key (filter, clipPath, layoutMotion, scrollSnapType, spring transition, …)
Logicactions/*.qs (or JSON steps)Mutate state; QSS/style bindings re-evaluate

QSS accepts the same keys as inline style (render.KnownStyleKeys). A rule body may hold numbers, strings, var(--x), and {{bindings}} — evaluated each frame like inline values. Nested objects (margin: {top: …}) stay on the node.

qscript does not assign styles directly. Write state, bind it:

/* styles/app.qss */
.filterCard {
  filter: {{ state.filterOn ? "saturate(0.3) brightness(1.15)" : "none" }}
}
.flipChip {
  x: {{ state.flipLeft ? 16 : 280 }}
  layoutMotion: true
  transition: 0.35s spring
}
# actions/toggle_filter.qs
state.filterOn = !state.filterOn
{ "type": "box", "id": "filter_card", "class": "filterCard", "children": [ … ] }

Runnable end-to-end: examples/canvas-fx (styles/app.qss + actions/*.qs). Tetris is the same separation for game chrome: examples/tetris.

Rendering

Both backends apply QSS with the same cascade (theme component default < type < class < id < inline). The native canvas backend (macOS default pure-Go window; games WASM with qorm_canvas) merges matching rules in its measure pass; the HTML path merges them into each node's emitted inline CSS (boxCSS / textCSS). Widget chrome defaults on the HTML path (button variants, shell theme vars) sit under QSS the way canvas theme component defaults do.

Accepted style keys

The loader whitelist is render.KnownStyleKeys (~100 keys). Unknown keys are load-time warnings (the app still runs). The full group list lives in the auto-generated common style props.

Keys that both HTML and canvas apply include box model, text color/size/weight, pseudo-state (hover* / pressed* / disabled*), and backdropBlur / backdropTint. Canvas-only visual effects (software raster) are listed below.

For flex sizing, width: "fill" / height: "fill" is accepted in inline style, QSS, and the node's layout object. Canvas resolves it to the containing content-box size minus that child's margins, including when the parent's cross-axis alignment is not stretch. HTML emits 100%; normal CSS sizing and margin rules then apply. Numeric sizes remain pixels.

Canvas also resolves CSS-style geometry from either inline style or QSS:

explicit, Canvas derives the other (width: 120; aspectRatio: 1.5 gives height 80). With both axes or neither axis explicit, authored/intrinsic size remains authoritative; min/max constraints are applied afterward.

(or Canvas aliases x/y) to anchor from the leading edges, or right / bottom to anchor from the parent's content box. An explicit leading-axis value wins over its trailing anchor.

forbidden), and default (or arrow) to native cursors. The full QSS cascade is honored; absent an authored value, Canvas derives pointer/text/ disabled cursors from the hovered widget.

Canvas visual effects

Declarative style keys consumed by the pure-Go canvas backend. Use them on any node (inline style or QSS). Runnable showcase: examples/canvas-fx.

Chrome, shadow, outline

{
  "style": {
    "background": "#1c1c1e",
    "borderRadius": 12,
    "boxShadowColor": "#00000088",
    "boxShadowBlur": 16,
    "boxShadowX": 0,
    "boxShadowY": 8,
    "boxShadowInset": false,
    "outlineColor": "#0a84ff",
    "outlineWidth": 2,
    "outlineOffset": 4
  }
}

CSS border). strokeDasharray and strokeDashoffset allow dashed strokes.

Text stroke, shadow, decoration

{
  "type": "text",
  "text": "Title",
  "style": {
    "fontSize": 28,
    "fontWeight": "700",
    "textDecoration": "underline",
    "textTransform": "uppercase",
    "textStrokeColor": "#000",
    "textStrokeWidth": 2,
    "textShadowColor": "#00000066",
    "textShadowBlur": 4,
    "textShadowX": 0,
    "textShadowY": 2,
    "lineClamp": 2
  }
}

Gradients

background / gradient accept:

Additionally, the radialGradient property can be used as a standalone style key.

Filters, blend, mask, clip

{
  "style": {
    "filter": "blur(8px) brightness(1.1) saturate(1.2)",
    "mixBlendMode": "multiply",
    "maskFade": "right",
    "maskFadeSize": 40,
    "clipPath": "circle(50%)",
    "layerCache": true,
    "overflow": "hidden"
  }
}
KeyMeaning
filterCSS filter stack: blur() brightness() contrast() saturate() grayscale() hue-rotate() opacity() drop-shadow() invert() sepia()
blur / filterBlurShorthand group blur radius (px)
contrast / hue-rotateShorthand standalone filter properties
dropShadowX / Y / Blur / ColorDirect properties for applying a drop-shadow filter
mixBlendModemultiply / screen / overlay / darken / lighten / difference / exclusion / color-dodge / color-burn / hard-light / plus-lighter / lighter when compositing the offscreen layer (lighter is the Porter-Duff alias of plus-lighter: min(1, Cs+Cb))
maskFade + maskFadeSizeSoft edge dissolve (top / bottom / left / right)
maskImagee.g. linear-gradient(to bottom, black, transparent)
clipPathcircle(50%) / ellipse(50% 40%) / inset(10px round 12px) / polygon(50% 0%, 100% 100%, 0% 100%) (optional evenodd / nonzero fill-rule)
layerCacheReuse the offscreen layer bitmap when content fingerprint is unchanged
overflow: "hidden"Clip children to the box (rounded when borderRadius is set)
tintRGB modulate on the subtree layer (Godot modulate / Phaser tint). Zero alpha = off
imageRenderingpixelated forces nearest-neighbour even on fractional scales (pixel art)

Transform (canvas)

Persistent visual transform — layout box is unchanged (Godot rotation / scale, Phaser setFlip):

{
  "style": {
    "rotate": 15,
    "scale": 1.2,
    "scaleX": 1,
    "scaleY": 1,
    "flipX": true,
    "flipY": false,
    "skewX": 12,
    "skewY": 0,
    "transformOrigin": "left top"
  }
}

Stacking (zIndex, canvas)

zIndex was already in render.KnownStyleKeys (HTML emits CSS z-index). The canvas backend now implements it: sibling paint and hit order.

{
  "type": "stack",
  "children": [
    { "id": "z_back", "type": "box", "style": { "zIndex": 1, "background": "#0a84ff" } },
    { "id": "z_front", "type": "box", "style": { "zIndex": 2, "background": "#ff375f" } }
  ]
}

Scroll snap

On a scroll / scrollview viewport:

{ "type": "scroll", "style": { "scrollSnapType": "y mandatory", "height": 320 }, "children": [
  { "type": "box", "style": { "height": 320, "scrollSnapAlign": "start" }, "children": [ … ] }
] }

Interaction + spring transition

{
  "style": {
    "pressedScale": 0.96,
    "hoverScale": 1.02,
    "hoverColor": "var(--label)",
    "hoverOpacity": 0.9,
    "pressedBackground": "var(--accent)",
    "pressedOpacity": 0.8,
    "focusBorderColor": "var(--accent)",
    "transition": "0.3s spring",
    "transitionEasing": "spring"
  }
}

hoverOpacity / pressedOpacity come from the full QSS cascade (type < class < id < inline), not only inline style

disabledOpacity (default 0.5) plus the not-allowed cursor

spring (overshoot then settle) on the canvas path

Game feedback FX (fx prop)

Canvas-only one-shots modeled on DOTween / Phaser / Godot:

{
  "fx": "hit",
  "fxToken": "{{ state.hits }}",
  "fxDuration": 320,
  "fxIntensity": 12
}
# actions/on_damage.qs — restarts the clip without remounting the node
state.hits = state.hits + 1

Names: shake, punch, flash/blink, hit, float/bob, wobble, knockback, burst. Full table: Animation — Game feedback FX.

Transition easings also accept game-engine names: backOut, elastic, bounce, quadOut, sineOut, expoOut, …

Property tweens also accept DOTween-style loops: transitionYoyo, transitionLoop, transitionRepeat.

Timeline sequence

DOTween Sequence / Godot Tween chain on any node — Append by default, "parallel": true Joins; bump timelineToken from qscript to replay:

{
  "timeline": [
    { "scale": 1.3, "duration": 180, "ease": "backOut" },
    { "dx": 48, "duration": 200, "ease": "easeOut", "parallel": true },
    { "wait": 80 },
    { "scale": 1, "dx": 0, "duration": 200, "ease": "easeInOut" }
  ],
  "timelineToken": "{{ state.tlPlay }}"
}

Full table: Animation — Timeline.

Also: path steps (polyline / cubic + orient), timelineOnComplete, list stagger (ms × index on entrance/fx/timeline), timeline { yoyo, loop }.

FLIP layout motion

{
  "id": "chip",
  "type": "box",
  "style": {
    "position": "absolute",
    "x": "{{ state.chipX }}",
    "y": 40,
    "layoutMotion": true,
    "transition": "0.35s"
  }
}

When layoutMotion is true, the node has a stable id, and transition is set, absolute position/size jumps ease instead of snapping (shared-element style). Demo: examples/canvas-fx "FLIP" chip.

Side-scrollers and tile worlds (board + tilemap)

Use a board as the world plane, not a row/column/list of tiles. The engine camera follows a target; tilemap bakes a char-grid + atlas into one world bitmap (cached until rows or a bump changes).

{
  "type": "board",
  "cameraTarget": "{{ state.mario }}",
  "cameraCenter": "x",
  "cameraCell": 32,
  "cameraViewport": 16,
  "cameraDeadZone": 160,
  "cameraLockLeft": true,
  "cameraResetToken": "{{ state.cameraGen }}",
  "cameraMax": { "x": 6240 },
  "disablePan": true,
  "children": [
    {
      "type": "tilemap",
      "id": "tiles",
      "rows": "{{ state.rows }}",
      "cell": 32,
      "bumpX": "{{ state.bumpCX }}",
      "bumpY": "{{ state.bumpCY }}",
      "bumpT": "{{ state.bumpT }}",
      "atlas": { "1": "assets/ground.png", "2": "assets/brick.png" }
    }
  ]
}
PropMeaning
cameraTargetObject with x/y in px (usually {{state.player}})
cameraCentertrue / "x" / "y"
cameraCell + cameraViewporte.g. 32 and 16 → 512 px follow window
cameraDeadZonepx; NES-style left band before the camera scrolls
cameraLockLeftnever scroll back (SMB). Pair with cameraResetToken
cameraResetTokenbump {{state.cameraGen}} on restart so lock-left rewinds
cameraMax{ "x": (levelW - viewportW) * cell }
disablePanno user drag-to-pan (games)

Actors stay as image / list children on the same board with absolute x/y. HUD lives outside the board (stack overlay). Do not tween physics x/y or put layoutMotion on 60 fps movers. Pixel art: set imageRendering: pixelated (a QSS image { … } rule is enough). Canonical app: examples/mario. Props: board / tilemap.

Diagnostics

Parse errors are load-time diagnostics naming BOTH the file and the line ([Stylesheet: app] app.qss:3: …). Unknown style keys (against render.KnownStyleKeys) are warnings. The rules parsed before and after an error still load, exactly like a scene keeps loading alongside its own diagnostics — one bad rule never blanks the app.

Loader contract

hard refusal from qorm build.

verbatim — the same fixed-point property component documents have.