shadimage
nteract
componentwidget-controlssource: ok · hit

Widget Controls

Built-in shadcn-backed widget components for standard ipywidgets including controls (IntSlider, FloatSlider, IntRangeSlider, FloatRangeSlider, IntProgress, Button, Checkbox, Text, Textarea, HTML, ColorPicker, Dropdown, RadioButtons, ToggleButton, ToggleButtons, SelectMultiple) and layout containers (VBox, HBox, Box, GridBox, Accordion, Tab).

npx shadcn add "https://nteract-elements.vercel.app/r/widget-controls.json"
free

Interaction

carouselaccordiondropdown

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light

Registry dependencies

@nteract/widget-view@nteract/output-areasliderprogressbuttoncheckboxinputtextarealabelselectradio-grouptoggletoggle-groupaccordiontabscommandpopoverbadge

Files58 · source cached 10/14/2026, 3:15:52 PM

registry/widgets/controls/index.ts
/**
 * Built-in widget components for @jupyter-widgets/controls.
 *
 * This module registers all built-in widget components with the widget registry.
 * Import this module to enable rendering of standard ipywidgets.
 */

import { registerWidget } from "../widget-registry";
import { AccordionWidget } from "./accordion-widget";
import { AudioWidget } from "./audio-widget";
import { BoundedFloatTextWidget } from "./bounded-float-text-widget";
import { BoundedIntTextWidget } from "./bounded-int-text-widget";
import { BoxWidget } from "./box-widget";
import { ButtonWidget } from "./button-widget";
import { CheckboxWidget } from "./checkbox-widget";
import { ColorPicker } from "./color-picker";
import { ColorsInputWidget } from "./colors-input-widget";
import { ComboboxWidget } from "./combobox-widget";
import { ControllerAxisWidget } from "./controller-axis-widget";
import { ControllerButtonWidget } from "./controller-button-widget";
import { ControllerWidget } from "./controller-widget";
import { DatePickerWidget } from "./date-picker-widget";
import { DatetimeWidget } from "./datetime-widget";
// Selection widgets
import { DropdownWidget } from "./dropdown-widget";
import { FileUploadWidget } from "./file-upload-widget";
import { FloatLogSlider } from "./float-log-slider";
import { FloatProgress } from "./float-progress";
import { FloatRangeSlider } from "./float-range-slider";
import { FloatSlider } from "./float-slider";
import { FloatTextWidget } from "./float-text-widget";
import { FloatsInputWidget } from "./floats-input-widget";
import { GridBoxWidget } from "./gridbox-widget";
import { HBoxWidget } from "./hbox-widget";
import { HTMLMathWidget } from "./html-math-widget";
import { HTMLWidget } from "./html-widget";
import { ImageWidget } from "./image-widget";
import { IntProgress } from "./int-progress";
import { IntRangeSlider } from "./int-range-slider";
// Import widget components
import { IntSlider } from "./int-slider";
import { IntTextWidget } from "./int-text-widget";
import { IntsInputWidget } from "./ints-input-widget";
import { LabelWidget } from "./label-widget";
import { DirectionalLinkWidget, LinkWidget } from "./link-widget";
import { OutputWidget } from "./output-widget";
import { PasswordWidget } from "./password-widget";
import { PlayWidget } from "./play-widget";
import { RadioButtonsWidget } from "./radio-buttons-widget";
import { SelectMultipleWidget } from "./select-multiple-widget";
import { SelectWidget } from "./select-widget";
import { SelectionRangeSliderWidget } from "./selection-range-slider-widget";
import { SelectionSliderWidget } from "./selection-slider-widget";
import { StackWidget } from "./stack-widget";
import { TabWidget } from "./tab-widget";
import { TagsInputWidget } from "./tags-input-widget";
import { TextWidget } from "./text-widget";
import { TextareaWidget } from "./textarea-widget";
import { TimePickerWidget } from "./time-picker-widget";
import { ToggleButtonWidget } from "./toggle-button-widget";
import { ToggleButtonsWidget } from "./toggle-buttons-widget";
import { ValidWidget } from "./valid-widget";
// Import layout widget components
import { VBoxWidget } from "./vbox-widget";
import { VideoWidget } from "./video-widget";

// Register all widgets with their model names
registerWidget("IntSliderModel", IntSlider);
registerWidget("FloatSliderModel", FloatSlider);
registerWidget("FloatLogSliderModel", FloatLogSlider);
registerWidget("IntRangeSliderModel", IntRangeSlider);
registerWidget("FloatRangeSliderModel", FloatRangeSlider);
registerWidget("SelectionSliderModel", SelectionSliderWidget);
registerWidget("SelectionRangeSliderModel", SelectionRangeSliderWidget);
registerWidget("IntProgressModel", IntProgress);
registerWidget("FloatProgressModel", FloatProgress);
registerWidget("ButtonModel", ButtonWidget);
registerWidget("PlayModel", PlayWidget);
registerWidget("CheckboxModel", CheckboxWidget);
registerWidget("TextModel", TextWidget);
registerWidget("TextareaModel", TextareaWidget);
registerWidget("HTMLModel", HTMLWidget);
registerWidget("HTMLMathModel", HTMLMathWidget);
registerWidget("LabelModel", LabelWidget);
registerWidget("ImageModel", ImageWidget);
registerWidget("AudioModel", AudioWidget);
registerWidget("VideoModel", VideoWidget);
registerWidget("ColorPickerModel", ColorPicker);
registerWidget("DatePickerModel", DatePickerWidget);
registerWidget("DatetimeModel", DatetimeWidget);
registerWidget("NaiveDatetimeModel", DatetimeWidget);
registerWidget("TimePickerModel", TimePickerWidget);
registerWidget("TimeModel", TimePickerWidget);
// Numeric text inputs
registerWidget("IntTextModel", IntTextWidget);
registerWidget("FloatTextModel", FloatTextWidget);
registerWidget("BoundedIntTextModel", BoundedIntTextWidget);
registerWidget("BoundedFloatTextModel", BoundedFloatTextWidget);
registerWidget("PasswordModel", PasswordWidget);
registerWidget("ValidModel", ValidWidget);
// Selection widgets
registerWidget("DropdownModel", DropdownWidget);
registerWidget("ComboboxModel", ComboboxWidget);
registerWidget("RadioButtonsModel", RadioButtonsWidget);
registerWidget("SelectModel", SelectWidget);
registerWidget("SelectMultipleModel", SelectMultipleWidget);
registerWidget("ToggleButtonModel", ToggleButtonWidget);
registerWidget("ToggleButtonsModel", ToggleButtonsWidget);
// Multi-value inputs
registerWidget("TagsInputModel", TagsInputWidget);
registerWidget("ColorsInputModel", ColorsInputWidget);
registerWidget("IntsInputModel", IntsInputWidget);
registerWidget("FloatsInputModel", FloatsInputWidget);
registerWidget("FileUploadModel", FileUploadWidget);

// Link widgets (frontend-only property synchronization)
registerWidget("LinkModel", LinkWidget);
registerWidget("DirectionalLinkModel", DirectionalLinkWidget);

// Register layout widgets
registerWidget("VBoxModel", VBoxWidget);
registerWidget("HBoxModel", HBoxWidget);
registerWidget("BoxModel", BoxWidget);
registerWidget("GridBoxModel", GridBoxWidget);
registerWidget("AccordionModel", AccordionWidget);
registerWidget("TabModel", TabWidget);
registerWidget("StackModel", StackWidget);

// Controller widgets (Gamepad API)
registerWidget("ControllerModel", ControllerWidget);
registerWidget("ControllerButtonModel", ControllerButtonWidget);
registerWidget("ControllerAxisModel", ControllerAxisWidget);

// Output widget (@jupyter-widgets/output)
registerWidget("OutputModel", OutputWidget);

export { AccordionWidget } from "./accordion-widget";
export { AudioWidget } from "./audio-widget";
export { BoundedFloatTextWidget } from "./bounded-float-text-widget";
export { BoundedIntTextWidget } from "./bounded-int-text-widget";
export { BoxWidget } from "./box-widget";
export { ButtonWidget } from "./button-widget";
export { CheckboxWidget } from "./checkbox-widget";
export { ColorPicker } from "./color-picker";
export { ColorsInputWidget } from "./colors-input-widget";
export { ComboboxWidget } from "./combobox-widget";
export { ControllerAxisWidget } from "./controller-axis-widget";
export { ControllerButtonWidget } from "./controller-button-widget";
export { ControllerWidget } from "./controller-widget";
export { DatePickerWidget } from "./date-picker-widget";
export { DatetimeWidget } from "./datetime-widget";
// Selection widgets
export { DropdownWidget } from "./dropdown-widget";
export { FileUploadWidget } from "./file-upload-widget";
export { FloatLogSlider } from "./float-log-slider";
export { FloatProgress } from "./float-progress";
export { FloatRangeSlider } from "./float-range-slider";
export { FloatSlider } from "./float-slider";
export { FloatTextWidget } from "./float-text-widget";
export { FloatsInputWidget } from "./floats-input-widget";
export { GridBoxWidget } from "./gridbox-widget";
export { HBoxWidget } from "./hbox-widget";
export { HTMLMathWidget } from "./html-math-widget";
export { HTMLWidget } from "./html-widget";
export { ImageWidget } from "./image-widget";
export { IntProgress } from "./int-progress";
export { IntRangeSlider } from "./int-range-slider";
// Re-export components for direct use
export { IntSlider } from "./int-slider";
export { IntTextWidget } from "./int-text-widget";
export { IntsInputWidget } from "./ints-input-widget";
export { LabelWidget } from "./label-widget";
export { DirectionalLinkWidget, LinkWidget } from "./link-widget";
export { OutputWidget } from "./output-widget";
export { PasswordWidget } from "./password-widget";
export { PlayWidget } from "./play-widget";
export { RadioButtonsWidget } from "./radio-buttons-widget";
export { SelectMultipleWidget } from "./select-multiple-widget";
export { SelectWidget } from "./select-widget";
export { SelectionRangeSliderWidget } from "./selection-range-slider-widget";
export { SelectionSliderWidget } from "./selection-slider-widget";
export { StackWidget } from "./stack-widget";
export { TabWidget } from "./tab-widget";
export { TagsInputWidget } from "./tags-input-widget";
export { TextWidget } from "./text-widget";
export { TextareaWidget } from "./textarea-widget";
export { TimePickerWidget } from "./time-picker-widget";
export { ToggleButtonWidget } from "./toggle-button-widget";
export { ToggleButtonsWidget } from "./toggle-buttons-widget";
export { ValidWidget } from "./valid-widget";
// Re-export layout widgets
export { VBoxWidget } from "./vbox-widget";
export { VideoWidget } from "./video-widget";
registry/widgets/use-layout-styles.ts
import type { CSSProperties } from "react";
import { useMemo } from "react";
import {
  extractChildGridStyles,
  extractContainerGridStyles,
  extractGeneralStyles,
  hasContainerGridStyles,
} from "./layout-utils";
import type { WidgetModel } from "./widget-store";
import { useResolvedModelValue } from "./widget-store-context";

/**
 * Hook to extract CSS styles from a widget's Layout model.
 *
 * ipywidgets widgets have a `layout` property that references a LayoutModel.
 * This hook resolves that reference and extracts CSS properties for styling.
 *
 * @param modelId - The widget model ID
 * @returns Object with containerStyle (for grid containers) and childStyle (for grid_area positioning)
 */
export function useLayoutStyles(modelId: string): {
  /** CSS grid container styles (grid_template_columns, grid_template_rows, etc.) + general styles */
  containerStyle: CSSProperties;
  /** CSS child placement styles (grid_area, grid_row, grid_column) */
  childStyle: CSSProperties;
  /** Whether the layout has grid container properties */
  hasGridLayout: boolean;
} {
  // Resolve the layout model reference (IPY_MODEL_xxx -> LayoutModel)
  const layoutModel = useResolvedModelValue(modelId, "layout") as
    | WidgetModel
    | undefined;

  return useMemo(() => {
    if (!layoutModel?.state) {
      return {
        containerStyle: {},
        childStyle: {},
        hasGridLayout: false,
      };
    }

    const state = layoutModel.state;

    // Extract different style categories
    const gridContainerStyles = extractContainerGridStyles(state);
    const generalStyles = extractGeneralStyles(state);
    const childStyles = extractChildGridStyles(state);

    // Combine container grid styles with general styles for the container
    const containerStyle: CSSProperties = {
      ...generalStyles,
      ...gridContainerStyles,
    };

    return {
      containerStyle,
      childStyle: childStyles,
      hasGridLayout: hasContainerGridStyles(state),
    };
  }, [layoutModel]);
}
registry/widgets/layout-utils.ts
import type { CSSProperties } from "react";

/**
 * Utilities for converting ipywidgets Layout model properties to CSS.
 *
 * ipywidgets uses snake_case for CSS properties (e.g., grid_template_columns),
 * which need to be converted to camelCase for React's style prop.
 */

/**
 * Map of ipywidgets Layout model property names to React CSS property names.
 * Only includes properties that need explicit mapping (non-trivial conversions).
 */
const LAYOUT_CSS_MAP: Record<string, string> = {
  // Grid container properties
  grid_template_columns: "gridTemplateColumns",
  grid_template_rows: "gridTemplateRows",
  grid_template_areas: "gridTemplateAreas",
  grid_auto_columns: "gridAutoColumns",
  grid_auto_rows: "gridAutoRows",
  grid_auto_flow: "gridAutoFlow",
  grid_gap: "gridGap",
  // Grid child placement properties
  grid_row: "gridRow",
  grid_column: "gridColumn",
  grid_area: "gridArea",
  // Size properties
  min_width: "minWidth",
  max_width: "maxWidth",
  min_height: "minHeight",
  max_height: "maxHeight",
  // Flexbox properties
  flex_flow: "flexFlow",
  align_items: "alignItems",
  align_self: "alignSelf",
  align_content: "alignContent",
  justify_content: "justifyContent",
  justify_items: "justifyItems",
  // Text properties
  object_fit: "objectFit",
  object_position: "objectPosition",
  // Overflow
  overflow_x: "overflowX",
  overflow_y: "overflowY",
};

/**
 * Grid container properties that apply to the parent grid element.
 */
const CONTAINER_GRID_PROPERTIES = new Set([
  "grid_template_columns",
  "grid_template_rows",
  "grid_template_areas",
  "grid_auto_columns",
  "grid_auto_rows",
  "grid_auto_flow",
  "grid_gap",
]);

/**
 * Child placement properties that apply to grid children.
 */
const CHILD_GRID_PROPERTIES = new Set(["grid_row", "grid_column", "grid_area"]);

/**
 * General layout properties that can apply to any element.
 */
const GENERAL_PROPERTIES = new Set([
  "width",
  "height",
  "min_width",
  "max_width",
  "min_height",
  "max_height",
  "margin",
  "padding",
  "border",
  "overflow",
  "overflow_x",
  "overflow_y",
  "visibility",
  "display",
  "flex",
  "flex_flow",
  "align_items",
  "align_self",
  "align_content",
  "justify_content",
  "justify_items",
  "order",
  "object_fit",
  "object_position",
]);

/**
 * Convert a snake_case property name to camelCase.
 */
function snakeToCamelCase(str: string): string {
  return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
}

/**
 * Convert an ipywidgets Layout property name to a React CSS property name.
 */
function toReactCSSProperty(snakeCase: string): string {
  return LAYOUT_CSS_MAP[snakeCase] ?? snakeToCamelCase(snakeCase);
}

/**
 * Extract CSS properties from a Layout model state.
 *
 * @param state - The Layout model's state object
 * @param propertyFilter - Optional set of property names to include
 * @returns React CSSProperties object
 */
export function layoutStateToCSS(
  state: Record<string, unknown>,
  propertyFilter?: Set<string>,
): CSSProperties {
  const style: Record<string, string> = {};

  for (const [key, value] of Object.entries(state)) {
    // Skip internal ipywidgets properties
    if (key.startsWith("_")) continue;
    // Skip null, undefined, or empty string values
    if (value === null || value === undefined || value === "") continue;
    // Apply filter if provided
    if (propertyFilter && !propertyFilter.has(key)) continue;
    // Only include string values (CSS values)
    if (typeof value !== "string") continue;

    const cssProperty = toReactCSSProperty(key);
    style[cssProperty] = value;
  }

  return style as CSSProperties;
}

/**
 * Extract container grid CSS properties from a Layout model state.
 * These properties should be applied to grid container elements.
 */
export function extractContainerGridStyles(
  state: Record<string, unknown>,
): CSSProperties {
  return layoutStateToCSS(state, CONTAINER_GRID_PROPERTIES);
}

/**
 * Extract child placement CSS properties from a Layout model state.
 * These properties should be applied to grid children for positioning.
 */
export function extractChildGridStyles(
  state: Record<string, unknown>,
): CSSProperties {
  return layoutStateToCSS(state, CHILD_GRID_PROPERTIES);
}

/**
 * Extract general layout CSS properties from a Layout model state.
 * These can be applied to any element (width, height, margin, etc.).
 */
export function extractGeneralStyles(
  state: Record<string, unknown>,
): CSSProperties {
  return layoutStateToCSS(state, GENERAL_PROPERTIES);
}

/**
 * Check if a Layout model state has any grid container properties.
 */
export function hasContainerGridStyles(
  state: Record<string, unknown>,
): boolean {
  for (const key of CONTAINER_GRID_PROPERTIES) {
    const value = state[key];
    if (value !== null && value !== undefined && value !== "") {
      return true;
    }
  }
  return false;
}
registry/widgets/controls/int-slider.tsx
/**
 * IntSlider widget - renders an integer slider.
 *
 * Maps to ipywidgets IntSliderModel.
 */

import { Label } from "@/components/ui/label";
import { Slider } from "@/components/ui/slider";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
  useWidgetModelValue,
  useWidgetStoreRequired,
} from "../widget-store-context";

