Plugin SDK
Chart types, renderer layers, native indicators, and widget contributions.
Everything importable from @luxalgo/vela/plugin. Three extension seams: chart types
(data + transform side), renderer layers (paint side), and native indicators
(core-computed indicators with their own layers) — plus the authoring surface for
scripting engines, which register per chart rather than into a registry. All
registries are id-keyed (re-registering an id replaces it) and read live — charts
constructed after registration pick the entries up.
Chart types — registerChartType
A chart type is a new price style: an id that becomes valid for the style dropdown,
renderer.set('priceStyle', id), and extended tickers.
import { registerChartType, type SeriesDataEngine, type SeriesDataEngineHost } from '@luxalgo/vela/plugin';
registerChartType({
id: 'mytype',
label: 'My Type', // shown by the widget's style picker
// 1) Optional BAR TRANSFORM — derive the view bars from the raw bars
// (this is how the built-in Heikin Ashi is implemented):
barTransform: {
full: (raw) => transformAll(raw), // full recompute (history loads)
next: (bar) => transformOne(bar), // incremental (live ticks)
},
// 2) Optional TICKER MODIFIER — `"BTCUSDT;mytype"` resolves the transform for
// scripts running on a modified series. Defaults to `!!barTransform`.
tickerModifier: true,
// 3) Optional DATA ENGINE — per-chart secondary data (order flow, deltas, …):
dataEngine: (): SeriesDataEngine => ({
start(host: SeriesDataEngineHost) {
// host.symbol / host.timeframe / host.live / host.bars()
// host.data → the chart's DataControl (providers, capabilities)
// host.pushData(payload) → the renderer channel named after YOUR id
// host.pushPending(ranges) → the `${id}-pending` loading protocol
},
suspend() {}, // style switched away — pause work, keep state
resume() {}, // style switched back
stop() {}, // chart destroyed
onViewport?(range) {}, // debounced visible-range pokes (backfill on scroll)
}),
});Lifecycle: the engine is created lazily the first time the chart enters the style
(after chart.ready()), suspended/resumed on style flips, stopped at destroy.
Two more levers for full-replacement types:
basePainting: 'none'suppresses the base candle painting while the style is active — for types whose renderer layer fully replaces the price representation (an order-flow grid, bricks…). Default'candles'keeps candles under your layer.chart.data.providerInstance(name)returns the registered provider instance — the seam for extended provider surfaces: a provider may implement interfaces beyond theDataProviderport; your data engine retrieves the instance and narrows it with its own type guard.
A chart type may also declare a settings section (settings: { title, rows, visibility }) that the chart-settings dialog renders as its own tab — values persist in
the renderer config, reach the type's renderer layer as args.settings, and its data
engine via onSettings(values). Every value row reduces to ONE composite shape — the
row kind: a label, an optional leading toggle (controls dim while it is off), and an
ordered list of inline controls (number, color, width, select, hint) in any
mix — the classic toggle/number/color/select/range kinds are sugar over it
(normalizeSettingsRow/settingsRowValueKeys expose the canonical view for alternate
renderers). Rows and individual controls may carry declarative when conditions (shown
only while another key holds a value; a control's own when swaps it in and out live
and exempts it from the toggle-off dim), and a section may go structured: layout: 'grouped' promotes heading rows to a group TOC beside the rows, an instances tab
strip repeats blocks with add/remove via an enableKey boolean, subsections add
indented rail entries (with optional in-group header subgroup titles), and
placement: 'after-symbol' picks the rail position — all pure data, evaluated live by
the dialog. See Settings Rows for
the control kinds, conditions, the structured form, and how to add new ones.
Renderer layers — registerRendererLayer
A layer owns one transparent canvas stacked into the native renderer's pile and is
repainted from the shared paint cycle. The layer id doubles as its data channel —
a chart type's host.pushData feeds the layer named like it with no extra wiring.
import { registerRendererLayer } from '@luxalgo/vela/plugin';
registerRendererLayer({
id: 'mytype', // = the `setNativeData` channel it receives
placement: 'above-data', // or 'below-data' (behind the candles)
repaintOnCursor: true, // opt-in: pointer moves repaint this layer too
create: () => ({
mount(canvas) { /* keep the canvas reference */ },
render({ bars, data, pending, coords, scale, bounds, theme, priceStyle, nowMs, cursor }) {
// Always clear + repaint your own canvas. Gate on `priceStyle` if the
// layer belongs to a chart type. Key mappings:
// coords.logicalToX(i) / coords.timeToX(ms) → x
// coords.priceToY(price, scale, bounds) → y
// coords.width / coords.dpr → sizing
// `cursor` is the plot-relative pointer ({ x, y } | null) — hover
// hit-testing input for layers that set `repaintOnCursor`.
},
animating?: () => false, // return true while a pulse/fade needs frames
modulateBase?: (args) => ({ candleBodyScale: 0.07, gridAlpha: 0 }),
destroy?: () => {},
}),
});Ownership — layers backed by a native indicator. When a mounted native indicator's type equals a layer's id, that indicator OWNS the layer, and the layer joins the chart's normal object model instead of sitting outside it:
- Stacking: the layer canvas follows the owner's z key (
seriesOrder, the object tree's drag/bring-to-front/send-to-back) against the candles'candleZOrder— restack the indicator below the candles and its layer paints behind them. Such an indicator mounts at the top of the stack (that is where anabove-datacanvas actually paints), so the recorded order is honest from the first frame. Granularity is the data canvas: model series composite inside ONE canvas, so an owned layer sits below or above that whole canvas, never between two individual plots. The gridlines are the floor: they paint on the backdrop canvas below every layer, so an indicator sent to the very back still renders on top of the grid. - Pane:
args.scale/args.boundsare the owner's pane — moving the indicator to its own pane takes the layer along. A study pane whose master content is only such layer natives autoscales from the visible bars (layer natives paint at bar prices), a collapsed host pane blanks the layer, andmodulateBaseis consulted only while the owner sits on the price pane.
Chart-type channels (no owning indicator) keep the declared placement and the price
pane, exactly as before.
Two per-frame levers beyond the basic contract:
repaintOnCursor(definition): pointer moves normally repaint only the crosshair overlay; a layer that hover-tests (tooltips, row highlights) sets this flag and is repainted — its own canvas only — whenever the cursor moves, withargs.cursorfresh.modulateBase(instance): the gradual counterpart of the chart type's all-or-nothingbasePainting: 'none'. Called afterrenderon every mounted layer that implements it (not only the active price style — an overlay that needs room beside the candles uses the same hook). The returned{ candleBodyScale?, candleBodyAlpha?, gridAlpha? }dims/slims the base painting for that same frame (values clamped to [0..1]; omitted fields keep their defaults). Return null for no opinion. When several layers speak, each field keeps the strongest (smallest) request. This is how a reveal-under style — or an overlay — fades candles down as its own layer fades in, instead of switching them off entirely.
Native indicators — registerNativeIndicator
Core-computed indicators (no script engine) with renderer-drawn layers — the built-in
volume and VPVR ride this seam. See NativeIndicator types in @luxalgo/vela/plugin.
A native whose visuals come entirely from a bespoke renderer layer (its type equals a
registered layer id) can override the axis of the pane it OWNS by emitting paneAxis
on its output: such content is not value-mapped (the layer paints in pixel bands), so a
derived price scale would label meaningless numbers. Two shapes:
paneAxis: 'none'— a blank axis;paneAxis: { bands: [{ frac, label }, …] }— a categorical axis: each label is drawn in the axis column (same typography as price ticks) atfracof the pane's height (0 = top, 1 = bottom) — e.g. a table pane labels its rows at their centers.
Either way the pane draws no price ticks, no horizontal gridlines, and no crosshair value chip. The override is emitted per compute, so it can follow the inputs (toggling a row off relabels the axis). It only holds while overriding natives are the pane's sole content; merging any real series into the pane brings the price axis back.
Widget actions — registerWidgetAction
Contribute UI as data descriptors (never DOM) — the widget projects them into its chrome; a future React view projects the same descriptors.
import { registerWidgetAction, registerIcon } from '@luxalgo/vela/plugin';
registerIcon('rocket', '<svg …>…</svg>'); // optional, inline SVG (stroke currentColor)
registerWidgetAction({
id: 'mytool.open',
target: 'topbar', // or 'context:body' | 'context:price-axis' | 'context:time-axis'
label: 'My tool', // ALWAYS required: aria-label, tooltip, mobile row text
icon: 'rocket',
iconOnly: true, // topbar only: no button text — the native 32px tool look
// on the right cluster (label becomes aria-label + a kit
// tooltip; mobile surfaces keep their text). Requires
// `icon`. The piece that makes a 'screenshot' slot
// override pixel-faithful to the button it replaces.
order: 10, // sort key within the contributed group
align: 'left', // topbar only: 'left' joins the primary chrome cluster
// (after the style/layout dropdowns, styled like them);
// 'right' (default) the right-hand tools cluster
when: (ctx) => ctx.priceStyle === 'mytype', // optional runtime gate
run: (ctx) => {
// ctx.chart (the CURRENT inner chart) · ctx.symbol / timeframe / priceStyle
// ctx.setSymbol / setTimeframe / setPriceStyle / openSymbolSearch(query?)
// ctx.togglePanel(id, open?) — open/close a docked side panel (dock stays exclusive)
// ctx.addIndicator({ name, script, language? }) — add a script indicator THROUGH
// the shell: recorded in the unified undo/redo timeline and the indicator count
// ctx.addNativeIndicator(type) — same, for native (core-computed) indicators
// ctx.stateChanged() — persistable third-party state changed (debounced save)
// ctx.host — mount host for kit components (Dialog/Menu/Tooltip)
// ctx.toast(message, kind?) — the widget's feedback pill
},
});Topbar actions render as buttons in the right-hand cluster by default; align: 'left'
moves one into the primary chrome cluster instead — right after the style/layout
dropdowns, wearing the same height/typography as the built-in buttons there (that is
the built-in Indicators button's exact spot and look, for actions that replace it).
On the mobile chrome the split carries over: left-aligned actions get their own
icon-only stop in the bottom bar (the built-in indicators slot), while right-aligned
ones stay in the three-dots sheet. context:* actions are appended to the matching
right-click menu zone. Register at import time — a widget constructed later picks them
up; after late registrations call widget.refreshActions().
align/order are the action's suggestion — the HOST has the last word: the shell's
topbar: { left, right } option (see Composing the
topbar) can pin an action id at an exact
position (overriding both), or omit the 'actions' flow slot entirely, in which case
unpinned actions don't render on that side. Publish your action ids (a stable exported
constant) so hosts can compose with them.
Two rules keep actions portable:
- Everything through
ctx, no outer references.when/runmust not close over a widget or chart instance — the context is rebuilt per invocation, so it always binds the widget that projected the action (and, in a future multi-chart shell, the active chart). Every member of the context is LIVE —ctx.chartresolves the current chart at call time, andctx.symbol/ctx.timeframe/ctx.priceStyle(and a workspace'sctx.cells/ctx.activeCellId) are getters that follow every market and active-cell switch. Read them at the point of use; copying one into a variable at mount freezes it (an attachment once named screenshot files after the mount-time symbol that way). - Kit components get
ctx.host. Mounting aDialog/Menu/Tooltipwithout an explicit host portals it to<body>, outside the theme's CSS variables (invisible backgrounds). Passhost: ctx.host.
Widget attachments — registerWidgetAttachment
An action is one button; an attachment is a unit of per-widget behavior — an overlay, a
gesture, custom key handling. It mounts once per widget with the same WidgetContext, and
returns a disposer the widget runs at destroy:
import { registerWidgetAttachment } from '@luxalgo/vela/plugin';
registerWidgetAttachment({
id: 'mytool.overlay',
mount: (ctx) => {
const el = document.createElement('div');
ctx.host.appendChild(el); // the THEMED widget root
const onKey = (e: KeyboardEvent) => { /* … ctx.chart.drawings.setTool('trendline') … */ };
document.addEventListener('keydown', onKey, true);
return () => { // runs when the widget is destroyed
document.removeEventListener('keydown', onKey, true);
el.remove();
};
},
});Attachments mount at widget construction (and on widget.refreshActions() for late
registrations), once per id per widget. The same portability rules as actions apply: everything
comes from ctx, never from module state.
Replacing a built-in button — slot overrides
The topbar's built-in entries are named SLOTS (see Composing the
topbar). The simple-button slots —
'indicators' and 'screenshot' — can be TAKEN OVER by a plugin: register an
action under the built-in id, and the override owns the slot's whole surface:
- the desktop button renders your action at the slot's position (native button gone);
- the slot's mobile counterpart (the mobile-bar Indicators stop, the more-drawer
Screenshot button) routes to your
run(ctx); - the slot's keyboard chord (
/for indicators,mod+alt+Sfor screenshot) routes to yourrun(ctx)too — don't bind your own; - the native machinery behind the slot is not constructed (the built-in indicator
picker dialog, for
'indicators').
The composite slots (symbol, timeframes, style, layout, undo-redo, alerts,
panels) are stateful controls the shell pushes state into — a {label, icon, run}
descriptor cannot stand in for them, so registering under those ids is refused with a
console warning.
Position follows the composition rules: a host-declared list places the slot wherever
it lists the id (and omitting the id hides your override with the slot — the host
keeps the last word); on a default side the override sits exactly where the native
button was, unless it declares order — then it flows like an ordinary action.
Replacing the indicator menu
Replacing the built-in indicator dialog is the canonical slot override plus the two contributions above — no shell option needed:
- Your menu is an ordinary contribution: a topbar action registered under the id
'indicators'provides the button (and inherits/+ the mobile stop), a widget attachment owns the per-shell dialog. Everything a menu needs is public on the context — the native catalog viactx.chart.availableNativeIndicators(), and shell-routed adds viactx.addNativeIndicator(type)andctx.addIndicator({ name, script, language? }). Prefer these over the rawctx.chart.addNativeIndicator/ctx.chart.addIndicator: the context forms enter the shell's unified undo/redo timeline and the topbar indicator count, exactly like an add from the built-in picker — the raw chart calls bypass the shell and stay invisible to Ctrl+Z.ctx.hostis the mount host for kit components. - The
indicatorsmanifest still resolves and auto-adds its enabled entries — the override replaces the UI, not the ledger. - The historical
indicatorPicker: falseshell option is deprecated (removal in 0.7.0): to hide the built-in surface without replacing it, omit'indicators'fromtopbar.left— same effect (no button, no mobile stop, no/, no dialog).
import { registerWidgetAction, registerWidgetAttachment } from '@luxalgo/vela/plugin';
import { Dialog } from '@luxalgo/vela/ui';
// One menu per shell: the attachment owns the lifecycle, the action opens it.
const menus = new WeakMap<HTMLElement, Dialog>();
registerWidgetAttachment({
id: 'mytool.indicator-menu',
mount: (ctx) => {
const dialog = new Dialog({
title: 'Indicators',
host: ctx.host,
closeOnInteractOutside: true,
content: (body) => {
void ctx.chart.availableNativeIndicators().then((natives) => {
for (const n of natives.filter((n) => n.supported)) {
const row = body.ownerDocument.createElement('button');
row.textContent = n.title;
row.addEventListener('click', () => ctx.addNativeIndicator(n.type)); // undo/redo-recorded
body.appendChild(row);
}
// …plus any script rows: ctx.addIndicator({ name, script, language: 'pine' })
});
},
});
menus.set(ctx.host, dialog);
return () => dialog.destroy();
},
});
registerWidgetAction({
id: 'indicators', // the built-in SLOT — button, mobile stop and `/` are yours
target: 'topbar',
label: 'Indicators',
icon: 'indicators', // the shells' own icon id — reuse it for a familiar button
run: (ctx) => menus.get(ctx.host)?.show(),
});No host-side wiring: any page that imports your package (before constructing shells)
gets your menu in place of the built-in one. Any plugin — not just one blessed
package — can ship its menu through this same public surface. One caveat to design
for: indicators added through ctx.addIndicator live on the chart and in the undo
timeline, but are not part of the shell's persisted manifest ledger (their names
would never resolve against the host's manifest on restore) — your menu owns their
persistence if you want them back after a reload, and
registerStatePersistence is the seam
built for exactly that.
Symbol ranking — registerSymbolRanking
The shells' symbol-search dialog displays the providers' AGGREGATED symbol index. A plugin (or host) can own its display order — one hook, last registration wins:
import { registerSymbolRanking } from '@luxalgo/vela/plugin';
registerSymbolRanking(async (pool) => {
const top = await fetchTopSymbols(); // may be async — a server-driven list
const rank = new Map(top.map((t, i) => [t.ticker, i]));
const head = pool.filter((s) => rank.has(s.ticker)).sort((a, b) => rank.get(a.ticker)! - rank.get(b.ticker)!);
const rest = pool.filter((s) => !rank.has(s.ticker));
return [...head, ...rest]; // full display order, all sources combined
});The contract:
- The hook sees the whole pool — every source combined, exactly what the dialog shows — and returns the full display order. Cross-source ordering (a top list mixing venues) is the point.
- Called when the pool changes (a provider's index lands or refreshes), never per keystroke — the picker caches the result. Async results land on the next repaint (stale-while-revalidate in between).
- Empty query = the head of your list. The built-in "majors first" pin stands down
while a ranking is registered. Under a typed query, the relevance tiers still lead
(prefix > substring > description > venue) — your order breaks ties within each
tier. Venue browsing (
nasdaq …) stays alphabetical. - Injection and hiding: the returned list may contain descriptors absent from the
pool (give them their
provider, and only inject what a provider actually serves — selecting an unservable row parks the load) and may omit entries. Duplicates keep their FIRST occurrence, so injecting at the head fixes both position and data. - A failing or rejecting hook is contained: the pool order stands, with a console warning.
State persistence — registerStatePersistence
The shells persist one versioned state document (getState() / applyState(),
written to storage in persist mode). A plugin can put its own state INTO that document
— instead of running a parallel store that can drift from it — through the document's
ext bags: one at the document root, one per chart. A registered handler owns one
namespaced key and says how its entry is written and read back:
import { registerStatePersistence } from '@luxalgo/vela/plugin';
// Per-chart state (scope 'cell'): one entry per chart, following the chart through
// layout switches, the dormant pool, and shell-to-shell document moves.
// The canonical use: restoring indicators your own menu added via ctx.addIndicator.
registerStatePersistence({
key: 'mytool.indicators', // namespaced, flat: 'vendor.feature'
scope: 'cell',
serialize(ctx) {
// Snapshot whatever your plugin needs to re-add its indicators later — refs
// (slugs/ids) beat full sources: the document stays light. `undefined` = no entry.
const mine = trackedIndicators(ctx.cellId); // your bookkeeping
return mine.length > 0 ? mine.map((i) => ({ slug: i.slug })) : undefined;
},
restore(payload, ctx) {
// The payload is UNTRUSTED (the codec passes `ext` through opaquely) — validate.
if (!Array.isArray(payload)) return;
for (const item of payload) {
if (typeof item?.slug !== 'string') continue;
void fetchSource(item.slug).then((script) =>
// Cell-bound adds: THIS chart (not the active one), and muted — a
// restore never pollutes the undo timeline.
ctx.addIndicator({ name: item.slug, script, language: 'pine' }),
);
}
},
});
// Document-level state (scope 'global'): one entry per document — shared preferences.
registerStatePersistence({
key: 'mytool.prefs',
scope: 'global',
serialize: () => ({ starred: [...starred] }),
restore(payload) {
if (payload && typeof payload === 'object' && Array.isArray((payload as { starred?: unknown }).starred)) {
starred = new Set((payload as { starred: string[] }).starred.filter((s) => typeof s === 'string'));
}
},
});The resulting document (what persist writes and getState() returns):
{
"version": 1,
"layout": "4",
"charts": [
{ "id": "c1", "symbol": "BTCUSDT", /* … */ "ext": { "mytool.indicators": [{ "slug": "my-osc" }] } }
],
"ext": { "mytool.prefs": { "starred": ["my-osc"] } }
}The contract, in five rules:
- Register at import time, before shells are constructed — the rule every contribution registry shares. Re-registering a key replaces the handler.
serializeruns on every shell snapshot (getState, each debounced persist write). Return a JSON-serializable payload, orundefinedfor "no entry". When your state changes outside any shell event (no indicator add, no market switch), callctx.stateChanged()so a save is scheduled — indicator adds/removals already trigger one.restoreruns when a document carrying your key is applied — boot restore, hostapplyState— after the core state is in place (chart alive, engines registered, indicator ledger converged). Cell-scope restores run muted, and the cell context'saddIndicator/addNativeIndicatorare muted on their own too — so an async restore (fetch a source, then add) also stays out of the undo/redo timeline. It is only called for keys the document actually carries.- The payload is opaque to Vela™ and untrusted by you. The codec round-trips
extentries verbatim — including keys whose plugin is not loaded this session, so a plugin-less reload never loses your state — and validates nothing inside them: yourrestoremust. - Scope picks the bag.
'cell'entries live on each chart (charts[i].ext) and travel with it;'global'entries live at the document root (state.ext). Handlers whose restore touches chart content belong in'cell'scope — its context is bound to the right chart even when it is not the active one.
Legend actions — registerLegendAction
An icon button on every indicator's legend row, revealed with the built-in controls (hover/selection) between them and the ✕. The classic use: open the row's script in a host editor.
import { registerLegendAction, registerIcon } from '@luxalgo/vela/plugin';
registerLegendAction({
id: 'mytool.open-source',
icon: 'code', // vela/ui icon registry
tooltip: 'Open the source',
when: (ind) => ind.source !== undefined, // per-indicator gate
run: (ctx, ind) => myEditor.open(ind.source!), // ctx = the shell's WidgetContext
});indis aLegendIndicatorInfo(id,title, optionalsource) —sourceis the script the indicator was added with (also exposed ashandle.source), and isundefinedfor native indicators, which is the usualwhengate.- The descriptor resolves per row, per click:
whenre-evaluates as rows appear, andrunreceives a fresh context each time. - Register at import time; after a late registration call
refreshActions()(both shells re-project the rows already on screen). - The seam degrades gracefully: a custom renderer without
setLegendActionssimply never shows contributed legend actions (same rule as the sync ghost crosshair).
Legend callouts — registerLegendCallout
A small tinted callout bubble with a centered icon, visible right of an
indicator's legend title while the row is idle (it hides while the hover/selection
controls are out). Hover shows its tooltip; when the spec carries content, clicking
deploys a panel of text blocks and action buttons — below the bubble, flipping above
it near the bottom screen edge. The classic use: a live status a user can act on (a
market-session badge, a "new version available" notice with an Update button).
import { registerLegendCallout } from '@luxalgo/vela/plugin';
registerLegendCallout({
id: 'mytool.status',
callout: (ind) => ({
icon: 'market-open', // vela/ui icon registry
background: 'color-mix(in srgb, var(--vela-up) 20%, transparent)',
color: 'var(--vela-up)', // icon ink (default: the row's text color)
tooltip: `${ind.title}: live`,
content: { // omit → a plain, non-clickable badge
title: 'Indicator status',
items: [
{ type: 'text', text: 'Computing on live bars.' },
{ type: 'button', label: 'Details', primary: true, run: (ctx, i) => ctx.togglePanel('mytool.panel') },
{ type: 'button', label: 'Mute', close: false, run: (ctx, i) => mute(i.id) },
],
},
}),
});- Unlike a legend action's static icon, the whole presentation is resolved per row
through
callout(ind)— returnnullto show none (the per-indicator gate), or a spec whose icon/tint/panel follow your own state. When that state changes, callrefreshActions()and the bubbles re-dress. - Panel content is data, never DOM: ordered
textandbuttonitems (consecutive buttons share one row). A button'srunreceives a freshWidgetContextplus the row'sLegendIndicatorInfo; buttons close the panel afterrununlessclose: false. - The bubble itself is the kit's
CalloutBubble(@luxalgo/vela/ui) — reusable in host chrome; the widget's own market-status badge is the same component. - The seam degrades gracefully: a custom renderer without
setLegendCalloutssimply never shows contributed callouts.
Side panels — registerSidePanel
A side panel is a docked column on the chart's right edge — the object tree and the data window are the two built-in ones, and a contributed panel joins them as an equal: same header and close button, same single-open dock, its own toggle button in the topbar's panel group.
The shell owns that chrome and hands mount the panel's body to fill; the contribution
never reaches into the widget's DOM:
import { registerSidePanel, registerIcon } from '@luxalgo/vela/plugin';
registerIcon('flow', '<svg …>…</svg>');
registerSidePanel({
id: 'mytool.flow', // stable: dock id, button id, and the key its width persists under
title: 'Order flow', // header title + button tooltip
icon: 'flow',
order: 30, // among the panel buttons (built-ins are 10 and 20; default 100)
width: 320, // declared width in px (default 280)
resizable: true, // drag the inner edge; double-click returns to `width`
minWidth: 240,
maxWidth: 560,
mount: (ctx, body, header) => {
const list = document.createElement('div');
body.appendChild(list); // `body` is the panel's scrolling area
header.setTitle('BTC flow'); // optional: replace the header title…
header.slot.appendChild(myIconButton); // …and dock compact controls beside it
return {
onChart: (chart) => { /* (re)bind: mount, widget rebuild, active cell change */ },
onOpen: () => { /* became visible — render now if you render lazily */ },
destroy: () => { /* widget destroyed, or this id re-registered */ },
};
},
});-
The header is shareable, not replaceable.
header.slotis the space between the title and the close button — lay out inline controls there (icon buttons, a document name);header.setTitlerewrites the title text (an empty string hides it, letting the slot own the row). The close button and the row itself stay the shell's, and the topbar toggle keeps the DECLAREDtitleas its tooltip. -
Width is a per-panel choice. Omit
resizablefor a fixed column; with it, the drag is clamped to[minWidth, maxWidth](defaults 200/640) and the width the user settles on is saved with the shell's state document, under the panel id. -
The dock is exclusive. Opening a panel closes the one showing — the chart keeps its width, and only one column is ever docked.
onOpenis where a lazy panel renders. -
onChartis the rebind hook, not a one-shot: the widget hands over a new chart instance after a symbol/timeframe rebuild, and a workspace re-points the panel at the active cell. -
Register at import time; after a late registration call
widget.refreshActions()(an open contributed panel stays open across the rebuild). -
A
mountthat throws is contained: the panel docks empty and the reason is logged, rather than taking the shell down.
Scripting engines — chart.registerEngine / registerDefaultEngine
Vela™ bundles no engine — you install one (@luxalgo/vela-pinets for Pine Script) or write
one against the port. Engines are per-chart instances
(chart.registerEngine('pine', new PineEngine()), or the widget's
engines: { pine: () => … } factories). Two things ship here:
registerDefaultEngine(language, factory) — the app-level default: every widget
and workspace cell built afterwards registers factory() on its chart automatically
(one instance per chart). A per-instance engines option wins for the same language
(resolveEngines(overrides) is the merge the shells apply), and the bare Vela chart
is untouched — with nothing registered, nothing changes. This is how an engine package
becomes a host's default with one call.
And the whole authoring surface, so an engine can be built as its own package:
- the
ScriptingEngineport types (PreparedScript,ExecutionRequest/ExecutionHandlers/ExecutionSession,EngineContextSnapshot,BarsChangeReason, …); - the model vocabulary engine output is built from (
OHLCV,IndicatorModel, the series/scene/drawing specs,InputSchema); stableSeriesId— mint every series/drawing id with it: the core's live-tick value patches are keyed by those ids, so they must reproduce across re-runs;- the semantic palette (
ACCENT,BULLISH, …) so engine defaults mean what the rest of the chart means.
The full contract — prepare/execute, the session levers, the data inversion, the backfill run policy, packaging and registration — is Adding an Engine.
Widget integration
- A registered chart type appears in the style dropdown automatically
(
priceStyleIds()= built-ins ∪ registry; labels fromlabel). - Keyboard bindings:
widget.keymap.register({ id, keys: 'mod+shift+k', label, category, scope?, run })— they show up in the?shortcuts panel.'mod'is ⌘ on macOS, Ctrl elsewhere. Scopes: bindings fire when their scope is the top of the stack ('global'always fires); the widget pushes'dialog'while its dialogs are open.
Rules of thumb
- Register at import time, before charts are constructed.
- Payloads pushed through channels are yours end to end — the core never inspects them.
- Never reach into renderer internals from a layer; everything you need arrives in
render(args).