Building a Custom Field Widget

Extend StudioBrain’s entity editor with your own form inputs

Overview

Plugin field widgets let you create custom form inputs that appear alongside StudioBrain’s 48 builtin widgets. They render inside entity editor forms, are available in the Layout Designer, and can be used in any template’s field_config.

Use cases:

  • Specialized color pickers (gradient, 3D color space, HSL wheel)
  • Domain-specific inputs (map coordinate picker, music note selector)
  • Integration widgets (external API lookup, AI-assisted input)
  • Custom validation UI (password strength meter, regex tester)

Plugin Manifest

Add a field_widgets array to your plugin.json:

{
  "id": "my-widget-plugin",
  "name": "My Custom Widgets",
  "version": "1.0.0",
  "capabilities": {
    "frontend": {
      "field_widgets": [
        {
          "id": "gradient-picker",
          "label": "Gradient Picker",
          "description": "Pick or create CSS gradients",
          "category": "color",
          "accepts_options": false,
          "value_type": "string",
          "preview_url": "widgets/gradient-preview.png"
        }
      ]
    }
  }
}

field_widgets Properties

PropertyTypeRequiredDescription
idstringYesWidget identifier (used in plugin:{pluginId}:{id})
labelstringYesDisplay name in Layout Designer dropdown
descriptionstringNoTooltip/help text
categorystringYesCategory group: text, number, selection, date, color, entity, array, media, document
accepts_optionsbooleanNoWhether the widget reads options from field_config
value_typestringNoExpected value type (string, number, object, array)
preview_urlstringNoThumbnail image shown in Layout Designer palette

Widget HTML File

Create your widget as an HTML file in your plugin’s directory. The widget renders inside a sandboxed iframe.

File: widgets/gradient-picker.html

Receiving Data from Host

The host sends value updates via postMessage:

window.addEventListener('message', (event) => {
  if (event.data.type === 'sb-field-widget-update') {
    const { value, disabled, options } = event.data;
    // Update your widget UI with the current value
    renderGradient(value);
    setDisabled(disabled);
  }
});

Sending Value Changes

When the user changes the value, send it back:

function onGradientChange(newGradient) {
  window.parent.postMessage({
    type: 'sb-field-widget-change',
    value: newGradient  // e.g., "linear-gradient(90deg, #ff0000, #0000ff)"
  }, '*');
}

Resizing

Tell the host how tall your widget needs to be (min 40px, max 300px):

function updateHeight() {
  const height = document.body.scrollHeight;
  window.parent.postMessage({
    type: 'sb-field-widget-resize',
    height: Math.min(300, Math.max(40, height))
  }, '*');
}
 
// Call on load and after UI changes
window.addEventListener('load', updateHeight);
new ResizeObserver(updateHeight).observe(document.body);

Theme Integration

Request the host’s CSS variables to match the design system:

// Request theme on load
window.parent.postMessage({ type: 'studiobrain-theme-request' }, '*');
 
// Receive theme variables
window.addEventListener('message', (event) => {
  if (event.data.type === 'studiobrain-theme-response') {
    const vars = event.data.variables;
    // vars is { '--surface-input-bg': '#1a1a2e', '--surface-primary-bg': '#6366f1', ... }
    Object.entries(vars).forEach(([key, val]) => {
      document.documentElement.style.setProperty(key, val);
    });
  }
});

Key CSS variables to use:

VariablePurpose
--surface-input-bgInput background
--surface-input-borderInput border
--surface-input-textInput text color
--surface-primary-bgAccent/primary color
--surface-elevated-bgCard/dropdown background
--surface-elevated-textBody text
--surface-elevated-text-secondaryMuted text

Using in Templates

Reference your widget in any template’s field_config:

field_config:
  background_gradient:
    widget: "plugin:my-widget-plugin:gradient-picker"

Or set it in the Layout Designer by selecting your widget from the “Plugin” category in the widget type dropdown.

Desktop vs Web Runtime

PlatformRuntimeNotes
Web/CloudSandboxed iframesandbox="allow-scripts" (no same-origin)
Desktop (Tauri)WASM or iframeWASM via sb-plugins crate for native performance

For iframe widgets, the protocol is identical on both platforms. WASM widgets use the WIT interface defined in sb-plugins — see Migrating from Python to WASM for details.

Complete Example: Gradient Picker

plugin.json

