@elabs-ai/components-editor 4.0.0 → 4.2.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 (77) hide show
  1. package/README.md +6 -7
  2. package/dist/{chunk-LBC5VJBD.js → chunk-FO5S3YZM.js} +349 -177
  3. package/dist/chunk-FO5S3YZM.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 +5 -4
  7. package/dist/index.js +112 -57
  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 +268 -191
  13. package/dist/markdown/index.js.map +1 -1
  14. package/dist/{markdown-editor-DfBZibAn.d.ts → markdown-editor-Dn-0L_MM.d.ts} +9 -2
  15. package/dist/monaco.d.ts +2 -0
  16. package/dist/monaco.js +9 -0
  17. package/dist/monaco.js.map +1 -0
  18. package/package.json +10 -6
  19. package/src/ai-objects/decision-card.tsx +15 -9
  20. package/src/ai-objects/entity.tsx +1 -1
  21. package/src/ai-objects/knowledge-card.tsx +18 -9
  22. package/src/barrel-monaco-lazy.test.ts +50 -0
  23. package/src/calc-block/calc-block.tsx +10 -4
  24. package/src/calc-block/calc-editor.css +1 -1
  25. package/src/code-editor/code-editor.stories.tsx +76 -1
  26. package/src/code-editor/code-editor.test.tsx +141 -14
  27. package/src/code-editor/code-editor.tsx +167 -36
  28. package/src/code-workspace/code-workspace.test.tsx +113 -11
  29. package/src/code-workspace/code-workspace.tsx +73 -20
  30. package/src/copy-button/copy-button.tsx +6 -3
  31. package/src/diff-editor/diff-editor.stories.tsx +9 -1
  32. package/src/diff-editor/diff-editor.test.tsx +9 -3
  33. package/src/diff-editor/diff-editor.tsx +47 -23
  34. package/src/editor-toolbar/editor-toolbar.tsx +8 -2
  35. package/src/index.ts +4 -3
  36. package/src/lib/monaco-deep-imports.d.ts +59 -0
  37. package/src/lib/monaco-theme-bridge.test.ts +307 -0
  38. package/src/lib/monaco-theme-bridge.ts +154 -10
  39. package/src/markdown-academic/citations.tsx +14 -7
  40. package/src/markdown-academic/footnotes.tsx +2 -2
  41. package/src/markdown-academic/math.tsx +7 -4
  42. package/src/markdown-academic/toc.tsx +1 -1
  43. package/src/markdown-editor/completions/completions-menu.tsx +5 -2
  44. package/src/markdown-editor/directive-views.tsx +35 -24
  45. package/src/markdown-editor/markdown-editor.css +26 -3
  46. package/src/markdown-editor/markdown-editor.focus.test.ts +49 -0
  47. package/src/markdown-editor/markdown-editor.stories.tsx +128 -7
  48. package/src/markdown-editor/markdown-editor.tsx +8 -5
  49. package/src/markdown-editor/milkdown-react/use-get-editor.timer-leak.test.ts +80 -0
  50. package/src/markdown-editor/milkdown-react/use-get-editor.ts +37 -1
  51. package/src/markdown-editor/paste-embed.ts +2 -1
  52. package/src/markdown-editor/slash/slash-menu.stories.tsx +8 -3
  53. package/src/markdown-editor/slash/slash-menu.test.tsx +27 -0
  54. package/src/markdown-editor/slash/slash-menu.tsx +18 -3
  55. package/src/markdown-editor/table-view.tsx +31 -17
  56. package/src/markdown-iteration/iteration-builder-dialog.stories.tsx +57 -19
  57. package/src/markdown-iteration/iteration-builder-dialog.tsx +39 -18
  58. package/src/markdown-iteration/template-dialog.tsx +25 -7
  59. package/src/markdown-outline/document-outline.stories.tsx +1 -1
  60. package/src/markdown-outline/document-outline.tsx +7 -3
  61. package/src/markdown-preview/markdown-preview-academic.test.tsx +3 -3
  62. package/src/markdown-preview/markdown-preview-transclusion.test.tsx +1 -1
  63. package/src/markdown-preview/markdown-preview.stories.tsx +24 -2
  64. package/src/markdown-preview/markdown-preview.test.tsx +20 -3
  65. package/src/markdown-toolbar/markdown-toolbar.stories.tsx +3 -0
  66. package/src/markdown-toolbar/markdown-toolbar.tsx +42 -24
  67. package/src/markdown-workspace/markdown-workspace.test.tsx +53 -4
  68. package/src/markdown-workspace/markdown-workspace.tsx +6 -3
  69. package/src/mermaid-diagram/mermaid-diagram-fixes.test.tsx +130 -0
  70. package/src/mermaid-diagram/mermaid-diagram.test.tsx +2 -1
  71. package/src/mermaid-diagram/mermaid-diagram.tsx +89 -40
  72. package/src/mermaid-diagram/mermaid-viewer.tsx +22 -21
  73. package/src/monaco.ts +21 -0
  74. package/src/prose/prose.stories.tsx +3 -0
  75. package/src/prose/prose.test.ts +54 -0
  76. package/src/prose/prose.tsx +7 -0
  77. package/dist/chunk-LBC5VJBD.js.map +0 -1
