>();
+
+ register(definition: WidgetDefinition
): void {
+ const id = definition.metadata.id;
+ this.defs.set(id, definition as WidgetDefinition);
+ }
+
+ unregister(widgetId: string): void {
+ this.defs.delete(widgetId);
+ }
+
+ get(widgetId: string): WidgetDefinition | undefined {
+ return this.defs.get(widgetId);
+ }
+
+ getAll(): WidgetDefinition[] {
+ return [...this.defs.values()];
+ }
+
+ /** Non-destructive metadata merge + optional component replace */
+ modify(widgetId: string, patch: Partial): void {
+ const cur = this.defs.get(widgetId);
+ if (!cur) return;
+ this.defs.set(widgetId, {
+ ...cur,
+ ...patch,
+ metadata: patch.metadata
+ ? { ...cur.metadata, ...patch.metadata }
+ : cur.metadata,
+ });
+ }
+}
diff --git a/packages/ui-next/src/widgets/slots/ExtensionSlot.tsx b/packages/ui-next/src/widgets/slots/ExtensionSlot.tsx
new file mode 100644
index 00000000..b89865ab
--- /dev/null
+++ b/packages/ui-next/src/widgets/slots/ExtensionSlot.tsx
@@ -0,0 +1,32 @@
+import { Fragment, useSyncExternalStore } from "react";
+
+import { slotRegistry } from "@/widgets/system";
+import type { SlotId, SlotProps } from "@/widgets/types";
+
+type ExtensionSlotProps = SlotProps & {
+ slotId: SlotId;
+};
+
+/**
+ * Renders all components injected into a named slot (`PluginAPI.injectSlot`).
+ * See `packages/ui/docs/widgets-api.md` §13 (Slot Injection).
+ */
+export function ExtensionSlot(props: ExtensionSlotProps) {
+ const { slotId, ...slotProps } = props;
+
+ const entries = useSyncExternalStore(
+ slotRegistry.subscribe,
+ () => slotRegistry.get(slotId),
+ () => slotRegistry.get(slotId),
+ );
+
+ return (
+ <>
+ {entries.map(({ component: Component, entryId }) => (
+
+
+
+ ))}
+ >
+ );
+}
diff --git a/packages/ui-next/src/widgets/system.ts b/packages/ui-next/src/widgets/system.ts
new file mode 100644
index 00000000..9a5f69a6
--- /dev/null
+++ b/packages/ui-next/src/widgets/system.ts
@@ -0,0 +1,20 @@
+/**
+ * Singleton widget system for the POC. For tests, instantiate `PluginManager` + registries manually.
+ */
+import { PluginManager } from "@/widgets/plugins/PluginManager";
+import { HookRegistry } from "@/widgets/plugins/HookRegistry";
+import { SlotRegistry } from "@/widgets/plugins/SlotRegistry";
+import { WidgetRegistry } from "@/widgets/registry/WidgetRegistry";
+
+export const layoutStore: Record = {};
+
+export const widgetRegistry = new WidgetRegistry();
+export const hookRegistry = new HookRegistry();
+export const slotRegistry = new SlotRegistry();
+
+export const pluginManager = new PluginManager(
+ widgetRegistry,
+ hookRegistry,
+ slotRegistry,
+ layoutStore,
+);
diff --git a/packages/ui-next/src/widgets/types.ts b/packages/ui-next/src/widgets/types.ts
new file mode 100644
index 00000000..a13a8030
--- /dev/null
+++ b/packages/ui-next/src/widgets/types.ts
@@ -0,0 +1,194 @@
+/**
+ * Types aligned with `packages/ui/docs/widgets-api.md` (§1, §2, §13).
+ * Intentionally minimal — extend as the host app gains real layout stores.
+ */
+import type { ComponentType, ReactElement } from "react";
+
+// ─── Widget props (§2) ─────────────────────────────────────────────────────
+
+export interface BaseWidgetProps {
+ widgetInstanceId: string;
+ widgetDefId: string;
+ isEditMode: boolean;
+ enabled?: boolean;
+ onPropsChange: (partial: Record) => Promise;
+ selectedWidgetId?: string | null;
+ onSelectWidget?: (widgetId: string, pageId?: string) => void;
+ editingWidgetId?: string | null;
+ onEditWidget?: (widgetId: string | null) => void;
+ selectedContainerId?: string | null;
+ onSelectContainer?: (containerId: string, pageId?: string) => void;
+ contextVariables?: Record;
+ pageContext?: Record;
+ customClassName?: string;
+}
+
+export type WidgetCategory =
+ | "control"
+ | "display"
+ | "layout"
+ | "chart"
+ | "system"
+ | "custom";
+
+export type WidgetCapability =
+ | "nested-layout"
+ | "translatable"
+ | "data-bound"
+ | "interactive"
+ | "exportable";
+
+/** Property editor field — full union lives in widgets-api §3 */
+export interface ConfigField {
+ type: string;
+ label: string;
+ description?: string;
+ default?: unknown;
+ group?: string;
+ order?: number;
+ [key: string]: unknown;
+}
+
+export type ConfigSchema> = Partial<
+ Record
+>;
+
+export interface WidgetMetadata {
+ id: string;
+ name: string;
+ category: WidgetCategory;
+ description: string;
+ icon?: ComponentType;
+ thumbnail?: string;
+ defaultProps?: Partial
;
+ configSchema?: ConfigSchema
>;
+ minSize?: { width: number; height: number };
+ resizable?: boolean;
+ tags?: string[];
+ capabilities?: WidgetCapability[];
+ maxInstances?: number;
+}
+
+export interface WidgetDefinition
{
+ component: ComponentType
;
+ metadata: WidgetMetadata
;
+ editComponent?: React.LazyExoticComponent>;
+ previewComponent?: ComponentType;
+ validate?: (props: P) => Record | null;
+}
+
+export interface WidgetInstance {
+ id: string;
+ widgetId: string;
+ props?: Record;
+ order?: number;
+}
+
+// ─── Plugins & hooks (§13) ───────────────────────────────────────────────────
+
+export type LayoutStore = Record;
+
+export interface HookContext {
+ pluginId: string;
+ pageId: string;
+ isEditMode: boolean;
+ store: Readonly;
+}
+
+export type HookName =
+ | "widget:beforeRender"
+ | "widget:afterRender"
+ | "widget:beforeSave"
+ | "widget:afterSave"
+ | "widget:beforeAdd"
+ | "widget:afterAdd"
+ | "widget:beforeRemove"
+ | "widget:afterRemove"
+ | "container:beforeRender"
+ | "container:afterRender"
+ | "container:beforeSave"
+ | "layout:beforeHydrate"
+ | "layout:afterHydrate"
+ | "layout:beforeSave"
+ | "layout:afterSave"
+ | "editor:beforeDrop"
+ | "editor:widgetPalette"
+ | "editor:contextMenu";
+
+/** Slot identifiers from widgets-api §13 — extend as product adds chrome */
+export type SlotId =
+ | "editor:toolbar:start"
+ | "editor:toolbar:end"
+ | "editor:sidebar:top"
+ | "editor:sidebar:bottom"
+ | "editor:panel:right:top"
+ | "editor:panel:right:bottom"
+ | "widget:toolbar"
+ | "container:toolbar"
+ | "container:empty"
+ | "page:header"
+ | "page:footer";
+
+export interface SlotProps {
+ pageId: string;
+ isEditMode: boolean;
+ selectedWidgetId?: string | null;
+ selectedContainerId?: string | null;
+}
+
+export type WidgetWrapper = (
+ Component: ComponentType
,
+) => ComponentType
;
+
+export interface PluginAPI {
+ registerWidget:
(definition: WidgetDefinition
) => void;
+ unregisterWidget: (widgetId: string) => void;
+ modifyWidget: (widgetId: string, patch: Partial) => void;
+ wrapWidget: (widgetId: string, wrapper: WidgetWrapper) => void;
+ extendConfig: (
+ widgetId: string,
+ fields: Record,
+ ) => void;
+ addHook: (name: T, handler: HookHandler) => void;
+ removeHook: (name: T, handler: HookHandler) => void;
+ injectSlot: (
+ slotId: SlotId,
+ component: ComponentType,
+ ) => void;
+ getStore: () => Readonly;
+ subscribe: (
+ selector: (state: LayoutStore) => T,
+ callback: (value: T) => void,
+ ) => () => void;
+}
+
+export interface WidgetPlugin {
+ id: string;
+ name: string;
+ version: string;
+ priority?: number;
+ requires?: string[];
+ setup: (api: PluginAPI) => void | Promise;
+ teardown?: () => void;
+}
+
+/** Narrow hook handler typing — expand per hook as needed */
+export type HookHandler =
+ T extends "editor:widgetPalette"
+ ? (
+ widgets: WidgetDefinition[],
+ ctx: HookContext,
+ ) => WidgetDefinition[]
+ : T extends "widget:beforeRender"
+ ? (
+ props: Record,
+ widget: WidgetInstance,
+ ctx: HookContext,
+ ) => Record | false
+ : T extends "widget:afterRender"
+ ? (
+ element: ReactElement,
+ widget: WidgetInstance,
+ ctx: HookContext,
+ ) => ReactElement
+ : (data: unknown, ctx: HookContext) => unknown;
diff --git a/packages/ui-next/src/widgets/widgets/sample/TextBlockWidget.tsx b/packages/ui-next/src/widgets/widgets/sample/TextBlockWidget.tsx
new file mode 100644
index 00000000..ff24df89
--- /dev/null
+++ b/packages/ui-next/src/widgets/widgets/sample/TextBlockWidget.tsx
@@ -0,0 +1,16 @@
+import type { BaseWidgetProps } from "@/widgets/types";
+
+export interface TextBlockWidgetProps extends BaseWidgetProps {
+ title?: string;
+ body?: string;
+}
+
+export function TextBlockWidget(props: TextBlockWidgetProps) {
+ const { title, body } = props;
+ return (
+
+
{title ?? "Text block"}
+ {body ?
{body}
:
Empty body
}
+
+ );
+}