Developing Lucy
Toolchain
Pinned in rokit.toml. Install once with rokit install, then wally install for TestEZ (into DevPackages/, gitignored).
| Tool | Use |
|---|---|
| rojo | sync source into Studio, build places |
| wally | TestEZ dev dependency, publishing goatsoft/lucy |
| lune | scripts/add.luau CLI, scripts/gen-palette.luau |
| selene / stylua | lint and format |
| run-in-roblox | headless test runs in CI |
| luau-lsp | type checking (editor or luau-lsp analyze) |
Daily loop
- Serve the dev place (port 38574 is in the project file):
rojo serve test.project.json - In Studio, open any place (or
rojo build test.project.json -o ~/tmp/LucyTests.rbxland open that) and connect the Rojo plugin. - Run the specs from the command bar:
require(game.ReplicatedStorage.LucyTests.Runner).run() require(game.ReplicatedStorage.LucyTests.Runner).run({ pattern = "Button" })runreturns{ passed, failed, skipped, errors, ok }. - Look at a component without pressing Play:Press Play for the story browser (
require(game.ReplicatedStorage.Lucy).Preview.show("Button", { root = game.ReplicatedStorage.LucyExamples })examples/Showcase.client.luau, mapped into StarterPlayerScripts), where every control is clickable. - After a change that could affect speed, run the benchmarks and compare with the published numbers:
require(game.ReplicatedStorage.LucyTests.Bench.Run).run()
Keep build output and scratch files outside the repo (for example ~/tmp).
TDD
- Add or extend
X.spec.luaunext to the module. Describe behaviour, not implementation. - Run it, watch it fail for the right reason.
- Implement until green, then tidy.
Spec helpers (src/Testing/Harness.luau):
Harness.app(config)→app, cleanup: an isolated App with a deterministic desktop input profile and no engine listeners. Always callcleanupinafterEach.Harness.caps.desktop / mobile / tablet / consoleforapp.input:setCaps(...).Harness.mount(app)→ a linked test ScreenGui to parent components into.Harness.flush()→ wait for Universal Styling to apply (a few frames).Harness.styled(instance, prop)→ the resolved value (GetStyled).Harness.colorEq(a, b)→ Color3 compare within 1/255.
Gotchas:
- Never assert styled values with plain property reads; they return the unstyled value by design.
- Property-changed signals are deferred;
task.wait()before asserting on a value set through a signal (see Input.spec). - Do not set styled properties from component code; it overrides the theme.
Writing a component
src/Components/Thing.spec.luaufirst.src/Components/Thing.luau:Annotate the rules list as--!strict local Component = require(script.Parent.Parent.Core.Component) local Thing = {} local rules: { Component.Rule } = { { selector = ".ui-thing", priority = 0, properties = { BackgroundColor3 = "$Card" } }, } Thing.rules = rules function Thing.new(props: Component.Props?): Component.Handle return Component.mount({ kind = "thing", props = props, rules = Thing.rules, defaults = { text = "", visible = true }, -- the keys that live in state render = function(h) return Component.create("TextLabel", { Tags = { "ui-thing" } }) end, update = function(h, state) h.Instance.Text = state.text; h.Instance.Visible = state.visible end, }) end return Thing{ Component.Rule }so rules with different properties type-check together.- Visual properties go in
ruleswith$Tokenvalues; layout and behaviour in code. - Sizes that should grow on touch or console come from density tokens (
Component.metric(app, "ControlMd")withComponent.onDensity). - Anything interactive:
Interactable/Selectablefollowdisabled, a keyboard path exists (handle:click()orhandle:toggle()), and it works inside focus scopes. - Add a story,
examples/stories/Thing.story.luau, with controls for its state keys (stories).tests/Stories.spec.luaumounts every story in each theme and device. - Add it to
src/init.luau,registry.json(with dependencies) anddocs/components.md. - Grep for the em-dash character before committing:
grep -rn $'\xe2\x80\x94' .must return nothing.
Static checks
rojo sourcemap test.project.json -o sourcemap.json
luau-lsp analyze --sourcemap=sourcemap.json --definitions=@roblox=globalTypes.d.luau \
--definitions=@testez=testez.d.luau src tests
selene src tests examples
stylua --check src tests scripts examples
Every source file starts with --!strict and luau-lsp analyze must report nothing. Specs stay in the default nonstrict mode (.luaurc) since TestEZ’s globals are loosely typed. Classes declare their type next to the metatable (export type AppState = typeof(setmetatable({} :: {...}, AppState))) and write methods as function AppState.get(self: AppState, ...).
Regenerating the palette
lune run scripts/gen-palette <path-or-url> # source URL is in docs/adr/0010
The website
The site in apps/site renders this docs/ folder (everything except adr/) at /docs/..., plus a landing page. It needs Node 24 and pnpm.
pnpm install
pnpm site:dev # local dev server
pnpm site:build # prerendered static site in apps/site/dist
pnpm site:typecheck
- A Markdown file added under
docs/becomes a page. Frontmattertitle,descriptionandorderset its sidebar entry; the sidebar groups are inapps/site/src/lib/docs.ts. - Links between docs files work on GitHub and on the site; links to ADRs go to GitHub.
- Pushes to
mainthat touch the site or docs deploy it via.github/workflows/pages.yml. Deploying never publishes the package. - If a build logs a hydration mismatch,
LUCY_DEBUG_HYDRATION=1 pnpm site:buildexplains it in the browser console.
Releasing
- Bump
versioninwally.toml, note changes in the README. wally publish.