Editor Palette Primitives.

Palettes are how blocks and rules get into the editor. Not drag targets. The source. block-palette presents a categorized grid of draggable block templates; rule-palette presents a categorized list of draggable validation rules. Both are leaf primitives. Neither manages nanostores. The search engine they share is typeahead, specifically its exported fuzzyScore function.


block-palette.

Source: packages/ui/src/primitives/block-palette.ts

createBlockPalette(options) returns BlockPaletteControls. It does not render DOM. The caller renders items; the primitive manages ARIA state, event delegation, keyboard navigation, and drag data.

const palette = createBlockPalette({
  container: document.getElementById('palette')!,
  items: [
    { id: 'heading', label: 'Heading', category: 'Text', keywords: ['h1', 'title'] },
    { id: 'image', label: 'Image', category: 'Media', keywords: ['photo', 'picture'] },
  ],
  categories: ['Text', 'Media'],
  onActivate: (item) => insertBlock(item.id),
});

The container receives role="listbox". Item elements must carry [data-palette-item] and data-palette-id attributes. The primitive walks up the DOM from the event target to find the nearest matching ancestor, so item content can be arbitrarily nested. aria-activedescendant stays in sync with keyboard navigation. A visually hidden live region (aria-live="polite") announces filtered counts to screen readers after a 150ms debounce (block-palette.ts:74).

Drag MIME types. A drag start sets three entries on dataTransfer (block-palette.ts:463-468):

MIME typeValue
application/x-rafters-blockJSON.stringify(item)
application/x-rafters-drag-dataJSON.stringify(item)
text/plainitem.label

Both application/x-rafters-block and application/x-rafters-drag-data carry the same JSON payload. The canvas drop zone reads application/x-rafters-drag-data; the block MIME type exists for drop zones that want to distinguish block drags from rule drags without parsing the payload.

Pointer-events footgun. If renderPaletteItem renders interactive elements inside the preview, such as a Button or Input component, those elements capture mousedown before the event reaches the [data-palette-item] container. The click handler at block-palette.ts:443 never fires. The drag never starts. Fix: add pointer-events-none to the preview wrapper div, not the item element itself.

// Wrong: interactive child eats the click
<div data-palette-item data-palette-id={item.id}>
  <Button>{item.label}</Button>
</div>

// Correct: preview is inert, item container receives the event
<div data-palette-item data-palette-id={item.id}>
  <div className="pointer-events-none">
    <Button>{item.label}</Button>
  </div>
</div>

This was hit in apps/demo/src/components/demos/EditorPlayground.tsx. See Editor Known Gaps for the broader pointer-events trap inventory.

The BlockPaletteControls interface exposes setItems, setSearchQuery, getFilteredItems, getGroupedItems, setDisabled, and destroy. getGroupedItems returns a Map<string, BlockPaletteItem[]> ordered by the categories array passed at construction, which determines display order in the sidebar. For palette item shape, see Editor Data Model.


rule-palette.

Source: packages/ui/src/primitives/rule-palette.ts

Same structure as block-palette. createRulePalette(options) returns RulePaletteControls. The DOM contract uses [data-rule-item] and data-rule-id attributes instead of the block palette’s attribute names (rule-palette.ts:83-84).

const palette = createRulePalette({
  container: document.getElementById('rules')!,
  items: [
    { id: 'required', label: 'Required', category: 'Validation' },
    { id: 'min-length', label: 'Min Length', category: 'Validation', requiresConfig: true },
  ],
  categories: ['Validation', 'Type Constraints'],
  onActivate: (item) => applyRule(item.id),
});

RulePaletteItem extends the block item shape with two rule-specific fields: requiresConfig (boolean, opens a config dialog on drop) and compatibleBlockTypes (string array; empty means compatible with all block types).

Drag MIME type. Rule drags set application/x-rafters-rule as the primary MIME type (rule-palette.ts:419), plus application/x-rafters-drag-data and text/plain for the same reasons as block-palette. Drop zones use the primary MIME type to distinguish rule drags from block drags at dragover time, before the drop resolves.

The pointer-events footgun applies here identically. Same fix.


typeahead.

Source: packages/ui/src/primitives/typeahead.ts

typeahead exports three entry points and one utility both palettes depend on.

createTypeahead(container, options) attaches a keydown listener to container and returns a CleanupFunction. createControlledTypeahead(options) returns { handleKeyDown, reset, getState } for framework integration where you manage the event binding. highlightMatch(element, query, options) wraps matching text in a <mark> element and returns a cleanup function to restore the original HTML.

fuzzyScore(query, target): number is the function both palettes import directly (block-palette.ts:30, rule-palette.ts:31). The scoring algorithm (typeahead.ts:39-68):

ConditionPoints
Each matched character+1
Consecutive match (position i follows previous match at i - 1)+2
Match at start of word (position 0, or preceded by space or hyphen)+3

Empty query returns 1 (matches everything). Query longer than target returns 0. All characters in the query must appear in order in the target; a partial match scores 0. The palettes call fuzzyScore against both item.label and each entry in item.keywords, keeping the highest score.

The type-to-search timeout defaults to 1000ms (typeahead.ts:238). After 1000ms of inactivity the accumulated search string resets. Callers can override this with options.timeout.

TypeaheadMatchMode is 'prefix' | 'fuzzy'. The default is 'prefix', which matches from the start of text or anywhere depending on matchFromStart. The palettes use fuzzyScore directly rather than routing through createTypeahead, so they always get fuzzy scoring regardless of the matchMode option.

Cross-package import note. fuzzyScore is also imported by @rafters/composites/registry.ts:8. That import crosses the leaf primitive boundary: composites importing from @rafters/ui primitives is an architecture violation. It is tracked in Editor Known Gaps.


All three are leaf primitives. Zero npm dependencies. No nanostores. Drag-and-drop state and keyboard focus management for the canvas itself are handled by sibling primitives; see Editor Canvas Primitives for the full picture.