@elabs-ai/components-editor 4.0.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +6 -7
  2. package/dist/{chunk-LBC5VJBD.js → chunk-C62O7IOQ.js} +110 -21
  3. package/dist/chunk-C62O7IOQ.js.map +1 -0
  4. package/dist/index.css +4 -2
  5. package/dist/index.css.map +1 -1
  6. package/dist/index.d.ts +2 -2
  7. package/dist/index.js +57 -20
  8. package/dist/index.js.map +1 -1
  9. package/dist/markdown/index.css +4 -2
  10. package/dist/markdown/index.css.map +1 -1
  11. package/dist/markdown/index.d.ts +2 -2
  12. package/dist/markdown/index.js +9 -9
  13. package/dist/markdown/index.js.map +1 -1
  14. package/dist/{markdown-editor-DfBZibAn.d.ts → markdown-editor-CBp_4eDv.d.ts} +1 -1
  15. package/package.json +6 -6
  16. package/src/ai-objects/entity.tsx +1 -1
  17. package/src/ai-objects/knowledge-card.tsx +1 -1
  18. package/src/calc-block/calc-editor.css +1 -1
  19. package/src/code-editor/code-editor.stories.tsx +76 -1
  20. package/src/code-editor/code-editor.tsx +1 -1
  21. package/src/code-workspace/code-workspace.test.tsx +83 -0
  22. package/src/code-workspace/code-workspace.tsx +73 -20
  23. package/src/diff-editor/diff-editor.stories.tsx +9 -1
  24. package/src/lib/monaco-deep-imports.d.ts +59 -0
  25. package/src/lib/monaco-theme-bridge.test.ts +307 -0
  26. package/src/lib/monaco-theme-bridge.ts +154 -10
  27. package/src/markdown-academic/citations.tsx +2 -2
  28. package/src/markdown-academic/footnotes.tsx +2 -2
  29. package/src/markdown-academic/toc.tsx +1 -1
  30. package/src/markdown-editor/directive-views.tsx +2 -5
  31. package/src/markdown-editor/markdown-editor.css +26 -3
  32. package/src/markdown-editor/markdown-editor.focus.test.ts +49 -0
  33. package/src/markdown-editor/markdown-editor.stories.tsx +128 -7
  34. package/src/markdown-editor/markdown-editor.tsx +8 -5
  35. package/src/markdown-editor/milkdown-react/use-get-editor.timer-leak.test.ts +80 -0
  36. package/src/markdown-editor/milkdown-react/use-get-editor.ts +37 -1
  37. package/src/markdown-editor/paste-embed.ts +2 -1
  38. package/src/markdown-editor/slash/slash-menu.stories.tsx +8 -3
  39. package/src/markdown-editor/slash/slash-menu.test.tsx +27 -0
  40. package/src/markdown-editor/slash/slash-menu.tsx +13 -1
  41. package/src/markdown-editor/table-view.tsx +3 -2
  42. package/src/markdown-iteration/iteration-builder-dialog.stories.tsx +57 -19
  43. package/src/markdown-outline/document-outline.stories.tsx +1 -1
  44. package/src/markdown-outline/document-outline.tsx +1 -1
  45. package/src/markdown-preview/markdown-preview-transclusion.test.tsx +1 -1
  46. package/src/markdown-preview/markdown-preview.stories.tsx +24 -2
  47. package/src/markdown-preview/markdown-preview.test.tsx +20 -3
  48. package/src/markdown-toolbar/markdown-toolbar.stories.tsx +3 -0
  49. package/src/markdown-workspace/markdown-workspace.test.tsx +31 -1
  50. package/src/mermaid-diagram/mermaid-viewer.tsx +1 -1
  51. package/src/prose/prose.stories.tsx +3 -0
  52. package/src/prose/prose.test.ts +54 -0
  53. package/src/prose/prose.tsx +7 -0
  54. package/dist/chunk-LBC5VJBD.js.map +0 -1
@@ -62,7 +62,7 @@ interface CodeEditorProps extends Omit<HTMLAttributes<HTMLDivElement>, "onChange
62
62
  * A token-themed Monaco editor wrapped as a brand-ui React component. Monaco