export function IntSlider({ modelId, className }: WidgetComponentProps) {
  const { sendUpdate } = useWidgetStoreRequired();

  // Subscribe to individual state keys for fine-grained updates
  const value = useWidgetModelValue<number>(modelId, "value") ?? 0;
  const min = useWidgetModelValue<number>(modelId, "min") ?? 0;
  const max = useWidgetModelValue<number>(modelId, "max") ?? 100;
  const step = useWidgetModelValue<number>(modelId, "step") ?? 1;
  const description = useWidgetModelValue<string>(modelId, "description");
  const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
  const orientation =
    useWidgetModelValue<"horizontal" | "vertical">(modelId, "orientation") ??
    "horizontal";
  const readout = useWidgetModelValue<boolean>(modelId, "readout") ?? true;

  const handleChange = (newValue: number[]) => {
    // Round to step and clamp to range
    const v = Math.round(newValue[0] / step) * step;
    const clamped = Math.min(max, Math.max(min, v));
    sendUpdate(modelId, { value: clamped });
  };

  const isVertical = orientation === "vertical";

  return (
    <div
      className={cn(
        "flex gap-3",
        isVertical ? "flex-col items-center" : "items-center",
        className,
      )}
      data-widget-id={modelId}
      data-widget-type="IntSlider"
    >
      {description && <Label className="shrink-0 text-sm">{description}</Label>}
      <Slider
        value={[value]}
        min={min}
        max={max}
        step={step}
        disabled={disabled}
        orientation={orientation}
        onValueChange={handleChange}
        className={isVertical ? "h-32" : "flex-1 min-w-24"}
      />
      {readout && (
        <span className="w-12 text-right tabular-nums text-sm text-muted-foreground">
          {value}
        </span>
      )}
    </div>
  );
}

export default IntSlider;
registry/widgets/controls/float-slider.tsx
/**
 * FloatSlider widget - renders a floating point slider.
 *
 * Maps to ipywidgets FloatSliderModel.
 */

import { Label } from "@/components/ui/label";
import { Slider } from "@/components/ui/slider";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
  useWidgetModelValue,
  useWidgetStoreRequired,
} from "../widget-store-context";

export function FloatSlider({ modelId, className }: WidgetComponentProps) {
  const { sendUpdate } = useWidgetStoreRequired();

  // Subscribe to individual state keys for fine-grained updates
  const value = useWidgetModelValue<number>(modelId, "value") ?? 0;
  const min = useWidgetModelValue<number>(modelId, "min") ?? 0;
  const max = useWidgetModelValue<number>(modelId, "max") ?? 100;
  const step = useWidgetModelValue<number>(modelId, "step") ?? 0.1;
  const description = useWidgetModelValue<string>(modelId, "description");
  const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
  const orientation =
    useWidgetModelValue<"horizontal" | "vertical">(modelId, "orientation") ??
    "horizontal";
  const readout = useWidgetModelValue<boolean>(modelId, "readout") ?? true;
  const readoutFormat =
    useWidgetModelValue<string>(modelId, "readout_format") ?? ".2f";

  const handleChange = (newValue: number[]) => {
    // Clamp to range (no integer rounding for floats)
    const clamped = Math.min(max, Math.max(min, newValue[0]));
    sendUpdate(modelId, { value: clamped });
  };

  // Format value for display based on readout_format
  const formatValue = (v: number): string => {
    // Parse Python-style format spec (e.g., ".2f")
    const match = readoutFormat.match(/\.(\d+)f/);
    if (match) {
      return v.toFixed(parseInt(match[1], 10));
    }
    return String(v);
  };

  const isVertical = orientation === "vertical";

  return (
    <div
      className={cn(
        "flex gap-3",
        isVertical ? "flex-col items-center" : "items-center",
        className,
      )}
      data-widget-id={modelId}
      data-widget-type="FloatSlider"
    >
      {description && <Label className="shrink-0 text-sm">{description}</Label>}
      <Slider
        value={[value]}
        min={min}
        max={max}
        step={step}
        disabled={disabled}
        orientation={orientation}
        onValueChange={handleChange}
        className={isVertical ? "h-32" : "flex-1 min-w-24"}
      />
      {readout && (
        <span className="w-16 text-right tabular-nums text-sm text-muted-foreground">
          {formatValue(value)}
        </span>
      )}
    </div>
  );
}

export default FloatSlider;
registry/widgets/controls/int-range-slider.tsx
/**
 * IntRangeSlider widget - renders a dual-handle range slider for integers.
 *
 * Maps to ipywidgets IntRangeSliderModel.
 */

import { Label } from "@/components/ui/label";
import { Slider } from "@/components/ui/slider";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
  useWidgetModelValue,
  useWidgetStoreRequired,
} from "../widget-store-context";

export function IntRangeSlider({ modelId, className }: WidgetComponentProps) {
  const { sendUpdate } = useWidgetStoreRequired();

  // ipywidgets uses "value" as a tuple [lower, upper]
  const value = useWidgetModelValue<[number, number]>(modelId, "value") ?? [
    25, 75,
  ];
  const min = useWidgetModelValue<number>(modelId, "min") ?? 0;
  const max = useWidgetModelValue<number>(modelId, "max") ?? 100;
  const step = useWidgetModelValue<number>(modelId, "step") ?? 1;
  const description = useWidgetModelValue<string>(modelId, "description");
  const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
  const orientation =
    useWidgetModelValue<"horizontal" | "vertical">(modelId, "orientation") ??
    "horizontal";
  const readout = useWidgetModelValue<boolean>(modelId, "readout") ?? true;

  const handleChange = (newValue: number[]) => {
    // Round to step and clamp to range
    const lower = Math.round(newValue[0] / step) * step;
    const upper = Math.round(newValue[1] / step) * step;
    const clampedLower = Math.min(max, Math.max(min, lower));
    const clampedUpper = Math.min(max, Math.max(min, upper));
    sendUpdate(modelId, { value: [clampedLower, clampedUpper] });
  };

  const isVertical = orientation === "vertical";

  return (
    <div
      className={cn(
        "flex gap-3",
        isVertical ? "flex-col items-center" : "items-center",
        className,
      )}
      data-widget-id={modelId}
      data-widget-type="IntRangeSlider"
    >
      {description && <Label className="shrink-0 text-sm">{description}</Label>}
      <Slider
        value={value}
        min={min}
        max={max}
        step={step}
        disabled={disabled}
        orientation={orientation}
        onValueChange={handleChange}
        className={isVertical ? "h-32" : "flex-1 min-w-24"}
      />
      {readout && (
        <span className="w-20 text-right tabular-nums text-sm text-muted-foreground">
          {value[0]} – {value[1]}
        </span>
      )}
    </div>
  );
}

export default IntRangeSlider;
registry/widgets/controls/float-range-slider.tsx
/**
 * FloatRangeSlider widget - renders a dual-handle range slider for floats.
 *
 * Maps to ipywidgets FloatRangeSliderModel.
 */

import { Label } from "@/components/ui/label";
import { Slider } from "@/components/ui/slider";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
  useWidgetModelValue,
  useWidgetStoreRequired,
} from "../widget-store-context";

export function FloatRangeSlider({ modelId, className }: WidgetComponentProps) {
  const { sendUpdate } = useWidgetStoreRequired();

  // ipywidgets uses "value" as a tuple [lower, upper]
  const value = useWidgetModelValue<[number, number]>(modelId, "value") ?? [
    25.0, 75.0,
  ];
  const min = useWidgetModelValue<number>(modelId, "min") ?? 0;
  const max = useWidgetModelValue<number>(modelId, "max") ?? 100;
  const step = useWidgetModelValue<number>(modelId, "step") ?? 0.1;
  const description = useWidgetModelValue<string>(modelId, "description");
  const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
  const orientation =
    useWidgetModelValue<"horizontal" | "vertical">(modelId, "orientation") ??
    "horizontal";
  const readout = useWidgetModelValue<boolean>(modelId, "readout") ?? true;
  const readoutFormat =
    useWidgetModelValue<string>(modelId, "readout_format") ?? ".2f";

  const handleChange = (newValue: number[]) => {
    // Clamp to range (no integer rounding for floats)
    const clampedLower = Math.min(max, Math.max(min, newValue[0]));
    const clampedUpper = Math.min(max, Math.max(min, newValue[1]));
    sendUpdate(modelId, { value: [clampedLower, clampedUpper] });
  };

  // Format value for display based on readout_format
  const formatValue = (v: number): string => {
    // Parse Python-style format spec (e.g., ".2f")
    const match = readoutFormat.match(/\.(\d+)f/);
    if (match) {
      return v.toFixed(parseInt(match[1], 10));
    }
    return String(v);
  };

  const isVertical = orientation === "vertical";

  return (
    <div
      className={cn(
        "flex gap-3",
        isVertical ? "flex-col items-center" : "items-center",
        className,
      )}
      data-widget-id={modelId}
      data-widget-type="FloatRangeSlider"
    >
      {description && <Label className="shrink-0 text-sm">{description}</Label>}
      <Slider
        value={value}
        min={min}
        max={max}
        step={step}
        disabled={disabled}
        orientation={orientation}
        onValueChange={handleChange}
        className={isVertical ? "h-32" : "flex-1 min-w-24"}
      />
      {readout && (
        <span className="w-28 text-right tabular-nums text-sm text-muted-foreground">
          {formatValue(value[0])} – {formatValue(value[1])}
        </span>
      )}
    </div>
  );
}

export default FloatRangeSlider;
registry/widgets/controls/int-progress.tsx
/**
 * IntProgress widget - renders an integer progress bar.
 *
 * Maps to ipywidgets IntProgressModel.
 */

import { Label } from "@/components/ui/label";
import { Progress } from "@/components/ui/progress";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import { useWidgetModelValue } from "../widget-store-context";

export function IntProgress({ modelId, className }: WidgetComponentProps) {
  // Subscribe to individual state keys
  const value = useWidgetModelValue<number>(modelId, "value") ?? 0;
  const min = useWidgetModelValue<number>(modelId, "min") ?? 0;
  const max = useWidgetModelValue<number>(modelId, "max") ?? 100;
  const description = useWidgetModelValue<string>(modelId, "description");
  const barStyle =
    useWidgetModelValue<"success" | "info" | "warning" | "danger" | "">(
      modelId,
      "bar_style",
    ) ?? "";
  const orientation =
    useWidgetModelValue<"horizontal" | "vertical">(modelId, "orientation") ??
    "horizontal";

  // Calculate percentage
  const range = max - min;
  const percentage = range > 0 ? ((value - min) / range) * 100 : 0;

  // Map bar_style to Tailwind classes
  const barStyleClasses: Record<string, string> = {
    success: "[&>[data-slot=progress-indicator]]:bg-green-500",
    info: "[&>[data-slot=progress-indicator]]:bg-blue-500",
    warning: "[&>[data-slot=progress-indicator]]:bg-yellow-500",
    danger: "[&>[data-slot=progress-indicator]]:bg-red-500",
  };

  const isVertical = orientation === "vertical";

  return (
    <div
      className={cn(
        "flex gap-3",
        isVertical ? "flex-col items-center" : "flex-1 items-center",
        className,
      )}
      data-widget-id={modelId}
      data-widget-type="IntProgress"
    >
      {description && <Label className="shrink-0 text-sm">{description}</Label>}
      <Progress
        value={percentage}
        className={cn(
          isVertical ? "h-32 w-2" : "flex-1 min-w-24",
          barStyle && barStyleClasses[barStyle],
        )}
      />
    </div>
  );
}

export default IntProgress;
registry/widgets/controls/float-progress.tsx
/**
 * FloatProgress widget - renders a floating point progress bar.
 *
 * Maps to ipywidgets FloatProgressModel.
 */

import { Label } from "@/components/ui/label";
import { Progress } from "@/components/ui/progress";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import { useWidgetModelValue } from "../widget-store-context";

export function FloatProgress({ modelId, className }: WidgetComponentProps) {
  // Subscribe to individual state keys
  const value = useWidgetModelValue<number>(modelId, "value") ?? 0;
  const min = useWidgetModelValue<number>(modelId, "min") ?? 0;
  const max = useWidgetModelValue<number>(modelId, "max") ?? 100;
  const description = useWidgetModelValue<string>(modelId, "description");
  const barStyle =
    useWidgetModelValue<"success" | "info" | "warning" | "danger" | "">(
      modelId,
      "bar_style",
    ) ?? "";
  const orientation =
    useWidgetModelValue<"horizontal" | "vertical">(modelId, "orientation") ??
    "horizontal";

  // Calculate percentage
  const range = max - min;
  const percentage = range > 0 ? ((value - min) / range) * 100 : 0;

  // Map bar_style to Tailwind classes
  const barStyleClasses: Record<string, string> = {
    success: "[&>[data-slot=progress-indicator]]:bg-green-500",
    info: "[&>[data-slot=progress-indicator]]:bg-blue-500",
    warning: "[&>[data-slot=progress-indicator]]:bg-yellow-500",
    danger: "[&>[data-slot=progress-indicator]]:bg-red-500",
  };

  const isVertical = orientation === "vertical";

  return (
    <div
      className={cn(
        "flex gap-3",
        isVertical ? "flex-col items-center" : "flex-1 items-center",
        className,
      )}
      data-widget-id={modelId}
      data-widget-type="FloatProgress"
    >
      {description && <Label className="shrink-0 text-sm">{description}</Label>}
      <Progress
        value={percentage}
        className={cn(
          isVertical ? "h-32 w-2" : "flex-1 min-w-24",
          barStyle && barStyleClasses[barStyle],
        )}
      />
    </div>
  );
}

export default FloatProgress;
registry/widgets/controls/button-widget.tsx
/**
 * Button widget - renders a clickable button.
 *
 * Maps to ipywidgets ButtonModel.
 */

import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
  useWidgetModelValue,
  useWidgetStoreRequired,
} from "../widget-store-context";
import { getButtonStyle } from "./button-style-utils";

export function ButtonWidget({ modelId, className }: WidgetComponentProps) {
  const { sendCustom } = useWidgetStoreRequired();

  // Subscribe to individual state keys
  const description = useWidgetModelValue<string>(modelId, "description") ?? "";
  const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
  const buttonStyle =
    useWidgetModelValue<string>(modelId, "button_style") ?? "";
  const icon = useWidgetModelValue<string>(modelId, "icon");
  const tooltip = useWidgetModelValue<string>(modelId, "tooltip");

  const handleClick = () => {
    // Send click event to kernel
    sendCustom(modelId, { event: "click" });
  };

  const { variant, className: styleClassName } = getButtonStyle(buttonStyle);

  return (
    <Button
      variant={variant}
      disabled={disabled}
      onClick={handleClick}
      title={tooltip}
      className={cn(styleClassName, className)}
      data-widget-id={modelId}
      data-widget-type="Button"
    >
      {icon && <span className="mr-1">{icon}</span>}
      {description || "Button"}
    </Button>
  );
}

export default ButtonWidget;
registry/widgets/controls/checkbox-widget.tsx
/**
 * Checkbox widget - renders a checkbox input.
 *
 * Maps to ipywidgets CheckboxModel.
 */

import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
  useWidgetModelValue,
  useWidgetStoreRequired,
} from "../widget-store-context";

export function CheckboxWidget({ modelId, className }: WidgetComponentProps) {
  const { sendUpdate } = useWidgetStoreRequired();

  // Subscribe to individual state keys
  const value = useWidgetModelValue<boolean>(modelId, "value") ?? false;
  const description = useWidgetModelValue<string>(modelId, "description");
  const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
  const indent = useWidgetModelValue<boolean>(modelId, "indent") ?? true;

  const handleChange = (checked: boolean | "indeterminate") => {
    if (checked !== "indeterminate") {
      sendUpdate(modelId, { value: checked });
    }
  };

  return (
    <div
      className={cn("flex items-center gap-2", indent && "pl-4", className)}
      data-widget-id={modelId}
      data-widget-type="Checkbox"
    >
      <Checkbox
        id={`checkbox-${modelId}`}
        checked={value}
        disabled={disabled}
        onCheckedChange={handleChange}
      />
      {description && (
        <Label
          htmlFor={`checkbox-${modelId}`}
          className={cn("text-sm", disabled && "opacity-50 cursor-not-allowed")}
        >
          {description}
        </Label>
      )}
    </div>
  );
}

export default CheckboxWidget;
registry/widgets/controls/text-widget.tsx
"use client";

/**
 * Text widget - renders a text input field.
 *
 * Maps to ipywidgets TextModel.
 */

import { useCallback, useEffect, useState } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
  useWidgetModelValue,
  useWidgetStoreRequired,
} from "../widget-store-context";

export function TextWidget({ modelId, className }: WidgetComponentProps) {
  // sendUpdate and sendCustom are now stable (useCommRouter uses refs internally)
  const { sendUpdate, sendCustom } = useWidgetStoreRequired();

  // Subscribe to individual state keys
  const value = useWidgetModelValue<string>(modelId, "value") ?? "";
  const description = useWidgetModelValue<string>(modelId, "description");
  const placeholder = useWidgetModelValue<string>(modelId, "placeholder") ?? "";
  const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
  const continuousUpdate =
    useWidgetModelValue<boolean>(modelId, "continuous_update") ?? true;

  // Local state for non-continuous updates
  const [localValue, setLocalValue] = useState(value);

  // Sync local state when value changes from kernel
  useEffect(() => {
    setLocalValue(value);
  }, [value]);

  const handleChange = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      const newValue = e.target.value;
      setLocalValue(newValue);

      if (continuousUpdate) {
        sendUpdate(modelId, { value: newValue });
      }
    },
    [modelId, continuousUpdate, sendUpdate],
  );

  const handleBlur = useCallback(() => {
    if (!continuousUpdate && localValue !== value) {
      sendUpdate(modelId, { value: localValue });
    }
  }, [modelId, continuousUpdate, localValue, value, sendUpdate]);

  const handleKeyDown = useCallback(
    (e: React.KeyboardEvent<HTMLInputElement>) => {
      if (e.key === "Enter") {
        // Send submit event
        sendCustom(modelId, { event: "submit" });
        // Also ensure value is synced
        if (!continuousUpdate && localValue !== value) {
          sendUpdate(modelId, { value: localValue });
        }
      }
    },
    [modelId, continuousUpdate, localValue, value, sendUpdate, sendCustom],
  );

  return (
    <div
      className={cn("flex items-center gap-3", className)}
      data-widget-id={modelId}
      data-widget-type="Text"
    >
      {description && <Label className="shrink-0 text-sm">{description}</Label>}
      <Input
        type="text"
        value={localValue}
        placeholder={placeholder}
        disabled={disabled}
        onChange={handleChange}
        onBlur={handleBlur}
        onKeyDown={handleKeyDown}
        className="flex-1"
      />
    </div>
  );
}

export default TextWidget;
registry/widgets/controls/textarea-widget.tsx
"use client";

/**
 * Textarea widget - renders a multi-line text input.
 *
 * Maps to ipywidgets TextareaModel.
 */

import { useCallback, useEffect, useState } from "react";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
  useWidgetModelValue,
  useWidgetStoreRequired,
} from "../widget-store-context";