{
  "id": "gradient-tools",
  "name": "Gradient Tools",
  "version": "1.0.0",
  "description": "CSS gradient picker for visual fields",
  "capabilities": {
    "frontend": {
      "field_widgets": [
        {
          "id": "gradient-picker",
          "label": "Gradient Picker",
          "category": "color",
          "accepts_options": false,
          "value_type": "string"
        }
      ]
    }
  }
}

widgets/gradient-picker.html

<!DOCTYPE html>
<html>
<head>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body { font-family: system-ui; padding: 8px; }
    .preview {
      height: 40px;
      border-radius: 8px;
      border: 1px solid var(--surface-input-border, #333);
      margin-bottom: 8px;
      cursor: pointer;
    }
    .controls { display: flex; gap: 8px; }
    .color-stop {
      display: flex; align-items: center; gap: 4px;
    }
    input[type="color"] {
      width: 32px; height: 32px; border: none; cursor: pointer;
    }
    input[type="range"] {
      flex: 1;
    }
    .angle-input {
      width: 60px; padding: 4px 8px;
      background: var(--surface-input-bg, #1a1a2e);
      color: var(--surface-input-text, #fff);
      border: 1px solid var(--surface-input-border, #333);
      border-radius: 6px;
    }
  </style>
</head>
<body>
  <div class="preview" id="preview"></div>
  <div class="controls">
    <div class="color-stop">
      <input type="color" id="color1" value="#ff0000">
    </div>
    <input type="range" id="angle" min="0" max="360" value="90">
    <input type="number" class="angle-input" id="angleDeg" value="90" min="0" max="360">
    <div class="color-stop">
      <input type="color" id="color2" value="#0000ff">
    </div>
  </div>
  <script>
    let currentValue = '';
    const preview = document.getElementById('preview');
    const color1 = document.getElementById('color1');
    const color2 = document.getElementById('color2');
    const angle = document.getElementById('angle');
    const angleDeg = document.getElementById('angleDeg');
 
    function buildGradient() {
      return `linear-gradient(${angle.value}deg, ${color1.value}, ${color2.value})`;
    }
 
    function update() {
      const gradient = buildGradient();
      preview.style.background = gradient;
      if (gradient !== currentValue) {
        currentValue = gradient;
        window.parent.postMessage({ type: 'sb-field-widget-change', value: gradient }, '*');
      }
    }
 
    function parseGradient(val) {
      if (!val) return;
      const match = val.match(/linear-gradient\((\d+)deg,\s*(#[0-9a-f]{6}),\s*(#[0-9a-f]{6})\)/i);
      if (match) {
        angle.value = angleDeg.value = match[1];
        color1.value = match[2];
        color2.value = match[3];
        preview.style.background = val;
      }
    }
 
    color1.addEventListener('input', update);
    color2.addEventListener('input', update);
    angle.addEventListener('input', () => { angleDeg.value = angle.value; update(); });
    angleDeg.addEventListener('input', () => { angle.value = angleDeg.value; update(); });
 
    window.addEventListener('message', (e) => {
      if (e.data.type === 'sb-field-widget-update') {
        parseGradient(e.data.value);
        if (e.data.disabled) {
          document.querySelectorAll('input').forEach(i => i.disabled = true);
        }
      }
      if (e.data.type === 'studiobrain-theme-response') {
        Object.entries(e.data.variables).forEach(([k, v]) => {
          document.documentElement.style.setProperty(k, v);
        });
      }
    });
 
    // Request theme
    window.parent.postMessage({ type: 'studiobrain-theme-request' }, '*');
    // Report height
    window.parent.postMessage({ type: 'sb-field-widget-resize', height: 100 }, '*');
    // Initialize
    update();
  </script>
</body>
</html>

Using in a Template

field_config:
  sky_gradient:
    widget: "plugin:gradient-tools:gradient-picker"
  ground_gradient:
    widget: "plugin:gradient-tools:gradient-picker"

SDK Widget Registry Integration

When a plugin loads, the plugin loader registers its widgets with the SDK widget registry. This makes plugin widgets available for name-pattern inference and Layout Designer discovery.

How Registration Works

  1. The plugin loader parses plugin.json and extracts the field_widgets array
  2. Each entry is mapped to a WidgetDefinition with source: 'plugin'
  3. The loader calls registerWidgets() from @biloxistudios/studiobrain-sdk
  4. The registry rebuilds its inference patterns to include the new widgets
import { registerWidgets } from '@biloxistudios/studiobrain-sdk';
 
// Plugin loader does this automatically at load time
registerWidgets(pluginManifest.capabilities.frontend.field_widgets.map(w => ({
  id: `plugin:${pluginManifest.id}:${w.id}`,
  label: w.label,
  description: w.description || '',
  category: w.category,
  source: 'plugin' as const,
  value_type: w.value_type,
  accepts_options: w.accepts_options,
  name_patterns: w.name_patterns,
})));

Adding Name Patterns for Inference

If your widget should be automatically suggested when a template field name matches a pattern, add name_patterns to your manifest:

{
  "id": "recipe-timer",
  "label": "Recipe Timer",
  "description": "Duration input with cooking presets",
  "category": "number",
  "value_type": "number",
  "name_patterns": ["cook_time", "prep_time", "bake_time", "timer"]
}

With this registration, any template field named cook_time or prep_time will automatically infer recipe-timer as its widget without needing an explicit field_config entry.

Patterns are case-insensitive regex fragments. They are tested against the raw field name. Be specific to avoid false matches — timer is fine for a cooking plugin, but time would conflict with the builtin time widget’s patterns.

WASM Plugin Widgets

For desktop and mobile platforms, plugin widgets can be implemented as WASM modules instead of iframes for native performance.

WASM Widget Lifecycle

  1. Plugin declares field_widgets in plugin.json (same manifest format as iframe widgets)
  2. Plugin ships a .wasm binary built against the StudioBrain WIT interface
  3. At load time, the WASM module is instantiated via wasmtime (desktop/mobile) or extism (web)
  4. The host calls the widget’s render() function with the current field value
  5. Value changes are returned via the WIT on_change() callback

WIT Interface (simplified)

interface sb-field-widget {
  record field-context {
    value: string,
    disabled: bool,
    options: option<list<string>>,
    theme: list<tuple<string, string>>,
  }

  render: func(ctx: field-context) -> string
  on-change: func(new-value: string)
  get-height: func() -> u32
}

For the full WIT spec and a working WASM widget example, see Migrating from Python to WASM.

Example: Recipe Timer Widget (Cooking Template Pack)

A cooking template pack might ship a recipe-timer widget that shows preset buttons (Quick, Medium, Long) alongside a manual minutes input.

plugin.json

{
  "id": "cooking-tools",
  "name": "Cooking Tools",
  "version": "1.0.0",
  "description": "Specialized widgets for recipe and cooking templates",
  "capabilities": {
    "frontend": {
      "field_widgets": [
        {
          "id": "recipe-timer",
          "label": "Recipe Timer",
          "description": "Duration input with cooking time presets",
          "category": "number",
          "value_type": "number",
          "name_patterns": ["cook_time", "prep_time", "bake_time", "rest_time"]
        }
      ]
    }
  }
}

Template usage

# _TEMPLATES/TEMPLATE.md frontmatter
entity_type: recipe
template_category: entity
field_config:
  cook_time:
    widget: "plugin:cooking-tools:recipe-timer"
  prep_time:
    widget: "plugin:cooking-tools:recipe-timer"

Because the widget registers name_patterns, templates that omit field_config for cook_time or prep_time fields will still get the recipe-timer widget through inference (provided the plugin is installed).

Example: Musical Key Widget (Music Template Pack)

A music production template pack could provide a music-key widget that renders an interactive circle-of-fifths selector.

plugin.json

{
  "id": "music-production",
  "name": "Music Production Tools",
  "version": "1.0.0",
  "description": "Widgets for music production templates",
  "capabilities": {
    "frontend": {
      "field_widgets": [
        {
          "id": "music-key",
          "label": "Musical Key",
          "description": "Key signature selector with circle-of-fifths visualization",
          "category": "selection",
          "value_type": "string",
          "accepts_options": false,
          "name_patterns": ["key_signature", "musical_key", "song_key"]
        },
        {
          "id": "bpm-input",
          "label": "BPM Input",
          "description": "Tempo input with tap-to-set and genre presets",
          "category": "number",
          "value_type": "number",
          "name_patterns": ["bpm", "tempo"]
        }
      ]
    }
  }
}

Template usage

# _TEMPLATES/TEMPLATE.md frontmatter for a Track entity type
entity_type: track
template_category: entity
field_config:
  key_signature:
    widget: "plugin:music-production:music-key"
  bpm:
    widget: "plugin:music-production:bpm-input"