Text styles (typography presets)

Framer-style text-styles picker for the Text settings section. A chip in the row opens a FloatingPanel picker; per-row Edit / "New Style" hand off to a second FloatingPanel editor. Both panels are lazy-loaded heavy chunks following the […

Framer-style text-styles picker for the Text settings section. A chip in the row opens a FloatingPanel picker; per-row Edit / "New Style" hand off to a second FloatingPanel editor. Both panels are lazy-loaded heavy chunks following the editor-popover-pattern trigger ↔ panel split — no modal dialogs, no window.prompt / window.alert.

Files

FileRole
TypographyPresetInput.tsxRegistered custom input ("TypographyPresetInput"). Owns data flow: reads presets from ROOT.props.theme.typography, computes selectedName from the node's className, and supplies create / update / delete / apply handlers.
TextStylePicker.tsxChip trigger + state coordinator. Stage union (closed / picker / editor) decides which lazy panel mounts.
TextStylePickerPanel.tsxLazy chunk. FloatingPanel with search input, scrollable list (per-row Edit pencil revealed on hover), and "New Style" footer.
TextStyleEditorPanel.tsxLazy chunk. FloatingPanel with name (inline error), font family + color (atom-driven dialogs), size / weight / line-height / letter-spacing / transform / decoration (ToolbarDropdown), text-align (ToolbarSegmentedControl), live preview, Save / Cancel + Delete (edit mode).

Stage flow

Closed  ──chip click──▶  picker open
picker  ──row click──▶  applyPreset → closed
picker  ──row Edit ──▶  editor (mode: "edit", originalName, draft)
picker  ──New Style──▶  editor (mode: "new", draft = node DOM capture)
editor  ──Save     ──▶  onCreate / onUpdate → closed
editor  ──Cancel   ──▶  closed
editor  ──Delete   ──▶  onDelete → closed   (edit mode only)

The picker and editor are mutually exclusive — opening one closes the other.

Storage

Presets live at ROOT.props.theme.typography as an array. Apply writes ph-{slug(name)} to the current node's className; the SDK's CSS generator emits a class with that slug at SSR time.

type TypographyPreset = {
  name: string; // also the CSS class slug source
  fontFamily: string; // family token used by extractGoogleFontsUrl
  fontSize: string; // CSS length
  fontWeight: string;
  lineHeight: string;
  letterSpacing?: string;
  textTransform?: string;
  // Extended fields — optional; emitted only when set on the preset
  color?: string; // any CSS color (rgb/oklch/hex)
  textDecoration?: string; // none | underline | line-through | overline
  textAlign?: string; // left | center | right | justify
};

Generated by generateTypographyCSSClasses and generateTypographyCSSVariables:

.ph-{slug} {
  font-family: var(--{slug}-font-family, …);
  font-size: var(--{slug}-font-size, …);
  font-weight: var(--{slug}-font-weight, …);
  line-height: var(--{slug}-line-height, …);
  letter-spacing: var(--{slug}-letter-spacing, …);
  text-transform: var(--{slug}-text-transform, …);
  /* color / text-decoration / text-align — only emitted when set */
}

Optional fields are omitted entirely from the rule when unset, so presets that predate the extended fields render identically.

The "smallest adapter" boundary

The existing toolbar input primitives (FontFamilyAltInput, ColorInput, MultiToggleInput, tailwind-radio rows) write className via changeProp + useNode. The text-style editor needs to write preset definitions (theme.typography[i].*) instead. Rather than wrap or fork those primitives, the editor:

  • Holds a local TextStyleDraft via useState. Nothing touches the editor theme until the user clicks Save.
  • Uses the atom-driven dialogs (FontFamilyDialogAtom, ColorPickerAtom) for font and color — those are already host-agnostic and only take a changed callback.
  • Uses ToolbarDropdown for selects and ToolbarSegmentedControl for alignment. Both are pure data-in / value-out — no node binding.

The host (TypographyPresetInput) is the only file that touches CraftJS:

buildNewDraft()        → readTypographyFromDom(nodeId)  (computed-style capture)
buildEditDraft(p)      → presetToDraft(rawPreset)
onCreate(draft)        → setProp(ROOT, writeTheme(...)) + apply class to node
onUpdate(name, draft)  → setProp(ROOT, writeTheme(...)) + swap class on rename
onDelete(name)         → setProp(ROOT, writeTheme(...)) + strip class from node

Validation

Name validation is inline under the input — never a popup.

  • Empty name → "Name is required".
  • Duplicate name → "A style named "X" already exists" (collision check ignores the original name in edit mode, so renaming to the same name is allowed).

The Save button is disabled while nameError is non-null.

Wiring summary (do-not-regress checklist)

  • Both popovers are FloatingPanel with zIndex={1100}, persistSize={false}.
  • The picker uses scrollable={false} and manages its own internal flex layout (search header / scrollable list / footer). The editor uses scrollable + bodyClassName so the form scrolls cleanly inside AutoHideScrollbar.
  • No position: fixed outside FloatingPanel.
  • No window.prompt / window.alert / window.confirm anywhere in the flow.
  • No new modal / dialog primitives — everything is a floating popover.
  • Reuses existing primitives (Chip (mode="popover"), ToolbarDropdown, ToolbarSegmentedControl, atom-driven font / color dialogs).

Related

  • editor-popover-pattern — the trigger ↔ panel lazy split this picker follows (it is not in popoverModeRegistry because it owns its own chip; it just borrows the lazy-load pattern).
  • theme — where typography presets live in the theme system and how they roll up into the SSR pipeline.
  • primitives/inputs — toolbar input primitives index.