export function TextareaWidget({ modelId, className }: WidgetComponentProps) {
  const { sendUpdate } = useWidgetStoreRequired();

  // Subscribe to individual state keys
  const value = useWidgetModelValue<string>(modelId, "value") ?? "";
  const description = useWidgetModelValue<string>(modelId, "description");
  const placeholder = useWidgetModelValue<string>(modelId, "placeholder") ?? "";
  const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
  const rows = useWidgetModelValue<number>(modelId, "rows") ?? 4;
  const continuousUpdate =
    useWidgetModelValue<boolean>(modelId, "continuous_update") ?? true;

  // Local state for non-continuous updates
  const [localValue, setLocalValue] = useState(value);

  // Sync local state when value changes from kernel
  useEffect(() => {
    setLocalValue(value);
  }, [value]);

  const handleChange = useCallback(
    (e: React.ChangeEvent<HTMLTextAreaElement>) => {
      const newValue = e.target.value;
      setLocalValue(newValue);

      if (continuousUpdate) {
        sendUpdate(modelId, { value: newValue });
      }
    },
    [modelId, continuousUpdate, sendUpdate],
  );

  const handleBlur = useCallback(() => {
    if (!continuousUpdate && localValue !== value) {
      sendUpdate(modelId, { value: localValue });
    }
  }, [modelId, continuousUpdate, localValue, value, sendUpdate]);

  return (
    <div
      className={cn("flex flex-col gap-2", className)}
      data-widget-id={modelId}
      data-widget-type="Textarea"
    >
      {description && <Label className="text-sm">{description}</Label>}
      <Textarea
        value={localValue}
        placeholder={placeholder}
        disabled={disabled}
        rows={rows}
        onChange={handleChange}
        onBlur={handleBlur}
        className="resize-y"
      />
    </div>
  );
}

export default TextareaWidget;
registry/widgets/controls/html-widget.tsx
/**
 * HTML widget - renders arbitrary HTML content.
 *
 * Maps to ipywidgets HTMLModel.
 */

import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import { useWidgetModelValue } from "../widget-store-context";

export function HTMLWidget({ modelId, className }: WidgetComponentProps) {
  const value = useWidgetModelValue<string>(modelId, "value") ?? "";
  const description = useWidgetModelValue<string>(modelId, "description");
  const placeholder = useWidgetModelValue<string>(modelId, "placeholder");

  // Show placeholder if value is empty
  const displayValue = value || placeholder || "";

  return (
    <div
      className={cn("inline-flex shrink-0 items-baseline gap-1", className)}
      data-widget-id={modelId}
      data-widget-type="HTML"
    >
      {description && <Label className="shrink-0 text-sm">{description}</Label>}
      <div
        className="widget-html-content"
        dangerouslySetInnerHTML={{ __html: displayValue }}
      />
    </div>
  );
}

export default HTMLWidget;
registry/widgets/controls/color-picker.tsx
/**
 * ColorPicker widget - renders a color selection input.
 *
 * Maps to ipywidgets ColorPickerModel.
 */

import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
  useWidgetModelValue,
  useWidgetStoreRequired,
} from "../widget-store-context";

export function ColorPicker({ modelId, className }: WidgetComponentProps) {
  const { sendUpdate } = useWidgetStoreRequired();

  const value = useWidgetModelValue<string>(modelId, "value") ?? "#000000";
  const description = useWidgetModelValue<string>(modelId, "description");
  const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
  const concise = useWidgetModelValue<boolean>(modelId, "concise") ?? false;

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    sendUpdate(modelId, { value: e.target.value });
  };

  return (
    <div
      className={cn("flex items-center gap-3", className)}
      data-widget-id={modelId}
      data-widget-type="ColorPicker"
    >
      {description && <Label className="shrink-0 text-sm">{description}</Label>}
      <input
        type="color"
        value={value}
        disabled={disabled}
        onChange={handleChange}
        className={cn(
          "h-8 w-12 cursor-pointer rounded border border-input bg-transparent p-0.5",
          "disabled:cursor-not-allowed disabled:opacity-50",
        )}
      />
      {!concise && (
        <span className="text-sm text-muted-foreground font-mono">{value}</span>
      )}
    </div>
  );
}

export default ColorPicker;
registry/widgets/controls/dropdown-widget.tsx
/**
 * Dropdown widget - renders a native select dropdown.
 *
 * Maps to ipywidgets DropdownModel.
 *
 * Uses native HTML <select> instead of Radix UI Select because Radix
 * uses Portal which doesn't work in sandboxed iframes (no allow-same-origin).
 * See: https://github.com/runtimed/runt/issues/62
 */

import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
  useWidgetModelValue,
  useWidgetStoreRequired,
} from "../widget-store-context";

export function DropdownWidget({ modelId, className }: WidgetComponentProps) {
  const { sendUpdate } = useWidgetStoreRequired();

  // Subscribe to individual state keys
  const options =
    useWidgetModelValue<string[]>(modelId, "_options_labels") ?? [];
  const index = useWidgetModelValue<number | null>(modelId, "index");
  const description = useWidgetModelValue<string>(modelId, "description");
  const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;

  // Convert index to string value for select
  const value =
    index !== null && index !== undefined && index >= 0 ? String(index) : "";

  const handleChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
    const newIndex = parseInt(event.target.value, 10);
    if (!Number.isNaN(newIndex)) {
      sendUpdate(modelId, { index: newIndex });
    }
  };

  return (
    <div
      className={cn("flex items-center gap-3", className)}
      data-widget-id={modelId}
      data-widget-type="Dropdown"
    >
      {description && <Label className="shrink-0 text-sm">{description}</Label>}
      <select
        value={value}
        onChange={handleChange}
        disabled={disabled}
        className={cn(
          // Base styles matching shadcn SelectTrigger
          "h-9 w-48 appearance-none rounded-md border border-input px-3 py-2 text-sm",
          "bg-transparent shadow-xs transition-[color,box-shadow] outline-none",
          // Dark mode background
          "dark:bg-input/30 dark:hover:bg-input/50",
          // Focus styles
          "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
          // Disabled state
          "disabled:cursor-not-allowed disabled:opacity-50",
          // Custom dropdown arrow using background image (chevron-down)
          "bg-[length:16px_16px] bg-[right_8px_center] bg-no-repeat",
          "bg-[url('data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%239ca3af%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpath%20d%3D%22m6%209%206%206%206-6%22%2F%3E%3C%2Fsvg%3E')]",
          "pr-9", // Extra padding for the arrow
        )}
      >
        {options.map((option, idx) => (
          <option key={idx} value={String(idx)}>
            {option}
          </option>
        ))}
      </select>
    </div>
  );
}

export default DropdownWidget;
registry/widgets/controls/radio-buttons-widget.tsx
/**
 * RadioButtons widget - renders a group of radio buttons.
 *
 * Maps to ipywidgets RadioButtonsModel.
 */

import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
  useWidgetModelValue,
  useWidgetStoreRequired,
} from "../widget-store-context";

export function RadioButtonsWidget({
  modelId,
  className,
}: WidgetComponentProps) {
  const { sendUpdate } = useWidgetStoreRequired();

  // Subscribe to individual state keys
  const options =
    useWidgetModelValue<string[]>(modelId, "_options_labels") ?? [];
  const index = useWidgetModelValue<number | null>(modelId, "index");
  const description = useWidgetModelValue<string>(modelId, "description");
  const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;

  // Convert index to string value for RadioGroup
  const value =
    index !== null && index !== undefined && index >= 0
      ? String(index)
      : undefined;

  const handleValueChange = (newValue: string) => {
    const newIndex = parseInt(newValue, 10);
    if (!Number.isNaN(newIndex)) {
      sendUpdate(modelId, { index: newIndex });
    }
  };

  return (
    <div
      className={cn("flex items-start gap-3", className)}
      data-widget-id={modelId}
      data-widget-type="RadioButtons"
    >
      {description && (
        <Label className="shrink-0 text-sm pt-0.5">{description}</Label>
      )}
      <RadioGroup
        value={value}
        onValueChange={handleValueChange}
        disabled={disabled}
      >
        {options.map((option, idx) => (
          <div key={idx} className="flex items-center gap-2">
            <RadioGroupItem
              value={String(idx)}
              id={`${modelId}-radio-${idx}`}
              disabled={disabled}
            />
            <Label
              htmlFor={`${modelId}-radio-${idx}`}
              className={cn(
                "text-sm font-normal cursor-pointer",
                disabled && "opacity-50 cursor-not-allowed",
              )}
            >
              {option}
            </Label>
          </div>
        ))}
      </RadioGroup>
    </div>
  );
}

export default RadioButtonsWidget;
registry/widgets/controls/select-multiple-widget.tsx
/**
 * SelectMultiple widget - renders a multi-select listbox.
 *
 * Maps to ipywidgets SelectMultipleModel.
 */

import { CheckIcon } from "lucide-react";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
  useWidgetModelValue,
  useWidgetStoreRequired,
} from "../widget-store-context";

export function SelectMultipleWidget({
  modelId,
  className,
}: WidgetComponentProps) {
  const { sendUpdate } = useWidgetStoreRequired();

  // Subscribe to individual state keys
  const options =
    useWidgetModelValue<string[]>(modelId, "_options_labels") ?? [];
  const selectedIndices = useWidgetModelValue<number[]>(modelId, "index") ?? [];
  const description = useWidgetModelValue<string>(modelId, "description");
  const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
  const rows = useWidgetModelValue<number>(modelId, "rows") ?? 5;

  const handleToggle = (idx: number) => {
    if (disabled) return;

    const newIndices = selectedIndices.includes(idx)
      ? selectedIndices.filter((i) => i !== idx)
      : [...selectedIndices, idx].sort((a, b) => a - b);

    sendUpdate(modelId, { index: newIndices });
  };

  // Calculate height based on rows
  const itemHeight = 32; // approximate height per item in px
  const maxHeight = rows * itemHeight;

  return (
    <div
      className={cn("flex items-start gap-3", className)}
      data-widget-id={modelId}
      data-widget-type="SelectMultiple"
    >
      {description && (
        <Label className="shrink-0 text-sm pt-1">{description}</Label>
      )}
      <div
        role="listbox"
        aria-multiselectable="true"
        aria-disabled={disabled}
        className={cn(
          "w-48 overflow-y-auto rounded-md border border-input bg-background shadow-xs",
          disabled && "opacity-50 cursor-not-allowed",
        )}
        style={{ maxHeight }}
      >
        {options.map((option, idx) => {
          const isSelected = selectedIndices.includes(idx);
          return (
            <div
              key={idx}
              role="option"
              aria-selected={isSelected}
              onClick={() => handleToggle(idx)}
              className={cn(
                "flex items-center gap-2 px-3 py-1.5 text-sm cursor-pointer select-none",
                "hover:bg-accent hover:text-accent-foreground",
                isSelected && "bg-accent/50",
                disabled && "pointer-events-none",
              )}
            >
              <span className="flex size-4 items-center justify-center">
                {isSelected && <CheckIcon className="size-3" />}
              </span>
              <span>{option}</span>
            </div>
          );
        })}
      </div>
    </div>
  );
}

export default SelectMultipleWidget;
registry/widgets/controls/toggle-button-widget.tsx
/**
 * ToggleButton widget - renders a single toggle button.
 *
 * Maps to ipywidgets ToggleButtonModel.
 */

import { Toggle } from "@/components/ui/toggle";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
  useWidgetModelValue,
  useWidgetStoreRequired,
} from "../widget-store-context";

export function ToggleButtonWidget({
  modelId,
  className,
}: WidgetComponentProps) {
  const { sendUpdate } = useWidgetStoreRequired();

  // Subscribe to individual state keys
  const value = useWidgetModelValue<boolean>(modelId, "value") ?? false;
  const description = useWidgetModelValue<string>(modelId, "description") ?? "";
  const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
  const icon = useWidgetModelValue<string>(modelId, "icon");
  const buttonStyle =
    useWidgetModelValue<string>(modelId, "button_style") ?? "";

  const handlePressedChange = (pressed: boolean) => {
    sendUpdate(modelId, { value: pressed });
  };

  // Map button_style to variant - outline for styled buttons
  const variant = buttonStyle ? "outline" : "default";

  return (
    <Toggle
      pressed={value}
      onPressedChange={handlePressedChange}
      disabled={disabled}
      variant={variant}
      className={cn(className)}
      data-widget-id={modelId}
      data-widget-type="ToggleButton"
    >
      {icon && <span className="mr-1">{icon}</span>}
      {description || "Toggle"}
    </Toggle>
  );
}

export default ToggleButtonWidget;
registry/widgets/controls/toggle-buttons-widget.tsx
/**
 * ToggleButtons widget - renders a group of toggle buttons (single selection).
 *
 * Maps to ipywidgets ToggleButtonsModel.
 */

import { Label } from "@/components/ui/label";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
  useWidgetModelValue,
  useWidgetStoreRequired,
} from "../widget-store-context";

export function ToggleButtonsWidget({
  modelId,
  className,
}: WidgetComponentProps) {
  const { sendUpdate } = useWidgetStoreRequired();

  // Subscribe to individual state keys
  const options =
    useWidgetModelValue<string[]>(modelId, "_options_labels") ?? [];
  const index = useWidgetModelValue<number | null>(modelId, "index");
  const description = useWidgetModelValue<string>(modelId, "description");
  const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
  const icons = useWidgetModelValue<string[]>(modelId, "icons") ?? [];
  const tooltips = useWidgetModelValue<string[]>(modelId, "tooltips") ?? [];

  // Convert index to string value for ToggleGroup
  const value =
    index !== null && index !== undefined && index >= 0
      ? String(index)
      : undefined;

  const handleValueChange = (newValue: string) => {
    if (newValue === "") {
      // Deselection - ToggleButtons typically require a selection
      return;
    }
    const newIndex = parseInt(newValue, 10);
    if (!Number.isNaN(newIndex)) {
      sendUpdate(modelId, { index: newIndex });
    }
  };

  return (
    <div
      className={cn("flex items-center gap-3", className)}
      data-widget-id={modelId}
      data-widget-type="ToggleButtons"
    >
      {description && <Label className="shrink-0 text-sm">{description}</Label>}
      <ToggleGroup
        type="single"
        value={value}
        onValueChange={handleValueChange}
        disabled={disabled}
        variant="outline"
      >
        {options.map((option, idx) => (
          <ToggleGroupItem
            key={idx}
            value={String(idx)}
            title={tooltips[idx] || undefined}
            disabled={disabled}
          >
            {icons[idx] && <span className="mr-1">{icons[idx]}</span>}
            {option}
          </ToggleGroupItem>
        ))}
      </ToggleGroup>
    </div>
  );
}

export default ToggleButtonsWidget;
registry/widgets/controls/vbox-widget.tsx
/**
 * VBox widget - vertical flex container.
 *
 * Maps to ipywidgets VBoxModel. Arranges children in a vertical column.
 */

import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import { parseModelRef, useWidgetModelValue } from "../widget-store-context";
import { WidgetView } from "../widget-view";

// Map ipywidgets box_style to Tailwind classes
const BOX_STYLE_MAP: Record<string, string> = {
  "": "",
  primary:
    "border border-blue-500 bg-blue-50/50 dark:bg-blue-950/50 rounded-md p-2",
  success:
    "border border-green-500 bg-green-50/50 dark:bg-green-950/50 rounded-md p-2",
  info: "border border-sky-500 bg-sky-50/50 dark:bg-sky-950/50 rounded-md p-2",
  warning:
    "border border-yellow-500 bg-yellow-50/50 dark:bg-yellow-950/50 rounded-md p-2",
  danger:
    "border border-red-500 bg-red-50/50 dark:bg-red-950/50 rounded-md p-2",
};

export function VBoxWidget({ modelId, className }: WidgetComponentProps) {
  // Subscribe to individual state keys
  const children = useWidgetModelValue<string[]>(modelId, "children");
  const boxStyle = useWidgetModelValue<string>(modelId, "box_style") ?? "";

  const styleClass = BOX_STYLE_MAP[boxStyle] ?? "";

  return (
    <div
      className={cn("flex flex-col gap-2", styleClass, className)}
      data-widget-id={modelId}
      data-widget-type="VBox"
    >
      {children?.map((childRef) => {
        const childId = parseModelRef(childRef);
        return childId ? <WidgetView key={childId} modelId={childId} /> : null;
      })}
    </div>
  );
}

export default VBoxWidget;
registry/widgets/controls/hbox-widget.tsx
/**
 * HBox widget - horizontal flex container.
 *
 * Maps to ipywidgets HBoxModel. Arranges children in a horizontal row.
 */

import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import { parseModelRef, useWidgetModelValue } from "../widget-store-context";
import { WidgetView } from "../widget-view";

// Map ipywidgets box_style to Tailwind classes
const BOX_STYLE_MAP: Record<string, string> = {
  "": "",
  primary:
    "border border-blue-500 bg-blue-50/50 dark:bg-blue-950/50 rounded-md p-2",
  success:
    "border border-green-500 bg-green-50/50 dark:bg-green-950/50 rounded-md p-2",
  info: "border border-sky-500 bg-sky-50/50 dark:bg-sky-950/50 rounded-md p-2",
  warning:
    "border border-yellow-500 bg-yellow-50/50 dark:bg-yellow-950/50 rounded-md p-2",
  danger:
    "border border-red-500 bg-red-50/50 dark:bg-red-950/50 rounded-md p-2",
};

export function HBoxWidget({ modelId, className }: WidgetComponentProps) {
  // Subscribe to individual state keys
  const children = useWidgetModelValue<string[]>(modelId, "children");
  const boxStyle = useWidgetModelValue<string>(modelId, "box_style") ?? "";

  const styleClass = BOX_STYLE_MAP[boxStyle] ?? "";

  return (
    <div
      className={cn(
        "flex flex-row flex-wrap items-baseline gap-1",
        styleClass,
        className,
      )}
      data-widget-id={modelId}
      data-widget-type="HBox"
    >
      {children?.map((childRef) => {
        const childId = parseModelRef(childRef);
        return childId ? <WidgetView key={childId} modelId={childId} /> : null;
      })}
    </div>
  );
}

export default HBoxWidget;
registry/widgets/controls/box-widget.tsx
/**
 * Box widget - generic flex container.
 *
 * Maps to ipywidgets BoxModel. Base container for layout widgets.
 * Defaults to vertical stacking (like VBox).
 */

import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import { parseModelRef, useWidgetModelValue } from "../widget-store-context";
import { WidgetView } from "../widget-view";

