Tabs 2–5 (Layout, Design, Interactions, Advanced) are driven entirely by a property registry. Sections and the controls inside them are described as plain data — SectionDef and PropertyDef — and the sidebar renders them declaratively. No…
Tabs 2–5 (Layout, Design, Interactions, Advanced) are driven entirely by a
property registry. Sections and the controls inside them are described as
plain data — SectionDef and PropertyDef — and the sidebar renders them
declaratively. No hand-coded JSX per panel.
Two things:
Built-ins live in registry/properties/ and self-register at
module load. The barrel (registry/index.ts) imports them all so
import "./registry" in RegistrySettings.tsx is enough to populate the
registry.
interface SectionDef {
id: SectionId; // unique — e.g. "spacing", "typography"
title: string; // header label — "Spacing"
tab: SettingsTab; // "component" | "layout" | "design" | ...
icon?: ReactNode;
keywords: string[]; // search keywords (lowercase)
hideKey?: HideKey; // hidden when toolbar.hide[] contains this
sortOrder: number; // lower = higher in tab. Default 100.
help?: string;
searchOnly?: boolean; // only appears in search results
defaultOpen?: boolean; // accordion starts open
advancedColumns?: number; // grid columns for "More X properties"
advancedSubsections?: SectionAdvancedSubsection[]; // nested groups
skipAdvancedToggle?: boolean;
}
Built-in section IDs include component, modifiers, layout, alignment,
spacing, size, typography, background, border, decoration,
hover-click, conditions, animations, effects, scroll-effect,
overflow-scroll, properties, aria, display, ai-context,
import-export. The type allows any string for custom sections.
interface PropertyDef {
id: string; // unique — usually matches TailwindStyles key
label: string;
section: SectionId; // which section this lives in
keywords: string[];
input: PropertyInput; // discriminated union — see below
propKey?: string; // override if different from id
propType?: string; // default: "class" — see items-and-inputs.md
index?: string; // breakpoint/state — "hover", "sm", "md"
hideKey?: HideKey;
sortOrder?: number;
searchOnly?: boolean;
inline?: boolean; // label + input on same row
showWhen?: (className, props) => boolean;
advancedGroup?: string; // hidden behind "More X properties"
help?: string;
}
input is a discriminated union of input flavors:
"tailwind-select" // dropdown over a TailwindStyles[<key>] array
"tailwind-radio" // radio buttons over a TailwindStyles[<key>] array
"universal" // UniversalInput — value-aware multi-mode input
"color" // ColorInput — picker with class-prefix routing
"checkbox" // toggle with on/off classes
"text" // plain text input
"select" // dropdown over an explicit options[]
"custom" // bring-your-own component
See items-and-inputs.md for what each one renders and how the
propType / propKey flow works.
propertyRegistry.ts exposes a small mutator API exported from @pagehub/sdk:
registerSectionDef(def: SectionDef): void; // idempotent by id (replaces)
unregisterSectionDef(id: SectionId): void; // also drops orphan properties
registerPropertyDef(def: PropertyDef): void; // idempotent by id (replaces)
unregisterPropertyDef(id: string): void;
Read accessors:
getSectionDefs({ tab }): SectionDef[]; // sorted by sortOrder
getSectionDef(id): SectionDef | undefined;
getProperties(sectionId): PropertyDef[]; // sorted by sortOrder
searchProperties(query): Map<sectionId, PropertyDef[]>;
External SDK consumers can override built-ins by calling
registerPropertyDef/registerSectionDef with the same id. Calling
unregister* removes them. There's no per-app namespace — first/last write
wins by id.
sectionDefs.tsSection defs are declared in registry/sectionDefs.ts (search for
registerSectionDef in the registry properties). Property defs live alongside
related ones in registry/properties/ — e.g. typography props in
typography.tsx, spacing in SpacingBody.tsx + companion files, etc.
Properties grouped by section ID:
| Section | File |
|---|---|
typography | registry/properties/typography.tsx |
spacing, size, alignment | layout.tsx, SpacingBody.tsx, AlignmentBody.tsx |
background, border, decoration | background.tsx, appearance.ts |
effects, animations, scroll-effect | effects.ts |
hover-click, conditions | interactions.tsx |
display | display.tsx |
aria | aria.ts |
advanced-settings, properties, import-export | advanced.tsx |
const SECTIONS_BY_TAB = Object.fromEntries(
TAB_IDS.map(tabId => [tabId, getSectionDefs({ tab: tabId }).filter(s => !s.searchOnly)])
);
Computed once at module load. Each universal tab's <StaticTabContent>
iterates these and dispatches:
<ComponentSection> for the component slot (calls the per-node MainTab).<AdvancedSettingsSection> for advanced-settings.<PropertySection sectionId={id} /> for everything else, including modifiers (now a list-style section with ModifierChipList body + ModifiersAddPicker header + — see editor-popover-pattern.md §8).<PropertySection> (PropertySection.tsx) is declarative:
getSectionDef(sectionId) for chrome (title, icon, help).getProperties(sectionId) for inputs.showWhen(className, props) to decide visibility.<PropertyRenderer def={...} /> for each visible property.PropertySection always mounts. When hideKey is in HiddenKeysAtom or
showWhen filters away every prop, the section renders in a disabled,
collapsed state. It stays in the tree at a stable position so React diffs
are minimal.
A component opts out of universal sections via toolbar.hide on its craft def:
toolbar: {
settings: TextMainTab,
hide: ["typography", "background"],
}
RegistrySettings.tsx:183-195 syncs toolbar.hide[] into HiddenKeysAtom
on selection change:
const hideKey = toolbar?.hide?.join(",") ?? "";
useEffect(() => {
setHiddenKeys(hideKey ? new Set(hideKey.split(",") as HideKey[]) : new Set());
}, [id, hideKey, setHiddenKeys]);
Sections (and properties) check HiddenKeysAtom.has(def.hideKey) to decide
whether to disable themselves.
A property with advancedGroup set is hidden behind a "More X properties"
toggle inside its section. SectionDef.advancedColumns controls the grid
column count; SectionDef.advancedSubsections lets you split the advanced
area into multiple nested ToolbarSections (each one collapsible on its own).
Use this for rarely-touched controls (e.g. exact letter-spacing, deep
border-radius corner controls) so the section doesn't feel cluttered.
| Goal | What to add |
|---|---|
| New input on existing section | registerPropertyDef({ ..., section: "<id>" }) |
| New universal section on a tab | registerSectionDef({ id, title, tab, ... }) then add properties |
| Make a section per-component-toggleable | Set hideKey on the SectionDef, document it in HideKey type |
| Hide one property without toggling the whole section | Use showWhen(className, props) |
| Push a property into "More X" | Set advancedGroup to a sub-section id |
| Custom control | input: { type: "custom", component: MyInput } |
External SDK consumers should call these from a registration module loaded during their app's boot — same time they register custom CraftJS components.