63
63
  * renders its own editing surface + widgets; this wrapper owns lifecycle,
64
64
  * controlled/uncontrolled value, and applies the brand theme bridge so the
65
- * editor matches the active `data-theme` (all three themes).
65
+ * editor matches the active `data-theme` (every theme).
66
66
  *
67
67
  * Workers (for completions/diagnostics) are wired by importing
68
68
  * `@elabs-ai/components-editor/monaco-environment` once at the app entry.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elabs-ai/components-editor",
3
- "version": "4.0.0",
3
+ "version": "4.1.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -65,8 +65,8 @@
65
65
  "monaco-editor": "^0.55.1",
66
66
  "react": "^18.2.0 || ^19.0.0",
67
67
  "react-dom": "^18.2.0 || ^19.0.0",
68
- "@elabs-ai/components-tokens": "4.0.0",
69
- "@elabs-ai/components-ui": "4.0.0"
68
+ "@elabs-ai/components-tokens": "4.1.0",
69
+ "@elabs-ai/components-ui": "4.1.0"
70
70
  },
71
71
  "devDependencies": {
72
72
  "@testing-library/jest-dom": "^6.6.3",
@@ -85,10 +85,10 @@
85
85
  "tsup": "^8.3.5",
86
86
  "typescript": "^5.7.3",
87
87
  "vitest": "^3.0.2",
88
+ "@elabs-ai/components-eslint-config": "0.1.0",
89
+ "@elabs-ai/components-tokens": "4.1.0",
88
90
  "@elabs-ai/components-typescript-config": "0.1.0",
89
- "@elabs-ai/components-tokens": "4.0.0",
90
- "@elabs-ai/components-ui": "4.0.0",
91
- "@elabs-ai/components-eslint-config": "0.1.0"
91
+ "@elabs-ai/components-ui": "4.1.0"
92
92
  },