// Map ipywidgets box_style to Tailwind classes
const BOX_STYLE_MAP: Record<string, string> = {
  "": "",
  primary:
    "border border-blue-500 bg-blue-50/50 dark:bg-blue-950/50 rounded-md p-2",
  success:
    "border border-green-500 bg-green-50/50 dark:bg-green-950/50 rounded-md p-2",
  info: "border border-sky-500 bg-sky-50/50 dark:bg-sky-950/50 rounded-md p-2",
  warning:
    "border border-yellow-500 bg-yellow-50/50 dark:bg-yellow-950/50 rounded-md p-2",
  danger:
    "border border-red-500 bg-red-50/50 dark:bg-red-950/50 rounded-md p-2",
};

export function BoxWidget({ modelId, className }: WidgetComponentProps) {
  // Subscribe to individual state keys
  const children = useWidgetModelValue<string[]>(modelId, "children");
  const boxStyle = useWidgetModelValue<string>(modelId, "box_style") ?? "";

  const styleClass = BOX_STYLE_MAP[boxStyle] ?? "";

  return (
    <div
      className={cn("flex flex-col gap-2", styleClass, className)}
      data-widget-id={modelId}
      data-widget-type="Box"
    >
      {children?.map((childRef) => {
        const childId = parseModelRef(childRef);
        return childId ? <WidgetView key={childId} modelId={childId} /> : null;
      })}
    </div>
  );
}

export default BoxWidget;
registry/widgets/controls/gridbox-widget.tsx
/**
 * GridBox widget - CSS grid container.
 *
 * Maps to ipywidgets GridBoxModel. Arranges children in a CSS grid layout.
 * Default is a responsive 2-column grid.
 */

import { cn } from "@/lib/utils";
import { useLayoutStyles } from "../use-layout-styles";
import type { WidgetComponentProps } from "../widget-registry";
import { parseModelRef, useWidgetModelValue } from "../widget-store-context";
import { WidgetView } from "../widget-view";

// Map ipywidgets box_style to Tailwind classes
const BOX_STYLE_MAP: Record<string, string> = {
  "": "",
  primary:
    "border border-blue-500 bg-blue-50/50 dark:bg-blue-950/50 rounded-md p-2",
  success:
    "border border-green-500 bg-green-50/50 dark:bg-green-950/50 rounded-md p-2",
  info: "border border-sky-500 bg-sky-50/50 dark:bg-sky-950/50 rounded-md p-2",
  warning:
    "border border-yellow-500 bg-yellow-50/50 dark:bg-yellow-950/50 rounded-md p-2",
  danger:
    "border border-red-500 bg-red-50/50 dark:bg-red-950/50 rounded-md p-2",
};

export function GridBoxWidget({ modelId, className }: WidgetComponentProps) {
  // Subscribe to individual state keys
  const children = useWidgetModelValue<string[]>(modelId, "children");
  const boxStyle = useWidgetModelValue<string>(modelId, "box_style") ?? "";

  // Get layout styles from the Layout model
  const { containerStyle, hasGridLayout } = useLayoutStyles(modelId);

  const styleClass = BOX_STYLE_MAP[boxStyle] ?? "";

  return (
    <div
      className={cn(
        "grid",
        // Only apply default Tailwind grid classes if no layout grid is specified
        !hasGridLayout && "grid-cols-1 sm:grid-cols-2 gap-2",
        styleClass,
        className,
      )}
      style={containerStyle}
      data-widget-id={modelId}
      data-widget-type="GridBox"
    >
      {children?.map((childRef) => {
        const childId = parseModelRef(childRef);
        return childId ? <WidgetView key={childId} modelId={childId} /> : null;
      })}
    </div>
  );
}

export default GridBoxWidget;
registry/widgets/controls/accordion-widget.tsx
/**
 * Accordion widget - collapsible panel container.
 *
 * Maps to ipywidgets AccordionModel. Displays children as collapsible panels.
 */

import {
  Accordion,
  AccordionContent,
  AccordionItem,
  AccordionTrigger,
} from "@/components/ui/accordion";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
  parseModelRef,
  useWidgetModelValue,
  useWidgetStoreRequired,
} from "../widget-store-context";
import { WidgetView } from "../widget-view";

export function AccordionWidget({ modelId, className }: WidgetComponentProps) {
  const { sendUpdate } = useWidgetStoreRequired();

  // Subscribe to individual state keys
  const children = useWidgetModelValue<string[]>(modelId, "children");
  const titles = useWidgetModelValue<string[]>(modelId, "_titles") ?? [];
  const selectedIndex = useWidgetModelValue<number | null>(
    modelId,
    "selected_index",
  );

  const handleValueChange = (value: string) => {
    // Convert string value back to index (or null if collapsed)
    const index = value !== "" ? parseInt(value, 10) : null;
    sendUpdate(modelId, { selected_index: index });
  };

  return (
    <Accordion
      type="single"
      collapsible
      value={selectedIndex !== null ? String(selectedIndex) : ""}
      onValueChange={handleValueChange}
      className={cn("w-full", className)}
      data-widget-id={modelId}
      data-widget-type="Accordion"
    >
      {children?.map((childRef, index) => {
        const childId = parseModelRef(childRef);
        if (!childId) return null;

        return (
          <AccordionItem key={childId} value={String(index)}>
            <AccordionTrigger>
              {titles[index] ?? `Panel ${index + 1}`}
            </AccordionTrigger>
            <AccordionContent>
              <WidgetView modelId={childId} />
            </AccordionContent>
          </AccordionItem>
        );
      })}
    </Accordion>
  );
}

export default AccordionWidget;
registry/widgets/controls/tab-widget.tsx
/**
 * Tab widget - tabbed panel container.
 *
 * Maps to ipywidgets TabModel. Displays children as tabbed panels.
 */

import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
  parseModelRef,
  useWidgetModelValue,
  useWidgetStoreRequired,
} from "../widget-store-context";
import { WidgetView } from "../widget-view";

export function TabWidget({ modelId, className }: WidgetComponentProps) {
  const { sendUpdate } = useWidgetStoreRequired();

  // Subscribe to individual state keys
  const children = useWidgetModelValue<string[]>(modelId, "children");
  const titles = useWidgetModelValue<string[]>(modelId, "_titles") ?? [];
  const selectedIndex =
    useWidgetModelValue<number>(modelId, "selected_index") ?? 0;

  const handleValueChange = (value: string) => {
    sendUpdate(modelId, { selected_index: parseInt(value, 10) });
  };

  return (
    <Tabs
      value={String(selectedIndex)}
      onValueChange={handleValueChange}
      className={cn("w-full", className)}
      data-widget-id={modelId}
      data-widget-type="Tab"
    >
      <TabsList>
        {children?.map((_, index) => (
          <TabsTrigger key={index} value={String(index)}>
            {titles[index] ?? `Tab ${index + 1}`}
          </TabsTrigger>
        ))}
      </TabsList>
      {children?.map((childRef, index) => {
        const childId = parseModelRef(childRef);
        if (!childId) return null;

        return (
          <TabsContent key={childId} value={String(index)}>
            <WidgetView modelId={childId} />
          </TabsContent>
        );
      })}
    </Tabs>
  );
}

export default TabWidget;
registry/widgets/controls/label-widget.tsx
/**
 * Label widget - renders plain text content.
 *
 * Maps to ipywidgets LabelModel.
 */

import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import { useWidgetModelValue } from "../widget-store-context";

export function LabelWidget({ modelId, className }: WidgetComponentProps) {
  const value = useWidgetModelValue<string>(modelId, "value") ?? "";
  const description = useWidgetModelValue<string>(modelId, "description");
  const placeholder = useWidgetModelValue<string>(modelId, "placeholder");

  // Show placeholder if value is empty
  const displayValue = value || placeholder || "";

  return (
    <div
      className={cn("inline-flex shrink-0 items-baseline gap-1", className)}
      data-widget-id={modelId}
      data-widget-type="Label"
    >
      {description && <Label className="shrink-0 text-sm">{description}</Label>}
      <span className="widget-label-content">{displayValue}</span>
    </div>
  );
}

export default LabelWidget;
registry/widgets/controls/link-widget.tsx
/**
 * Link widgets — frontend-only property synchronization.
 *
 * LinkModel: bidirectional sync (widgets.jslink)
 * DirectionalLinkModel: one-way sync (widgets.jsdlink)
 *
 * These are headless widgets that render no visible UI. Property
 * synchronization is handled at the store level by createLinkManager
 * (see link-subscriptions.ts), which runs automatically in the
 * WidgetStoreProvider.
 *
 * These components exist in the widget registry so that WidgetView
 * renders null instead of showing "Unsupported widget".
 */

import type { WidgetComponentProps } from "../widget-registry";

/**
 * DirectionalLinkWidget — one-way property synchronization (source → target).
 * Renders nothing. Sync logic runs at the store level.
 */
export function DirectionalLinkWidget(_props: WidgetComponentProps) {
  return null;
}

/**
 * LinkWidget — bidirectional property synchronization (source ↔ target).
 * Renders nothing. Sync logic runs at the store level.
 */
export function LinkWidget(_props: WidgetComponentProps) {
  return null;
}
registry/widgets/controls/image-widget.tsx
/**
 * Image widget - displays images from the kernel.
 *
 * Maps to ipywidgets ImageModel.
 */

import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { buildMediaSrc } from "../buffer-utils";
import type { WidgetComponentProps } from "../widget-registry";
import { useWidgetModelValue } from "../widget-store-context";

export function ImageWidget({ modelId, className }: WidgetComponentProps) {
  const value = useWidgetModelValue<string | ArrayBuffer>(modelId, "value");
  const format = useWidgetModelValue<string>(modelId, "format") ?? "png";
  const width = useWidgetModelValue<string>(modelId, "width") ?? "";
  const height = useWidgetModelValue<string>(modelId, "height") ?? "";
  const description = useWidgetModelValue<string>(modelId, "description");

  const src = buildMediaSrc(value, "image", format);

  if (!src) {
    return null;
  }

  // Build style object for width/height
  const style: React.CSSProperties = {};
  if (width) style.width = width;
  if (height) style.height = height;

  return (
    <div
      className={cn("inline-flex items-start gap-3", className)}
      data-widget-id={modelId}
      data-widget-type="Image"
    >
      {description && (
        <Label className="shrink-0 pt-1 text-sm">{description}</Label>
      )}
      <img
        src={src}
        alt={description || "Widget image"}
        className="block max-w-full h-auto"
        style={{ objectFit: "contain", ...style }}
      />
    </div>
  );
}

export default ImageWidget;
registry/widgets/controls/int-text-widget.tsx
"use client";

/**
 * IntText widget - renders a numeric text input for integers.
 *
 * Maps to ipywidgets IntTextModel.
 */

import { useCallback, useEffect, useState } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
  useWidgetModelValue,
  useWidgetStoreRequired,
} from "../widget-store-context";

export function IntTextWidget({ modelId, className }: WidgetComponentProps) {
  const { sendUpdate } = useWidgetStoreRequired();

  // Subscribe to individual state keys
  const value = useWidgetModelValue<number>(modelId, "value") ?? 0;
  const min = useWidgetModelValue<number>(modelId, "min");
  const max = useWidgetModelValue<number>(modelId, "max");
  const step = useWidgetModelValue<number>(modelId, "step") ?? 1;
  const description = useWidgetModelValue<string>(modelId, "description");
  const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
  const continuousUpdate =
    useWidgetModelValue<boolean>(modelId, "continuous_update") ?? false;

  // Local state for the input value (as string for editing)
  const [localValue, setLocalValue] = useState(String(value));

  // Sync local state when value changes from kernel
  useEffect(() => {
    setLocalValue(String(value));
  }, [value]);

  const clampValue = useCallback(
    (val: number): number => {
      let clamped = val;
      if (min !== undefined && min !== null) {
        clamped = Math.max(min, clamped);
      }
      if (max !== undefined && max !== null) {
        clamped = Math.min(max, clamped);
      }
      return clamped;
    },
    [min, max],
  );

  const handleChange = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      const newValue = e.target.value;
      setLocalValue(newValue);

      if (continuousUpdate) {
        const parsed = parseInt(newValue, 10);
        if (!Number.isNaN(parsed)) {
          const clamped = clampValue(parsed);
          sendUpdate(modelId, { value: clamped });
        }
      }
    },
    [modelId, continuousUpdate, clampValue, sendUpdate],
  );

  const handleBlur = useCallback(() => {
    const parsed = parseInt(localValue, 10);
    if (!Number.isNaN(parsed)) {
      const clamped = clampValue(parsed);
      setLocalValue(String(clamped));
      if (clamped !== value) {
        sendUpdate(modelId, { value: clamped });
      }
    } else {
      // Reset to current value if invalid
      setLocalValue(String(value));
    }
  }, [modelId, localValue, value, clampValue, sendUpdate]);

  const handleKeyDown = useCallback(
    (e: React.KeyboardEvent<HTMLInputElement>) => {
      if (e.key === "Enter") {
        const parsed = parseInt(localValue, 10);
        if (!Number.isNaN(parsed)) {
          const clamped = clampValue(parsed);
          setLocalValue(String(clamped));
          sendUpdate(modelId, { value: clamped });
        }
      }
    },
    [modelId, localValue, clampValue, sendUpdate],
  );

  return (
    <div
      className={cn("flex items-center gap-3", className)}
      data-widget-id={modelId}
      data-widget-type="IntText"
    >
      {description && <Label className="shrink-0 text-sm">{description}</Label>}
      <Input
        type="number"
        value={localValue}
        disabled={disabled}
        step={step}
        min={min}
        max={max}
        onChange={handleChange}
        onBlur={handleBlur}
        onKeyDown={handleKeyDown}
        className="w-24"
      />
    </div>
  );
}

export default IntTextWidget;
registry/widgets/controls/float-text-widget.tsx
"use client";

/**
 * FloatText widget - renders a numeric text input for floats.
 *
 * Maps to ipywidgets FloatTextModel.
 */

import { useCallback, useEffect, useState } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
  useWidgetModelValue,
  useWidgetStoreRequired,
} from "../widget-store-context";

export function FloatTextWidget({ modelId, className }: WidgetComponentProps) {
  const { sendUpdate } = useWidgetStoreRequired();

  // Subscribe to individual state keys
  const value = useWidgetModelValue<number>(modelId, "value") ?? 0;
  const min = useWidgetModelValue<number>(modelId, "min");
  const max = useWidgetModelValue<number>(modelId, "max");
  const step = useWidgetModelValue<number>(modelId, "step") ?? 0.1;
  const description = useWidgetModelValue<string>(modelId, "description");
  const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
  const continuousUpdate =
    useWidgetModelValue<boolean>(modelId, "continuous_update") ?? false;

  // Local state for the input value (as string for editing)
  const [localValue, setLocalValue] = useState(String(value));

  // Sync local state when value changes from kernel
  useEffect(() => {
    setLocalValue(String(value));
  }, [value]);

  const clampValue = useCallback(
    (val: number): number => {
      let clamped = val;
      if (min !== undefined && min !== null) {
        clamped = Math.max(min, clamped);
      }
      if (max !== undefined && max !== null) {
        clamped = Math.min(max, clamped);
      }
      return clamped;
    },
    [min, max],
  );

  const handleChange = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      const newValue = e.target.value;
      setLocalValue(newValue);

      if (continuousUpdate) {
        const parsed = parseFloat(newValue);
        if (!Number.isNaN(parsed)) {
          const clamped = clampValue(parsed);
          sendUpdate(modelId, { value: clamped });
        }
      }
    },
    [modelId, continuousUpdate, clampValue, sendUpdate],
  );

  const handleBlur = useCallback(() => {
    const parsed = parseFloat(localValue);
    if (!Number.isNaN(parsed)) {
      const clamped = clampValue(parsed);
      setLocalValue(String(clamped));
      if (clamped !== value) {
        sendUpdate(modelId, { value: clamped });
      }
    } else {
      // Reset to current value if invalid
      setLocalValue(String(value));
    }
  }, [modelId, localValue, value, clampValue, sendUpdate]);

  const handleKeyDown = useCallback(
    (e: React.KeyboardEvent<HTMLInputElement>) => {
      if (e.key === "Enter") {
        const parsed = parseFloat(localValue);
        if (!Number.isNaN(parsed)) {
          const clamped = clampValue(parsed);
          setLocalValue(String(clamped));
          sendUpdate(modelId, { value: clamped });
        }
      }
    },
    [modelId, localValue, clampValue, sendUpdate],
  );

  return (
    <div
      className={cn("flex items-center gap-3", className)}
      data-widget-id={modelId}
      data-widget-type="FloatText"
    >
      {description && <Label className="shrink-0 text-sm">{description}</Label>}
      <Input
        type="number"
        value={localValue}
        disabled={disabled}
        step={step}
        min={min}
        max={max}
        onChange={handleChange}
        onBlur={handleBlur}
        onKeyDown={handleKeyDown}
        className="w-24"
      />
    </div>
  );
}

export default FloatTextWidget;
registry/widgets/controls/bounded-int-text-widget.tsx
"use client";

/**
 * BoundedIntText widget - renders a bounded numeric text input for integers.
 *
 * Maps to ipywidgets BoundedIntTextModel.
 * This is functionally the same as IntText since both support min/max bounds.
 */

import { useCallback, useEffect, useState } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
  useWidgetModelValue,
  useWidgetStoreRequired,
} from "../widget-store-context";

export function BoundedIntTextWidget({
  modelId,
  className,
}: WidgetComponentProps) {
  const { sendUpdate } = useWidgetStoreRequired();

  // Subscribe to individual state keys
  const value = useWidgetModelValue<number>(modelId, "value") ?? 0;
  const min = useWidgetModelValue<number>(modelId, "min") ?? 0;
  const max = useWidgetModelValue<number>(modelId, "max") ?? 100;
  const step = useWidgetModelValue<number>(modelId, "step") ?? 1;
  const description = useWidgetModelValue<string>(modelId, "description");
  const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
  const continuousUpdate =
    useWidgetModelValue<boolean>(modelId, "continuous_update") ?? false;

  // Local state for the input value (as string for editing)
  const [localValue, setLocalValue] = useState(String(value));

  // Sync local state when value changes from kernel
  useEffect(() => {
    setLocalValue(String(value));
  }, [value]);

  const clampValue = useCallback(
    (val: number): number => {
      return Math.max(min, Math.min(max, val));
    },
    [min, max],
  );

  const handleChange = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      const newValue = e.target.value;
      setLocalValue(newValue);

      if (continuousUpdate) {
        const parsed = parseInt(newValue, 10);
        if (!Number.isNaN(parsed)) {
          const clamped = clampValue(parsed);
          sendUpdate(modelId, { value: clamped });
        }
      }
    },
    [modelId, continuousUpdate, clampValue, sendUpdate],
  );

  const handleBlur = useCallback(() => {
    const parsed = parseInt(localValue, 10);
    if (!Number.isNaN(parsed)) {
      const clamped = clampValue(parsed);
      setLocalValue(String(clamped));
      if (clamped !== value) {
        sendUpdate(modelId, { value: clamped });
      }
    } else {
      // Reset to current value if invalid
      setLocalValue(String(value));
    }
  }, [modelId, localValue, value, clampValue, sendUpdate]);

  const handleKeyDown = useCallback(
    (e: React.KeyboardEvent<HTMLInputElement>) => {
      if (e.key === "Enter") {
        const parsed = parseInt(localValue, 10);
        if (!Number.isNaN(parsed)) {
          const clamped = clampValue(parsed);
          setLocalValue(String(clamped));
          sendUpdate(modelId, { value: clamped });
        }
      }
    },
    [modelId, localValue, clampValue, sendUpdate],
  );

  return (
    <div
      className={cn("flex items-center gap-3", className)}
      data-widget-id={modelId}
      data-widget-type="BoundedIntText"
    >
      {description && <Label className="shrink-0 text-sm">{description}</Label>}
      <Input
        type="number"
        value={localValue}
        disabled={disabled}
        step={step}
        min={min}
        max={max}
        onChange={handleChange}
        onBlur={handleBlur}
        onKeyDown={handleKeyDown}
        className="w-24"
      />
    </div>
  );
}

