> ## Documentation Index
> Fetch the complete documentation index at: https://dripart-docs-custom-nodes-sdk-v2-frontend.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# JavaScript UI and widgets API

> Host-rendered UI, mounted and canvas widgets, widget events, and custom widget types.

<Note>
  This page is generated from the authoritative declaration file. Do not edit it by hand. Contract SHA-256: <code>152c7fab547f</code>.
</Note>

This reference contains 46 exported declarations: `SidebarTabBase`, `MountedSidebarTab`, `VueSidebarTab`, `SidebarTabDef`, `VueComponent`, `DialogBase`, `DialogKeyEvent`, `MountedDialog`, `VueDialog`, `DialogDef`, `DialogHandle`, `UiHandle`, `PromptDef`, `MenuItemDef`, `MenuDef`, `MenuHandle`, `WidgetValue`, `WidgetOptions`, `WidgetHandle`, `WidgetSerializeEvent`, `Unsubscribe`, `MountDef`, `MountedData`, `MountedValue`, `CanvasPointerEvent`, `CanvasTheme`, `CanvasDef`, `CanvasHandle`, `WidgetDef`, `WidgetCollection`, `ComboPreviewRegistration`, `ComboPreviewAssignment`, `WidgetsHandle`, `LocalizationMessage`, `LocalizationCatalog`, `LocalizationHandle`, `WidgetTextSelection`, `WidgetTextEventBase`, `WidgetTextInputEvent`, `WidgetTextWheelEvent`, `WidgetTextKeyEvent`, `WidgetTextInteractionEvent`, `WidgetTypeData`, `WidgetTypeValue`, `WidgetTypeContext`, `WidgetTypeDef`.

