# How it works

> The pipeline end to end, where the transformer and CLI diverge, and the two lowering paths.

Source: https://docs.astra-void.xyz/vela-rbxts/getting-started/how-it-works/

Vela has no runtime you install. It is a host adapter that decides which files to touch, plus a
native compiler that does the class-name work.

[The two ways to run Vela](https://docs.astra-void.xyz/vela-rbxts/getting-started/installation.md#choose-how-vela-runs) differ at
exactly two points: what hands the adapter a file, and where the resulting text goes. Everything
between is one shared pipeline, which is why both tracks emit the same Luau.

## The pipeline

**Something hands the adapter a file.** Under the *transformer*, roblox-ts resolves the
`tsconfig.json` plugin entry. It calls the exported factory with your `Program` and its own
`typescript` instance. The factory throws without that instance, so plain `tsc` cannot drive it.
Under the *CLI*, `vela build` walks `--src` itself and calls the same adapter per file, with no
`Program` and no plugin options.

**The host adapter filters files.** Five conditions, in order. A file that fails any is passed
through untouched:

1. The filename ends in `.tsx`, case-insensitively. **`.ts` files are never transformed**, so a
   `className` built in a `.ts` helper is invisible to Vela.
2. `.d.ts` and `.d.tsx` are skipped.
3. Any path containing a `node_modules` segment is skipped.
4. The source text contains the literal substring `className`.
5. The source text matches a coarse JSX-open-tag regex.

The last three are toggles (`skipNodeModules`, `requireClassName`, `requireJsxSyntax`), all on by
default, and there is no glob support.

**The adapter finds your config.** Vela walks up from each file's directory looking for
`vela.config.ts` or `vela.config.json`. The `.ts` form wins when a directory has both. A TypeScript
config is stripped of its `vela-rbxts` imports, transpiled, and executed in a scope where
`defineConfig` and `defaultConfig` are injected. A TypeScript diagnostic throws, as does an export
that is neither a resolved config nor an input-shaped object. Finding nothing gets you the default
theme. See [config discovery](https://docs.astra-void.xyz/vela-rbxts/reference/config.md#discovery) for the caching.

**The native compiler parses and lowers.** It walks the JSX tree and resolves each class token
against the merged theme. Out comes new source text plus a diagnostic list.

**The text goes back out**, as text rather than an AST. Under the transformer it is parsed into a
fresh `SourceFile` and substituted in memory. Diagnostics are handed back through roblox-ts's
`addDiagnostic`. **On a roblox-ts that does not expose that hook they are dropped silently.** Under
the CLI the text is written to the mirrored path. It is skipped when byte-identical, so `rbxtsc -w`
is not woken. The CLI prints the diagnostics itself.

## What gets touched

An element is lowered if it is a supported host element or a component. Vela recognizes exactly
eight host elements:

`frame`, `scrollingframe`, `canvasgroup`, `textlabel`, `textbutton`, `textbox`, `imagelabel`, `imagebutton`

A component is any tag starting with an uppercase letter, such as `<Panel />`, or any member
expression such as `<Switch.Root />`, at any depth. A namespaced tag like `<svg:rect />` is never a
component.

Anything else is skipped with its `className` left untouched, reporting
`classname-on-unsupported-host`. That covers lowercase intrinsics Vela does not implement, like
`screengui`, and namespaced tags. The warning names only the local part, so `<svg:rect />` is
reported as `rect`.

### `className` on components

`<Panel className="p-4 bg-slate-800" />` is lowered the way a host element is. On the static path
the resolved props become ordinary JSX attributes on the component, `className` is removed, and
helper instances are prepended as children. A self-closing component that needs helpers gains a
closing tag:

```tsx title="In"
<Box className="bg-slate-700 rounded-md" />
```

```tsx title="Out (shape, not verbatim)"
<Box BackgroundColor3={Color3.fromRGB(49, 65, 88)}>
  <uicorner CornerRadius={new UDim(0, 6)} />
</Box>
```

> **The component has to forward what it does not consume**
>
> Vela hands the component props and children. It cannot make the component do anything with them. If `Box` does not spread its unrecognized props onto a Roblox host element and render its children, the styling is lost. **There is no diagnostic for that**, at compile time or in the editor.
>
> A component library can be built to receive this shape. [lattice-ui](https://docs.astra-void.xyz/lattice-ui/index.md) forwards unknown props from every part and re-parents Vela's helper instances under the element the props land on. [Styling Lattice with Vela](https://docs.astra-void.xyz/lattice-ui/guides/styling-with-vela.md) is a worked example.

The eventual host element is unknown, so the per-element utility restrictions do not apply inside a
component's `className`. The editor offers the full set there and never raises
`unsupported-host-utility`. Whether `text-lg` means anything depends on which host your component
renders, and nothing checks that.

## The two lowering paths

Which one you get changes the shape of the output and whether anything runs at runtime.

### Static lowering

The default. The compiler resolves every token to a concrete value, emits Roblox properties on the
element, prepends helper instances as children, and removes the `className`. The element keeps its
tag, and nothing from Vela survives into the running game. This path supports the full utility set.

It also emits two properties you did not ask for. A host element carrying a `className` starts from
`BackgroundTransparency = 1` and `BorderSizePixel = 0`. No class list has to paint over the opaque
grey box Roblox gives every `GuiObject`. Anything that actually paints opts back out, and a
background painted by a variant reopens it at runtime. Components are never preflighted. See
[`preflight`](https://docs.astra-void.xyz/vela-rbxts/reference/config.md#preflight).

### The runtime path

When Vela cannot finish at compile time it swaps the element's tag for `VelaRuntimeHost`, imported
from `@rbxts/vela-runtime` and configured once at the top of the module body. Static props it did
resolve are emitted with an `as never` cast, since the host's prop type does not describe them.
Conditional rules are serialized into `__velaRules`, their conditions into `__velaTests`, and the
original tag travels as `__velaTag`. Structural work rides along in further internal props:
`__velaTransition`, `__velaAnimation`, `__velaText`, `__velaMargin` and `__velaDivide`.

For a host element, `__velaTag` is the tag string. For a component it is a live reference,
`__velaTag={Box}`, so the host renders your component rather than an intrinsic.

At runtime the host normalizes theme strings into `Color3` and `UDim` values. It reads the
environment, applies whichever rules match, tokenizes any dynamic `className`, strips the internal
props, and calls `React.createElement` with the original tag and the helper children. It reads
`Workspace.CurrentCamera.ViewportSize` for width, height, orientation and rem. It reads
`UserInputService.TouchEnabled` / `MouseEnabled` / `GamepadEnabled` for input mode, and
`MouseEnter`/`MouseLeave` on the element for `hover:`.

The host is a package, not a copy. One ModuleScript the whole place shares, over the target-neutral
`@rbxts/vela-runtime-core`. A transformed module carries an import and its config:

```ts title="What a runtime-path module emits"
import { createVelaRuntimeHost } from "@rbxts/vela-runtime";
const VelaRuntimeHost = createVelaRuntimeHost({ preflight: true, theme: { … }, plugins: { … } });
```

A [Vide](https://docs.astra-void.xyz/vela-rbxts/guides/vide.md) project imports `@rbxts/vela-runtime-vide` in the same position.

> **Most files send an emptied theme**
>
> That config carries only what your project changed. The runtime holds the defaults itself, so an untouched scale sends `{}`. A file whose host never has to *parse* a class value sends its scales emptied entirely. One that does, through a computed `className` or a host taking a spread, keeps the full tables. `preflight`, `theme.rem` and the motion driver stay either way, and an emptied table is marked in `theme.replaced` so the runtime takes it as given.

### What triggers the runtime path

Any one of these is sufficient.

**A `className` expression that does not collapse to one static token list.** A template literal
with an interpolation, a variable, a call, a spread. A **branch** is on this list only because it
needs the host to read its conditions. `a ? "x" : "y"` names its tokens in the source. The compiler
resolves every one of them, with diagnostics, and hands the element the resolved props alongside the
tests.

**Any token carrying a variant prefix.** A variant depends on the live environment, so even a plain
static string literal forces the runtime path:

```tsx title="Static string, runtime path"
<frame className="w-full sm:w-1/2" />
```

The trigger is the variant, not the dynamism. The cost is an import.

**A structural utility.** Margins (except the static `mx-auto`/`my-auto`), `divide-*` and
`animate-*` build wrapper frames, separator frames or animation loops. None can be a static prop.
The one motion utility that does *not* promote is `transition-*` alone. With nothing to animate, it
warns `transition-without-runtime` and is dropped.

> **The in-game token resolver understands far less than the compiler**
>
> It handles the colour, background, radius, padding, margin, gap and sizing prefixes, plus `divide-*`, the case transforms, the decorations and the motion families. Every other utility inside a *text* `className` the host has to parse is dropped with no diagnostic. That covers layout, alignment, text size, position and constraints.
>
> This limit applies to text and nothing else. Variant-prefixed tokens and the tokens inside a branch
> go through the static resolver and are only *evaluated* at runtime, which is why
> `big ? "text-lg" : "text-sm"` works while `` `text-${size}` `` silently does not.

The full list of divergences is in [Dynamic class names](https://docs.astra-void.xyz/vela-rbxts/guides/dynamic-class-names.md).

## Five rules that keep you on the static path

Almost every "my class did nothing" report comes from an element that quietly took the runtime path.

1. **Write `className` as a plain string literal.** A literal inside a `.map()` callback is still a
   literal — what matters is the expression in the attribute.
2. **When the look changes between known states, write the branch out**, as `[base, on ? "bg-blue-600" : "bg-slate-700"]`, rather than interpolating a computed string. That keeps both looks resolved and checked. Branching the whole JSX element additionally avoids the host.
3. **When a number changes continuously, set the property directly**, as `Size={UDim2.fromScale(health, 1)}`, and let the classes carry what does not change. Never set the same property from both a class and a prop. On a collision, Vela emits after you and the class wins.
4. **Give every element an explicit size.** Roblox starts instances at zero, so a missing `h-*` is an
   invisible element, not a small one.
5. **Prefer `gap-*` to margins.** Margins build a wrapper frame, which forces the runtime path even in
   a plain literal — and they sum with the parent's gap.

Variants are the deliberate exception: `hover:` on a control, a breakpoint on a handful of top-level
containers. What does not earn the host is an *opaque* string written where a readable one would do.
The runtime resolver lowers everything the static path does, so what you lose is not the classes but
every check on them.

## Next step

Get the honest boundaries of the project in
[Scope and status](https://docs.astra-void.xyz/vela-rbxts/getting-started/scope-and-status.md).
