diff --git a/frontend/src/widgets/ArtifactGateWidget.test.tsx b/frontend/src/widgets/ArtifactGateWidget.test.tsx
new file mode 100644
index 00000000..5e68299d
--- /dev/null
+++ b/frontend/src/widgets/ArtifactGateWidget.test.tsx
@@ -0,0 +1,54 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { ArtifactGateWidget } from "./ArtifactGateWidget";
+
+test("renders artifact content in a pre block", () => {
+ const onRespond = vi.fn();
+ render(
+
+ );
+ expect(screen.getByText("SELECT 42")).toBeInTheDocument();
+});
+
+test("clicking an option without opens responds immediately", async () => {
+ const onRespond = vi.fn();
+ render(
+
+ );
+ await userEvent.click(screen.getByRole("button", { name: /Approva/ }));
+ expect(onRespond).toHaveBeenCalledWith({ id: "u1", kind: "artifact-gate", choices: ["approve"] });
+});
+
+test("reserved control responds with control field", async () => {
+ const onRespond = vi.fn();
+ render(
+
+ );
+ await userEvent.click(screen.getByRole("button", { name: /indietro/i }));
+ expect(onRespond).toHaveBeenCalledWith({ id: "u1", control: "back" });
+});
diff --git a/frontend/src/widgets/ArtifactGateWidget.tsx b/frontend/src/widgets/ArtifactGateWidget.tsx
new file mode 100644
index 00000000..7770fd84
--- /dev/null
+++ b/frontend/src/widgets/ArtifactGateWidget.tsx
@@ -0,0 +1,60 @@
+import { useState } from "react";
+import type { WidgetProps } from "./types";
+import type { UiResponse, WidgetDescriptor } from "../api/types";
+import { ReservedControls } from "./ReservedControls";
+import { LinkageHost } from "./LinkageHost";
+
+export function ArtifactGateWidget({ descriptor, onRespond }: WidgetProps) {
+ const [pendingLinkage, setPendingLinkage] = useState<{
+ parentResponse: UiResponse;
+ childDescriptor: WidgetDescriptor;
+ } | null>(null);
+
+ function handleOption(optionId: string) {
+ const option = descriptor.options?.find((o) => o.id === optionId);
+ const parentResponse: UiResponse = { id: descriptor.id, kind: "artifact-gate", choices: [optionId] };
+
+ if (option?.opens) {
+ // Route through LinkageHost to collect child text first
+ setPendingLinkage({ parentResponse, childDescriptor: option.opens });
+ } else {
+ onRespond(parentResponse);
+ }
+ }
+
+ if (pendingLinkage) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {descriptor.title &&
{descriptor.title}
}
+ {descriptor.artifact?.content && (
+
+ {descriptor.artifact.content}
+
+ )}
+
+ {descriptor.options?.map((o) => (
+
+ ))}
+
+ onRespond({ id: descriptor.id, control: c })}
+ />
+
+ );
+}
diff --git a/frontend/src/widgets/ArtifactWidget.tsx b/frontend/src/widgets/ArtifactWidget.tsx
new file mode 100644
index 00000000..729f8c33
--- /dev/null
+++ b/frontend/src/widgets/ArtifactWidget.tsx
@@ -0,0 +1,19 @@
+import type { WidgetProps } from "./types";
+
+/**
+ * ArtifactWidget: view-only renderer. Never calls onRespond.
+ * Invariant no-limbo: this widget intentionally produces no response;
+ * it is used only as a passive display, never as a decision point.
+ */
+export function ArtifactWidget({ descriptor }: WidgetProps) {
+ return (
+
+ {descriptor.title &&
{descriptor.title}
}
+ {descriptor.artifact?.content && (
+
+ {descriptor.artifact.content}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/widgets/LinkageHost.tsx b/frontend/src/widgets/LinkageHost.tsx
new file mode 100644
index 00000000..df3a0349
--- /dev/null
+++ b/frontend/src/widgets/LinkageHost.tsx
@@ -0,0 +1,27 @@
+import { useState } from "react";
+import type { WidgetDescriptor, UiResponse } from "../api/types";
+import { FreetextWidget } from "./FreetextWidget";
+
+/**
+ * LinkageHost: when an artifact-gate option has `opens` (a child descriptor),
+ * this component renders the child widget and merges the parent choice with
+ * the child's text into a single combined UiResponse.
+ */
+export function LinkageHost({
+ parentResponse,
+ childDescriptor,
+ onRespond,
+}: {
+ parentResponse: UiResponse;
+ childDescriptor: WidgetDescriptor;
+ onRespond: (r: UiResponse) => void;
+}) {
+ function handleChildResponse(childResponse: UiResponse) {
+ // Merge parent choice + child text into one response
+ onRespond({ ...parentResponse, text: childResponse.text });
+ }
+
+ // Currently only freetext child is supported per spec.
+ // FreetextWidget is the canonical child for "opens".
+ return ;
+}
diff --git a/frontend/src/widgets/MultiselectWidget.test.tsx b/frontend/src/widgets/MultiselectWidget.test.tsx
new file mode 100644
index 00000000..4d5881fa
--- /dev/null
+++ b/frontend/src/widgets/MultiselectWidget.test.tsx
@@ -0,0 +1,101 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { MultiselectWidget } from "./MultiselectWidget";
+
+test("confirms the checked ids including initially selected", async () => {
+ const onRespond = vi.fn();
+ render(
+
+ );
+ await userEvent.click(screen.getByRole("checkbox", { name: /t2/ }));
+ await userEvent.click(screen.getByRole("button", { name: /conferma/i }));
+ expect(onRespond).toHaveBeenCalledWith({ id: "u1", kind: "multiselect", choices: ["t1", "t2"] });
+});
+
+test("select-all checks all options", async () => {
+ const onRespond = vi.fn();
+ render(
+
+ );
+ await userEvent.click(screen.getByRole("button", { name: /seleziona tutti/i }));
+ await userEvent.click(screen.getByRole("button", { name: /conferma/i }));
+ expect(onRespond).toHaveBeenCalledWith({ id: "u1", kind: "multiselect", choices: ["a", "b"] });
+});
+
+test("deselect-all unchecks all options", async () => {
+ const onRespond = vi.fn();
+ render(
+
+ );
+ await userEvent.click(screen.getByRole("button", { name: /deseleziona tutti/i }));
+ await userEvent.click(screen.getByRole("button", { name: /conferma/i }));
+ expect(onRespond).toHaveBeenCalledWith({ id: "u1", kind: "multiselect", choices: [] });
+});
+
+test("confirm is disabled when nothing checked and allow_empty is false", async () => {
+ const onRespond = vi.fn();
+ render(
+
+ );
+ const btn = screen.getByRole("button", { name: /conferma/i });
+ expect(btn).toBeDisabled();
+ await userEvent.click(btn);
+ expect(onRespond).not.toHaveBeenCalled();
+});
+
+test("confirm is enabled when allow_empty is false but something is checked", async () => {
+ const onRespond = vi.fn();
+ render(
+
+ );
+ const btn = screen.getByRole("button", { name: /conferma/i });
+ expect(btn).not.toBeDisabled();
+ await userEvent.click(btn);
+ expect(onRespond).toHaveBeenCalledWith({ id: "u1", kind: "multiselect", choices: ["x"] });
+});
diff --git a/frontend/src/widgets/MultiselectWidget.tsx b/frontend/src/widgets/MultiselectWidget.tsx
new file mode 100644
index 00000000..67f621ba
--- /dev/null
+++ b/frontend/src/widgets/MultiselectWidget.tsx
@@ -0,0 +1,59 @@
+import { useState } from "react";
+import type { WidgetProps } from "./types";
+
+export function MultiselectWidget({ descriptor, onRespond }: WidgetProps) {
+ const options = descriptor.options ?? [];
+ const initialChecked = new Set(options.filter((o) => o.selected).map((o) => o.id));
+ const [checked, setChecked] = useState>(initialChecked);
+
+ const allChecked = options.length > 0 && options.every((o) => checked.has(o.id));
+
+ function toggle(id: string) {
+ setChecked((prev) => {
+ const next = new Set(prev);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ return next;
+ });
+ }
+
+ function selectAll() {
+ setChecked(new Set(options.map((o) => o.id)));
+ }
+
+ function deselectAll() {
+ setChecked(new Set());
+ }
+
+ const isDisabled = descriptor.allow_empty === false && checked.size === 0;
+
+ return (
+