@elabs-ai/components-editor 4.0.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 (207) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -0
  3. package/dist/chunk-LBC5VJBD.js +3748 -0
  4. package/dist/chunk-LBC5VJBD.js.map +1 -0
  5. package/dist/index.css +233 -0
  6. package/dist/index.css.map +1 -0
  7. package/dist/index.d.ts +199 -0
  8. package/dist/index.js +362 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/lib/monaco-environment.d.ts +2 -0
  11. package/dist/lib/monaco-environment.js +44 -0
  12. package/dist/lib/monaco-environment.js.map +1 -0
  13. package/dist/markdown/frontmatter.d.ts +11 -0
  14. package/dist/markdown/frontmatter.js +33 -0
  15. package/dist/markdown/frontmatter.js.map +1 -0
  16. package/dist/markdown/index.css +233 -0
  17. package/dist/markdown/index.css.map +1 -0
  18. package/dist/markdown/index.d.ts +1486 -0
  19. package/dist/markdown/index.js +4882 -0
  20. package/dist/markdown/index.js.map +1 -0
  21. package/dist/markdown/parse.d.ts +25 -0
  22. package/dist/markdown/parse.js +14 -0
  23. package/dist/markdown/parse.js.map +1 -0
  24. package/dist/markdown-editor-DfBZibAn.d.ts +702 -0
  25. package/package.json +101 -0
  26. package/src/ai-objects/ai-objects-integration.test.tsx +128 -0
  27. package/src/ai-objects/ai-objects.stories.tsx +233 -0
  28. package/src/ai-objects/ai-objects.test.tsx +205 -0
  29. package/src/ai-objects/decision-card.tsx +178 -0
  30. package/src/ai-objects/directives.ts +136 -0
  31. package/src/ai-objects/entity.tsx +181 -0
  32. package/src/ai-objects/index.ts +34 -0
  33. package/src/ai-objects/knowledge-card.tsx +143 -0
  34. package/src/calc-block/calc-block.stories.tsx +297 -0
  35. package/src/calc-block/calc-block.test.tsx +164 -0
  36. package/src/calc-block/calc-block.tsx +398 -0
  37. package/src/calc-block/calc-editor-monaco.ts +214 -0
  38. package/src/calc-block/calc-editor-prose.ts +128 -0
  39. package/src/calc-block/calc-editor.css +80 -0
  40. package/src/calc-block/calc-editor.stories.tsx +272 -0
  41. package/src/calc-block/calc-editor.test.ts +228 -0
  42. package/src/calc-block/calc-editor.ts +270 -0
  43. package/src/calc-block/calc-inline.stories.tsx +44 -0
  44. package/src/calc-block/calc-inline.test.tsx +44 -0
  45. package/src/calc-block/calc-inline.tsx +75 -0
  46. package/src/calc-block/index.ts +20 -0
  47. package/src/calc-block/types.ts +164 -0
  48. package/src/code-editor/code-editor.stories.tsx +218 -0
  49. package/src/code-editor/code-editor.test.tsx +134 -0
  50. package/src/code-editor/code-editor.tsx +250 -0
  51. package/src/code-editor/index.ts +6 -0
  52. package/src/code-workspace/code-workspace.stories.tsx +51 -0
  53. package/src/code-workspace/code-workspace.test.tsx +146 -0
  54. package/src/code-workspace/code-workspace.tsx +253 -0
  55. package/src/code-workspace/index.ts +6 -0
  56. package/src/copy-button/copy-button.tsx +51 -0
  57. package/src/copy-button/index.ts +1 -0
  58. package/src/css.d.ts +3 -0
  59. package/src/diff-editor/diff-editor.stories.tsx +44 -0
  60. package/src/diff-editor/diff-editor.test.tsx +68 -0
  61. package/src/diff-editor/diff-editor.tsx +151 -0
  62. package/src/diff-editor/index.ts +1 -0
  63. package/src/editor-context-menu/editor-context-menu.tsx +120 -0
  64. package/src/editor-context-menu/index.ts +1 -0
  65. package/src/editor-toolbar/editor-toolbar.tsx +83 -0
  66. package/src/editor-toolbar/index.ts +1 -0
  67. package/src/index.ts +48 -0
  68. package/src/lib/editor-completions-monaco.test.ts +206 -0
  69. package/src/lib/editor-completions-monaco.ts +127 -0
  70. package/src/lib/editor-completions.test.ts +156 -0
  71. package/src/lib/editor-completions.ts +153 -0
  72. package/src/lib/editor-content-access-prose.test.ts +411 -0
  73. package/src/lib/editor-content-access-prose.ts +172 -0
  74. package/src/lib/editor-content-access.test.ts +182 -0
  75. package/src/lib/editor-content-access.ts +109 -0
  76. package/src/lib/languages.ts +32 -0
  77. package/src/lib/markdown/diff.test.ts +80 -0
  78. package/src/lib/markdown/diff.ts +194 -0
  79. package/src/lib/markdown/directives.ts +342 -0
  80. package/src/lib/markdown/frontmatter.ts +50 -0
  81. package/src/lib/markdown/markdown-scale.test.ts +65 -0
  82. package/src/lib/markdown/markdown-scale.ts +62 -0
  83. package/src/lib/markdown/merge.test.ts +64 -0
  84. package/src/lib/markdown/merge.ts +158 -0
  85. package/src/lib/markdown/slugify.test.ts +156 -0
  86. package/src/lib/markdown/slugify.ts +47 -0
  87. package/src/lib/monaco-environment.ts +103 -0
  88. package/src/lib/monaco-theme-bridge.ts +293 -0
  89. package/src/lib/use-data-theme.ts +61 -0
  90. package/src/markdown/frontmatter.ts +14 -0
  91. package/src/markdown/index.ts +323 -0
  92. package/src/markdown/parse.test.ts +60 -0
  93. package/src/markdown/parse.ts +38 -0
  94. package/src/markdown-academic/citations.test.ts +77 -0
  95. package/src/markdown-academic/citations.tsx +442 -0
  96. package/src/markdown-academic/footnotes.tsx +235 -0
  97. package/src/markdown-academic/index.ts +32 -0
  98. package/src/markdown-academic/math.tsx +163 -0
  99. package/src/markdown-academic/toc.tsx +88 -0
  100. package/src/markdown-editor/completions/completions-menu.tsx +96 -0
  101. package/src/markdown-editor/completions/completions-prose.test.ts +356 -0
  102. package/src/markdown-editor/completions/completions-prose.ts +313 -0
  103. package/src/markdown-editor/completions/completions-widget.tsx +75 -0
  104. package/src/markdown-editor/completions/index.ts +37 -0
  105. package/src/markdown-editor/directive-nodes.ts +151 -0
  106. package/src/markdown-editor/directive-views.test.tsx +189 -0
  107. package/src/markdown-editor/directive-views.tsx +795 -0
  108. package/src/markdown-editor/exit-keymap.test.ts +88 -0
  109. package/src/markdown-editor/exit-keymap.ts +87 -0
  110. package/src/markdown-editor/index.ts +6 -0
  111. package/src/markdown-editor/markdown-editor.css +250 -0
  112. package/src/markdown-editor/markdown-editor.directives.test.tsx +105 -0
  113. package/src/markdown-editor/markdown-editor.fill.test.ts +34 -0
  114. package/src/markdown-editor/markdown-editor.paste-embed.test.tsx +297 -0
  115. package/src/markdown-editor/markdown-editor.stories.tsx +560 -0
  116. package/src/markdown-editor/markdown-editor.strictmode.test.tsx +38 -0
  117. package/src/markdown-editor/markdown-editor.table.test.tsx +287 -0
  118. package/src/markdown-editor/markdown-editor.tsx +560 -0
  119. package/src/markdown-editor/milkdown-react/editor.tsx +33 -0
  120. package/src/markdown-editor/milkdown-react/index.ts +8 -0
  121. package/src/markdown-editor/milkdown-react/types.ts +27 -0
  122. package/src/markdown-editor/milkdown-react/use-editor.ts +27 -0
  123. package/src/markdown-editor/milkdown-react/use-get-editor.ts +56 -0
  124. package/src/markdown-editor/milkdown-react/use-instance.ts +23 -0
  125. package/src/markdown-editor/paste-embed.ts +355 -0
  126. package/src/markdown-editor/slash/brand-slash-commands.test.ts +194 -0
  127. package/src/markdown-editor/slash/brand-slash-commands.ts +324 -0
  128. package/src/markdown-editor/slash/brand-slash-plugin.test.ts +254 -0
  129. package/src/markdown-editor/slash/brand-slash-plugin.ts +385 -0
  130. package/src/markdown-editor/slash/index.ts +107 -0
  131. package/src/markdown-editor/slash/insert-directive.test.ts +263 -0
  132. package/src/markdown-editor/slash/insert-directive.ts +345 -0
  133. package/src/markdown-editor/slash/monaco-slash-menu.test.tsx +198 -0
  134. package/src/markdown-editor/slash/monaco-slash-menu.tsx +356 -0
  135. package/src/markdown-editor/slash/shortcut-monaco.ts +69 -0
  136. package/src/markdown-editor/slash/shortcut.test.ts +157 -0
  137. package/src/markdown-editor/slash/shortcut.ts +57 -0
  138. package/src/markdown-editor/slash/slash-menu.stories.tsx +139 -0
  139. package/src/markdown-editor/slash/slash-menu.tsx +115 -0
  140. package/src/markdown-editor/slash/slash-scroll.test.tsx +52 -0
  141. package/src/markdown-editor/slash/slash-widget.tsx +97 -0
  142. package/src/markdown-editor/slash/source-slash-trigger.test.ts +42 -0
  143. package/src/markdown-editor/slash/source-slash-trigger.ts +46 -0
  144. package/src/markdown-editor/table-view.tsx +249 -0
  145. package/src/markdown-iteration/directive.tsx +92 -0
  146. package/src/markdown-iteration/edit-context.ts +56 -0
  147. package/src/markdown-iteration/index.ts +24 -0
  148. package/src/markdown-iteration/iteration-block.test.tsx +138 -0
  149. package/src/markdown-iteration/iteration-builder-dialog.stories.tsx +293 -0
  150. package/src/markdown-iteration/iteration-builder-dialog.tsx +312 -0
  151. package/src/markdown-iteration/iteration-builder.test.ts +252 -0
  152. package/src/markdown-iteration/iteration-builder.ts +303 -0
  153. package/src/markdown-iteration/iteration.test.ts +56 -0
  154. package/src/markdown-iteration/iteration.tsx +333 -0
  155. package/src/markdown-iteration/template-dialog.stories.tsx +63 -0
  156. package/src/markdown-iteration/template-dialog.test.tsx +104 -0
  157. package/src/markdown-iteration/template-dialog.tsx +116 -0
  158. package/src/markdown-outline/document-outline.stories.tsx +76 -0
  159. package/src/markdown-outline/document-outline.tsx +83 -0
  160. package/src/markdown-outline/index.ts +2 -0
  161. package/src/markdown-outline/markdown-outline.test.ts +49 -0
  162. package/src/markdown-outline/markdown-outline.ts +60 -0
  163. package/src/markdown-preview/code-fence.tsx +193 -0
  164. package/src/markdown-preview/index.ts +8 -0
  165. package/src/markdown-preview/markdown-preview-academic.stories.tsx +203 -0
  166. package/src/markdown-preview/markdown-preview-academic.test.tsx +257 -0
  167. package/src/markdown-preview/markdown-preview-iteration-calc.test.tsx +71 -0
  168. package/src/markdown-preview/markdown-preview-iteration-nested.test.tsx +58 -0
  169. package/src/markdown-preview/markdown-preview-iteration.stories.tsx +187 -0
  170. package/src/markdown-preview/markdown-preview-iteration.test.tsx +90 -0
  171. package/src/markdown-preview/markdown-preview-linking.test.tsx +61 -0
  172. package/src/markdown-preview/markdown-preview-transclusion.test.tsx +33 -0
  173. package/src/markdown-preview/markdown-preview.stories.tsx +108 -0
  174. package/src/markdown-preview/markdown-preview.test.tsx +411 -0
  175. package/src/markdown-preview/markdown-preview.tsx +1541 -0
  176. package/src/markdown-toolbar/index.ts +8 -0
  177. package/src/markdown-toolbar/markdown-commands.test.ts +84 -0
  178. package/src/markdown-toolbar/markdown-commands.ts +104 -0
  179. package/src/markdown-toolbar/markdown-toolbar.stories.tsx +56 -0
  180. package/src/markdown-toolbar/markdown-toolbar.tsx +263 -0
  181. package/src/markdown-workspace/focus-writing.test.ts +52 -0
  182. package/src/markdown-workspace/focus-writing.ts +38 -0
  183. package/src/markdown-workspace/index.ts +6 -0
  184. package/src/markdown-workspace/markdown-workspace.stories.tsx +688 -0
  185. package/src/markdown-workspace/markdown-workspace.test.tsx +326 -0
  186. package/src/markdown-workspace/markdown-workspace.tsx +787 -0
  187. package/src/mermaid-diagram/index.ts +1 -0
  188. package/src/mermaid-diagram/mermaid-diagram.stories.tsx +42 -0
  189. package/src/mermaid-diagram/mermaid-diagram.test.tsx +86 -0
  190. package/src/mermaid-diagram/mermaid-diagram.tsx +318 -0
  191. package/src/mermaid-diagram/mermaid-viewer.test.tsx +89 -0
  192. package/src/mermaid-diagram/mermaid-viewer.tsx +367 -0
  193. package/src/mermaid-diagram/real-parse.test.ts +42 -0
  194. package/src/mermaid-diagram/remediate.test.ts +60 -0
  195. package/src/mermaid-diagram/remediate.ts +124 -0
  196. package/src/mermaid-workspace/index.ts +1 -0
  197. package/src/mermaid-workspace/mermaid-workspace.stories.tsx +28 -0
  198. package/src/mermaid-workspace/mermaid-workspace.tsx +71 -0
  199. package/src/metric-block/index.ts +1 -0
  200. package/src/metric-block/metric-block.stories.tsx +40 -0
  201. package/src/metric-block/metric-block.tsx +14 -0
  202. package/src/monaco-workers.d.ts +10 -0
  203. package/src/prose/index.ts +14 -0
  204. package/src/prose/prose.stories.tsx +44 -0
  205. package/src/prose/prose.test.tsx +44 -0
  206. package/src/prose/prose.tsx +21 -0
  207. package/src/timeline/index.ts +18 -0
