agentleFS
decentralanddecentraland/sdk-skills/build-ui/SKILL.md

build-ui

Skill4 starsChanged 10 days agoApache-2.0View on GitHub

Build 2D screen-space UI for Decentraland scenes with React-ECS: HUDs, menus, dialogs, health bars, form inputs, sliders. Use for any on-screen UI, UI editable in the Creator Hub UI Designer, or adapting existing coded UI for the editor. For 3D in-world text see advanced-rendering; for clickable 3D objects see add-interactivity.

8 other projects use a version of this.

Adopted by

Most recently taken up by

Used with

Projects with it also use create-auth and better-auth-best-practices.

Other files people use alongside this one.

Files found in the same public projects as a version of this one, more often than they turn up anywhere else. Each project counts once, large collections count for less, and this file's own repository is left out.
  • wdchenxyzSkill

    create-auth

    Scaffold and implement authentication in TypeScript/JavaScript apps using Better Auth. Detect frameworks, configure database adapters, set up route handlers, add OAuth providers, and create auth UI pages. Use when users want to add login, sign-up, or authentication to a new or existing project with Better Auth.

    Also in 3 projects that use this

  • anasahmed07Skill

    better-auth-best-practices

    Skill for integrating Better Auth - the comprehensive TypeScript authentication framework.

    Also in 3 projects that use this

The file

Show its rules and text

We split this skill into the rules it gives an agent

24 rules. 0 are common, 5 are distinctive, and 19 are very specific to this project.

  • 0

    Common

    Most of the 8 edited versions we read keep these.

  • 5

    Distinctive

    This author's own choices. Worth a look.

  • 19

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Write to the contract below and neither happens.

    line 53
  2. Very specific:

    Create src/ui/interaction.tsx and (for platform variants) src/ui/platform.tsx verbatim from {baseDir}/references/interaction-helper.md.

    line 75
  3. Very specific:

    Write each component to The contract.

    line 76
  4. Very specific:

    Write src/ui/index.tsx in the exact generated shape below and call setupUi() from main() in src/index.ts.

    line 77
  5. Very specific:

    Put every clock, easing, timer, formatter and state machine in a .ts file outside src/ui/ — see The driver pattern.

    line 78
  6. Distinctive:

    Run the Self-check list over every file.

    line 79
  7. Very specific:

    Only five element names are modeled: UiEntity, Label, Input, Dropdown, Button — plus a reference to another src/ui/ file (<KitToast />), …

    line 117
  8. Very specific:

    No React hooks (useState, useEffect) in scene UI.

    line 150
  9. Very specific:

    Visibility is always an active display gate: Never {state.visible && <X/>} (opaque) and never display: state.visible ?

    line 203
  10. Very specific:

    No color, texture-set or children prop.

    line 219
  11. Very specific:

    Every Label (and Button text) declares a uiTransform width AND height.

    line 304
  12. Very specific:

    Every bound size or position is a plain px number — no arithmetic, no percent strings.

    line 305
  13. Very specific:

    Use hoverText on interactive entities as the just-in-time instruction — it appears exactly when it is relevant and costs no reading …

    line 335
  14. Very specific:

    Use an image/diagram (a uiBackground texture or an in-world plane) for spatial or sequence rules.

    line 336
  15. Distinctive:

    When Choosing the mode sends a UI to coded, everything above about elements, sizing, placement and pointer blocking still applies.

    line 363
  16. Very specific:

    Only one setUiRenderer per scene — a second call silently overwrites the first.

    line 368
  17. Very specific:

    Run over every .tsx under src/ui/ you write or adapt.

    line 413
  18. Very specific:

    Every JSX element name is UiEntity, Label, Input, Dropdown, Button, or a component exported by another src/ui/ file.

    line 416
  19. Very specific:

    Every value inside uiTransform / uiBackground is a literal or a bare state.x / props.x.

    line 420
  20. Very specific:

    All text is on Label / Button value.

    line 421
  21. Distinctive:

    Every template literal in a prop interpolates only bare references.

    line 422
  22. Very specific:

    Every handler is a / @ui-action */ function taking ({ state, props, value }: UiAction), wired through a thunk.

    line 428
  23. Distinctive:

    All bound sizes/positions are px numbers.

    line 429
  24. Very specific:

    No clock, Date.now(), setTimeout, easing, rounding or string formatting anywhere in src/ui/ — it lives in the driver.

    line 431

“Common” is measured: kept by at least 6 in 10 of the 8 edited versions of this file we could read. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Building UI with React-ECS

Decentraland SDK7 draws 2D overlays with a React-like JSX system (@dcl/sdk/react-ecs). There are two ways to write that code, and the editable way is the default:

ModeWhat it isWhen
Editable (default)React-ECS written to the contract the Creator Hub's 2D UI editor (UI Designer) can parse, render on a canvas and write back: one src/ui/<Component>.tsx per component, state/props bindings, useInteraction layers, @ui-action handlers, animation in a driver outside src/ui/Every HUD, menu, dialog, toast, notification, scoreboard, settings panel, health bar, timer, slider — anything a designer may later restyle without touching code
Coded (corner case)Free-form React-ECS: .map() over data, conditional subtrees, computed styles, helper components, any file layoutOnly when the UI is inherently data-driven or dynamic in a way the contract cannot express — see Choosing the mode. Details: {baseDir}/references/coded-ui.md

Both are ordinary React-ECS and run anywhere; the editable contract is a subset. Do not ask the user which mode they want: pick editable unless the UI hits a coded-only trigger, and say so when it does.

When to use which UI approach

NeedApproachSee
Screen-space HUD, menus, buttons, inputsReact-ECS (this skill)below
3D text floating in the worldTextShape + Billboardadvanced-rendering
Open a web pageopenExternalUrlscene-runtime
Clickable objects in 3D spacepointer eventsadd-interactivity
On-screen buttons that fire InputActions (mobile)uiInputBinding on a UiEntity + TouchScreenControls{baseDir}/references/ui-components.md, advanced-input
A custom font (fontSrc) on any textLoad fonts before writing it: its first rule is an SDK-version check (fontSrc needs 7.31.0+ and fails silently below), and it says which clients show the fontfonts
Login / nickname entry, chat box, friends list, emote menu, avatar dress-up, loading screenDon't build it — the Explorer already provides each one; build a scene version only if the design needs a different one{baseDir}/../create-scene/references/platform-provided.md

