# Configuration

> How vela.config.ts is found and loaded, the complete theme schema, the built-in defaults, and the tsconfig plugin options.

Source: https://docs.astra-void.xyz/vela-rbxts/reference/config/

Vela reads one optional config file, covering the theme, whether [preflight](#preflight) neutralizes
the Roblox host defaults, and which UI [`framework`](#framework) the emit targets. There is no build
configuration to write — file selection, output and diagnostics all come from `tsconfig.json`.

## Discovery

Two filenames are recognized, exactly: `vela.config.ts` and `vela.config.json`. There are no `.js`,
`.mjs`, `.cjs`, or `.mts` variants, and no `package.json` key. When a directory contains both, the
`.ts` file wins.

Vela walks upward from each source file's directory and uses the first match, falling back to
`defaultConfig`. A config in a subdirectory therefore shadows the repo root's for the files beneath
it — usually accidental rather than intended.

The JSON form holds the same input shape as `defineConfig`'s argument, plus an optional `$schema`
key that is stripped before parsing. The package ships a `schema.json` you can point it at for
editor validation:

```json title="vela.config.json"
{
  "$schema": "./node_modules/vela-rbxts/schema.json",
  "theme": {
    "extend": {
      "colors": { "brand": { "500": "Color3.fromRGB(99, 102, 241)" } }
    }
  }
}
```

> **Resolution runs per file; the load runs once per build**
>
> The walk up from each source file runs per file, which is what lets two directories carry different configs. The load is resolved once per directory per build and re-read only when the file changes. A config that throws is cached too, so a typo is reported once and the next edit lifts it. Keep the file cheap regardless: it is executed as real code.

## How the TypeScript form is loaded

Vela does not hand the file to roblox-ts. It loads it itself, in three steps:

1. Every `import ... from "vela-rbxts"` statement is stripped from the source text.
2. The remainder is transpiled with `ts.transpileModule` to CommonJS with diagnostics reporting on.
3. The result is executed as a function with `exports`, `require`, `module`, `__filename` and `__dirname`. `defineConfig` and `defaultConfig` are injected as arguments too, which is the part that matters.

The injection is why step 1 is safe: the import goes, its bindings stay in scope.

Three consequences. **The file is transpiled, not type-checked**, so a type error passes silently.
**Module specifiers resolve relative to the config file**, never through `tsconfig.json` `paths`.
And **the export must be usable**: a resolved `TailwindConfig` or an input-shaped object. Anything
else throws, naming the key that failed.

```ts title="vela.config.ts"
import { defineConfig } from "vela-rbxts";

export default defineConfig({
  theme: {
    extend: {
      colors: {
        brand: {
          500: "Color3.fromRGB(99, 102, 241)",
          700: "Color3.fromRGB(67, 56, 202)",
        },
      },
    },
  },
});
```

## Schema

The schema is five optional keys: `preflight`, `theme`, `plugins` since 0.7.0, `framework` since
0.12.0, and `presets` since 0.13.0. There is no `content`, `darkMode`, `prefix`, `important`,
`corePlugins`, `safelist`, or `variants` option. Those keys do not exist in the type and are ignored
if you write them anyway.

| Prop | Type | Description |
| --- | --- | --- |
| `presets` | `readonly (TailwindConfigInput \| TailwindConfig)[]` | Shareable slices of configuration folded in before this config resolves. Added in 0.13.0. They resolve after the built-in defaults and in array order, so the config that names them always outranks what it pulled in. definePreset() types one without resolving it. A vela.config.json can inline a preset object but cannot import one from a package. |
| `framework` | `"react" \| "vide"` | Which UI library the project's JSX compiles for. Default react. Added in 0.12.0. Left unset it is inferred from the nearest tsconfig.json: a compilerOptions.jsxFactory beginning with Vide. selects Vide. Writing the key at all — with either value — stops that inference. |
| `preflight` | `boolean` | Default true. Neutralizes the Roblox host defaults on every supported host element that carries a className. Set false to restore the pre-0.5.0 behavior, where Vela only ever added properties. |
| `theme.colors` | `Record<string, string \| Partial<Record<Shade \| "DEFAULT", string>>>` | Replaces the entire color registry. A value is either a literal roblox-ts expression string or a map from shade (50…950, plus the optional DEFAULT that a bare family name resolves to) to one. |
| `theme.radius` | `Record<string, string>` | Replaces the entire radius scale. Each value is a roblox-ts expression producing a UDim. |
| `theme.spacing` | `Record<string, string>` | Replaces the entire spacing scale. Each value is a roblox-ts expression producing a UDim. |
| `theme.fontFamily` | `Record<string, string>` | Replaces the entire font family scale. Each value is a Roblox font family asset path — a rbxasset:// font JSON or an uploaded rbxassetid://. Added in 0.7.0; the keys are what font-{family} looks up. |
| `theme.screens` | `Record<string, number>` | Replaces the breakpoint scale that the responsive variants are named after, in pixels of viewport width. Added in 0.13.0. Defaults: sm 640, md 768, lg 1024, xl 1280, 2xl 1536. Every key gives both a min-width prefix and its max- complement, so one entry named tablet defines tablet: and max-tablet:. |
| `theme.rem` | `{ base?: number; min?: number; max?: number; baseResolution?: { x: number; y: number }; pinnedUnder?: string[] }` | How one rem resolves against the viewport, which is what every pixel offset a utility lowers is measured in. Added in 0.12.0. Defaults: base 16, min 8, max 64, baseResolution 1920×1020, pinnedUnder ["surfacegui", "billboardgui"]. A record rather than a keyed scale, so it merges field by field and theme.extend.rem behaves identically — except pinnedUnder, the one list among them, which replaces, since a list that merged could never say "none". |
| `plugins` | `VelaPlugin[] \| { utilities, motion }` | Plugins that register utility classes of their own and can replace the motion driver. Added in 0.7.0. In vela.config.json, state the resolved object form directly. |
| `theme.extend.colors` | `Record<string, string \| Partial<Record<Shade \| "DEFAULT", string>>>` | Merges over the default color registry, per family and — when both sides are palettes — per shade. Setting DEFAULT on a family is what makes a bare bg-brand resolve. |
| `theme.extend.radius` | `Record<string, string>` | Shallow-merges over the default radius scale by key. |
| `theme.extend.spacing` | `Record<string, string>` | Shallow-merges over the default spacing scale by key. |
| `theme.extend.fontFamily` | `Record<string, string>` | Shallow-merges over the default font family scale by key. |
| `theme.extend.screens` | `Record<string, number>` | Shallow-merges over the default breakpoint scale by key, so a new name joins sm…2xl instead of replacing them. |
| `theme.extend.rem` | `{ base?: number; min?: number; max?: number; baseResolution?: { x: number; y: number } }` | The same field-by-field merge theme.rem does. rem is the one family where the replace-versus-extend distinction does not exist, because it is a record of four settings rather than a scale of keys. |

The valid shades are `50` through `950`, plus one non-numeric key: `DEFAULT`, which a bare family
name resolves to — so `bg-brand` needs no shade. It is a config key only: `bg-brand-DEFAULT` is read
as the semantic key `brand-DEFAULT` and reported as `unknown-theme-key`. Every built-in palette
ships a `DEFAULT` mirroring its `500`.

An empty palette object throws `Color palette normalization requires at least one shade value.`, but
only where the palette is normalized. That means a family not already in the registry, or any family
under a top-level `theme.colors`. Extending an existing palette takes the merge path instead, so
`theme.extend.colors: { blue: {} }` throws nothing and does nothing.

### framework

Which UI library the project's JSX compiles for. `"react"` is the default, and `"vide"` emits for
[Vide](https://centau.github.io/vide/). Most projects never write it: left unset, the target is
inferred from `tsconfig.json`.

```ts title="vela.config.ts"
export default defineConfig({ framework: "vide" });
```

It decides two things: the module specifier the emit imports for its runtime host, and the reactive
shape of what is handed to that host. Everything else is target-neutral, and a statically lowered
element is byte-identical under both.

Inference walks up to the nearest `tsconfig.json`, follows a **relative** `extends` up to eight
levels, and reads `compilerOptions.jsxFactory`. A factory beginning with `Vide.` selects Vide. It is
keyed on whether the config *names* the key, so `framework: "react"` pins React even under a Vide
`jsxFactory`.

The [Vide guide](https://docs.astra-void.xyz/vela-rbxts/guides/vide.md) covers what changes when you write the code.

### preflight

Roblox paints every `GuiObject` as an opaque grey box with a 1px border. A supported host element
carrying a `className` starts from `BackgroundTransparency = 1` and `BorderSizePixel = 0` instead,
so a class list says everything about how the element looks.

```tsx
<frame className="w-20 h-10" />
// → <frame Size={UDim2.fromOffset(80, 40)} BorderSizePixel={0} BackgroundTransparency={1} />

<frame className="w-20 h-10 bg-slate-800" />
// → <frame BackgroundColor3={Color3.fromRGB(29, 41, 61)} Size={UDim2.fromOffset(80, 40)} BorderSizePixel={0} />
```

The transparency is only added when nothing else paints the element. A `bg-*`, `opacity-*`, a
gradient stop or a transparency prop of your own all opt back out, and `BorderSizePixel` is skipped
when you declared it. A background painted by a variant reopens the element at runtime. Never
touched: an element with **no `className`**, and a **component**.

```ts title="vela.config.ts"
export default defineConfig({ preflight: false });
```

Turning it off means Vela only ever adds the properties your classes name, and the gray default
shows through wherever you do not paint over it.

### Values are roblox-ts expression strings

Every value in the theme is a string containing a roblox-ts expression. On the static path Vela
parses it and splices the expression into the TSX it emits. It is not a colour object, not a hex
string, and not a number.

```ts title="vela.config.ts"
export default defineConfig({
  theme: {
    extend: {
      colors: { ink: "Color3.fromRGB(17, 17, 17)" },
      radius: { pill: "new UDim(0.5, 0)" },
      spacing: { gutter: "new UDim(0, 20)" },
    },
  },
});
```

`"#111111"` and `4` both fail, at different times. `4` is rejected at load. `"#111111"` is a string
nothing validates, so it falls back to a *string literal*. That is a roblox-ts type error on the
next build rather than a config error. The
[theming guide](https://docs.astra-void.xyz/vela-rbxts/guides/theming.md#values-are-source-code-not-values) has the three shapes
side by side.

> **The runtime path re-parses theme values from text**
>
> The splice above is the **static** path only. A class resolved at runtime carries your theme as serialized text. Two Luau parsers re-read it, accepting exactly `Color3.fromRGB(r, g, b)` and `new UDim(a, b)` with numeric arguments. Anything else compiles fine and then **silently degrades at runtime**, with no diagnostic, on dynamically classed elements only.

## Merge semantics

For `radius`, `spacing` and `fontFamily`, a top-level key replaces the whole scale and `extend`
shallow-merges over the defaults by key. For `colors`, `extend` merges per family, and per shade
when both the default and your value are palettes. A literal replaces a palette wholesale, and vice
versa.

> **theme.colors silently discards theme.extend.colors**
>
> If `theme.colors` is present, `theme.extend.colors` is **not applied at all** — it is dropped
> without warning, and every built-in palette is dropped with it. This diverges from real Tailwind,
> where `extend` is layered on top of a replaced `colors`.
>
> A config with `theme.colors.surface` and `theme.extend.colors.brand` resolves to a theme containing
> only `surface` — neither `brand-500` nor `slate-700` resolves.
>
> Add colours with `theme.extend.colors` alone. **Every axis behaves this way** — a top-level `theme.radius`, `theme.spacing` or `theme.fontFamily` discards both the defaults and its own `extend` counterpart in the same silence.

## The default theme

`defaultConfig` is the resolved form of the built-in defaults, and it is what you get when no
`vela.config.ts` is found.

### Colors

Twenty-eight families. Two are literals: `black` and `white`. The other twenty-six are palettes with
all eleven shades (`50` through `950`), each also carrying a `DEFAULT` that mirrors that palette's
`500`:

`slate`, `gray`, `zinc`, `neutral`, `stone`, `red`, `orange`, `amber`, `yellow`, `lime`, `green`,
`emerald`, `teal`, `cyan`, `sky`, `blue`, `indigo`, `violet`, `purple`, `fuchsia`, `pink`, `rose`,
`mauve`, `olive`, `mist`, `taupe`.

The first twenty-two track the Tailwind palette. `mauve`, `olive`, `mist` and `taupe` are Vela
additions.

### Radius

Ten named keys, plus a `DEFAULT` that bare `rounded` resolves to.

| Key | Value |
|---|---|
| `DEFAULT` | `new UDim(0, 4)` |
| `none` | `new UDim(0, 0)` |
| `xs` | `new UDim(0, 2)` |
| `sm` | `new UDim(0, 4)` |
| `md` | `new UDim(0, 6)` |
| `lg` | `new UDim(0, 8)` |
| `xl` | `new UDim(0, 12)` |
| `2xl` | `new UDim(0, 16)` |
| `3xl` | `new UDim(0, 24)` |
| `4xl` | `new UDim(0, 32)` |
| `full` | `new UDim(0.5, 0)` |

`rounded-*` is a pure lookup with no numeric fallback, so this is the complete set until you extend
it.

### Spacing

One key: `"4"`, mapped to `new UDim(0, 16)`.

There is no built-in spacing scale. `p-2`, `gap-6` and `w-40` come from the compiler's arithmetic
fallback: any unsigned finite multiple of `0.5`, giving `new UDim(0, key * 4)`. A negative number, a
finer fraction or a non-numeric word reports `unknown-theme-key`. Use `theme.extend.spacing` for
named `p-gutter`-style tokens.

### fontFamily

Three keys. Each value is a Roblox font family asset path, not an expression.

| Key | Value |
|---|---|
| `sans` | `rbxasset://fonts/families/SourceSansPro.json` |
| `serif` | `rbxasset://fonts/families/Merriweather.json` |
| `mono` | `rbxasset://fonts/families/RobotoMono.json` |

`sans` is what an element gets when no `font-{family}` is present. Any Roblox font family works,
including one you uploaded:

```ts title="vela.config.ts"
export default defineConfig({
  theme: {
    extend: {
      fontFamily: {
        display: "rbxassetid://12345678",
        body: "rbxasset://fonts/families/Nunito.json",
      },
    },
  },
});
```

That gives `font-display` and `font-body`. `font-*` resolves the fixed weight names first and reads
anything else as a key here. A payload that is neither reports `unknown-theme-key`. Unlike the other
axes, these are **not** roblox-ts expressions: they go into `Font`'s first argument as written.

### screens

The viewport widths, in pixels, the responsive variants are named after.

| Key | Default |
|---|---|
| `sm` | `640` |
| `md` | `768` |
| `lg` | `1024` |
| `xl` | `1280` |
| `2xl` | `1536` |

Every key gives two prefixes, a minimum width and its `max-` complement, so one entry is all a
custom breakpoint takes:

```ts title="vela.config.ts"
export default defineConfig({
  theme: { extend: { screens: { tablet: 900 } } },
});
```

That defines both `tablet:` (≥ 900) and `max-tablet:` (< 900). `theme.screens` replaces the scale
outright, removing `sm` through `2xl`, so any class still naming one reports `unknown-breakpoint`.
`theme.extend.screens` merges by key instead.

### rem

These decide what one rem is worth, and every pixel offset a utility lowers is measured in rem — see
[the theming guide](https://docs.astra-void.xyz/vela-rbxts/guides/theming.md#rem).

| Key | Default | What it is |
|---|---|---|
| `base` | `16` | Pixels per rem at `baseResolution` |
| `min` | `8` | Lower clamp, in pixels |
| `max` | `64` | Upper clamp, in pixels |
| `baseResolution` | `{ x: 1920, y: 1020 }` | The viewport `base` is calibrated against |
| `pinnedUnder` | `["surfacegui", "billboardgui"]` | Containers whose subtree keeps literal pixels. Since 0.12.5 |

```ts title="vela.config.ts"
export default defineConfig({
  theme: {
    rem: { base: 16, min: 8, max: 64, baseResolution: { x: 1920, y: 1020 } },
  },
});
```

`rem` is a record of settings rather than a scale of keys, so the replace-versus-extend rule does
not apply. **`theme.rem` and `theme.extend.rem` do the same thing**, merging field by field.
`pinnedUnder` is the exception: it replaces, and emptying it puts both containers back on the curve.

A `SurfaceGui` or `BillboardGui` takes its pixel space from the part it is drawn on rather than the
viewport, so following the curve there is wrong. The container element in the JSX opens a pin, and
what is written under it lowers to literal offsets. A container the compiler never sees is outside
this. Pin such a project with `rem: { min: 16, max: 16 }`. An inverted clamp collapses onto `min`
during resolution.

## Plugins

A plugin is a function that receives an API object and registers things on it. The `plugin()` helper
from `@vela-rbxts/config` wraps one, with an optional name.

```ts title="vela.config.ts"
import { defineConfig, plugin } from "@vela-rbxts/config";

export default defineConfig({
  plugins: [
    plugin(({ addUtilities, theme }) => {
      addUtilities({
        btn: "bg-blue-600 rounded-lg px-4 py-2",
        panel: { BorderSizePixel: "0", BackgroundColor3: theme("colors.zinc.950") },
      });
    }, { name: "acme" }),
  ],
});
```

Plugin functions run **while the config resolves**, not while a file compiles, so the compiler, the
runtime host and the LSP all receive the same plain table.

### addUtilities

A registered utility is either a **utility class list** or a **Roblox property map**.

| Form | Example | Meaning |
|---|---|---|
| Class list | `btn: "bg-blue-600 rounded-lg px-4"` | Expands to those utilities |
| Property map | `panel: { BorderSizePixel: "0" }` | Assigns those properties directly |

Property-map values follow the same rule as theme values: they are
[roblox-ts expression strings](#values-are-roblox-ts-expression-strings), not numbers or objects.

Four things are true of a registered utility:

- **It takes variants.** `hover:btn` builds a hover rule out of everything `btn` expands to.
- **It resolves on both paths.** A `btn` inside a dynamic `className` works.
- **It can reach through another.** `"btn-lg": "btn text-lg"` expands `btn` in turn.
- **It sorts ahead of the plain utilities**, so a `bg-*` written beside one still wins:
  `className="btn bg-rose-600"` is rose, regardless of token order.

> **A cycle expands to nothing, silently**
>
> The depth cap that stops `{ a: "b", b: "a" }` recursing terminates the expansion rather than reporting it. The element comes out carrying none of the utilities the cycle named. If a plugin utility does nothing at all, check whether it reaches back into itself.

### addVariant

The states a UI has of its own, such as a panel being open or a row selected, have no fixed list for
Vela to guess at. A plugin registers one against a Roblox attribute on the styled instance:

```ts title="vela.config.ts"
plugin(({ addVariant }) => {
  addVariant("open", { attribute: "State", equals: "open" });
  addVariant("premium", { attribute: "Tier", equals: 3 });
});
```

That gives an `open:` prefix reading `instance:GetAttribute("State")`. `equals` takes a string,
number or boolean, and `attr-[State=open]:` reads one inline. Both forms compose with every other
variant and are checked, completed and sorted like a built-in one. Only the condition travels to the
runtime. An `attr-[…]` that does not parse reports `malformed-attribute-variant`.

### theme()

`theme("colors.blue.600")` reads the **resolved** theme, defaults merged and `extend` applied, and
returns the roblox-ts expression string for that key. A second argument is used when the path is
missing.

### Motion driver

`setMotionDriver` replaces TweenService as what executes
[`transition` and `animate-*`](https://docs.astra-void.xyz/vela-rbxts/reference/utilities.md#motion).

```ts title="vela.config.ts"
plugin(({ setMotionDriver }) => {
  setMotionDriver({ module: "@acme/springs", export: "driver" });
});
```

The runtime host imports that module and calls its `transition` and `animate` methods.
**Each method is taken over on its own**, so a driver that implements only `transition` keeps the
built-in `animate-*` presets on TweenService.

They have to be **methods**, not properties holding arrows:

```ts title="@acme/springs"
export const driver = {
  transition(instance: Instance, goal: object, spec: { time: number }) { … },
  animate(instance: Instance, preset: string) { … },
};
```

roblox-ts compiles a method with an implicit `self` and an arrow without one, so the two shapes are
not interchangeable across the call. The runtime calls them as methods and types them as methods, so
the arrow form is a compile error.

**A driver is also handed the helper instances.** Tweens on `UICorner`, `UIStroke` and `UIShadow`
arrive with a fourth argument naming which helper is moving. It is additive, so a three-argument
driver keeps working. The driver is imported by *every* transformed module that needs one, so the
specifier must be a package name or a path relative to your `baseUrl`. A relative `./` specifier is
rejected at load:

```
plugins.motion set the motion driver module to "./driver". A relative path cannot resolve
from every module that imports the driver; use a package name or a baseUrl-relative path.
```

Omit `export` to import the module's default export.

### The JSON form

`vela.config.json` cannot hold functions, so it states the **resolved** shape that plugins produce:

```json title="vela.config.json"
{
  "plugins": {
    "utilities": {
      "btn": "bg-blue-600 rounded-lg px-4 py-2",
      "panel": { "BorderSizePixel": "0" }
    },
    "motion": { "module": "@acme/springs" }
  }
}
```

It is the same object the TypeScript form produces once its plugin functions have run, so you give
up `theme()` and any logic, not any capability.

## Presets

A preset is a shareable slice of configuration: a design system's theme, plugins, utilities and
variants, folded into a project in one line.

```ts title="vela.config.ts"
import { defineConfig } from "vela-rbxts";
import { gameUiPreset } from "@acme/game-ui/vela";

export default defineConfig({
  presets: [gameUiPreset()],
  theme: { extend: { colors: { brand: { 500: "Color3.fromRGB(99, 102, 241)" } } } },
});
```

Presets resolve **after the built-in defaults and before the config naming them**, in written order.
It is a fold over configuration *inputs* rather than a merge of finished configs, so a preset
replacing `theme.radius` stays extendable by the project's `theme.extend.radius`. `definePreset()`
types a preset without resolving it:

```ts title="@acme/game-ui/vela.ts"
import { definePreset } from "@vela-rbxts/config";

export const gameUiPreset = () =>
  definePreset({
    theme: { extend: { screens: { tablet: 900 } } },
  });
```

A `vela.config.json` can inline a preset object but cannot import one from a package.

## tsconfig plugin options

The plugin entry object in `tsconfig.json` is passed straight through as options. These are the keys
that are expressible in JSON.

| Prop | Type | Description |
| --- | --- | --- |
| `filter.skipNodeModules` | `boolean` | Default true. Skip any file whose path contains a node_modules segment. |
| `filter.requireClassName` | `boolean` | Default true. Skip any file whose source text does not contain the substring className. |
| `filter.requireJsxSyntax` | `boolean` | Default true. Skip any file whose source text does not match an opening-JSX-tag pattern. |
| `diagnosticCodeBase` | `number` | Default 89000. The first numeric diagnostic code; each diagnostic in a file gets base + index. |
| `projectRoot` | `string` | Defaults to the program's current directory. Currently inert — nothing resolves paths from it; discovery walks up from each file being compiled. |
| `config` | `TailwindConfig` | An explicit resolved config used for every file. It overrides the discovered config but does not skip discovery, which still runs and can still fail the build. |

```json title="tsconfig.json"
{
  "compilerOptions": {
    "plugins": [
      {
        "transform": "vela-rbxts/transformer",
        "filter": { "skipNodeModules": true },
        "diagnosticCodeBase": 89000
      }
    ]
  }
}
```

## File eligibility

A file is transformed only if it passes all five checks, applied in this order:

1. The filename ends with `.tsx`, case-insensitively. **A `.ts` file is never transformed**, whatever
   is in it.
2. It is not a declaration file — no `.d.ts` or `.d.tsx`.
3. Its path contains no `node_modules` segment, unless `filter.skipNodeModules` is off.
4. Its source text contains the literal substring `className`, unless `filter.requireClassName` is
   off.
5. Its source text matches an opening-JSX-tag pattern, unless `filter.requireJsxSyntax` is off.

There is no glob support — the three booleans above are the only controls. Checks 4 and 5 are text
scans, so a file mentioning `className` only in a comment still passes.

## See also

- [Theming](https://docs.astra-void.xyz/vela-rbxts/guides/theming.md) for a working walkthrough of extending the theme.
- [Utility reference](https://docs.astra-void.xyz/vela-rbxts/reference/utilities.md) for the classes that read these theme keys.
- [API](https://docs.astra-void.xyz/vela-rbxts/reference/api.md) for `defineConfig` and `defaultConfig`.
