@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.
- package/README.md +6 -7
- package/dist/{chunk-LBC5VJBD.js → chunk-C62O7IOQ.js} +110 -21
- package/dist/chunk-C62O7IOQ.js.map +1 -0
- package/dist/index.css +4 -2
- package/dist/index.css.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +57 -20
- package/dist/index.js.map +1 -1
- package/dist/markdown/index.css +4 -2
- package/dist/markdown/index.css.map +1 -1
- package/dist/markdown/index.d.ts +2 -2
- package/dist/markdown/index.js +9 -9
- package/dist/markdown/index.js.map +1 -1
- package/dist/{markdown-editor-DfBZibAn.d.ts → markdown-editor-CBp_4eDv.d.ts} +1 -1
- package/package.json +6 -6
- package/src/ai-objects/entity.tsx +1 -1
- package/src/ai-objects/knowledge-card.tsx +1 -1
- package/src/calc-block/calc-editor.css +1 -1
- package/src/code-editor/code-editor.stories.tsx +76 -1
- package/src/code-editor/code-editor.tsx +1 -1
- package/src/code-workspace/code-workspace.test.tsx +83 -0
- package/src/code-workspace/code-workspace.tsx +73 -20
- package/src/diff-editor/diff-editor.stories.tsx +9 -1
- package/src/lib/monaco-deep-imports.d.ts +59 -0
- package/src/lib/monaco-theme-bridge.test.ts +307 -0
- package/src/lib/monaco-theme-bridge.ts +154 -10
- package/src/markdown-academic/citations.tsx +2 -2
- package/src/markdown-academic/footnotes.tsx +2 -2
- package/src/markdown-academic/toc.tsx +1 -1
- package/src/markdown-editor/directive-views.tsx +2 -5
- package/src/markdown-editor/markdown-editor.css +26 -3
- package/src/markdown-editor/markdown-editor.focus.test.ts +49 -0
- package/src/markdown-editor/markdown-editor.stories.tsx +128 -7
- package/src/markdown-editor/markdown-editor.tsx +8 -5
- package/src/markdown-editor/milkdown-react/use-get-editor.timer-leak.test.ts +80 -0
- package/src/markdown-editor/milkdown-react/use-get-editor.ts +37 -1
- package/src/markdown-editor/paste-embed.ts +2 -1
- package/src/markdown-editor/slash/slash-menu.stories.tsx +8 -3
- package/src/markdown-editor/slash/slash-menu.test.tsx +27 -0
- package/src/markdown-editor/slash/slash-menu.tsx +13 -1
- package/src/markdown-editor/table-view.tsx +3 -2
- package/src/markdown-iteration/iteration-builder-dialog.stories.tsx +57 -19
- package/src/markdown-outline/document-outline.stories.tsx +1 -1
- package/src/markdown-outline/document-outline.tsx +1 -1
- package/src/markdown-preview/markdown-preview-transclusion.test.tsx +1 -1
- package/src/markdown-preview/markdown-preview.stories.tsx +24 -2
- package/src/markdown-preview/markdown-preview.test.tsx +20 -3
- package/src/markdown-toolbar/markdown-toolbar.stories.tsx +3 -0
- package/src/markdown-workspace/markdown-workspace.test.tsx +31 -1
- package/src/mermaid-diagram/mermaid-viewer.tsx +1 -1
- package/src/prose/prose.stories.tsx +3 -0
- package/src/prose/prose.test.ts +54 -0
- package/src/prose/prose.tsx +7 -0
- 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
|
|
158
|
-
|
|
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":
|
|
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
|
|
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
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
|
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
|
-
|
|
27
|
-
|
|
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
|
/* ----------------------------------------------------------------------------
|