Choosing the mode

Go editable by default. Send a UI — or just one panel of it — to coded only when it needs one of these, which the editor has no representation for:

  • Rows built from a runtime collection (.map() over players, inventory items, chat messages) where the count is unknown at authoring time. A fixed set of up to a handful of rows is unrolled by hand and stays editable.
  • Per-call arbitrary content: a function rendering different structure or a set of alternative textures per call (showPopup(title, body, imageSrc) with N images). One text slot per value and one texture.src per image is fine editable.
  • Generic wrappers with children/slots (<Card>{children}</Card>) — component refs take no children.
  • Per-row draft objects, per-row closures, remount hacks for uncontrolled inputs.

Almost everything else that looks dynamic — fill bars, timers, eased motion, auto-hide, canvas-responsive px sizes, spinners — is a driver-maintained state variable and stays editable.

If only one panel needs coded, keep the rest editable and mount the coded panel separately with ReactEcsRenderer.addUiRenderer from a module outside src/ui/ ({baseDir}/references/coded-ui.md → Hybrid scenes). Tell the user which part is not editable and why. When adapting an existing coded UI, expect the result to be larger (unrolled loops and variants are the price of editability); report that trade-off rather than half-porting.

Prerequisite: @dcl/sdk 7.26.0+ — the version that ships ScreenInsetArea / InteractableArea and the per-device default virtual screen. The UI editor is always on in the Creator Hub. Below 7.26.0, entering 2D mode shows an SDK-upgrade notice with an Update SDK button instead of the canvas.

How the editor reads code

The editor has no saved format of its own: the scene's real .tsx files directly under src/ui/ are the document. It parses them into a node tree, renders that on a canvas, and writes visual edits back as minimal text splices, immediately (the badge reads "All changes saved"; there is no manual save). A 1 s disk watcher reflects external edits, so the file is live while the editor is open. "Editable" is therefore a checkable property of the code. Code the parser cannot statically understand degrades silently:

DegradationTriggerEffect
Frozen nodeany uiTransform / uiBackground value, or any Label/Input/Dropdown prop, that is neither a literal nor a bare state.x / props.x referencenode renders on the canvas, but the panel refuses every edit on it
Opaque nodeunknown element name, a spread other than one useInteraction const, conditional/logical/.map() children, JSX commentsgrey read-only block; its children are not walked, so the subtree disappears from the canvas

Write to the contract below and neither happens.

What the editor writes and shows (layout Flow/Free axes, the Full Screen preset, Scene Inset, Opacity naming, canvas tools, mode persistence, the Bevy freeze, the mobile preview and its limits): {baseDir}/references/editor-behavior.md. Read it before hand-writing layout the editor will edit.

Setup (editable)

File layout

src/ui/
  index.tsx        <- GENERATED aggregator. Never hand-edit.
  interaction.tsx  <- reserved helper (useInteraction). Verbatim.
  platform.tsx     <- reserved helper (usePlatform). Verbatim.
  MyHud.tsx        <- a top-level UI root (rendered by the aggregator)
  KitToast.tsx     <- a reusable component (marked /** @ui-component */)
src/ui-behaviors.ts  <- driver: outside src/ui/, never parsed by the editor
src/mobile-hud.ts    <- GENERATED by the MobileHUD panel. Editor-owned. Never hand-edit.