Download the complete contract from the docs source: [TypeScript declaration](https://github.com/Comfy-Org/docs/blob/main/public/custom-nodes-sdk/v2/comfy-api.d.ts).

## Contract

```typescript theme={null}
// ─── uiHandle.ts ─────────────────────────────────────────────────

/** @knipIgnoreUnusedButUsedByCustomNodes */
export interface SidebarTabBase {
  /**
   * Unique across every pack, so namespace it — `'mtb.assets'`, not
   * `'assets'`. Registering an id twice throws rather than silently replacing
   * the other pack's tab.
   */
  readonly id: string
  readonly title: string
  /**
   * An iconify class, e.g. `'icon-[lucide--activity]'`. Omit for no icon.
   */
  readonly icon?: string
  readonly tooltip?: string
}

/**
 * A tab the pack draws into a container itself.
 *
 * Framework-agnostic, and the only form available to a pack that ships
 * hand-written ES modules with no build step — which is most of them.
 */
/** @knipIgnoreUnusedButUsedByCustomNodes */
export interface MountedSidebarTab extends SidebarTabBase {
  /**
   * Fills the tab's panel. Called each time the tab becomes visible, so treat
   * it as mount rather than as one-time setup, and put teardown in `destroy`.
   */
  render(container: HTMLElement): void
  /** Releases what `render` retained — listeners, timers, observers. */
  destroy?(): void
}

/**
 * A tab that is a Vue component, mounted and torn down by the host.
 *
 * The preferred form where a pack can build. It keeps reactivity, scoped
 * styles and `onUnmounted`, and the host mounts and unmounts it.
 *
 * Per ADR 0005 the pack bundles its own Vue (~30KB gzipped) — there is no
 * import map, so `import { defineComponent } from 'vue'` resolves at the
 * pack's build time, not ours. That is a second Vue instance on the page,
 * which the ADR weighed and accepted; nothing is shared across the boundary,
 * so the two runtimes never touch.
 */
/** @knipIgnoreUnusedButUsedByCustomNodes */
export interface VueSidebarTab extends SidebarTabBase {
  readonly component: VueComponent
}

/** @knipIgnoreUnusedButUsedByCustomNodes */
export type SidebarTabDef = MountedSidebarTab | VueSidebarTab

/** A Vue component bundled by the pack. */
/** @knipIgnoreUnusedButUsedByCustomNodes */
export type VueComponent = object

/** @knipIgnoreUnusedButUsedByCustomNodes */
export interface DialogBase {
  /**
   * Unique across every pack, so namespace it. The host prefixes it with
   * `extension-`, which keeps packs out of the internal dialog keyspace.
   */
  readonly key: string
  readonly title?: string
}

/** A bounded keyboard event captured while a mounted dialog owns focus. */
/** @knipIgnoreUnusedButUsedByCustomNodes */
export interface DialogKeyEvent {
  readonly key: string
  readonly code: string
  readonly repeat: boolean
  readonly altKey: boolean
  readonly ctrlKey: boolean
  readonly metaKey: boolean
  readonly shiftKey: boolean
  /** True for an input, textarea, select, or editable content target. */
  readonly editableTarget: boolean
}

/** A dialog the pack draws into a container itself. */
/** @knipIgnoreUnusedButUsedByCustomNodes */
export interface MountedDialog extends DialogBase {
  render(container: HTMLElement): void
  /** Receives dialog-scoped key events even before a child takes focus. */
  onKeyDown?(event: DialogKeyEvent): void | Promise<void>
  destroy?(): void
}

/** A dialog that is a Vue component, mounted and torn down by the host. */
/** @knipIgnoreUnusedButUsedByCustomNodes */
export interface VueDialog extends DialogBase {
  readonly component: VueComponent
  readonly props?: Readonly<Record<string, unknown>>
}

/** @knipIgnoreUnusedButUsedByCustomNodes */
export type DialogDef = MountedDialog | VueDialog

/** @knipIgnoreUnusedButUsedByCustomNodes */
export interface DialogHandle {
  close(): void
}

export interface UiHandle {
  /**
   * Adds a tab to the sidebar. Returns a function that removes it again.
   */
  addSidebarTab(def: SidebarTabDef): Unsubscribe
  /**
   * Shows a small readout in the top bar — a status, a count, a live metric.
   *
   * Replaces `app.menu.settingsGroup` and inserting an element next to
   * `.comfy-settings-btn`. Declarative on purpose: the pack says what to show
   * and the host renders it, in house style and at whatever size the viewport
   * allows. Nothing here takes an element, a class or a style, which is what
   * keeps the chrome ours to restyle.
   *
   * Returns a handle rather than an unsubscribe: for a value that changes,
   * call `update({ text })`. A closure would not work — the host renders when
   * reactive state changes and cannot see a plain function, so the readout
   * would show its first value forever.
   */
  addTopBarBadge(badge: BadgeContribution): ChromeItemHandle<BadgeContribution>
  /**
   * Adds a button to the action bar. `run` is called on click.
   *
   * For a pack that also wants a keyboard shortcut or a palette entry,
   * register a command and call it from `run`, rather than duplicating the
   * behaviour in both places.
   */
  addActionBarButton(
    button: ButtonContribution
  ): ChromeItemHandle<ButtonContribution>
  /**
   * Opens a modal dialog. Returns a handle that closes it again.
   *
   * Replaces `app.ui.dialog` and the `new app.ui.dialog.constructor()` idiom.
   * Several conversions hand-rolled a native `<dialog>` or borrowed core's
   * `.comfy-modal` class names instead — the latter couples a pack to markup
   * we rename freely, so both are worth retiring.
   */
  showDialog(def: DialogDef): DialogHandle
  /**
   * Shows a menu where the user clicked.
   *
   * `b.addMenuItem` is the node's own context menu — a different menu, on a
   * different target, opened by the host. This is for a menu a pack raises
   * itself: a lora row's Move Up / Remove, a chip that picks an output type.
   * Four files hand-rolled it by constructing the renderer's menu class
   * directly, which pins them to a renderer we intend to replace.
   *
   * Positioned from the event so the menu lands under the pointer, which is the
   * only placement that reads as a context menu. Arrow keys traverse nested
   * items, Enter or Tab selects one, and Escape closes the menu.
   */
  showMenu(def: MenuDef): MenuHandle
  /**
   * Asks the user for a value. Resolves `undefined` if they cancel.
   *
   * Packs called `canvas.prompt(...)`, which draws a small field at the cursor
   * — clicking a lora's strength to type a new one. That field belongs to the
   * legacy canvas and the host itself no longer uses it; this is the prompt the
   * host does use, so a pack keeps the capability and loses only the placement.
   */
  prompt(def: PromptDef): Promise<string | undefined>
}

/** @knipIgnoreUnusedButUsedByCustomNodes */
export interface PromptDef {
  /** What is being asked for — "Strength", "Label". */
  readonly label: string
  readonly value?: string
  readonly placeholder?: string
}

/** @knipIgnoreUnusedButUsedByCustomNodes */
export interface MenuItemDef {
  readonly label: string
  /** Shown but not selectable. */
  readonly disabled?: boolean
  /** A nested menu. Mutually exclusive with {@link run}. */
  readonly submenu?: readonly MenuItemDef[]
  run?(): void
}

/** @knipIgnoreUnusedButUsedByCustomNodes */
export interface MenuDef {
  readonly items: readonly MenuItemDef[]
  /** Shown above the items. */
  readonly title?: string
  /** The event that asked for the menu; it decides where the menu appears. */
  readonly event: MouseEvent
}

/** @knipIgnoreUnusedButUsedByCustomNodes */
export interface MenuHandle {
  close(): void
}

// ─── widgetHandle.ts ─────────────────────────────────────────────

// `null` is included because core's own `WidgetValue` has it and
// `addWidget('button', name, null, cb)` produced exactly that. Omitting it made
// a null value inexpressible through the published API, so a converted button's
// `widgets_values` entry changed and the saved workflow differed.
export type WidgetValue = string | number | boolean | object | undefined | null

/** Options understood by core or by a widget type declared by the pack. */
/** @knipIgnoreUnusedButUsedByCustomNodes */
export interface WidgetOptions {
  readonly [key: string]: unknown
  readonly on?: string
  readonly off?: string
  readonly max?: number
  readonly min?: number
  readonly precision?: number
  readonly read_only?: boolean
  readonly step?: number
  readonly step2?: number
  readonly multiline?: boolean
  readonly property?: string
  readonly socketless?: boolean
  readonly canvasOnly?: boolean
  readonly hideInPanel?: boolean
  readonly nodeType?: string
  readonly serialize?: boolean
  readonly values?: unknown
  readonly iconClass?: string
  readonly disabled?: boolean
  readonly useGrouping?: boolean
  readonly placeholder?: string
  readonly showThumbnails?: boolean
  readonly showItemNavigators?: boolean
  readonly hidden?: boolean
}

/**
 * Shapes follow `src/types/extensionV2.ts`, the agreed extension contract.
 *
 * Accessor methods rather than properties, so a read can be a store query and
 * a write can dispatch a command.
 */
export interface WidgetHandle extends HandleCommon {
  readonly name: string
  readonly widgetType: string

  getValue<T = WidgetValue>(): T
  /**
   * Commits a value exactly as a user edit does: the value is written, a
   * widget bound to a node property syncs it, the widget's callback chain and
   * the node's `onWidgetChanged` run, and `graph.version` advances. This
   * replaces the manual pair `widget.value = x; widget.callback?.(x)` — and
   * the bare write too, because a write the rest of the system cannot see was
   * never a feature, it was litegraph defaulting to inconsistency.
   *
   * Writing the current value again is a no-op, which is also what ends a
   * cycle of handlers writing to each other. `on('change')` fires once per
   * commit; `on('activate')` does not fire, because activate reports a user's
   * act.
   */
  setValue(value: WidgetValue): void

  /**
   * The widgets core attached to this one — a seed's `control_after_generate`,
   * a bounding box's components.
   *
   * `setHidden` already cascades through these, so hiding needs no call here.
   * What does is reading one: a pack asks a seed's control widget whether it
   * says `fixed` or `randomize` to know what the node will do next.
   */
  linked(): readonly WidgetHandle[]
  /**
   * Replaces the controls attached to this widget.
   *
   * Core uses this relationship for compound inputs: hiding a seed also hides
   * its `control_after_generate` picker. Packs build the same compound control
   * when they add a random-seed button or an index policy, and assigning
   * `linkedWidgets` directly was the only way to make conversion-to-input hide
   * the whole unit.
   *
   * Every name must identify another widget on this node. Pass an empty array
   * to clear the relationship.
   */
  setLinked(names: readonly string[]): void

  isHidden(): boolean
  /**
   * Replaces the `type = 'converted-widget'` hack. Value is retained.
   *
   * Cascades to the widgets core attached to this one — a seed's
   * `control_after_generate`, a bounding box's components. The legacy
   * `hideWidget` helper this replaces recursed through `linkedWidgets`, and
   * packs that lost the cascade were left with an orphaned control widget
   * floating where its owner used to be.
   */
  setHidden(hidden: boolean): void
  getOptions(): Readonly<WidgetOptions> | undefined
  setOption(key: string, value: unknown): void
  setLabel(label: string): void

  isDisabled(): boolean
  setDisabled(disabled: boolean): void
  isSerialized(): boolean
  /** The height the host most recently allocated, or undefined before layout. */
  getHeight(): number | undefined
  /**
   * Pins the widget's height in graph units, instead of letting it share
   * whatever space the node has spare.
   *
   * The node divides free height between every widget that does not state one,
   * so a node carrying two mounted strips gave each half the node however
   * small they were meant to be. `MountDef.height` does not do this — it sets
   * the container's CSS height *inside* an allocation the renderer already
   * chose, which is why a fixed strip still drifted.
   *
   * Replaces re-assigning `node.computeSize`, which is what packs did and
   * which is not published. Omit it for a panel meant to fill the node: the
   * growable path is the one that fills.
   */
  setHeight(px: number): void

  /**
   * Replaces capture-and-chain on `widget.callback`, which 1,000+ sites do and
   * which silently drops an earlier pack's listener whenever one forgets to
   * call through. Listeners here are additive and independent.
   */
  on(
    event: 'change',
    listener: (value: WidgetValue, oldValue: WidgetValue) => void
  ): Unsubscribe
  on(event: 'removed', listener: () => void): Unsubscribe
  /**
   * The widget was activated — a button click, or a value committed.
   *
   * Buttons carry no value, so `change` can never fire for one and a button
   * created through this API would otherwise be inert. Prefer `change` when you
   * care about the value; use this when you care that the user acted — a
   * programmatic `setValue` never fires it.
   */
  on(event: 'activate', listener: (value: WidgetValue) => void): Unsubscribe
  /**
   * Contributes behavior to a host-owned multiline text editor without exposing
   * its DOM. The event reports the live value and caret on each input,
   * selection change, or wheel gesture; its write method preserves both the
   * widget commit protocol and the requested selection.
   */
  on(
    event: 'textInteraction',
    listener: (event: WidgetTextInteractionEvent) => void
  ): Unsubscribe
  /**
   * The value is about to be written out, and may be replaced for this
   * destination only.
   *
   * This is what `widget.serializeValue` did, and the reason it is back: a
   * static `serialize` flag can only *suppress* a value, and a whole class of
   * packs needs to *supply* a different one. rgthree's Seed keeps the sentinel
   * `-1` in the saved workflow and sends the rolled seed; pysssss' PresetText
   * expands `@name` into the queued prompt while the user keeps seeing the
   * reference; Impact Pack embeds image data the canvas never shows.
   *
   * `context` says which destination is being built, because those packs want
   * to change one and not the other:
   *
   * - `'workflow'` — the file the user saves.
   * - `'prompt'` — the queued API payload the backend executes.
   * - `'embedded'` — the copy of the workflow that travels with that prompt
   *   and is written into the output image. Distinct from `'workflow'`
   *   because a pack may want the image to reproduce the run while the saved
   *   file keeps its sentinel: rgthree's Seed saves `-1` but embeds the seed
   *   it actually rolled, so dragging the PNG back in reproduces it.
   *
   * A handler that ignores `context` changes all three.
   *
   * Calling `setSerializedValue` replaces the value for this write only; the
   * widget itself is untouched, so the user still sees what they typed. Last
   * handler to call it wins.
   */
  on(
    event: 'beforeSerialize',
    listener: (event: WidgetSerializeEvent) => void
  ): Unsubscribe
}

/** Where a value is being written, and the chance to change it. */
export interface WidgetSerializeEvent {
  readonly context: 'workflow' | 'prompt' | 'embedded'
  /** What would be written if no handler intervened. */
  readonly value: WidgetValue
  setSerializedValue(value: WidgetValue): void
}

export type Unsubscribe = () => void

/**
 * A widget whose body the pack renders itself.
 *
 * @knipIgnoreUnusedButUsedByCustomNodes
 */
export interface MountDef {
  readonly name: string
  /**
   * Fills the mounted container. Called once, with an element already attached
   * to the node.
   *
   * `value` holds meaningful serialized state only when `defaultValue` was
   * given. A decorative mount receives the same accessor for one render shape,
   * but should not use it as storage.
   */
  render(container: HTMLElement, value: MountedValue): void
  /** Releases anything `render` retained — listeners, timers, observers. */
  destroy?(): void
  /** Reserved height in graph units. Omit to size to content. */
  readonly height?: number
  /** Set false to keep the element rendered at low zoom. Defaults to true. */
  readonly hideOnZoom?: boolean
  readonly hidden?: boolean
  /**
   * Whether the value is written into the saved workflow.
   *
   * Defaults to `true` when `defaultValue` makes this a value-holding control,
   * and to `false` for a decorative mount.
   */
  readonly serialize?: boolean
  /**
   * Whether the value is sent in the API prompt. Defaults to `serialize`.
   *
   * These are two different flags in litegraph — `widget.serialize` gates the
   * saved workflow, `options.serialize` gates the prompt — and collapsing them
   * into one boolean made two states unsayable. "Saved but not sent" is the
   * one packs need: it is exactly what the legacy
   * `addDOMWidget(…, { serialize: false })` did, and a readout that a node
   * fills in from its own execution result belongs in the workflow but has no
   * business appearing as an input on the next queue.
   *
   * Set it apart from `serialize` only when the two genuinely differ.
   */
  readonly sendToPrompt?: boolean
  /**
   * Makes this a value-holding widget rather than decoration.
   *
   * Without it a mount is a drawing: it can occupy a `widgets_values` slot but
   * has nothing to put in it, so a colour picker or a text box converted onto
   * `mount` kept its position and silently lost what the user typed. Supplying
   * a default gives the widget a real cell, reachable through `render`'s second
   * argument.
   */
  readonly defaultValue?: MountedData
}

/** What a mounted control can hold. @knipIgnoreUnusedButUsedByCustomNodes */
export type MountedData = string | number | boolean | object | null

/**
 * Reading and writing a mounted widget's value.
 *
 * @knipIgnoreUnusedButUsedByCustomNodes
 */
export interface MountedValue {
  get(): MountedData
  set(value: MountedData): void
  /** Notified when the value changed elsewhere — a workflow load. */
  onChange(listener: (value: MountedData) => void): Unsubscribe
}

/**
 * A pointer event on the widget's own canvas, in the same units `draw` uses.
 *
 * @knipIgnoreUnusedButUsedByCustomNodes
 */
export interface CanvasPointerEvent {
  /** Distance from the canvas's left edge, in CSS pixels. */
  readonly x: number
  /** Distance from its top edge, in CSS pixels. */
  readonly y: number
  /** The DOM event, for modifier keys, `button`, and `preventDefault()`. */
  readonly event: PointerEvent
}

/**
 * The colours a pack should draw its own controls in.
 *
 * Published because we told packs to draw. A widget that hardcodes its palette
 * looks wrong the moment the user switches theme, and the alternative — reading
 * `LiteGraph.WIDGET_BGCOLOR` and friends — is a renderer constant we intend to
 * delete. These are the design system's own tokens, resolved from the widget's
 * computed style, so they follow the theme without the pack knowing which one
 * is active.
 *
 * Named by intent rather than by token, because the token names will churn and
 * a pack should not have to follow. Re-read on every draw, so a theme switch
 * needs nothing from the pack.
 *
 * @knipIgnoreUnusedButUsedByCustomNodes
 */
export interface CanvasTheme {
  /** A control's background. */
  readonly surface: string
  /** The same under the pointer. */
  readonly surfaceHovered: string
  /** A control's outline. */
  readonly border: string
  /** A label. */
  readonly text: string
  /** A value, a unit, anything the label outranks. */
  readonly textSecondary: string
}

/** @knipIgnoreUnusedButUsedByCustomNodes */
export interface CanvasDef {
  readonly name: string
  /** Reserved height in pixels. Omit to size to the node's width. */
  readonly height?: number
  draw(
    context: CanvasRenderingContext2D,
    size: readonly [number, number],
    theme: CanvasTheme,
    value: MountedValue | undefined
  ): void
  /**
   * The pointer went down on this widget.
   *
   * Coordinates are relative to the canvas and in the same units `draw`
   * receives, so a hit test written against the drawing works unchanged —
   * which is the point. A pack that drew its own controls keeps both the
   * drawing and the hit testing; only the surface changes, from the host's
   * canvas to its own.
   *
   * The primary button is taken: it stops here rather than also reaching the
   * node, or adjusting a slider would drag the node underneath it. Middle and
   * right are left alone, so panning and the context menu still work over the
   * widget.
   *
   * The pointer is captured for the gesture, so a drag that leaves the widget
   * still reports moves and the release.
   */
  onPointerDown?(event: CanvasPointerEvent): void
  /** Moves during a drag, and hover when no button is down. */
  onPointerMove?(event: CanvasPointerEvent): void
  onPointerUp?(event: CanvasPointerEvent): void
  /**
   * The secondary button went down on this widget.
   *
   * Right-click is left alone by {@link onPointerDown} so the node's own
   * context menu keeps working over a widget, which is right by default and
   * wrong for a widget that has its own menu — a lora row wants Move Up, Move
   * Down, Remove. Declaring this claims the gesture: the browser menu is
   * suppressed and the node's does not open.
   */
  onContextMenu?(event: CanvasPointerEvent): void
  /**
   * Makes the surface hold a value rather than only draw one.
   *
   * Without it a drawn control that stores something has to be two widgets — a
   * hidden value widget and a surface — and two widgets cannot occupy the one
   * position the original had. That is not a tidiness point: `serialize` writes
   * at each widget's own index and leaves a hole where a non-serializing widget
   * sits, so the pair has to be ordered value-first to keep the saved array
   * intact, and a pack that gets that wrong writes a null into every workflow
   * the node has ever appeared in. It moved rgthree's Power Puter chip row
   * below its code box.
   *
   * `draw` receives the current value as its fourth argument.
   */
  readonly defaultValue?: MountedData
  /** Whether the value reaches the saved workflow. See {@link MountDef.serialize}. */
  readonly serialize?: boolean
  /** Whether the value reaches the API prompt. See {@link MountDef.sendToPrompt}. */
  readonly sendToPrompt?: boolean
}

/** @knipIgnoreUnusedButUsedByCustomNodes */
export interface CanvasHandle {
  readonly widget: WidgetHandle
  /** Redraws now. Call when the data behind the drawing changed. */
  redraw(): void
}

/** Everything needed to create a widget. */
export interface WidgetDef {
  readonly type: string
  readonly name: string
  readonly value?: WidgetValue
  readonly options?: WidgetOptions
  /** Display-only widgets — replaces the readOnly/opacity DOM fiddling. */
  readonly disabled?: boolean
  readonly hidden?: boolean
  /**
   * Whether the value is written into the saved workflow.
   *
   * Replaces `widget.serializeValue = async () => {}`, the idiom packs use to
   * keep a derived readout out of `widgets_values`. Orthogonal to `hidden`.
   */
  readonly serialize?: boolean
}

export interface WidgetCollection {
  readonly length: number
  get(name: string): WidgetHandle | undefined
  at(index: number): WidgetHandle | undefined
  all(): readonly WidgetHandle[]
  names(): readonly string[]
  /**
   * Replaces splice/assign reordering. `names` must be a permutation of the
   * current names — a partial list throws rather than silently dropping
   * widgets, which is how the array-splice idiom lost them.
   */
  reorder(names: readonly string[]): void
  move(name: string, toIndex: number): void
  /**
   * Creates a widget on this node.
   *
   * The counterpart to `remove` — packs that rebuild a readout widget do
   * remove-then-create, and without this only half the operation has a
   * destination, which makes the conversion cosmetic.
   */
  add(def: WidgetDef): WidgetHandle
  /**
   * Mounts an element on the node and hands it to the pack to fill.
   *
   * The replacement for `addDOMWidget`, and the destination for hand-painted
   * canvas controls. Across kjnodes' canvas editors the drawing is rectangles,
   * images, straight lines and text — all DOM primitives — but a pack that
   * wants to keep its existing `ctx` code can append a `<canvas>` to the
   * container and carry it over unchanged.
   *
   * The gain is not the drawing, it is the input: these editors hand-roll
   * hit-testing against bounding boxes because canvas gives them nothing to
   * attach a listener to. Mounted in the DOM, pointer events land on the
   * element and most of that code goes away.
   */
  mount(def: MountDef): WidgetHandle
  /**
   * A per-node drawing surface, and the destination for `onDrawForeground`.
   *
   * Works under both renderers without the pack knowing which it is on: the
   * canvas is a DOM element, which the legacy renderer positions over the
   * graph canvas and Nodes 2.0 renders directly. That is the whole reason it
   * is a mounted element rather than a hook into the graph's own context —
   * drawing into the shared context is what ties a pack to the old renderer.
   *
   * `draw` is called on mount, on resize, and whenever `redraw()` is called.
   */
  canvas(def: CanvasDef): CanvasHandle
  remove(name: string): boolean
  [Symbol.iterator](): Iterator<WidgetHandle>
}

export interface ComboPreviewRegistration {
  /** Namespaced registration id. */
  readonly id: string
  /** Managed model catalogues searched in order. */
  readonly modelCategories: readonly (
    | 'loras'
    | 'checkpoints'
    | 'unet'
    | 'diffusion_models'
  )[]
  /** Model filename suffixes that activate this policy. */
  readonly extensions: readonly (
    | 'safetensors'
    | 'sft'
    | 'pt'
    | 'ckpt'
    | 'gguf'
  )[]
  /** Host-owned adjacent-preview lookup policy. */
  readonly candidatePolicy: 'adjacent-model-preview-v1'
  /** Preview media types the host may display. */
  readonly media: readonly (
    | 'image/png'
    | 'image/webp'
    | 'image/jpeg'
    | 'video/mp4'
    | 'video/webm'
  )[]
}

export interface ComboPreviewAssignment {
  /** Managed model catalogue containing `modelValue`. */
  readonly category: 'loras' | 'checkpoints' | 'unet' | 'diffusion_models'
  /** Logical model filename from the managed combo; never a host path. */
  readonly modelValue: string
  /** Graph node whose host-owned output image is used as the preview. */
  readonly sourceNodeId: string
  /** Exact image in that node's current host-owned output list. */
  readonly imageIndex: number
  readonly policy: 'adjacent-model-preview-v1'
}

export interface WidgetsHandle {
  /**
   * Adds a declarative preview policy to host-owned combo option menus.
   * The host resolves managed assets and renders the hover surface; the pack
   * receives neither filesystem paths nor media URLs.
   */
  registerComboPreview(definition: ComboPreviewRegistration): Unsubscribe
  /**
   * Re-encodes one managed graph output as an adjacent managed-model preview.
   * The host resolves both resources; the pack receives no path or image bytes.
   */
  assignComboPreview(assignment: ComboPreviewAssignment): Promise<void>
}

export type LocalizationMessage =
  | string
  | null
  | { readonly [key: string]: LocalizationMessage }

export interface LocalizationCatalog {
  /** Native vue-i18n-shaped messages such as main/nodeDefs/nodeCategories. */
  readonly messages: Readonly<Record<string, LocalizationMessage>>
  /** Exact-source fallback translations used only at host-owned render points. */
  readonly phrases?: Readonly<Record<string, string>>
}

export interface LocalizationHandle {
  /**
   * Contributes one bounded catalog for a host-supported locale. The host
   * owns merging, rendering, precedence, and cleanup; no DOM access is given.
   */
  registerCatalog(locale: string, catalog: LocalizationCatalog): Unsubscribe
}

// ─── widgetTextInteraction.ts ────────────────────────────────────

/** @knipIgnoreUnusedButUsedByCustomNodes */
export interface WidgetTextSelection {
  readonly start: number
  readonly end: number
}

/** @knipIgnoreUnusedButUsedByCustomNodes */
export interface WidgetTextEventBase {
  readonly value: string
  readonly selection: WidgetTextSelection
  /** Positions a host menu at the text editor without exposing its element. */
  readonly menuEvent: MouseEvent
  /** Commits through the widget protocol and optionally restores the caret. */
  setValue(value: string, selection?: WidgetTextSelection): void
  focus(): void
}

/** @knipIgnoreUnusedButUsedByCustomNodes */
export interface WidgetTextInputEvent extends WidgetTextEventBase {
  readonly kind: 'input' | 'selection'
}

/** @knipIgnoreUnusedButUsedByCustomNodes */
export interface WidgetTextWheelEvent extends WidgetTextEventBase {
  readonly kind: 'wheel'
  readonly deltaY: number
  readonly ctrlKey: boolean
  /** Claims the wheel gesture so the canvas does not pan or zoom. */
  preventDefault(): void
}

/** @knipIgnoreUnusedButUsedByCustomNodes */
export interface WidgetTextKeyEvent extends WidgetTextEventBase {
  readonly kind: 'keydown'
  readonly key: string
  readonly ctrlKey: boolean
  readonly altKey: boolean
  readonly shiftKey: boolean
  readonly metaKey: boolean
  readonly repeat: boolean
  preventDefault(): void
  stopPropagation(): void
}

/**
 * An interaction with a host-owned multiline text editor.
 *
 * This is the renderer-independent replacement for reaching through
 * `widget.inputEl`: packs can inspect the live caret, offer a menu through
 * `menuEvent`, replace text, and implement selection-based wheel edits without
 * receiving the host's element or markup.
 */
export type WidgetTextInteractionEvent =
  | WidgetTextInputEvent
  | WidgetTextWheelEvent
  | WidgetTextKeyEvent

// ─── widgetTypes.ts ──────────────────────────────────────────────

/** What a pack-declared widget can hold. */
export type WidgetTypeData = string | number | boolean | object | null

/**
 * Reading and writing the widget's value, for the renderer to bind to.
 *
 * @knipIgnoreUnusedButUsedByCustomNodes
 */
export interface WidgetTypeValue {
  get(): WidgetTypeData
  set(value: WidgetTypeData): void
  /** Notified when the value changes for any other reason — a workflow load. */
  onChange(listener: (value: WidgetTypeData) => void): Unsubscribe
}

export interface WidgetTypeContext {
  /** A frozen snapshot of the input declaration's current options. */
  getOptions(): Readonly<Record<string, unknown>>
  /**
   * Runs while the widget's owning node belongs to a graph.
   *
   * Widget constructors run before a node has an id or graph, so a node handle
   * cannot be supplied directly to `render`. The listener runs after the node
   * joins a graph and tears down when it leaves.
   */
  onNodeReady(listener: (node: NodeHandle) => Unsubscribe | void): Unsubscribe
}

export interface WidgetTypeDef {
  /** Used when the definition supplies none. */
  readonly defaultValue?: WidgetTypeData
  /** Height in pixels. Omit to size to content. */
  readonly height?: number
  /** Smallest width the control needs, in pixels. */
  readonly minWidth?: number
  /** Smallest height the control needs, in pixels. */
  readonly minHeight?: number
  /**
   * Whether the value is saved and sent. Defaults to `true`: this widget holds
   * a real input value, unlike a mounted decoration.
   */
  readonly serialize?: boolean
  /**
   * Fills the container. Return a teardown if the control owns listeners,
   * timers or observers.
   *
   * `name` is the input being rendered — controls commonly label themselves
   * with it, which a type-level renderer otherwise has no way to know.
   */
  render(
    container: HTMLElement,
    value: WidgetTypeValue,
    name: string,
    context: WidgetTypeContext
  ): Unsubscribe | void
}
```
