Facetgetting started

Scope and status

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

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-rbxtsfacet initfacet add buttonrbxtsc 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
alertthree parts, default or destructive — and the variant goes on each
badgestatus pill that hugs its label
cardsix flat parts — root, header, title, description, content, footer
kbdkey cap, and the registry’s one deliberate typeface
labelform label
separatorone-pixel divider, either orientation
skeletonplaceholder block, deliberately without a pulse

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

Item
accordioncollapsible items, single or multiple open
avatarimage with a text fallback, and the circle is the wrapper’s
checkboxchecked, indeterminate, disabled
progressdeterminate or indeterminate bar
radio-groupexactly one item checked
scroll-areaviewport with a drawn overlay scrollbar
slidertrack, range fill, draggable thumb
switchtoggle with an animated thumb
tabslist, triggers, switched panels
text-fieldsingle-line input with label, description, message
textareamulti-line input that grows with its text
toggle-grouptwo-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
dialogmodal 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.

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.

  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

  • 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 ✕.
  • 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, 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.

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 primitivedone 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.

DecisionShort version
Text is a propText?: string on every component that draws a string; children stays composition. The compiler leaves no other option.
cn does not merge conflictsVela’s last-token-wins is what tailwind-merge exists to fake, so ordering discipline replaces a merge pass.
One registry styleThe style field stays in facet.json, pinned to "default". A second style does not arrive.
Nothing is recorded at copy timeNo facet.lock, no hashes. A hash answers whether a file changed; a diff has to show how.
A revision is a commitEvery 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 glyphsReplaceable by slot. This was listed as blocking the layered components; it does not.
Ratio is a class, not a componentVela lowers aspect-* onto the native constraint, so a wrapper would add an instance to carry what an existing instance carries.
One recipe object per fileLuau 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 blackThe 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 providersRegistryItem.providers, so the next layered component is wired correctly by a CLI released before it existed.
npm OIDC trusted publishingNo 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. 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. 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.