Skip to main content

Editor Panel

The editor surface renders in the dashboard overlay editor sidebar. It is the primary configuration UI for your extension - where streamers adjust settings, enter text, toggle features, and manage data.

Entry point​

Every extension that includes the "editor" target must have a src/editor.tsx file that calls Lumio.render():

// src/editor.tsx
import { Lumio, CompactView, TextField, Toggle, useExtensionStorage } from "@zaflun/lumio-sdk";

function Settings() {
const [visible, setVisible] = useExtensionStorage("visible", true);
const [homeTeam, setHomeTeam] = useExtensionStorage("homeTeam", "");
const [awayTeam, setAwayTeam] = useExtensionStorage("awayTeam", "");

return (
<CompactView title="Sports Scoreboard">
<Toggle
label="Show scores"
checked={visible}
onChange={setVisible}
/>
<TextField
label="Home team"
value={homeTeam}
onChange={setHomeTeam}
/>
<TextField
label="Away team"
value={awayTeam}
onChange={setAwayTeam}
/>
</CompactView>
);
}

Lumio.render(<Settings />, { target: "editor" });

Layout constraints​

The editor panel renders inside a fixed-width sidebar (approximately 320px wide). Follow these guidelines:

  • Use CompactView for sections - it provides a consistent heading and padding
  • Use VStack for vertical layouts within sections
  • Avoid horizontal scrolling - content must fit within 320px
  • Use HStack sparingly and only when the two children are very compact (e.g., a label + short input)
  • Keep labels concise - long labels wrap awkwardly in the narrow sidebar

Multiple sections​

Use multiple CompactView components for logical groups of settings:

import { Lumio, CompactView, VStack, TextField, Dropdown, Slider, useExtensionStorage } from "@zaflun/lumio-sdk";

function Settings() {
const [homeTeam, setHomeTeam] = useExtensionStorage("homeTeam", "");
const [awayTeam, setAwayTeam] = useExtensionStorage("awayTeam", "");
const [position, setPosition] = useExtensionStorage("position", "top-right");
const [opacity, setOpacity] = useExtensionStorage("opacity", 100);

return (
<VStack>
<CompactView title="Teams">
<TextField
label="Home team"
value={homeTeam}
onChange={setHomeTeam}
/>
<TextField
label="Away team"
value={awayTeam}
onChange={setAwayTeam}
/>
</CompactView>

<CompactView title="Display">
<Dropdown
label="Position"
value={position}
options={[
{ value: "top-left", label: "Top left" },
{ value: "top-right", label: "Top right" },
{ value: "bottom-left", label: "Bottom left" },
{ value: "bottom-right", label: "Bottom right" },
]}
onChange={setPosition}
/>
<Slider
label="Opacity"
min={0}
max={100}
value={opacity}
onChange={setOpacity}
/>
</CompactView>
</VStack>
);
}

Lumio.render(<Settings />, { target: "editor" });

Configuration approaches​

Three valid approaches exist for building extension settings:

1. config_schema only (no editor.tsx)​

Define a config_schema in lumio.config.json and the host auto-generates a settings panel from the schema. No custom code needed -- the host renders each field using the same @lumio/ui components your editor.tsx would produce.

{
"config_schema": {
"primaryColor": {
"type": "color",
"label": "Primary color",
"default": "#6366f1"
},
"fontSize": {
"type": "slider",
"label": "Font size",
"default": 16,
"min": 8,
"max": 72,
"unit": "px"
}
}
}

This is the recommended approach for simple settings. See config_schema for the 17 supported field types.

2. editor.tsx only (no config_schema)​

Build a fully custom settings UI using SDK components. Use this when you need dynamic behavior, conditional fields, server function calls, or layouts that go beyond what the schema can express.

3. Both config_schema and editor.tsx​

When both are present, the auto-generated schema fields render first, followed by the custom editor panel below. This lets you define simple settings declaratively in the schema while adding custom interactive elements in editor.tsx.

Config schema field types​

The following 17 field types are supported in config_schema:

