diff --git a/clients/web/package-lock.json b/clients/web/package-lock.json
index b92eff57..e4233a49 100644
--- a/clients/web/package-lock.json
+++ b/clients/web/package-lock.json
@@ -16,6 +16,7 @@
"@mantine/hooks": "^8.3.17",
"@mantine/notifications": "^8.3.17",
"@napi-rs/keyring": "^1.3.0",
+ "ace-builds": "^1.44.0",
"ajv": "^8.17.1",
"atomically": "^2.1.1",
"chokidar": "^4.0.3",
@@ -25,6 +26,7 @@
"papaparse": "^5.5.4",
"pino": "^9.14.0",
"react": "^19.2.4",
+ "react-ace": "^15.0.0",
"react-dom": "^19.2.4",
"react-icons": "^5.6.0",
"react-markdown": "^10.1.0",
@@ -4383,6 +4385,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/ace-builds": {
+ "version": "1.44.0",
+ "resolved": "https://registry.npmjs.org/ace-builds/-/ace-builds-1.44.0.tgz",
+ "integrity": "sha512-PFNMSYqFdEUkul2Ntud0HvA09AgY+F1ag0UYdpMH60wNI/qOA8cB8tlTgoALMEwIdUPJK2CjrIQ7OnbiSS/ugQ==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/acorn": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
@@ -5061,6 +5069,12 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/diff-match-patch": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz",
+ "integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==",
+ "license": "Apache-2.0"
+ },
"node_modules/doctrine": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
@@ -5487,6 +5501,15 @@
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
+ "node_modules/fast-equals": {
+ "version": "5.4.1",
+ "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.1.tgz",
+ "integrity": "sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@@ -8294,6 +8317,22 @@
"node": ">=0.10.0"
}
},
+ "node_modules/react-ace": {
+ "version": "15.0.0",
+ "resolved": "https://registry.npmjs.org/react-ace/-/react-ace-15.0.0.tgz",
+ "integrity": "sha512-gdmS5ftF0hsbkcrAjZQqYbXwFg5JrHuxjki8mP6Bn9kfa6lHKfZk9sU9EfS0ifQ1CpGCmRxF/VC7GRvlJMBuZw==",
+ "license": "MIT",
+ "dependencies": {
+ "ace-builds": "^1.36.3",
+ "diff-match-patch": "^1.0.5",
+ "fast-equals": "^5.3.3",
+ "prop-types": "^15.8.1"
+ },
+ "peerDependencies": {
+ "react": "^0.13.0 || ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^0.13.0 || ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/react-docgen": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-8.0.3.tgz",
diff --git a/clients/web/package.json b/clients/web/package.json
index 451c0767..8696fb25 100644
--- a/clients/web/package.json
+++ b/clients/web/package.json
@@ -42,6 +42,7 @@
"@mantine/hooks": "^8.3.17",
"@mantine/notifications": "^8.3.17",
"@napi-rs/keyring": "^1.3.0",
+ "ace-builds": "^1.44.0",
"ajv": "^8.17.1",
"atomically": "^2.1.1",
"chokidar": "^4.0.3",
@@ -51,6 +52,7 @@
"papaparse": "^5.5.4",
"pino": "^9.14.0",
"react": "^19.2.4",
+ "react-ace": "^15.0.0",
"react-dom": "^19.2.4",
"react-icons": "^5.6.0",
"react-markdown": "^10.1.0",
diff --git a/clients/web/src/App.css b/clients/web/src/App.css
index b208285c..a32c6435 100644
--- a/clients/web/src/App.css
+++ b/clients/web/src/App.css
@@ -73,6 +73,16 @@
ScrollArea would break flex sizing, e.g. the Resources accordion) blend in. */
--inspector-scrollbar-thumb: rgba(0, 0, 0, 0.4);
+ /* ── Code editor (Ace) ─────────────────────────────────── */
+ /* Ace's own `github` themes fail WCAG AA on the gutter: `#AAA` on the light
+ gutter's `#e8e8e8` is 1.89:1, and `#7388b5` on the dark gutter's `#24292e`
+ is 4.13:1 — both under 4.5:1 at the 12px the line numbers render. The
+ numbers are `aria-hidden`, which exempts nothing here: contrast is a
+ concern for sighted users, and axe flags them accordingly.
+ `gray-7` is 5.82:1 on the light gutter; the dark block lifts it to
+ `gray-5`, 4.88:1 on the dark one. */
+ --inspector-code-gutter-text: var(--mantine-color-gray-7);
+
/* ── Connection status ─────────────────────────────────── */
--inspector-status-connected: var(--mantine-color-green-6);
--inspector-status-connecting: var(--mantine-color-yellow-6);
@@ -161,6 +171,9 @@
schemes, which sits too dark on charcoal — lift them one to two steps so
they stay legible in dark mode. Light-mode `:root` keeps the `*-6` values. */
+ /* Code editor (Ace) — see the note in `:root`. */
+ --inspector-code-gutter-text: var(--mantine-color-gray-5);
+
/* Status */
--inspector-status-connected: var(--mantine-color-green-4);
--inspector-status-connecting: var(--mantine-color-yellow-4);
@@ -593,3 +606,12 @@ body.resizing-col {
user-select: none;
cursor: col-resize;
}
+
+/* Ace renders its own DOM, so its gutter can only be reached by selector — it
+ is not a Mantine component with a theme entry. The doubled class raises
+ specificity above Ace's own `.ace-github .ace_gutter`, which is injected into
+
at runtime and therefore wins on source order otherwise. */
+.ace_editor.ace-github .ace_gutter,
+.ace_editor.ace-github-dark .ace_gutter {
+ color: var(--inspector-code-gutter-text);
+}
diff --git a/clients/web/src/components/elements/JsonObjectInput/JsonObjectInput.stories.tsx b/clients/web/src/components/elements/JsonObjectInput/JsonObjectInput.stories.tsx
index 51405384..cdbcfc0f 100644
--- a/clients/web/src/components/elements/JsonObjectInput/JsonObjectInput.stories.tsx
+++ b/clients/web/src/components/elements/JsonObjectInput/JsonObjectInput.stories.tsx
@@ -4,17 +4,23 @@ import { expect, userEvent, within } from "storybook/test";
import type { JsonObject } from "@inspector/core/json/jsonUtils.js";
import { JsonObjectInput } from "./JsonObjectInput";
+const LABEL = "Request metadata JSON";
+
/**
* The component is controlled, so every story drives it through a small
- * stateful wrapper — otherwise the box would not accept a keystroke and the
+ * stateful wrapper — otherwise the editor would not accept a keystroke and the
* play functions could not exercise the draft behavior at all.
+ *
+ * These stories run in real Chromium, which is what makes them the *only*
+ * place Ace's keyboard behavior can be tested: the unit suite runs in
+ * happy-dom, where Ace's hidden-textarea input path does not work at all.
*/
function ControlledJsonObjectInput({ initial }: { initial: JsonObject }) {
const [value, setValue] = useState(initial);
return (
@@ -29,6 +35,57 @@ const meta: Meta = {
export default meta;
type Story = StoryObj;
+/** The live editor instance, which Ace hangs off its container as `env.editor`. */
+function aceEditor(canvasElement: HTMLElement): {
+ focus(): void;
+ selectAll(): void;
+ getValue(): string;
+} {
+ const container = canvasElement.querySelector(".ace_editor");
+ const editor = (
+ container as
+ | (HTMLElement & {
+ env?: {
+ editor?: {
+ focus(): void;
+ selectAll(): void;
+ getValue(): string;
+ };
+ };
+ })
+ | null
+ )?.env?.editor;
+ if (!editor) throw new Error("Ace editor not mounted");
+ return editor;
+}
+
+/**
+ * Focus the editor and empty it, so the next keystrokes are the whole document
+ * and the typing itself — the thing under test — is left to `userEvent`.
+ *
+ * Emptied rather than merely selected: typing an opening brace or quote with a
+ * selection makes Ace **wrap** the selection instead of replacing it, so typing
+ * `{` over a selected `{}` yields `{{}}` and the assertion stops being about
+ * auto-closing at all.
+ *
+ * Focus and select go through Ace's API on purpose. `userEvent.click` does not
+ * focus the editor — Ace's input is a 1px offscreen textarea, so the click
+ * lands nowhere and the keystrokes reach the body instead. And select-all is
+ * not `Control+A` everywhere (Ace binds `Cmd-A` on mac), so pressing it would
+ * make these stories pass or fail by host OS.
+ */
+async function focusAndClear(canvasElement: HTMLElement): Promise {
+ const editor = aceEditor(canvasElement);
+ editor.focus();
+ editor.selectAll();
+ await userEvent.keyboard("{Backspace}");
+}
+
+/** Ace renders each token in its own element, so read the document, not text. */
+function editorText(canvasElement: HTMLElement): string {
+ return aceEditor(canvasElement).getValue();
+}
+
/** A structured payload renders as formatted JSON, nesting intact. */
export const Populated: Story = {
args: {
@@ -39,9 +96,7 @@ export const Populated: Story = {
},
},
play: async ({ canvasElement }) => {
- const canvas = within(canvasElement);
- const box = await canvas.findByLabelText("Request metadata JSON");
- await expect(JSON.parse((box as HTMLTextAreaElement).value)).toEqual({
+ await expect(JSON.parse(editorText(canvasElement))).toEqual({
tenant: "acme",
trace: { id: "abc123", sampled: true },
features: ["apps", "tasks"],
@@ -54,8 +109,36 @@ export const Empty: Story = {
args: { initial: {} },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
- const box = await canvas.findByLabelText("Request metadata JSON");
- await expect(box).toHaveValue("{}");
+ await expect(editorText(canvasElement)).toBe("{}");
+ await expect(canvas.queryByText(/Not valid JSON/)).toBeNull();
+ },
+};
+
+/**
+ * Opening a brace closes it — one of the two reasons this is an editor rather
+ * than a textarea, and the thing that makes hand-writing a nested `_meta`
+ * payload bearable.
+ *
+ * Deliberately one keystroke: Ace also auto-pairs quotes and skips over a
+ * closing character you type yourself, so a longer sequence asserts the sum of
+ * several behaviours and stops being a readable statement about this one.
+ */
+export const ClosesBracesWhileTyping: Story = {
+ args: { initial: {} },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await focusAndClear(canvasElement);
+ // `{{` is userEvent's escape for a literal `{`.
+ await userEvent.keyboard("{{");
+ // Ace does not close it on the keystroke — an opening brace at the end of a
+ // line is often the start of a block the user is about to fill in.
+ await expect(editorText(canvasElement)).toBe("{");
+ // Enter is what completes the pair, leaving the cursor indented between
+ // them. This is the behaviour that makes writing nested `_meta` by hand
+ // bearable, and the reason this is an editor rather than a textarea.
+ await userEvent.keyboard("{Enter}");
+ await expect(editorText(canvasElement)).toBe("{\n \n}");
+ // The result parses, so nothing is flagged.
await expect(canvas.queryByText(/Not valid JSON/)).toBeNull();
},
};
@@ -69,22 +152,31 @@ export const InvalidDraft: Story = {
args: { initial: {} },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
- const box = await canvas.findByLabelText("Request metadata JSON");
- await userEvent.clear(box);
- await userEvent.type(box, '{{"a":');
- await expect(box).toHaveValue('{"a":');
+ await focusAndClear(canvasElement);
+ // Genuinely half-typed — the state a user is in partway through writing an
+ // entry, rather than a contrived bad string.
+ await userEvent.keyboard('{{"a":');
+ // Shown exactly as typed. The old textarea re-escaped its own contents on
+ // every keystroke here, which is what made it unusable (#1928).
+ await expect(editorText(canvasElement)).toBe('{"a":');
await expect(await canvas.findByText(/Not valid JSON/)).toBeVisible();
},
};
-/** Valid JSON that is not an object is rejected with its own message. */
+/**
+ * Valid JSON that is not an object is rejected with its own message, distinct
+ * from unparseable text.
+ *
+ * A bare number rather than an array: brackets are auto-paired too, so typing
+ * one would test the pairing as much as the rejection.
+ */
export const NotAnObject: Story = {
args: { initial: {} },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
- const box = await canvas.findByLabelText("Request metadata JSON");
- await userEvent.clear(box);
- await userEvent.type(box, "[[1,2]");
+ await focusAndClear(canvasElement);
+ await userEvent.keyboard("42");
+ await expect(editorText(canvasElement)).toBe("42");
await expect(
await canvas.findByText(/Must be a JSON object/),
).toBeVisible();
diff --git a/clients/web/src/components/elements/JsonObjectInput/JsonObjectInput.test.tsx b/clients/web/src/components/elements/JsonObjectInput/JsonObjectInput.test.tsx
index b1da0ffb..e805e8d4 100644
--- a/clients/web/src/components/elements/JsonObjectInput/JsonObjectInput.test.tsx
+++ b/clients/web/src/components/elements/JsonObjectInput/JsonObjectInput.test.tsx
@@ -1,9 +1,9 @@
import { useState } from "react";
import { describe, it, expect, vi } from "vitest";
-import { screen } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
+import { act, screen } from "@testing-library/react";
import type { JsonObject } from "@inspector/core/json/jsonUtils.js";
import { renderWithMantine } from "../../../test/renderWithMantine";
+import { aceLabel, getAceText, setAceText } from "../../../test/aceEditor";
import { JsonObjectInput } from "./JsonObjectInput";
const LABEL = "Payload JSON";
@@ -20,7 +20,7 @@ function Harness({
return (
<>
{
setValue(next);
@@ -32,38 +32,60 @@ function Harness({
);
}
-function box() {
- return screen.getByLabelText(LABEL) as HTMLTextAreaElement;
-}
-
describe("JsonObjectInput", () => {
+ it("names the editor for assistive tech", () => {
+ renderWithMantine();
+ // Ace composes "