# Scope and status

> What exists at 0.4.0, what has actually been looked at in Studio, and which decisions are still open.

Source: https://docs.astra-void.xyz/facet/getting-started/scope-and-status/

Facet is early. The whole chain works end to end — all three packages are on npm, the registry is
live, and `npm i -D facet-rbxts` → `facet init` → `facet add button` → `rbxtsc` compiles in a
project set up from scratch — and the registry now holds twenty-one components. What has *not* been
checked is how most of them look.

## What ships at 0.4.0

**Every command the CLI advertises is written.** `list`, `init`, `add`, `remove`, `diff`, `doctor` —
all implemented, all covered by tests that put a fixture project through them offline against a
registry built into a temporary directory. See the [CLI reference](https://docs.astra-void.xyz/facet/reference/cli.md).

**The registry holds twenty-three items** — twenty-one components and the two helpers they import.

The **pure-recipe tier** is a recipe plus a host element, with no Lattice primitive underneath:

| Item | |
| --- | --- |
| [`alert`](https://docs.astra-void.xyz/facet/components/alert.md) | three parts, default or destructive — and the variant goes on each |
| [`badge`](https://docs.astra-void.xyz/facet/components/badge.md) | status pill that hugs its label |
| [`card`](https://docs.astra-void.xyz/facet/components/card.md) | six flat parts — root, header, title, description, content, footer |
| [`kbd`](https://docs.astra-void.xyz/facet/components/kbd.md) | key cap, and the registry's one deliberate typeface |
| [`label`](https://docs.astra-void.xyz/facet/components/label.md) | form label |
| [`separator`](https://docs.astra-void.xyz/facet/components/separator.md) | one-pixel divider, either orientation |
| [`skeleton`](https://docs.astra-void.xyz/facet/components/skeleton.md) | placeholder block, deliberately without a pulse |

The **one-primitive tier** wraps a single Lattice primitive, new in 0.4.0:

| Item | |
| --- | --- |
| [`accordion`](https://docs.astra-void.xyz/facet/components/accordion.md) | collapsible items, single or multiple open |
| [`avatar`](https://docs.astra-void.xyz/facet/components/avatar.md) | image with a text fallback, and the circle is the wrapper's |
| [`checkbox`](https://docs.astra-void.xyz/facet/components/checkbox.md) | checked, indeterminate, disabled |
| [`progress`](https://docs.astra-void.xyz/facet/components/progress.md) | determinate or indeterminate bar |
| [`radio-group`](https://docs.astra-void.xyz/facet/components/radio-group.md) | exactly one item checked |
| [`scroll-area`](https://docs.astra-void.xyz/facet/components/scroll-area.md) | viewport with a drawn overlay scrollbar |
| [`slider`](https://docs.astra-void.xyz/facet/components/slider.md) | track, range fill, draggable thumb |
| [`switch`](https://docs.astra-void.xyz/facet/components/switch.md) | toggle with an animated thumb |
| [`tabs`](https://docs.astra-void.xyz/facet/components/tabs.md) | list, triggers, switched panels |
| [`text-field`](https://docs.astra-void.xyz/facet/components/text-field.md) | single-line input with label, description, message |
| [`textarea`](https://docs.astra-void.xyz/facet/components/textarea.md) | multi-line input that grows with its text |
| [`toggle-group`](https://docs.astra-void.xyz/facet/components/toggle-group.md) | two-state buttons, single or multiple pressed |

Plus [`button`](https://docs.astra-void.xyz/facet/components/button.md), which is its own case — a recipe, a label recipe, and
`asChild` — and the **layered tier**, which starts here:

| Item | |
| --- | --- |
| [`dialog`](https://docs.astra-void.xyz/facet/components/dialog.md) | modal with overlay, header, footer, close — and a provider your app has to have |

`utils` (`cn`, and `ClassValue` re-exported as `ClassName`) and `text` (`TextSlot`) are the two
`registry:lib` items everything else imports.

> **Five of the twenty-one have been opened in Studio**
>
> `button`, `badge`, `card`, `label` and `separator` have been rendered and looked at. **Everything
> else has not** — it compiles through `rbxtsc`, it renders in the playground and in the previews on
> these pages, and that is a weaker claim.
>
> The distinction is worth keeping because it has already cost something: compiling is a static
> result, and Roblox runtime behavior does not show up in it. Reading the emitted Luau was not enough
> either — `card`'s description had no `font-*` in it, so it kept Roblox's LegacyArial default and was
> the only thing on screen in another typeface. Nobody caught that from the source.

**The registry is versioned.** Every push republishes the moving `r/`, and also writes an immutable
`r/<sha>/` that never changes again. A project pins one through the `registry` field `facet.json`
already had — see [pinning a revision](https://docs.astra-void.xyz/facet/reference/facet-json.md#pinning-a-revision).

**`facet add` edits your client entry, once.** `dialog` declares a `PortalProvider`, and a missing
one is the only failure on this page that happens at *runtime* rather than at build time. So the CLI
parses the entry, asks, and writes it — see [wiring a provider](https://docs.astra-void.xyz/facet/reference/cli.md#wiring-a-provider).

## What the one-primitive tier established

Three rules came out of building twelve components on twelve primitives, and every component after
them inherits all three. They are written up in
[Component conventions](https://docs.astra-void.xyz/facet/guides/component-conventions.md#wrapping-a-lattice-primitive).

1. **State a component styles by is mirrored, not reached for.** Lattice keeps its contexts private,
   so a wrapper holds the value itself with `useControllableState` — the same hook the primitive
   uses — and drives the primitive controlled.
2. **A `className` on a primitive call site has to be visible to the transformer.** Vela rewrites the
   call sites it can *see*; a `className` tucked into a shared spread reaches the primitive as a raw
   prop and is dropped in silence.
3. **Where the primitive owns a property, the recipe stays off it.** The slider's track carries no
   `flex-*`, the switch's thumb no position, the textarea's input no height.

## What is not covered

> **A preview is not Studio**
>
> Every component page carries a live preview, and it is the real thing as far as it goes: the actual
> registry source, lowered by the actual Vela compiler against the actual `@facet-ui/theme` tokens,
> rendered by [Loom](https://docs.astra-void.xyz/loom/index.md). What it is *not* is Roblox. Loom reimplements Roblox's layout and text
> measurement; it does not run the engine, and its fidelity is
> [deliberately partial](https://docs.astra-void.xyz/loom/getting-started/scope-and-status.md).
>
> So a preview is strong evidence and weak proof. Where these pages state what something looks like on
> screen, it is because someone opened Studio and looked — that is still the only claim worth making
> about runtime behavior, and it is a different and stronger claim than "the preview looks right".

- **The geometry of the one-primitive tier is unverified.** All twelve compile and render, but what
  the type system cannot see is thumb travel, range fill, scrollbar placement and textarea growth.
  Those are the four things Studio still has to answer for that tier.
- **`dialog`'s geometry is unverified too**, and its three questions are separate: whether the panel
  lands centred, whether the dim covers the screen beneath it, and whether the close ✕ reaches the
  panel's right edge. The last one the preview *cannot* show — Loom does not lay out what `self-end`
  lowers to. See [the corner ✕](https://docs.astra-void.xyz/facet/components/dialog.md#the-corner--is-not-a-corner-).
- **No `rbxtsc` check in the CLI's own test suite.** `test/e2e.test.ts` runs `init`, `add` and
  `doctor` against a temporary registry, offline, with packages faked into `node_modules` at chosen
  versions — which is the only way to assert on a project sitting below a floor. What it does not do
  is compile the result. The playground app build is what checks that, and CI runs both on every
  branch and pull request.
- **No image icons.** Facet renders `▾`, `✓`, `✕` as text glyphs and exposes the slot, so a project
  that wants real artwork passes its own. That is a
  [settled position](https://docs.astra-void.xyz/facet/guides/component-conventions.md#7-icons-are-text-glyphs-replaceable-by-slot),
  not a gap.
- **No blocks.** `login-form`, `settings-panel`, `inventory-grid`, `shop-row` are on the roadmap,
  after the singles settle.
- **Pinning is per project, not per component.** A revision says "this project builds against that
  registry", not "`button` came from that revision and `card` from this one", so `facet diff` still
  cannot attribute a change. See
  [Updating copied components](https://docs.astra-void.xyz/facet/guides/updating-copied-components.md#pinning-a-registry-revision).

## What is coming, in order

The build order is by what each component needs underneath it, because that is how the conventions
get proven before anything complicated depends on them.

**Nothing beneath them** — pure recipe plus a host element. **Done.**

`aspect-ratio` came off this list rather than getting built: Vela lowers `aspect-*` onto
`UIAspectRatioConstraint`, so it is
[a class rather than a component](https://docs.astra-void.xyz/facet/guides/component-conventions.md#5-layout-is-an-instance-not-a-property).

**One Lattice primitive** — **done as of 0.4.0**, pending Studio verification.

`toggle` came off this list without being built: `@lattice-ui/react-toggle` does not exist, and a
standalone pressed state is exactly the controlled/uncontrolled logic that belongs in Lattice rather
than in a copied file. It returns when the primitive does — or a
[one-item `toggle-group`](https://docs.astra-void.xyz/facet/components/toggle-group.md#toggle-is-not-in-the-registry) covers it.

**Layered** — needs portals, focus trapping, or popper. [`dialog`](https://docs.astra-void.xyz/facet/components/dialog.md) is
built; the rest is `alert-dialog` · `popover` · `tooltip` · `dropdown-menu` · `context-menu` ·
`select` · `combobox` · `toast` · `sheet` · `command`.

Each of them declares the same `PortalProvider` `dialog` does, so the entry edit happens once.
`dropdown-menu` will wrap `@lattice-ui/react-menu` — there is no `react-dropdown-menu` — and
`alert-dialog` and `sheet` are both `react-dialog` again with different chrome.

**Blocks** — multi-file compositions, once the singles settle: `login-form` · `settings-panel` ·
`inventory-grid` · `shop-row`.

**Roblox-native, with no shadcn counterpart**, worth their own pass rather than being wedged into
that list: `viewport` (a `ViewportFrame` with a model), `billboard`, `surface`, `player-list`,
`hotbar`.

## Decisions that are settled

Each of these is written down in the repo with the reasoning kept where it can be argued with.

| Decision | Short version |
| --- | --- |
| **Text is a prop** | `Text?: string` on every component that draws a string; `children` stays composition. The compiler leaves no other option. [→](https://docs.astra-void.xyz/facet/guides/text-and-labels.md) |
| **`cn` does not merge conflicts** | Vela's last-token-wins is what `tailwind-merge` exists to fake, so ordering discipline replaces a merge pass. [→](https://docs.astra-void.xyz/facet/guides/variants-and-classes.md#the-one-rule) |
| **One registry style** | The `style` field stays in `facet.json`, pinned to `"default"`. A second style does not arrive. |
| **Nothing is recorded at copy time** | No `facet.lock`, no hashes. A hash answers *whether* a file changed; a diff has to show *how*. [→](https://docs.astra-void.xyz/facet/guides/updating-copied-components.md#why-there-is-no-lock-file) |
| **A revision is a commit** | Every push publishes an immutable `r/<sha>/` beside the moving `r/`, and pinning is the `registry` field that already existed — so no CLI change, no format change, and a CLI released months ago can pin today. [→](https://docs.astra-void.xyz/facet/reference/registry-format.md#revisions) |
| **Icons are text glyphs** | Replaceable by slot. This was listed as blocking the layered components; it does not. [→](https://docs.astra-void.xyz/facet/guides/component-conventions.md#7-icons-are-text-glyphs-replaceable-by-slot) |
| **Ratio is a class, not a component** | Vela lowers `aspect-*` onto the native constraint, so a wrapper would add an instance to carry what an existing instance carries. [→](https://docs.astra-void.xyz/facet/guides/component-conventions.md#5-layout-is-an-instance-not-a-property) |
| **One recipe object per file** | Luau allows 200 module-scope locals and Vela inlines its runtime per file, so every export costs a register. `card` stopped loading over exactly this. [→](https://docs.astra-void.xyz/facet/guides/component-conventions.md#6-flat-named-exports) |
| **The scrim is black** | The one class in the registry that names a colour instead of a role. No role is dark in both modes, and a token is too large a commitment for one class in one component. [→](https://docs.astra-void.xyz/facet/components/dialog.md#the-scrim) |
| **A component declares its own providers** | `RegistryItem.providers`, so the next layered component is wired correctly by a CLI released before it existed. [→](https://docs.astra-void.xyz/facet/reference/registry-format.md#providers) |
| **npm OIDC trusted publishing** | No `NPM_TOKEN` in repository settings. Publishes from GitHub Actions with provenance attestations. |

## Decisions that are open

- **Runtime theming.** Vela resolves classes at compile time, so a build carries one mode. A
  settings menu with a light/dark toggle has no answer today. The leaning is: do nothing now, and
  eventually get a Vela-side token indirection — which Facet cannot make unilaterally. The tempting
  middle option, a runtime `ThemeProvider` alongside classes, is probably the trap: it buys runtime
  theming at the cost of the property that makes copy-in work, which is that a component's
  appearance is entirely described by its classes.
- **Whether `facet diff` should fetch the text a component was copied from.** Revisions make it
  *possible* — the base text is now addressable rather than something you would have to store — but
  it needs a record of which revision each component came from, which reopens
  [provenance](https://docs.astra-void.xyz/facet/guides/updating-copied-components.md#why-there-is-no-lock-file). Nothing is
  built.
- **`facet create`** — scaffolding a new roblox-ts project preconfigured for Facet, as Lattice's CLI
  does.
- **What to do about the inert `className` prop.** Every component advertises one, and a class
  passed to it from a Vela-compiled call site is dropped in silence —
  [why](https://docs.astra-void.xyz/facet/guides/variants-and-classes.md#overriding-from-the-call-site). The options are to stop
  advertising it, to keep it as the internal composition slot it actually is, or to push for a Vela
  change that hands a component its `className` as a string instead of pre-resolving it. Nothing is
  decided.
- **Whether the registry should make `asChild`'s missing label easier.** `asChild` itself is no
  longer in question — it works as of Lattice 0.8.0, verified in Studio against a bare `<textbutton>`
  child, with the recipe's background, size, hover variant and re-parented `UICorner`/`UIListLayout`/
  `UIPadding` all crossing `Slot` intact. What does *not* cross is the label: `TextSlot` never
  renders on that path, so the child draws its own text at Roblox's 8px near-black default.
  `buttonLabelVariants` is exported so a consumer can state it themselves, but whether that pairing
  should be documented, automated, or something else is unresolved. See
  [Button](https://docs.astra-void.xyz/facet/components/button.md#aschild).
