@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
@@ -14,6 +14,7 @@
14
14
  * a contained error, never crashes the page). a11y: `output: "htmlAndMathml"`
15
15
  * emits MathML (read by assistive tech) alongside the visual HTML.
16
16
  */
17
+ import { useLocale } from "@elabs-ai/components-ui";
17
18
  import { cn } from "@elabs-ai/components-ui/lib/cn";
18
19
  import katex from "katex";
19
20
  import { useMemo, type HTMLAttributes } from "react";
@@ -96,13 +97,14 @@ export interface MathProps extends Omit<HTMLAttributes<HTMLElement>, "children">
96
97
 
97
98
  /** Inline math (`$…$`) → KaTeX, in the text flow. */
98
99
  export function MathInline({ tex, className, ...props }: MathProps) {
100
+ const { t } = useLocale();
99
101
  const { html, error } = useMemo(() => renderKatex(tex, false), [tex]);
100
102
  if (error) {
101
103
  return (
102
104
  <code
103
105
  className={cn("text-destructive-text", className)}
104
- aria-label={`Math (could not render): ${tex}`}
105
- title="Could not render math"
106
+ aria-label={t("editor.math.renderErrorLabel", { tex })}
107
+ title={t("editor.math.renderError")}
106
108
  {...props}
107
109
  >
108
110
  {tex}
@@ -125,6 +127,7 @@ export function MathInline({ tex, className, ...props }: MathProps) {
125
127
 
126
128
  /** Block math (`$$…$$`) → centered display KaTeX. */
127
129
  export function MathBlock({ tex, className, ...props }: MathProps) {
130
+ const { t } = useLocale();
128
131
  const { html, error } = useMemo(() => renderKatex(tex, true), [tex]);
129
132
  if (error) {
130
133
  return (
@@ -133,8 +136,8 @@ export function MathBlock({ tex, className, ...props }: MathProps) {
133
136
  "overflow-x-auto rounded-md bg-surface-muted p-3 text-destructive-text",
134
137
  className,
135
138
  )}
136
- aria-label={`Math (could not render): ${tex}`}
137
- title="Could not render math"
139
+ aria-label={t("editor.math.renderErrorLabel", { tex })}
140
+ title={t("editor.math.renderError")}
138
141
  {...props}
139
142
  >
140
143
  <code>{tex}</code>
@@ -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>
@@ -8,6 +8,7 @@
8
8
  * Same visual grammar (bg-popover, accent selection) for consistency with the
9
9
  * slash popup and Monaco's own themed suggest widget.
10
10
  */
11
+ import { useLocale } from "@elabs-ai/components-ui";
11
12
  import { cn } from "@elabs-ai/components-ui/lib/cn";
12
13
  import { forwardRef, type HTMLAttributes } from "react";
13
14
 
@@ -38,17 +39,19 @@ export const CompletionMenu = forwardRef<HTMLDivElement, CompletionMenuProps>(
38
39
  activeIndex,
39
40
  onSelect,
40
41
  idPrefix = "brand-completions",
41
- emptyLabel = "No suggestions",
42
+ emptyLabel: emptyLabelProp,
42
43
  className,
43
44
  ...props
44
45
  },
45
46
  ref,
46
47
  ) {
48
+ const { t } = useLocale();
49
+ const emptyLabel = emptyLabelProp ?? t("editor.completions.noSuggestions");
47
50
  return (
48
51
  <div
49
52
  ref={ref}
50
53
  role="listbox"
51
- aria-label="Suggestions"
54
+ aria-label={t("editor.completions.suggestions")}
52
55
  className={cn(
53
56
  "max-h-[min(280px,50vh)] w-64 overflow-y-auto overflow-x-hidden rounded-md bg-popover p-1 text-popover-foreground shadow-ring-md",
54
57
  className,
@@ -46,6 +46,7 @@ import {
46
46
  DropdownMenuSubContent,
47
47
  DropdownMenuSubTrigger,
48
48
  DropdownMenuTrigger,
49
+ useLocale,
49
50
  } from "@elabs-ai/components-ui";
50
51
  import { cn } from "@elabs-ai/components-ui/lib/cn";
51
52
  import { editorViewCtx, parserCtx, serializerCtx } from "@milkdown/kit/core";
@@ -170,16 +171,14 @@ function InlineEdit({ value, onCommit, ariaLabel, placeholder, className }: Inli
170
171
  spellCheck={false}
171
172
  onBlur={commit}
172
173
  onKeyDown={onKeyDown}
173
- className={cn(
174
- "brand-inline-edit rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring",
175
- className,
176
- )}
174
+ className={cn("brand-inline-edit rounded-sm focus-ring", className)}
177
175
  />
178
176
  );
179
177
  }
180
178
 
181
179
  /** `:::name` block directives → live @brand component with an editable body. */
182
180
  function ContainerDirectiveView() {
181
+ const { t } = useLocale();
183
182
  const { contentRef } = useNodeViewContext();
184
183
  const { name, attributes, update } = useDirectiveAttrs();
185
184
 
@@ -192,8 +191,8 @@ function ContainerDirectiveView() {
192
191
  <CardHeader className="pb-3">
193
192
  <CardTitle>
194
193
  <InlineEdit
195
- ariaLabel="Card title"
196
- placeholder="Card title"
194
+ ariaLabel={t("editor.directiveViews.cardTitle")}
195
+ placeholder={t("editor.directiveViews.cardTitle")}
197
196
  value={attributes.title ?? ""}
198
197
  onCommit={(v) => update("title", v)}
199
198
  />
@@ -216,7 +215,7 @@ function ContainerDirectiveView() {
216
215
  flow, so its label must not join the document heading outline (see #21). */}
217
216
  <div className="mb-1 font-medium leading-none tracking-tight">
218
217
  <InlineEdit
219
- ariaLabel="Callout title"
218
+ ariaLabel={t("editor.directiveViews.calloutTitle")}
220
219
  placeholder={capitalize(attributes.type ?? "note")}
221
220
  value={attributes.title ?? ""}
222
221
  onCommit={(v) => update("title", v)}
@@ -253,7 +252,9 @@ function ContainerDirectiveView() {
253
252
  className="brand-directive brand-directive--unknown"
254
253
  data-brand-directive={name}
255
254
  >
256
- <div className="mb-1 font-medium leading-none tracking-tight">Unknown block: {name}</div>
255
+ <div className="mb-1 font-medium leading-none tracking-tight">
256
+ {t("editor.directiveViews.unknownBlock", { name })}
257
+ </div>
257
258
  <AlertDescription>{body}</AlertDescription>
258
259
  </Alert>
259
260
  );
@@ -495,6 +496,7 @@ function IterationMenuItems({
495
496
  * inline (today's behaviour, unchanged).
496
497
  */
497
498
  function IterationDirectiveView() {
499
+ const { t } = useLocale();
498
500
  const { contentRef, node, getPos, setAttrs } = useNodeViewContext();
499
501
  const { name, attributes } = useDirectiveAttrs();
500
502
  const [, getInstance] = useInstance();
@@ -580,7 +582,7 @@ function IterationDirectiveView() {
580
582
  attributes: attributes as Record<string, string>,
581
583
  }).cells.length > 0;
582
584
 
583
- const disabledHint = "— needs embedded values";
585
+ const disabledHint = t("editor.directiveViews.needsEmbeddedValues");
584
586
 
585
587
  const convertToStatic = () => {
586
588
  const template = readBodyMarkdown(getInstance as GetEditor, node);
@@ -592,14 +594,14 @@ function IterationDirectiveView() {
592
594
  {
593
595
  type: "item",
594
596
  id: "edit",
595
- label: "Edit iteration…",
597
+ label: t("editor.directiveViews.editIteration"),
596
598
  icon: <Pencil className="size-4" aria-hidden="true" />,
597
599
  onSelect: requestEdit,
598
600
  },
599
601
  {
600
602
  type: "layout",
601
603
  id: "layout",
602
- label: "Change layout",
604
+ label: t("editor.directiveViews.changeLayout"),
603
605
  icon: <LayoutGrid className="size-4" aria-hidden="true" />,
604
606
  value: (attributes.layout as IterationLayout) || ITERATION_LAYOUTS[kind][0]!,
605
607
  options: ITERATION_LAYOUTS[kind],
@@ -610,7 +612,9 @@ function IterationDirectiveView() {
610
612
  {
611
613
  type: "item",
612
614
  id: "transpose",
613
- label: hasEmbeddedData ? "Transpose" : `Transpose ${disabledHint}`,
615
+ label: hasEmbeddedData
616
+ ? t("editor.directiveViews.transpose")
617
+ : `${t("editor.directiveViews.transpose")} ${disabledHint}`,
614
618
  icon: <ArrowLeftRight className="size-4" aria-hidden="true" />,
615
619
  onSelect: transpose,
616
620
  disabled: !hasEmbeddedData,
@@ -620,7 +624,9 @@ function IterationDirectiveView() {
620
624
  {
621
625
  type: "item",
622
626
  id: "convert-to-static",
623
- label: hasEmbeddedData ? "Convert to static" : `Convert to static ${disabledHint}`,
627
+ label: hasEmbeddedData
628
+ ? t("editor.directiveViews.convertToStatic")
629
+ : `${t("editor.directiveViews.convertToStatic")} ${disabledHint}`,
624
630
  icon: <FileText className="size-4" aria-hidden="true" />,
625
631
  onSelect: convertToStatic,
626
632
  disabled: !hasEmbeddedData,
@@ -630,11 +636,15 @@ function IterationDirectiveView() {
630
636
  const header = (
631
637
  <div className="mb-1.5 flex items-center gap-1.5 text-meta font-medium text-info-text">
632
638
  <Icon className="size-3.5 shrink-0" aria-hidden="true" />
633
- <span>{isPivot ? "Pivot" : "Iterate"}</span>
639
+ <span>{isPivot ? t("editor.directiveViews.pivot") : t("editor.directiveViews.iterate")}</span>
634
640
  {!isPivot && attributes.as ? (
635
- <span className="font-normal text-muted-foreground">· per {attributes.as}</span>
641
+ <span className="font-normal text-muted-foreground">
642
+ {t("editor.directiveViews.perItem", { as: attributes.as })}
643
+ </span>
636
644
  ) : null}
637
- <span className="font-normal text-muted-foreground">— template</span>
645
+ <span className="font-normal text-muted-foreground">
646
+ {t("editor.directiveViews.templateSuffix")}
647
+ </span>
638
648
  {onEdit ? (
639
649
  <DropdownMenu>
640
650
  <DropdownMenuTrigger asChild>
@@ -642,9 +652,9 @@ function IterationDirectiveView() {
642
652
  type="button"
643
653
  // `data-directive-chrome` routes the click to the browser, not ProseMirror.
644
654
  data-directive-chrome=""
645
- aria-label="Iteration actions"
646
- 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"
655
+ aria-label={t("editor.directiveViews.iterationActions")}
656
+ title={t("editor.directiveViews.iterationActionsTitle")}
657
+ 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
658
  >
649
659
  <MoreHorizontal className="size-4" aria-hidden="true" />
650
660
  </button>
@@ -703,6 +713,7 @@ function IterationDirectiveView() {
703
713
 
704
714
  /** `::name` leaf directives (e.g. `::metric`) → live, atomic @brand component. */
705
715
  function LeafDirectiveView() {
716
+ const { t } = useLocale();
706
717
  const { name, attributes, update } = useDirectiveAttrs();
707
718
 
708
719
  if (name !== "metric") {
@@ -711,7 +722,7 @@ function LeafDirectiveView() {
711
722
  className="brand-directive brand-directive--leaf brand-directive--unknown rounded-md border border-destructive/40 bg-surface-muted p-3 text-sm text-muted-foreground"
712
723
  data-brand-leaf={name}
713
724
  >
714
- Unknown inline block: <code>::{name}</code>
725
+ {t("editor.directiveViews.unknownInlineBlock")} <code>::{name}</code>
715
726
  </div>
716
727
  );
717
728
  }
@@ -723,16 +734,16 @@ function LeafDirectiveView() {
723
734
  data-brand-leaf="metric"
724
735
  label={
725
736
  <InlineEdit
726
- ariaLabel="Metric label"
727
- placeholder="Label"
737
+ ariaLabel={t("editor.directiveViews.metricLabel")}
738
+ placeholder={t("editor.directiveViews.metricLabelPlaceholder")}
728
739
  value={attributes.label ?? ""}
729
740
  onCommit={(v) => update("label", v)}
730
741
  />
731
742
  }
732
743
  value={
733
744
  <InlineEdit
734
- ariaLabel="Metric value"
735
- placeholder="0"
745
+ ariaLabel={t("editor.directiveViews.metricValue")}
746
+ placeholder={t("editor.directiveViews.metricValuePlaceholder")}
736
747
  value={attributes.value ?? ""}
737
748
  onCommit={(v) => update("value", v)}
738
749
  className="min-w-[1ch]"
@@ -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
  /* ----------------------------------------------------------------------------
@@ -0,0 +1,49 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { readFileSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+
5
+ // Read the stylesheet as text. The repo runs vitest per-package (turbo /
6
+ // `--filter`), so cwd is the package root — `src/...` resolves deterministically.
7
+ const css = readFileSync(resolve(process.cwd(), "src/markdown-editor/markdown-editor.css"), "utf8");
8
+
9
+ /**
10
+ * #309: `.ProseMirror` used to suppress the platform focus outline
11
+ * (`outline: none`, repeated as a dead no-op at `:focus`) with no replacement
12
+ * on the element itself — the only stand-in lived on a DIFFERENT element in a
13
+ * different file (`markdown-editor.tsx`'s wrapper `focus-ring-within` class),
14
+ * an implicit, unlocked, easily-overridden dependency raw CSS can't express.
15
+ * jsdom doesn't apply real focus/outline rendering, so this locks the
16
+ * stylesheet TEXT deterministically; `markdown-editor.stories.tsx`'s
17
+ * `FocusIndicator` play is the rendered-surface proof (resolved computed
18
+ * values, real contrast, both themes).
19
+ */
20
+ describe("editor focus indicator CSS (regression, #309)", () => {
21
+ it("gives the editable its own :focus-visible indicator with a real outline", () => {
22
+ const focusVisibleRule = css.match(/\.milkdown-host \.ProseMirror:focus-visible\s*\{([^}]*)\}/);
23
+ expect(
24
+ focusVisibleRule,
25
+ "expected a .milkdown-host .ProseMirror:focus-visible rule",
26
+ ).not.toBeNull();
27
+ const body = focusVisibleRule![1]!;
28
+ expect(body).toMatch(/outline:\s*(?!none\b)\S/);
29
+ });
30
+
31
+ it("never suppresses the outline on a :focus/:focus-visible rule with nothing else in it", () => {
32
+ // A rule whose ENTIRE body is `outline: none;` (whitespace only otherwise)
33
+ // on a focus selector is exactly the dead-suppression defect #309 found —
34
+ // catches a future edit re-adding `.ProseMirror:focus { outline: none; }`
35
+ // (or a :focus-visible equivalent) with no compensating declaration.
36
+ const focusRules = [...css.matchAll(/([.\w-]+:focus(?:-visible)?)\s*\{([^}]*)\}/g)];
37
+ for (const [, selector, body] of focusRules) {
38
+ const bareOutlineNone = /^\s*outline:\s*none;?\s*$/.test(body!);
39
+ expect(
40
+ bareOutlineNone,
41
+ `${selector} suppresses outline with no replacement in the same rule`,
42
+ ).toBe(false);
43
+ }
44
+ });
45
+
46
+ it("no longer contains the dead .ProseMirror:focus { outline: none; } rule", () => {
47
+ expect(css).not.toMatch(/\.milkdown-host \.ProseMirror:focus\s*\{\s*outline:\s*none;?\s*\}/);
48
+ });
49
+ });
@@ -1,5 +1,5 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react-vite";
2
- import { expect, waitFor, within } from "storybook/test";
2
+ import { expect, userEvent, waitFor, within } from "storybook/test";
3
3
  import { useRef, useState } from "react";
4
4
  import { ThemeProvider } from "@elabs-ai/components-tokens";
5
5
  import { MarkdownEditor, type MarkdownEditorHandle } from "./markdown-editor";
@@ -14,9 +14,12 @@ const meta = {
14
14
  docs: {
15
15
  description: {
16
16
  component:
17
+ "The WYSIWYG markdown surface; the Monaco SOURCE editor is `Editor/CodeEditor` " +
18
+ "and the read-only render is `Editor/MarkdownPreview` — see " +
19
+ "[Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). " +
17
20
  "A headless Milkdown (ProseMirror) WYSIWYG markdown surface vendored onto " +
18
21
  "brand-ui. Companion to the Monaco `CodeEditor` (source) in the same package. " +
19
- "Theming is 100% semantic tokens, so it tracks all three themes via `data-theme`. " +
22
+ "Theming is 100% semantic tokens, so it tracks every theme via `data-theme`. " +
20
23
  "Depends on only `@milkdown/kit` — no Vue/Crepe.",
21
24
  },
22
25
  },
@@ -138,7 +141,7 @@ Two upstream data sources are still un-mapped.
138
141
  * The brand `:::` directives render as the REAL @brand components inside the editor
139
142
  * (a live `Card`, `Alert`, `MetricBlock`), with the card/callout title and the metric
140
143
  * label/value editable inline — not the old token-styled `toDOM` chrome. Switch the
141
- * Storybook theme to confirm they track all three themes.
144
+ * Storybook theme to confirm they track every theme.
142
145
  */
143
146
  export const LiveDirectives: Story = {
144
147
  name: "Live directives (inline-editable)",
@@ -275,9 +278,9 @@ Prose continues normally after the table.
275
278
  * - Tab moves to the next cell; Shift+Tab to the previous.
276
279
  * - The serialized markdown (shown below the editor) stays lossless GFM.
277
280
  *
278
- * Three-theme sweep: switch the Storybook theme toolbar to verify the table
281
+ * Cross-theme sweep: switch the Storybook theme toolbar to verify the table
279
282
  * borders (`border-border-strong`), header tint (`bg-surface-muted`), and
280
- * toolbar chrome use only semantic tokens across all three themes.
283
+ * toolbar chrome use only semantic tokens across every theme.
281
284
  */
282
285
  export const EditableTable: Story = {
283
286
  name: "Editable table (wysiwyg-tables)",
@@ -342,7 +345,14 @@ export const FillsContainer: Story = {
342
345
  await expect(editable).toBeVisible();
343
346
  // The 1-line doc's editable fills the 700px container (well past the ~144px
344
347
  // content floor) — clickable everywhere, with room for the `/` menu.
345
- expect(editable.clientHeight).toBeGreaterThan(500);
348
+ //
349
+ // Waited for, not read once: the fill chain is `.milkdown-host .ProseMirror.editor
350
+ // { flex: 1 1 auto }`, and Milkdown adds the `editor` class in a later commit than
351
+ // the one that puts `role="textbox"` in the DOM. Between the two the editable sits at
352
+ // its own `min-height: 9rem` (144px) inside an already-700px host — measured, not
353
+ // guessed. Reading `clientHeight` on the frame `findByRole` resolves therefore samples
354
+ // a real but transient layout; the settled condition is the one this story is about.
355
+ await waitFor(() => expect(editable.clientHeight).toBeGreaterThan(500), { timeout: 8000 });
346
356
  },
347
357
  };
348
358
 
@@ -504,7 +514,7 @@ export const IterationContextMenu: Story = {
504
514
  };
505
515
 
506
516
  /**
507
- * Three-theme sweep for the node menu (#223 round-2): both `NodeMenu` (⋯) and
517
+ * Cross-theme sweep for the node menu (#223 round-2): both `NodeMenu` (⋯) and
508
518
  * `IterationContextMenu` (right-click) above run only under the toolbar's
509
519
  * DEFAULT theme (`light`) — Storybook's `defaultTheme` isn't overridable
510
520
  * per-story from the global decorator alone, and toggling `preview.tsx` by hand
@@ -558,3 +568,114 @@ export const IterationContextMenuHighDecoration: Story = {
558
568
  render: IterationContextMenu.render,
559
569
  play: IterationContextMenu.play,
560
570
  };
571
+
572
+ /**
573
+ * #309 — the editable's own compound focus indicator. Resolves the governing
574
+ * theme element FROM THE SUBJECT (`el.closest("[data-theme]")`, never a
575
+ * guessed ancestor — quality-gates.md § Theme-safe), reads RESOLVED computed
576
+ * values (not the class string — a class-string assertion passed on the
577
+ * previous, invisible wrapper ring, which is exactly how this shipped), and
578
+ * asserts at least one of the indicator's two layers clears 3:1 against the
579
+ * ground the editable sits on. `markdown-editor.focus.test.ts` locks the
580
+ * stylesheet TEXT; this is the rendered-surface proof.
581
+ */
582
+ export const FocusIndicator: Story = {
583
+ name: "Focus indicator (#309)",
584
+ render: () => (
585
+ <div className="mx-auto max-w-3xl p-6">
586
+ <MarkdownEditor
587
+ defaultValue={"# Focus indicator\n\nClick or Tab into the editable below."}
588
+ aria-label="Markdown editor"
589
+ />
590
+ </div>
591
+ ),
592
+ play: async ({ canvasElement }) => {
593
+ const canvas = within(canvasElement);
594
+ const editable = await canvas.findByRole(
595
+ "textbox",
596
+ { name: "Markdown editor" },
597
+ { timeout: 8000 },
598
+ );
599
+ await expect(editable).toBeVisible();
600
+ expect(editable.classList.contains("ProseMirror")).toBe(true);
601
+
602
+ const wrapper = canvasElement.querySelector<HTMLElement>('[data-testid="markdown-editor"]');
603
+ expect(wrapper).not.toBeNull();
604
+
605
+ // Resolve ANY CSS colour string down to sRGB so a contrast ratio is a
606
+ // measurement, not an assumption — same helper `FlowNode`'s
607
+ // `FocusIndicator` lock uses for the flow-canvas half of this same fix
608
+ // family (#286).
609
+ const toSrgb = (colour: string): [number, number, number] => {
610
+ const surface = document.createElement("canvas");
611
+ surface.width = 1;
612
+ surface.height = 1;
613
+ const ctx = surface.getContext("2d")!;
614
+ ctx.fillStyle = colour;
615
+ ctx.fillRect(0, 0, 1, 1);
616
+ const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
617
+ return [r! / 255, g! / 255, b! / 255];
618
+ };
619
+ const luminance = ([r, g, b]: [number, number, number]) => {
620
+ const lin = (v: number) => (v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4));
621
+ return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
622
+ };
623
+ const contrast = (a: string, b: string) => {
624
+ const [hi, lo] = [luminance(toSrgb(a)), luminance(toSrgb(b))].sort((x, y) => y - x);
625
+ return (hi! + 0.05) / (lo! + 0.05);
626
+ };
627
+
628
+ // Resting: no indicator on the editable, and the wrapper draws no ring of
629
+ // its own either (#309 — exactly one indicator, never a doubled one).
630
+ await expect(getComputedStyle(editable).outlineStyle).toBe("none");
631
+ await expect(getComputedStyle(wrapper!).boxShadow).toBe("none");
632
+
633
+ // Click into the editable. `:focus-visible` matches on pointer click for
634
+ // a contenteditable surface per spec (unlike a plain <input>/<button>),
635
+ // so this is a faithful proxy for the real "click to edit" path.
636
+ await userEvent.click(editable);
637
+ await waitFor(() => {
638
+ expect(getComputedStyle(editable).outlineStyle).toBe("solid");
639
+ });
640
+
641
+ // Exactly one indicator: the wrapper still draws nothing.
642
+ await expect(getComputedStyle(wrapper!).boxShadow).toBe("none");
643
+
644
+ const ground = getComputedStyle(wrapper!).backgroundColor;
645
+ const contourInk = getComputedStyle(editable).outlineColor;
646
+ const ringInk = getComputedStyle(editable).getPropertyValue("--ring").trim();
647
+ const contourRatio = contrast(contourInk, ground);
648
+ const ringRatio = contrast(ringInk, ground);
649
+ const bestRatio = Math.max(contourRatio, ringRatio);
650
+ await expect(
651
+ bestRatio,
652
+ `focus indicator vs editor ground: contour ${contourInk} = ${contourRatio.toFixed(2)}:1, ring ${ringInk} = ${ringRatio.toFixed(2)}:1`,
653
+ ).toBeGreaterThanOrEqual(3);
654
+
655
+ // Blur restores the resting state.
656
+ (editable as HTMLElement).blur();
657
+ await waitFor(() => {
658
+ expect(getComputedStyle(editable).outlineStyle).toBe("none");
659
+ });
660
+ },
661
+ };
662
+
663
+ export const FocusIndicatorDark: Story = {
664
+ name: "Focus indicator (#309) — dark",
665
+ decorators: [
666
+ (Story) => (
667
+ <ThemeProvider defaultTheme="dark" storageKey={null}>
668
+ <Story />
669
+ </ThemeProvider>
670
+ ),
671
+ ],
672
+ render: FocusIndicator.render,
673
+ play: FocusIndicator.play,
674
+ };
675
+
676
+ export const FocusIndicatorHighDecoration: Story = {
677
+ name: "Focus indicator (#309) — high decoration",
678
+ globals: { decoration: "10" },
679
+ render: FocusIndicator.render,
680
+ play: FocusIndicator.play,
681
+ };
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * - Engine dep: only `@milkdown/kit` (headless). React glue is vendored under
9
9
  * ./milkdown-react so we never pull `@milkdown/react` → `@milkdown/crepe` → Vue.
10
- * - Theming: token-driven via markdown-editor.css (all three themes, no raw color).
10
+ * - Theming: token-driven via markdown-editor.css (every theme, no raw color).
11
11
  * - Controlled (`value` + `onChange`) or uncontrolled (`defaultValue`). Mirrors the
12
12
  * platform: `isControlled = value !== undefined`; never flips between modes.
13
13
  * - StrictMode-safe (see markdown-editor.strictmode.test.tsx).
@@ -527,10 +527,13 @@ export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorPro
527
527
  <div
528
528
  data-testid="markdown-editor"
529
529
  className={cn(
530
- // A 1px hairline focus ring (not a heavy 2px ring) the editable is a
531
- // large surface, so a thinner edit-mode ring reads calmer while still
532
- // meeting the visible-focus requirement (same `ring` token). (A7)
533
- "milkdown-host overflow-auto rounded-md border border-border bg-background text-foreground focus-within:ring-1 focus-within:ring-ring",
530
+ // No focus-ring utility here (deliberately, #309): the editable
531
+ // `.ProseMirror` element owns the compound focus indicator itself
532
+ // (`markdown-editor.css`'s `:focus-visible` rule), so it renders
533
+ // even when a consumer `className` overrides this wrapper's
534
+ // classes (`MarkdownWorkspace` does exactly that via
535
+ // `className="border-0"`) and never doubles up with a wrapper ring.
536
+ "milkdown-host overflow-auto rounded-md border border-border bg-background text-foreground",
534
537
  className,
535
538
  )}
536
539
  // Publish the shared markdown scale as CSS vars the editor CSS reads, so the
@@ -0,0 +1,80 @@
1
+ import { cleanup, render, screen, waitFor } from "@testing-library/react";
2
+ import { createElement } from "react";
3
+ import { afterEach, expect, test, vi } from "vitest";
4
+
5
+ import { MarkdownEditor } from "../markdown-editor";
6
+ import { waitForPendingMilkdownTeardown } from "./use-get-editor";
7
+
8
+ afterEach(cleanup);
9
+
10
+ // @milkdown/ctx's internal `Timer` (armed by `Ctx.wait()`, which
11
+ // `@milkdown/core`'s `create()`/`destroy()` call internally for every
12
+ // `createTimer(name)` — e.g. `ConfigReady`, `InitReady`, `EditorViewReady`)
13
+ // uses `TimerType`'s default delay: `createTimer(name, timeout = 3e3)`,
14
+ // never overridden anywhere in this repo's editor setup.
15
+ const MILKDOWN_TIMER_DELAY_MS = 3000;
16
+
17
+ /**
18
+ * Locks issue #84: `@milkdown/ctx`'s `Timer` class starts a 3-second
19
+ * `setTimeout` on every wait and never clears the handle on the resolve
20
+ * path — only the DOM/global event listener is removed
21
+ * (`lib/index.js:281-288`, vendored `@milkdown/ctx@7.21.2`). That leaves the
22
+ * timer armed for up to 3s after the promise it backs has already settled,
23
+ * and if it fires after Vitest has recycled the test file's jsdom
24
+ * environment, its callback's bare (unqualified) `removeEventListener` call
25
+ * throws `ReferenceError: removeEventListener is not defined` — the
26
+ * mechanism behind #65's originally-reported error.
27
+ *
28
+ * A bare "was `clearTimeout` called at some point" assertion would pass
29
+ * regardless of this bug, since unrelated React/ProseMirror machinery also
30
+ * calls `clearTimeout`. So this test tracks every `setTimeout` handle armed
31
+ * with Milkdown's exact 3-second delay, and every `clearTimeout` call, and
32
+ * asserts each such handle is cleared once Milkdown's own teardown
33
+ * (`waitForPendingMilkdownTeardown`, tracked in `use-get-editor.ts`) has
34
+ * settled. Against the unpatched `@milkdown/ctx` dependency this fails (no
35
+ * `clearTimeout` call in the vendored file at all — `grep -c clearTimeout`
36
+ * returns 0); the `patches/@milkdown__ctx@7.21.2.patch` fix (storing the
37
+ * handle and clearing it in `#removeListener`, which both the resolve and
38
+ * reject/timeout paths already call) makes it pass.
39
+ */
40
+ test("clears the vendored @milkdown/ctx wait timer on teardown (#84)", async () => {
41
+ const originalSetTimeout = globalThis.setTimeout;
42
+ const originalClearTimeout = globalThis.clearTimeout;
43
+
44
+ const armedHandles = new Set<ReturnType<typeof setTimeout>>();
45
+ const clearedHandles = new Set<Parameters<typeof clearTimeout>[0]>();
46
+
47
+ vi.spyOn(globalThis, "setTimeout").mockImplementation(((
48
+ handler: TimerHandler,
49
+ timeout?: number,
50
+ ...args: unknown[]
51
+ ) => {
52
+ const handle = originalSetTimeout(handler as never, timeout, ...args);
53
+ if (timeout === MILKDOWN_TIMER_DELAY_MS) armedHandles.add(handle);
54
+ return handle;
55
+ }) as unknown as typeof setTimeout);
56
+
57
+ vi.spyOn(globalThis, "clearTimeout").mockImplementation(((
58
+ handle?: Parameters<typeof clearTimeout>[0],
59
+ ) => {
60
+ clearedHandles.add(handle);
61
+ return originalClearTimeout(handle as never);
62
+ }) as unknown as typeof clearTimeout);
63
+
64
+ const { unmount } = render(createElement(MarkdownEditor, { defaultValue: "# Hello Timer" }));
65
+ await waitFor(() => expect(screen.getByText("Hello Timer")).toBeInTheDocument());
66
+
67
+ unmount();
68
+ await waitForPendingMilkdownTeardown();
69
+
70
+ vi.restoreAllMocks();
71
+
72
+ // Sanity: the mechanism under test actually armed at least one 3-second
73
+ // wait timer during create()/destroy() — otherwise the loop below would
74
+ // trivially pass over an empty set.
75
+ expect(armedHandles.size).toBeGreaterThan(0);
76
+
77
+ for (const handle of armedHandles) {
78
+ expect(clearedHandles.has(handle)).toBe(true);
79
+ }
80
+ });