Weva Authoring Guide

How to build UI with this package: HTML/CSS authoring patterns, controller binding, events, forms, gestures, and the runtime API. This is the manual for users of the package; engine internals live in AGENTS.md at the repo root.

For the supported HTML/CSS feature subset, see the package README.md and CONFORMANCE.md.


1. Five-minute quick start

  1. Drop a UIDocument into the scene. GameObject menu → Weva → New UIDocument creates a GameObject with a UIDocument, UnityInputController, IMGUIDocumentRenderer (debug mode), and DevToolsOverlay already wired.

  2. Author your UI. Two TextAssets (.html + .css) imported from Assets/UI/:

    <!-- Assets/UI/menu.html -->
    <link rel="stylesheet" href="menu.css" />
    <main class="menu">
      <h1>Welcome, {{ PlayerName }}</h1>
      <p>Coins: <span class="coins">{{ CoinCount }}</span></p>
      <button on-click="OnStart">Start</button>
      <button on-click="OnQuit" disabled="{{ !CanQuit }}">Quit</button>
    </main>
    /* Assets/UI/menu.css */
    .menu { display: flex; flex-direction: column; gap: 16px; padding: 24px; }
    button { padding: 8px 16px; border-radius: 8px; background: #4f46e5; color: white; }
    button:hover { background: #6366f1; }
    button:disabled { opacity: 0.5; cursor: not-allowed; }
    .coins { color: gold; font-weight: 600; }
  3. Wire a controller.

    using UnityEngine;
    using Weva;
    using Weva.Binding;
    
    public sealed class MainMenuController : MonoBehaviour {
        [UIBind] public string PlayerName = "Aerith";
        [UIBind] public int CoinCount = 0;
        [UIBind] public bool CanQuit = true;
    
        UIDocument doc;
        void Awake() { doc = GetComponent<UIDocument>(); doc.SetController(this); }
    
        public void OnStart() { CoinCount += 5; }   // mutating a [UIBind] field
        public void OnQuit()  { Application.Quit(); } // re-renders the {{ }} on its next read
    }

    Drop the script next to the UIDocument and assign the HTML/CSS TextAssets on the inspector. Press play.

That's the whole loop. Hot-reload picks up edits to the .html/.css without restarting play mode.

2. Data binding

Every [UIBind] field/property on the controller is reachable via {{ Name }} in HTML and CSS-attribute values. Bindings are polled once per frame from UIDocumentLifecycle.Update, write-only when changed, and the only frames that re-paint are ones where a binding actually flipped.

public class HUDController : MonoBehaviour {
    [UIBind] public int    HP;
    [UIBind] public int    MaxHP;
    [UIBind] public string PartyLeader;
    [UIBind] public bool   IsCriticalHP => HP < MaxHP / 4;
    [UIBind] public string HpPctStyle => $"--pct:{(HP * 100.0 / MaxHP):F0}%";
}
<div class="hud">
  <div class="bar hp" style="{{ HpPctStyle }}">
    <span class="num">{{ HP }} / {{ MaxHP }}</span>
  </div>
  <div class="leader" data-critical="{{ IsCriticalHP }}">{{ PartyLeader }}</div>
</div>
.bar.hp .fill { width: var(--pct, 0%); transition: width 0.2s ease; }
.leader[data-critical="True"] { color: #f87171; }

Notes:

Repeated lists can be authored with a template:

<ol class="stages">
  <template data-each="Stages as stage" data-key="Id">
    <li class="stage-card" data-class-selected="stage.IsSelected">
      <strong>{{ $index }}. {{ stage.Name }}</strong>
      <span>{{ stage.Score }}</span>
    </li>
  </template>
</ol>

data-each="<items> as <alias>" reads any IEnumerable binding and clones the template body once per item. data-key gives each clone stable identity so reorders reuse existing DOM nodes instead of clearing the container. Inside the template, {{ stage.Name }} resolves against the item, parent controller bindings still resolve normally, and $index exposes the zero-based item index.

Use data-class-<name>="BindingPath" for boolean class toggles. The binding adds or removes only that one class and leaves the rest of the element's class attribute intact.

For changes outside [UIBind] (programmatic DOM mutation), see §6.

3. Events

on-<event>="MethodName" attributes call methods on the controller:

<button on-click="OnStart">Start</button>
<input type="text" on-input="OnSearch" on-keydown="OnSearchKey" />
<form on-submit="OnLogin">…</form>

Method signatures:

public void OnStart() { … }
public void OnStart(PointerEvent e) { … }   // optional event arg
public void OnSearch(InputEvent e) { … }
public void OnSearchKey(KeyboardEvent e) { if (e.Key == "Enter") DoSearch(); }
public void OnLogin(SubmitFormEvent e) { … }

Recognized event kinds (see Runtime/Events/EventKind.cs): PointerDown / PointerUp / PointerMove / PointerEnter / PointerLeave / Click / KeyDown / KeyUp / KeyPress / Focus / Blur / Change / Input / Submit / Wheel / Scroll.

Standard CSS state pseudo-classes (:hover, :focus, :focus-visible, :active, :disabled, :checked, :placeholder-shown, :focus-within) flip automatically based on event-driven state — no controller code needed.

4. Forms

ElementBehavior
<input type="text"> / password / email / number / search / tel / urlTyped text input. IME integration via UnityImeBridge.
<input type="checkbox">Toggleable boolean. checked="" reflects state.
<input type="radio" name="g">Group-exclusive (one per name= selected).
<input type="range" min max step value>Slider with thumb-drag, click-track, keyboard arrows / PageUp / Home.
<input type="hidden">Form data only, no rendering.
<select> + <option>Single-select dropdown source. (Visible popup is ContextMenu — see §7.)
<textarea>Multi-line text input.
<button> / <button type="submit">Click target; submit triggers enclosing form.
<form on-submit="…">Captures Enter inside text inputs and submit clicks; collect via FormElement.GetFormData().
<dialog> / <dialog open>Modal / non-modal dialog. DialogElement.ShowModal() opens.
<details> / <summary>Collapsible group (UA stylesheet handles [open] toggle visuals; controller wiring TBD).
[popover] attributeLight-dismissable popup. PopoverController handles outside-click + Escape.

Programmatic access:

var range = doc.GetElementById("volume");
var rc = new Weva.Forms.RangeController(range, doc.Events, e => doc.CurrentState.ElementToBox.Lookup(e));
rc.Wire();
rc.ValueChanged  += () => Debug.Log($"slid: {rc.Value}");
rc.ValueCommitted += () => Save();   // pointer-up or keyboard step

Sliders, checkbox toggle, select option pick, etc. all dispatch standard input (per-change) and change (committed) events you can subscribe via on-input / on-change HTML attributes or dispatcher.AddEventListener(elem, EventKind.Input, listener) from C#.

5. Gestures (Manipulators)

Drag, right-click, long-press → high-level callbacks without writing the pointer-down/move/up plumbing yourself:

using Weva.Events.Manipulators;

var canvas = doc.GetElementById("paint-canvas");
var pan = new PanManipulator(canvas, doc.Events) { Threshold = 4 };
pan.PanStart += (sx, sy) => StartStroke(sx, sy);
pan.PanMove  += (dx, dy) => ExtendStroke(dx, dy);
pan.PanEnd   += (tx, ty) => EndStroke(tx, ty);
pan.Wire();

var slot = doc.GetElementById("slot-7");
var ctx = new ContextualMenuManipulator(slot, doc.Events) { LongPressSeconds = 0.5 };
ctx.MenuRequested += (x, y) => ShowSlotMenu(x, y);
ctx.Wire();

PanManipulator calls dispatcher.SetPointerCapture(target) so drags survive leaving the source element. ContextualMenuManipulator fires on right-click (PointerDown button 2), Shift+F10 keyboard shortcut, and long-press touch.

6. Programmatic DOM updates

Anything beyond polled [UIBind] fields:

// Get an element
var coins = doc.GetElementById("coins");
var slots = doc.GetElementsByClassName("slot");

// Mutate text
foreach (var c in coins.Children)
    if (c is Weva.Dom.TextNode tn) tn.Data = newValue.ToString();

// Mutate attributes (style, class, data-*, etc.)
slot.SetAttribute("style", $"--pct:{pct}%");
slot.SetAttribute("data-rarity", "epic");
slot.RemoveAttribute("disabled");

// Add/remove children
var newRow = new Weva.Dom.Element("div");
newRow.SetAttribute("class", "log-line");
newRow.AppendChild(new Weva.Dom.TextNode(message));
logContainer.AppendChild(newRow);

logContainer.RemoveChild(oldestRow);

DOM mutations fire bubbling Mutated events; the InvalidationTracker catches them and queues the affected stages. The "skip paint when idle" optimization correctly re-enables paint on the next frame.

7. Pop-ups, dropdowns, context menus

using Weva.Forms;

ContextMenu.Show(doc.Doc, doc.Events, doc.CurrentState.Invalidation, x, y, new[] {
    MenuItem.Item("Equip",      () => Equip(slot),  shortcut: "E"),
    MenuItem.Item("Inspect",    () => Inspect(slot)),
    MenuItem.Separator(),
    MenuItem.Item("Drop",       () => Drop(slot),   disabled: !canDrop),
});

Dismissed automatically on outside click, Escape, or item activation. ArrowUp/Down + Home/End + Enter navigate the menu via keyboard. Restyle by targeting .ui-menu, .ui-menu-item, .ui-menu-separator in your CSS.

8. Tooltips

Set title="…" on any element. Construct one TooltipManager per document at startup:

var st = doc.CurrentState;
var tt = new Weva.Forms.TooltipManager(st.Doc, doc.Events, st.Clock, st.Invalidation) {
    ShowDelaySeconds = 0.6
};
tt.Wire();

The manager injects a <div class="ui-tooltip">…</div> into the document when the pointer rests on a title-bearing element for the configured delay. Restyle via .ui-tooltip in your CSS.

9. Virtualized lists

Render only the visible window of a 100k-row data source:

using Weva.Forms;

var listHost = doc.GetElementById("inventory-list");
var st = doc.CurrentState;
var ctl = new VirtualListController<Item>(
    listHost,
    itemHeight: 32,
    elementToBox: e => st.ElementToBox.Lookup(e),
    scrollContainer: doc.LayoutEngine.ScrollContainer,
    tracker: st.Invalidation
) {
    Source = inventory,
    ItemTemplate = (i, item) => {
        var row = new Weva.Dom.Element("div");
        row.SetAttribute("class", "row");
        row.AppendChild(new Weva.Dom.TextNode($"{item.Name} ×{item.Count}"));
        return row;
    },
};

void Update() { ctl.Tick(); }   // call every frame from a MonoBehaviour

The host should be overflow: auto with explicit dimensions so it has a scroll viewport. Items must have a known fixed height. Variable-height support is a v2 follow-up.

10. Custom components

Reusable element templates with slots:

<template id="hp-pill">
  <span class="icon">♥</span>
  <span class="num"><slot></slot></span>
</template>
<hp-pill>{{ HP }}</hp-pill>
<hp-pill>{{ MaxHP }}</hp-pill>

Large components can live next to the document and be imported before the component registry scans the DOM:

<!-- menu.html -->
<template src="stage-card.html"></template>
<stage-card>Forest Gate</stage-card>
<!-- stage-card.html -->
<template id="stage-card">
  <article class="stage-card"><slot></slot></article>
</template>

If the importing template already has an id, its body is filled from the external file instead:

<template id="stage-card" src="stage-card-body.html"></template>

ComponentExpander expands <hp-pill> instances into the template's content with per-instance slot fills. Put component styles in the regular .css asset for v1:

hp-pill { display: inline-flex; align-items: center; gap: 4px; }
hp-pill .num { font-variant-numeric: tabular-nums; }

Inline <style> blocks inside templates are parsed as HTML but are not wired into the cascade yet. Code that registers a scoped stylesheet through ComponentRegistry.Register(tag, template, stylesheet) can use :host and the component selector scoper directly.

11. Layout patterns

Flex (most common)

.row     { display: flex; gap: 8px; align-items: center; }
.column  { display: flex; flex-direction: column; gap: 12px; }
.spacer  { flex: 1; }                     /* push siblings to the edges */

Grid

.toolbar {
  display: grid;
  grid-template-columns: auto 1fr auto;   /* left / center-stretch / right */
  align-items: center;
  gap: 12px;
}

Sticky positioning

.list-header {
  position: sticky;
  top: 0;
  background: var(--surface);
  z-index: 1;
}

Container queries (responsive without media queries)

.card-container { container-type: inline-size; }
@container (min-width: 320px) {
  .card { display: grid; grid-template-columns: 1fr 1fr; }
}
@container (max-width: 319px) {
  .card { display: flex; flex-direction: column; }
}

Anchor positioning

.tooltip {
  position: absolute;
  position-anchor: --slot-7;
  bottom: anchor(top);
  left: anchor(center);
  translate: -50% -8px;
}
.slot[data-id="7"] { anchor-name: --slot-7; }

12. CSS variables for runtime theming

:root {
  --color-primary: #4f46e5;
  --color-text: #f8fafc;
  --space-md: 12px;
}
button { background: var(--color-primary); color: var(--color-text); padding: var(--space-md); }

Mutate at runtime to retheme:

doc.GetElementsByTagName("html").First().SetAttribute("style", "--color-primary: #ef4444;");

13. Images and the image registry

<img> (and CSS background-image / border-image-source) reference assets through an image handle string — a stable address the engine looks up in an IImageRegistry your game owns. The HTML never names a file path or GUID directly; the registry decides what "ui/heart" actually resolves to.

Pick a registry

The package ships two implementations:

RegistryWhen to use
InMemoryImageRegistryTests, prototypes, or assets you load yourself and push in
AddressablesImageRegistryProduction — handles resolve to Addressables addresses; lazy load

Recommendation: for any shipping game with more than a handful of icons, use AddressablesImageRegistry. It loads sprites on first paint, retains ref-counted handles for the registry lifetime, and bumps Version so paint caches refresh when an async load completes.

Using AddressablesImageRegistry

  1. In Project Settings → Player → Other Settings → Scripting Define Symbols, add UNITYUI_ADDRESSABLES (the registry is compile-gated so projects without the Addressables UPM package still compile).

  2. Install com.unity.addressables via Package Manager.

  3. Mark sprites Addressable (right-click → Mark as Addressable) and pick short, stable addresses like ui/heart, SkillIcons/Stimpack. These strings go straight into your HTML.

    <img src="ui/heart" />
    <div style="border-image-source: url(ui/panel-frame); border-image-slice: 16;">
      ...
    </div>
  4. Wire it once per UIDocument:

    void OnEnable() {
        var doc = GetComponent<UIDocument>();
        doc.ImageRegistry = new AddressablesImageRegistry();
        doc.SetController(this);
    }

That's it — every <img> and every CSS url(...) reference now lazy-loads through Addressables. No Register(...) calls per asset, no manual LoadAssetAsync boilerplate.

Mixing Addressable and non-Addressable sources

For sprites that don't live in Addressables (e.g. a runtime-generated texture, or a sprite from a ScriptableObject manager), call Register on the same registry — it accepts pre-loaded sources too:

var registry = new AddressablesImageRegistry();
// Manually-loaded sprite — bypasses Addressables for this handle:
registry.Register("ui/runtime-thumbnail", new SpriteImageSource(thumbnailSprite));
// Other handles still load lazily through Addressables:
// <img src="ui/heart"> → AddressablesImageRegistry.LoadAssetAsync("ui/heart")

Preloading

For known-up-front asset sets (main menu icons, all skill icons), preload to avoid the first-paint flash:

await registry.PreloadAsync(new[] { "ui/heart", "ui/star", "ui/coin" });

9-slice frames

When a Sprite is configured with border values in the Sprite Editor (green handles in the inspector preview), an <img> referencing that sprite automatically paints as 9 sub-quads — corners stay at source-pixel size, edges and center stretch. No CSS needed. Combine with object-fit: fill if you don't want 9-slice; without it the auto path fires when the sprite has borders.

For CSS border-image, the sprite's border supplies the slice values when border-image-slice is omitted or set to 100% fill. See Assets/UI/9slice-demo.html for a side-by-side demo.

14. Performance

15. DevTools (F12)

Press F12 in play mode for an IMGUI overlay with four modes (cycle with keys configured on the overlay component):

There's also Window → Weva → DevTools for editor-time inspection without entering play mode.

16. Hot reload

Edit a .html or .css TextAsset while play mode is running; the watcher picks it up, reparses, and rebuilds without a domain reload. Controller state and [UIBind] field values survive the reload.

For programmatically constructed UI (no source .html file), doc.Rebuild() re-runs the pipeline from scratch.

17. CSS features intentionally not implemented

A handful of spec features parse without error but won't be wired up — they target prose layouts or browser scenarios that game UI doesn't reach. If your stylesheet uses them, the declarations stay valid but have no visible effect.

Partial support — accepts the syntax, behaves differently than the spec

These features parse cleanly so author stylesheets stay valid, but the run-time semantics are reduced. Pin the listed values if your design depends on the spec behavior.

If you need one of the strict "not implemented" items above, file an issue with a use case — they were excluded based on game-UI authoring patterns, not technical impossibility. Partial-support items are tracked for future expansion.

18. Focus & controller (gamepad) navigation

Keyboard focus — works out of the box

When a WevaDocument has the built-in input controller attached (added automatically by WevaDocument.OnEnable, or explicitly as Weva.Forms.Bridge.UnityInputController), Tab / Shift+Tab move focus through the document with no code:

.btn:focus { box-shadow: 0 0 0 3px #36e0ff; }   /* shows where the pad landed */

Activating the focused control

Pointer clicks fire Click automatically. To activate the focused element from a key or gamepad button, dispatch a synthetic click at its centre — this drives :active and any on-click handler:

var f = doc.Events.FocusedElement;
var box = doc.CurrentState.ElementToBox.Lookup(f);   // layout box
// accumulate absolute centre from box.X/Y up the parent chain, then:
doc.Events.DispatchPointerDown(cx, cy, 0, default);
doc.Events.DispatchPointerUp(cx, cy, 0, default);

Gamepad / directional navigation — you wire it

Spatial (d-pad / stick) navigation is not automatic. Create a Weva.Events.DirectionalNavigation for the document and feed it input each frame. It picks the nearest focusable in a direction using the elements' layout rects:

using Weva.Events;

var nav = new DirectionalNavigation(doc.Events, doc.Doc, NavRectOf) {
    // Skip display:none / visibility:hidden (e.g. a closed menu's items).
    IsHidden = e => { var s = doc.Cascade?.GetComposedStyle(e, doc.State);
                      return s != null && (s.Get("display")=="none" || s.Get("visibility")=="hidden"); }
};
doc.Events.IsHidden = nav.IsHidden;   // make Tab honour the same hidden test

// Each frame, from your input source (new Input System shown):
var pad = UnityEngine.InputSystem.Gamepad.current;
if (pad != null) {
    var v = pad.dpad.ReadValue();
    if (v.x >  0.5f) nav.MoveFocus(NavDirection.Right);
    if (v.x < -0.5f) nav.MoveFocus(NavDirection.Left);
    if (v.y >  0.5f) nav.MoveFocus(NavDirection.Up);
    if (v.y < -0.5f) nav.MoveFocus(NavDirection.Down);
    if (pad.buttonSouth.wasPressedThisFrame) /* activate focused — see above */;
}

// NavRectOf maps an element to its absolute layout rect:
NavRect? NavRectOf(Element e) {
    var b = doc.CurrentState?.ElementToBox?.Lookup(e); if (b == null) return null;
    double x = 0, y = 0; for (var n = b; n != null; n = n.Parent) { x += n.X; y += n.Y; }
    return new NavRect(x, y, b.Width, b.Height);
}

Add edge-repeat (hold-to-repeat with a delay) for held directions, and call doc.Events.Focus(firstButton) once so the first input has an anchor.

Full working reference: Assets/inputtest.unity + Assets/Scripts/InputTestController.cs — a focus test bench with d-pad/stick/arrow nav, A/Enter/Space activate, B/Esc to close an overlay menu, and a focus-trap.

19. Where to look next