export default BoundedIntTextWidget;
registry/widgets/controls/bounded-float-text-widget.tsx
"use client";

/**
 * BoundedFloatText widget - renders a bounded numeric text input for floats.
 *
 * Maps to ipywidgets BoundedFloatTextModel.
 * This is functionally the same as FloatText since both support min/max bounds.
 */

import { useCallback, useEffect, useState } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
  useWidgetModelValue,
  useWidgetStoreRequired,
} from "../widget-store-context";

export function BoundedFloatTextWidget({
  modelId,
  className,
}: WidgetComponentProps) {
  const { sendUpdate } = useWidgetStoreRequired();

  // Subscribe to individual state keys
  const value = useWidgetModelValue<number>(modelId, "value") ?? 0;
  const min = useWidgetModelValue<number>(modelId, "min") ?? 0;
  const max = useWidgetModelValue<number>(modelId, "max") ?? 100;
  const step = useWidgetModelValue<number>(modelId, "step") ?? 0.1;
  const description = useWidgetModelValue<string>(modelId, "description");
  const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
  const continuousUpdate =
    useWidgetModelValue<boolean>(modelId, "continuous_update") ?? false;

  // Local state for the input value (as string for editing)
  const [localValue, setLocalValue] = useState(String(value));

  // Sync local state when value changes from kernel
  useEffect(() => {
    setLocalValue(String(value));
  }, [value]);

  const clampValue = useCallback(
    (val: number): number => {
      return Math.max(min, Math.min(max, val));
    },
    [min, max],
  );

  const handleChange = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      const newValue = e.target.value;
      setLocalValue(newValue);

      if (continuousUpdate) {
        const parsed = parseFloat(newValue);
        if (!Number.isNaN(parsed)) {
          const clamped = clampValue(parsed);
          sendUpdate(modelId, { value: clamped });
        }
      }
    },
    [modelId, continuousUpdate, clampValue, sendUpdate],
  );

  const handleBlur = useCallback(() => {
    const parsed = parseFloat(localValue);
    if (!Number.isNaN(parsed)) {
      const clamped = clampValue(parsed);
      setLocalValue(String(clamped));
      if (clamped !== value) {
        sendUpdate(modelId, { value: clamped });
      }
    } else {
      // Reset to current value if invalid
      setLocalValue(String(value));
    }
  }, [modelId, localValue, value, clampValue, sendUpdate]);

  const handleKeyDown = useCallback(
    (e: React.KeyboardEvent<HTMLInputElement>) => {
      if (e.key === "Enter") {
        const parsed = parseFloat(localValue);
        if (!Number.isNaN(parsed)) {
          const clamped = clampValue(parsed);
          setLocalValue(String(clamped));
          sendUpdate(modelId, { value: clamped });
        }
      }
    },
    [modelId, localValue, clampValue, sendUpdate],
  );

  return (
    <div
      className={cn("flex items-center gap-3", className)}
      data-widget-id={modelId}
      data-widget-type="BoundedFloatText"
    >
      {description && <Label className="shrink-0 text-sm">{description}</Label>}
      <Input
        type="number"
        value={localValue}
        disabled={disabled}
        step={step}
        min={min}
        max={max}
        onChange={handleChange}
        onBlur={handleBlur}
        onKeyDown={handleKeyDown}
        className="w-24"
      />
    </div>
  );
}

export default BoundedFloatTextWidget;
registry/widgets/controls/password-widget.tsx
"use client";

/**
 * Password widget - renders a masked text input field.
 *
 * Maps to ipywidgets PasswordModel.
 */

import { useCallback, useEffect, useState } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
  useWidgetModelValue,
  useWidgetStoreRequired,
} from "../widget-store-context";

export function PasswordWidget({ modelId, className }: WidgetComponentProps) {
  const { sendUpdate, sendCustom } = useWidgetStoreRequired();

  // Subscribe to individual state keys
  const value = useWidgetModelValue<string>(modelId, "value") ?? "";
  const description = useWidgetModelValue<string>(modelId, "description");
  const placeholder = useWidgetModelValue<string>(modelId, "placeholder") ?? "";
  const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
  const continuousUpdate =
    useWidgetModelValue<boolean>(modelId, "continuous_update") ?? true;

  // Local state for non-continuous updates
  const [localValue, setLocalValue] = useState(value);

  // Sync local state when value changes from kernel
  useEffect(() => {
    setLocalValue(value);
  }, [value]);

  const handleChange = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      const newValue = e.target.value;
      setLocalValue(newValue);

      if (continuousUpdate) {
        sendUpdate(modelId, { value: newValue });
      }
    },
    [modelId, continuousUpdate, sendUpdate],
  );

  const handleBlur = useCallback(() => {
    if (!continuousUpdate && localValue !== value) {
      sendUpdate(modelId, { value: localValue });
    }
  }, [modelId, continuousUpdate, localValue, value, sendUpdate]);

  const handleKeyDown = useCallback(
    (e: React.KeyboardEvent<HTMLInputElement>) => {
      if (e.key === "Enter") {
        // Send submit event
        sendCustom(modelId, { event: "submit" });
        // Also ensure value is synced
        if (!continuousUpdate && localValue !== value) {
          sendUpdate(modelId, { value: localValue });
        }
      }
    },
    [modelId, continuousUpdate, localValue, value, sendUpdate, sendCustom],
  );

  return (
    <div
      className={cn("flex items-center gap-3", className)}
      data-widget-id={modelId}
      data-widget-type="Password"
    >
      {description && <Label className="shrink-0 text-sm">{description}</Label>}
      <Input
        type="password"
        value={localValue}
        placeholder={placeholder}
        disabled={disabled}
        onChange={handleChange}
        onBlur={handleBlur}
        onKeyDown={handleKeyDown}
        className="flex-1"
      />
    </div>
  );
}

export default PasswordWidget;
registry/widgets/controls/valid-widget.tsx
/**
 * Valid widget - displays a validation indicator (checkmark or X).
 *
 * Maps to ipywidgets ValidModel.
 */

import { CheckIcon, XIcon } from "lucide-react";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import { useWidgetModelValue } from "../widget-store-context";

export function ValidWidget({ modelId, className }: WidgetComponentProps) {
  const value = useWidgetModelValue<boolean>(modelId, "value") ?? false;
  const readout = useWidgetModelValue<string>(modelId, "readout") ?? "";
  const description = useWidgetModelValue<string>(modelId, "description");

  return (
    <div
      className={cn("inline-flex items-center gap-2", className)}
      data-widget-id={modelId}
      data-widget-type="Valid"
    >
      {description && <Label className="shrink-0 text-sm">{description}</Label>}
      {value ? (
        <CheckIcon className="size-4 text-green-500" />
      ) : (
        <XIcon className="size-4 text-red-500" />
      )}
      {!value && readout && (
        <span className="text-sm text-muted-foreground">{readout}</span>
      )}
    </div>
  );
}

export default ValidWidget;
registry/widgets/controls/select-widget.tsx
/**
 * Select widget - renders a single-selection listbox.
 *
 * Maps to ipywidgets SelectModel.
 */

import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
  useWidgetModelValue,
  useWidgetStoreRequired,
} from "../widget-store-context";

export function SelectWidget({ modelId, className }: WidgetComponentProps) {
  const { sendUpdate } = useWidgetStoreRequired();

  // Subscribe to individual state keys
  const options =
    useWidgetModelValue<string[]>(modelId, "_options_labels") ?? [];
  const selectedIndex = useWidgetModelValue<number | null>(modelId, "index");
  const description = useWidgetModelValue<string>(modelId, "description");
  const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
  const rows = useWidgetModelValue<number>(modelId, "rows") ?? 5;

  const handleSelect = (idx: number) => {
    if (disabled) return;
    sendUpdate(modelId, { index: idx });
  };

  // Calculate height based on rows
  const itemHeight = 32; // approximate height per item in px
  const maxHeight = rows * itemHeight;

  return (
    <div
      className={cn("flex items-start gap-3", className)}
      data-widget-id={modelId}
      data-widget-type="Select"
    >
      {description && (
        <Label className="shrink-0 pt-1 text-sm">{description}</Label>
      )}
      <div
        role="listbox"
        aria-disabled={disabled}
        className={cn(
          "w-48 overflow-y-auto rounded-md border border-input bg-background shadow-xs",
          disabled && "cursor-not-allowed opacity-50",
        )}
        style={{ maxHeight }}
      >
        {options.map((option, idx) => {
          const isSelected = selectedIndex === idx;
          return (
            <div
              key={idx}
              role="option"
              aria-selected={isSelected}
              onClick={() => handleSelect(idx)}
              className={cn(
                "flex cursor-pointer select-none items-center px-3 py-1.5 text-sm",
                "hover:bg-accent hover:text-accent-foreground",
                isSelected && "bg-accent text-accent-foreground",
                disabled && "pointer-events-none",
              )}
            >
              <span>{option}</span>
            </div>
          );
        })}
      </div>
    </div>
  );
}

export default SelectWidget;
registry/widgets/controls/html-math-widget.tsx
/**
 * HTMLMath widget - renders HTML content with LaTeX math support.
 *
 * Maps to ipywidgets HTMLMathModel.
 * Uses KaTeX to render math expressions in $...$ and $$...$$ delimiters.
 */

import "katex/dist/katex.min.css";

import katex from "katex";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import { useWidgetModelValue } from "../widget-store-context";

// Process math in content using KaTeX
function processMath(content: string): string {
  // Process display math ($$...$$)
  let processed = content.replace(/\$\$([\s\S]*?)\$\$/g, (_, math) => {
    try {
      return katex.renderToString(math.trim(), {
        displayMode: true,
        throwOnError: false,
      });
    } catch {
      return `$$${math}$$`;
    }
  });

  // Process inline math ($...$)
  processed = processed.replace(/\$([^$\n]+?)\$/g, (_, math) => {
    try {
      return katex.renderToString(math.trim(), {
        displayMode: false,
        throwOnError: false,
      });
    } catch {
      return `$${math}$`;
    }
  });

  return processed;
}

export function HTMLMathWidget({ modelId, className }: WidgetComponentProps) {
  const value = useWidgetModelValue<string>(modelId, "value") ?? "";
  const description = useWidgetModelValue<string>(modelId, "description");
  const placeholder = useWidgetModelValue<string>(modelId, "placeholder");

  // Show placeholder if value is empty
  const displayValue = value || placeholder || "";

  // Process content for math rendering
  const processedContent = displayValue ? processMath(displayValue) : "";

  return (
    <div
      className={cn("inline-flex shrink-0 items-baseline gap-1", className)}
      data-widget-id={modelId}
      data-widget-type="HTMLMath"
    >
      {description && <Label className="shrink-0 text-sm">{description}</Label>}
      <div
        className="widget-html-math-content"
        dangerouslySetInnerHTML={{ __html: processedContent }}
      />
    </div>
  );
}

export default HTMLMathWidget;
registry/widgets/controls/date-picker-widget.tsx
"use client";

/**
 * DatePicker widget - renders a date input field.
 *
 * Maps to ipywidgets DatePickerModel.
 */

import { useCallback } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
  useWidgetModelValue,
  useWidgetStoreRequired,
} from "../widget-store-context";

type IpyDate = { year: number; month: number; date: number } | null;

// Convert ipywidgets date object to YYYY-MM-DD format for input
function toDateString(value: IpyDate): string {
  if (!value) return "";
  const year = String(value.year).padStart(4, "0");
  const month = String(value.month + 1).padStart(2, "0");
  const date = String(value.date).padStart(2, "0");
  return `${year}-${month}-${date}`;
}

export function DatePickerWidget({ modelId, className }: WidgetComponentProps) {
  const { sendUpdate } = useWidgetStoreRequired();

  // Subscribe to individual state keys
  const value = useWidgetModelValue<IpyDate>(modelId, "value") ?? null;
  const min = useWidgetModelValue<IpyDate>(modelId, "min") ?? null;
  const max = useWidgetModelValue<IpyDate>(modelId, "max") ?? null;
  const description = useWidgetModelValue<string>(modelId, "description");
  const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;

  const handleChange = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      const newValue = e.target.value;
      if (newValue) {
        const [year, month, day] = newValue.split("-").map(Number);
        sendUpdate(modelId, { value: { year, month: month - 1, date: day } });
      } else {
        sendUpdate(modelId, { value: null });
      }
    },
    [modelId, sendUpdate],
  );

  return (
    <div
      className={cn("flex items-center gap-3", className)}
      data-widget-id={modelId}
      data-widget-type="DatePicker"
    >
      {description && <Label className="shrink-0 text-sm">{description}</Label>}
      <Input
        type="date"
        value={toDateString(value)}
        min={toDateString(min)}
        max={toDateString(max)}
        disabled={disabled}
        onChange={handleChange}
        className="w-40"
      />
    </div>
  );
}

export default DatePickerWidget;
registry/widgets/controls/time-picker-widget.tsx
"use client";

/**
 * TimePicker widget - renders a time input field.
 *
 * Maps to ipywidgets TimePickerModel.
 */

import { useCallback } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
  useWidgetModelValue,
  useWidgetStoreRequired,
} from "../widget-store-context";

// Convert time object or string to HH:MM format for input
function toTimeString(
  value: { hours: number; minutes: number; seconds?: number } | string | null,
): string {
  if (!value) return "";
  if (typeof value === "string") return value;
  const hours = String(value.hours).padStart(2, "0");
  const minutes = String(value.minutes).padStart(2, "0");
  return `${hours}:${minutes}`;
}

export function TimePickerWidget({ modelId, className }: WidgetComponentProps) {
  const { sendUpdate } = useWidgetStoreRequired();

  // Subscribe to individual state keys
  const value =
    useWidgetModelValue<
      { hours: number; minutes: number; seconds?: number } | string | null
    >(modelId, "value") ?? null;
  const min =
    useWidgetModelValue<{ hours: number; minutes: number } | string | null>(
      modelId,
      "min",
    ) ?? null;
  const max =
    useWidgetModelValue<{ hours: number; minutes: number } | string | null>(
      modelId,
      "max",
    ) ?? null;
  const description = useWidgetModelValue<string>(modelId, "description");
  const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;

  const handleChange = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      const newValue = e.target.value;
      if (newValue) {
        const [hours, minutes] = newValue.split(":").map(Number);
        sendUpdate(modelId, {
          value: { hours, minutes, seconds: 0, milliseconds: 0 },
        });
      } else {
        sendUpdate(modelId, { value: null });
      }
    },
    [modelId, sendUpdate],
  );

  return (
    <div
      className={cn("flex items-center gap-3", className)}
      data-widget-id={modelId}
      data-widget-type="TimePicker"
    >
      {description && <Label className="shrink-0 text-sm">{description}</Label>}
      <Input
        type="time"
        value={toTimeString(value)}
        min={toTimeString(min)}
        max={toTimeString(max)}
        disabled={disabled}
        onChange={handleChange}
        className="w-32"
      />
    </div>
  );
}

export default TimePickerWidget;
registry/widgets/controls/float-log-slider.tsx
/**
 * FloatLogSlider widget - renders a logarithmic scale slider.
 *
 * Maps to ipywidgets FloatLogSliderModel.
 */

import { Label } from "@/components/ui/label";
import { Slider } from "@/components/ui/slider";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
  useWidgetModelValue,
  useWidgetStoreRequired,
} from "../widget-store-context";

export function FloatLogSlider({ modelId, className }: WidgetComponentProps) {
  const { sendUpdate } = useWidgetStoreRequired();

  // Subscribe to individual state keys
  const value = useWidgetModelValue<number>(modelId, "value") ?? 1;
  const base = useWidgetModelValue<number>(modelId, "base") ?? 10;
  const min = useWidgetModelValue<number>(modelId, "min") ?? 0; // exponent min
  const max = useWidgetModelValue<number>(modelId, "max") ?? 4; // exponent max
  const step = useWidgetModelValue<number>(modelId, "step") ?? 0.1;
  const description = useWidgetModelValue<string>(modelId, "description");
  const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
  const orientation =
    useWidgetModelValue<"horizontal" | "vertical">(modelId, "orientation") ??
    "horizontal";
  const readout = useWidgetModelValue<boolean>(modelId, "readout") ?? true;
  const readoutFormat =
    useWidgetModelValue<string>(modelId, "readout_format") ?? ".3g";

  // Convert value to log scale position (exponent)
  const valueToExponent = (v: number): number => {
    if (v <= 0) return min;
    return Math.log(v) / Math.log(base);
  };

  // Convert exponent to actual value
  const exponentToValue = (exp: number): number => {
    return base ** exp;
  };

  const currentExponent = valueToExponent(value);

  const handleChange = (newValue: number[]) => {
    const newExponent = Math.min(max, Math.max(min, newValue[0]));
    const newActualValue = exponentToValue(newExponent);
    sendUpdate(modelId, { value: newActualValue });
  };

  // Format value for display
  const formatValue = (v: number): string => {
    // Parse Python-style format spec (e.g., ".3g")
    const matchG = readoutFormat.match(/\.(\d+)g/);
    if (matchG) {
      return v.toPrecision(parseInt(matchG[1], 10));
    }
    const matchF = readoutFormat.match(/\.(\d+)f/);
    if (matchF) {
      return v.toFixed(parseInt(matchF[1], 10));
    }
    const matchE = readoutFormat.match(/\.(\d+)e/);
    if (matchE) {
      return v.toExponential(parseInt(matchE[1], 10));
    }
    return String(v);
  };

  const isVertical = orientation === "vertical";

  return (
    <div
      className={cn(
        "flex gap-3",
        isVertical ? "flex-col items-center" : "items-center",
        className,
      )}
      data-widget-id={modelId}
      data-widget-type="FloatLogSlider"
    >
      {description && <Label className="shrink-0 text-sm">{description}</Label>}
      <Slider
        value={[currentExponent]}
        min={min}
        max={max}
        step={step}
        disabled={disabled}
        orientation={orientation}
        onValueChange={handleChange}
        className={isVertical ? "h-32" : "min-w-24 flex-1"}
      />
      {readout && (
        <span className="w-20 text-right text-sm tabular-nums text-muted-foreground">
          {formatValue(value)}
        </span>
      )}
    </div>
  );
}

