@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
@@ -0,0 +1,307 @@
1
+ /**
2
+ * monaco-theme-bridge.test.ts — locks #88: Monaco composites a translucent
3
+ * `editor.lineHighlightBackground` UNDER every token's text on the cursor's
4
+ * line, so the REAL, on-screen ground a syntax color renders against on that
5
+ * line is `flattenOver(lineHighlight, background)`, not the bare
6
+ * `--background`. `buildBrandThemeData()` used to AA-clamp every rule's
7
+ * foreground against the bare background alone, so a rule could pass its own
8
+ * nominal check and still fail WCAG AA once Monaco actually painted it.
9
+ *
10
+ * This derives REAL numbers from the shipped theme tokens (parsed straight out
11
+ * of `packages/tokens/src/themes/{light,dark}.css`, the same technique
12
+ * `packages/tokens/src/themes-contrast.test.ts` uses) rather than asserting on
13
+ * a hand-picked ratio — before the fix this fails at `token: "string"` in the
14
+ * `light` theme (measured ~4.16:1 against the composited ground, short of the
15
+ * 4.5:1 AA bar); after the fix every rule clears its bar in both themes.
16
+ */
17
+ import { existsSync, readFileSync } from "node:fs";
18
+ import { resolve } from "node:path";
19
+ import { afterEach, describe, expect, it } from "vitest";
20
+
21
+ import { TokenTheme } from "monaco-editor/esm/vs/editor/common/languages/supports/tokenization.js";
22
+ import { vs, vs_dark } from "monaco-editor/esm/vs/editor/standalone/common/themes.js";
23
+
24
+ import {
25
+ buildBrandThemeData,
26
+ contrast,
27
+ flattenOver,
28
+ IGNORED_BASE_SCOPES,
29
+ LINE_HIGHLIGHT_ALPHA,
30
+ withAlpha,
31
+ } from "./monaco-theme-bridge";
32
+
33
+ type ThemeSlug = "light" | "dark";
34
+
35
+ /**
36
+ * Resolve `packages/tokens/src/themes/<theme>.css` regardless of whether the
37
+ * test runner's cwd is this package (`pnpm --filter … test`) or the repo root
38
+ * (`pnpm test` via Turborepo) — same multi-candidate approach as
39
+ * `packages/ai/src/dark-theme-variant.test.ts`.
40
+ */
41
+ function themeCssPath(theme: ThemeSlug): string {
42
+ const candidates = [
43
+ resolve(process.cwd(), `../tokens/src/themes/${theme}.css`), // cwd = packages/editor
44
+ resolve(process.cwd(), `packages/tokens/src/themes/${theme}.css`), // cwd = repo root
45
+ resolve(process.cwd(), `../../packages/tokens/src/themes/${theme}.css`),
46
+ ].find((candidate) => existsSync(candidate));
47
+ if (!candidates) {
48
+ throw new Error(`themes/${theme}.css not found from cwd ${process.cwd()}`);
49
+ }
50
+ return candidates;
51
+ }
52
+
53
+ /**
54
+ * All `--token: oklch(...)` declarations in a `[data-theme="name"]` block,
55
+ * with single-level `var(--other)` aliases resolved (`--chart-1`/`--ring` both
56
+ * alias `--primary` in the shipped themes) — same technique as
57
+ * `themes-contrast.test.ts`'s `tokenMap`.
58
+ */
59
+ function tokenMap(theme: ThemeSlug): Record<string, string> {
60
+ const css = readFileSync(themeCssPath(theme), "utf8");
61
+ const block = css.match(new RegExp(`\\[data-theme="${theme}"\\]\\s*\\{([\\s\\S]*?)\\n\\}`))?.[1];
62
+ if (block == null) {
63
+ throw new Error(`[data-theme="${theme}"] block not found in ${theme}.css`);
64
+ }
65
+ const literals: Record<string, string> = {};
66
+ const aliases: Record<string, string> = {};
67
+ for (const m of block.matchAll(/(--[\w-]+):\s*(oklch\([^;]+\)|var\(\s*--[\w-]+\s*\))\s*;/g)) {
68
+ const name = m[1];
69
+ const value = m[2]?.trim();
70
+ if (name == null || value == null) continue;
71
+ if (value.startsWith("var(")) {
72
+ const target = value.match(/var\(\s*(--[\w-]+)\s*\)/)?.[1];
73
+ if (target != null) aliases[name] = target;
74
+ } else {
75
+ literals[name] = value;
76
+ }
77
+ }
78
+ const map: Record<string, string> = { ...literals };
79
+ for (const [name, firstTarget] of Object.entries(aliases)) {
80
+ let target: string | undefined = firstTarget;
81
+ const seen = new Set<string>([name]);
82
+ while (target != null && !seen.has(target)) {
83
+ seen.add(target);
84
+ const literal = literals[target];
85
+ if (literal != null) {
86
+ map[name] = literal;
87
+ break;
88
+ }
89
+ target = aliases[target];
90
+ }
91
+ }
92
+ return map;
93
+ }
94
+
95
+ // Every semantic token `buildBrandThemeData` reads (see monaco-theme-bridge.ts).
96
+ const TOKEN_NAMES = [
97
+ "--background",
98
+ "--foreground",
99
+ "--muted",
100
+ "--muted-foreground",
101
+ "--border",
102
+ "--primary",
103
+ "--ring",
104
+ "--ring-contour",
105
+ "--popover",
106
+ "--popover-foreground",
107
+ "--input",
108
+ "--chart-1",
109
+ "--chart-2",
110
+ "--chart-3",
111
+ "--chart-4",
112
+ "--success",
113
+ "--destructive",
114
+ "--calc-result",
115
+ "--warning",
116
+ ] as const;
117
+
118
+ afterEach(() => {
119
+ document.documentElement.removeAttribute("style");
120
+ });
121
+
122
+ /** Build real Monaco theme data from the shipped `theme`'s actual tokens, on a
123
+ * DETACHED element (never appended to `document`) so this is pure token → theme
124
+ * math with no dependency on any particular DOM/render state. */
125
+ function buildThemeDataFor(theme: ThemeSlug) {
126
+ const tokens = tokenMap(theme);
127
+ const el = document.createElement("div");
128
+ for (const name of TOKEN_NAMES) {
129
+ const value = tokens[name];
130
+ if (value != null) el.style.setProperty(name, value);
131
+ }
132
+ return buildBrandThemeData(el);
133
+ }
134
+
135
+ // `comment`/`delimiter` are intentionally softer (3.2:1, "muted but legible");
136
+ // every other rule with a foreground targets full AA (4.5:1).
137
+ const SOFT_RATIO_TOKENS = new Set(["comment", "delimiter"]);
138
+
139
+ describe.each<ThemeSlug>(["light", "dark"])("buildBrandThemeData (%s)", (theme) => {
140
+ const data = buildThemeDataFor(theme);
141
+ const colors = data.colors;
142
+ const background = colors["editor.background"]!;
143
+ const foreground = colors["editor.foreground"]!;
144
+ const lineHighlight = colors["editor.lineHighlightBackground"]!;
145
+
146
+ it("derives editor.lineHighlightBackground from the exported LINE_HIGHLIGHT_ALPHA expression (no drift)", () => {
147
+ // If the overlay Monaco actually paints (`colors["editor.lineHighlightBackground"]`)
148
+ // is ever computed from a DIFFERENT alpha/expression than the one used to
149
+ // derive the ground syntax colors are clamped against, this catches it —
150
+ // both must be `withAlpha(foreground, LINE_HIGHLIGHT_ALPHA)`.
151
+ expect(lineHighlight).toBe(withAlpha(foreground, LINE_HIGHLIGHT_ALPHA));
152
+ });
153
+
154
+ it("AA-clamps every syntax rule's foreground against the COMPOSITED line-highlight ground, not the bare background (#88)", () => {
155
+ // The real, on-screen ground for text painted on the cursor's line: Monaco
156
+ // renders `editor.lineHighlightBackground` UNDER the token text there.
157
+ const tokenGround = flattenOver(lineHighlight, background);
158
+
159
+ const failures: string[] = [];
160
+ for (const rule of data.rules ?? []) {
161
+ if (!rule.foreground) continue;
162
+ const minRatio = SOFT_RATIO_TOKENS.has(rule.token) ? 3.2 : 4.5;
163
+ const ratio = contrast(`#${rule.foreground}`, tokenGround);
164
+ if (ratio < minRatio) {
165
+ failures.push(
166
+ `token "${rule.token || "(base)"}" measures ${ratio.toFixed(2)}:1 against the ` +
167
+ `composited line-highlight ground, needs >= ${minRatio}:1`,
168
+ );
169
+ }
170
+ }
171
+ expect(failures).toEqual([]);
172
+ });
173
+
174
+ it("gives Monaco's focusBorder a layer of the compound indicator that clears 1.4.11 (#67)", () => {
175
+ // Monaco's `focusBorder` is a SINGLE colour key, so it cannot carry the DOM's
176
+ // two-layer `focus-ring` (ring + `--ring-contour` outline). It must therefore be
177
+ // whichever layer actually clears the non-text 3:1 bar against the editor ground.
178
+ // Before the fix this was hard-wired to `--ring`, which on `light` IS `--primary`
179
+ // and measures ~1.36:1 — a focus indicator nobody can see.
180
+ const focusBorder = colors.focusBorder!;
181
+ expect(contrast(focusBorder, background)).toBeGreaterThanOrEqual(3);
182
+ });
183
+
184
+ it("still clamps the calc-result inlay color against the composited ground", () => {
185
+ const tokenGround = flattenOver(lineHighlight, background);
186
+ const calcResult = colors["editorInlayHint.foreground"]!;
187
+ expect(contrast(calcResult, tokenGround)).toBeGreaterThanOrEqual(4.5);
188
+ });
189
+ });
190
+
191
+ /**
192
+ * Locks #90: Monaco's `vs`/`vs-dark` base themes (inherited at
193
+ * `buildBrandThemeData`'s `inherit: true`) ship LANGUAGE-SUFFIXED rules —
194
+ * `string.key.json`, `string.value.json`, `keyword.json`, … — that the
195
+ * bridge's shorter, unsuffixed rules (`string.key`, `string.value`,
196
+ * `keyword`) can never beat: Monaco's token-theme trie resolves the DEEPEST
197
+ * matching scope, and rules are sorted lexicographically before insertion, so
198
+ * `string.key` is always inserted before `string.key.json` regardless of
199
+ * which array it came from — the base's `.json` child then overwrites the
200
+ * clone it inherited from our shorter rule.
201
+ *
202
+ * This asserts against Monaco's REAL resolution — `TokenTheme` built from
203
+ * `base.rules.concat(data.rules)`, exactly mirroring
204
+ * `standaloneThemeService.js`'s `tokenTheme` getter — rather than against the
205
+ * bridge's returned `rules` array, since the array has ALWAYS looked correct
206
+ * (the `// JSON keys` comment on `key`/`string.key` records exactly that
207
+ * mistaken belief). Before the fix this fails on `string.key.json`,
208
+ * `string.value.json` and `keyword.json` in BOTH bases (`number.json` and
209
+ * `delimiter.bracket.json` were already branded — `vs`/`vs-dark` don't
210
+ * specialise those two scopes, so they fall through to the bridge's
211
+ * unsuffixed `number`/`delimiter` rules even pre-fix); after the fix every
212
+ * scope below resolves to a brand colour in both bases.
213
+ */
214
+ describe.each<[ThemeSlug, typeof vs]>([
215
+ ["light", vs],
216
+ ["dark", vs_dark],
217
+ ])("Monaco real trie resolution (%s base) — #90", (theme, base) => {
218
+ const data = buildThemeDataFor(theme);
219
+ // Mirror `standaloneThemeService.js`: `rules = baseData.rules.concat(this.themeData.rules)`.
220
+ const merged = [...base.rules, ...(data.rules ?? [])];
221
+ const tokenTheme = TokenTheme.createFromRawTokenTheme(merged, []);
222
+ const colorMap = tokenTheme.getColorMap();
223
+
224
+ const toHexByte = (n: number) => Math.round(n).toString(16).padStart(2, "0").toUpperCase();
225
+ const resolvedForegroundHex = (scope: string): string | undefined => {
226
+ const rule = tokenTheme._match(scope);
227
+ const color = colorMap[rule._foreground];
228
+ if (!color) return undefined;
229
+ return `${toHexByte(color.rgba.r)}${toHexByte(color.rgba.g)}${toHexByte(color.rgba.b)}`;
230
+ };
231
+
232
+ // Every foreground the bridge itself declares (uppercased 6-hex, no `#`) —
233
+ // a scope resolving to one of these IS branded, whatever its numeric colour id.
234
+ const brandForegrounds = new Set(
235
+ (data.rules ?? [])
236
+ .map((rule) => rule.foreground)
237
+ .filter((fg): fg is string => typeof fg === "string" && fg.length > 0)
238
+ .map((fg) => fg.toUpperCase()),
239
+ );
240
+
241
+ it.each([
242
+ "string.key.json",
243
+ "string.value.json",
244
+ "keyword.json",
245
+ "number.json",
246
+ "delimiter.bracket.json",
247
+ ])(
248
+ "scope %s resolves through the real trie to a brand colour, not a stock base colour",
249
+ (scope) => {
250
+ const hex = resolvedForegroundHex(scope);
251
+ expect(hex).toBeDefined();
252
+ expect(brandForegrounds.has(hex!)).toBe(true);
253
+ },
254
+ );
255
+
256
+ // PR #119 review thread 2 (chatgpt-codex-connector): `CodeEditorProps.language`
257
+ // is a plain, unrestricted `string` passed straight to
258
+ // `monaco.editor.setModelLanguage` (`code-editor.tsx`) — it is NOT limited
259
+ // to `EDITOR_LANGUAGES`, so a consumer passing `language="pug"` (or
260
+ // `"handlebars"`) really does reach these scopes. `IGNORED_BASE_SCOPES`'s
261
+ // old "nothing in this package can ever render them" premise was false for
262
+ // these three; only `metatag.php` (no `foreground` in the base themes —
263
+ // nothing to un-brand) is legitimately left out.
264
+ it.each(["tag.id.pug", "tag.class.pug", "variable.parameter"])(
265
+ "scope %s (reachable via an unrestricted CodeEditor `language` prop) resolves to a brand colour",
266
+ (scope) => {
267
+ const hex = resolvedForegroundHex(scope);
268
+ expect(hex).toBeDefined();
269
+ expect(brandForegrounds.has(hex!)).toBe(true);
270
+ },
271
+ );
272
+ });
273
+
274
+ /**
275
+ * Drift guard (#90): every DOTTED scope Monaco's `vs`/`vs-dark` base themes
276
+ * specialise must be either (a) re-declared by the bridge's own `rules`, or
277
+ * (b) named in `IGNORED_BASE_SCOPES` with a reason it can never reach this
278
+ * package's editors. This is what survives a `monaco-editor` UPGRADE: today's
279
+ * fix hand-lists the scopes evidenced against 0.55.1 — if a future version
280
+ * specialises a new one (or changes what `EDITOR_LANGUAGES` can reach), this
281
+ * test reds instead of silently shipping another un-branded scope.
282
+ */
283
+ describe("drift guard against monaco-editor's base themes — #90", () => {
284
+ const light = buildThemeDataFor("light");
285
+ const dark = buildThemeDataFor("dark");
286
+ const bridgeTokens = new Set(
287
+ [...(light.rules ?? []), ...(dark.rules ?? [])].map((rule) => rule.token),
288
+ );
289
+
290
+ const dottedBaseTokens = new Set(
291
+ [...vs.rules, ...vs_dark.rules]
292
+ .map((rule) => rule.token)
293
+ .filter((token) => token.includes(".")),
294
+ );
295
+
296
+ it("has at least one dotted scope to guard (sanity — a stale extraction would vacuously pass)", () => {
297
+ expect(dottedBaseTokens.size).toBeGreaterThan(0);
298
+ });
299
+
300
+ it.each([...dottedBaseTokens].sort())(
301
+ "base scope %s is either overridden by the bridge or explicitly ignored",
302
+ (token) => {
303
+ const covered = bridgeTokens.has(token) || IGNORED_BASE_SCOPES.has(token);
304
+ expect(covered).toBe(true);
305
+ },
306
+ );
307
+ });
@@ -61,11 +61,19 @@ const clamp01 = (n: number) => (Number.isFinite(n) ? Math.min(1, Math.max(0, n))
61
61
  const byte = (n: number) => n.toString(16).padStart(2, "0");
62
62
 
63
63
  /** Mix `alpha` (0..1) into a `#rrggbb` color, returning `#rrggbbaa`. */
64
- function withAlpha(hex: string, alpha: number): string {
64
+ export function withAlpha(hex: string, alpha: number): string {
65
65
  const base = hex.slice(0, 7);
66
66
  return `${base}${byte(Math.round(clamp01(alpha) * 255))}`;
67
67
  }
68
68
 
69
+ /**
70
+ * Alpha of the translucent cursor-line highlight Monaco paints UNDER every
71
+ * token's text on the active line (`editor.lineHighlightBackground`). Named
72
+ * and shared so the visual overlay and the AA-contrast ground it composites
73
+ * into (`flattenOver`, below) can never drift apart (#88).
74
+ */
75
+ export const LINE_HIGHLIGHT_ALPHA = 0.05;
76
+
69
77
  /** Strip `#` and any alpha — Monaco token rules want a bare 6-char hex. */
70
78
  function bare(hex: string): string {
71
79
  return hex.replace("#", "").slice(0, 6).padEnd(6, "0");
@@ -84,11 +92,31 @@ function luminance(hex: string): number {
84
92
  0.0722 * toLinear(channel(hex, 2))
85
93
  );
86
94
  }
87
- function contrast(a: string, b: string): number {
95
+ export function contrast(a: string, b: string): number {
88
96
  const la = luminance(a);
89
97
  const lb = luminance(b);
90
98
  return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
91
99
  }
100
+
101
+ /**
102
+ * Composite a translucent `#rrggbbaa` overlay (an alpha-suffixed hex, as
103
+ * `withAlpha` produces) over an opaque `#rrggbb` ground — the same straight,
104
+ * source-over blend a browser/Monaco applies when it paints a translucent
105
+ * decoration on top of the editor surface. Returns the resulting opaque
106
+ * `#rrggbb`.
107
+ *
108
+ * Exists so contrast clamps (and tests) can target the REAL, on-screen ground
109
+ * a syntax color renders against, not the bare, uncomposited surface color —
110
+ * see #88.
111
+ */
112
+ export function flattenOver(overlayHexWithAlpha: string, groundHex: string): string {
113
+ const overlayBase = overlayHexWithAlpha.slice(0, 7);
114
+ const alphaHex = overlayHexWithAlpha.length >= 9 ? overlayHexWithAlpha.slice(7, 9) : "ff";
115
+ const alpha = clamp01((parseInt(alphaHex, 16) || 0) / 255);
116
+ const blend = (i: number) =>
117
+ Math.round(channel(overlayBase, i) * alpha + channel(groundHex, i) * (1 - alpha));
118
+ return `#${byte(blend(0))}${byte(blend(1))}${byte(blend(2))}`;
119
+ }
92
120
  function mixHex(hex: string, target: string, t: number): string {
93
121
  const lerp = (i: number) =>
94
122
  Math.round(channel(hex, i) + (channel(target, i) - channel(hex, i)) * t);
@@ -143,6 +171,17 @@ export function buildBrandThemeData(
143
171
  const border = read("--border", muted);
144
172
  const primary = read("--primary", foreground);
145
173
  const ring = read("--ring", primary);
174
+ const ringContour = read("--ring-contour", ring);
175
+ // Monaco's `focusBorder` is a SINGLE colour key — a theme cannot hand it the
176
+ // two-layer compound indicator the DOM gets from the `focus-ring` utility (#67),
177
+ // so it gets whichever layer actually clears the 1.4.11 bar against this editor's
178
+ // own ground. That is exactly the `max(ring, contour)` rule
179
+ // `themes-contrast.test.ts` asserts on the tokens, evaluated here at runtime so it
180
+ // also holds for a consumer theme this package has never heard of: on `light` the
181
+ // ring IS `--primary` (1.36:1) and the contour wins; on `dark` the contour
182
+ // deliberately collapses into the background and the ring wins.
183
+ const focusBorder =
184
+ contrast(ringContour, background) > contrast(ring, background) ? ringContour : ring;
146
185
  const popover = read("--popover", background);
147
186
  const popoverFg = read("--popover-foreground", foreground);
148
187
  const input = read("--input", border);
@@ -152,10 +191,21 @@ export function buildBrandThemeData(
152
191
  const chart4 = read("--chart-4", primary);
153
192
  const success = read("--success", chart2);
154
193
  const destructive = read("--destructive", "#ff0000");
194
+
195
+ // Monaco paints `editor.lineHighlightBackground` — a translucent overlay —
196
+ // UNDER every token's text on the CURSOR'S line, so the real, on-screen
197
+ // ground a syntax color renders against there is this COMPOSITE, not the
198
+ // bare `background` alone (#88). Computed once and reused for both the
199
+ // `colors` entry below and the AA-clamp ground, so the two can't drift apart.
200
+ const lineHighlight = withAlpha(foreground, LINE_HIGHLIGHT_ALPHA);
201
+ const tokenGround = flattenOver(lineHighlight, background);
202
+
155
203
  // Calc result-inlay color (#220): the computed answer shown after each ```calc
156
204
  // line. Themed here (not hardcoded) so it re-applies on theme change with the
157
- // rest of the editor; AA-clamped against the editor background like syntax tokens.
158
- const calcResult = ensureReadable(read("--calc-result", primary), background, 4.5);
205
+ // rest of the editor; AA-clamped against the composited line-highlight ground
206
+ // like syntax tokens (#88) an inlay on the cursor's line is painted over the
207
+ // same overlay.
208
+ const calcResult = ensureReadable(read("--calc-result", primary), tokenGround, 4.5);
159
209
 
160
210
  const colors: Monaco.editor.IColors = {
161
211
  "editor.background": background,
@@ -167,7 +217,7 @@ export function buildBrandThemeData(
167
217
  "editor.selectionBackground": withAlpha(primary, 0.28),
168
218
  "editor.inactiveSelectionBackground": withAlpha(primary, 0.14),
169
219
  "editor.selectionHighlightBackground": withAlpha(primary, 0.14),
170
- "editor.lineHighlightBackground": withAlpha(foreground, 0.05),
220
+ "editor.lineHighlightBackground": lineHighlight,
171
221
  "editor.lineHighlightBorder": "#00000000",
172
222
  "editorIndentGuide.background1": withAlpha(border, 0.6),
173
223
  "editorIndentGuide.activeBackground1": mutedFg,
@@ -193,7 +243,7 @@ export function buildBrandThemeData(
193
243
  "input.background": input,
194
244
  "input.foreground": foreground,
195
245
  "input.border": border,
196
- focusBorder: ring,
246
+ focusBorder,
197
247
  "dropdown.background": popover,
198
248
  "dropdown.foreground": popoverFg,
199
249
  "dropdown.border": border,
@@ -235,10 +285,23 @@ export function buildBrandThemeData(
235
285
  "diffEditor.border": border,
236
286
  };
237
287
 
238
- // Syntax tokens: enforce AA (4.5:1) against the editor background; comments
239
- // get a softer 3.2:1 so they stay intentionally muted but legible. Keyword/
240
- // operator/tag stay on the brand primary (identity), readability-clamped too.
241
- const ink = (hex: string, ratio = 4.5) => bare(ensureReadable(hex, background, ratio));
288
+ // Syntax tokens: enforce AA (4.5:1) against the composited line-highlight
289
+ // ground (#88 Monaco paints that translucent overlay UNDER every token on
290
+ // the cursor's line, so clamping against the bare `background` targets a
291
+ // ground that is never actually rendered); comments get a softer 3.2:1 so
292
+ // they stay intentionally muted but legible. Keyword/operator/tag stay on
293
+ // the brand primary (identity), readability-clamped too.
294
+ //
295
+ // `AA_MARGIN` adds headroom on top of the nominal ratio: `ensureReadable`
296
+ // stops at the FIRST 10% mix step that clears the bar, so a zero-margin
297
+ // clamp can land a hair below it once axe's own rounding is applied — #88
298
+ // measured `string` short by 0.34:1 for exactly this reason. Modeling the
299
+ // OTHER transient overlays (selection, bracket-match, diff bands) is
300
+ // explicitly out of scope for #88; the margin is the accepted headroom for
301
+ // those too, not a claim they're individually composited in.
302
+ const AA_MARGIN = 0.15;
303
+ const ink = (hex: string, ratio = 4.5) =>
304
+ bare(ensureReadable(hex, tokenGround, ratio + AA_MARGIN));
242
305
  const rules: Monaco.editor.ITokenThemeRule[] = [
243
306
  { token: "", foreground: bare(foreground), background: bare(background) },
244
307
  { token: "comment", foreground: ink(mutedFg, 3.2), fontStyle: "italic" },
@@ -263,12 +326,93 @@ export function buildBrandThemeData(
263
326
  { token: "string.value", foreground: ink(chart2) },
264
327
  { token: "invalid", foreground: ink(destructive) },
265
328
  { token: "namespace", foreground: ink(success) },
329
+ // Monaco's built-in `vs`/`vs-dark` bases (inherited at `inherit: true`,
330
+ // below) ship LANGUAGE-SUFFIXED rules — `string.key.json`,
331
+ // `string.value.json`, `keyword.json`, `string.yaml`, `delimiter.html`, …
332
+ // — and Monaco's token-theme trie resolves the DEEPEST matching scope
333
+ // (`ThemeTrieElement.match`), with rules sorted lexicographically before
334
+ // insertion. A shorter brand scope (e.g. `string.key`, above) can
335
+ // therefore NEVER override a longer base scope: every scope a base theme
336
+ // specialises must be re-declared here, or that language renders in
337
+ // stock VS colours (#90). Keep this list in sync with the base themes —
338
+ // `IGNORED_BASE_SCOPES` + the drift guard in `monaco-theme-bridge.test.ts`
339
+ // fail CI if a future `monaco-editor` upgrade adds a new one.
340
+ { token: "string.key.json", foreground: ink(chart1) }, // pairs with `key`
341
+ { token: "string.value.json", foreground: ink(chart2) }, // pairs with `string`
342
+ { token: "keyword.json", foreground: ink(primary) }, // pairs with `keyword`
343
+ // Closes the class, not just JSON — the same base-specialisation gap
344
+ // reaches YAML/HTML/SQL/XML/CSS/SCSS (#90 evidence #7).
345
+ { token: "string.html", foreground: ink(chart2) },
346
+ { token: "string.sql", foreground: ink(chart2) },
347
+ { token: "string.yaml", foreground: ink(chart2) },
348
+ { token: "delimiter.html", foreground: ink(mutedFg, 3.2) },
349
+ { token: "delimiter.xml", foreground: ink(mutedFg, 3.2) },
350
+ { token: "attribute.value.html", foreground: ink(chart2) },
351
+ { token: "attribute.value.xml", foreground: ink(chart2) },
352
+ { token: "attribute.value.number", foreground: ink(chart4) },
353
+ { token: "attribute.value.unit", foreground: ink(chart4) },
354
+ { token: "attribute.value.number.css", foreground: ink(chart4) },
355
+ { token: "attribute.value.unit.css", foreground: ink(chart4) },
356
+ { token: "attribute.value.hex.css", foreground: ink(chart4) },
357
+ { token: "number.hex", foreground: ink(chart4) },
358
+ { token: "keyword.flow", foreground: ink(primary) },
359
+ { token: "keyword.flow.scss", foreground: ink(primary) },
360
+ { token: "operator.scss", foreground: ink(primary) },
361
+ { token: "operator.sql", foreground: ink(primary) },
362
+ { token: "operator.swift", foreground: ink(primary) },
363
+ { token: "predefined.sql", foreground: ink(chart3) },
364
+ { token: "metatag", foreground: ink(chart3) },
365
+ { token: "metatag.html", foreground: ink(chart3) },
366
+ { token: "metatag.xml", foreground: ink(chart3) },
367
+ { token: "metatag.content.html", foreground: ink(chart3) },
368
+ { token: "meta.scss", foreground: ink(chart1) },
369
+ { token: "meta.tag", foreground: ink(chart1) },
370
+ // `CodeEditorProps.language` is a plain, unrestricted `string` passed
371
+ // straight to `monaco.editor.setModelLanguage` (`code-editor.tsx`) — NOT
372
+ // limited to `EDITOR_LANGUAGES` — so a consumer really can reach these
373
+ // pug/handlebars scopes (PR #119 review thread 2). See
374
+ // `IGNORED_BASE_SCOPES` below for the one scope that stays un-overridden.
375
+ { token: "tag.id.pug", foreground: ink(primary) }, // pairs with `tag`
376
+ { token: "tag.class.pug", foreground: ink(primary) },
377
+ { token: "variable.parameter", foreground: bare(foreground) }, // pairs with `variable`
266
378
  ];
267
379
 
268
380
  // `base` is a placeholder; `applyBrandTheme` overrides it per theme.
269
381
  return { base: "vs", inherit: true, colors, rules };
270
382
  }
271
383
 
384
+ /**
385
+ * Base-specialised dotted scopes (`vs`/`vs_dark`,
386
+ * `monaco-editor/esm/vs/editor/standalone/common/themes.js`) deliberately left
387
+ * un-overridden by `buildBrandThemeData`'s `rules`. Read by the drift-guard
388
+ * test (`monaco-theme-bridge.test.ts`, #90) so a future `monaco-editor`
389
+ * upgrade that adds a genuinely new specialised scope fails CI instead of
390
+ * silently un-branding it.
391
+ *
392
+ * PR #119 review thread 2 (fix-round-2): this set used to also carry
393
+ * `tag.id.pug` / `tag.class.pug` / `variable.parameter` on the premise that
394
+ * "the language that emits them is NOT in `EDITOR_LANGUAGES`, so nothing in
395
+ * this package can ever render them" — that premise is FALSE.
396
+ * `CodeEditorProps.language` (`code-editor.tsx`) is a plain, unrestricted
397
+ * `string` forwarded straight to `monaco.editor.setModelLanguage`; the
398
+ * toolbar's `EDITOR_LANGUAGES` list is a curated picker UI, not an
399
+ * enforcement boundary. A consumer passing `language="pug"` or
400
+ * `language="handlebars"` genuinely reaches those scopes and would have
401
+ * inherited stock Monaco colours instead of the token-derived brand theme.
402
+ * They are now branded in `rules` above instead of ignored here.
403
+ *
404
+ * - `metatag.php` — the one scope legitimately still ignored: verified
405
+ * against `monaco-editor/esm/vs/editor/standalone/common/themes.js`, the
406
+ * base themes give it only a `fontStyle` (`bold`), no `foreground` at all
407
+ * — there is no colour to override, so branding it would be a no-op rule
408
+ * with nothing to test.
409
+ *
410
+ * If a future scope needs the same "unreachable" reasoning, verify it
411
+ * against `CodeEditorProps.language`'s actual (unrestricted) type before
412
+ * adding it here — not against `EDITOR_LANGUAGES`.
413
+ */
414
+ export const IGNORED_BASE_SCOPES = new Set(["metatag.php"]);
415
+
272
416
  /** The Monaco theme id used for a given brand theme. */
273
417
  export function brandThemeId(theme: ThemeName): string {
274
418
  return `brand-${theme}`;
@@ -262,7 +262,7 @@ function CiteLink({ entry, label }: { entry: ResolvedCitation; label: string })
262
262
  // (4.5:1), which `--primary` missed at 4.29-4.31:1 in light. The
263
263
  // resting `underline` is the separate 1.4.1 non-colour cue (#317's
264
264
  // link-in-text-block half) — keep both.
265
- className="text-primary-text underline hover:underline focus-visible:rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
265
+ className="text-primary-text underline hover:underline focus-visible:rounded-sm focus-ring"
266
266
  >
267
267
  {label}
268
268
  </a>
@@ -427,7 +427,7 @@ export const Bibliography = forwardRef<HTMLElement, BibliographyProps>(function
427
427
  rel="noopener noreferrer"
428
428
  // #317/#399 — bibliography DOI/URL is body text: `-text` rung
429
429
  // + resting underline (the non-colour cue).
430
- className="break-words text-primary-text underline underline-offset-2 hover:underline focus-visible:rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
430
+ className="break-words text-primary-text underline underline-offset-2 hover:underline focus-visible:rounded-sm focus-ring"
431
431
  >
432
432
  {data.url ?? `doi:${data.doi}`}
433
433
  </a>
@@ -165,7 +165,7 @@ export function FootnoteRef({ node: _n, children: _c, ...rest }: TagProps) {
165
165
  data-footnote-ref=""
166
166
  aria-label={`Footnote ${n}`}
167
167
  // #399 — a footnote marker is superscript body text: `-text` rung.
168
- className="px-0.5 font-medium text-primary-text underline tabular-nums hover:underline focus-visible:rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
168
+ className="px-0.5 font-medium text-primary-text underline tabular-nums hover:underline focus-visible:rounded-sm focus-ring"
169
169
  >
170
170
  {n}
171
171
  </a>
@@ -196,7 +196,7 @@ export function FootnoteItem({ node: _n, children, ...rest }: TagProps) {
196
196
  ? `Back to reference ${n}, mention ${i + 1}`
197
197
  : `Back to reference ${n}`
198
198
  }
199
- className="ms-0.5 inline-flex items-center text-muted-foreground no-underline hover:text-foreground focus-visible:rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
199
+ className="ms-0.5 inline-flex items-center text-muted-foreground no-underline hover:text-foreground focus-visible:rounded-sm focus-ring"
200
200
  >
201
201
  <span aria-hidden="true">↩</span>
202
202
  {refList.length > 1 ? (
@@ -75,7 +75,7 @@ export const TableOfContents = forwardRef<HTMLElement, TableOfContentsProps>(
75
75
  <li key={it.id} className={INDENT[Math.min(it.level - minLevel, INDENT.length - 1)]}>
76
76
  <a
77
77
  href={`#${it.id}`}
78
- className="text-muted-foreground underline hover:text-foreground hover:underline focus-visible:rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
78
+ className="text-muted-foreground underline hover:text-foreground hover:underline focus-visible:rounded-sm focus-ring"
79
79
  >
80
80
  {it.text}
81
81
  </a>
@@ -170,10 +170,7 @@ function InlineEdit({ value, onCommit, ariaLabel, placeholder, className }: Inli
170
170
  spellCheck={false}
171
171
  onBlur={commit}
172
172
  onKeyDown={onKeyDown}
173
- className={cn(
174
- "brand-inline-edit rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring",
175
- className,
176
- )}
173
+ className={cn("brand-inline-edit rounded-sm focus-ring", className)}
177
174
  />
178
175
  );
179
176
  }
@@ -644,7 +641,7 @@ function IterationDirectiveView() {
644
641
  data-directive-chrome=""
645
642
  aria-label="Iteration actions"
646
643
  title="Iteration actions…"
647
- className="ms-auto inline-flex size-5 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
644
+ className="ms-auto inline-flex size-5 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground focus-ring"
648
645
  >
649
646
  <MoreHorizontal className="size-4" aria-hidden="true" />
650
647
  </button>
@@ -3,12 +3,18 @@
3
3
  *
4
4
  * Milkdown core ships ZERO styles (headless), so this is where brand-ui owns the
5
5
  * look. Every value is a semantic design token (var(--foreground), --border,
6
- * --radius, …) — NO raw colors — so the editor matches all three themes
6
+ * --radius, …) — NO raw colors — so the editor matches every theme
7
7
  * (light/dark) for free. Every rule
8
8
  * is scoped under `.milkdown-host` so nothing leaks globally.
9
9
  */
10
10
 
11
11
  .milkdown-host .ProseMirror {
12
+ /* Suppresses the platform default outline — NOT a bare removal: the
13
+ `:focus-visible` rule below is the replacement, drawn on this same
14
+ element (#309). It owns the indicator unconditionally, so it can't be
15
+ clipped by an ancestor and can't be deleted by a consumer `className`
16
+ reaching `cn()` on the wrapper (`markdown-editor.tsx`'s root `div`
17
+ intentionally carries no focus-ring utility of its own — see #67/#309). */
12
18
  outline: none;
13
19
  padding: 1rem 1.1rem;
14
20
  min-height: 9rem;
@@ -23,8 +29,25 @@
23
29
  margin-inline: auto;
24
30
  }
25
31
 
26
- .milkdown-host .ProseMirror:focus {
27
- outline: none;
32
+ /* Focus indicator (#309) — a compound two-layer shape mirroring the
33
+ `focus-ring-inset` Tailwind utility (`packages/tokens/src/themes.css`,
34
+ ADR 0027), hand-written because this stylesheet is a plain side-effect CSS
35
+ import resolved by the CONSUMER's bundler — not guaranteed to run through
36
+ Tailwind, so `@apply`/utility classes aren't reachable here (verified:
37
+ `@apply` appears zero times in any .css file in this repo). Drawn INSIDE
38
+ the editable's own box (`outline-offset: -3px`, an inset `box-shadow`), so
39
+ it can't be clipped by an ancestor and survives a consumer `className`
40
+ deleting the wrapper's classes (`MarkdownWorkspace` already does this via
41
+ `className="border-0"`). Two layers of opposite value so at least one edge
42
+ clears 3:1 against the ground whatever `--ring` resolves to in a given
43
+ theme (`--ring-contour` is the dedicated contour token — `themes-contrast.
44
+ test.ts`'s `INDICATOR_SURFACES` locks `max(ring, contour) >= 3:1` at the
45
+ token level; `markdown-editor.stories.tsx`'s `FocusIndicator` play
46
+ re-measures it on this rendered surface, both themes). */
47
+ .milkdown-host .ProseMirror:focus-visible {
48
+ outline: 1px solid var(--ring-contour);
49
+ outline-offset: -3px;
50
+ box-shadow: inset 0 0 0 2px var(--ring);
28
51
  }
29
52
 
30
53
  /* ----------------------------------------------------------------------------