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, 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
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 of 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 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.

  1. State a component styles by is mirrored, not reached for. Lattice keeps its contexts private. A wrapper holds the value with useControllableState 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. One tucked into a shared spread reaches the primitive as a raw prop and is dropped silently.
  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. 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 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 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-row are on the roadmap.
  • Pinning is per project, not per component, 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 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.

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. 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 ThemeProvider alongside classes is the tempting middle option and probably the trap.
  • Whether facet diff should 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 className prop. 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 its className as a string.
  • Whether the registry should make asChild’s missing label easier. asChild works, verified in Studio: the recipe’s background, size, hover variant and re-parented modifiers all cross 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. See Button.