export default FloatLogSlider;
registry/widgets/controls/play-widget.tsx
"use client";

/**
 * Play widget - animation control with play/pause/step.
 *
 * Maps to ipywidgets PlayModel.
 */

import {
  PauseIcon,
  PlayIcon,
  SkipBackIcon,
  SkipForwardIcon,
} from "lucide-react";
import { useCallback, useEffect, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
  useWidgetModelValue,
  useWidgetStoreRequired,
} from "../widget-store-context";

export function PlayWidget({ modelId, className }: WidgetComponentProps) {
  // sendUpdate is now stable (useCommRouter uses refs internally)
  const { sendUpdate } = useWidgetStoreRequired();
  const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);

  // Subscribe to individual state keys
  const value = useWidgetModelValue<number>(modelId, "value") ?? 0;
  const min = useWidgetModelValue<number>(modelId, "min") ?? 0;
  const max = useWidgetModelValue<number>(modelId, "max") ?? 100;
  const step = useWidgetModelValue<number>(modelId, "step") ?? 1;
  const interval = useWidgetModelValue<number>(modelId, "interval") ?? 100; // ms
  const playing = useWidgetModelValue<boolean>(modelId, "_playing") ?? false;
  const repeat = useWidgetModelValue<boolean>(modelId, "repeat") ?? false;
  const description = useWidgetModelValue<string>(modelId, "description");
  const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;

  // Ref for animation state values - these change frequently and shouldn't restart the interval.
  // sendUpdate is stable so it doesn't need a ref.
  const valuesRef = useRef({ value, min, max, step, repeat });

  // Keep values ref up-to-date without triggering the main effect
  useEffect(() => {
    valuesRef.current = { value, min, max, step, repeat };
  });

  // Handle animation interval - only restart when play state or interval timing changes
  useEffect(() => {
    if (playing && !disabled) {
      intervalRef.current = setInterval(() => {
        const { value, min, max, step, repeat } = valuesRef.current;
        const nextValue =
          value + step > max ? (repeat ? min : max) : value + step;
        const shouldStop = value + step > max && !repeat;
        sendUpdate(modelId, {
          value: nextValue,
          _playing: !shouldStop,
        });
      }, interval);
    } else {
      if (intervalRef.current) {
        clearInterval(intervalRef.current);
        intervalRef.current = null;
      }
    }

    return () => {
      if (intervalRef.current) {
        clearInterval(intervalRef.current);
      }
    };
  }, [playing, disabled, interval, modelId, sendUpdate]);

  const handlePlayPause = useCallback(() => {
    sendUpdate(modelId, { _playing: !playing });
  }, [modelId, playing, sendUpdate]);

  const handleStepBack = useCallback(() => {
    const { min, step, value } = valuesRef.current;
    const newValue = Math.max(min, value - step);
    sendUpdate(modelId, { value: newValue, _playing: false });
  }, [modelId, sendUpdate]);

  const handleStepForward = useCallback(() => {
    const { max, step, value } = valuesRef.current;
    const newValue = Math.min(max, value + step);
    sendUpdate(modelId, { value: newValue, _playing: false });
  }, [modelId, sendUpdate]);

  return (
    <div
      className={cn("flex items-center gap-2", className)}
      data-widget-id={modelId}
      data-widget-type="Play"
    >
      {description && <Label className="shrink-0 text-sm">{description}</Label>}
      <div className="flex items-center gap-1">
        <Button
          variant="outline"
          size="icon"
          className="size-8"
          onClick={handleStepBack}
          disabled={disabled || value <= min}
        >
          <SkipBackIcon className="size-4" />
        </Button>
        <Button
          variant="outline"
          size="icon"
          className="size-8"
          onClick={handlePlayPause}
          disabled={disabled}
        >
          {playing ? (
            <PauseIcon className="size-4" />
          ) : (
            <PlayIcon className="size-4" />
          )}
        </Button>
        <Button
          variant="outline"
          size="icon"
          className="size-8"
          onClick={handleStepForward}
          disabled={disabled || value >= max}
        >
          <SkipForwardIcon className="size-4" />
        </Button>
      </div>
      <span className="w-12 text-right text-sm tabular-nums text-muted-foreground">
        {value}
      </span>
    </div>
  );
}

export default PlayWidget;
registry/widgets/controls/combobox-widget.tsx
"use client";

/**
 * Combobox widget - text input with autocomplete suggestions.
 *
 * Maps to ipywidgets ComboboxModel.
 *
 * Uses native HTML <input> with <datalist> instead of Radix UI Popover
 * because Radix uses Portal which doesn't work in sandboxed iframes
 * (no allow-same-origin). See: https://github.com/runtimed/runt/issues/62
 */

import { useCallback, useEffect, useId, useState } from "react";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
  useWidgetModelValue,
  useWidgetStoreRequired,
} from "../widget-store-context";

export function ComboboxWidget({ modelId, className }: WidgetComponentProps) {
  const { sendUpdate, sendCustom } = useWidgetStoreRequired();
  const datalistId = useId();

  // Subscribe to individual state keys
  const value = useWidgetModelValue<string>(modelId, "value") ?? "";
  const options = useWidgetModelValue<string[]>(modelId, "options") ?? [];
  const placeholder =
    useWidgetModelValue<string>(modelId, "placeholder") ?? "Select or type...";
  const description = useWidgetModelValue<string>(modelId, "description");
  const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
  const ensureOption =
    useWidgetModelValue<boolean>(modelId, "ensure_option") ?? false;
  const continuousUpdate =
    useWidgetModelValue<boolean>(modelId, "continuous_update") ?? true;

  const [inputValue, setInputValue] = useState(value);

  // Sync input value when value changes from kernel
  useEffect(() => {
    setInputValue(value);
  }, [value]);

  const handleChange = useCallback(
    (event: React.ChangeEvent<HTMLInputElement>) => {
      const newValue = event.target.value;
      setInputValue(newValue);
      if (continuousUpdate) {
        // Only update if not enforcing option or value is in options
        if (!ensureOption || options.includes(newValue)) {
          sendUpdate(modelId, { value: newValue });
        }
      }
    },
    [modelId, continuousUpdate, ensureOption, options, sendUpdate],
  );

  const handleBlur = useCallback(() => {
    if (!continuousUpdate) {
      if (!ensureOption || options.includes(inputValue)) {
        sendUpdate(modelId, { value: inputValue });
      } else {
        // Reset to last valid value
        setInputValue(value);
      }
    }
    sendCustom(modelId, { event: "submit" });
  }, [
    modelId,
    continuousUpdate,
    ensureOption,
    options,
    inputValue,
    value,
    sendUpdate,
    sendCustom,
  ]);

  return (
    <div
      className={cn("flex items-center gap-3", className)}
      data-widget-id={modelId}
      data-widget-type="Combobox"
    >
      {description && <Label className="shrink-0 text-sm">{description}</Label>}
      <input
        type="text"
        list={datalistId}
        value={inputValue}
        onChange={handleChange}
        onBlur={handleBlur}
        placeholder={placeholder}
        disabled={disabled}
        className={cn(
          // Base styles matching shadcn input
          "h-9 w-48 rounded-md border border-input px-3 py-2 text-sm",
          "bg-transparent shadow-xs transition-[color,box-shadow] outline-none",
          // Dark mode background
          "dark:bg-input/30 dark:hover:bg-input/50",
          // Placeholder
          "placeholder:text-muted-foreground",
          // Focus styles
          "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
          // Disabled state
          "disabled:cursor-not-allowed disabled:opacity-50",
        )}
      />
      <datalist id={datalistId}>
        {options.map((option, idx) => (
          <option key={idx} value={option} />
        ))}
      </datalist>
    </div>
  );
}

export default ComboboxWidget;
registry/widgets/controls/selection-slider-widget.tsx
/**
 * SelectionSlider widget - slider that selects from discrete options.
 *
 * Maps to ipywidgets SelectionSliderModel.
 */

import { Label } from "@/components/ui/label";
import { Slider } from "@/components/ui/slider";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
  useWidgetModelValue,
  useWidgetStoreRequired,
} from "../widget-store-context";

export function SelectionSliderWidget({
  modelId,
  className,
}: WidgetComponentProps) {
  const { sendUpdate } = useWidgetStoreRequired();

  // Subscribe to individual state keys
  const options =
    useWidgetModelValue<string[]>(modelId, "_options_labels") ?? [];
  const index = useWidgetModelValue<number>(modelId, "index") ?? 0;
  const description = useWidgetModelValue<string>(modelId, "description");
  const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
  const orientation =
    useWidgetModelValue<"horizontal" | "vertical">(modelId, "orientation") ??
    "horizontal";
  const readout = useWidgetModelValue<boolean>(modelId, "readout") ?? true;
  const continuousUpdate =
    useWidgetModelValue<boolean>(modelId, "continuous_update") ?? true;

  const handleChange = (newValue: number[]) => {
    const newIndex = Math.round(newValue[0]);
    const clampedIndex = Math.min(options.length - 1, Math.max(0, newIndex));
    if (continuousUpdate || clampedIndex !== index) {
      sendUpdate(modelId, { index: clampedIndex });
    }
  };

  const isVertical = orientation === "vertical";
  const currentLabel = options[index] ?? "";

  return (
    <div
      className={cn(
        "flex gap-3",
        isVertical ? "flex-col items-center" : "items-center",
        className,
      )}
      data-widget-id={modelId}
      data-widget-type="SelectionSlider"
    >
      {description && <Label className="shrink-0 text-sm">{description}</Label>}
      <Slider
        value={[index]}
        min={0}
        max={Math.max(0, options.length - 1)}
        step={1}
        disabled={disabled || options.length === 0}
        orientation={orientation}
        onValueChange={handleChange}
        className={isVertical ? "h-32" : "min-w-24 flex-1"}
      />
      {readout && (
        <span className="min-w-16 text-sm text-muted-foreground">
          {currentLabel}
        </span>
      )}
    </div>
  );
}

export default SelectionSliderWidget;
registry/widgets/controls/selection-range-slider-widget.tsx
/**
 * SelectionRangeSlider widget - range slider that selects from discrete options.
 *
 * Maps to ipywidgets SelectionRangeSliderModel.
 */

import { Label } from "@/components/ui/label";
import { Slider } from "@/components/ui/slider";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
  useWidgetModelValue,
  useWidgetStoreRequired,
} from "../widget-store-context";

export function SelectionRangeSliderWidget({
  modelId,
  className,
}: WidgetComponentProps) {
  const { sendUpdate } = useWidgetStoreRequired();

  // Subscribe to individual state keys
  const options =
    useWidgetModelValue<string[]>(modelId, "_options_labels") ?? [];
  const index = useWidgetModelValue<number[]>(modelId, "index") ?? [0, 0];
  const description = useWidgetModelValue<string>(modelId, "description");
  const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
  const orientation =
    useWidgetModelValue<"horizontal" | "vertical">(modelId, "orientation") ??
    "horizontal";
  const readout = useWidgetModelValue<boolean>(modelId, "readout") ?? true;
  const continuousUpdate =
    useWidgetModelValue<boolean>(modelId, "continuous_update") ?? true;

  const [lowIndex, highIndex] = index;

  const handleChange = (newValue: number[]) => {
    const newLow = Math.round(newValue[0]);
    const newHigh = Math.round(newValue[1]);
    const clampedLow = Math.min(options.length - 1, Math.max(0, newLow));
    const clampedHigh = Math.min(options.length - 1, Math.max(0, newHigh));

    if (
      continuousUpdate ||
      clampedLow !== lowIndex ||
      clampedHigh !== highIndex
    ) {
      sendUpdate(modelId, { index: [clampedLow, clampedHigh] });
    }
  };

  const isVertical = orientation === "vertical";
  const lowLabel = options[lowIndex] ?? "";
  const highLabel = options[highIndex] ?? "";

  return (
    <div
      className={cn(
        "flex gap-3",
        isVertical ? "flex-col items-center" : "items-center",
        className,
      )}
      data-widget-id={modelId}
      data-widget-type="SelectionRangeSlider"
    >
      {description && <Label className="shrink-0 text-sm">{description}</Label>}
      <Slider
        value={[lowIndex, highIndex]}
        min={0}
        max={Math.max(0, options.length - 1)}
        step={1}
        disabled={disabled || options.length === 0}
        orientation={orientation}
        onValueChange={handleChange}
        className={isVertical ? "h-32" : "min-w-24 flex-1"}
      />
      {readout && (
        <span className="min-w-24 text-sm text-muted-foreground">
          {lowLabel} - {highLabel}
        </span>
      )}
    </div>
  );
}

export default SelectionRangeSliderWidget;
registry/widgets/controls/file-upload-widget.tsx
"use client";

/**
 * FileUpload widget - file upload button.
 *
 * Maps to ipywidgets FileUploadModel.
 */

import { UploadIcon } from "lucide-react";
import { useCallback, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
  useWidgetModelValue,
  useWidgetStoreRequired,
} from "../widget-store-context";
import { getButtonStyle } from "./button-style-utils";

interface FileData {
  name: string;
  type: string;
  size: number;
  content: string; // base64
  last_modified: number;
}

export function FileUploadWidget({ modelId, className }: WidgetComponentProps) {
  const { sendUpdate } = useWidgetStoreRequired();
  const inputRef = useRef<HTMLInputElement>(null);

  // Subscribe to individual state keys
  const value = useWidgetModelValue<FileData[]>(modelId, "value") ?? [];
  const accept = useWidgetModelValue<string>(modelId, "accept") ?? "";
  const multiple = useWidgetModelValue<boolean>(modelId, "multiple") ?? false;
  const description =
    useWidgetModelValue<string>(modelId, "description") ?? "Upload";
  const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
  const buttonStyle =
    useWidgetModelValue<string>(modelId, "button_style") ?? "";
  const icon = useWidgetModelValue<string>(modelId, "icon") ?? "upload";

  const { variant, className: styleClassName } = getButtonStyle(buttonStyle);

  const handleClick = useCallback(() => {
    inputRef.current?.click();
  }, []);

  const handleFileChange = useCallback(
    async (e: React.ChangeEvent<HTMLInputElement>) => {
      const files = e.target.files;
      if (!files || files.length === 0) return;

      const fileDataArray: FileData[] = [];

      for (const file of Array.from(files)) {
        // Read file as base64
        const content = await new Promise<string>((resolve, reject) => {
          const reader = new FileReader();
          reader.onload = () => {
            const result = reader.result as string;
            // Remove data URL prefix to get just base64
            const base64 = result.split(",")[1] || "";
            resolve(base64);
          };
          reader.onerror = reject;
          reader.readAsDataURL(file);
        });

        fileDataArray.push({
          name: file.name,
          type: file.type,
          size: file.size,
          content,
          last_modified: file.lastModified,
        });
      }

      sendUpdate(modelId, { value: fileDataArray });

      // Reset input so same file can be selected again
      if (inputRef.current) {
        inputRef.current.value = "";
      }
    },
    [modelId, sendUpdate],
  );

  const fileCount = value.length;
  const buttonText =
    fileCount > 0
      ? `${fileCount} file${fileCount > 1 ? "s" : ""} selected`
      : description;

  return (
    <div
      className={cn("flex items-center gap-3", className)}
      data-widget-id={modelId}
      data-widget-type="FileUpload"
    >
      <input
        ref={inputRef}
        type="file"
        accept={accept}
        multiple={multiple}
        onChange={handleFileChange}
        disabled={disabled}
        className="hidden"
      />
      <Button
        variant={variant}
        onClick={handleClick}
        disabled={disabled}
        className={cn(styleClassName, "gap-2")}
      >
        {icon === "upload" && <UploadIcon className="size-4" />}
        {buttonText}
      </Button>
      {fileCount > 0 && (
        <Label className="text-sm text-muted-foreground">
          {value.map((f) => f.name).join(", ")}
        </Label>
      )}
    </div>
  );
}

export default FileUploadWidget;
registry/widgets/controls/tags-input-widget.tsx
"use client";

/**
 * TagsInput widget - multi-value string tag input.
 *
 * Maps to ipywidgets TagsInputModel.
 */

import { XIcon } from "lucide-react";
import { useCallback, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
  useWidgetModelValue,
  useWidgetStoreRequired,
} from "../widget-store-context";

export function TagsInputWidget({ modelId, className }: WidgetComponentProps) {
  const { sendUpdate } = useWidgetStoreRequired();

  // Subscribe to individual state keys
  const value = useWidgetModelValue<string[]>(modelId, "value") ?? [];
  const allowedTags = useWidgetModelValue<string[]>(modelId, "allowed_tags");
  const allowDuplicates =
    useWidgetModelValue<boolean>(modelId, "allow_duplicates") ?? true;
  const placeholder =
    useWidgetModelValue<string>(modelId, "placeholder") ?? "Add tag...";
  const description = useWidgetModelValue<string>(modelId, "description");
  const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;

  const [inputValue, setInputValue] = useState("");

  const handleKeyDown = useCallback(
    (e: React.KeyboardEvent<HTMLInputElement>) => {
      if (e.key === "Enter" && inputValue.trim()) {
        e.preventDefault();
        const newTag = inputValue.trim();

        // Check if tag is allowed
        if (
          allowedTags &&
          allowedTags.length > 0 &&
          !allowedTags.includes(newTag)
        ) {
          return;
        }

        // Check for duplicates
        if (!allowDuplicates && value.includes(newTag)) {
          return;
        }

        sendUpdate(modelId, { value: [...value, newTag] });
        setInputValue("");
      } else if (e.key === "Backspace" && !inputValue && value.length > 0) {
        // Remove last tag on backspace when input is empty
        sendUpdate(modelId, { value: value.slice(0, -1) });
      }
    },
    [modelId, inputValue, value, allowedTags, allowDuplicates, sendUpdate],
  );

  const handleRemoveTag = useCallback(
    (indexToRemove: number) => {
      sendUpdate(modelId, {
        value: value.filter((_, idx) => idx !== indexToRemove),
      });
    },
    [modelId, value, sendUpdate],
  );

  return (
    <div
      className={cn("flex items-start gap-3", className)}
      data-widget-id={modelId}
      data-widget-type="TagsInput"
    >
      {description && (
        <Label className="shrink-0 pt-2 text-sm">{description}</Label>
      )}
      <div
        className={cn(
          "flex min-h-10 flex-wrap items-center gap-1.5 rounded-md border border-input bg-background px-3 py-2",
          disabled && "cursor-not-allowed opacity-50",
        )}
      >
        {value.map((tag, idx) => (
          <Badge
            key={`${tag}-${idx}`}
            variant="secondary"
            className="gap-1 pr-1"
          >
            {tag}
            {!disabled && (
              <button
                type="button"
                onClick={() => handleRemoveTag(idx)}
                className="rounded-full p-0.5 hover:bg-muted"
              >
                <XIcon className="size-3" />
              </button>
            )}
          </Badge>
        ))}
        <Input
          type="text"
          value={inputValue}
          onChange={(e) => setInputValue(e.target.value)}
          onKeyDown={handleKeyDown}
          placeholder={value.length === 0 ? placeholder : ""}
          disabled={disabled}
          className="h-6 min-w-20 flex-1 border-0 p-0 shadow-none focus-visible:ring-0"
        />
      </div>
    </div>
  );
}

