Found by an adversarial review of the Build page before it was ever run — all
three reviewers independently flagged the first one.
1. The "what is this object?" cards were dead. chooserCard parented a
Size=fromScale(1,1) TextButton into Theme.panel() intending an overlay, but
Theme.panel() contains a UIListLayout, which lays out EVERY GuiObject child —
there is no opt-out, and ZIndex does not affect layout. So the button became
another list row: clicking a card's title/summary did nothing, and the
oversized button spilled past the card and took the click for the card BELOW,
applying the WRONG component (which also strips the previous component's
attributes). This was the page's primary interaction.
Fixed with Theme.panelButton() — the card itself is the button, matching the
pattern OverviewPage already uses.
2. Clear did nothing on an object whose only tag was unknown to this engine,
yet reported success. The page renders exactly that branch with a Clear
button. BuildAdmin.clear now takes the tags to remove explicitly, and the page
passes the unknown tags it just displayed — never removing an unlisted tag
speculatively, since it may belong to another plugin. It also reports
"nothing to clear" instead of a false success.
3. FieldRow's help text set Position inside a UIListLayout parent, so its indent
was silently dropped and every help line rendered flush left. Uses padding now.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Select a Part or Model in Studio, answer "what is this object?", fill a form —
it becomes a gatherable node, a mob or a quest giver. Closes the gap between
authoring a def and setting up a world object, which until now meant knowing to
tag a part and hand-typing PascalCase attributes in the property panel.
Engine — components can declare an attribute SCHEMA:
- src/components/Schema.luau (new): AttributeSpec/Display/ComponentSchema types,
normalize/defaults/get/list, and the schemas for Gatherable, Mob, QuestGiver.
Dependency-free ON PURPOSE: the plugin requires it live at edit time, and the
component modules themselves can't be required there (Harvesting asserts
IsServer; Remotes creates instances in ReplicatedStorage).
- Components.define now accepts EITHER the legacy `attr = default` map or a
schema array, normalizing both to one ordered spec list; bind() reads the
derived default map, so binding is byte-identical. Legacy maps are sorted, as
`pairs` order is arbitrary and would make a UI jitter. New getSchema/
listSchemas. The three shipped components pull name/tag/display/attributes
from the schema; their onSetup bodies are untouched (defaults verified
identical, all 23 attributes).
Plugin — the Build page:
- Field.luau (new): coerce/format/equalsDefault, lifted from ConfigAdmin (which
now delegates), shared by every schema-driven editor.
- FieldRow.luau (new): the shared [○/●] label … control + help row, including a
⌄ picker that cycles authored ids for fields declaring `ref`.
- BuildAdmin.luau (new): live schema read with three distinct empty states,
selection/eligibility/identify, deltas-only attribute writes, applyType
(tag + clear any other component) and clear.
- BuildAdminUi.luau (new): chooser cards, grouped form, multi-select apply,
stale-bind-marker warning, SelectionChanged-driven refresh.
- init.server.luau: record()-wrapped buildActions + the page.
Docs: docs/admin-plugin.md Build section + a 60-second walkthrough,
docs/extending.md schema guide, a CONTRIBUTING rule that new creator components
declare one, CHANGELOG.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>