@@ -9,6 +9,13 @@ import { Command } from '@milkdown/kit/prose/state';
9
9
  type MonacoCodeEditor = monaco.editor.IStandaloneCodeEditor;
10
10
  /** A Monaco editor action (system command + optional hotkey + palette/menu entry). */
11
11
  type EditorAction = monaco.editor.IActionDescriptor;
12
+ /**
13
+ * The runtime value type handed back by `import("monaco-editor")` — same
14
+ * namespace as the type-only `monaco` import above (`typeof` a type-only
15
+ * namespace import resolves to the module's own type), used for `monacoRef`
16
+ * and `onMount`'s second argument.
17
+ */
18
+ type MonacoNamespace = typeof monaco;
12
19
  interface CodeEditorProps extends Omit<HTMLAttributes<HTMLDivElement>, "onChange" | "defaultValue"> {
13
20
  /** Controlled content. Pair with `onChange`. */
14
21
  value?: string;
@@ -44,7 +51,7 @@ interface CodeEditorProps extends Omit<HTMLAttributes<HTMLDivElement>, "onChange
44
51
  */
45
52
  contextMenu?: "brand" | "monaco" | "none";
46
53
  /** Called once the editor instance + monaco namespace are ready. */
47
- onMount?: (editor: MonacoCodeEditor, monacoApi: typeof monaco) => void;
54
+ onMount?: (editor: MonacoCodeEditor, monacoApi: MonacoNamespace) => void;
48
55
  /**
49
56
  * Declarative Monaco editor actions — each registers a command (run on its
50
57
  * `keybindings`, in the command palette, and optionally the context menu via
@@ -62,7 +69,7 @@ interface CodeEditorProps extends Omit<HTMLAttributes<HTMLDivElement>, "onChange
62
69
  * A token-themed Monaco editor wrapped as a brand-ui React component. Monaco
63
70
  * renders its own editing surface + widgets; this wrapper owns lifecycle,
64
71
  * controlled/uncontrolled value, and applies the brand theme bridge so the
65
- * editor matches the active `data-theme` (all three themes).
72
+ * editor matches the active `data-theme` (every theme).
66
73
  *
67
74
  * Workers (for completions/diagnostics) are wired by importing
68
75
  * `@elabs-ai/components-editor/monaco-environment` once at the app entry.
@@ -0,0 +1,2 @@
1
+ import * as monaco from 'monaco-editor';
2
+ export { monaco };
package/dist/monaco.js ADDED
@@ -0,0 +1,9 @@
1
+ "use client";
2
+ "use client";
3
+
4
+ // src/monaco.ts
5
+ import * as monaco from "monaco-editor";
6
+ export {
7
+ monaco
8
+ };
9
+ //# sourceMappingURL=monaco.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/monaco.ts"],"sourcesContent":["\"use client\";\n\n/**\n * The `monaco-editor` namespace, as its own subpath (ADR 0006 subpath exports).\n *\n * The root barrel (`.`) exports lightweight chrome — `CopyButton`,\n * `EDITOR_LANGUAGES`, `languageLabel` — that a consumer should be able to\n * import WITHOUT pulling Monaco in at all: the editing surfaces\n * (`CodeEditor`, `DiffEditor`, `MarkdownEditor`) already own lazy-loading the\n * engine themselves. Re-exporting the whole `monaco-editor` namespace from\n * that SAME barrel defeated that split — `import { CopyButton } from\n * \"@elabs-ai/components-editor\"` pulled megabytes of Monaco and touched\n * browser globals at import time, breaking SSR/RSC for a component that never\n * renders the editor.\n *\n * A consumer that needs to build a custom editor, or wire Monaco commands\n * directly, imports this subpath instead:\n *\n * import { monaco } from \"@elabs-ai/components-editor/monaco\";\n */\nexport * as monaco from \"monaco-editor\";\n"],"mappings":";;;;AAoBA,YAAY,YAAY;","names":[]}
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.2.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -36,6 +36,10 @@
36
36
  "./monaco-environment": {
37
37
  "types": "./dist/lib/monaco-environment.d.ts",
38
38
  "default": "./dist/lib/monaco-environment.js"
39
+ },
40
+ "./monaco": {
41
+ "types": "./dist/monaco.d.ts",
42
+ "default": "./dist/monaco.js"
39
43
  }
