Three layers, and Facet is the thinnest
| Layer | Owns | Package |
|---|---|---|
| Lattice UI | Behavior — focus, layering, presence, portals, controlled/uncontrolled state, asChild | @lattice-ui/react-* |
| Vela | Styling — className lowered to Roblox properties at compile time | vela-rbxts |
| Facet | The opinionated composition of the two, as source you own | the registry |
Do not reimplement in a copied component what Lattice or Vela already owns. A Facet component
that grows focus management should be fixed upstream in the Lattice primitive. One that grows a
literal Color3 has stopped being themeable.
That division is what makes the copy-in model viable. The file you own is composition: recipes, a host element, prop plumbing. It is short because everything difficult sits in a package that is a dependency.
The registry is fetched, not bundled
registry/ in the Facet repo builds to a static site on GitHub Pages, and the CLI reads it over the
network at runtime:
https://facet.astra-void.xyz/ schema.json the JSON Schema every facet.json names in its own $schema r/index.json the index the CLI reads first r/button.json one payload per component, source text inlinedTwo consequences, both intentional:
- Adding a component to Facet means publishing the registry, not releasing the CLI.
facet add buttonproduces the same files for everyone, rather than whatever was frozen into their CLI version.
The cost is that add needs a network — the same trade shadcn/ui makes, and the failure is loud
rather than silently stale. You can point elsewhere: see
Using another registry.
The index carries a version field, currently 1. A CLI that meets a higher one refuses to guess
and tells you to upgrade.
What add does to a file
Between the registry payload and your project, exactly one transformation happens: ~/ imports
are rewritten.
Registry sources address each other with a ~/ prefix, because the CLI can rewrite ~/lib/utils
and cannot rewrite ../lib/utils:
import { TextSlot } from "~/lib/text";import { cn } from "~/lib/utils";The first segment names an alias (ui, lib, hooks), which facet.json maps to a directory. How
the rewrite lands depends on whether that alias has an import specifier:
import { TextSlot } from "../lib/text";import { cn } from "../lib/utils";import { TextSlot } from "shared/lib/text";import { cn } from "shared/lib/utils";Relative is the default because it needs no tsconfig paths. It is computed from where the
importing file lands, so moving your ui directory does not break the lib imports inside it.
An unknown alias segment is left alone, so it fails loudly at typecheck rather than being rewritten to something wrong.
Nothing else is touched — no formatting, no codemod, no injected header.
Where files land
A registry file’s path is <alias>/<name>, and the item’s type decides the alias:
| Item type | facet.json alias | Default directory |
|---|---|---|
registry:ui | ui | src/shared/ui |
registry:lib | lib | src/shared/lib |
registry:hook | hooks | src/shared/hooks |
registry:block | ui | src/shared/ui |
dir is a real path from the project root rather than a module specifier, because roblox-ts
projects are laid out by Rojo.
One transaction
Every write goes through a transaction that commits or rolls back as a unit. A failure halfway
through facet add card cannot leave a card.tsx whose helpers were never written.
And, for one kind of component, one edit to a file you wrote
An item may declare providers — a React provider it
needs above your whole app. Today that is dialog, which cannot find a
PlayerGui to portal into.
This is the only place the CLI touches a file it did not write, and it asks first. Everything else it reports fails at build time. A missing provider fails at runtime, in production. See wiring a provider.
What the copied component then depends on
A Facet component may import from four places only, enforced by review, because anything else will not resolve once copied:
@rbxts/*@lattice-ui/*@facet-ui/react-variants~/...— other registry files
Each of those must be declared in the registry entry as a dependency (npm) or a
registryDependency (another item). An undeclared import ships a file that cannot compile.
Where the theme actually lives
Roblox has no CSS variables, so the indirection that makes shadcn/ui themeable lives one layer down
— in vela.config.ts:
import { defineConfig } from "vela-rbxts";import { facetTheme } from "@facet-ui/theme";
export default defineConfig({ theme: { extend: { ...facetTheme({ base: "zinc", mode: "dark" }), }, },});Components name roles (bg-primary, text-muted-foreground), never ramp steps. facetTheme()
maps those roles onto a neutral ramp, and Vela resolves them to literal Color3 values at compile
time, so switching base rethemes every copied component.
Because resolution happens at compile time, mode: "dark" is a build-time choice, not a runtime toggle. Roblox has no equivalent of a browser re-reading CSS variables on every paint. See Theming.
What Facet does not do
- It does not record anything at copy time. No lock file, no hashes, no note of which registry version a file came from. That is why
facet diffcan show that a file differs but not whether you changed it. See Updating copied components. - It does not update. There is no
facet upgrade. The most the CLI can do is show a diff. - It does not have a second style.
facet.jsonkeeps shadcn’sstylefield pinned to"default", and nothing branches on it.