Property Registry (Universal Sections + Properties)

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.

What gets registered

Two things:

  1. Sections — accordion panels (e.g. "Spacing", "Typography", "Colors"). Each section belongs to one of the universal tabs.
  2. Properties — individual inputs inside sections (e.g. the padding control, the heading-font picker, the border-radius slider).

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.

SectionDef

propertyDefs.ts:42-78:

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.

PropertyDef

propertyDefs.ts:171-202:

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.

Public API

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.

Built-in sections live in sectionDefs.ts

Section 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:

SectionFile
typographyregistry/properties/typography.tsx
spacing, size, alignmentlayout.tsx, SpacingBody.tsx, AlignmentBody.tsx
background, border, decorationbackground.tsx, appearance.ts
effects, animations, scroll-effecteffects.ts
hover-click, conditionsinteractions.tsx
displaydisplay.tsx
ariaaria.ts
advanced-settings, properties, import-exportadvanced.tsx

How sections render

RegistrySettings.tsx:57-117:

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:

  1. Reads getSectionDef(sectionId) for chrome (title, icon, help).
  2. Reads getProperties(sectionId) for inputs.
  3. Evaluates each property's showWhen(className, props) to decide visibility.
  4. Renders <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.

hideKey + HiddenKeysAtom

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.

Advanced groups (the "More X properties" toggle)

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.

Adding to the registry

GoalWhat to add
New input on existing sectionregisterPropertyDef({ ..., section: "<id>" })
New universal section on a tabregisterSectionDef({ id, title, tab, ... }) then add properties
Make a section per-component-toggleableSet hideKey on the SectionDef, document it in HideKey type
Hide one property without toggling the whole sectionUse showWhen(className, props)
Push a property into "More X"Set advancedGroup to a sub-section id
Custom controlinput: { 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.