@@ -0,0 +1,560 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { expect, waitFor, within } from "storybook/test";
3
+ import { useRef, useState } from "react";
4
+ import { ThemeProvider } from "@elabs-ai/components-tokens";
5
+ import { MarkdownEditor, type MarkdownEditorHandle } from "./markdown-editor";
6
+ import { IterationBuilderProvider } from "../markdown-iteration/iteration-builder-dialog";
7
+
8
+ const meta = {
9
+ title: "Editor/MarkdownEditor",
10
+ component: MarkdownEditor,
11
+ tags: ["autodocs"],
12
+ parameters: {
13
+ layout: "fullscreen",
14
+ docs: {
15
+ description: {
16
+ component:
17
+ "A headless Milkdown (ProseMirror) WYSIWYG markdown surface vendored onto " +
18
+ "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`. " +
20
+ "Depends on only `@milkdown/kit` — no Vue/Crepe.",
21
+ },
22
+ },
23
+ },
24
+ } satisfies Meta<typeof MarkdownEditor>;
25
+
26
+ export default meta;
27
+ type Story = StoryObj<typeof meta>;
28
+
29
+ /**
30
+ * Post-condition every node-menu play owes (#386). A Radix MODAL menu marks the
31
+ * rest of the document `aria-hidden` while it is open, so a play that returns
32
+ * with one still up (or still tearing down) fails axe's `aria-hidden-focus` —
33
+ * and the marker survives into the NEXT story in this file, where `findByRole`
34
+ * then can't see the `⋯` button at all ("Unable to find role=button and name
35
+ * 'Iteration actions'") even though `findByText` still matches, because only
36
+ * role queries consult the accessibility tree. Waiting on the marker is the
37
+ * behavioural assertion "dismissing the menu restores the document".
38
+ */
39
+ async function expectDocumentRestored(canvasElement: HTMLElement) {
40
+ const doc = canvasElement.ownerDocument;
41
+ await waitFor(() => expect(within(doc.body).queryByRole("menu")).toBeNull());
42
+ await waitFor(() => expect(doc.querySelector('[data-aria-hidden="true"]')).toBeNull());
43
+ }
44
+
45
+ const SAMPLE = `# Customer Objective
46
+
47
+ Migrate **1,245 reports** to the new platform by Q3. Track scope, owners and risk.
48
+
49
+ ## Checklist
50
+
51
+ - [x] Freeze scope
52
+ - [ ] Map data sources
53
+ - [ ] Pilot with finance
54
+
55
+ > Frozen scope candidates are the migration baseline.
56
+
57
+ | Phase | Owner | Status |
58
+ | ------ | ------- | ------ |
59
+ | Draft | Manuel | done |
60
+ | Review | Team | active |
61
+
62
+ Inline \`code\`, a [link](https://example.com), and a divider:
63
+
64
+ ---
65
+
66
+ \`\`\`ts
67
+ export const scope = 1245;
68
+ \`\`\`
69
+ `;
70
+
71
+ export const Default: Story = {
72
+ render: () => (
73
+ <div className="mx-auto max-w-3xl p-6">
74
+ <MarkdownEditor defaultValue={SAMPLE} />
75
+ </div>
76
+ ),
77
+ play: async ({ canvasElement }) => {
78
+ // Milkdown mounts asynchronously — wait for the rendered heading.
79
+ const canvas = within(canvasElement);
80
+ await expect(
81
+ await canvas.findByText("Customer Objective", {}, { timeout: 8000 }),
82
+ ).toBeVisible();
83
+ // #21: the contenteditable role="textbox" surface must have an accessible name
84
+ // (axe `aria-input-field-name`). Defaults to "Markdown editor".
85
+ await expect(
86
+ await canvas.findByRole("textbox", { name: "Markdown editor" }, { timeout: 8000 }),
87
+ ).toBeVisible();
88
+ },
89
+ };
90
+
91
+ export const Controlled: Story = {
92
+ render: function ControlledStory() {
93
+ const [value, setValue] = useState("# Type here\n\nEdits flow through `onChange`.");
94
+ return (
95
+ <div className="mx-auto flex max-w-3xl flex-col gap-3 p-6">
96
+ <MarkdownEditor value={value} onChange={setValue} />
97
+ <p className="text-meta text-muted-foreground">{value.length} characters</p>
98
+ </div>
99
+ );
100
+ },
101
+ };
102
+
103
+ export const ReadOnly: Story = {
104
+ name: "Read-only",
105
+ render: () => (
106
+ <div className="mx-auto max-w-3xl p-6">
107
+ <MarkdownEditor defaultValue={SAMPLE} readOnly />
108
+ </div>
109
+ ),
110
+ };
111
+
112
+ const DIRECTIVES = `# Migration plan
113
+
114
+ :::card{title="Objective"}
115
+
116
+ Migrate the reporting estate to the new platform by Q3.
117
+
118
+ :::
119
+
120
+ :::callout{type="warning" title="At risk"}
121
+
122
+ Two upstream data sources are still un-mapped.
123
+
124
+ :::
125
+
126
+ ::metric{label="Reports in scope" value="1,245" description="across 9 domains" delta="+12%"}
127
+
128
+ :::timeline
129
+
130
+ - (done) Freeze scope
131
+ - (active) Map data sources
132
+ - (pending) Pilot with finance
133
+
134
+ :::
135
+ `;
136
+
137
+ /**
138
+ * The brand `:::` directives render as the REAL @brand components inside the editor
139
+ * (a live `Card`, `Alert`, `MetricBlock`), with the card/callout title and the metric
140
+ * label/value editable inline — not the old token-styled `toDOM` chrome. Switch the
141
+ * Storybook theme to confirm they track all three themes.
142
+ */
143
+ export const LiveDirectives: Story = {
144
+ name: "Live directives (inline-editable)",
145
+ render: () => (
146
+ <div className="mx-auto max-w-3xl p-6">
147
+ <MarkdownEditor defaultValue={DIRECTIVES} />
148
+ </div>
149
+ ),
150
+ play: async ({ canvasElement }) => {
151
+ const canvas = within(canvasElement);
152
+ // The directive node-views expose their editable attributes as textboxes.
153
+ await expect(
154
+ await canvas.findByRole("textbox", { name: "Card title" }, { timeout: 8000 }),
155
+ ).toBeVisible();
156
+ await expect(canvas.getByRole("textbox", { name: "Metric value" })).toHaveTextContent("1,245");
157
+ // The callout renders as a real Alert (role="alert").
158
+ await expect(canvas.getByRole("alert")).toBeVisible();
159
+ },
160
+ };
161
+
162
+ /**
163
+ * Demonstrates `onEmbedAsset` — the paste/drop image-embed hook.
164
+ *
165
+ * The mock hook simulates a 600 ms upload delay and resolves with a data-URL
166
+ * so no real server is needed. To exercise it manually:
167
+ * 1. Copy an image file to your clipboard (or drag one onto the editor).
168
+ * 2. Paste / drop into the editor surface.
169
+ * 3. You will see the "Uploading…" chip appear, then the image render inline.
170
+ * 4. The markdown output below shows `![filename](/demo/uploads/...)`.
171
+ *
172
+ * The error variant is toggled by the "Simulate failure" button — it wires a
173
+ * hook that always rejects so you can observe the inline error chip + toast.
174
+ *
175
+ * Theme sweep: switch the Storybook theme (toolbar) to verify the upload chip
176
+ * and error chip use only semantic tokens across light/dark.
177
+ */
178
+ export const PasteEmbed: Story = {
179
+ name: "Paste / drop image embed (onEmbedAsset)",
180
+ render: function PasteEmbedStory() {
181
+ const ref = useRef<MarkdownEditorHandle>(null);
182
+ const [markdown, setMarkdown] = useState("# Image embed demo\n\nPaste or drop an image here.");
183
+ const [simulateFailure, setSimulateFailure] = useState(false);
184
+ const [lastPath, setLastPath] = useState<string | null>(null);
185
+
186
+ const onEmbedAsset = async (file: File): Promise<string> => {
187
+ if (simulateFailure) {
188
+ await new Promise((_, reject) =>
189
+ setTimeout(() => reject(new Error("Simulated upload failure")), 600),
190
+ );
191
+ throw new Error("Simulated upload failure");
192
+ }
193
+ // Simulate upload delay: read file as data-URL and return it.
194
+ return new Promise<string>((resolve, reject) => {
195
+ const reader = new FileReader();
196
+ reader.onload = () => {
197
+ const path = `/demo/uploads/${Date.now()}-${file.name}`;
198
+ setLastPath(path);
199
+ resolve(path);
200
+ };
201
+ reader.onerror = () => reject(new Error("FileReader error"));
202
+ reader.readAsDataURL(file);
203
+ });
204
+ };
205
+
206
+ return (
207
+ <div className="mx-auto flex max-w-3xl flex-col gap-3 p-6">
208
+ <div className="flex items-center gap-3">
209
+ <label className="flex items-center gap-2 text-caption text-muted-foreground">
210
+ <input
211
+ type="checkbox"
212
+ checked={simulateFailure}
213
+ onChange={(e) => setSimulateFailure(e.target.checked)}
214
+ />
215
+ Simulate upload failure
216
+ </label>
217
+ {lastPath && (
218
+ <span className="text-caption text-muted-foreground">
219
+ Last resolved path: <code>{lastPath}</code>
220
+ </span>
221
+ )}
222
+ </div>
223
+ <MarkdownEditor
224
+ ref={ref}
225
+ value={markdown}
226
+ onChange={setMarkdown}
227
+ onEmbedAsset={onEmbedAsset}
228
+ className="min-h-[200px]"
229
+ />
230
+ <details>
231
+ <summary className="cursor-pointer text-caption text-muted-foreground">
232
+ Serialized markdown
233
+ </summary>
234
+ <pre className="mt-1 overflow-auto rounded bg-muted p-3 text-caption">{markdown}</pre>
235
+ </details>
236
+ </div>
237
+ );
238
+ },
239
+ play: async ({ canvasElement }) => {
240
+ const canvas = within(canvasElement);
241
+ // Wait for the editor to boot.
242
+ await expect(
243
+ await canvas.findByRole("textbox", { name: "Markdown editor" }, { timeout: 8000 }),
244
+ ).toBeVisible();
245
+ // The editor surface is ready; paste/drop interaction requires a real
246
+ // ClipboardEvent with files, which Storybook interaction tests can't
247
+ // synthesize reliably across browsers. Verify the surface is accessible.
248
+ await expect(canvas.getByRole("textbox", { name: "Markdown editor" })).toBeVisible();
249
+ },
250
+ };
251
+
252
+ /** GFM table seed for the EditableTable story. */
253
+ const TABLE_DOC = `# Project status
254
+
255
+ Click inside any table cell to reveal the **Table controls** toolbar below the
256
+ editor. Use it to add or remove rows and columns. Tab / Shift+Tab move between
257
+ cells. The table serializes back to lossless GFM markdown.
258
+
259
+ | Phase | Owner | Status | Due |
260
+ | -------- | ------- | ------- | ------- |
261
+ | Planning | Alice | done | 2024-Q1 |
262
+ | Build | Bob | active | 2024-Q2 |
263
+ | Review | Charlie | pending | 2024-Q3 |
264
+
265
+ Prose continues normally after the table.
266
+ `;
267
+
268
+ /**
269
+ * Demonstrates the table node-view feature:
270
+ *
271
+ * - Click inside a table cell → the **Table controls** toolbar appears at the
272
+ * bottom of the editor surface.
273
+ * - Toolbar buttons: Add row above/below, Delete row, Add col left/right,
274
+ * Delete column — all aria-labelled, keyboard-operable.
275
+ * - Tab moves to the next cell; Shift+Tab to the previous.
276
+ * - The serialized markdown (shown below the editor) stays lossless GFM.
277
+ *
278
+ * Three-theme sweep: switch the Storybook theme toolbar to verify the table
279
+ * borders (`border-border-strong`), header tint (`bg-surface-muted`), and
280
+ * toolbar chrome use only semantic tokens across all three themes.
281
+ */
282
+ export const EditableTable: Story = {
283
+ name: "Editable table (wysiwyg-tables)",
284
+ render: function EditableTableStory() {
285
+ const ref = useRef<MarkdownEditorHandle>(null);
286
+ const [markdown, setMarkdown] = useState(TABLE_DOC);
287
+ return (
288
+ <div className="mx-auto flex max-w-3xl flex-col gap-3 p-6">
289
+ <MarkdownEditor
290
+ ref={ref}
291
+ value={markdown}
292
+ onChange={setMarkdown}
293
+ className="min-h-[280px]"
294
+ />
295
+ <details>
296
+ <summary className="cursor-pointer text-caption text-muted-foreground">
297
+ Serialized markdown (GFM round-trip)
298
+ </summary>
299
+ <pre className="mt-1 overflow-auto rounded bg-muted p-3 text-caption">{markdown}</pre>
300
+ </details>
301
+ </div>
302
+ );
303
+ },
304
+ play: async ({ canvasElement }) => {
305
+ const canvas = within(canvasElement);
306
+ // Wait for the editor to boot and the table to render.
307
+ await expect(
308
+ await canvas.findByRole("textbox", { name: "Markdown editor" }, { timeout: 8000 }),
309
+ ).toBeVisible();
310
+ // The GFM table must render as a real <table> element in the WYSIWYG.
311
+ await expect(await canvas.findByRole("table", {}, { timeout: 8000 })).toBeVisible();
312
+ // The table cells are accessible (role="cell" / "columnheader").
313
+ const cells = canvas.getAllByRole("cell");
314
+ expect(cells.length).toBeGreaterThan(0);
315
+ },
316
+ };
317
+
318
+ /**
319
+ * Fill height (editor-fill-height): in a definite-height container, the editable
320
+ * must fill the whole pane — not just its content. A short (1-line) document
321
+ * still produces a full-height clickable surface, and the caret-anchored `/` menu
322
+ * has room to open near the bottom edge. Before the fix the host was
323
+ * content-sized (`min-height: 9rem` ≈ 144px), leaving a large dead area below the
324
+ * editable into which the slash menu clipped.
325
+ */
326
+ export const FillsContainer: Story = {
327
+ name: "Fills container (editor-fill-height)",
328
+ render: () => (
329
+ // A definite-height parent (the workspace's `h-full` wysiwyg pane in the real
330
+ // app). No `h-full` on the editor itself — it fills via `min-height: 100%`.
331
+ <div style={{ height: 700 }}>
332
+ <MarkdownEditor defaultValue={"# One line"} aria-label="Markdown editor" />
333
+ </div>
334
+ ),
335
+ play: async ({ canvasElement }) => {
336
+ const canvas = within(canvasElement);
337
+ const editable = await canvas.findByRole(
338
+ "textbox",
339
+ { name: "Markdown editor" },
340
+ { timeout: 8000 },
341
+ );
342
+ await expect(editable).toBeVisible();
343
+ // The 1-line doc's editable fills the 700px container (well past the ~144px
344
+ // content floor) — clickable everywhere, with room for the `/` menu.
345
+ expect(editable.clientHeight).toBeGreaterThan(500);
346
+ },
347
+ };
348
+
349
+ /**
350
+ * Code-block exit (A3): the caret can escape a fenced code block — including the
351
+ * trailing ```calc fence — with **Tab** or **Mod-Enter**. A code block that is
352
+ * the document's last child no longer traps the caret; exiting inserts a new
353
+ * paragraph below it. The behavior is unit-locked in `exit-keymap.test.ts`; this
354
+ * story is the manual playground — click inside the block and press Tab.
355
+ */
356
+ export const CodeBlockExit: Story = {
357
+ name: "Code-block exit (Tab / Mod-Enter)",
358
+ render: () => (
359
+ <div className="mx-auto max-w-3xl p-6">
360
+ <MarkdownEditor
361
+ defaultValue={
362
+ "Click inside the block below and press **Tab** to escape it.\n\n" +
363
+ "```calc\nrevenue = 1200\ncost = 800\nprofit = revenue - cost\n```\n"
364
+ }
365
+ aria-label="Markdown editor"
366
+ />
367
+ </div>
368
+ ),
369
+ play: async ({ canvasElement }) => {
370
+ const canvas = within(canvasElement);
371
+ await expect(
372
+ await canvas.findByRole("textbox", { name: "Markdown editor" }, { timeout: 8000 }),
373
+ ).toBeVisible();
374
+ },
375
+ };
376
+
377
+ const ITERATE_NODE_MENU_SEED = `# Node menu demo
378
+
379
+ :::iterate{as="item" layout="stacked" values="Alice, Bob"}
380
+ {{item.name}}
381
+ :::
382
+ `;
383
+
384
+ const PIVOT_NODE_MENU_SEED = `# Pivot node menu demo
385
+
386
+ :::pivot{layout="matrix" rows="Q1, Q2" cols="North, South"}
387
+ {{row}} · {{col}}
388
+ :::
389
+ `;
390
+
391
+ /**
392
+ * The iteration node-view's menu (#223): a `⋯` button opens "Edit iteration…"
393
+ * (the guided re-edit), "Change layout" (a submenu of radio items — stacked /
394
+ * grid / bento for `iterate` — that rewrites the `layout` attribute directly,
395
+ * no dialog), and "Convert to static" (replaces the directive with its
396
+ * populated markdown; no ProseMirror dependency). `IterationBuilderProvider`
397
+ * wires the `⋯`/right-click affordance on; without it neither menu renders.
398
+ * The `ContextMenu` story below opens the SAME item set via right-click — both
399
+ * read one shared list (`IterationMenuItems` in directive-views.tsx) so they
400
+ * can never diverge.
401
+ */
402
+ export const NodeMenu: Story = {
403
+ name: "Iteration node menu (⋯ dropdown)",
404
+ render: function NodeMenuStory() {
405
+ const [markdown, setMarkdown] = useState(ITERATE_NODE_MENU_SEED);
406
+ return (
407
+ <IterationBuilderProvider>
408
+ <div className="mx-auto max-w-3xl p-6">
409
+ <MarkdownEditor value={markdown} onChange={setMarkdown} aria-label="Markdown editor" />
410
+ </div>
411
+ <pre className="mx-auto max-w-3xl whitespace-pre-wrap px-6 text-caption text-muted-foreground">
412
+ {markdown}
413
+ </pre>
414
+ </IterationBuilderProvider>
415
+ );
416
+ },
417
+ play: async ({ canvas, canvasElement, userEvent }) => {
418
+ await expect(await canvas.findByText("Iterate", {}, { timeout: 8000 })).toBeVisible();
419
+
420
+ const menuButton = await canvas.findByRole(
421
+ "button",
422
+ { name: "Iteration actions" },
423
+ { timeout: 8000 },
424
+ );
425
+ await userEvent.click(menuButton);
426
+ const body = within(canvasElement.ownerDocument.body);
427
+ const menu = await body.findByRole("menu", {}, { timeout: 8000 });
428
+ await waitFor(() => expect(within(menu).getByText("Edit iteration…")).toBeVisible());
429
+ await expect(within(menu).getByText("Change layout")).toBeVisible();
430
+ await expect(within(menu).getByText("Convert to static")).toBeVisible();
431
+ // `iterate` has no second axis — "Transpose" (pivot-only) never appears for it.
432
+ expect(within(menu).queryByText("Transpose")).toBeNull();
433
+
434
+ // "Change layout" → hover opens its submenu (Radix's pointer path); pick
435
+ // "grid" — rewrites the `layout` attribute directly, no dialog.
436
+ await userEvent.hover(within(menu).getByText("Change layout"));
437
+ const gridOption = await body.findByRole("menuitemradio", { name: "grid" }, { timeout: 8000 });
438
+ await waitFor(() => expect(gridOption).toBeVisible());
439
+ await userEvent.click(gridOption);
440
+ await waitFor(() =>
441
+ expect(canvas.getByText(/layout="grid"/, { exact: false })).toBeInTheDocument(),
442
+ );
443
+
444
+ // Re-open the menu (selecting a radio item closes it AND the controlled
445
+ // `value` round-trip remounts the node-view, so re-query the button fresh
446
+ // rather than reuse the now-detached reference) and "Convert to static" —
447
+ // the directive is replaced with its populated markdown, so the branded
448
+ // "Iterate" frame disappears entirely.
449
+ const menuButton2 = await canvas.findByRole(
450
+ "button",
451
+ { name: "Iteration actions" },
452
+ { timeout: 8000 },
453
+ );
454
+ await userEvent.click(menuButton2);
455
+ const menu2 = await body.findByRole("menu", {}, { timeout: 8000 });
456
+ await waitFor(() => expect(within(menu2).getByText("Convert to static")).toBeVisible());
457
+ await userEvent.click(within(menu2).getByText("Convert to static"));
458
+ await waitFor(() => expect(canvas.queryByText("Iterate")).toBeNull());
459
+ await waitFor(() => expect(canvas.queryByText(/:::iterate/, { exact: false })).toBeNull());
460
+ await expectDocumentRestored(canvasElement);
461
+ },
462
+ };
463
+
464
+ /**
465
+ * The SAME iteration node menu opens on right-click — the AC's "never
466
+ * right-click-only" cuts both ways: the `⋯` button (story above) and the
467
+ * context menu (here) must expose IDENTICAL actions. Uses a `:::pivot` with
468
+ * real row/column values so "Transpose" (pivot-only) has data to swap.
469
+ */
470
+ export const IterationContextMenu: Story = {
471
+ name: "Iteration node menu (right-click)",
472
+ render: function IterationContextMenuStory() {
473
+ const [markdown, setMarkdown] = useState(PIVOT_NODE_MENU_SEED);
474
+ return (
475
+ <IterationBuilderProvider>
476
+ <div className="mx-auto max-w-3xl p-6">
477
+ <MarkdownEditor value={markdown} onChange={setMarkdown} aria-label="Markdown editor" />
478
+ </div>
479
+ <pre className="mx-auto max-w-3xl whitespace-pre-wrap px-6 text-caption text-muted-foreground">
480
+ {markdown}
481
+ </pre>
482
+ </IterationBuilderProvider>
483
+ );
484
+ },
485
+ play: async ({ canvas, canvasElement, userEvent }) => {
486
+ const pivotLabel = await canvas.findByText("Pivot", {}, { timeout: 8000 });
487
+ // Right-click anywhere in the directive frame — not just the ⋯ button.
488
+ await userEvent.pointer({ target: pivotLabel, keys: "[MouseRight]" });
489
+ const body = within(canvasElement.ownerDocument.body);
490
+ const menu = await body.findByRole("menu", {}, { timeout: 8000 });
491
+ await waitFor(() => expect(within(menu).getByText("Edit iteration…")).toBeVisible());
492
+ await expect(within(menu).getByText("Change layout")).toBeVisible();
493
+ await expect(within(menu).getByText("Transpose")).toBeVisible();
494
+ await expect(within(menu).getByText("Convert to static")).toBeVisible();
495
+
496
+ await userEvent.click(within(menu).getByText("Transpose"));
497
+ // Rows/cols are swapped losslessly (Q1,Q2 ⇄ North,South).
498
+ await waitFor(() =>
499
+ expect(canvas.getByText(/rows="North, South"/, { exact: false })).toBeInTheDocument(),
500
+ );
501
+ expect(canvas.getByText(/cols="Q1, Q2"/, { exact: false })).toBeInTheDocument();
502
+ await expectDocumentRestored(canvasElement);
503
+ },
504
+ };
505
+
506
+ /**
507
+ * Three-theme sweep for the node menu (#223 round-2): both `NodeMenu` (⋯) and
508
+ * `IterationContextMenu` (right-click) above run only under the toolbar's
509
+ * DEFAULT theme (`light`) — Storybook's `defaultTheme` isn't overridable
510
+ * per-story from the global decorator alone, and toggling `preview.tsx` by hand
511
+ * for a manual three-run sweep leaves no durable, re-runnable evidence. These
512
+ * four stories wrap the SAME render/play pairs in an explicit `<ThemeProvider>`
513
+ * instead, so `pnpm --filter @elabs-ai/components-docs test-storybook`
514
+ * (or `mcp__storybook__run-story-tests`) exercises `dark` and decoration 10
515
+ * on every run, with zero manual steps. `<ThemeProvider>` (not a plain
516
+ * `data-theme` wrapper div) because it writes the attribute onto the DOCUMENT
517
+ * ROOT — the menu's Radix `Portal` mounts its content on `document.body`,
518
+ * OUTSIDE any local wrapper element, so only a document-level attribute
519
+ * actually themes it. `storageKey={null}` keeps the override from persisting
520
+ * to localStorage across runs.
521
+ */
522
+ export const NodeMenuDark: Story = {
523
+ name: "Iteration node menu (⋯ dropdown) — dark",
524
+ decorators: [
525
+ (Story) => (
526
+ <ThemeProvider defaultTheme="dark" storageKey={null}>
527
+ <Story />
528
+ </ThemeProvider>
529
+ ),
530
+ ],
531
+ render: NodeMenu.render,
532
+ play: NodeMenu.play,
533
+ };
534
+
535
+ export const NodeMenuHighDecoration: Story = {
536
+ name: "Iteration node menu (⋯ dropdown) — high decoration",
537
+ globals: { decoration: "10" },
538
+ render: NodeMenu.render,
539
+ play: NodeMenu.play,
540
+ };
541
+
542
+ export const IterationContextMenuDark: Story = {
543
+ name: "Iteration node menu (right-click) — dark",
544
+ decorators: [
545
+ (Story) => (
546
+ <ThemeProvider defaultTheme="dark" storageKey={null}>
547
+ <Story />
548
+ </ThemeProvider>
549
+ ),
550
+ ],
551
+ render: IterationContextMenu.render,
552
+ play: IterationContextMenu.play,
553
+ };
554
+
555
+ export const IterationContextMenuHighDecoration: Story = {
556
+ name: "Iteration node menu (right-click) — high decoration",
557
+ globals: { decoration: "10" },
558
+ render: IterationContextMenu.render,
559
+ play: IterationContextMenu.play,
560
+ };
@@ -0,0 +1,38 @@
1
+ import { StrictMode } from "react";
2
+ import { cleanup, render, screen, waitFor } from "@testing-library/react";
3
+ import { afterEach, expect, test } from "vitest";
4
+
5
+ import { MarkdownEditor } from "./markdown-editor";
6
+
7
+ afterEach(cleanup);
8
+
9
+ /**
10
+ * THE SPIKE GATE — the #1 risk of vendoring Milkdown is ProseMirror's imperative
11
+ * create/destroy under React 19 StrictMode (dev runs mount → cleanup → mount, so a
12
+ * `destroy()` can fire mid-`create()`). `@milkdown/core` defers destroy until
13
+ * creation settles (editor.ts), so the double-mount must CONVERGE to one editing
14
+ * surface — not crash, leak, or leave two editors. This proves it for our vendored
15
+ * integration before we build the full component on top of it.
16
+ */
17
+ test("StrictMode double-mount converges to a single editor", async () => {
18
+ const { container } = render(
19
+ <StrictMode>
20
+ <MarkdownEditor defaultValue="# Hello Workbench" />
21
+ </StrictMode>,
22
+ );
23
+
24
+ // Milkdown mounts a real ProseMirror view in jsdom and renders the heading text.
25
+ await waitFor(() => expect(screen.getByText("Hello Workbench")).toBeInTheDocument());
26
+
27
+ // After the deferred destroy settles, exactly one editable surface remains.
28
+ await waitFor(
29
+ () => expect(container.querySelectorAll('[contenteditable="true"]')).toHaveLength(1),
30
+ { timeout: 3000 },
31
+ );
32
+ });
33
+
34
+ test("renders a single editor without StrictMode too", async () => {
35
+ const { container } = render(<MarkdownEditor defaultValue="# Plain mount" />);
36
+ await waitFor(() => expect(screen.getByText("Plain mount")).toBeInTheDocument());
37
+ expect(container.querySelectorAll('[contenteditable="true"]')).toHaveLength(1);
38
+ });