TypeDescriptionKey properties
stringSingle-line text inputplaceholder
numberNumeric inputmin, max, step
booleanToggle switch
colorColor picker with hex input
selectDropdown with predefined optionsoptions
textareaMulti-line text inputplaceholder, rows
sliderRange slidermin, max, step, unit
fontFont family picker (Google Fonts)The value is the family name. On stream the overlay or widget loads it through Lumio's font proxy (/v1/fonts), up to four families per layer
iconIcon picker (Lucide, emoji, emotes, uploads)
imageImage upload or URL. The editor uploads a PNG, JPEG, GIF, WebP or AVIF (up to 5 MB), picks an earlier upload, or takes a URLplaceholder. The value is a URL or an upload reference lumio-upload:<uuid>. Pass it unchanged to an Image / <img> src: the host loads the referenced upload of the channel with its own access and renders it (there is no public URL to build). A URL renders only under the host URL policy (data:image/*, platform image CDNs, your egress.allowHosts). The local lumio dev sandbox does not load uploads and renders no image for a reference, so test with a URL there. In the dashboard preview, a member without uploads:read sees no uploaded image
soundSound picker from the account's sound libraryIncludes extension-bundled sounds
multiselectMultiple selection from optionsoptions, multiple
groupCollapsible group of nested fieldsfields, collapsed
dividerVisual separator (no value)
infoRead-only informational text (no value)content
channelMulti-platform channel picker
designerFullpage designer surface fieldpresets

designer_schema for fullpage design editors​

Extensions that include the "designer" target can define a designer_schema in lumio.config.json. The designer provides a split-pane view with a live preview alongside configuration fields. Fields use the same type system as config_schema, but the schema is an array (with explicit key properties) rather than an object:

{
"targets": ["layer", "designer"],
"designer_schema": [
{
"key": "layout",
"type": "select",
"label": "Layout",
"default": "horizontal",
"options": [
{ "value": "horizontal", "label": "Horizontal" },
{ "value": "vertical", "label": "Vertical" }
]
},
{
"key": "bgColor",
"type": "color",
"label": "Background",
"default": "#1a1a2e"
}
]
}

See lumio.config.json reference and Targets for details.

Using useLumioConfig in editor.tsx​

When building a custom editor, use the useLumioConfig hook with a functional updater to modify individual settings without overwriting the entire config object:

import { useLumioConfig, EditorInput, EditorSlider } from "@zaflun/lumio-sdk";

function Settings() {
const [config, setConfig] = useLumioConfig();

// Functional updater -- recommended for editor.tsx
const update = (key: string, value: unknown) =>
setConfig((prev) => ({
...prev,
settings: { ...(prev.settings ?? {}), [key]: value },
}));

return (
<>
<EditorInput
label="Title"
value={String(config.settings?.title ?? "")}
onChange={(v) => update("title", v)}
/>
<EditorSlider
label="Font Size"
value={Number(config.settings?.fontSize ?? 16)}
onChange={(v) => update("fontSize", v)}
min={8}
max={72}
unit="px"
/>
</>
);
}

The functional updater (prev) => next prevents race conditions when multiple fields update concurrently.

Available components​

All SDK components work on the editor surface. Input components are fully interactive on this surface:

ComponentTypeWorks in editor
BoxLayoutYes
VStackLayoutYes
HStackLayoutYes
CompactViewLayoutYes
ListLayoutYes
TextDisplayYes
ImageDisplayYes
ButtonInputYes (interactive)
ToggleInputYes (interactive)
TextFieldInputYes (interactive)
TextAreaInputYes (interactive)
NumberFieldInputYes (interactive)
DropdownInputYes (interactive)
SliderInputYes (interactive)

Editor-specific components​

The SDK provides 13 additional components designed specifically for editor panels. These serialize via the Worker Reconciler and render as @lumio/ui components on the host side:

ComponentDescription
EditorInputText, number, or color input with optional unit suffix
EditorTextareaMulti-line text input
EditorSelectDropdown select with predefined options
EditorCheckboxToggle checkbox
EditorSliderRange slider with optional unit
EditorColorPickerColor picker with hex input
EditorFontPickerFont family picker with weight and italic controls
EditorFontRowCompact font styling row (bold, italic, weight, size, color)
EditorButtonAction button (primary, default, destructive variants)
EditorGroupCollapsible section group
EditorLabelSection label/heading
EditorDividerVisual separator
EditorInfoInformational text block

See Editor Components for the full API reference with props and examples.

Calling server functions​

The editor can call server functions - both queries and mutations. Functions with editorOnly: true can only be called from the editor surface; your extension running in an OBS overlay or widget cannot call them. Lumio takes the surface from the host's credential (the streamer's dashboard session), not from your extension.

import { Lumio, CompactView, Button, List, Text, useQuery, useMutation } from "@zaflun/lumio-sdk";

function RuleManager() {
const { data: rules, refetch } = useQuery("getRules");
const { mutate: addRule } = useMutation("addRule");
const { mutate: deleteRule } = useMutation("deleteRule");

return (
<CompactView title="Challenge Rules">
<Button
label="Add rule"
onClick={async () => {
await addRule({ text: "New rule" });
refetch();
}}
/>
<List
items={(rules ?? []).map((rule) => ({
id: rule.id,
label: rule.text,
action: (
<Button
label="Delete"
variant="destructive"
onClick={async () => {
await deleteRule({ id: rule.id });
refetch();
}}
/>
),
}))}
/>
</CompactView>
);
}

Lumio.render(<RuleManager />, { target: "editor" });

Storage write propagation​

Every call to a storage setter in the editor is immediately propagated to the layer surface via WebSocket. There is no debounce - each call triggers a storage update. For text inputs that update on every keystroke, consider debouncing in your component:

import { useEffect, useState } from "react";
import { TextField, useExtensionStorage } from "@zaflun/lumio-sdk";

function DebouncedTextField({ storageKey }: { storageKey: string }) {
const [stored, setStored] = useExtensionStorage(storageKey, "");
const [local, setLocal] = useState(stored);

// Pick up the stored value once it has loaded, or when another surface changed it
useEffect(() => setLocal(stored), [stored]);

useEffect(() => {
if (local === stored) return;
const timer = setTimeout(() => setStored(local), 300);
return () => clearTimeout(timer);
}, [local, stored, setStored]);

return (
<TextField
label="Team name"
value={local}
onChange={setLocal}
/>
);
}