(null);
+ useEffect(() => {
+ root.current?.querySelector('[role="tablist"]')?.setAttribute("aria-label", label);
+ }, [label]);
+
+ return (
+
+ ({
+ ...item,
+ render: (
+
+ ),
+ }))}
+ value={value}
+ activateOnFocus
+ onValueChange={(next) => {
+ const item = items.find((candidate) => candidate.value === next);
+ if (item) onChange(item.value);
+ }}
+ variant="segmented"
+ size="sm"
+ />
+
+ );
+}
diff --git a/playground/src/components/StatusBadge.test.tsx b/playground/src/components/StatusBadge.test.tsx
new file mode 100644
index 0000000000..1b28bbe3d0
--- /dev/null
+++ b/playground/src/components/StatusBadge.test.tsx
@@ -0,0 +1,16 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { StatusBadge } from "@/components/StatusBadge";
+
+describe("StatusBadge", () => {
+ it.each([
+ ["READY", "ready"],
+ ["SETUP_FAILED", "setup_failed"],
+ ["canceled", "canceled"],
+ [undefined, "unknown"],
+ ])("normalizes %s", (status, label) => {
+ render();
+ expect(screen.getByText(label)).toBeVisible();
+ });
+});
diff --git a/playground/src/components/StatusBadge.tsx b/playground/src/components/StatusBadge.tsx
new file mode 100644
index 0000000000..3b5e2b9a67
--- /dev/null
+++ b/playground/src/components/StatusBadge.tsx
@@ -0,0 +1,28 @@
+import { Badge, type BadgeVariant } from "@cloudflare/kumo/components/badge";
+
+/** Maps known Cog statuses to semantic colors and renders unknown values neutrally. */
+export function StatusBadge({ status }: { status?: string }) {
+ const normalized = status?.toLowerCase() || "unknown";
+ const variants: Record = {
+ ready: "success",
+ succeeded: "success",
+ busy: "warning",
+ starting: "warning",
+ processing: "warning",
+ defunct: "error",
+ failed: "error",
+ error: "error",
+ setup_failed: "error",
+ unhealthy: "error",
+ unreachable: "error",
+ canceled: "neutral",
+ unknown: "neutral",
+ };
+ return (
+
+ );
+}
diff --git a/playground/src/components/editor/JsonEditor.test.tsx b/playground/src/components/editor/JsonEditor.test.tsx
new file mode 100644
index 0000000000..e3963551eb
--- /dev/null
+++ b/playground/src/components/editor/JsonEditor.test.tsx
@@ -0,0 +1,157 @@
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { JsonEditor } from "@/components/editor/JsonEditor";
+
+describe("JsonEditor", () => {
+ it("renders highlighted read-only JSON and follows controlled updates", async () => {
+ const { container, rerender } = render(
+ ,
+ );
+
+ const editor = screen.getByRole("textbox", { name: "Response JSON" });
+ expect(editor).toHaveAttribute("aria-readonly", "true");
+ expect(editor).toHaveAttribute("contenteditable", "false");
+ expect(container.querySelector(".cm-gutters")).toBeInTheDocument();
+ expect(container.querySelector(".cm-foldGutter")).toBeInTheDocument();
+ expect(container.querySelector(".cm-content span")).toBeInTheDocument();
+
+ rerender();
+ await waitFor(() => expect(editor).toHaveTextContent('"answer":43'));
+ });
+
+ it("follows the tail until the user scrolls away", async () => {
+ const { container, rerender } = render(
+ ,
+ );
+ const scroller = container.querySelector(".cm-scroller");
+ if (!scroller) throw new Error("CodeMirror scroller was not rendered");
+
+ let scrollHeight = 1_000;
+ Object.defineProperties(scroller, {
+ clientHeight: { configurable: true, value: 200 },
+ scrollHeight: { configurable: true, get: () => scrollHeight },
+ scrollTop: { configurable: true, value: 0, writable: true },
+ });
+
+ rerender();
+ await waitFor(() => expect(scroller.scrollTop).toBe(scrollHeight));
+ await nextAnimationFrame();
+
+ scroller.scrollTop = 300;
+ fireEvent.scroll(scroller);
+ scrollHeight = 1_200;
+ rerender();
+ await nextAnimationFrame();
+
+ expect(scroller.scrollTop).toBe(300);
+
+ scrollHeight = 1_300;
+ rerender(
+ ,
+ );
+ rerender(
+ ,
+ );
+ await nextAnimationFrame();
+
+ expect(scroller.scrollTop).toBe(300);
+
+ rerender(
+ ,
+ );
+ scrollHeight = 1_400;
+ rerender(
+ ,
+ );
+
+ await waitFor(() => expect(scroller.scrollTop).toBe(scrollHeight));
+ });
+
+ it("does not override a user scroll while a tail measurement is queued", async () => {
+ const { container, rerender } = render(
+ ,
+ );
+ const scroller = container.querySelector(".cm-scroller");
+ if (!scroller) throw new Error("CodeMirror scroller was not rendered");
+ Object.defineProperties(scroller, {
+ clientHeight: { configurable: true, value: 200 },
+ scrollHeight: { configurable: true, value: 1_000 },
+ scrollTop: { configurable: true, value: 0, writable: true },
+ });
+
+ rerender();
+ fireEvent.wheel(scroller, { deltaY: -100 });
+ scroller.scrollTop = 300;
+ fireEvent.scroll(scroller);
+ await nextAnimationFrame();
+
+ expect(scroller.scrollTop).toBe(300);
+ });
+
+ it("copies the complete document", async () => {
+ const writeText = vi.fn().mockResolvedValue(undefined);
+ Object.defineProperty(navigator, "clipboard", {
+ configurable: true,
+ value: { writeText },
+ });
+ render();
+
+ fireEvent.click(screen.getByRole("button", { name: "Copy Copy JSON" }));
+
+ await waitFor(() => expect(writeText).toHaveBeenCalledWith('{"answer":42}'));
+ });
+
+ it("removes disabled editors and copy controls from keyboard navigation", () => {
+ render(
+ ,
+ );
+
+ const editor = screen.getByRole("textbox", { name: "Disabled JSON" });
+ expect(editor).toHaveAttribute("tabindex", "-1");
+ expect(editor).toHaveAttribute("aria-disabled", "true");
+ expect(editor).toHaveAttribute("aria-invalid", "true");
+ expect(editor).toHaveAttribute("aria-describedby", "disabled-help");
+ expect(screen.getByRole("button", { name: "Copy Disabled JSON" })).toBeDisabled();
+ });
+
+ it("leaves Tab available for keyboard navigation", () => {
+ render();
+ const editor = screen.getByRole("textbox", { name: "Input JSON" });
+ editor.focus();
+
+ expect(fireEvent.keyDown(editor, { key: "Tab", code: "Tab" })).toBe(true);
+ });
+
+ it("does not add controlled updates to undo history", async () => {
+ const { rerender } = render();
+ const editor = screen.getByRole("textbox", { name: "Updated JSON" });
+
+ rerender();
+ await waitFor(() => expect(editor).toHaveTextContent('"answer":43'));
+ editor.focus();
+ fireEvent.keyDown(editor, { key: "z", code: "KeyZ", metaKey: true });
+
+ expect(editor).toHaveTextContent('"answer":43');
+ });
+});
+
+function nextAnimationFrame(): Promise {
+ return new Promise((resolve) => window.requestAnimationFrame(() => resolve()));
+}
diff --git a/playground/src/components/editor/JsonEditor.tsx b/playground/src/components/editor/JsonEditor.tsx
new file mode 100644
index 0000000000..3c778afa46
--- /dev/null
+++ b/playground/src/components/editor/JsonEditor.tsx
@@ -0,0 +1,217 @@
+import { Compartment, EditorState, Transaction } from "@codemirror/state";
+import { EditorView } from "@codemirror/view";
+import { useEffect, useRef } from "react";
+
+import { editorBehavior, jsonEditorExtensions } from "@/components/editor/config";
+
+export type JsonEditorProps = {
+ value: string;
+ onChange?: (value: string) => void;
+ readOnly?: boolean;
+ disabled?: boolean;
+ invalid?: boolean;
+ describedBy?: string;
+ followTail?: boolean;
+ active?: boolean;
+ className?: string;
+ label: string;
+ autoHeight?: boolean;
+};
+
+/**
+ * Renders a controlled CodeMirror JSON editor with accessible read-only and disabled modes,
+ * copying, optional content-based sizing, and live-output tail following.
+ */
+export function JsonEditor({
+ value,
+ onChange,
+ readOnly = false,
+ disabled = false,
+ invalid = false,
+ describedBy,
+ followTail = false,
+ active = true,
+ className = "",
+ label,
+ autoHeight = false,
+}: JsonEditorProps) {
+ const hostRef = useRef(null);
+ const editorRef = useRef(undefined);
+ const initialValue = useRef(value);
+ const initialLabel = useRef(label);
+ const initialReadOnly = useRef(readOnly);
+ const initialDisabled = useRef(disabled);
+ const initialInvalid = useRef(invalid);
+ const initialDescribedBy = useRef(describedBy);
+ const onChangeRef = useRef(onChange);
+ const updatingValue = useRef(false);
+ const followRef = useRef(true);
+ const followEnabled = useRef(followTail);
+ const scrollingToTail = useRef(false);
+ const scrollRequest = useRef(0);
+ const activeRef = useRef(active);
+ const followTailRef = useRef(followTail);
+ const behavior = useRef(new Compartment());
+ onChangeRef.current = onChange;
+ activeRef.current = active;
+ followTailRef.current = followTail;
+
+ useEffect(() => {
+ if (!hostRef.current) return;
+ const editor = new EditorView({
+ parent: hostRef.current,
+ state: EditorState.create({
+ doc: initialValue.current,
+ extensions: [
+ ...jsonEditorExtensions(),
+ behavior.current.of(
+ editorBehavior(
+ initialReadOnly.current,
+ initialDisabled.current,
+ initialInvalid.current,
+ initialLabel.current,
+ initialDescribedBy.current,
+ ),
+ ),
+ EditorView.updateListener.of((update) => {
+ if (update.docChanged && !updatingValue.current) {
+ onChangeRef.current?.(update.state.doc.toString());
+ }
+ }),
+ ],
+ }),
+ });
+ editorRef.current = editor;
+ const updateFollow = () => {
+ if (scrollingToTail.current) return;
+ const { clientHeight, scrollHeight, scrollTop } = editor.scrollDOM;
+ followRef.current = scrollHeight - scrollTop - clientHeight < 48;
+ };
+ const stopFollowing = () => {
+ followRef.current = false;
+ scrollingToTail.current = false;
+ scrollRequest.current += 1;
+ };
+ const stopFollowingFromWheel = (event: WheelEvent) => {
+ if (event.deltaY < 0) stopFollowing();
+ };
+ const stopFollowingFromPointer = (event: PointerEvent) => {
+ if (event.target === editor.scrollDOM) stopFollowing();
+ };
+ const stopFollowingFromKey = (event: KeyboardEvent) => {
+ if (["ArrowUp", "Home", "PageUp"].includes(event.key)) stopFollowing();
+ };
+ editor.scrollDOM.addEventListener("scroll", updateFollow, { passive: true });
+ editor.scrollDOM.addEventListener("wheel", stopFollowingFromWheel, { passive: true });
+ editor.scrollDOM.addEventListener("touchstart", stopFollowing, { passive: true });
+ editor.scrollDOM.addEventListener("pointerdown", stopFollowingFromPointer, { passive: true });
+ editor.scrollDOM.addEventListener("keydown", stopFollowingFromKey);
+ return () => {
+ editor.scrollDOM.removeEventListener("scroll", updateFollow);
+ editor.scrollDOM.removeEventListener("wheel", stopFollowingFromWheel);
+ editor.scrollDOM.removeEventListener("touchstart", stopFollowing);
+ editor.scrollDOM.removeEventListener("pointerdown", stopFollowingFromPointer);
+ editor.scrollDOM.removeEventListener("keydown", stopFollowingFromKey);
+ editor.destroy();
+ editorRef.current = undefined;
+ };
+ }, []);
+
+ useEffect(() => {
+ editorRef.current?.dispatch({
+ effects: behavior.current.reconfigure(
+ editorBehavior(readOnly, disabled, invalid, label, describedBy),
+ ),
+ });
+ }, [describedBy, disabled, invalid, label, readOnly]);
+
+ useEffect(() => {
+ const editor = editorRef.current;
+ if (!editor) return;
+ const current = editor.state.doc.toString();
+ if (followTail && !followEnabled.current) followRef.current = true;
+ followEnabled.current = followTail;
+ const scroll = followTail && active && followRef.current;
+ if (current !== value) {
+ updatingValue.current = true;
+ editor.dispatch({
+ changes: changedRange(current, value),
+ annotations: Transaction.addToHistory.of(false),
+ selection: readOnly
+ ? undefined
+ : { anchor: Math.min(editor.state.selection.main.head, value.length) },
+ });
+ updatingValue.current = false;
+ }
+ if (!scroll) return;
+
+ const request = ++scrollRequest.current;
+ scrollingToTail.current = true;
+ editor.requestMeasure({
+ read(view) {
+ return view.scrollDOM.scrollHeight;
+ },
+ write(scrollHeight, view) {
+ if (
+ scrollRequest.current !== request ||
+ !activeRef.current ||
+ !followTailRef.current ||
+ !followRef.current
+ ) {
+ if (scrollRequest.current === request) scrollingToTail.current = false;
+ return;
+ }
+ view.scrollDOM.scrollTop = scrollHeight;
+ window.requestAnimationFrame(() => {
+ if (scrollRequest.current === request) scrollingToTail.current = false;
+ });
+ },
+ });
+ }, [active, followTail, readOnly, value]);
+
+ const copy = async () => {
+ try {
+ await navigator.clipboard.writeText(editorRef.current?.state.doc.toString() ?? value);
+ } catch {
+ const editor = editorRef.current;
+ if (!editor) return;
+ editor.dispatch({ selection: { anchor: 0, head: editor.state.doc.length } });
+ editor.focus();
+ }
+ };
+
+ return (
+
+
+
+
+ );
+}
+
+function editorHeight(value: string): number {
+ return Math.min(320, Math.max(80, value.split("\n").length * 18 + 18));
+}
+
+function changedRange(current: string, value: string) {
+ let from = 0;
+ while (from < current.length && from < value.length && current[from] === value[from]) from += 1;
+
+ let currentTo = current.length;
+ let valueTo = value.length;
+ while (currentTo > from && valueTo > from && current[currentTo - 1] === value[valueTo - 1]) {
+ currentTo -= 1;
+ valueTo -= 1;
+ }
+ return { from, to: currentTo, insert: value.slice(from, valueTo) };
+}
diff --git a/playground/src/components/editor/LazyJsonEditor.tsx b/playground/src/components/editor/LazyJsonEditor.tsx
new file mode 100644
index 0000000000..4780a1efec
--- /dev/null
+++ b/playground/src/components/editor/LazyJsonEditor.tsx
@@ -0,0 +1,19 @@
+import { Loader } from "@cloudflare/kumo/components/loader";
+import { lazy, Suspense } from "react";
+
+import type { JsonEditorProps } from "@/components/editor/JsonEditor";
+
+const JsonEditor = lazy(async () => ({
+ default: (await import("@/components/editor/JsonEditor")).JsonEditor,
+}));
+
+/** Keeps CodeMirror out of the initial bundle until an editor is first displayed. */
+export function LazyJsonEditor(props: JsonEditorProps) {
+ return (
+ }
+ >
+
+
+ );
+}
diff --git a/playground/src/components/editor/config.ts b/playground/src/components/editor/config.ts
new file mode 100644
index 0000000000..beea25adbd
--- /dev/null
+++ b/playground/src/components/editor/config.ts
@@ -0,0 +1,107 @@
+import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
+import { json } from "@codemirror/lang-json";
+import {
+ bracketMatching,
+ foldGutter,
+ foldKeymap,
+ HighlightStyle,
+ syntaxHighlighting,
+} from "@codemirror/language";
+import { EditorState, type Extension } from "@codemirror/state";
+import {
+ drawSelection,
+ EditorView,
+ highlightActiveLine,
+ highlightActiveLineGutter,
+ highlightSpecialChars,
+ keymap,
+ lineNumbers,
+} from "@codemirror/view";
+import { tags } from "@lezer/highlight";
+
+/** Builds the shared JSON language, editing, highlighting, and theme extensions. */
+export function jsonEditorExtensions(): Extension[] {
+ return [
+ lineNumbers(),
+ foldGutter(),
+ highlightSpecialChars(),
+ history(),
+ drawSelection(),
+ bracketMatching(),
+ highlightActiveLine(),
+ highlightActiveLineGutter(),
+ keymap.of([...defaultKeymap, ...historyKeymap, ...foldKeymap]),
+ json(),
+ syntaxHighlighting(kumoHighlightStyle),
+ kumoEditorTheme,
+ EditorView.lineWrapping,
+ ];
+}
+
+/** Maps editor mode and accessibility state into reconfigurable CodeMirror extensions. */
+export function editorBehavior(
+ readOnly: boolean,
+ disabled: boolean,
+ invalid: boolean,
+ label: string,
+ describedBy?: string,
+): Extension[] {
+ const attributes: Record = {
+ "aria-label": label,
+ "aria-readonly": String(readOnly || disabled),
+ spellcheck: "false",
+ };
+ if (describedBy) attributes["aria-describedby"] = describedBy;
+ if (invalid) attributes["aria-invalid"] = "true";
+ if (disabled) {
+ attributes["aria-disabled"] = "true";
+ attributes.tabindex = "-1";
+ } else if (readOnly) {
+ attributes.tabindex = "0";
+ }
+ return [
+ EditorState.readOnly.of(readOnly || disabled),
+ EditorView.editable.of(!readOnly && !disabled),
+ EditorView.contentAttributes.of(attributes),
+ ];
+}
+
+const kumoEditorTheme = EditorView.theme({
+ "&": {
+ height: "100%",
+ backgroundColor: "var(--color-kumo-base)",
+ color: "var(--text-color-kumo-default)",
+ },
+ "&.cm-focused": { outline: "none" },
+ ".cm-scroller": {
+ fontFamily: '"SF Mono", Monaco, Consolas, "Liberation Mono", monospace',
+ fontSize: "12px",
+ lineHeight: "1.5",
+ },
+ ".cm-content": { caretColor: "var(--text-color-kumo-default)", padding: "8px 0" },
+ ".cm-line": { padding: "0 10px" },
+ ".cm-gutters": {
+ backgroundColor: "var(--color-kumo-elevated)",
+ borderRight: "1px solid var(--color-kumo-hairline)",
+ color: "var(--text-color-kumo-subtle)",
+ },
+ ".cm-activeLine, .cm-activeLineGutter": {
+ backgroundColor: "color-mix(in srgb, var(--color-kumo-fill) 45%, transparent)",
+ },
+ ".cm-selectionBackground, &.cm-focused .cm-selectionBackground": {
+ backgroundColor: "var(--color-kumo-info-tint) !important",
+ },
+ ".cm-cursor, .cm-dropCursor": { borderLeftColor: "var(--text-color-kumo-default)" },
+});
+
+const kumoHighlightStyle = HighlightStyle.define([
+ { tag: tags.propertyName, color: "var(--text-color-kumo-link)" },
+ { tag: [tags.string, tags.special(tags.string)], color: "var(--text-color-kumo-success)" },
+ { tag: tags.number, color: "var(--text-color-kumo-warning)" },
+ { tag: [tags.bool, tags.null], color: "var(--text-color-kumo-brand)" },
+ { tag: tags.escape, color: "var(--text-color-kumo-info)" },
+ {
+ tag: [tags.brace, tags.squareBracket, tags.separator],
+ color: "var(--text-color-kumo-subtle)",
+ },
+]);
diff --git a/playground/src/styles/app.css b/playground/src/styles/app.css
new file mode 100644
index 0000000000..ac83ce2dcb
--- /dev/null
+++ b/playground/src/styles/app.css
@@ -0,0 +1,5 @@
+@source "../../node_modules/@cloudflare/kumo/dist/**/*.{js,jsx,ts,tsx}";
+@import "@cloudflare/kumo/styles/tailwind";
+@import "tailwindcss";
+
+/* Playground-specific layout extends Kumo's semantic tokens and components. */
diff --git a/playground/src/styles/playground.css b/playground/src/styles/playground.css
new file mode 100644
index 0000000000..aa1a706295
--- /dev/null
+++ b/playground/src/styles/playground.css
@@ -0,0 +1,869 @@
+/*
+ * Cog Playground styles.
+ *
+ * Colors come from Kumo's semantic design tokens. Light/dark is driven by the
+ * data-mode attribute on , which switches the token values automatically.
+ */
+
+:root {
+ /* Surfaces */
+ --bg: var(--color-kumo-canvas);
+ --surface: var(--color-kumo-elevated);
+ --surface-hover: var(--color-kumo-fill-hover);
+ --border: var(--color-kumo-hairline);
+
+ /* Text */
+ --text: var(--text-color-kumo-default);
+ --text-muted: var(--text-color-kumo-subtle);
+
+ /* Brand / actions */
+ --accent: var(--color-kumo-brand);
+ --on-accent: #fff;
+
+ /* Status (text-optimized tokens for good contrast on surfaces) */
+ --green: var(--text-color-kumo-success);
+ --yellow: var(--text-color-kumo-warning);
+ --red: var(--text-color-kumo-danger);
+}
+
+* {
+ box-sizing: border-box;
+}
+/* Ensure the hidden attribute wins over author display rules below. */
+[hidden] {
+ display: none !important;
+}
+body {
+ margin: 0;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
+ background: var(--bg);
+ color: var(--text);
+ font-size: 14px;
+ line-height: 1.5;
+}
+.muted {
+ color: var(--text-muted);
+ font-size: 12px;
+}
+.spacer {
+ flex: 1;
+}
+
+header {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 10px 20px;
+ background: var(--surface);
+ border-bottom: 1px solid var(--border);
+}
+header h1 {
+ font-size: 16px;
+ margin: 0;
+ font-weight: 600;
+ white-space: nowrap;
+}
+#playground-toolbar {
+ display: flex;
+ align-items: flex-start;
+ gap: 12px 24px;
+ padding: 10px 20px;
+ background: var(--surface);
+ border-bottom: 1px solid var(--border);
+ flex-wrap: wrap;
+ position: sticky;
+ top: 0;
+ z-index: 20;
+}
+#target-bar,
+#action-bar {
+ position: relative;
+ display: flex;
+ align-items: flex-end;
+ gap: 8px;
+ min-width: 0;
+ margin: 0;
+ padding: 0;
+ border: 0;
+}
+#target-bar {
+ flex: 1 1 360px;
+ max-width: 640px;
+ align-items: flex-end;
+ flex-wrap: wrap;
+}
+.target-input {
+ flex: 1 1 280px;
+ min-width: 220px;
+}
+#action-bar {
+ flex: 0 1 auto;
+ align-items: center;
+ flex-wrap: wrap;
+ margin-left: auto;
+}
+#target-bar > output {
+ flex-basis: 100%;
+ min-width: 0;
+ overflow-wrap: anywhere;
+}
+#target-bar > output:empty {
+ display: none;
+}
+.run-actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding-left: 12px;
+ border-left: 1px solid var(--border);
+}
+#target-bar label {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+.playground-segmented {
+ flex: none;
+}
+.playground-segmented > div {
+ border-radius: 8px;
+ box-shadow: none;
+}
+.playground-segmented [role="tablist"],
+.playground-options {
+ display: flex;
+ width: max-content;
+ max-width: 100%;
+ gap: 2px;
+ padding: 3px;
+ background: var(--color-kumo-fill);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+}
+.playground-segmented [role="tablist"] {
+ align-items: stretch;
+ height: 34px;
+ overflow-x: auto;
+ white-space: nowrap;
+}
+.playground-segmented [role="tab"],
+.playground-options button {
+ min-height: 26px;
+ margin: 0;
+ padding: 4px 12px;
+ background: transparent;
+ border-radius: 5px;
+ color: var(--text-muted);
+ font-size: 12px;
+ font-weight: 600;
+}
+.playground-segmented [role="tab"][aria-selected="true"],
+.playground-options button[aria-pressed="true"] {
+ background: var(--accent);
+ color: var(--on-accent);
+ box-shadow: 0 1px 2px color-mix(in srgb, var(--text) 20%, transparent);
+}
+.playground-segmented [role="tab"][aria-selected="false"]:hover,
+.playground-options button[aria-pressed="false"]:hover {
+ background: var(--surface-hover);
+ color: var(--text);
+}
+.playground-segmented [role="presentation"] {
+ display: none;
+}
+.playground-options {
+ margin: 0;
+}
+.playground-options button {
+ border: 0;
+ cursor: pointer;
+}
+
+#setup-panel {
+ position: relative;
+ flex: 0 0 auto;
+ margin-left: 0;
+}
+#setup-panel summary {
+ padding: 6px 0;
+ cursor: pointer;
+ font-size: 13px;
+ color: var(--text-muted);
+ white-space: nowrap;
+}
+#setup-logs {
+ position: absolute;
+ top: calc(100% + 8px);
+ right: 0;
+ z-index: 30;
+ width: min(720px, calc(100vw - 40px));
+ margin: 0;
+ padding: 12px;
+ max-height: 200px;
+ overflow: auto;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ box-shadow: 0 8px 24px color-mix(in srgb, var(--text) 18%, transparent);
+ font-family: "SF Mono", Monaco, Consolas, monospace;
+ font-size: 12px;
+ white-space: pre-wrap;
+ color: var(--text-muted);
+}
+
+main {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 1px;
+ background: var(--border);
+ min-height: 60vh;
+}
+@media (max-width: 900px) {
+ main {
+ grid-template-columns: 1fr;
+ }
+}
+@media (min-width: 601px) and (max-width: 1100px) {
+ #target-bar {
+ order: 1;
+ max-width: none;
+ }
+ #action-bar {
+ order: 2;
+ flex-basis: auto;
+ }
+ .setup-label {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+ }
+}
+section {
+ background: var(--bg);
+ padding: 16px 20px;
+}
+#json-container {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+.panel-head {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ margin-bottom: 14px;
+}
+.panel-head h2 {
+ font-size: 13px;
+ margin: 0;
+ font-weight: 600;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+.response-title {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+/* Inputs */
+input[type="text"],
+input[type="password"],
+input[type="url"],
+input[type="number"],
+select,
+textarea {
+ width: 100%;
+ padding: 6px 10px;
+ background: var(--color-kumo-control);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ color: var(--text);
+ font-size: 13px;
+ font-family: inherit;
+}
+textarea {
+ resize: vertical;
+}
+input:focus,
+select:focus,
+textarea:focus {
+ outline: none;
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 25%, transparent);
+}
+input[type="checkbox"] {
+ width: auto;
+ margin-right: 6px;
+}
+input[type="file"] {
+ font-size: 12px;
+ color: var(--text-muted);
+ width: auto;
+}
+input[type="file"]::file-selector-button {
+ padding: 4px 10px;
+ background: var(--surface-hover);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ color: var(--text);
+ cursor: pointer;
+ font-size: 12px;
+ margin-right: 8px;
+}
+
+.field {
+ margin-bottom: 14px;
+}
+.field label {
+ display: block;
+ font-size: 13px;
+ font-weight: 500;
+ margin-bottom: 4px;
+}
+.field label .req {
+ color: var(--red);
+}
+.field .desc {
+ display: block;
+ font-size: 12px;
+ color: var(--text-muted);
+ margin-bottom: 4px;
+}
+.deprecated-tag {
+ color: var(--yellow);
+ font-weight: 400;
+ font-size: 12px;
+}
+.field-error {
+ display: block;
+ color: var(--red);
+ font-size: 12px;
+ margin-top: 4px;
+}
+.field-errors {
+ margin: 4px 0 0;
+ padding-left: 18px;
+ color: var(--red);
+ font-size: 12px;
+}
+.validation-summary {
+ margin: 6px 0 0;
+ padding-left: 18px;
+}
+
+.file-widget {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 6px;
+}
+.file-widget .file-name {
+ font-size: 12px;
+ color: var(--green);
+}
+
+#webhook-options {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 8px 20px;
+ background: var(--surface);
+ border-bottom: 1px solid var(--border);
+ flex-wrap: wrap;
+ margin: 0;
+}
+.webhook-title {
+ color: var(--text-muted);
+ font-size: 12px;
+ font-weight: 600;
+}
+#webhook-options label {
+ font-size: 12px;
+ color: var(--text-muted);
+ display: flex;
+ align-items: center;
+}
+.json-editor {
+ position: relative;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ width: 100%;
+ min-height: 120px;
+ overflow: hidden;
+}
+.json-editor:focus-within {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 25%, transparent);
+}
+.json-editor > div:first-child,
+.json-editor .cm-editor {
+ width: 100%;
+ height: 100%;
+}
+.editor-copy {
+ position: absolute;
+ top: 6px;
+ right: 14px;
+ z-index: 6;
+ padding: 2px 8px;
+ font-size: 11px;
+ font-weight: 500;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ color: var(--text-muted);
+ cursor: pointer;
+ opacity: 0;
+ transition: opacity 0.12s ease;
+}
+.json-editor:hover .editor-copy {
+ opacity: 0.95;
+}
+.editor-copy:focus-visible {
+ opacity: 0.95;
+}
+.editor-copy:hover {
+ color: var(--text);
+ background: var(--surface-hover);
+}
+.response-editor {
+ min-height: 80px;
+}
+.json-input {
+ height: min(55vh, 520px);
+ min-height: 280px;
+}
+.viewer-inspector {
+ min-height: 80px;
+}
+.viewer-timeline {
+ margin-top: 6px;
+}
+.json-field {
+ height: 140px;
+}
+.metrics-table {
+ border-collapse: collapse;
+ font-size: 12px;
+ width: 100%;
+}
+.metrics-table td,
+.metrics-table th {
+ padding: 3px 10px 3px 0;
+ border-bottom: 1px solid var(--border);
+ font-weight: normal;
+ text-align: left;
+}
+.metrics-table th {
+ color: var(--text-muted);
+}
+
+.notice {
+ display: block;
+ background: var(--color-kumo-warning-tint);
+ border: 1px solid var(--color-kumo-warning);
+ border-radius: 6px;
+ padding: 8px 12px;
+ font-size: 13px;
+ color: var(--text-color-kumo-warning);
+ margin-bottom: 12px;
+}
+.error-container {
+ background: var(--color-kumo-danger-tint);
+ border: 1px solid var(--color-kumo-danger);
+ border-radius: 6px;
+ padding: 10px 14px;
+ margin-bottom: 12px;
+ font-size: 13px;
+ color: var(--text-color-kumo-danger);
+}
+
+.field-heading {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin-bottom: 4px;
+}
+.field-heading label {
+ margin: 0;
+}
+.field-disabled {
+ opacity: 0.55;
+ pointer-events: none;
+}
+.input-media {
+ display: block;
+ max-width: 100%;
+ max-height: 240px;
+ margin-top: 8px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+}
+.empty-output {
+ padding: 32px 16px;
+ border: 1px dashed var(--border);
+ border-radius: 6px;
+ color: var(--text-muted);
+ text-align: center;
+}
+.live-output {
+ min-height: 280px;
+ max-height: 60vh;
+ overflow: auto;
+ padding: 16px;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+}
+.live-output .empty-output {
+ border: 0;
+}
+.live-output .metrics-table {
+ margin-bottom: 14px;
+}
+.text-output {
+ min-height: 120px;
+ margin: 0;
+ padding: 0;
+ background: transparent;
+ border: 0;
+ font-family: inherit;
+ font-size: 14px;
+ line-height: 1.7;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+.inspector-logs {
+ min-height: 220px;
+ max-height: 60vh;
+ margin: 0;
+ overflow: auto;
+ padding: 12px;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ font-family: "SF Mono", Monaco, Consolas, monospace;
+ font-size: 12px;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+.request-summary {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px;
+ margin: 0 0 16px;
+}
+.request-summary > div {
+ min-width: 0;
+ padding: 10px;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+}
+.request-summary dt {
+ color: var(--text-muted);
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+.request-summary dd {
+ margin: 4px 0 0;
+ overflow-wrap: anywhere;
+ font-family: "SF Mono", Monaco, Consolas, monospace;
+ font-size: 12px;
+}
+.method-badge {
+ display: inline-block;
+ padding: 1px 5px;
+ background: var(--color-kumo-info-tint);
+ border-radius: 4px;
+ color: var(--text-color-kumo-info);
+ font-size: 10px;
+ font-weight: 700;
+}
+.inspector-document {
+ margin-bottom: 10px;
+}
+.inspector-document > summary {
+ margin-bottom: 6px;
+ color: var(--text-muted);
+ cursor: pointer;
+ font-size: 12px;
+ font-weight: 600;
+}
+.trace-timeline {
+ --trace-background: color-mix(in srgb, var(--accent) 12%, transparent);
+ --trace-color: var(--accent);
+
+ position: relative;
+ max-height: 60vh;
+ margin: 0;
+ padding: 2px 8px 2px 92px;
+ overflow: auto;
+ list-style: none;
+}
+.trace-timeline::before {
+ position: absolute;
+ top: 8px;
+ bottom: 8px;
+ left: 70px;
+ width: 1px;
+ background: var(--border);
+ content: "";
+}
+.trace-timeline > li {
+ position: relative;
+ display: grid;
+ grid-template-columns: 70px minmax(0, 1fr);
+ gap: 8px;
+ min-height: 42px;
+ padding: 0 0 14px;
+}
+.trace-timeline > li::before {
+ position: absolute;
+ top: 5px;
+ left: -26px;
+ width: 9px;
+ height: 9px;
+ background: var(--trace-color);
+ border: 2px solid var(--bg);
+ border-radius: 50%;
+ box-shadow: 0 0 0 1px var(--border);
+ content: "";
+}
+.trace-timeline time {
+ position: absolute;
+ top: 1px;
+ left: -92px;
+ width: 58px;
+ color: var(--text-muted);
+ font-family: "SF Mono", Monaco, Consolas, monospace;
+ font-size: 11px;
+ text-align: right;
+}
+.trace-kind {
+ width: fit-content;
+ height: fit-content;
+ padding: 1px 5px;
+ background: var(--trace-background);
+ border-radius: 4px;
+ color: var(--trace-color);
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.03em;
+ text-transform: uppercase;
+}
+.trace-timeline strong {
+ font-size: 12px;
+ font-weight: 600;
+}
+.trace-timeline details {
+ margin-top: 4px;
+}
+.trace-timeline summary {
+ color: var(--text-muted);
+ cursor: pointer;
+ font-size: 11px;
+}
+.trace-request {
+ --trace-background: var(--color-kumo-info-tint);
+ --trace-color: var(--text-color-kumo-info);
+}
+.trace-response {
+ --trace-background: var(--color-kumo-success-tint);
+ --trace-color: var(--green);
+}
+.trace-webhook {
+ --trace-background: var(--color-kumo-warning-tint);
+ --trace-color: var(--yellow);
+}
+.trace-cancel {
+ --trace-background: var(--surface-hover);
+ --trace-color: var(--text-muted);
+}
+.trace-error {
+ --trace-background: var(--color-kumo-danger-tint);
+ --trace-color: var(--red);
+}
+
+@media (max-width: 600px) {
+ header {
+ display: grid;
+ grid-template-columns: auto auto 1fr auto auto;
+ padding: 8px 12px;
+ }
+ header .spacer {
+ display: none;
+ }
+ header > .muted {
+ grid-row: 2;
+ grid-column: 1 / 5;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ header > #setup-panel {
+ grid-row: 2;
+ grid-column: 5;
+ justify-self: end;
+ }
+ header > button:first-of-type {
+ grid-column: 4;
+ }
+ header > button:last-of-type {
+ grid-column: 5;
+ }
+ #playground-toolbar,
+ #webhook-options {
+ padding-inline: 12px;
+ }
+ #playground-toolbar {
+ gap: 12px;
+ }
+ section {
+ padding-inline: 12px;
+ }
+ #playground-toolbar {
+ position: relative;
+ top: auto;
+ }
+ #target-bar,
+ #action-bar {
+ flex: 1 1 100%;
+ }
+ #action-bar {
+ margin-left: 0;
+ }
+ #target-bar {
+ max-width: none;
+ }
+ .target-input {
+ flex-basis: 190px;
+ min-width: 190px;
+ }
+ .playground-options button {
+ padding-inline: 9px;
+ }
+ #action-bar > input {
+ flex: 1 1 130px;
+ width: auto;
+ min-width: 130px;
+ }
+ .setup-label {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+ }
+ #setup-logs {
+ right: -4px;
+ width: calc(100vw - 24px);
+ }
+ .panel-head > * {
+ min-width: 0;
+ }
+ .playground-segmented {
+ max-width: 100%;
+ }
+}
+@media (max-width: 360px) {
+ header {
+ grid-template-columns: auto 1fr auto auto;
+ }
+ header > output {
+ grid-row: 2;
+ grid-column: 1;
+ }
+ header > .muted {
+ grid-row: 3;
+ grid-column: 1 / -1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ header > #setup-panel {
+ grid-row: 2;
+ grid-column: 3 / 5;
+ justify-self: end;
+ }
+ header > button:first-of-type {
+ grid-column: 3;
+ }
+ header > button:last-of-type {
+ grid-column: 4;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .live-output {
+ scroll-behavior: auto;
+ }
+ .streaming-cursor {
+ animation: none;
+ }
+}
+
+.output-item {
+ margin-bottom: 10px;
+}
+.output-item img {
+ max-width: 100%;
+ border-radius: 6px;
+ border: 1px solid var(--border);
+}
+.output-item audio,
+.output-item video {
+ width: 100%;
+ max-width: 500px;
+}
+.output-item pre {
+ margin: 0;
+ padding: 10px;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ font-family: "SF Mono", Monaco, Consolas, monospace;
+ font-size: 12px;
+ white-space: pre-wrap;
+ word-break: break-word;
+ overflow-x: auto;
+}
+.output-item a {
+ color: var(--text-color-kumo-link);
+ font-size: 13px;
+}
+.streaming-cursor {
+ display: inline-block;
+ width: 8px;
+ height: 14px;
+ background: var(--accent);
+ animation: blink 1s step-end infinite;
+ vertical-align: text-bottom;
+ margin-left: 2px;
+}
+@keyframes blink {
+ 50% {
+ opacity: 0;
+ }
+}
diff --git a/playground/src/test/FakeJsonEditor.tsx b/playground/src/test/FakeJsonEditor.tsx
new file mode 100644
index 0000000000..6604130a0c
--- /dev/null
+++ b/playground/src/test/FakeJsonEditor.tsx
@@ -0,0 +1,39 @@
+import type { JsonEditorProps } from "@/components/editor/JsonEditor";
+
+export function FakeJsonEditor({
+ label,
+ onChange,
+ value,
+ describedBy,
+ disabled,
+ invalid,
+ readOnly,
+ autoHeight,
+ followTail,
+ active,
+}: JsonEditorProps) {
+ if (onChange) {
+ return (
+