diff --git a/playground/src/components/SegmentedTabs.test.tsx b/playground/src/components/SegmentedTabs.test.tsx new file mode 100644 index 0000000000..4b2a81f13b --- /dev/null +++ b/playground/src/components/SegmentedTabs.test.tsx @@ -0,0 +1,33 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { SegmentedTabs } from "@/components/SegmentedTabs"; + +describe("SegmentedTabs", () => { + it("reports the selected Kumo tab", () => { + const onChange = vi.fn(); + render( + , + ); + + const form = screen.getByRole("tab", { name: "Form" }); + const json = screen.getByRole("tab", { name: "JSON" }); + expect(screen.getByRole("tablist", { name: "Modes" })).toBeVisible(); + expect(form).toHaveAttribute("aria-selected", "true"); + expect(form).toHaveAttribute("id", "modes-tab-form"); + expect(form).toHaveAttribute("aria-controls", "modes-panel-form"); + expect(json).toHaveAttribute("aria-selected", "false"); + + fireEvent.click(json); + expect(onChange).toHaveBeenCalledWith("json"); + }); +}); diff --git a/playground/src/components/SegmentedTabs.tsx b/playground/src/components/SegmentedTabs.tsx new file mode 100644 index 0000000000..17c49d7993 --- /dev/null +++ b/playground/src/components/SegmentedTabs.tsx @@ -0,0 +1,65 @@ +import { Tabs } from "@cloudflare/kumo/components/tabs"; +import { useEffect, useRef } from "react"; + +type TabItem = { + value: Value; + label: string; +}; + +type Props = { + id: string; + label: string; + items: readonly TabItem[]; + value: Value; + onChange: (value: Value) => void; +}; + +/** Generates the ID referenced by a matching panel's `aria-labelledby`. */ +export function tabId(id: string, value: string): string { + return `${id}-tab-${value}`; +} + +/** Generates the panel ID referenced by a matching tab's `aria-controls`. */ +export function tabPanelId(id: string, value: string): string { + return `${id}-panel-${value}`; +} + +/** Adds stable ARIA tab/panel relationships around Kumo's controlled tabs. */ +export function SegmentedTabs({ + id, + label, + items, + value, + onChange, +}: Props) { + const root = useRef(null); + useEffect(() => { + root.current?.querySelector('[role="tablist"]')?.setAttribute("aria-label", label); + }, [label]); + + return ( +
+ ({ + ...item, + render: ( +
+ ); +} 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 ( + + + {normalized} + + + ); +} 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 ( +