40
44
  },
41
45
  "publishConfig": {
@@ -65,8 +69,8 @@
65
69
  "monaco-editor": "^0.55.1",
66
70
  "react": "^18.2.0 || ^19.0.0",
67
71
  "react-dom": "^18.2.0 || ^19.0.0",
68
- "@elabs-ai/components-tokens": "4.0.0",
69
- "@elabs-ai/components-ui": "4.0.0"
72
+ "@elabs-ai/components-tokens": "4.2.0",
73
+ "@elabs-ai/components-ui": "4.2.0"
70
74
  },
71
75
  "devDependencies": {
72
76
  "@testing-library/jest-dom": "^6.6.3",
@@ -85,10 +89,10 @@
85
89
  "tsup": "^8.3.5",
86
90
  "typescript": "^5.7.3",
87
91
  "vitest": "^3.0.2",
92
+ "@elabs-ai/components-eslint-config": "0.1.0",
93
+ "@elabs-ai/components-tokens": "4.2.0",
88
94
  "@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"
95
+ "@elabs-ai/components-ui": "4.2.0"
92
96
  },
93
97
  "scripts": {
94
98
  "build": "rm -rf dist && tsup && node ../../scripts/link-dist-css.mjs",
@@ -24,6 +24,7 @@ import {
24
24
  CardContent,
25
25
  CardHeader,
26
26
  Separator,
27
+ useLocale,
27
28
  type BadgeProps,
28
29
  } from "@elabs-ai/components-ui";
29
30
  import { cn } from "@elabs-ai/components-ui/lib/cn";
@@ -51,11 +52,12 @@ const STATUS_BADGE_VARIANT: Record<DecisionStatus, BadgeProps["variant"]> = {
51
52
  superseded: "secondary",
52
53
  };
53
54
 
54
- const STATUS_LABELS: Record<DecisionStatus, string> = {
55
- accepted: "Accepted",
56
- rejected: "Rejected",
57
- proposed: "Proposed",
58
- superseded: "Superseded",
55
+ /** Status translation key (see `editor.decisionCard.status.*` in messages.ts). */
56
+ const STATUS_LABEL_KEYS: Record<DecisionStatus, string> = {
57
+ accepted: "editor.decisionCard.statusAccepted",
58
+ rejected: "editor.decisionCard.statusRejected",
59
+ proposed: "editor.decisionCard.statusProposed",
60
+ superseded: "editor.decisionCard.statusSuperseded",
59
61
  };
60
62
 
61
63
  const STATUS_ICONS: Record<DecisionStatus, ComponentType<SVGProps<SVGSVGElement>>> = {
@@ -113,11 +115,12 @@ export const DecisionCard = forwardRef<HTMLElement, DecisionCardProps>(function
113
115
  { status: rawStatus, date, alternatives, children, className, ...props },
114
116
  ref,
115
117
  ) {
118
+ const { t } = useLocale();
116
119
  const status: DecisionStatus = isDecisionStatus(rawStatus ?? "")
117
120
  ? (rawStatus as DecisionStatus)
118
121
  : "proposed";
119
122
  const badgeVariant = STATUS_BADGE_VARIANT[status];
120
- const label = STATUS_LABELS[status];
123
+ const label = t(STATUS_LABEL_KEYS[status]);
121
124
  const Icon = STATUS_ICONS[status];
122
125
 
123
126
  const altItems = alternatives
@@ -130,7 +133,7 @@ export const DecisionCard = forwardRef<HTMLElement, DecisionCardProps>(function
130
133
  return (
131
134
  <section
132
135
  ref={ref}
133
- aria-label={`Decision: ${label}`}
136
+ aria-label={t("editor.decisionCard.label", { label })}
134
137
  className={cn("not-prose", className)}
135
138
  {...props}
136
139
  >
@@ -158,9 +161,12 @@ export const DecisionCard = forwardRef<HTMLElement, DecisionCardProps>(function
158
161
  <Separator />
159
162
  <div className="px-6 py-4">
160
163
  <p className="mb-2 text-meta font-medium text-muted-foreground">
161
- Alternatives considered
164
+ {t("editor.decisionCard.alternativesConsidered")}
162
165
  </p>
163
- <ul className="flex flex-wrap gap-1.5" aria-label="Alternatives considered">
166
+ <ul
167
+ className="flex flex-wrap gap-1.5"
168
+ aria-label={t("editor.decisionCard.alternativesConsidered")}
169
+ >
164
170
  {altItems.map((alt) => (
165
171
  <li key={alt}>
166
172
  <Badge variant="outline" className="text-meta">
@@ -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: {
@@ -18,7 +18,7 @@
18
18
  * `KnowledgeCard` is standalone (no `resolve` → sources rendered as plain labels).
19
19
  * The `knowledgeDirective({ resolve })` factory wires the hook for directive use.
20
20
  */
21
- import { Card, CardContent, CardFooter } from "@elabs-ai/components-ui";
21
+ import { Card, CardContent, CardFooter, useLocale } from "@elabs-ai/components-ui";
22
22
  import { cn } from "@elabs-ai/components-ui/lib/cn";
23
23
  import { BookOpen, FileText } from "lucide-react";
24
24
  import { forwardRef, type HTMLAttributes, type ReactNode } from "react";
@@ -49,6 +49,7 @@ interface SourceRowProps {
49
49
  }
50
50
 
51
51
  function SourceRow({ path, resolve }: SourceRowProps) {
52
+ const { t } = useLocale();
52
53
  const resolved = resolve ? resolve(path) : null;
53
54
  const display = resolved?.title ?? path;
54
55
 
@@ -58,9 +59,9 @@ function SourceRow({ path, resolve }: SourceRowProps) {
58
59
  href={resolved.href}
59
60
  rel="noopener noreferrer"
60
61
  target="_blank"
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"
63
- aria-label={`Source: ${display}`}
62
+ // #399 — a source link is TEXT: the `--link` ink, not the fill.
63
+ className="flex min-w-0 items-center gap-1.5 text-meta text-link underline-offset-2 hover:underline focus-ring"
64
+ aria-label={t("editor.knowledgeCard.source", { name: display })}
64
65
  >
65
66
  <FileText className="size-3 shrink-0" aria-hidden="true" />
66
67
  <span className="truncate">{display}</span>
@@ -71,7 +72,7 @@ function SourceRow({ path, resolve }: SourceRowProps) {
71
72
  return (
72
73
  <span
73
74
  className="flex min-w-0 items-center gap-1.5 text-meta text-muted-foreground"
74
- aria-label={`Source (unresolved): ${display}`}
75
+ aria-label={t("editor.knowledgeCard.sourceUnresolved", { name: display })}
75
76
  >
76
77
  <FileText className="size-3 shrink-0" aria-hidden="true" />
77
78
  <span className="truncate">{display}</span>
@@ -102,6 +103,7 @@ export const KnowledgeCard = forwardRef<HTMLElement, KnowledgeCardProps>(functio
102
103
  { sources, resolve, children, className, ...props },
103
104
  ref,
104
105
  ) {
106
+ const { t } = useLocale();
105
107
  const sourcePaths = sources
106
108
  ? sources
107
109
  .split(",")
@@ -112,7 +114,7 @@ export const KnowledgeCard = forwardRef<HTMLElement, KnowledgeCardProps>(functio
112
114
  return (
113
115
  <section
114
116
  ref={ref}
115
- aria-label="Knowledge fact"
117
+ aria-label={t("editor.knowledgeCard.label")}
116
118
  className={cn("not-prose", className)}
117
119
  {...props}
118
120
  >
@@ -120,15 +122,22 @@ export const KnowledgeCard = forwardRef<HTMLElement, KnowledgeCardProps>(functio
120
122
  <CardContent className="pt-4">
121
123
  <div className="mb-2 flex items-center gap-1.5">
122
124
  <BookOpen className="size-3.5 shrink-0 text-info-text" aria-hidden="true" />
123
- <span className="text-meta font-medium text-info-text">Knowledge</span>
125
+ <span className="text-meta font-medium text-info-text">
126
+ {t("editor.knowledgeCard.heading")}
127
+ </span>
124
128
  </div>
125
129
  <div className="text-body text-foreground">{children}</div>
126
130
  </CardContent>
127
131
 
128
132
  {sourcePaths.length > 0 ? (
129
133
  <CardFooter className="flex-col items-start gap-1 border-t border-border pt-3">
130
- <p className="text-meta font-medium text-muted-foreground">Sources</p>
131
- <ul className="flex w-full flex-col gap-1" aria-label="Sources">
134
+ <p className="text-meta font-medium text-muted-foreground">
135
+ {t("editor.knowledgeCard.sources")}
136
+ </p>
137
+ <ul
138
+ className="flex w-full flex-col gap-1"
139
+ aria-label={t("editor.knowledgeCard.sources")}
140
+ >
132
141
  {sourcePaths.map((path) => (
133
142
  <li key={path} className="min-w-0">
134
143
  <SourceRow path={path} resolve={resolve} />
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ /**
4
+ * The root barrel (`.`) exports lightweight chrome (`CopyButton`,
5
+ * `EDITOR_LANGUAGES`, `languageLabel`) alongside the Monaco-backed editing
6
+ * surfaces (`CodeEditor`, `DiffEditor`, `CodeWorkspace`). A consumer that only
7
+ * wants the chrome must never pay for Monaco: `monaco-editor` touches browser
8
+ * globals at import time and is megabytes on the wire, breaking SSR/RSC for a
9
+ * component that never renders an editor.
10
+ *
11
+ * `code-editor.tsx`/`diff-editor.tsx` used to `import * as monaco from
12
+ * "monaco-editor"` at the top of the module — a VALUE import, evaluated the
13
+ * moment anything imports the barrel (native ESM instantiates every module in
14
+ * the graph, regardless of which named export is actually read). This test
15
+ * locks the fix: mock `monaco-editor` to THROW on evaluation, then import the
16
+ * barrel and prove the lightweight exports still work. If either editing
17
+ * surface regresses to a top-level value import, this fails at the `import`
18
+ * statement itself.
19
+ */
20
+ vi.mock("monaco-editor", () => {
21
+ throw new Error(
22
+ "monaco-editor must not be evaluated merely by importing the @elabs-ai/components-editor barrel",
23
+ );
24
+ });
25
+
26
+ describe("@elabs-ai/components-editor barrel", () => {
27
+ it("importing the barrel never evaluates monaco-editor", async () => {
28
+ await expect(import("./index")).resolves.toBeDefined();
29
+ });
30
+
31
+ it("CopyButton is usable without evaluating monaco-editor", async () => {
32
+ const { CopyButton } = await import("./index");
33
+ expect(CopyButton).toBeTypeOf("function");
34
+ });
35
+
36
+ it("EDITOR_LANGUAGES / languageLabel are usable without evaluating monaco-editor", async () => {
37
+ const { EDITOR_LANGUAGES, languageLabel } = await import("./index");
38
+ expect(Array.isArray(EDITOR_LANGUAGES)).toBe(true);
39
+ expect(EDITOR_LANGUAGES.length).toBeGreaterThan(0);
40
+ expect(languageLabel(EDITOR_LANGUAGES[0]!.id)).toBeTypeOf("string");
41
+ });
42
+
43
+ it("EditorToolbar + EditorContextMenu are usable (rendered) without evaluating monaco-editor", async () => {
44
+ const { EditorToolbar, EditorContextMenu } = await import("./index");
45
+ // Both are `forwardRef`/context components — objects, not plain functions;
46
+ // assert they're valid React element types instead of a bare `typeof`.
47
+ expect(EditorToolbar).toBeTruthy();
48
+ expect(EditorContextMenu).toBeTruthy();
49
+ });
50
+ });
@@ -32,6 +32,7 @@
32
32
  * color is never the only signal (var-def = weight, unresolved = dotted underline),
33
33
  * so roles stay distinct in the high-contrast theme.
34
34
  */
35
+ import { useLocale } from "@elabs-ai/components-ui";
35
36
  import { cn } from "@elabs-ai/components-ui/lib/cn";
36
37
  import { TriangleAlert } from "lucide-react";
37
38
  import { forwardRef, useId, useMemo, type HTMLAttributes, type ReactNode } from "react";
@@ -239,6 +240,7 @@ function renderSource(text: string, tokens: CalcToken[]): ReactNode {
239
240
 
240
241
  /** The right-hand cell: a value, a calm error marker, or nothing. */
241
242
  function ResultCell({ result }: { result: CalcLineResult }): ReactNode {
243
+ const { t } = useLocale();
242
244
  if (result.error) {
243
245
  // Native `title` (not a Radix Tooltip) so CalcBlock is self-contained — it
244
246
  // renders inside MarkdownPreview / a story with no TooltipProvider ancestor.
@@ -246,7 +248,7 @@ function ResultCell({ result }: { result: CalcLineResult }): ReactNode {
246
248
  <span
247
249
  className="inline-flex shrink-0 text-calc-warning"
248
250
  title={result.error.message}
249
- aria-label={`Error: ${result.error.message}`}
251
+ aria-label={t("editor.calcBlock.error", { message: result.error.message })}
250
252
  >
251
253
  <TriangleAlert className="size-3.5" aria-hidden="true" />
252
254
  </span>
@@ -255,7 +257,7 @@ function ResultCell({ result }: { result: CalcLineResult }): ReactNode {
255
257
  if (result.value) {
256
258
  return (
257
259
  <div className="brand-calc-tok--result shrink-0 tabular-nums text-calc-result">
258
- <span className="sr-only">equals </span>
260
+ <span className="sr-only">{t("editor.calcBlock.equals")}</span>
259
261
  <span>{result.value.display}</span>
260
262
  </div>
261
263
  );
@@ -270,7 +272,7 @@ export const CalcBlock = forwardRef<HTMLDivElement, CalcBlockProps>(function Cal
270
272
  title,
271
273
  showTotal = true,
272
274
  total,
273
- totalLabel = "Total",
275
+ totalLabel: totalLabelProp,
274
276
  markers = true,
275
277
  readOnly: _readOnly,
276
278
  className,
@@ -278,6 +280,8 @@ export const CalcBlock = forwardRef<HTMLDivElement, CalcBlockProps>(function Cal
278
280
  },
279
281
  ref,
280
282
  ) {
283
+ const { t } = useLocale();
284
+ const totalLabel = totalLabelProp ?? t("editor.calcBlock.total");
281
285
  // Author markers are stripped BEFORE evaluation: the math engine sees clean
282
286
  // lines, and because markers are trailing, token columns stay aligned with the
283
287
  // rendered (cleaned) text. `lines`/`evalSource` are the marker-free versions.
@@ -376,7 +380,9 @@ export const CalcBlock = forwardRef<HTMLDivElement, CalcBlockProps>(function Cal
376
380
  </div>
377
381
 
378
382
  {empty ? (
379
- <p className="px-4 py-6 text-body text-muted-foreground">Empty calc block.</p>
383
+ <p className="px-4 py-6 text-body text-muted-foreground">
384
+ {t("editor.calcBlock.emptyBlock")}
385
+ </p>
380
386
  ) : (
381
387
  <div className="flex flex-col gap-y-1 px-4 py-3 font-mono text-code leading-relaxed">
382
388
  {rows}
@@ -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
  /**