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.
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 | three parts, default or destructive — and the variant goes on each |
badge | status pill that hugs its label |
card | six flat parts — root, header, title, description, content, footer |
kbd | key cap, and the registry’s one deliberate typeface |
label | form label |
separator | one-pixel divider, either orientation |
skeleton | placeholder block, deliberately without a pulse |
The one-primitive tier wraps a single Lattice primitive, new in 0.4.0:
| Item | |
|---|---|
accordion | collapsible items, single or multiple open |
avatar | image with a text fallback, and the circle is the wrapper’s |
checkbox | checked, indeterminate, disabled |
progress | determinate or indeterminate bar |
radio-group | exactly one item checked |
scroll-area | viewport with a drawn overlay scrollbar |
slider | track, range fill, draggable thumb |
switch | toggle with an animated thumb |
tabs | list, triggers, switched panels |
text-field | single-line input with label, description, message |
textarea | multi-line input that grows with its text |
toggle-group | two-state buttons, single or multiple pressed |
Plus button, which is its own case — a recipe, a label recipe, and
asChild — and the layered tier, which starts here:
| Item | |
|---|---|
dialog | 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.
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.
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.
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.
- 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. - A
classNameon a primitive call site has to be visible to the transformer. Vela rewrites the call sites it can see; aclassNametucked into a shared spread reaches the primitive as a raw prop and is dropped in silence. - 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
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. 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.
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 whatself-endlowers to. See the corner ✕.- No
rbxtsccheck in the CLI’s own test suite.test/e2e.test.tsrunsinit,addanddoctoragainst a temporary registry, offline, with packages faked intonode_modulesat 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, not a gap. - No blocks.
login-form,settings-panel,inventory-grid,shop-roware on the roadmap, after the singles settle. - Pinning is per project, not per component. A revision says “this project builds against that
registry”, not “
buttoncame from that revision andcardfrom this one”, sofacet diffstill cannot attribute a change. See Updating copied components.
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.
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 covers it.
Layered — needs portals, focus trapping, or popper. dialog 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. → |
cn does not merge conflicts | Vela’s last-token-wins is what tailwind-merge exists to fake, so ordering discipline replaces a merge pass. → |
| 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. → |
| 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. → |
| Icons are text glyphs | Replaceable by slot. This was listed as blocking the layered components; it does not. → |
| 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. → |
| 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. → |
| 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. → |
| A component declares its own providers | RegistryItem.providers, so the next layered component is wired correctly by a CLI released before it existed. → |
| 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
ThemeProvideralongside 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 diffshould 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. 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
classNameprop. Every component advertises one, and a class passed to it from a Vela-compiled call site is dropped in silence — why. 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 itsclassNameas a string instead of pre-resolving it. Nothing is decided. - Whether the registry should make
asChild’s missing label easier.asChilditself 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-parentedUICorner/UIListLayout/UIPaddingall crossingSlotintact. What does not cross is the label:TextSlotnever renders on that path, so the child draws its own text at Roblox’s 8px near-black default.buttonLabelVariantsis exported so a consumer can state it themselves, but whether that pairing should be documented, automated, or something else is unresolved. See Button.