feat(web): use the Ace editor for the request-metadata JSON

A textarea is a poor place to hand-write a nested `_meta` payload. Ace closes a
brace when you open one, folds nested objects, and — through its JSON worker —
marks the offending line in the gutter instead of reporting one message for the
whole document.

Three things the swap turned up:

- Ace fires *two* change events for a replace (a remove, then an insert), so
  select-all-and-retype briefly leaves an empty document. Acting on the first
  emitted `{}` and wiped the user's saved metadata the instant they selected
  it. `handleChange` now coalesces the pair in a microtask, and the component
  tracks what it last emitted so the parent echoing that value back is not
  mistaken for an external reset.

- Ace's own `github` themes fail WCAG AA on the gutter — `#AAA` on `#e8e8e8` is
  1.89:1, and the dark theme is 4.13:1. Folding needs the gutter, so the colors
  are overridden with palette tokens (5.82:1 and 4.88:1) rather than hiding it.

- `textInputAriaLabel` alone never reaches the DOM: Ace composes the label as
  "<label>, Cursor at row N" and only recomputes it when the cursor moves, so
  it is forced once on load. Until then the control's only accessible name is a
  position readout.

Testing is split because Ace's input path does not work under happy-dom at all
— `userEvent.type` reaches the textarea and produces no edit, so a test written
that way asserts nothing. Unit tests drive the editor instance through a shared
`src/test/aceEditor.ts` helper; the real keyboard behaviour (`{` then Enter
giving `{\n  \n}`) is asserted by Storybook play functions in Chromium.

`react-ace`/`ace-builds` are declared in `clients/web` rather than the root, per
the rule for a dependency that renders React components.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-23 16:54:58 -04:00
commit ed799a0c0b

View file

@ -0,0 +1,58 @@
import { act } from "@testing-library/react";
import type { Ace } from "ace-builds";
/**
* Reach the live Ace editor a component mounted, and drive it.
*
* Typing is deliberately **not** simulated. Ace reads keystrokes through a 1px
* offscreen textarea plus selection and composition state that happy-dom does
* not implement, so `userEvent.type` reaches the element and produces no edit
* at all — a test written that way asserts nothing while looking like it
* asserts a lot. Real keyboard behaviour (auto-closing a brace on Enter, and
* the draft surviving invalid text) is covered by the `JsonObjectInput`
* Storybook play functions, which run in Chromium.
*
* Ace hangs the instance off its container element as `env.editor`; that is the
* supported way back to it from the DOM.
*/
export function getAceEditor(container: ParentNode = document): Ace.Editor {
const node = container.querySelector(".ace_editor");
const editor = (
node as (HTMLElement & { env?: { editor?: Ace.Editor } }) | null
)?.env?.editor;
if (!editor) throw new Error("Ace editor not mounted");
return editor;
}
/** The document the editor currently holds. */
export function getAceText(container?: ParentNode): string {
return getAceEditor(container).getValue();
}
/**
* Replace the document the way a select-all-and-retype would, and let React
* flush.
*
* Async because `JsonObjectInput` coalesces the paired remove/insert events
* Ace fires for a replace; an `await act` is what drains that microtask.
*/
export async function setAceText(
text: string,
container?: ParentNode,
): Promise<void> {
await act(async () => {
// `1` parks the cursor at the end, matching where typing would leave it.
getAceEditor(container).setValue(text, 1);
});
}
/**
* Matcher for an Ace-labelled control.
*
* Ace composes its text input's `aria-label` as `"<label>, Cursor at row N"`,
* so an exact-string `getByLabelText` never matches — the position readout is
* Ace's, only the prefix is ours.
*/
export function aceLabel(label: string): RegExp {
return new RegExp(label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
}