How an individual control inside the sidebar actually works — the input primitives, how they read/write the selected node's data, and how breakpoints
How an individual control inside the sidebar actually works — the input primitives, how they read/write the selected node's data, and how breakpoints
PropertyDef (data)
↓
PropertyRenderer (dispatch on input.type)
↓
┌─────────────────┬─────────────────┬──────────────┬──────────────┐
│ ToolbarItem │ UniversalInput │ ColorInput │ TailwindInput│ ← input primitives
│ (text, select, │ (value-aware, │ (palette + │ (tailwind │
│ slider, │ token-aware) │ custom) │ options + │
│ number, ...) │ │ │ prefix) │
└─────────────────┴─────────────────┴──────────────┴──────────────┘
<ToolbarItem> directly (it knows what control
it wants).<PropertyRenderer>
which dispatches the registered input.type to the right primitive.Both ultimately call into changeProp / getPropFinalValue —
the same prop read/write engine.
ToolbarItem.tsx is the input primitive used everywhere a single
prop needs a control. One component, many shapes via the type prop:
<ToolbarItem
propKey="placeholder" // which prop on the node
propType="component" // "class" | "component" | "modifier" | ...
type="text" // visual control flavor
label="Placeholder"
labelHide={true}
// ...flavor-specific props
/>
type — visual control flavortype | Renders |
|---|---|
text, textarea | Plain <input> / <textarea> |
number | Numeric <input type="number"> |
slider | Range slider with min/max/step |
select | Dropdown (also accepts valueLabels for index-based selects) |
radio | Horizontal radio group |
toggle, checkbox | DaisyUI-styled toggle |
toggleNext | Cycle-through button (click steps through options) |
codemirror | Lazy-loaded CodeMirror editor (HTML/CSS/JS) |
propType — what kind of value gets writtenThis is the most important — and most misunderstood — prop on ToolbarItem.
propType | Writes to | Used for |
|---|---|---|
"class" (default) | node.props.className Tailwind class | Layout, design, spacing — anything style-driven |
"component" | node.props[propKey] directly | Component-specific props (e.g. FormElement placeholder, Button text) |
"modifier" | node.props.modifiers[propKey] | Modifier props (Background modifiers, etc.) |
"style" | node.props.style[propKey] inline style | Inline CSS overrides |
"action" | node.props.action[propKey] | Button/Link action sub-props |
"animation" | node.props.animation[propKey] | Animation preset sub-props |
Class-based props (propType: "class") are the default because most editor
controls are Tailwind utilities. The class-routing logic uses propTag /
propKey to identify which class to mutate (e.g. propTag: "pt" ⇒ swap
pt-* classes).
propKey — the prop nameFor propType: "class", this matches the TailwindStyles key (e.g.
"backgroundColor", "padding"). For other propTypes, this matches the
literal prop name on the node (e.g. "placeholder", "name").
// 1. Read the current value (view-aware)
const { value, viewValue } = getPropFinalValue(__props, view, nodeProps, classDark);
// 2. On change, dispatch to changeProp (handles class/component/modifier/etc)
const changed = (newValue) => {
if (propType === "class") {
// Apply to all currently selected views. Normal case: single view from ViewAtom.
// Multi-scope case: user Alt-clicked chip cells → MultiScopeAtom is non-empty,
// getEffectiveViews returns those bps instead.
const effectiveViews = getEffectiveViews(modifiers, view, multiScope);
effectiveViews.forEach(v => changeProp({ propKey, value: newValue, view: v, ... }));
} else {
changeProp({ propKey, value: newValue, view, ... });
}
};
getPropFinalValue and changeProp live in
packages/sdk/src/chrome/viewport/propSystem.ts (re-exported via viewportExports).
They're the single read/write engine for every input.
Every input is breakpoint-aware. The active view comes from ViewAtom
("mobile" | "sm" | "desktop" | "lg" | "xl" | "2xl" — see
VIEW_BREAKPOINT_SCOPE_KEYS).
The canvas viewport switcher is the scope. Switching the canvas to md
directs all class writes to md:. There is no separate breakpoint picker in
the tab bar. Power users can opt into multi-scope writes by Alt-clicking cells
in the BreakpointChip popover — that toggles bps into MultiScopeAtom and
fans writes out to all selected bps.
Class-based props use Tailwind's responsive prefix scheme:
view: "desktop" → bare class (pt-4)view: "sm" → sm:pt-4view: "lg" → lg:pt-4Plus a dark modifier from EditModifiersAtom.dark — dark:pt-4.
propType: "component" and friends do not fan out — they're single-value
props with no breakpoint scope.
Every input gets a single BreakpointChip next to its label (replacing the
old always-visible 6-dot row). Silent-when-clean: chip only renders when there
is an override to show (density=auto) or when density=always.
┌───────────────────────────────────┐
│ Padding [●m] │ ← chip: override at md
│ [pt-4 _____________________] │
└───────────────────────────────────┘
bg-primary dot when any prop in the section
has any non-base override.Full reference: responsive-editor.md.
Plumbing:
| Layer | File | What it does |
|---|---|---|
| Per-field chip | BreakpointChip in breakpoint-chip/BreakpointChip.tsx | Active-bp detection, dot state, click/right-click |
| Singleton popover | breakpoint-chip/ChipPopover.tsx | Cell grid, value display, Alt-click multi-scope |
| Context menu | breakpoint-chip/ChipContextMenu.tsx | useChipContextMenu hook + portal menu |
| Commands | breakpoint-chip/commands.ts | cmdResetAt, cmdResetAll, cmdPromoteToBase, cmdCopyDown, cmdCopyUp |
| Section override dot | breakpoint-chip/useSectionHasAnyOverride.ts | Returns true when any propKey has a non-base override |
Skip rules: chip returns null when propType is "root" or "component" (those don't cascade across breakpoints).
ToolbarItem's index prop adds a state prefix:
index: "hover" → writes hover:bg-primary on a class changeindex: "focus" → focus:bg-primaryindex: "active" → active:bg-primaryCombined with view: index: "hover", view: "lg" → lg:hover:bg-primary.
Some property defs in the registry set index per-property (e.g. the Hover
section's color picker has index: "hover").
PropertyRenderer.tsx dispatches a PropertyDef.input to the right
primitive. The cases:
switch (def.input.type) {
case "tailwind-select": // ToolbarItem type="select" with TailwindStyles[key]
case "tailwind-radio": // ToolbarItem type="radio" with TailwindStyles[key]
case "universal": // <UniversalInput> — multi-mode input
case "color": // <ColorInput prefix={...}>
case "checkbox": // ToolbarItem type="toggle" with on/off classes
case "text": // ToolbarItem type="text"
case "select": // ToolbarItem type="select" with explicit options
case "custom": // <def.input.component def={def} />
}
The dispatch normalizes propKey, propType, index, and inline from the
PropertyDef onto the underlying input — so the same control gets the same
behavior whether it's invoked declaratively (registry) or imperatively (MainTab).
universal-input/ is a value-aware multi-mode input. It lets the user
type a Tailwind class (pt-4), a design var (var(--space-md)), an arbitrary
value (pt-[37px]), or pick from the dropdown — all in the same field. Used
for spacing, sizing, and any prop where users mix tokens with arbitraries.
Modes are configured via allowedTypes: ValueType[]:
"tailwind" — TailwindStyles options"designVar" — design system variables"arbitrary" — pt-[37px] style values"calc" — calc() expressionstailwindKey or tailwindOptions populates the dropdown.
showVarSelector adds a quick "pick a token" button.
When the field is not focused and the value is a Tailwind class (selectedType === "tailwind", propType === "class"), UniversalInput shows a humanized label via formatTailwindDisplayLabel — same helper the dropdown uses. So p-space-sm reads as "Space Small", rounded-lg as "Large", bg-primary as "Primary", etc. On focus, the field swaps back to the raw class so the user can edit it; on blur it humanizes again. Numeric (px/em/rem/%/...) and var-mode values are not transformed — they're already readable. propType !== "class" (component / modifier / style props) is also skipped.
inputs/color/ColorInput.tsx is the color picker. prefix tells it which class
to write (bg, text, border, divide). It auto-detects the current
value's mode (palette token vs hex vs hsl) and shows the right picker UI.
inputs/advanced/TailwindInput.tsx is the basic "TailwindStyles dropdown" input.
Used when tailwind-select has a propTag — i.e. when the prop is identified
by a class prefix rather than by literal class. Handles prefix stripping
on read.
When a control needs more than the dispatch can express, register a custom input:
registerPropertyDef({
id: "my-custom-prop",
label: "Custom",
section: "advanced",
keywords: [...],
input: {
type: "custom",
component: MyCustomInput,
},
});
MyCustomInput receives { def, index }. It can use useNode() /
useEditor() and changeProp directly — same APIs as ToolbarItem.
Every sidebar row renders through a single <Chip> (Chip.tsx) — see primitives/chip.md. Chip owns the label gutter (with truncation tooltip + click-forward), the breakpoint-pill indicator (when propKey + propType are class-typed), the bordered frame, and the trailing clear/× slot.
Props that affect layout (passed through ToolbarItem to Chip):
labelHide — omit the label gutter (single-control row).labelWidth — override the default w-20 gutter (rare).wrap — escape hatch: when truthy, Chip renders children directly with no frame (used when a parent already provides the row shape).append — extra content rendered at the right edge of the inputpropType: "component" does not fan out across views. If you need a
per-breakpoint prop, it has to be class-based (Tailwind responsive prefixes).
No way to have desktop:placeholder vs mobile:placeholder on a
FormElement — that's one prop, one value.propKey for class props matches TailwindStyles key, not the class.
propKey: "backgroundColor" writes bg-* classes — the key resolves to a
prefix internally.getEffectiveViews only fires for class props. That's intentional —
multi-view writing only makes sense for responsive Tailwind classes.type: "select" with valueLabels. When valueLabels is an array, the
stored value is the index, not the label. Use this for ordinal props
(e.g. font weight presets); use options: [{label, value}] for
string-keyed selects.changeProp themselves. They don't auto-route
values like ToolbarItem does. Mirror the propKey / propType /
view / index handling.useNode() + node.actions.setProp exists, but
going around changeProp skips the view fan-out, modifier handling, and
class-routing logic. Always go through the engine.