Facet is early, but 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 from
scratch. The registry 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 and
doctor. Each is covered by tests that run a fixture project 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 of 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 and it renders in the previews, which is a weaker claim.
The distinction has already cost something. Compiling is a static result, and reading the emitted Luau was not enough either. card’s description had no font-*, 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 writes an immutable
r/<sha>/ beside it. Pin one through the registry field in facet.json — see
pinning a revision.
facet add edits your client entry, once. dialog declares a PortalProvider. A missing one
is the only failure here that happens at runtime rather than build time. 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. A wrapper holds the value with
useControllableStateand 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. One tucked into a shared spread reaches the primitive as a raw prop and is dropped silently. - 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 theme tokens, rendered by Loom. What it is not is Roblox. Loom reimplements the layout and text measurement rather than running 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, someone opened Studio and looked.
- The geometry of the one-primitive tier is unverified. All twelve compile and render. Thumb travel, range fill, scrollbar placement and textarea growth are what Studio still has to answer for.
dialog’s geometry is unverified too. 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 the preview cannot show, since 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 in at chosen versions. That is the only way to assert on a project sitting below a floor, but it does not compile the result. The playground app build checks that, and CI runs both. - No image icons. Facet renders
▾,✓,✕as text glyphs and exposes the slot, which is a settled position, not a gap. - No blocks.
login-form,settings-panel,inventory-grid,shop-roware on the roadmap. - Pinning is per project, not per component, so
facet 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 the kind of controlled/uncontrolled logic that belongs in Lattice rather
than a copied file. A
one-item toggle-group covers it
meanwhile.
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 declares the same PortalProvider dialog does, so the entry edit happens once.
dropdown-menu wraps @lattice-ui/react-menu, and alert-dialog and sheet are 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. A build carries one mode, so a settings menu with a light/dark toggle has no
answer today. The leaning is to do nothing now and eventually get a Vela-side token indirection —
which Facet cannot make unilaterally. A runtime
ThemeProvideralongside classes is the tempting middle option and probably the trap. - Whether
facet diffshould fetch the text a component was copied from. Revisions make it possible. But it needs a record of which revision each component came from, which reopens provenance. 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. See why. Stop advertising it, keep it as the internal composition slot it is, or push for a Vela change that hands a component itsclassNameas a string. - Whether the registry should make
asChild’s missing label easier.asChildworks, verified in Studio: the recipe’s background, size, hover variant and re-parented modifiers all crossSlotintact. 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. See Button.