Written by facet init at the project root, committed with the project, and read by every command
except list.
{ "$schema": "https://facet.astra-void.xyz/schema.json", "style": "default", "theme": { "base": "zinc", "mode": "dark" }, "aliases": { "ui": { "dir": "src/shared/ui" }, "lib": { "dir": "src/shared/lib" }, "hooks": { "dir": "src/shared/hooks" } }, "velaConfig": "vela.config.ts"}It is parsed loosely and normalized against those defaults, so a hand-edited config missing a key picks the default up rather than crashing mid-copy.
$schema
The JSON Schema the file advertises, for editor completion and validation. It is generated from
the CLI’s FacetConfig type by the deploy that publishes the registry, so it cannot lag the CLI.
style
"default", and there is no second value.
shadcn shipped two styles and has since deprecated default, so the field outlived the second
style. It is worse here. Nothing about a Roblox component is verifiable by reading it, so a second
style doubles the set that has to be opened in Studio and looked at.
The field stays because facet.json is a file you commit, and removing a key is a breaking change
for the benefit of deleting one line. Nothing branches on it, and it is typed as the literal
"default" rather than string, so any code that starts branching has to change the type first.
theme
"theme": { "base": "zinc", "mode": "dark" }| Field | Values | |
|---|---|---|
base | "zinc" · "slate" · "stone" · "neutral" | Neutral ramp the semantic tokens derive from |
mode | "dark" · "light" | Which side of the ramp they resolve to |
These are what init writes into the generated vela.config.ts, and what doctor uses to print
the right facetTheme({ … }) snippet if the config is unwired.
The theme lives in vela.config.ts, and init never rewrites an existing one. Changing facet.json afterwards changes what the CLI would suggest, not what your build resolves. See Theming.
aliases
Where each class of file lands, and how other copied files import it.
"aliases": { "ui": { "dir": "src/shared/ui", "import": "shared/ui" }, "lib": { "dir": "src/shared/lib", "import": "shared/lib" }, "hooks": { "dir": "src/shared/hooks", "import": "shared/hooks" }}| Field | |
|---|---|
dir | Directory from the project root. A real path, because roblox-ts projects are laid out by Rojo, not by module resolution. |
import | The specifier other copied files use to reach this directory. Omit for relative imports. |
Which alias a file lands under is decided by its registry item type:
| Item type | Alias |
|---|---|
registry:ui | ui |
registry:lib | lib |
registry:hook | hooks |
registry:block | ui |
Relative is the default, because it needs no tsconfig paths. With import set, the rewrite is
textual: ~/lib/utils becomes shared/lib/utils. Without it, the CLI computes a relative path from
the importing file’s destination, so ~/lib/utils inside src/shared/ui/button.tsx becomes
../lib/utils.
If you set import, your tsconfig needs a matching paths entry. facet doctor fails on this
rather than warning, because every copied component imports through it. It accepts the specifier
itself or any wildcard that would cover it — "shared/ui", "shared/ui/*" and "shared/*" all
count.
It only affects what the next add writes. To re-resolve an existing component, re-add it with
--overwrite — and commit first, because that replaces the file wholesale.
velaConfig
"velaConfig": "vela.config.ts"Path to the project’s Vela config, from the project root. init creates it if it does not exist,
and nothing ever rewrites it. add and doctor read it to check whether Facet’s tokens are
supplied.
That check is textual — it looks for @facet-ui/theme in the source. A config that spells the
tokens out by hand instead of spreading facetTheme is unwired, but not necessarily broken.
doctor then looks for each required token by name as a colour key. That is the most that can be
checked without evaluating a file that is arbitrary TypeScript.
registry
"registry": "https://ui.example.com/r"Optional. A URL or a filesystem path. Omit for the published registry at
https://facet.astra-void.xyz/r.
This is the right place to point at a fork or a private registry for a team. Everyone who clones the
project gets the same source without remembering a --registry flag. It is overridden by
--registry on the command line and by FACET_REGISTRY_DIR in the environment. See
Using another registry.
Pinning a revision
https://facet.astra-void.xyz/r moves — every push to Facet’s main republishes it. The same
push also writes an immutable copy under the commit that produced it, and this field is how you pin
one:
{ "registry": "https://facet.astra-void.xyz/r/a1b2c3d" }Every command reads it. add copies from that revision, diff compares against it, list shows
what it holds, and doctor checks it. Each prints which registry it used.
revisions.json lists what exists.
This is deliberate, and it is the whole reason the versioning scheme is shaped this way. registry has been in facet.json since init first wrote one, for forks and private registries. A CLI released long before revisions existed can pin one today. Nothing about the format changed either: a pinned revision is an ordinary registry base that happens never to move.
Pinning trades currency for stability, and the trade is real in both directions. A pinned project gets a registry that cannot shift under it, and stops receiving fixes until someone changes this line. See Updating copied components for when that is worth doing.