# Supported instances and properties

> The per-property support matrix — what loom renders, what it accepts and silently ignores, and what will not typecheck at all.

Source: https://docs.astra-void.xyz/loom/reference/supported-properties/

[Scope and status](https://docs.astra-void.xyz/loom/getting-started/scope-and-status.md) answers "is text done?" at the area
level. This page answers "does `AutomaticCanvasSize` work?" at the property level, so you know
exactly how far a preview can be trusted before you open Studio.

Everything below describes loom `0.11.0`: the JSX intrinsics in `@loom-dev/react`, the DOM mapping in
`@loom-dev/renderer`, and the Rust layout engine in `crates/loom-layout`.

## How to read this

| Mark | Meaning |
| --- | --- |
| **Supported** | Read by the layout engine or painted by the renderer. Behaves like Roblox, within the fidelity limits below. |
| **Partial** | Implemented, with a stated gap. Correct in the common case, wrong at the edge. |
| **Accepted** | Typed and settable, and nothing reads it. No error, no effect. |
| **Not typed** | No JSX prop and no runtime handling. A compile error, and it would not render either. |

**Accepted** is the row that costs you time, so it is spelled out rather than omitted. Those props
exist so real component code — which sets them for the Roblox build — runs under the preview without
edits.

> **An enum prop can be written as a bare string**
>
> The engine takes the string wherever it takes the item — `AutomaticSize="XY"` *is*
> `Enum.AutomaticSize.XY`, and roblox-ts's own React typings offer both spellings. Loom reads either,
> everywhere, as of `0.9.1`. Before that release the Scene IR encoded a string happily but every place
> the adapters read one *back* off an instance insisted on an `EnumItem`: a label written
> `AutomaticSize="XY"` was auto-sized by the layout and measured by nobody, so it collapsed to zero and
> spilled its text. `FontSize` written as a string was ignored the same way.

## Instances

| Intrinsic | Status | Notes |
| --- | --- | --- |
| `screengui` `surfacegui` `billboardgui` | Supported | LayerCollectors: never background-painted, always click-through, z-ordered among themselves by `DisplayOrder`. |
| `frame` | Supported | |
| `scrollingframe` | Supported | Sinks pointer input unconditionally, like Roblox. |
| `canvasgroup` | Supported | `GroupTransparency` becomes CSS `opacity` on the container div, which is the same "composite the subtree, then fade it" model. |
| `textlabel` `textbutton` `textbox` | Supported | See [Text](#text-properties). |
| `imagelabel` `imagebutton` | Supported | The `Image` paints in a layer beneath the text, in every `ScaleType`. See [Image properties](#image-properties). |
| `viewportframe` `videoframe` | Partial | Typed and laid out as plain `GuiObject`s. No 3D scene, no video — they paint as backgrounds. |
| `uilistlayout` `uigridlayout` `uipadding` | Supported | |
| `uipagelayout` `uitablelayout` | Supported | Implemented in `0.7.0`. See [Layout and modifier properties](#layout-and-modifier-properties). |
| `uicorner` `uistroke` `uigradient` | Supported | Mapped to `border-radius` (per-corner), a `box-shadow` ring on the side `BorderStrokePosition` asks for, and a CSS `linear-gradient`. |
| `uishadow` | Supported | A CSS drop shadow, layered *under* a `UIStroke` ring rather than replacing it. |
| `uisizeconstraint` `uiaspectratioconstraint` | Supported | |
| `uiscale` | **Accepted** | Typed, recognized as a modifier, and read by nothing. A scaled subtree renders at 1×. |
| `uiflexitem` | Supported | `FlexMode` — `Grow` / `Fill`, or `Custom` with `GrowRatio`. |
| Everything else | Not typed | `uitextsizeconstraint`, `folder`, `part`, … — the intrinsic set is exactly what the renderer implements. |

## GuiObject properties

Shared by every visual class.

| Property | Status | Notes |
| --- | --- | --- |
| `Size` `Position` `AnchorPoint` | Supported | Full `UDim2` scale + offset resolution. |
| `AutomaticSize` | Supported | `X` / `Y` / `XY` grow-to-content, with the object's own `Size` as the floor and the room its parent leaves as the ceiling — see [below](#automaticsize-is-bounded-by-the-parent). |
| `BackgroundColor3` `BackgroundTransparency` | Supported | |
| `Visible` | Supported | Hidden via CSS; the node still occupies its computed rect, as in Roblox. |
| `ZIndex` | Supported | `ZIndexBehavior.Sibling` semantics — the renderer's native model. |
| `LayoutOrder` | Supported | Honored by every layout under `SortOrder.LayoutOrder` — which is **not** the default. See [`SortOrder` defaults to `Name`](#sortorder-defaults-to-name). |
| `Rotation` | Supported | Degrees clockwise about the element center, as a CSS transform. Purely visual: the layout rect is unrotated, matching Roblox. |
| `ClipsDescendants` | Supported | |
| `Active` | Partial | **Honored at runtime, not declared in the props type**, so setting it works and your editor will redline it. It governs whether input is *sunk*, not whether the object hears it: a `frame` with an `InputBegan` handler is hit-testable either way, and a frame with no listeners stays click-through. |
| `Name` | Supported | |
| *(unwritten properties)* | Supported | Reading a property nobody has written yields the Roblox default — `Visible`, `ZIndex`, `BackgroundTransparency`, `Rotation`, `LayoutOrder`, `Active`, `ClipsDescendants`, `AnchorPoint`, `Position`, `Size` — not `undefined`. |
| `Event` `Change` `ref` | Supported | `Event={{ Activated }}`, `Change={{ Text }}`, and `ref` to the live `LoomInstance`. |
| `BorderSizePixel` `BorderColor3` `SizeConstraint` `Transparency` `Interactable` | Not typed | |

## ScreenGui properties

| Property | Status | Notes |
| --- | --- | --- |
| `DisplayOrder` | Supported | Becomes the layer's CSS `z-index` (may be negative). |
| `Enabled` | Supported | |
| `ZIndexBehavior` | **Accepted** | `Sibling` is what the renderer already does; `Global` is not emulated. |
| `IgnoreGuiInset` | **Accepted** | The runtime's `GetGuiInset()` is zero, so there is no inset to ignore. |
| `ResetOnSpawn` `ScreenInsets` | **Accepted** | Typed so real code runs unchanged; nothing reads them. |

## Text properties

| Property | Status | Notes |
| --- | --- | --- |
| `Text` `TextColor3` `TextSize` `TextTransparency` | Supported | A newline in `Text` breaks the line and a run of spaces stays a run of spaces, as in Roblox — since `0.9.3`, when the paint stopped folding both away through HTML's defaults. |
| `TextXAlignment` `TextYAlignment` | Supported | |
| `TextWrapped` | Supported | `white-space: normal` vs `nowrap`, and applied during measurement — see [below](#automaticsize-on-text). The deprecated `TextWrap` alias reads as the same property and is declared on the props type as of `0.6.3`. |
| `LineHeight` | Supported | The multiplier, clamped to the 1…3 Studio allows and spent *between* lines: `n` lines measure `TextSize + (n - 1) * TextSize * LineHeight`, so a one-line label is exactly `TextSize` tall however high its `LineHeight`. The browser's taller line boxes are handled by the clip rect rather than by cropping the glyphs — see [below](#textsize-is-a-face-height-not-a-font-size). |
| `FontFace` | Supported | `new Font(family, weight, style)` resolved to a CSS family/weight/slant, including for text measurement. Preferred over the legacy `Font` enum when both are set. |
| `Font` | Supported | The legacy enum — all 53 items in the engine's own order as of `0.9.0` — mapped to a family by name prefix, with the weight read off the name suffix. 28 families load a real face; the eight the engine licenses privately (`Gotham`, `BuilderSans`, `Bodoni`, …) resolve to a stack and warn. See [Fonts and text metrics](https://docs.astra-void.xyz/loom/guides/fonts.md). |
| `FontSize` | Supported | The legacy `Enum.FontSize` — the pixel size is read out of the enum name, so `Size24` paints and measures at 24px. `TextSize` wins when both are set, as in Roblox. |
| `RichText` | Supported | `<b>`, `<i>`, `<u>`, `<s>`, `<br/>`, `<font>` (colour, size, face, family, weight, transparency), `<uppercase>` / `<smallcaps>` and the character entities. With the flag off — or on a tag the engine would not recognise either — the markup stays literal, as in Roblox. Measurement uses each run's own font. |
| `TextScaled` | **Accepted** | Never applied. Text renders at `TextSize` whether or not it fits its box. |
| `TextTruncate` `MaxVisibleGraphemes` `TextStrokeColor3` `TextStrokeTransparency` | Not typed | No ellipsis, no glyph outline. |

### `AutomaticSize` on text

Auto-sizing text is measured browser-side — the adapter runs a canvas `measureText()` with the same
font the renderer paints and hands the result to the WASM engine as `TextBounds`, because font
metrics do not exist inside the engine. Which typeface that is, and why it is worth pinning, is
[Fonts and text metrics](https://docs.astra-void.xyz/loom/guides/fonts.md).

`TextWrapped` is honored: the runs are laid into lines at word boundaries rather than measured as one
long line. Since `0.9.5` **one function decides every wrap** — measurement asks it how many lines a
label needs, and the text layer asks it where to put the breaks it paints. Before that the box came
from the measurer while the glyphs inside it were left to CSS, which wraps on its own kerned run
widths, so a label could reserve nine lines and paint eight, breaking at different words than Studio.
The advances behind it are the engine's: half-pixel per grapheme since `0.9.4`, plus the run's kerning
since `0.9.5`.

**The wrap width is the nearest ancestor that has a width of its own**, less every
`UIPadding` in between — not the immediate parent. A parent that is itself `AutomaticSize` was sized
*by* the label, so wrapping against it is the same circle as wrapping against the label's own width,
and the text never wraps at all. The library idiom stacks two or three such containers (a padded body
inside a flex item inside a card), and the card — the one node with a real width — is where the room
actually runs out.

The re-wrap settles inside the paint that caused it. The wrap width comes from the layout that paint
produces, so the first measurement after a container narrows is still against the old width; the
adapter re-encodes and re-lays-out until the two agree (up to four passes, then it defers to the next
frame) and patches the DOM once, with the settled result. Rendering the first pass would put a label
wider than its container on screen for a frame — which, during a live window drag, is every frame.

A `RichText` string is measured run by run in the font its own tags ask for, so a bold or resized run
does not clip. An empty `TextBox` is measured against its `PlaceholderText`, which is what it
displays — otherwise an `AutomaticSize.Y` input would collapse to zero height and become unclickable.

Both adapters do this. The `vide` adapter measured by splitting on newlines only until `0.6.4`, so
`TextWrapped` did nothing there and the same scene laid out differently through vide than through
react — the one thing a shared Scene IR is supposed to rule out.

The wrap width also carries the `UIPadding` insets in between, **including scale ones**: the layout
engine resolves a scale inset against the node's own width where its X axis is a real one, and
against zero where the axis is automatic (a scale inset on an automatic axis is circular — the width
sets the padding sets the width). Until `0.8.1` the adapter read offsets only, which is the right
answer for the automatic case and wrong for the other, so a wrapped label under a
`PaddingLeft={new UDim(0.15, 0)}` ancestor was measured against a width that ignored the inset and
came out with a box built for fewer lines than it was then painted with.

### `TextSize` is a face height, not a font size

`TextSize` means different things to the two renderers. Roblox fits the whole face into it —
ascender to descender, which is why one line measures exactly that tall — while CSS `font-size` sets
the em square, and a face's ascent plus descent runs well past 1em.

Until `0.9.3` loom painted `font-size: TextSize` straight through, so every glyph came out too big by
its own face's ratio: 17% for Roboto, 25% for Merriweather, 47% for Oswald. Text then measured that
much wider, wrapped that much earlier, and `AutomaticSize` boxes came out taller and wider than the
engine's. Loom now divides by the face box, and `LineHeight` sets the line box in pixels off
`TextSize` because the pitch the engine spends is `TextSize`-relative rather than font-size relative.
Which ratio to divide by is itself calibrated per family — see
[`TextSize` is not a font size](https://docs.astra-void.xyz/loom/guides/fonts.md#textsize-is-not-a-font-size).

Until `0.8.1` the text overlay was also clipped to the box the layout computed, so the browser's
taller line boxes had nowhere to go and the clip took the difference out of the glyphs — a
paragraph's last line lost the tails of its `y`, `p` and `g`, and at a large `TextSize` the first line
lost the tops of its ascenders. The clip rect carries that overhang now, with padding handing the
content box its original height straight back. Nothing about the layout moves: `TextBounds`,
`AbsoluteSize` and every rect around the label are the values they were, and a label still clips its
own text at its left and right edges.

### `AutomaticSize` is bounded by the parent

Roblox bounds automatic growth: an object with `AutomaticSize` on an axis grows up to the maximum
size its parent allows, and a `TextWrapped` label grows until that maximum and only *then* wraps.
Until `0.6.4` loom grew unbounded, so any content with an irreducible minimum — a row of buttons, a
long word — pushed its container past the slot positioning it, and at a narrow viewport a `45%`-wide
card grew over the card beside it.

Every auto-sized node now carries the ceiling its parent leaves, inherited through the padding in
between. `Size` remains the floor even when it is itself past that ceiling, and a wrapping
`UIListLayout` on an automatic fill axis wraps against the same ceiling rather than measuring as one
run. Content that still does not fit **overflows**, which is what the engine does — it does not widen
the object to make room.

**A `ScrollingFrame` is the exception**, since `0.9.3`. A canvas free to grow is one whose entire
point is outgrowing the window, so children of a `ScrollingFrame` get no ceiling on an axis carrying
`AutomaticCanvasSize` or a `CanvasSize` of 0. A `CanvasSize` that gives the axis a real extent is
still the ceiling it always was. Before that release the ceiling applied here too, so the canvas came
out exactly the window's size and nothing ever scrolled.

## Image properties

Shared by `imagelabel` and `imagebutton`. The image paints in a layer beneath the text — a
background-painted element rather than an `<img>`, because a sprite window and a 9-slice both place a
*region* of the source, which `object-fit` cannot express.

| Property | Status | Notes |
| --- | --- | --- |
| `Image` | Supported | `http(s):`, `data:` and `blob:` URLs load directly. `rbxassetid://` is resolved by a dev-server route, and baked into a static build — see the callout below. |
| `ImageTransparency` | Supported | |
| `ImageColor3` | Supported | A per-channel multiply through an `feColorMatrix`, matching the engine — so a full-colour image tints as correctly as a monochrome icon. The default white costs no filter at all. |
| `ScaleType` | Supported | All five as of `0.7.0`: `Stretch`, `Fit`, `Crop`, `Slice` and `Tile`. |
| `SliceCenter` `SliceScale` | Supported | The 9-slice centre in the source image's own pixels, painted as a CSS `border-image`: corners keep their size, edges stretch along one axis, the middle along both. `SliceScale` scales the painted border without touching the source. A `SliceCenter` that leaves no centre (the default `Rect.new(0, 0, 0, 0)` included) stretches instead. |
| `TileSize` | Supported | One tile's size for `ScaleType.Tile`, a `UDim2` against the node — so `UDim2.fromScale(0.5, 0.5)` is four tiles. Defaults to `{1,0},{1,0}`, one tile filling the node. |
| `ImageRectOffset` `ImageRectSize` | Supported | The sprite window, in image pixels. A zero `ImageRectSize` means the whole image, as in Roblox. The window is clipped to its own destination rect, so a sheet's neighbouring sprites never leak under `Fit`. |
| `ResampleMode` | Supported | `Pixelated` becomes CSS `image-rendering: pixelated` — pixel art scales up crisp instead of blurred. |

Two combinations are **not** reproduced, and say so rather than painting something wrong:

- **`ScaleType.Tile` with an `ImageRect` window.** CSS backgrounds cannot repeat a *region*, so the
  whole image tiles and loom warns once, naming the node.
- **A `SliceCenter` measured against a window** rather than against the whole image. The slice insets
  are read from the source's natural size.

> **rbxassetid:// resolution**
>
> Roblox's thumbnail API sends no CORS headers, so the browser cannot resolve an asset id on its own.
> `@loom-dev/preview` installs a resolver for both cases:
>
> - **Under a dev server** (`loom preview`, the embedded server, Next dev) the id is resolved
>   server-side and answered as a redirect from `<base>__loom/asset/<id>`.
> - **In a static build** (`loom build`, `vite build`) there is no server later, so the ids are
>   resolved *at build time*, the images are downloaded into the output, and a `__loom/assets.json`
>   manifest points the page at them — see [Static builds and
>   embedding](https://docs.astra-void.xyz/loom/guides/static-builds-and-embedding.md#rbxassetid-in-a-static-build).
>
> The build finds those ids two ways: it reads the emitted output for `rbxassetid://<digits>`, and —
> since `0.9.5` — it mounts each gallery target in node and reads the live tree, which is what catches
> an id composed at runtime (`` `rbxassetid://${iconId}` ``). An image the first render never reaches
> is still missed. Anywhere else, install your own resolver with `setImageResolver` from
> `@loom-dev/renderer`.

## TextBox properties

| Property | Status | Notes |
| --- | --- | --- |
| `PlaceholderText` | Supported | Becomes the DOM input's `placeholder`. |
| `ClearTextOnFocus` `TextEditable` `MultiLine` | Supported | |
| `PlaceholderColor3` | **Accepted** | The placeholder is not restyled — you get the browser default. |

## ScrollingFrame properties

| Property | Status | Notes |
| --- | --- | --- |
| `CanvasSize` | Supported | Children lay out against the canvas, not the window. |
| `CanvasPosition` | Supported | Applied as the DOM scroll offset by the renderer, not by the layout engine. |
| `AutomaticCanvasSize` | Supported | `X` / `Y` / `XY`. The affected axis grows to the union bounding box of the frame's **direct** children, then `max()`ed with the resolved `CanvasSize`. |
| `ScrollingDirection` `ScrollingEnabled` | Supported | Decide which axes can scroll, and therefore which bars can appear. |
| `ScrollBarThickness` | Supported | The bar's width, default `12`. A thickness of `0` shows no bar, as in Roblox. |
| `ScrollBarImageColor3` | Supported | Tints the thumb. Untinted, loom draws the engine's grey rather than the property's own white default — the engine tints grey sprites loom does not fetch, and a white bar would be invisible on most surfaces. |
| `ScrollBarImageTransparency` | **Accepted** | The thumb is drawn opaque. |
| `VerticalScrollBarInset` `HorizontalScrollBarInset` | **Accepted** | The bar is always painted *over* the canvas — Roblox's `ScrollBarInset.None`, which is also the engine's default. `AbsoluteWindowSize` reserves nothing for it, so a layout that budgets for an inset bar comes out that many pixels wider here than in Studio. |

> **Scrolling only started working in `0.9.3`**
>
> A scrolling list in Roblox is an `AutomaticSize` column inside an `AutomaticCanvasSize` frame, and
> loom capped every child's automatic growth at its parent's box — right for a `45%` column, wrong for
> a canvas, where outgrowing the window is the whole point. The column came out exactly the window's
> height, so the canvas equalled the window, nothing overflowed and nothing scrolled. And with no bar
> drawn, a frame that *did* have something to scroll looked like a static clipped box.
>
> A `ScrollingFrame` now leaves its children no ceiling on an axis whose canvas is free to grow —
> `AutomaticCanvasSize`, or a `CanvasSize` of 0 on that axis. A `CanvasSize` that gives the axis a real
> extent is still the ceiling it always was. The bar is drawn from the same window/canvas math the
> adapters feed back as `AbsoluteWindowSize`/`AbsoluteCanvasSize`, so it cannot disagree with the
> numbers a component sized itself against.

> **AutomaticSize plus an explicit CanvasSize**
>
> `AutomaticSize` on a `ScrollingFrame` that also sets `CanvasSize` is explicitly deferred in the
> layout engine. The combination will not error; it will not size the way Studio does either.

## Layout and modifier properties

| Instance | Supported | Accepted / partial |
| --- | --- | --- |
| `uilistlayout` | `FillDirection`, `HorizontalAlignment`, `VerticalAlignment`, `SortOrder`, `Padding`, `HorizontalFlex`, `VerticalFlex`, `Wraps` | — |
| `uigridlayout` | `CellSize`, `CellPadding`, `FillDirection`, `FillDirectionMaxCells`, `HorizontalAlignment`, `VerticalAlignment`, `SortOrder` | `StartCorner` — only `TopLeft` is implemented; the other three corners fall back to it. |
| `uitablelayout` | `MajorAxis`, `Padding`, `FillEmptySpaceColumns`, `FillEmptySpaceRows`, `HorizontalAlignment`, `VerticalAlignment`, `SortOrder` | See [`UITableLayout`](#uitablelayout). |
| `uipagelayout` | `FillDirection`, `Padding`, `Circular`, `HorizontalAlignment`, `VerticalAlignment`, `SortOrder` | `Animated`, `TweenTime`, `EasingStyle`, `EasingDirection` and the gamepad/touch/scroll input flags are **accepted**: a preview shows the settled layout, so page changes are instant. See [`UIPageLayout`](#uipagelayout). |
| `uiflexitem` | `FlexMode` (`Grow` / `Fill` / `Custom`), `GrowRatio` | — |
| `uipadding` | `PaddingTop`, `PaddingBottom`, `PaddingLeft`, `PaddingRight` | A scale inset resolves against the node's own size on a real axis, and against **zero** on an `AutomaticSize` axis — where it would be circular. Text measurement asks the same question as of `0.8.1`. |
| `uisizeconstraint` | `MinSize`, `MaxSize` | — |
| `uiaspectratioconstraint` | `AspectRatio`, `DominantAxis` | `AspectType` — `ScaleWithParentSize` is deferred. |
| `uicorner` | `CornerRadius`, `TopLeftRadius`, `TopRightRadius`, `BottomLeftRadius`, `BottomRightRadius` | Each per-corner radius overrides `CornerRadius` for its own corner — that is how a card rounds only its top while its footer rounds only its bottom. The `UIStroke` ring and the `UIShadow` are box-shadows, so they follow the radius for free. |
| `uistroke` | `Color`, `Thickness`, `Transparency`, `Enabled`, `BorderStrokePosition` | `Outer` (the default) spreads outward, `Inner` insets so the stroke eats into the object instead of inflating it, `Center` straddles the edge. `Enabled = false` or a fully transparent stroke paints nothing. `ApplyStrokeMode` — the ring is always drawn border-style, so `Contextual` on a text class does not outline glyphs. |
| `uigradient` | `Color`, `Rotation`, `Enabled` | `Offset` is ignored. The gradient **overlays** the background rather than multiplying it, and `Transparency` (a `NumberSequence`) is not typed at all. |
| `uishadow` | `Color`, `Offset`, `BlurRadius`, `Spread`, `Transparency`, `Enabled`, `ZIndex` | Drawn as a CSS drop shadow under the `UIStroke` ring. |
| `uiscale` | — | `Scale` is ignored entirely. |

`UIListLayout` and `UIGridLayout` report `AbsoluteContentSize` back after layout, gated on real
change — so a control that sizes itself from `Change={{ AbsoluteContentSize }}` (a dropdown, say)
gets a real number instead of collapsing to zero height.

### `SortOrder` defaults to `Name`

Every `UIGridStyleLayout` defaults `SortOrder` to `Enum.SortOrder.Name`, which is the engine's own
default — so **a list whose children carry distinct `Name`s flows alphabetically**, not in source
order. Children with equal names keep source order (the sort is stable), which is why a tree that
never sets `Name` is unaffected either way.

Loom defaulted to `LayoutOrder` until `0.7.0`. If a list reordered itself when you upgraded, it was
already ordering that way in Studio; set `SortOrder={Enum.SortOrder.LayoutOrder}` to get source /
`LayoutOrder` order back.

### `UITableLayout`

The layout's siblings are the table's *lines* — rows, or columns under `MajorAxis.ColumnMajor` — and
each line's own children are the cells:

```tsx
<frame Size={UDim2.new(1, 0, 0, 120)}>
  <uitablelayout Padding={UDim2.new(0, 8, 0, 4)} FillEmptySpaceColumns={true} />
  <frame Name="Row1">
    <textlabel Text="Region" Size={UDim2.fromOffset(120, 26)} />
    <textlabel Text="Players" Size={UDim2.fromOffset(100, 26)} />
  </frame>
</frame>
```

A column is as wide as its widest cell and a row as tall as its tallest, both measured against the
**table's** content box — so a `0.25` scale cell is a quarter of the table, not of its row. A line
spans the whole table on its minor axis, which is what the engine reports for the row frames
themselves; the line's own `Size` is ignored, and so is any `UIPadding` on it (insetting each line
separately would slide its cells off the columns they define).

`FillEmptySpaceColumns` / `FillEmptySpaceRows` scale the tracks **proportionally** so the table spans
its container — in both directions, so a table whose natural width overruns is squeezed by the same
rule. A hidden cell takes neither a track nor a gap.

### `UIPageLayout`

Pages keep their own `Size` and sit one container-plus-`Padding` apart along `FillDirection`, so a
parent with `ClipsDescendants` shows exactly one. Which page that is, is *state* — changed by method
call as in Roblox, through a ref:

```tsx
const pager = useRef<{ Next(): void; CurrentPageIndex: number }>();

<frame Size={UDim2.new(1, 0, 0, 90)} ClipsDescendants={true}>
  <uipagelayout
    ref={(instance) => { pager.current = instance as never; }}
    Padding={UDim.new(0, 16)}
    Circular={true}
  />
  {/* …pages… */}
</frame>;
```

`JumpToIndex(i)` / `JumpTo(page)` / `Next()` / `Previous()` all work and fire `PageLeave` →
`PageEnter` → `Stopped`; `Circular` decides whether `Next` and `Previous` wrap at the ends.

> **CurrentPage is an instance, so the engine reads an index**
>
> Roblox's `CurrentPage` is a **GuiObject reference**, and a Scene IR property value is a datatype —
> never a node — so it cannot cross the wasm boundary. The layout engine reads `CurrentPageIndex`, a
> 0-based int the runtime keeps in step; `CurrentPage` still reads back as the instance for your code.
>
> That only matters if you drive a pager from something other than the `@rbxts/react` adapter: set
> `CurrentPageIndex` on the node yourself.

## Instance API

Every mounted object is a real instance with Roblox's reflection surface on it, not a React element
with a Roblox-shaped name. So a `ref` behaves the way a `ref` does in a place.

| Group | What is there |
| --- | --- |
| Identity | `ClassName`, `Name`, `Parent`, `IsA`, `GetFullName` |
| Tree | `GetChildren`, `GetDescendants`, `FindFirstChild` (with `recursive`), `FindFirstChildOfClass`, `FindFirstAncestor`, `FindFirstAncestorOfClass`, `FindFirstAncestorWhichIsA`, `IsDescendantOf` |
| Lifetime | `Destroy`, `ClearAllChildren`, and the `Destroying` / `ChildAdded` / `ChildRemoved` / `AncestryChanged` signals |
| Change notification | `GetPropertyChangedSignal(name)` and `Changed`, which fires with the property name |
| Geometry | `AbsolutePosition` / `AbsoluteSize`, written back after each layout pass and signalled only when they actually move |
| Attributes | `GetAttribute`, `SetAttribute`, `GetAttributes`, `GetAttributeChangedSignal`, `AttributeChanged` — *(`0.11.0`)* |
| `TextBox` | `CaptureFocus`, `ReleaseFocus`, `IsFocused` |
| `BindableEvent` | `Event` and `Fire`, class-scoped so no other class answers for those names |

**`WaitForChild` does not yield.** The runtime is synchronous, so it returns the child if it is already
there and otherwise warns and returns `undefined` rather than blocking the frame. Code that waits on a
child built later in the same render needs restructuring, not patience.

### Attributes

The second namespace on every instance, and the one an app owns outright, landed in `0.11.0`. It
behaves as it does in a place: an attribute is not readable as a property, does not fire `Changed`,
and reaches neither the renderer nor the Scene IR — so writing one paints nothing and schedules no
flush.

```ts
const player = Players.LocalPlayer;

player.GetAttributeChangedSignal("ColorScheme").Connect(() => {
  print(player.GetAttribute("ColorScheme"));
});

player.SetAttribute("ColorScheme", "dark"); // fires
player.SetAttribute("ColorScheme", "dark"); // unchanged — silent, as in Roblox
player.SetAttribute("ColorScheme", undefined); // removes it, and still fires
```

`GetAttributes()` hands back a snapshot rather than the live store, and names are validated the way
the engine validates them — up to 100 alphanumerics and underscores, with the `RBX` prefix reserved.
A name a real place would refuse throws here rather than working in the preview and failing in Studio.

## Input

Pointer and keyboard input route through the real runtime, not DOM shims: `Activated`,
`MouseButton1Click`, `InputBegan` / `InputChanged` / `InputEnded`, `UserInputService`, focus and
`TextBox` editing all behave as in Roblox, including input-sinking. Only `TextButton`,
`ImageButton`, `TextBox`, `ScrollingFrame` and anything with `Active = true` sink pointer input —
a transparent `Frame` is click-through, exactly as in Roblox.

`Active` governs *sinking*, not *hearing*: a plain `Frame` with an `InputBegan` handler — a slider
handle, say — is hit-testable whether or not it is `Active`, while a frame with no listeners stays
click-through so a transparent positioning layer does not swallow clicks.

## What this list is not

It is a description of loom `0.11.0`, not a compatibility promise. The project is pre-1.0 and rows
move — an **Accepted** here is a gap someone can close without a breaking change. When a preview and
Studio disagree and the property is not on this page, treat Studio as correct and
[file it](https://github.com/astra-void/loom/issues).