export default TagsInputWidget;
registry/widgets/controls/colors-input-widget.tsx
"use client";

/**
 * ColorsInput widget - multi-value color input.
 *
 * Maps to ipywidgets ColorsInputModel.
 */

import { PlusIcon, XIcon } from "lucide-react";
import { useCallback, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
  useWidgetModelValue,
  useWidgetStoreRequired,
} from "../widget-store-context";

export function ColorsInputWidget({
  modelId,
  className,
}: WidgetComponentProps) {
  const { sendUpdate } = useWidgetStoreRequired();
  const colorInputRef = useRef<HTMLInputElement>(null);

  // Subscribe to individual state keys
  const value = useWidgetModelValue<string[]>(modelId, "value") ?? [];
  const allowedTags = useWidgetModelValue<string[]>(modelId, "allowed_tags");
  const allowDuplicates =
    useWidgetModelValue<boolean>(modelId, "allow_duplicates") ?? true;
  const description = useWidgetModelValue<string>(modelId, "description");
  const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;

  const [editingIndex, setEditingIndex] = useState<number | null>(null);

  const handleAddColor = useCallback(() => {
    colorInputRef.current?.click();
  }, []);

  const handleColorChange = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      const newColor = e.target.value;

      // Check if color is allowed
      if (
        allowedTags &&
        allowedTags.length > 0 &&
        !allowedTags.includes(newColor)
      ) {
        return;
      }

      // Check for duplicates
      if (!allowDuplicates && value.includes(newColor)) {
        return;
      }

      if (editingIndex !== null) {
        // Update existing color
        const newValue = [...value];
        newValue[editingIndex] = newColor;
        sendUpdate(modelId, { value: newValue });
        setEditingIndex(null);
      } else {
        // Add new color
        sendUpdate(modelId, { value: [...value, newColor] });
      }
    },
    [modelId, value, allowedTags, allowDuplicates, editingIndex, sendUpdate],
  );

  const handleRemoveColor = useCallback(
    (indexToRemove: number) => {
      sendUpdate(modelId, {
        value: value.filter((_, idx) => idx !== indexToRemove),
      });
    },
    [modelId, value, sendUpdate],
  );

  const handleEditColor = useCallback((idx: number) => {
    setEditingIndex(idx);
    // Trigger color picker with slight delay to allow state update
    setTimeout(() => {
      colorInputRef.current?.click();
    }, 0);
  }, []);

  return (
    <div
      className={cn("flex items-start gap-3", className)}
      data-widget-id={modelId}
      data-widget-type="ColorsInput"
    >
      {description && (
        <Label className="shrink-0 pt-2 text-sm">{description}</Label>
      )}
      <div
        className={cn(
          "flex flex-wrap items-center gap-2",
          disabled && "cursor-not-allowed opacity-50",
        )}
      >
        {value.map((color, idx) => (
          <div
            key={`${color}-${idx}`}
            className="group relative flex items-center"
          >
            <button
              type="button"
              onClick={() => handleEditColor(idx)}
              disabled={disabled}
              className="size-8 rounded-md border border-input shadow-sm transition-transform hover:scale-105"
              style={{ backgroundColor: color }}
              title={color}
            />
            {!disabled && (
              <button
                type="button"
                onClick={() => handleRemoveColor(idx)}
                className="absolute -right-1 -top-1 rounded-full bg-background p-0.5 opacity-0 shadow-sm transition-opacity group-hover:opacity-100"
              >
                <XIcon className="size-3" />
              </button>
            )}
          </div>
        ))}
        <input
          ref={colorInputRef}
          type="color"
          value={editingIndex !== null ? value[editingIndex] : "#000000"}
          onChange={handleColorChange}
          disabled={disabled}
          className="sr-only"
        />
        <Button
          variant="outline"
          size="icon"
          className="size-8"
          onClick={handleAddColor}
          disabled={disabled}
        >
          <PlusIcon className="size-4" />
        </Button>
      </div>
    </div>
  );
}

export default ColorsInputWidget;
registry/widgets/controls/controller-widget.tsx
"use client";

/**
 * Controller widget - gamepad input via Web Gamepad API.
 *
 * Maps to ipywidgets ControllerModel. Polls connected gamepads
 * and updates button/axis child widgets with current state.
 */

import { GamepadIcon } from "lucide-react";
import { useCallback, useEffect, useRef } from "react";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
  parseModelRef,
  useWidgetModelValue,
  useWidgetStoreRequired,
} from "../widget-store-context";
import { WidgetView } from "../widget-view";

export function ControllerWidget({ modelId, className }: WidgetComponentProps) {
  // sendUpdate is now stable (useCommRouter uses refs internally)
  const { sendUpdate } = useWidgetStoreRequired();
  const animationRef = useRef<number | null>(null);
  const lastStateRef = useRef<{
    buttons: { pressed: boolean; value: number }[];
    axes: number[];
  } | null>(null);

  // Subscribe to widget state
  const index = useWidgetModelValue<number>(modelId, "index") ?? 0;
  const connected = useWidgetModelValue<boolean>(modelId, "connected") ?? false;
  const name = useWidgetModelValue<string>(modelId, "name") ?? "";
  const mapping = useWidgetModelValue<string>(modelId, "mapping") ?? "";
  const buttons = useWidgetModelValue<string[]>(modelId, "buttons") ?? [];
  const axes = useWidgetModelValue<string[]>(modelId, "axes") ?? [];

  // Ref for polling state values - these change during polling and shouldn't restart the rAF loop.
  // sendUpdate is stable so it doesn't need a ref.
  const stateRef = useRef({ connected, buttons, axes });

  // Keep state ref up-to-date without triggering the polling effect
  useEffect(() => {
    stateRef.current = { connected, buttons, axes };
  });

  // Poll gamepad state - only depends on index, modelId, sendUpdate to avoid loop restarts
  const pollGamepad = useCallback(() => {
    const gamepads = navigator.getGamepads();
    const gamepad = gamepads[index];
    const { connected, buttons, axes } = stateRef.current;

    if (gamepad) {
      // Check if we need to update connection state
      if (!connected) {
        sendUpdate(modelId, {
          connected: true,
          name: gamepad.id,
          mapping: gamepad.mapping,
          timestamp: gamepad.timestamp,
        });
      }

      const lastState = lastStateRef.current;
      let hasChanges = false;

      // Update button states
      gamepad.buttons.forEach((button, i) => {
        const buttonRef = buttons[i];
        if (!buttonRef) return;

        const buttonId = parseModelRef(buttonRef);
        if (!buttonId) return;

        const lastButton = lastState?.buttons[i];
        if (
          !lastButton ||
          lastButton.pressed !== button.pressed ||
          Math.abs(lastButton.value - button.value) > 0.01
        ) {
          sendUpdate(buttonId, {
            pressed: button.pressed,
            value: button.value,
          });
          hasChanges = true;
        }
      });

      // Update axis states
      gamepad.axes.forEach((axisValue, i) => {
        const axisRef = axes[i];
        if (!axisRef) return;

        const axisId = parseModelRef(axisRef);
        if (!axisId) return;

        const lastAxis = lastState?.axes[i];
        if (lastAxis === undefined || Math.abs(lastAxis - axisValue) > 0.01) {
          sendUpdate(axisId, { value: axisValue });
          hasChanges = true;
        }
      });

      // Store current state for comparison
      if (hasChanges || !lastState) {
        lastStateRef.current = {
          buttons: gamepad.buttons.map((b) => ({
            pressed: b.pressed,
            value: b.value,
          })),
          axes: [...gamepad.axes],
        };
      }

      // Update timestamp
      if (hasChanges) {
        sendUpdate(modelId, { timestamp: gamepad.timestamp });
      }
    } else if (connected) {
      // Gamepad disconnected
      sendUpdate(modelId, { connected: false });
      lastStateRef.current = null;
    }

    animationRef.current = requestAnimationFrame(pollGamepad);
  }, [index, modelId, sendUpdate]);

  // Start/stop polling based on component lifecycle
  useEffect(() => {
    // Start polling
    animationRef.current = requestAnimationFrame(pollGamepad);

    // Handle gamepad connect/disconnect events
    const handleConnect = (e: GamepadEvent) => {
      if (e.gamepad.index === index) {
        sendUpdate(modelId, {
          connected: true,
          name: e.gamepad.id,
          mapping: e.gamepad.mapping,
        });
      }
    };

    const handleDisconnect = (e: GamepadEvent) => {
      if (e.gamepad.index === index) {
        sendUpdate(modelId, { connected: false });
        lastStateRef.current = null;
      }
    };

    window.addEventListener("gamepadconnected", handleConnect);
    window.addEventListener("gamepaddisconnected", handleDisconnect);

    return () => {
      if (animationRef.current) {
        cancelAnimationFrame(animationRef.current);
      }
      window.removeEventListener("gamepadconnected", handleConnect);
      window.removeEventListener("gamepaddisconnected", handleDisconnect);
    };
  }, [index, modelId, pollGamepad, sendUpdate]);

  return (
    <div
      className={cn("flex flex-col gap-3 rounded-lg border p-4", className)}
      data-widget-id={modelId}
      data-widget-type="Controller"
    >
      {/* Header */}
      <div className="flex items-center gap-2">
        <GamepadIcon
          className={cn(
            "size-5",
            connected ? "text-green-500" : "text-muted-foreground",
          )}
        />
        <Label className="font-medium">{name || `Controller ${index}`}</Label>
        <span
          className={cn(
            "ml-auto text-xs",
            connected ? "text-green-500" : "text-muted-foreground",
          )}
        >
          {connected ? "Connected" : "Disconnected"}
        </span>
      </div>

      {connected && (
        <>
          {/* Buttons */}
          {buttons.length > 0 && (
            <div className="space-y-1">
              <Label className="text-xs text-muted-foreground">Buttons</Label>
              <div className="flex flex-wrap gap-1">
                {buttons.map((buttonRef, i) => {
                  const buttonId = parseModelRef(buttonRef);
                  return buttonId ? (
                    <WidgetView key={buttonId} modelId={buttonId} />
                  ) : (
                    <div
                      key={i}
                      className="flex size-8 items-center justify-center rounded-full border bg-muted text-xs"
                    >
                      {i}
                    </div>
                  );
                })}
              </div>
            </div>
          )}

          {/* Axes */}
          {axes.length > 0 && (
            <div className="space-y-1">
              <Label className="text-xs text-muted-foreground">Axes</Label>
              <div className="flex flex-col gap-1">
                {axes.map((axisRef, i) => {
                  const axisId = parseModelRef(axisRef);
                  return axisId ? (
                    <div key={axisId} className="flex items-center gap-2">
                      <span className="w-8 text-xs text-muted-foreground">
                        {i}:
                      </span>
                      <WidgetView modelId={axisId} />
                    </div>
                  ) : null;
                })}
              </div>
            </div>
          )}

          {/* Mapping info */}
          {mapping && (
            <div className="text-xs text-muted-foreground">
              Mapping: {mapping}
            </div>
          )}
        </>
      )}

      {!connected && (
        <div className="py-4 text-center text-sm text-muted-foreground">
          Press a button on a gamepad to connect
        </div>
      )}
    </div>
  );
}

export default ControllerWidget;
registry/widgets/controls/controller-button-widget.tsx
/**
 * ControllerButton widget - displays a gamepad button state.
 *
 * Maps to ipywidgets ControllerButtonModel.
 */

import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import { useWidgetModelValue } from "../widget-store-context";

export function ControllerButtonWidget({
  modelId,
  className,
}: WidgetComponentProps) {
  const pressed = useWidgetModelValue<boolean>(modelId, "pressed") ?? false;
  const value = useWidgetModelValue<number>(modelId, "value") ?? 0;

  return (
    <div
      className={cn(
        "flex size-8 items-center justify-center rounded-full border-2 text-xs font-medium transition-colors",
        pressed
          ? "border-primary bg-primary text-primary-foreground"
          : "border-muted-foreground/30 bg-muted text-muted-foreground",
        className,
      )}
      data-widget-id={modelId}
      data-widget-type="ControllerButton"
      title={`Value: ${value.toFixed(2)}`}
    >
      {pressed ? "●" : "○"}
    </div>
  );
}

export default ControllerButtonWidget;
registry/widgets/controls/controller-axis-widget.tsx
/**
 * ControllerAxis widget - displays a gamepad axis value.
 *
 * Maps to ipywidgets ControllerAxisModel.
 */

import { Progress } from "@/components/ui/progress";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import { useWidgetModelValue } from "../widget-store-context";

export function ControllerAxisWidget({
  modelId,
  className,
}: WidgetComponentProps) {
  // Axis value ranges from -1 to 1
  const value = useWidgetModelValue<number>(modelId, "value") ?? 0;

  // Convert -1..1 to 0..100 for progress display
  const progressValue = ((value + 1) / 2) * 100;

  return (
    <div
      className={cn("flex items-center gap-2", className)}
      data-widget-id={modelId}
      data-widget-type="ControllerAxis"
    >
      <Progress value={progressValue} className="h-2 w-16" />
      <span className="w-12 text-right text-xs tabular-nums text-muted-foreground">
        {value.toFixed(2)}
      </span>
    </div>
  );
}

export default ControllerAxisWidget;
registry/widgets/controls/output-widget.tsx
"use client";

/**
 * Output widget - renders captured Jupyter outputs.
 *
 * Maps to ipywidgets OutputModel (@jupyter-widgets/output).
 * Renders an array of Jupyter outputs using the OutputArea component.
 * Media rendering configuration (custom renderers, priority) is
 * inherited from MediaProvider context if present.
 *
 * Note: The Output widget protocol is particularly complex. Rather than
 * simply setting outputs on the model from Python, Jupyter sends outputs
 * as custom messages that must be accumulated client-side. This includes
 * handling clear_output(wait=True) which defers clearing until the next
 * output arrives. We sync rendered state back to the model to keep
 * Python's `out.outputs` in sync with what's displayed.
 */

import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { type JupyterOutput, OutputArea } from "@/registry/cell/OutputArea";
import type { WidgetComponentProps } from "../widget-registry";
import {
  useWidgetModelValue,
  useWidgetStoreRequired,
} from "../widget-store-context";

interface OutputCustomMessage {
  method?: unknown;
  output?: unknown;
  wait?: unknown;
}

function isJupyterOutput(value: unknown): value is JupyterOutput {
  if (!value || typeof value !== "object") return false;
  const output = value as Partial<JupyterOutput>;
  return (
    output.output_type === "execute_result" ||
    output.output_type === "display_data" ||
    output.output_type === "stream" ||
    output.output_type === "error"
  );
}

export function OutputWidget({ modelId, className }: WidgetComponentProps) {
  const { store, sendUpdate } = useWidgetStoreRequired();
  const stateOutputs =
    useWidgetModelValue<JupyterOutput[]>(modelId, "outputs") ?? [];
  const stateOutputsRef = useRef(stateOutputs);
  const shouldClearOnNextOutputRef = useRef(false);
  const [renderedOutputs, setRenderedOutputs] =
    useState<JupyterOutput[]>(stateOutputs);
  const renderedOutputsRef = useRef(renderedOutputs);

  useEffect(() => {
    stateOutputsRef.current = stateOutputs;
    renderedOutputsRef.current = stateOutputs;
    setRenderedOutputs(stateOutputs);
  }, [stateOutputs]);

  useEffect(() => {
    renderedOutputsRef.current = renderedOutputs;
  }, [renderedOutputs]);

  useEffect(() => {
    let replayingBufferedMessages = true;

    const unsubscribe = store.subscribeToCustomMessage(modelId, (content) => {
      const message = content as OutputCustomMessage;
      const method =
        typeof message.method === "string" ? message.method : undefined;

      // OutputModel may have already synchronized state.outputs.
      // Skip initial buffered custom messages in that case to avoid duplicates.
      if (replayingBufferedMessages && stateOutputsRef.current.length > 0) {
        return;
      }

      if (method === "clear_output") {
        const wait = Boolean(message.wait);
        if (wait) {
          shouldClearOnNextOutputRef.current = true;
        } else {
          shouldClearOnNextOutputRef.current = false;
          renderedOutputsRef.current = [];
          setRenderedOutputs([]);
          // Keep Python-side `out.outputs` in sync with displayed output state.
          sendUpdate(modelId, { outputs: [] });
        }
        return;
      }

      if (method !== "output" || !isJupyterOutput(message.output)) {
        return;
      }
      const nextOutput: JupyterOutput = message.output;

      const prev = renderedOutputsRef.current;
      const next = shouldClearOnNextOutputRef.current
        ? [nextOutput]
        : [...prev, nextOutput];
      shouldClearOnNextOutputRef.current = false;
      renderedOutputsRef.current = next;
      setRenderedOutputs(next);
      // Keep Python-side `out.outputs` in sync with displayed output state.
      sendUpdate(modelId, { outputs: next });
    });

    replayingBufferedMessages = false;
    return unsubscribe;
  }, [modelId, sendUpdate, store]);

  if (renderedOutputs.length === 0) {
    return null;
  }

  // Check if we're already inside an iframe (isolated context).
  // If so, skip nested isolation since the outer iframe already provides security.
  // This prevents double-nesting: widget iframe → OutputWidget iframe → content
  const isInIframe = typeof window !== "undefined" && window.parent !== window;

  return (
    <div
      className={cn("widget-output", className)}
      data-widget-id={modelId}
      data-widget-type="Output"
    >
      <OutputArea
        outputs={renderedOutputs}
        isolated={isInIframe ? false : "auto"}
      />
    </div>
  );
}