93
93
  "scripts": {
94
94
  "build": "rm -rf dist && tsup && node ../../scripts/link-dist-css.mjs",
@@ -62,7 +62,7 @@ function kindMeta(kind: string | undefined): KindMeta {
62
62
  export const entityChipVariants = cva(
63
63
  // inline-flex + align-middle keeps the chip in the text baseline;
64
64
  // no `block` wrapper so it is valid inside <p>.
65
- "inline-flex items-center gap-1 rounded-sm border px-1.5 py-0.5 text-meta font-medium align-middle focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
65
+ "inline-flex items-center gap-1 rounded-sm border px-1.5 py-0.5 text-meta font-medium align-middle focus-ring",
66
66
  {
67
67
  variants: {
68
68
  kind: {
@@ -59,7 +59,7 @@ function SourceRow({ path, resolve }: SourceRowProps) {
59
59
  rel="noopener noreferrer"
60
60
  target="_blank"
61
61
  // #399 — a source link is TEXT: on-surface `-text` rung, not the fill.
62
- className="flex min-w-0 items-center gap-1.5 text-meta text-primary-text underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
62
+ className="flex min-w-0 items-center gap-1.5 text-meta text-primary-text underline-offset-2 hover:underline focus-ring"
63
63
  aria-label={`Source: ${display}`}
64
64
  >
65
65
  <FileText className="size-3 shrink-0" aria-hidden="true" />
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * Colors come from the `--calc-*` syntax tokens — the SAME tokens `CalcBlock`
7
7
  * paints with (#221), already AA-verified against `--background` (the editor
8
- * surface) and `--card` in all three themes — so highlighting tracks the theme via
8
+ * surface) and `--card` in every theme — so highlighting tracks the theme via
9
9
  * the cascade with no hardcoded hex. The compound `.brand-calc-tok.brand-calc-tok--x`
10
10
  * selector raises specificity above Monaco's own token color class (`.mtkN`) so
11
11
  * the calc color wins regardless of stylesheet load order (no `!important`).
@@ -4,6 +4,7 @@ import { useMemo, useState } from "react";
4
4
  import * as monaco from "monaco-editor";
5
5
  import { CodeEditor } from "./code-editor";
6
6
  import { EditorToolbar } from "../editor-toolbar";
7
+ import { buildBrandThemeData } from "../lib/monaco-theme-bridge";
7
8
 
8
9
  const meta = {
9
10
  title: "Editor/CodeEditor",
@@ -14,9 +15,12 @@ const meta = {
14
15
  docs: {
15
16
  description: {
16
17
  component:
18
+ "The real Monaco EDITING surface; a read-only highlighted block is `AI/CodeBlock` " +
19
+ "and a tool-output viewer is `AI/Sandbox` — see " +
20
+ "[Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). " +
17
21
  "A token-themed Monaco editor. The editing surface + widgets are Monaco; " +
18
22
  "brand-ui supplies the chrome (toolbar/Select/Button). Colors track the " +
19
- "active `data-theme` across all three themes via the theming bridge.",
23
+ "active `data-theme` across every theme via the theming bridge.",
20
24
  },
21
25
  },
22
26
  },
@@ -76,6 +80,15 @@ export const WithToolbar: Story = {
76
80
  },
77
81
  };
78
82
 
83
+ /**
84
+ * Locks #90: Monaco's `vs`/`vs-dark` base themes ship LANGUAGE-SUFFIXED JSON
85
+ * scopes (`string.key.json`, `string.value.json`, `keyword.json`) that beat the
86
+ * bridge's shorter, unsuffixed rules in Monaco's token-theme trie (deepest scope
87
+ * wins) — so JSON rendered in stock VS Code colors instead of brand colors. The
88
+ * play function reads REAL computed colors off the rendered `.mtk*` token spans
89
+ * and asserts none of them is a stock JSON color, in whichever theme this story
90
+ * is rendered under (`globals=theme:light|dark`).
91
+ */
79
92
  export const JsonReadOnly: Story = {
80
93
  name: "JSON (read-only)",
81
94
  render: () => (
@@ -83,6 +96,68 @@ export const JsonReadOnly: Story = {
83
96
  <CodeEditor language="json" defaultValue={JSON_SAMPLE} readOnly />
84
97
  </div>
85
98
  ),
99
+ play: async ({ canvasElement }) => {
100
+ // Monaco mounts asynchronously, and syntax tokenization is a SEPARATE,
101
+ // later pass: the editor first paints one un-highlighted `.mtk*` span per
102
+ // line, then re-renders with per-scope spans once the JSON monarch
103
+ // tokenizer finishes (observed to take ~1-3s in a headless test browser).
104
+ // Waiting for "any `.mtk*` span" resolves on that FIRST, un-highlighted
105
+ // paint — a false green that passes identically whether the fix is
106
+ // present or not (#90 fix-round-1 finding). Wait for the actual `"name"`
107
+ // key to have tokenized into its own span instead — that only exists once
108
+ // real per-scope highlighting has landed.
109
+ const surface = await within(canvasElement).findByTestId("code-editor", {}, { timeout: 10000 });
110
+ let spans: HTMLSpanElement[] = [];
111
+ await waitFor(
112
+ () => {
113
+ spans = Array.from(
114
+ surface.querySelectorAll<HTMLSpanElement>('.view-line span[class*="mtk"]'),
115
+ );
116
+ const keySpan = spans.some((span) => span.textContent?.trim() === '"name"');
117
+ if (!keySpan) {
118
+ throw new Error(
119
+ `Monaco has not tokenized the "name" key into its own span yet (spans=${spans.length})`,
120
+ );
121
+ }
122
+ },
123
+ { timeout: 20000, interval: 100 },
124
+ );
125
+
126
+ // Stock `vs`/`vs-dark` colors for string.key.json / string.value.json /
127
+ // keyword.json (extracted straight from monaco-editor's own base themes —
128
+ // see monaco-theme-bridge.test.ts's "Monaco real trie resolution" block for
129
+ // the same values as raw hex). If the bridge's brand rules ever stop beating
130
+ // the base theme's suffixed scopes, a token renders one of these again.
131
+ const stockColors = new Set([
132
+ "rgb(163, 21, 21)", // vs: string.key.json / string (maroon)
133
+ "rgb(4, 81, 165)", // vs: string.value.json / keyword.json (navy)
134
+ "rgb(156, 220, 254)", // vs-dark: string.key.json (cyan)
135
+ "rgb(206, 145, 120)", // vs-dark: string.value.json / keyword.json / string (orange)
136
+ ]);
137
+
138
+ const offenders = spans
139
+ .map((span) => getComputedStyle(span).color)
140
+ .filter((color) => stockColors.has(color));
141
+ await expect(offenders).toEqual([]);
142
+
143
+ // Positive check, not just an absence check: the "name" key span must be
144
+ // colored with the bridge's OWN live `string.key.json` brand ink (read
145
+ // straight off the currently-applied CSS custom properties), not merely
146
+ // "some color that isn't stock" — a fix that mapped the scope to the
147
+ // wrong token would pass the negative check above but fail this one.
148
+ const hexToRgb = (hex: string) => {
149
+ const n = Number.parseInt(hex.replace("#", ""), 16);
150
+ return `rgb(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255})`;
151
+ };
152
+ const liveRules = buildBrandThemeData().rules ?? [];
153
+ const expectedKeyForeground = liveRules.find(
154
+ (rule) => rule.token === "string.key.json",
155
+ )?.foreground;
156
+ await expect(expectedKeyForeground).toBeDefined();
157
+ const keySpan = spans.find((span) => span.textContent?.trim() === '"name"');
158
+ await expect(keySpan).toBeDefined();
159
+ await expect(getComputedStyle(keySpan!).color).toEqual(hexToRgb(expectedKeyForeground!));
160
+ },
86
161
  };
87
162
 
88
163
  /**
@@ -89,7 +89,7 @@ const BASE_OPTIONS: monaco.editor.IStandaloneEditorConstructionOptions = {
89
89
  * A token-themed Monaco editor wrapped as a brand-ui React component. Monaco
90
90
  * renders its own editing surface + widgets; this wrapper owns lifecycle,
91
91
  * controlled/uncontrolled value, and applies the brand theme bridge so the
92
- * editor matches the active `data-theme` (all three themes).
92
+ * editor matches the active `data-theme` (every theme).
93
93
  *
94
94
  * Workers (for completions/diagnostics) are wired by importing
95
95
  * `@elabs-ai/components-editor/monaco-environment` once at the app entry.
@@ -144,3 +144,86 @@ describe("CodeWorkspace — CodeWorkspaceHandle via ref", () => {
144
144
  expect(listener).not.toHaveBeenCalled(); // not called yet, only on selection change
145
145
  });
146
146
  });
147
+
148
+ describe("CodeWorkspace — tab/panel a11y (#154)", () => {
149
+ it("gives every tab an aria-controls that resolves to a real element, for paths with '/', '.', a space and non-ASCII", () => {
150
+ const trickyFiles: EditorFile[] = [
151
+ { path: "src/hello.ts", value: "a" },
152
+ { path: "a b/c.d.json", value: "b" },
153
+ { path: "café/résumé.md", value: "c" },
154
+ ];
155
+ render(<CodeWorkspace files={trickyFiles} />);
156
+ const tabs = screen.getAllByRole("tab");
157
+ expect(tabs).toHaveLength(trickyFiles.length);
158
+ for (const tab of tabs) {
159
+ const controls = tab.getAttribute("aria-controls");
160
+ expect(controls).toBeTruthy();
161
+ expect(document.getElementById(controls!)).not.toBeNull();
162
+ }
163
+ });
164
+ });
165
+
166
+ describe("CodeWorkspace — force-mounted panels stay out of the layout", () => {
167
+ const threeFiles: EditorFile[] = [
168
+ { path: "a.ts", value: "A" },
169
+ { path: "b.ts", value: "B" },
170
+ { path: "c.ts", value: "C" },
171
+ ];
172
+
173
+ it("hides every inactive tabpanel so it takes no layout space and no tab stop", () => {
174
+ render(<CodeWorkspace files={threeFiles} />);
175
+ const panels = screen.getAllByRole("tabpanel", { hidden: true });
176
+ expect(panels).toHaveLength(3);
177
+ const active = panels.filter((p) => p.getAttribute("data-state") === "active");
178
+ const inactive = panels.filter((p) => p.getAttribute("data-state") !== "active");
179
+ expect(active).toHaveLength(1);
180
+ expect(active[0]!.hasAttribute("hidden")).toBe(false);
181
+ expect(active[0]!.getAttribute("tabindex")).toBe("0");
182
+ for (const panel of inactive) {
183
+ expect(panel.hasAttribute("hidden")).toBe(true);
184
+ expect(panel.getAttribute("tabindex")).toBe("-1");
185
+ }
186
+ });
187
+
188
+ it("keeps aria-controls resolvable while the inactive panels are hidden", () => {
189
+ render(<CodeWorkspace files={threeFiles} />);
190
+ for (const tab of screen.getAllByRole("tab")) {
191
+ const controls = tab.getAttribute("aria-controls");
192
+ expect(controls).toBeTruthy();
193
+ expect(document.getElementById(controls!)).not.toBeNull();
194
+ }
195
+ });
196
+ });
197
+
198
+ describe("CodeWorkspace — tab identity survives a file-list reorder (#412 review)", () => {
199
+ it("keeps the active panel's id stable and does not remount Monaco when a file is prepended", () => {
200
+ const initial: EditorFile[] = [
201
+ { path: "src/a.ts", value: "AAA" },
202
+ { path: "b.json", value: "{}" },
203
+ ];
204
+ const { rerender } = render(<CodeWorkspace files={initial} />);
205
+ const idBefore = screen
206
+ .getAllByRole("tabpanel", { hidden: true })
207
+ .find((p) => p.getAttribute("data-state") === "active")!.id;
208
+ const createdBefore = h.create.mock.calls.length;
209
+
210
+ rerender(<CodeWorkspace files={[{ path: "zz/new.ts", value: "N" }, ...initial]} />);
211
+
212
+ const idAfter = screen
213
+ .getAllByRole("tabpanel", { hidden: true })
214
+ .find((p) => p.getAttribute("data-state") === "active")!.id;
215
+ expect(idAfter).toBe(idBefore);
216
+ expect(h.create.mock.calls.length).toBe(createdBefore);
217
+ });
218
+
219
+ it("gives colliding paths distinct tab values", () => {
220
+ const colliding: EditorFile[] = [
221
+ { path: "a/b", value: "1" },
222
+ { path: "a.b", value: "2" },
223
+ { path: "a-b", value: "3" },
224
+ ];
225
+ render(<CodeWorkspace files={colliding} />);
226
+ const ids = screen.getAllByRole("tabpanel", { hidden: true }).map((p) => p.id);
227
+ expect(new Set(ids).size).toBe(colliding.length);
228
+ });
229
+ });
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { Tabs, TabsList, TabsTrigger } from "@elabs-ai/components-ui";
3
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@elabs-ai/components-ui";
4
4
  import { cn } from "@elabs-ai/components-ui/lib/cn";
5
5
  import {
6
6
  forwardRef,
@@ -74,6 +74,31 @@ const EXT_LANGUAGE: Record<string, string> = {
74
74
 
75
75
  const basename = (path: string) => path.split("/").pop() || path;
76
76
 
77
+ /**
78
+ * Radix composes each `TabsContent`'s DOM id from the `Tabs`/`TabsTrigger`
79
+ * `value` (see #154): a raw `file.path` leaks `/`, `.`, spaces and non-ASCII
80
+ * characters straight into that id, which is what a screen reader gets back
81
+ * via `aria-controls`. `path` stays the display label and the lookup key in
82
+ * component state (`active`); this is only ever a DOM-id-safe Tabs value.
83
+ *
84
+ * The encoding is INJECTIVE and depends only on the path — never on the
85
+ * file's position in `files`. Every character outside `[A-Za-z0-9-]` becomes
86
+ * `_<hex code point>_`, and `_` itself is never emitted literally, so the
87
+ * mapping is reversible and two distinct paths can never produce the same
88
+ * value ("a/b" → `tab-a_2f_b`, "a.b" → `tab-a_2e_b`). An index-derived value
89
+ * would have been collision-safe too, but it changes when the list is
90
+ * reordered or a file is inserted, which re-keys the active `TabsContent` and
91
+ * throws away Monaco's selection, scroll position and undo history on an
92
+ * otherwise harmless `files` update.
93
+ */
94
+ function tabIdForFile(path: string): string {
95
+ let out = "";
96
+ for (const char of path) {
97
+ out += /[A-Za-z0-9-]/.test(char) ? char : `_${char.codePointAt(0)!.toString(16)}_`;
98
+ }
99
+ return `tab-${out}`;
100
+ }
101
+
77
102
  function inferLanguage(file: EditorFile): string {
78
103
  if (file.language) return file.language;
79
104
  const ext = file.path.split(".").pop()?.toLowerCase() ?? "";
@@ -143,6 +168,10 @@ export const CodeWorkspace = forwardRef<CodeWorkspaceHandle, CodeWorkspaceProps>
143
168
  const activeFile = files.find((f) => f.path === requested) ?? files[0];
144
169
  const active = activeFile?.path ?? "";
145
170
 
171
+ // DOM-id-safe Tabs value per file (#154); `path` stays the lookup key.
172
+ const tabs = files.map((file) => ({ file, id: tabIdForFile(file.path) }));
173
+ const activeTabId = tabs.find((t) => t.file.path === active)?.id ?? "";
174
+
146
175
  // The root div ref (getElement() preserves old HTMLDivElement access).
147
176
  const rootRef = useRef<HTMLDivElement | null>(null);
148
177
 
@@ -213,16 +242,19 @@ export const CodeWorkspace = forwardRef<CodeWorkspaceHandle, CodeWorkspaceProps>
213
242
  {...props}
214
243
  >
215
244
  <Tabs
216
- value={active}
217
- onValueChange={selectTab}
245
+ value={activeTabId}
246
+ onValueChange={(id) => {
247
+ const found = tabs.find((t) => t.id === id);
248
+ if (found) selectTab(found.file.path);
249
+ }}
218
250
  className="flex min-h-0 flex-1 flex-col gap-0"
219
251
  >
220
252
  <div className="flex items-center border-b border-border bg-surface">
221
253
  <TabsList className="h-9 flex-1 justify-start gap-0 rounded-none bg-transparent p-0">
222
- {files.map((file) => (
254
+ {tabs.map(({ file, id }) => (
223
255
  <TabsTrigger
224
- key={file.path}
225
- value={file.path}
256
+ key={id}
257
+ value={id}
226
258
  className="h-9 rounded-none border-e border-border px-3 text-xs data-[state=active]:bg-background data-[state=active]:shadow-none"
227
259
  >
228
260
  {basename(file.path)}
@@ -232,20 +264,41 @@ export const CodeWorkspace = forwardRef<CodeWorkspaceHandle, CodeWorkspaceProps>
232
264
  {activeFile ? <CopyButton value={activeFile.value} className="me-1.5" /> : null}
233
265
  </div>
234
266
 
235
- <div className="min-h-0 flex-1">
236
- {activeFile ? (
237
- <CodeEditor
238
- key={activeFile.path}
239
- path={activeFile.path}
240
- language={inferLanguage(activeFile)}
241
- value={activeFile.value}
242
- readOnly={readOnly}
243
- onChange={(next) => onFileChange?.(activeFile.path, next)}
244
- onMount={(editor) => setActiveEditor(editor)}
245
- height="100%"
246
- />
247
- ) : null}
248
- </div>
267
+ {tabs.map(({ file, id }) => {
268
+ const isActive = file.path === activeFile?.path;
269
+ return (
270
+ // `forceMount` keeps every tab's content in the DOM so each
271
+ // trigger's `aria-controls` resolves to a real element, not only
272
+ // the active one (#154). Radix derives its own `hidden` from
273
+ // `forceMount || isSelected`, so under `forceMount` it hides
274
+ // NOTHING: without the explicit `hidden` below every inactive
275
+ // panel stays a visible `flex-1` child and the editor height is
276
+ // split across all of them. `tabIndex={-1}` keeps the empty
277
+ // panels out of the tab sequence for the same reason. Only the
278
+ // active file mounts a (single) Monaco instance.
279
+ <TabsContent
280
+ key={id}
281
+ value={id}
282
+ forceMount
283
+ hidden={!isActive}
284
+ tabIndex={isActive ? 0 : -1}
285
+ className="mt-0 min-h-0 flex-1"
286
+ >
287
+ {isActive ? (
288
+ <CodeEditor
289
+ key={file.path}
290
+ path={file.path}
291
+ language={inferLanguage(file)}
292
+ value={file.value}
293
+ readOnly={readOnly}
294
+ onChange={(next) => onFileChange?.(file.path, next)}
295
+ onMount={(editor) => setActiveEditor(editor)}
296
+ height="100%"
297
+ />
298
+ ) : null}
299
+ </TabsContent>
300
+ );
301
+ })}
249
302
  </Tabs>
250
303
  </div>
251
304
  );
@@ -5,7 +5,15 @@ const meta = {
5
5
  title: "Editor/DiffEditor",
6
6
  component: DiffEditor,
7
7
  tags: ["autodocs"],
8
- parameters: { layout: "fullscreen" },
8
+ parameters: {
9
+ layout: "fullscreen",
10
+ docs: {
11
+ description: {
12
+ component:
13
+ "The only diff surface that can be TYPED INTO — Monaco’s side-by-side editor. Reading lines of a patch is `AI/DiffView`, accepting or rejecting per hunk is `AI/ChangeReview`, and reading a patch in a console transcript is `Terminal/TerminalDiffHunk`. See [Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs).",
14
+ },
15
+ },
16
+ },
9
17
  } satisfies Meta<typeof DiffEditor>;
10
18
 
11
19
  export default meta;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Ambient declarations for the `monaco-editor` deep ESM paths used by
3
+ * `monaco-theme-bridge.test.ts` to resolve syntax colours through Monaco's
4
+ * REAL token-theme trie (#90) — the only way to prove the bridge's rules
5
+ * actually beat the inherited `vs`/`vs-dark` base themes, since the bug this
6
+ * locks is invisible to a check that only reads `buildBrandThemeData()`'s
7
+ * returned array.
8
+ *
9
+ * `monaco-editor`'s package `exports` map (`"./*": "./*"`) makes these paths
10
+ * resolvable at runtime/bundle time, but ships no `.d.ts` for anything below
11
+ * the top-level `editor.main.d.ts` barrel — hence the shims. Typed only as
12
+ * far as the test needs; internals (`ThemeTrieElementRule`'s private-by-
13
+ * convention `_foreground`/`_background` fields) are read structurally
14
+ * because that's the real shape `tokenization.js` constructs at runtime (see
15
+ * `ThemeTrieElementRule` in that file for the source of truth).
16
+ */
17
+ declare module "monaco-editor/esm/vs/editor/common/languages/supports/tokenization.js" {
18
+ import type * as Monaco from "monaco-editor";
19
+
20
+ /** Mirrors `ThemeTrieElementRule` — the resolved node `_match` returns. */
21
+ interface ResolvedThemeTrieRule {
22
+ readonly _fontStyle: number;
23
+ readonly _foreground: number;
24
+ readonly _background: number;
25
+ readonly metadata: number;
26
+ }
27
+
28
+ /** Mirrors `Color` (`base/common/color.js`) — enough to read RGB channels. */
29
+ interface MonacoColor {
30
+ readonly rgba: { r: number; g: number; b: number; a: number };
31
+ toString(): string;
32
+ }
33
+
34
+ export class TokenTheme {
35
+ static createFromRawTokenTheme(
36
+ source: Monaco.editor.ITokenThemeRule[],
37
+ customTokenColors: string[],
38
+ ): TokenTheme;
39
+ /** Internal (underscore) but exercised directly — see file header. */
40
+ _match(token: string): ResolvedThemeTrieRule;
41
+ getColorMap(): MonacoColor[];
42
+ }
43
+ }
44
+
45
+ declare module "monaco-editor/esm/vs/editor/standalone/common/themes.js" {
46
+ import type * as Monaco from "monaco-editor";
47
+
48
+ interface BuiltinRawTheme {
49
+ readonly base: string;
50
+ readonly inherit: boolean;
51
+ readonly rules: Monaco.editor.ITokenThemeRule[];
52
+ readonly colors: Record<string, string>;
53
+ }
54
+
55
+ export const vs: BuiltinRawTheme;
56
+ export const vs_dark: BuiltinRawTheme;
57
+ export const hc_black: BuiltinRawTheme;
58
+ export const hc_light: BuiltinRawTheme;
59
+ }