Steps for a new UI:

  1. One component per file, src/ui/<ComponentName>.tsx. Basename must be a valid PascalCase identifier equal to the exported function name — the editor ignores any other file. Exactly one exported component per file (the editor reads the first exported function that returns JSX). Every file starts with /** @jsx ReactEcs.createElement */ and imports ReactEcs from @dcl/sdk/react-ecs. // comments only — {/* JSX comments */} are opaque.
  2. Create src/ui/interaction.tsx and (for platform variants) src/ui/platform.tsx verbatim from {baseDir}/references/interaction-helper.md. The lowercase names keep them out of the roots list; the editor scaffolds them itself and never lists them as UIs.
  3. Write each component to The contract. Start from {baseDir}/references/component-template.md.
  4. Write src/ui/index.tsx in the exact generated shape below and call setupUi() from main() in src/index.ts. (The editor does this wiring itself — it uncomments the template's //setupUi() line and adds the import — but when authoring by hand, do it yourself.)
  5. Put every clock, easing, timer, formatter and state machine in a .ts file outside src/ui/ — see The driver pattern.
  6. Run the Self-check list over every file.

A legacy single-file src/ui.tsx is backed up to src/ui.tsx.bak and deleted the first time the editor opens the scene — always author under src/ui/. To port an existing coded UI, follow {baseDir}/references/adapting-coded-ui.md (the blockers in order: .map() → unroll; conditionals → active display gates on the element being hidden; computed styles → driver variables; interpolated text → segment bindings; hand-tracked hover → layers; then explicit boxes everywhere).

The aggregator (src/ui/index.tsx)

Generated from the list of top-level roots and rewritten whenever the editor opens the scene or a root is added, renamed, removed or re-inset — any hand edit is lost. Emit exactly:

/** @jsx ReactEcs.createElement */
import ReactEcs, { UiEntity, ReactEcsRenderer, ScreenInsetArea } from '@dcl/sdk/react-ecs'
import { MyHud } from './MyHud'

export function setupUi() {
  ReactEcsRenderer.setUiRenderer(() => (
    <UiEntity uiTransform={{ width: '100%', height: '100%' }}>
      <ScreenInsetArea>
        <MyHud />
      </ScreenInsetArea>
    </UiEntity>
  ))
}

Screen inset is a per-root choice (the editor's Scene Inset dropdown); device is its default:

Editor insetWrapper in index.tsxIntended for
device (default)<ScreenInsetArea>normal scene UI — constrained to the device safe area (notch, status bar, home indicator, rounded corners); desktop insets are zero
interactable<InteractableArea>UI that must also avoid the explorer's own on-screen controls (minimap, chat, left-side controls)
nonebare <Component />letterbox bars, full-screen backdrops, deliberately drawing where platform UI lives

setUiRenderer is called with no options, so the scene gets the SDK platform default virtual canvas — desktop 1920x1080, mobile 1600x720 — and the renderer-level screenInset default 'device'. Never hand-add virtualWidth/virtualHeight (regeneration drops them); design px values against those two canvases. This is the one place where the coded-UI rule "always pass the virtual size explicitly" cannot be honored. Hand-tuning safe-area placement on mobile: the aggregator's wrapper nests inside the renderer's own inset ({baseDir}/references/editor-behavior.md → Scene Inset).

Both wrapper elements belong only in index.tsx — inside a component file their names are unknown to the parser and make the subtree opaque.

Elements

Only five element names are modeled: UiEntity, Label, Input, Dropdown, Button — plus a reference to another src/ui/ file (<KitToast />), the sanctioned reuse unit. Any other element name (a local helper component, a library component, ScreenInsetArea) is opaque. Full prop tables: {baseDir}/references/ui-components.md.

  • UiEntity — container. uiTransform (width, height, min/max, positionType, position, display, flexDirection, justifyContent, alignItems, alignContent, alignSelf, flexWrap, flexGrow, overflow, padding, margin, opacity, zIndex, borderWidth/Color/Radius, pointerFilter), uiBackground (color, texture, textureMode, textureSlices, uvs, avatarTexture), uiInputBinding, and the four listeners onMouseDown / onMouseUp / onMouseEnter / onMouseLeave.
  • Label / Button — the only places text lives: value, fontSize, textAlign, color, font, textWrap; Button adds variant ('primary' | 'secondary'), disabled, onMouseUp. A uiText={{…}} bag on a UiEntity is not modeled — restructure it as a Label child (coded UI may still use it). value accepts simple markup like <b>.
  • Input — placeholder, value, color, placeholderColor, disabled, textAlign, font, fontSize, onChange, onSubmit. Not a React controlled component — see Gotchas.
  • Dropdown — acceptEmpty, emptyLabel, options (string[]), selectedIndex, disabled, color, textAlign, font, fontSize, onChange(index).
  • Component ref <KitToast … /> — selectable, movable, per-instance editable props. Cannot be moved or sized from outside: give each instance a wrapper UiEntity with explicit width/height and its margin/position. Accepts no nested JSX children.

The four listeners are the complete set of UI event handlers, each () => void: no onMouseDrag/onMouseMove, no event object, no pointer coordinates, all hardcoded to InputAction.IA_POINTER. Drag interactions still work via PrimaryPointerInfo.screenDelta — {baseDir}/references/drag-slider.md.

The contract

State: the binding surface

export interface State {
  score: number
  label: string
  visible: boolean
  panelWidth: number
  labelColor: { r: number; g: number; b: number; a: number }
  options: string[]
}
export const state: State = {
  score: 0, label: '00.00', visible: false, panelWidth: 320,
  labelColor: { r: 1, g: 1, b: 1, a: 1 }, options: ['A', 'B'],
}

Module-level export interface State + export const state: State is the recognized signature; every property is an editable variable. Types: number, string, boolean, Color4 (annotate structurally as { r; g; b; a } — matched by having r/g/b members, not by the name Color4) and string[]. A number[] (e.g. a uvs quad) binds and works but the panel mislabels it as a string list.

  • state is a plain exported object — that is what lets the driver mutate it.
  • Module state is shared by every instance of a file. Per-instance values live in the parent and arrive as props.
  • No React hooks (useState, useEffect) in scene UI; state is module-level. The UI re-renders every frame, so mutations show immediately.

Style bindings

Any uiTransform / uiBackground key whose value is a bare reference — state.x or props.x, no operators, calls or concatenation — is an editable binding:

uiTransform={{ width: state.panelWidth, position: { top: state.panelTop }, borderColor: state.frameColor }}
uiBackground={{ color: state.panelColor, texture: { src: state.iconSrc } }}

Recognized positions: top-level keys, members of the edge groups (position / margin / padding → { left: state.x }), whole groups (borderColor: state.c), and the dotted paths texture.src / avatarTexture.userId. Literal and bound siblings mix freely; nesting is one level deep, as in react-ecs itself. Element props bind the same way: value={state.label}, color={state.labelColor}, fontSize={state.size}, selectedIndex={state.i}, options={state.options}.

Mixed text

A template literal whose interpolations are all bare references round-trips as ordered literal/binding segments:

<Label value={`Score: <b>${state.score}</b>`} … />
<Label value={`${state.mins}:${state.secs}`} … />
<Label value={`env: ${state.realm}\nplayers: ${state.count}`} … />

One computed interpolation (${state.a + 1}, ${fmt(state.t)}) freezes the node — format in the driver, interpolate the finished value. (Every real Label also carries an explicit uiTransform box — see Sizing and mobile.)

Actions (event handlers)

type UiAction = { state: State; props: Parameters<typeof MyHud>[0]; value?: unknown }

/** @ui-action */
function openPanel({ state }: UiAction) {
  state.visible = true
}

Wire with the canonical thunk onMouseDown={() => openPanel({ state, props })}, or onChange={(value) => setName({ state, props, value })} for value-bearing events. Recognized: onMouseDown, onMouseUp, onMouseEnter, onMouseLeave, and onChange / onSubmit on Input / Dropdown. Handler bodies are free-form code; an unrecognized handler expression (an inline arrow with a block body) is simply not shown as bound and does not freeze the node. Actions mutate state synchronously — anything time-based is a flag the driver picks up next frame.

Interaction layers

useInteraction is the recognized construct for per-state styling. Layers deep-merge in precedence base → active → hover → press; the second argument drives active and may be any expression (stored verbatim).

const panel = useInteraction(
  {
    base: { uiTransform: { display: 'flex', width: 320, height: 200 } },
    active: { uiTransform: { display: 'none' } },
  },
  state.visible !== true,
)
return <UiEntity {...panel}>…</UiEntity>
  • Visibility is always an active display gate. Never {state.visible && <X/>} (opaque) and never display: state.visible ? 'flex' : 'none' (frozen). Elements are hidden, not unmounted — the canvas renders the base layer, so all states appear stacked while editing; that is expected.
  • Hover/press feedback is always a hover / press layer, never hand-tracked booleans on onMouseEnter / onMouseLeave.
  • {...someInteractionConst} is the only spread the parser accepts. Extra attributes may sit beside it (<UiEntity {...panel} onMouseDown={…}>) and win over it.
  • The spread carries all four pointer listeners unconditionally — even for a base + active visibility gate. Put the gate on the panel, the smallest element the decision applies to, never on a full-screen wrapper — see Pointer blocking.
  • For UI that animates out, use two variables: visible (intent, flipped by actions) plus hidden (render gate, set by the driver only after the exit animation). Gate on state.hidden === true.

Component props

/** @ui-component */
export function KitToast(props: { message?: string; fillPx?: number; on?: boolean; onClose?: (value?: unknown) => void }) {
  • The marker before the exported function makes the file a reusable component (rendered only where another root nests it). Without it, the file is a top-level root the aggregator renders.
  • Props are an inline object type on the single props parameter; supported types number, string, boolean, callback (value?: unknown) => void. Anything else shows read-only. Always declare props optional.
  • Inside, props.x joins the binding surface: style keys, text values, the active expression (props.active === true).
  • No color, texture-set or children prop. A variant prop that picks a color is not expressible — unroll every visual variant as siblings gated with active display layers ({baseDir}/references/component-template.md §6).
  • value={props.label} fails strict TS (string | undefined); use value={`${props.label}`} — still a recognized binding (renders undefined if a parent omits the prop).
  • Forward a child's callback up with a one-line action: /** @ui-action */ function forwardClose({ props }: UiAction) { props.onClose?.() }.

Platform variants — the only structural conditional

const platform = usePlatform()
return platform === 'mobile' ? <PhoneMenu /> : <DesktopBar />

Recognized at the component's return and as a JSX child; !==, reversed operands and an inline usePlatform() === 'mobile' all parse. Both branches must be a single JSX element or the literal null, and at least one must be an element. Backed by the reserved src/ui/platform.tsx. Use it for genuinely different structure per device (a bottom sheet instead of a side rail); per-property overrides are not modeled — the virtual canvas already scales proportionally.

What is never expressible

Not expressibleSubstitute
loops / .map() over dataunroll every element by hand; factor a row repeated more than ~3× into its own @ui-component file
shared theme constants (color: THEME.primary)inline { r, g, b, a } literals at every site — any identifier in a style object freezes the node; a palette change is a find-and-replace
computed style values (arithmetic, Math.*, calls, concat, ternaries)one driver-maintained state variable per derived value
percent-string bindings (width: state.pct + '%')bind a px number; static percent literals (width: '90%') are fine
conditional element props (disabled={state.i === -1})a pre-computed boolean state variable
local helper components in the same filea separate src/ui/ file marked /** @ui-component */
children/slots on a componentkeep the layout inline in the screen file; factor out leaf widgets only
a color or texture-set as a propunroll the variants inside the component (texture.src can bind to a string prop; uvs cannot)
data-driven rows with per-row drafts and closuresnot portable — coded UI in its own module outside src/ui/

Nine-slice backgrounds (textureMode: 'nine-slices' + textureSlices), literal 8-float uvs atlas crops, overflow: 'scroll' (static rows still scroll), pointerFilter, border props and the whole flex model round-trip.

The driver pattern

The editor never parses files outside src/ui/, and state is a plain exported object: the editor owns structure, style and rest values; a driver owns the clock and the math.

// src/ui-behaviors.ts — outside src/ui/, invisible to the editor
import { engine } from '@dcl/sdk/ecs'
import { state as panel } from './ui/MyPanel'

const OPEN_WIDTH = 480
let anim = 0

export function registerUiBehaviors() {
  engine.addSystem((dt: number) => {
    const target = panel.visible ? 1 : 0
    anim = Math.max(0, Math.min(1, anim + (target > anim ? dt : -dt) / 0.25))
    const t = 1 - Math.pow(1 - anim, 3) // easeOutCubic
    panel.panelWidth = Math.max(1, Math.round(OPEN_WIDTH * t))
    panel.textColor.a = t
    if (target === 0 && anim <= 0) panel.hidden = true // release the display gate
  })
}

Register it from main() next to setupUi(). Every bound variable's initial value in state is its designable rest state — capture it at registration and animate around it, so a designer can restyle from the panel without touching the driver. Belongs in the driver: clocks, tweens, easings, timers and auto-hide deadlines (timers.setTimeout from @dcl/sdk/ecs, never the JS global), padStart / rounding / text formatting, state machines, derived values (px from percent, canvas-responsive sizes from UiCanvasInformation), drag accumulation, TouchScreenControls toggles, anything reading the ECS. The animation itself has no editor representation — the editor sees a bound key and its rest value only. Full examples: {baseDir}/references/driver-pattern.md.

Common widgets

Build every widget from the primitives — there is no widget library. Editable templates first; coded fallbacks in {baseDir}/references/coded-ui.md.

AskEditable pattern
Dialog / prompt / confirmationfull-screen positioning wrapper + gated panel + buttons: {baseDir}/references/component-template.md §4 (add a second button for accept/reject)
Toast / notification / popupKitToast component (§2) with visible prop; auto-hide is a driver countdown (driver-pattern.md, *AutoHide)
Timed announcement / flash messagea gated panel with one Label; the driver decrements hideIn and flips visible
Score / counter / timer readoutmixed-text Label (§3); the driver formats the string (driver-pattern.md §2)
Health / progress / fill barKitProgressBar (§5): the driver writes fillPx from a percent against the literal track width
Menu / button with hoverhover / press layers (§3)
Settings form (Input / Dropdown)@ui-action with value
Slider / scrub bar / drag handle{baseDir}/references/drag-slider.md (tap-to-step zones + drag, gated release catcher)
Full-screen modal that pauses playgated panel + driver toggling TouchScreenControls (see Placement)
Inventory grid / list of unknown lengthcoded — coded-ui.md

Sizing and mobile

Every box is explicit. Text intrinsic sizing and auto-sizing from children are unreliable across the three surfaces that render this code:

SurfaceUnset Label dimension / auto-sized parent
Bevy explorermeasures rendered text and feeds it into flex layout — looks correct
Unity explorercontributes ~0 to layout; glyphs still draw on the zero-height node → stacked labels overlap, parents collapse to padding
Creator Hub canvasa component root or ref wrapper with an unset dimension renders as 0 (collapsed instance, panel reads height 0) even though Yoga lays it out at runtime

Verified in-world with side-by-side screenshots: a 720-px dialog whose two labels had width: '100%', textWrap="wrap" and no height, in a panel with no height, was correct on Bevy and squashed on Unity — both labels drawn over each other, the panel collapsed to padding + button. Fix: panel height: 210, name label height: 30, wrapped body label height: 60, button label 100%/100%. A correct preview on one surface proves nothing about the others. Rules:

  • The root of every /** @ui-component */ file declares width AND height (px numbers or percent literals). So does every wrapper UiEntity around a component ref, matching the component's root size — a wrapper carrying only margin / position collapses identically.
  • Every Label (and Button text) declares a uiTransform width AND height; every container that stacks labels has an explicit height. Wrapped text: height = line count × line height (two lines at fontSize: 20 → 60). A label filling a fixed parent uses 100% / 100%.
  • Every bound size or position is a plain px number — no arithmetic, no percent strings. Static percent literals in unbound keys are the right tool for fluid layout.
  • No aspect-ratio property. UiTransform has no aspectRatio (not in the protocol, the SDK types or either client's layout mapping), height: '50%' is half the parent's height, and a texture gives a UiEntity no intrinsic size: 'auto' follows children, not the image, on both clients. For a fixed-ratio image set both axes in px on the virtual canvas (height = width × ratio), or derive the px width from the container's known px width — never a percent width with an 'auto' height.
  • Design against desktop 1920x1080 and mobile 1600x720. The mobile canvas is 33% shorter: anchor to edges and use flex/percent for the fluid axis rather than absolute offsets computed for one height. Start from desktop sizes and scale up only what measures small on a device: on 7.26.0+ pixel UI is already ~2–3× larger on a phone, and the mobile canvas adds ~1.2×. Those sizes are minimums: on another aspect ratio the 100% root grows on the looser axis (1920×1200 on a 16:10 display), so build full-screen art cover-fit around a centred stage of the virtual size ({baseDir}/references/ui-components.md → Setup).
  • Touch targets ≥ ~48 px on the virtual canvas; body text fontSize ≥ 16, ≥ 20 for anything read while moving; textWrap="wrap" plus an explicit width on any label that can grow.
  • Full-screen or large UI flashes (damage, hit, transitions): at most 3 per second, no saturated red full-view flash, and a way to turn intense effects down — lighting-environment → RULE: Photosensitivity.
  • No emoji or decorative Unicode in any text value (Label / Button value, uiText.value, Input placeholder, Dropdown options, bound strings). Glyph coverage comes from the fonts each explorer bundles, not the SDK, and the Unity explorer ships no emoji glyphs — verified in-world: value="✨ Particles" lost its sparkle. Use a small UiEntity with uiBackground={{ texture: { src: 'images/icon.png' } }} beside the label, or an atlas sprite via uvs; texture.src is a binding, so the icon stays editable. Stick to ASCII plus the accented letters the copy needs.
  • Texture src paths are relative to the scene root ('images/panel.png'), not src/.
  • On mobile (app 1.10.1+), borderRadius and borderWidth accept pixels only; percentages have no effect. Older mobile apps draw square corners. Hover layers do nothing on touch — never the only affordance or the only way to read a value.
  • Translucent colours on built-up shapes band on mobile. A chamfered or rounded shape assembled from overlapping UiEntity pieces (rectangles plus triangle images, the workaround for fixed-size nine-slice corners) blends a translucent uiBackground.color once per overlap, so the overlaps show as lighter bands on the Godot client (observed 2026-11: a 4 % white fill with a 25 % white edge). Use opaque colours pre-blended against the known background, or tile the pieces edge to edge. Unity was not checked with overlapping pieces.
  • Testing on the mobile client: the godot-explorer repo ships a mobile-dev-debug-tool skill (.claude/skills/mobile-dev-debug-tool) that drives a desktop debug build of the app, and a phone over the scene-inspector deeplink: scene state, logs, screenshots and taps. A production app opened by deeplink gives scene state and logs only, no screenshots or input (observed 2026-11). The preview's interactable area may also be smaller than production's ([UNVERIFIED]): {baseDir}/references/ui-components.md → InteractableArea.

Placement: never anchor to the top-left; keep mobile UI off the action buttons

The most common layout mistake in generated scenes is a panel pinned to the top-left (position: { top: 0, left: 0 }, or a full-canvas root whose children flow from the origin). That corner — and the whole left edge on desktop — is where the explorer draws its own UI (minimap, chat, and on mobile the virtual joystick). Scene UI there renders underneath the client HUD: occluded, buttons frequently unclickable, and nothing warns. In order of preference:

  1. Small HUD (score, timer, a couple of buttons) → anchor to the right edge or center it: positionType: 'absolute', position: { top: 40, right: 40 }, or justifyContent: 'center' on the root. Never leave a panel at the origin; no left: 0 without an interactable inset.
  2. Needs a reliable safe area → the interactable inset (editable: the root's Scene Inset; coded: screenInset: 'interactable' on the renderer, 7.26.0+, below that wrap in <InteractableArea>). It excludes the minimap, chat and left-side controls, so left: 0 inside it is safe on desktop. It excludes the left column only — it shares its right edge with the device area, so the bottom-right action cluster sits inside it by design.
  3. Mobile: the bottom-right action buttons (by default jump, pointer and E on SDK 7.32.0+; all eight on older SDKs) are drawn on top of the interactable area, and taps there go to the client. A gameplay HUD stays clear: anchor top-right / top-center, no interactive element bottom-right on mobile (read UiCanvasInformation.interactableArea for exact margins; the button slots are fixed). UI that interrupts gameplay (scoreboard, results, shop, rules card) hides the touch controls while open — TouchScreenControls.hideAll() + hideJoystick() on open, showDefault() + showJoystick() on close (no-ops on desktop). showDefault() is 7.32.0+ — on 7.26.0–7.31.x it does not exist (Property 'showDefault' does not exist) and showAll() is the restore. Restore with showDefault(), not showAll(): showAll() reveals all eight buttons — including the five the player never had — and clears every per-button setting, custom icons included, while showDefault() restores the set the scene started with and keeps the icons. Optionally add InputModifier disableAll on engine.PlayerEntity for the same span (desktop and mobile clients — the mobile docs recommend it for this case; it only has no effect in the legacy web explorer). Route every close path through the same function so controls are never left hidden. Editable: the driver does this when state.visible flips; coded pattern: {baseDir}/references/coded-ui.md → Full-screen modal. In a scene that uses the UI Designer's MobileHUD panel, that runtime toggle fights the panel's static call — see MobileHUD. A card the player dismisses with a key needs a tap path on mobile. A title, ending or "press E to continue" card advanced only by IA_PRIMARY is a dead end on a phone once the touch controls are hidden — the native E button was the only way forward (owner-reported 2026-10-12: "cannot press the E button"). Bind the same action to the UI: uiInputBinding={{ actions: [InputAction.IA_PRIMARY] }} on a visible Continue button, or on the card itself so a tap anywhere advances it ({baseDir}/references/ui-components.md → uiInputBinding: the element fires the action as if the key were pressed, so the existing inputSystem check keeps working). Keep the touch controls visible when the card has no tappable control of its own. The button form is the documented mobile pattern; the tap-anywhere form has not been checked on a device yet.

Rules: show, don't tell

Content rule for any panel that explains the game: one screen, no scrolling, 3–5 short lines, show (icon, diagram, highlighted first target) rather than tell. Two anti-patterns show up constantly in generated games. Both lose players before the first interaction.

  • The rules pop-up. An intro modal with several dense paragraphs explaining every mechanic, scoring rule and control. Players dismiss it unread. Fix: the pop-up (if any) is one screen, 3–5 lines, ≤ 8 words each, ideally one goal line plus one control line plus one picture/diagram. Everything else is learned by playing.
  • The in-world rulebook. The same paragraphs on a stylized plane or board in the scene. It looks like design, but the text is too small and too long to read in-world, and players will not stand still and squint. Fix: a sign says one thing (≤ 10 words, large fontSize); one sign per idea; put the rest into the level design.

Show instead of tell:

  • Make the first target unmistakable: a glowing, animated, or oversized object placed in the player's opening sightline. A pulsing outline teaches "click me" better than a sentence does.
  • Teach one mechanic by letting the player do it once safely (tutorial gate, practice target) before the real game starts.
  • Use hoverText on interactive entities as the just-in-time instruction — it appears exactly when it is relevant and costs no reading up-front.
  • Use an image/diagram (a uiBackground texture or an in-world plane) for spatial or sequence rules; a picture of "red gems = 1, blue = 5" replaces a paragraph.
  • Deliver anything longer through an NPC or a dialog one message at a time, skippable, or as Timed Announcement-style hints at the moment they matter.

Text budget as a checklist: intro panel ≤ 5 lines; sign ≤ 10 words; hint ≤ 1 sentence; if a rule needs more than that, redesign the mechanic or show it with visuals.

Pointer blocking

A UI element with any listener — or pointerFilter: 'block' — captures pointer input over its WHOLE rect, not just its visible pixels, blocking clicks to the 3D world and every UI element behind it. A transparent background changes nothing. An element with no listeners and the default pointerFilter: 'none' lets clicks through.

NEVER put a handler, a {...useInteraction} spread, or pointerFilter: 'block' on a 100%×100% wrapper. Its rect is the whole screen, so one stray listener on the layout root makes the player unable to click any other UI element or anything in the world — while the UI still looks correct because the visible panel is small. The spread counts because useInteraction always returns all four listeners, including when used only as a visibility gate. Attach handlers and gates to the smallest element that needs them (panel, button, row); layout wrappers stay plain literal-styled UiEntitys doing positioning only. Check this every time you add a handler. Before/after: {baseDir}/references/component-template.md §4.

Two blocking full-screen overlays are legitimate, and both must be a deliberate, gated decision rather than a side effect: a modal backdrop meant to swallow clicks while open, and a drag-release catcher shown only while a drag runs ({baseDir}/references/drag-slider.md). In editable UI the gate is an active display: 'none' layer; in coded UI, conditional rendering.

MobileHUD (src/mobile-hud.ts) — editor-owned, do not hand-write

Creator Hub 0.50.0+. MobileHUD is a fixed first entry in the UI Designer's GUIs rail (once the scene has at least one GUI). It edits the scene's TouchScreenControls — the native mobile joystick, crosshair and gamepad buttons — with a read-only canvas preview. It is not a react-ecs root: it is a single TouchScreenControls.createOrReplace(engine.RootEntity, { … }) call written to src/mobile-hud.ts (outside src/ui/, so the root scanner never treats it as a GUI), exported as setupMobileHud and imported in src/index.ts next to setupUi().

  • Never hand-write or edit src/mobile-hud.ts. The panel regenerates it from its own parse of that exact format; code it cannot parse is silently reduced to defaults.
  • Never write a second TouchScreenControls.createOrReplace anywhere else in such a scene — one component on engine.RootEntity, last write wins.
  • The file is written lazily and deleted automatically: it exists only while the config deviates from SDK defaults. A scene with no file is on stock behaviour — the SDK's default button set (jump, pointer, E on 7.32.0+; advanced-input → The default button set).
  • The panel's createOrReplace overrides that default set, so a MobileHUD scene can show more buttons than a plain one. It lists only the buttons the designer hid or re-iconed, and every button it omits is shown — so a config that hides nothing emits touchInputs: [] and all eight buttons appear. To keep the stock three, hide IA_SECONDARY and IA_ACTION_3–IA_ACTION_6 in the panel. [UNVERIFIED on a device: read from the 0.52.0 emitter and the 7.32.0 startup order; the override applies when setupMobileHud() runs inside main(), which is where the panel wires it whenever setupUi() is itself called there.]
  • To change the mobile HUD in a UI-Designer scene, change it in the panel. Genuine runtime control (hide buttons during a cutscene or a modal) goes in a driver outside src/ui/, accepting that it fights the panel's static call — advanced-input has the component API.

What the panel exposes and the protobuf caveat: {baseDir}/references/editor-behavior.md → MobileHUD.

Coded UI (corner cases)

When Choosing the mode sends a UI to coded, everything above about elements, sizing, placement and pointer blocking still applies. What changes:

  • Any file layout works (src/ui.tsx single file, or a module outside src/ui/ beside editable roots). Conditional rendering, .map(), helper components, uiText bags, computed styles and theme constants are all fine.
  • Always pass { virtualWidth: 1920, virtualHeight: 1080 } to setUiRenderer / addUiRenderer. It pins the reference resolution and is identical on both sides of the 7.26.0 version gate (below it, omitting the size disables scaling; on 7.26.0+ a 16:9 size is overridden to 1600x720 on mobile anyway). Never emit a single dimension. screenInset and the renderer zIndex option (7.29.0+) are not version-safe.
  • Root <UiEntity> sets width: '100%', height: '100%' (required for reliable absolute positioning) and stays pointer-transparent.
  • Only one setUiRenderer per scene — a second call silently overwrites the first. Independent modules use addUiRenderer(ownerEntity, Widget, options), removed with removeUiRenderer(owner) or automatically when the owner entity is destroyed; this is also how a coded panel coexists with an editable src/ui/, whose aggregator owns setUiRenderer.

Virtual-screen and version-gate tables, hybrid scenes, module-level state with conditional rendering, and the from-scratch widget patterns: {baseDir}/references/coded-ui.md.

Gotchas (verified against engine test scenes)

  • Input and Dropdown are not React controlled components. onChange / onSubmit fire with the current value, but the field does not re-read value / selectedIndex every frame. To clear an Input, set value to a non-empty sentinel (' ') for one frame, then ''. Trust your scene state, never the rendered field: a controlled Input does not follow a programmatic reset (open client issue), and re-writing the same string afterwards fires no onChange. onSubmit (Enter) commits and clears the field, and the client emits onSubmit then onChange. Removing a focused Input inside its own handler is safe. Dropdown onChange receives the index; with acceptEmpty the empty entry is index-shifted. Details: {baseDir}/references/ui-components.md → Input.
  • Button disabled halves text/background alpha and drops onMouseDown / onMouseUp only — onMouseEnter / onMouseLeave still fire.
  • zIndex is per-sibling-group — it does not lift an element above a different branch. Use tree order (or array return in coded UI) for cross-branch stacking, and the renderer zIndex option to stack whole renderers (ui-components.md → Renderer zIndex; the Admin Tools smart item sits at 1000).
  • opacity multiplies down the tree. A child at 0.8 inside a root at 0.5 renders at 0.4.
  • textureMode: 'stretch' deforms non-uniform art; use 'nine-slices' + textureSlices for panels/buttons, 'center' for native size. color beside texture tints it (mobile apps below 1.16.0 ignore tint and alpha on uvs crops: {baseDir}/../sdk-scenes/references/mobile-parity.md; desktop texture limits such as unclipped borderRadius: {baseDir}/references/ui-components.md → Backgrounds). On mobile the corner cells are fixed-size as on desktop, but the edge and centre cells tile instead of stretching (godot-explorer dcl_ui_background.rs: a NinePatchRect with the same margins and TILE_FIT on both axes), so a gradient or a single large motif in them repeats on a phone while flat edges and centres look the same as desktop. Author nine-slice art with uniform edge and centre cells, and check it on a device.
  • Insets do not clip. ScreenInsetArea / InteractableArea set no overflow, so content positioned past the inset renders into the reserved zone. Overflow past the safe-area outline is a placement bug to fix, not something to hide with overflow: 'hidden'.
  • Hand-placed ScreenInsetArea / InteractableArea on 7.26.0+ stack on the renderer's own screenInset (default 'device') — pass screenInset: 'none' when you place the wrapper yourself. Below 7.26.0 the wrapper is the only mechanism. The generated aggregator is the exception you cannot change (see The aggregator).
  • UiCanvasInformation.width / height are raw canvas pixels, not virtual units — inputs for decisions (which layout, which texture), not for computing sizes.

Troubleshooting

Work through the wiring causes first — they are the cause by a wide margin.

ProblemCauseSolution
UI not rendering / nothing on screen (most common)setupUi() not called from main() in src/index.tsAdd the call. Always check this first
UI not rendering even though setupUi() is calledReactEcsRenderer.setUiRenderer(...) missing from setupUi()Restore the aggregator shape (editable) or the canonical call (coded)
UI blank on first frames, appears laterroot component returns null / falsy on first renderRender a placeholder or a gated root instead
Desktop: switching screens flashes one frame of text and colour fills with no textured backgroundsa just-mounted uiBackground texture draws one frame after the text and fills of the same tree (observed in 50 ms frame captures); pre-rendering the textures in 1 px boxes or under a solid veil did not helpKeep the next screen mounted but hidden (opacity: 0) during the previous phase and reveal it, or cover the switch with a wipe
Multiple UIs fighting / UI missingmore than one setUiRenderer — the last silently winsOne setUiRenderer; extra modules via addUiRenderer with their own owner entities
Absolute-positioned children laid out unexpectedlyroot <UiEntity> lacks width: '100%', height: '100%'Add it (empirically required — a top-right child vanished without it)
UI elements overlappingmissing flexDirection, or labels with no boxflexDirection: 'column' on the parent; explicit Label boxes
A layout is wrong and no wiring cause fitsA container's size comes from a different source than assumed (px, % of parent, 'auto' from children, text with no box)Temporarily set borderWidth: 1 + a loud borderColor on every container (px; desktop and mobile 1.10.1+), screenshot, and write down each container's width/height source before changing anything; remove the borders afterwards
Buttons in the top-left / along the left edge visible but not clickable, or partly hiddenscene UI sits under the explorer's HUD (minimap, chat, mobile joystick), which takes the clicksAnchor right or center, or use the interactable inset — see Placement
Mobile: taps on the lower-right of a large panel do nothing / trigger jump or Ethe native action buttons are drawn over the interactable areaKeep interactive elements out of the bottom-right, or hide the touch controls while a full-screen UI is open
Button clicks not registeringmissing onMouseDownAdd it to the Button / UiEntity (a @ui-action thunk in editable UI)
Nothing on screen is clickable any morea 100%×100% wrapper carries a listener, a useInteraction spread, or pointerFilter: 'block'Strip them from the wrapper; move gates/handlers onto the panel/button
Looks right on one explorer, labels overlap / panel squashed on UnityLabels with no explicit box, container auto-sizing from textExplicit width / height on every label and every label-stacking container
Part of a string missing or shows as □emoji / decorative UnicodeRemove it; use a texture icon
JSX errors at compile timefile is .ts not .tsxRename
Text not visiblecolor matches backgroundContrasting color
Component looks collapsed / height 0 on the editor canvas, fine in-worldcomponent root or ref wrapper without explicit width / heightDeclare both
Panel refuses every edit on a node (frozen)a computed value, identifier or call in uiTransform / uiBackground / Label propsLiteral or bare state.x / props.x; move the math to the driver
Subtree missing from the canvas (opaque){cond && …}, ternary, .map(), JSX comment, unknown element, non-useInteraction spreadSubstitute per What is never expressible
Editor shows "UI Editor Unavailable"@dcl/sdk below 7.26.0Update the scene's SDK
Drag slider jitters / wrong speedvalue read through a stale closure, or TRACK_PX / scale-factor mismatch{baseDir}/references/drag-slider.md

Self-check list

Run over every .tsx under src/ui/ you write or adapt. Each item is a silent editor failure if violated.

  1. File is src/ui/<PascalCaseName>.tsx, basename equals the exported component name, first line is /** @jsx ReactEcs.createElement */, exactly one exported component.
  2. Every JSX element name is UiEntity, Label, Input, Dropdown, Button, or a component exported by another src/ui/ file.
  3. No {cond && <X/>}, no cond ? <A/> : <B/> — except a usePlatform() variant whose branches are each a single element or null.
  4. No .map(), loops, or array-built children.
  5. No {/* JSX comments */}; comments are // outside JSX.
  6. Every value inside uiTransform / uiBackground is a literal or a bare state.x / props.x. Grep those objects for ?, +, *, Math., ( and bare identifiers — each is a frozen node.
  7. All text is on Label / Button value; no uiText bag on a UiEntity.
  8. Every template literal in a prop interpolates only bare references.
  9. The only spread on any element is a single {...someUseInteractionConst}.
  10. Visibility is a useInteraction active layer setting display: 'none'; hover/press are hover / press layers.
  11. No pointer handler, no {...useInteraction} spread, no pointerFilter: 'block' on any 100%×100% element (or any unsized element that fills the screen). Sole exceptions, both gated off when idle: a modal backdrop and a drag-release catcher.
  12. export interface State + export const state: State present; every property is number, string, boolean, string[], or a structural { r, g, b, a } color.
  13. Declared props are an inline object type of optional number / string / boolean / callback members only.
  14. Every handler is a /** @ui-action */ function taking ({ state, props, value }: UiAction), wired through a thunk.
  15. All bound sizes/positions are px numbers; no percent strings in bound values.
  16. The component root declares both width and height (px or percent literals), and so does every wrapper UiEntity around a component ref, matching that component's root size.
  17. No clock, Date.now(), setTimeout, easing, rounding or string formatting anywhere in src/ui/ — it lives in the driver.
  18. src/ui/index.tsx matches the generated shape exactly, each root wrapped in ScreenInsetArea unless full-canvas control was explicitly requested.
  19. Mobile pass: layout survives a 1600x720 canvas, touch targets ≥ ~48 px, no hover-only affordances, no emoji, nothing anchored top-left, nothing interactive bottom-right.
  20. In a UI-Designer scene, mobile controls are the MobileHUD panel's (src/mobile-hud.ts). Grep the scene for TouchScreenControls: the only createOrReplace is in that generated file, and every other hit is a runtime hide/show toggle inside a driver (Placement rule 3, MobileHUD).
  21. Coded or hybrid scene: grep setUiRenderer( / addUiRenderer( — every call outside the generated src/ui/index.tsx aggregator passes { virtualWidth: 1920, virtualHeight: 1080 }, and sizes are px on that canvas (no vw/vh or canvas-pixel arithmetic).

References

  • {baseDir}/references/component-template.md — the editor's seed file, a minimal component, a fully-featured one (bindings, mixed text, hover, two-variable exit gate), the dialog before/after, a props-driven fill bar, unrolled variants, the composed screen, the aggregator.
  • {baseDir}/references/interaction-helper.md — verbatim src/ui/interaction.tsx and src/ui/platform.tsx, plus the usage shapes the parser recognizes.
  • {baseDir}/references/driver-pattern.md — eased open/close, formatted timer, two-variable exit gate, action→driver handshake, derived values, naming-convention drivers.
  • {baseDir}/references/adapting-coded-ui.md — before/after recipes for porting a coded UI, and what to tell the user cannot be ported.
  • {baseDir}/references/drag-slider.md — why sliders need PrimaryPointerInfo.screenDelta, the in-world-verified editable slider (tap-to-step + drag, gated release catcher, driver), the coded variant, the SDK-version-dependent scale factor.
  • {baseDir}/references/editor-behavior.md — what the Creator Hub UI editor writes and shows: layout axes, widget presets, Scene Inset, canvas tools, mode persistence, the mobile preview and its limits, the MobileHUD panel's fields.
  • {baseDir}/references/ui-components.md — full prop reference for the five elements, uiInputBinding, backgrounds (texture modes, nine-slices, UVs, tint, avatar), element and renderer zIndex, screenInset / ScreenInsetArea / InteractableArea, UiCanvasInformation, and the engine test scenes that are ground truth.
  • {baseDir}/references/coded-ui.md — the free-form path: virtual-screen default rule and the 7.26.0 version gate, addUiRenderer and hybrid scenes, module-level state with conditional rendering, the from-scratch widget patterns, and each pattern's editable equivalent.

Cross-references

  • Custom fonts: fontSrc (a .ttf shipped with the scene) on Label, Button, Input, Dropdown and uiText, always with font as the fallback. SDK version, where it renders, editable-UI handling and testing: fonts.
  • Platform detection: getPlatform() / isMobile() from @dcl/sdk/platform (wrapped by usePlatform() in editable UI). See advanced-input.
  • Localized UI text: getPlayerLanguage() (same module) returns the player's client language as a BCP-47 tag, with onPlayerLanguageChanged for mid-session switches. It returns 'en' until the explorer answers, so read it in a system (the driver), not at module scope. See advanced-input > "Player language". SDK 7.30.0+.
  • Sending the player to a client panel: openExplorerUi opens the map / backpack / places / events panels, and openExplorerUiAndWait (@dcl/sdk/explorer-ui) resolves when the player closes it. Requires a user gesture. See scene-runtime > "openExplorerUi -- Open Explorer Panels" and "openExplorerUiAndWait -- Wait Until the Panel Closes".
  • Replacing the native mobile controls: TouchScreenControls (SDK 7.26.0+, advanced-input) hides the joystick / crosshair / gamepad buttons so scene UI can take their place, with uiInputBinding wiring replacement buttons to InputActions. In a UI-Designer scene that config belongs to the MobileHUD panel. The client's own HUD (chat, profile, emote wheel) cannot be hidden.
  • Editing the open scene live (entities, smart items, settings): creator-hub-mcp.
  • Worked example of animated UI (sprite sheets, loading indicators): ui-animations, from the docs example catalog ({baseDir}/../sdk-scenes/references/example-scenes.md). It is coded UI: port its animation into a driver for editable UI.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.