export default OutputWidget;
registry/widgets/controls/button-style-utils.ts
/**
 * Shared utility for mapping ipywidgets button_style to shadcn Button
 * variant + Tailwind className overrides.
 *
 * The shadcn Button only has default, destructive, outline, secondary, ghost,
 * link variants — no info/success/warning. We use the "default" variant as a
 * base and apply Tailwind color classes via className. tailwind-merge resolves
 * the conflict in favor of our overrides.
 */

type ButtonStyleVariant = "default" | "destructive" | "outline";

interface ButtonStyleResult {
  variant: ButtonStyleVariant;
  className: string;
}

const BUTTON_STYLE_MAP: Record<string, ButtonStyleResult> = {
  primary: {
    variant: "default",
    className: "bg-blue-600 text-white hover:bg-blue-700",
  },
  success: {
    variant: "default",
    className: "bg-green-600 text-white hover:bg-green-700",
  },
  info: {
    variant: "default",
    className: "bg-sky-600 text-white hover:bg-sky-700",
  },
  warning: {
    variant: "default",
    className: "bg-amber-500 text-white hover:bg-amber-600",
  },
  danger: {
    variant: "destructive",
    className: "",
  },
  "": {
    variant: "outline",
    className: "",
  },
};

const DEFAULT_STYLE: ButtonStyleResult = { variant: "outline", className: "" };

export function getButtonStyle(buttonStyle: string): ButtonStyleResult {
  return BUTTON_STYLE_MAP[buttonStyle] ?? DEFAULT_STYLE;
}
registry/widgets/controls/audio-widget.tsx
/**
 * Audio widget - plays audio from the kernel.
 *
 * Maps to ipywidgets AudioModel.
 */

import { useMemo } from "react";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { buildMediaSrc } from "../buffer-utils";
import type { WidgetComponentProps } from "../widget-registry";
import { useWidgetModelValue } from "../widget-store-context";

export function AudioWidget({ modelId, className }: WidgetComponentProps) {
  const value = useWidgetModelValue<string | ArrayBuffer>(modelId, "value");
  const format = useWidgetModelValue<string>(modelId, "format") ?? "mp3";
  const autoplay = useWidgetModelValue<boolean>(modelId, "autoplay") ?? true;
  const loop = useWidgetModelValue<boolean>(modelId, "loop") ?? true;
  const controls = useWidgetModelValue<boolean>(modelId, "controls") ?? true;
  const description = useWidgetModelValue<string>(modelId, "description");

  const src = useMemo(
    () => buildMediaSrc(value, "audio", format),
    [value, format],
  );

  if (!value) {
    return null;
  }

  return (
    <div
      className={cn("inline-flex items-start gap-3", className)}
      data-widget-id={modelId}
      data-widget-type="Audio"
    >
      {description && (
        <Label className="shrink-0 pt-1 text-sm">{description}</Label>
      )}
      {/* biome-ignore lint/a11y/useMediaCaption: ipywidgets audio does not provide captions */}
      <audio src={src} autoPlay={autoplay} loop={loop} controls={controls} />
    </div>
  );
}

export default AudioWidget;
registry/widgets/controls/video-widget.tsx
/**
 * Video widget - plays video from the kernel.
 *
 * Maps to ipywidgets VideoModel.
 */

import { useMemo } from "react";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { buildMediaSrc } from "../buffer-utils";
import type { WidgetComponentProps } from "../widget-registry";
import { useWidgetModelValue } from "../widget-store-context";

export function VideoWidget({ modelId, className }: WidgetComponentProps) {
  const value = useWidgetModelValue<string | ArrayBuffer>(modelId, "value");
  const format = useWidgetModelValue<string>(modelId, "format") ?? "mp4";
  const width = useWidgetModelValue<string>(modelId, "width") ?? "";
  const height = useWidgetModelValue<string>(modelId, "height") ?? "";
  const autoplay = useWidgetModelValue<boolean>(modelId, "autoplay") ?? true;
  const loop = useWidgetModelValue<boolean>(modelId, "loop") ?? true;
  const controls = useWidgetModelValue<boolean>(modelId, "controls") ?? true;
  const description = useWidgetModelValue<string>(modelId, "description");

  const src = useMemo(
    () => buildMediaSrc(value, "video", format),
    [value, format],
  );

  if (!value) {
    return null;
  }

  const style: React.CSSProperties = {};
  if (width) style.width = width;
  if (height) style.height = height;

  return (
    <div
      className={cn("inline-flex items-start gap-3", className)}
      data-widget-id={modelId}
      data-widget-type="Video"
    >
      {description && (
        <Label className="shrink-0 pt-1 text-sm">{description}</Label>
      )}
      {/* biome-ignore lint/a11y/useMediaCaption: ipywidgets video does not provide captions */}
      <video
        src={src}
        autoPlay={autoplay}
        loop={loop}
        controls={controls}
        style={style}
        className="max-w-full"
      />
    </div>
  );
}

export default VideoWidget;
registry/widgets/controls/ints-input-widget.tsx
"use client";

/**
 * IntsInput widget - multi-value integer tag input.
 *
 * Maps to ipywidgets IntsInputModel.
 */

import { XIcon } from "lucide-react";
import { useCallback, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
  useWidgetModelValue,
  useWidgetStoreRequired,
} from "../widget-store-context";

export function IntsInputWidget({ modelId, className }: WidgetComponentProps) {
  const { sendUpdate } = useWidgetStoreRequired();

  // Subscribe to individual state keys
  const value = useWidgetModelValue<number[]>(modelId, "value") ?? [];
  const allowDuplicates =
    useWidgetModelValue<boolean>(modelId, "allow_duplicates") ?? true;
  const placeholder =
    useWidgetModelValue<string>(modelId, "placeholder") ?? "Add integer...";
  const description = useWidgetModelValue<string>(modelId, "description");
  const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
  const min = useWidgetModelValue<number>(modelId, "min");
  const max = useWidgetModelValue<number>(modelId, "max");

  const [inputValue, setInputValue] = useState("");

  const handleKeyDown = useCallback(
    (e: React.KeyboardEvent<HTMLInputElement>) => {
      if (e.key === "Enter" && inputValue.trim()) {
        e.preventDefault();
        const parsed = parseInt(inputValue.trim(), 10);

        // Validate integer
        if (Number.isNaN(parsed)) return;

        // Check bounds
        if (min != null && parsed < min) return;
        if (max != null && parsed > max) return;

        // Check for duplicates
        if (!allowDuplicates && value.includes(parsed)) return;

        sendUpdate(modelId, { value: [...value, parsed] });
        setInputValue("");
      } else if (e.key === "Backspace" && !inputValue && value.length > 0) {
        sendUpdate(modelId, { value: value.slice(0, -1) });
      }
    },
    [modelId, inputValue, value, allowDuplicates, min, max, sendUpdate],
  );

  const handleRemoveTag = useCallback(
    (indexToRemove: number) => {
      sendUpdate(modelId, {
        value: value.filter((_, idx) => idx !== indexToRemove),
      });
    },
    [modelId, value, sendUpdate],
  );

  return (
    <div
      className={cn("flex items-start gap-3", className)}
      data-widget-id={modelId}
      data-widget-type="IntsInput"
    >
      {description && (
        <Label className="shrink-0 pt-2 text-sm">{description}</Label>
      )}
      <div
        className={cn(
          "flex min-h-10 flex-wrap items-center gap-1.5 rounded-md border border-input bg-background px-3 py-2",
          disabled && "cursor-not-allowed opacity-50",
        )}
      >
        {value.map((num, idx) => (
          <Badge
            key={`${num}-${idx}`}
            variant="secondary"
            className="gap-1 pr-1"
          >
            {num}
            {!disabled && (
              <button
                type="button"
                onClick={() => handleRemoveTag(idx)}
                className="rounded-full p-0.5 hover:bg-muted"
              >
                <XIcon className="size-3" />
              </button>
            )}
          </Badge>
        ))}
        <Input
          type="text"
          inputMode="numeric"
          value={inputValue}
          onChange={(e) => setInputValue(e.target.value)}
          onKeyDown={handleKeyDown}
          placeholder={value.length === 0 ? placeholder : ""}
          disabled={disabled}
          className="h-6 min-w-20 flex-1 border-0 p-0 shadow-none focus-visible:ring-0"
        />
      </div>
    </div>
  );
}

export default IntsInputWidget;
registry/widgets/controls/floats-input-widget.tsx
"use client";

/**
 * FloatsInput widget - multi-value float tag input.
 *
 * Maps to ipywidgets FloatsInputModel.
 */

import { XIcon } from "lucide-react";
import { useCallback, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
  useWidgetModelValue,
  useWidgetStoreRequired,
} from "../widget-store-context";

export function FloatsInputWidget({
  modelId,
  className,
}: WidgetComponentProps) {
  const { sendUpdate } = useWidgetStoreRequired();

  // Subscribe to individual state keys
  const value = useWidgetModelValue<number[]>(modelId, "value") ?? [];
  const allowDuplicates =
    useWidgetModelValue<boolean>(modelId, "allow_duplicates") ?? true;
  const placeholder =
    useWidgetModelValue<string>(modelId, "placeholder") ?? "Add number...";
  const description = useWidgetModelValue<string>(modelId, "description");
  const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;
  const min = useWidgetModelValue<number>(modelId, "min");
  const max = useWidgetModelValue<number>(modelId, "max");

  const [inputValue, setInputValue] = useState("");

  const handleKeyDown = useCallback(
    (e: React.KeyboardEvent<HTMLInputElement>) => {
      if (e.key === "Enter" && inputValue.trim()) {
        e.preventDefault();
        const parsed = parseFloat(inputValue.trim());

        // Validate float
        if (Number.isNaN(parsed)) return;

        // Check bounds
        if (min != null && parsed < min) return;
        if (max != null && parsed > max) return;

        // Check for duplicates
        if (!allowDuplicates && value.includes(parsed)) return;

        sendUpdate(modelId, { value: [...value, parsed] });
        setInputValue("");
      } else if (e.key === "Backspace" && !inputValue && value.length > 0) {
        sendUpdate(modelId, { value: value.slice(0, -1) });
      }
    },
    [modelId, inputValue, value, allowDuplicates, min, max, sendUpdate],
  );

  const handleRemoveTag = useCallback(
    (indexToRemove: number) => {
      sendUpdate(modelId, {
        value: value.filter((_, idx) => idx !== indexToRemove),
      });
    },
    [modelId, value, sendUpdate],
  );

  return (
    <div
      className={cn("flex items-start gap-3", className)}
      data-widget-id={modelId}
      data-widget-type="FloatsInput"
    >
      {description && (
        <Label className="shrink-0 pt-2 text-sm">{description}</Label>
      )}
      <div
        className={cn(
          "flex min-h-10 flex-wrap items-center gap-1.5 rounded-md border border-input bg-background px-3 py-2",
          disabled && "cursor-not-allowed opacity-50",
        )}
      >
        {value.map((num, idx) => (
          <Badge
            key={`${num}-${idx}`}
            variant="secondary"
            className="gap-1 pr-1"
          >
            {num}
            {!disabled && (
              <button
                type="button"
                onClick={() => handleRemoveTag(idx)}
                className="rounded-full p-0.5 hover:bg-muted"
              >
                <XIcon className="size-3" />
              </button>
            )}
          </Badge>
        ))}
        <Input
          type="text"
          inputMode="decimal"
          value={inputValue}
          onChange={(e) => setInputValue(e.target.value)}
          onKeyDown={handleKeyDown}
          placeholder={value.length === 0 ? placeholder : ""}
          disabled={disabled}
          className="h-6 min-w-20 flex-1 border-0 p-0 shadow-none focus-visible:ring-0"
        />
      </div>
    </div>
  );
}

export default FloatsInputWidget;
registry/widgets/controls/datetime-widget.tsx
"use client";

/**
 * Datetime widget - renders a datetime-local input field.
 *
 * Maps to ipywidgets DatetimeModel (UTC/timezone-aware) and NaiveDatetimeModel (local time).
 *
 * DatetimeModel: Values are in UTC. We convert to local time for display and back to UTC on change.
 * NaiveDatetimeModel: Values are in local time. No conversion needed.
 */

import { useCallback } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import {
  useWidgetModel,
  useWidgetModelValue,
  useWidgetStoreRequired,
} from "../widget-store-context";

type DatetimeValue =
  | {
      year: number;
      month: number;
      date: number;
      hours: number;
      minutes: number;
      seconds: number;
      milliseconds?: number;
    }
  | string
  | null;

/**
 * Create a Date from UTC components (for DatetimeModel).
 * The returned Date stores the time internally as UTC, so local getters will
 * automatically convert to the user's timezone.
 */
function utcComponentsToDate(
  value: Exclude<DatetimeValue, string | null>,
): Date {
  const date = new Date();
  date.setUTCFullYear(value.year, value.month, value.date);
  date.setUTCHours(
    value.hours,
    value.minutes,
    value.seconds,
    value.milliseconds ?? 0,
  );
  return date;
}

/**
 * Create a Date from local components (for NaiveDatetimeModel).
 */
function localComponentsToDate(
  value: Exclude<DatetimeValue, string | null>,
): Date {
  const date = new Date();
  date.setFullYear(value.year, value.month, value.date);
  date.setHours(
    value.hours,
    value.minutes,
    value.seconds,
    value.milliseconds ?? 0,
  );
  return date;
}

/**
 * Format a Date as datetime-local input string (YYYY-MM-DDTHH:MM).
 * Always uses local time getters since datetime-local displays in local time.
 */
function formatDateForInput(date: Date): string {
  const year = String(date.getFullYear()).padStart(4, "0");
  const month = String(date.getMonth() + 1).padStart(2, "0");
  const day = String(date.getDate()).padStart(2, "0");
  const hours = String(date.getHours()).padStart(2, "0");
  const minutes = String(date.getMinutes()).padStart(2, "0");
  return `${year}-${month}-${day}T${hours}:${minutes}`;
}

/**
 * Convert datetime value to datetime-local input format (YYYY-MM-DDTHH:MM).
 * @param value - The datetime value from ipywidgets
 * @param isUtc - Whether the value is in UTC (DatetimeModel) or local time (NaiveDatetimeModel)
 */
function toDatetimeLocalString(value: DatetimeValue, isUtc: boolean): string {
  if (!value) return "";

  if (typeof value === "string") {
    const date = new Date(value);
    if (Number.isNaN(date.getTime())) return "";
    return formatDateForInput(date);
  }

  // Convert components to Date, then format for display
  const date = isUtc
    ? utcComponentsToDate(value)
    : localComponentsToDate(value);
  return formatDateForInput(date);
}

/**
 * Extract UTC components from a Date (for DatetimeModel).
 */
function dateToUtcComponents(d: Date) {
  return {
    year: d.getUTCFullYear(),
    month: d.getUTCMonth(),
    date: d.getUTCDate(),
    hours: d.getUTCHours(),
    minutes: d.getUTCMinutes(),
    seconds: d.getUTCSeconds(),
    milliseconds: d.getUTCMilliseconds(),
  };
}

/**
 * Extract local components from a Date (for NaiveDatetimeModel).
 */
function dateToLocalComponents(d: Date) {
  return {
    year: d.getFullYear(),
    month: d.getMonth(),
    date: d.getDate(),
    hours: d.getHours(),
    minutes: d.getMinutes(),
    seconds: d.getSeconds(),
    milliseconds: d.getMilliseconds(),
  };
}

export function DatetimeWidget({ modelId, className }: WidgetComponentProps) {
  const { sendUpdate } = useWidgetStoreRequired();
  const model = useWidgetModel(modelId);

  // Determine if this is a timezone-aware (UTC) model or naive (local) model
  const isUtc = model?.modelName === "DatetimeModel";

  // Subscribe to individual state keys
  const value = useWidgetModelValue<DatetimeValue>(modelId, "value") ?? null;
  const min = useWidgetModelValue<DatetimeValue>(modelId, "min") ?? null;
  const max = useWidgetModelValue<DatetimeValue>(modelId, "max") ?? null;
  const description = useWidgetModelValue<string>(modelId, "description");
  const disabled = useWidgetModelValue<boolean>(modelId, "disabled") ?? false;

  const handleChange = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      const newValue = e.target.value;
      if (newValue) {
        // datetime-local input gives us a local time string
        const d = new Date(newValue);
        // Convert to appropriate format based on model type
        const components = isUtc
          ? dateToUtcComponents(d)
          : dateToLocalComponents(d);
        sendUpdate(modelId, { value: components });
      } else {
        sendUpdate(modelId, { value: null });
      }
    },
    [modelId, sendUpdate, isUtc],
  );

  return (
    <div
      className={cn("flex items-center gap-3", className)}
      data-widget-id={modelId}
      data-widget-type="Datetime"
    >
      {description && <Label className="shrink-0 text-sm">{description}</Label>}
      <Input
        type="datetime-local"
        value={toDatetimeLocalString(value, isUtc)}
        min={toDatetimeLocalString(min, isUtc)}
        max={toDatetimeLocalString(max, isUtc)}
        disabled={disabled}
        onChange={handleChange}
        className="w-52"
      />
    </div>
  );
}

export default DatetimeWidget;
registry/widgets/controls/stack-widget.tsx
/**
 * Stack widget - single-child container showing one child at a time.
 *
 * Maps to ipywidgets StackModel. Like Tab but without the tab headers —
 * displays only the child at `selected_index`.
 */

import { cn } from "@/lib/utils";
import type { WidgetComponentProps } from "../widget-registry";
import { parseModelRef, useWidgetModelValue } from "../widget-store-context";
import { WidgetView } from "../widget-view";

export function StackWidget({ modelId, className }: WidgetComponentProps) {
  // Subscribe to individual state keys
  const children = useWidgetModelValue<string[]>(modelId, "children");
  const selectedIndex =
    useWidgetModelValue<number>(modelId, "selected_index") ?? 0;

  if (!children || children.length === 0) return null;

  const selectedRef = children[selectedIndex];
  if (!selectedRef) return null;

  const childId = parseModelRef(selectedRef);
  if (!childId) return null;

  return (
    <div
      className={cn("w-full", className)}
      data-widget-id={modelId}
      data-widget-type="Stack"
    >
      <WidgetView modelId={childId} />
    </div>
  );
}

export default StackWidget;

More from nteract

Browse all components in this library →