@djangocfg/ui-tools 2.1.301 → 2.1.303

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 (30) hide show
  1. package/dist/{DocsLayout-MWRKNFXR.mjs → DocsLayout-6ECALRLD.mjs} +3 -3
  2. package/dist/{DocsLayout-MWRKNFXR.mjs.map → DocsLayout-6ECALRLD.mjs.map} +1 -1
  3. package/dist/{DocsLayout-NWJUF42A.cjs → DocsLayout-ASPSECYR.cjs} +48 -48
  4. package/dist/{DocsLayout-NWJUF42A.cjs.map → DocsLayout-ASPSECYR.cjs.map} +1 -1
  5. package/dist/{Mermaid.client-XFQ74OYN.mjs → Mermaid.client-SXRRI2YW.mjs} +43 -6
  6. package/dist/Mermaid.client-SXRRI2YW.mjs.map +1 -0
  7. package/dist/{Mermaid.client-RSWUUHIL.cjs → Mermaid.client-W76R5AKJ.cjs} +43 -6
  8. package/dist/Mermaid.client-W76R5AKJ.cjs.map +1 -0
  9. package/dist/{chunk-CKD7GNE5.mjs → chunk-K35OF7OB.mjs} +88 -52
  10. package/dist/chunk-K35OF7OB.mjs.map +1 -0
  11. package/dist/{chunk-SEXWBCLX.cjs → chunk-PFKR6ZPZ.cjs} +88 -52
  12. package/dist/chunk-PFKR6ZPZ.cjs.map +1 -0
  13. package/dist/index.cjs +11 -11
  14. package/dist/index.d.cts +20 -0
  15. package/dist/index.d.ts +20 -0
  16. package/dist/index.mjs +5 -5
  17. package/package.json +6 -6
  18. package/src/components/markdown/MarkdownMessage/CodeBlock.tsx +53 -42
  19. package/src/components/markdown/MarkdownMessage/MarkdownMessage.story.tsx +422 -0
  20. package/src/components/markdown/MarkdownMessage/MarkdownMessage.tsx +38 -11
  21. package/src/components/markdown/MarkdownMessage/components.tsx +69 -14
  22. package/src/components/markdown/MarkdownMessage/plainText.ts +33 -0
  23. package/src/components/markdown/MarkdownMessage/types.ts +13 -0
  24. package/src/tools/Mermaid/Mermaid.client.tsx +10 -1
  25. package/src/tools/Mermaid/components/MermaidFullscreenModal.tsx +76 -3
  26. package/src/tools/Mermaid/index.tsx +7 -0
  27. package/dist/Mermaid.client-RSWUUHIL.cjs.map +0 -1
  28. package/dist/Mermaid.client-XFQ74OYN.mjs.map +0 -1
  29. package/dist/chunk-CKD7GNE5.mjs.map +0 -1
  30. package/dist/chunk-SEXWBCLX.cjs.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/ui-tools",
3
- "version": "2.1.301",
3
+ "version": "2.1.303",
4
4
  "description": "Heavy React tools with lazy loading - for Electron, Vite, CRA, Next.js apps",
5
5
  "keywords": [
6
6
  "ui-tools",
@@ -91,8 +91,8 @@
91
91
  "check": "tsc --noEmit"
92
92
  },
93
93
  "peerDependencies": {
94
- "@djangocfg/i18n": "^2.1.301",
95
- "@djangocfg/ui-core": "^2.1.301",
94
+ "@djangocfg/i18n": "^2.1.303",
95
+ "@djangocfg/ui-core": "^2.1.303",
96
96
  "consola": "^3.4.2",
97
97
  "lodash-es": "^4.18.1",
98
98
  "lucide-react": "^0.545.0",
@@ -140,10 +140,10 @@
140
140
  "@maplibre/maplibre-gl-geocoder": "^1.7.0"
141
141
  },
142
142
  "devDependencies": {
143
- "@djangocfg/i18n": "^2.1.301",
143
+ "@djangocfg/i18n": "^2.1.303",
144
144
  "@djangocfg/playground": "workspace:*",
145
- "@djangocfg/typescript-config": "^2.1.301",
146
- "@djangocfg/ui-core": "^2.1.301",
145
+ "@djangocfg/typescript-config": "^2.1.303",
146
+ "@djangocfg/ui-core": "^2.1.303",
147
147
  "@types/lodash-es": "^4.17.12",
148
148
  "@types/mapbox__mapbox-gl-draw": "^1.4.8",
149
149
  "@types/node": "^24.7.2",
@@ -10,33 +10,43 @@ interface CodeBlockProps {
10
10
  isCompact?: boolean;
11
11
  }
12
12
 
13
- /** Code block with a hover-revealed Copy button on top of PrettyCode. */
14
- export const CodeBlock: React.FC<CodeBlockProps> = ({ code, language, isUser, isCompact = false }) => {
13
+ /**
14
+ * Markdown code block. Renders <PrettyCode> directly its own
15
+ * FloatingToolbar already carries the language tag and Copy action.
16
+ *
17
+ * Earlier versions stacked an extra <CopyButton> here too, which
18
+ * surfaced as two copy buttons on every code fence. The fallback
19
+ * branch below still ships its own button because the plain <pre>
20
+ * has no toolbar of its own.
21
+ */
22
+ export const CodeBlock: React.FC<CodeBlockProps> = ({ code, language, isCompact = false }) => {
15
23
  const theme = useResolvedTheme();
16
24
 
17
- return (
18
- <div className="relative group my-3">
19
- <CopyButton
20
- value={code}
21
- variant="ghost"
22
- className={`
23
- absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity
24
- h-8 w-8
25
- ${isUser
26
- ? 'hover:bg-white/20 text-white'
27
- : 'hover:bg-muted-foreground/20 text-muted-foreground hover:text-foreground'
28
- }
29
- `}
30
- title="Copy code"
31
- />
25
+ // Hoist derived values out of JSX (COMPONENTS.md
26
+ // "Data Preparation Before Render"):
27
+ // `textSizeClass` — chat-density font tier, so the JSX line
28
+ // stays a one-liner.
29
+ // The `--code` token (ui-core/styles/theme/tokens.css) handles the
30
+ // palette in both themes / both bubble roles; we don't branch on
31
+ // `isUser` here on purpose.
32
+ const textSizeClass = isCompact ? 'text-xs' : 'text-sm';
32
33
 
34
+ return (
35
+ <div className="my-3">
33
36
  <PrettyCode
34
37
  data={code}
35
38
  language={language}
36
- className={isCompact ? 'text-xs' : 'text-sm'}
37
- customBg={isUser ? 'bg-white/10' : 'bg-muted dark:bg-muted'}
39
+ className={textSizeClass}
40
+ customBg="bg-code"
38
41
  mode={theme}
39
42
  isCompact={isCompact}
43
+ // Disable click-to-scroll-isolation in chat markdown: code
44
+ // fences here are part of an assistant reply, not a docs
45
+ // viewer. Forcing the user to click into a small block to
46
+ // scroll past it interrupts the reading flow. The standalone
47
+ // PrettyCode use cases (docs, diff viewers, long code panes)
48
+ // keep the default `true`.
49
+ scrollIsolation={false}
40
50
  />
41
51
  </div>
42
52
  );
@@ -44,26 +54,27 @@ export const CodeBlock: React.FC<CodeBlockProps> = ({ code, language, isUser, is
44
54
 
45
55
  /** Simple `<pre>` fallback used when CodeBlock throws (lazy module
46
56
  * failure, missing PrettyCode peer, etc). */
47
- export const CodeBlockFallback: React.FC<CodeBlockProps> = ({ code, isUser }) => (
48
- <div className="relative group my-3">
49
- <CopyButton
50
- value={code}
51
- variant="ghost"
52
- className={`
53
- absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity
54
- h-8 w-8
55
- ${isUser
56
- ? 'hover:bg-white/20 text-white'
57
- : 'hover:bg-muted-foreground/20 text-muted-foreground hover:text-foreground'
58
- }
59
- `}
60
- title="Copy code"
61
- />
62
- <pre className={`
63
- p-3 rounded text-xs font-mono overflow-x-auto
64
- ${isUser ? 'bg-white/10 text-white' : 'bg-muted text-foreground'}
65
- `}>
66
- <code>{code}</code>
67
- </pre>
68
- </div>
69
- );
57
+ export const CodeBlockFallback: React.FC<CodeBlockProps> = ({ code, isUser }) => {
58
+ // See CodeBlock above for the spirit of this layout — palette
59
+ // pre-computed before render.
60
+ const copyHoverClass = isUser
61
+ ? 'hover:bg-white/20 text-white'
62
+ : 'hover:bg-muted-foreground/20 text-muted-foreground hover:text-foreground';
63
+ const copyButtonClass =
64
+ `absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity ` +
65
+ `h-8 w-8 ${copyHoverClass}`;
66
+
67
+ return (
68
+ <div className="relative group my-3">
69
+ <CopyButton
70
+ value={code}
71
+ variant="ghost"
72
+ className={copyButtonClass}
73
+ title="Copy code"
74
+ />
75
+ <pre className="p-3 rounded text-xs font-mono overflow-x-auto bg-code text-code-foreground border border-code-border">
76
+ <code>{code}</code>
77
+ </pre>
78
+ </div>
79
+ );
80
+ };
@@ -169,3 +169,425 @@ export const LinkRules = () => (
169
169
  </div>
170
170
  </div>
171
171
  );
172
+
173
+ // Chat-style stories — exercise the plain-text fast path so short
174
+ // one-liners and prose paragraphs don't get prose vertical margins
175
+ // inside a chat bubble. Compare against `LongAssistantReply` below
176
+ // to see when the heavier ReactMarkdown pipeline kicks in.
177
+ const SHORT_USER_LINES = [
178
+ 'Hi',
179
+ 'А?',
180
+ 'How do I install cmdop?',
181
+ 'Can you ssh into vps-audi and check uptime?',
182
+ ];
183
+
184
+ const SHORT_ASSISTANT_LINES = [
185
+ 'Sure — what do you need?',
186
+ 'Готово, запросил статус.',
187
+ 'Done.',
188
+ ];
189
+
190
+ // Crafted to make the plain-vs-markdown contrast obvious: contains
191
+ // `*pair*` (italic in markdown), a `#hash` token (lone hash with no
192
+ // space — NOT a heading even in markdown, kept here to show the
193
+ // asymmetry), and embedded newlines that should survive in plain mode.
194
+ const CONTRAST_CONTENT = `Multi
195
+ line
196
+ user message
197
+ with *asterisks*
198
+ and #hash that should NOT format`;
199
+
200
+ const LONG_ASSISTANT_REPLY = `## Установка CMDOP
201
+
202
+ Запусти этот скрипт:
203
+
204
+ \`\`\`bash
205
+ curl -sSL cmdop.com/install.sh | bash
206
+ \`\`\`
207
+
208
+ Дальше:
209
+
210
+ 1. **Войти:** \`cmdop login\`
211
+ 2. **Подключить машину:** \`cmdop connect\`
212
+ 3. **Проверить:** \`cmdop doctor\`
213
+
214
+ Если что-то не работает — скажи.`;
215
+
216
+ // Mermaid payload — exercises the `mermaid` fence branch in
217
+ // components.tsx (lines 101–106). MarkdownMessage hands the code body
218
+ // to <Mermaid> instead of PrettyCode when the fence language is
219
+ // `mermaid`. We pick a small flowchart + a sequence diagram so the
220
+ // story reflects what an LLM typically emits.
221
+ const MERMAID_CONTENT = `Here's the chat session lifecycle:
222
+
223
+ \`\`\`mermaid
224
+ flowchart LR
225
+ A[User types] --> B[ChatProvider]
226
+ B --> C{streaming?}
227
+ C -->|yes| D[CancelMessage]
228
+ C -->|no| E[SendMessage]
229
+ D --> E
230
+ E --> F[Agent stream]
231
+ F --> G[Tokens arrive]
232
+ G --> H[Reducer dispatch]
233
+ H --> I[Bubble re-renders]
234
+ \`\`\`
235
+
236
+ And the cancel flow as a sequence:
237
+
238
+ \`\`\`mermaid
239
+ sequenceDiagram
240
+ participant U as User
241
+ participant C as ChatInput
242
+ participant P as ChatProvider
243
+ participant S as ChatService
244
+ U->>C: Press Enter (mid-stream)
245
+ C->>P: sendMessage(text)
246
+ P->>S: CancelMessage
247
+ S-->>P: STREAM_CANCELLED
248
+ P->>S: SendMessage(text)
249
+ S-->>P: STREAM_START
250
+ \`\`\`
251
+
252
+ The renderer routes \`mermaid\` fences to the diagram component;
253
+ non-mermaid fences (above) keep going through PrettyCode.`;
254
+
255
+ // Kitchen-sink: every block element the renderer can produce, in one
256
+ // payload, so any spacing regression is visible at a glance. Pairs
257
+ // neighbouring elements that are easy to break — `p → hr → p`,
258
+ // `code → list`, `list → table → list`, blockquote-with-paragraphs.
259
+ const KITCHEN_SINK = `# Top-level heading
260
+
261
+ Short intro paragraph that sits right under the heading. The next
262
+ paragraph should have a clear gap from this one, not be glued to it.
263
+
264
+ Second paragraph — verifies \`p + p\` spacing.
265
+
266
+ ---
267
+
268
+ After the divider, the next paragraph should still breathe. The
269
+ divider itself is a soft hairline, not a heavy 1px gray bar.
270
+
271
+ ## Subheading h2
272
+
273
+ Plain *italic*, **bold**, and \`inline code\` mid-sentence. Inline
274
+ code should sit on the line, not push it taller.
275
+
276
+ ### Subheading h3
277
+
278
+ A short list:
279
+
280
+ - Bullet one with a [link](https://example.com)
281
+ - Bullet two with \`inline code\`
282
+ - Bullet three — short
283
+
284
+ A numbered list:
285
+
286
+ 1. First step
287
+ 2. Second step with **bold** inside
288
+ 3. Third step
289
+
290
+ A loose list (paragraph-wrapped \`<li>\`):
291
+
292
+ - First item
293
+
294
+ with a follow-up paragraph that should NOT explode the list.
295
+
296
+ - Second item, also loose.
297
+
298
+ #### Heading h4
299
+
300
+ > Blockquote without italic, with a left border. Lighter colour for
301
+ > de-emphasis. Multi-line quotes wrap cleanly.
302
+ >
303
+ > Second paragraph inside the same blockquote.
304
+
305
+ A code fence:
306
+
307
+ \`\`\`go
308
+ func add(a, b int) int {
309
+ // verifies code block spacing, rounded corners, dark bg
310
+ return a + b
311
+ }
312
+ \`\`\`
313
+
314
+ A table — wraps inside a horizontally scrollable container:
315
+
316
+ | Field | Type | Notes |
317
+ |---|---|---|
318
+ | id | string | UUID |
319
+ | name | string | display name |
320
+ | online | bool | last-heartbeat ≤ 60s |
321
+
322
+ ---
323
+
324
+ Closing paragraph after a second divider, to verify spacing holds at
325
+ the very end too.`;
326
+
327
+ function ChatBubble({
328
+ role,
329
+ content,
330
+ rules,
331
+ plainText,
332
+ }: {
333
+ role: 'user' | 'assistant';
334
+ content: string;
335
+ rules?: LinkRule[];
336
+ plainText?: boolean;
337
+ }) {
338
+ const isUser = role === 'user';
339
+ return (
340
+ <div className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}>
341
+ <div
342
+ className={`max-w-[85%] rounded-xl px-3 py-2 ${
343
+ isUser
344
+ ? 'bg-primary text-primary-foreground'
345
+ : 'bg-card text-card-foreground border border-border'
346
+ }`}
347
+ >
348
+ <MarkdownMessage
349
+ content={content}
350
+ isUser={isUser}
351
+ linkRules={rules}
352
+ plainText={plainText}
353
+ />
354
+ </div>
355
+ </div>
356
+ );
357
+ }
358
+
359
+ export const ChatBubbles = () => (
360
+ <div className="mx-auto max-w-2xl space-y-6 p-6">
361
+ <div>
362
+ <h3 className="mb-2 text-sm font-semibold">
363
+ User messages — explicit <code>plainText</code>
364
+ </h3>
365
+ <p className="mb-3 text-xs text-muted-foreground">
366
+ ChatGPT/Claude.ai split: user-typed content stays as-typed.
367
+ <code> *stars* </code> aren't bold; <code># headers </code>
368
+ aren't headings. Newlines preserved via{' '}
369
+ <code>whitespace-pre-wrap</code>.
370
+ </p>
371
+ <div className="rounded-lg border border-border bg-card p-4 space-y-2">
372
+ {SHORT_USER_LINES.map((line, i) => (
373
+ <ChatBubble
374
+ key={`u-${i}`}
375
+ role="user"
376
+ content={line}
377
+ plainText
378
+ rules={onPrimaryRules}
379
+ />
380
+ ))}
381
+ <ChatBubble
382
+ role="user"
383
+ content={'Multi\nline\nuser message\nwith *asterisks*\nand #hash that should NOT format'}
384
+ plainText
385
+ rules={onPrimaryRules}
386
+ />
387
+ </div>
388
+ </div>
389
+
390
+ <div>
391
+ <h3 className="mb-2 text-sm font-semibold">
392
+ Heuristic auto-detect (no <code>plainText</code> prop)
393
+ </h3>
394
+ <p className="mb-3 text-xs text-muted-foreground">
395
+ Without <code>plainText</code>, <code>looksLikePlainProse</code>
396
+ decides: short / single-paragraph / no markers → flat render;
397
+ long / multi-paragraph / structural → full markdown. Same
398
+ renderer for both branches; only margins differ.
399
+ </p>
400
+ <div className="rounded-lg border border-border bg-card p-4 space-y-2">
401
+ {SHORT_ASSISTANT_LINES.map((line, i) => (
402
+ <ChatBubble
403
+ key={`a-${i}`}
404
+ role="assistant"
405
+ content={line}
406
+ rules={subtleRules}
407
+ />
408
+ ))}
409
+ <ChatBubble
410
+ role="assistant"
411
+ content="Sure — let me check uptime and disk on the machine and report back. Should only take a couple of seconds."
412
+ rules={subtleRules}
413
+ />
414
+ </div>
415
+ </div>
416
+
417
+ <div>
418
+ <h3 className="mb-2 text-sm font-semibold">
419
+ Bubble with mention chips
420
+ </h3>
421
+ <p className="mb-3 text-xs text-muted-foreground">
422
+ Content with <code>[label](cmdop://...)</code> always goes
423
+ through ReactMarkdown — that's how MentionChip renders.
424
+ </p>
425
+ <div className="rounded-lg border border-border bg-card p-4 space-y-2">
426
+ <ChatBubble
427
+ role="user"
428
+ content="Спроси у @[Vps-audi](cmdop://machine/abc-123) сколько свободного места."
429
+ rules={onPrimaryRules}
430
+ />
431
+ <ChatBubble
432
+ role="assistant"
433
+ content="Готово — спросил [Vps-audi](cmdop://machine/abc-123)."
434
+ rules={subtleRules}
435
+ />
436
+ </div>
437
+ </div>
438
+
439
+ <div>
440
+ <h3 className="mb-2 text-sm font-semibold">
441
+ Same content, two modes — side-by-side contrast
442
+ </h3>
443
+ <p className="mb-3 text-xs text-muted-foreground">
444
+ Identical input string. Left bubble forces{' '}
445
+ <code>plainText</code> (user-side default in cmdop):{' '}
446
+ <code>*asterisks*</code> stay literal, <code>#hash</code> stays
447
+ literal, newlines preserved verbatim. Right bubble omits{' '}
448
+ <code>plainText</code> so the heuristic kicks in — short
449
+ content but with <code>*pair*</code> markers, so it routes
450
+ through ReactMarkdown and renders <em>italic</em>.
451
+ </p>
452
+ <div className="grid grid-cols-2 gap-3">
453
+ <div className="rounded-lg border border-border bg-card p-4">
454
+ <div className="mb-2 text-[10px] uppercase tracking-wide text-muted-foreground">
455
+ plainText = true
456
+ </div>
457
+ <ChatBubble
458
+ role="user"
459
+ content={CONTRAST_CONTENT}
460
+ plainText
461
+ rules={onPrimaryRules}
462
+ />
463
+ </div>
464
+ <div className="rounded-lg border border-border bg-card p-4">
465
+ <div className="mb-2 text-[10px] uppercase tracking-wide text-muted-foreground">
466
+ plainText omitted → heuristic
467
+ </div>
468
+ <ChatBubble
469
+ role="assistant"
470
+ content={CONTRAST_CONTENT}
471
+ rules={subtleRules}
472
+ />
473
+ </div>
474
+ </div>
475
+ </div>
476
+
477
+ <div>
478
+ <h3 className="mb-2 text-sm font-semibold">
479
+ Long assistant reply (multi-paragraph + code + headings)
480
+ </h3>
481
+ <p className="mb-3 text-xs text-muted-foreground">
482
+ Full markdown pipeline. Tight CSS overrides
483
+ (<code>p+p mt-2</code>, <code>p my-0</code>) keep the bubble
484
+ from looking airy — defaults from Tailwind's prose are too
485
+ loose for chat density.
486
+ </p>
487
+ <div className="rounded-lg border border-border bg-card p-4">
488
+ <ChatBubble
489
+ role="assistant"
490
+ content={LONG_ASSISTANT_REPLY}
491
+ rules={subtleRules}
492
+ />
493
+ </div>
494
+ </div>
495
+ </div>
496
+ );
497
+
498
+ // Kitchen-sink: stress-test every element the renderer can emit in a
499
+ // single bubble. If something regresses (e.g. paragraphs collapse, hr
500
+ // shows a heavy gray bar, table loses borders, list items glue to
501
+ // each other), it's visible at a glance.
502
+ //
503
+ // Numbers in MarkdownMessage.tsx come from research on ChatGPT /
504
+ // Claude.ai / Cursor 2025 — see KitchenSink output as the visual
505
+ // contract. Don't tune values by feel; pair-check against this view.
506
+ export const KitchenSink = () => (
507
+ <div className="mx-auto max-w-2xl space-y-6 p-6">
508
+ <div>
509
+ <h3 className="mb-2 text-sm font-semibold">
510
+ Every block element in one bubble
511
+ </h3>
512
+ <p className="mb-3 text-xs text-muted-foreground">
513
+ Visual contract for the renderer. Heading sizes downscaled
514
+ chat-style (h1 ≈ body), <code>p</code>/<code>ul</code>/
515
+ <code>ol</code>/<code>pre</code>/<code>blockquote</code>/
516
+ <code>hr</code>/<code>table</code> all share a coherent
517
+ rhythm. Loose lists keep their density — no exploded gap on
518
+ paragraph-wrapped <code>&lt;li&gt;</code>.
519
+ </p>
520
+ <div className="rounded-lg border border-border bg-card p-4">
521
+ <ChatBubble
522
+ role="assistant"
523
+ content={KITCHEN_SINK}
524
+ rules={subtleRules}
525
+ />
526
+ </div>
527
+ </div>
528
+
529
+ <div>
530
+ <h3 className="mb-2 text-sm font-semibold">
531
+ Same payload on the user-side palette
532
+ </h3>
533
+ <p className="mb-3 text-xs text-muted-foreground">
534
+ Verifies that <code>prose-invert</code> doesn't break any of
535
+ the new rules — saturated bubbles need the same spacing.
536
+ </p>
537
+ <div className="rounded-lg border border-border bg-card p-4">
538
+ <ChatBubble
539
+ role="user"
540
+ content={KITCHEN_SINK}
541
+ rules={onPrimaryRules}
542
+ />
543
+ </div>
544
+ </div>
545
+ </div>
546
+ );
547
+
548
+ // Mermaid diagrams. Live-renders the fence body via the <Mermaid>
549
+ // tool (lazy-loaded). Useful as a manual smoke test for: (1) the
550
+ // `language === 'mermaid'` branch in components.tsx, (2) the SVG
551
+ // scaling inside a chat-density bubble, (3) interaction with the
552
+ // surrounding markdown spacing rules (paragraph above/below the
553
+ // diagram should keep the same rhythm as around a code fence).
554
+ export const MermaidDiagrams = () => (
555
+ <div className="mx-auto max-w-2xl space-y-6 p-6">
556
+ <div>
557
+ <h3 className="mb-2 text-sm font-semibold">
558
+ Mermaid in an assistant bubble
559
+ </h3>
560
+ <p className="mb-3 text-xs text-muted-foreground">
561
+ Two diagrams (flowchart + sequence) wrapped in surrounding
562
+ prose. Verifies that <code>```mermaid</code> fences route to{' '}
563
+ <code>&lt;Mermaid&gt;</code> and that the SVG fits the
564
+ bubble's max-width without breaking the layout.
565
+ </p>
566
+ <div className="rounded-lg border border-border bg-card p-4">
567
+ <ChatBubble
568
+ role="assistant"
569
+ content={MERMAID_CONTENT}
570
+ rules={subtleRules}
571
+ />
572
+ </div>
573
+ </div>
574
+
575
+ <div>
576
+ <h3 className="mb-2 text-sm font-semibold">
577
+ Same payload on the user-side palette
578
+ </h3>
579
+ <p className="mb-3 text-xs text-muted-foreground">
580
+ On a saturated <code>bg-primary</code> bubble the diagram
581
+ keeps its own surface — Mermaid renders SVG with its own
582
+ background, independent of the bubble palette.
583
+ </p>
584
+ <div className="rounded-lg border border-border bg-card p-4">
585
+ <ChatBubble
586
+ role="user"
587
+ content={MERMAID_CONTENT}
588
+ rules={onPrimaryRules}
589
+ />
590
+ </div>
591
+ </div>
592
+ </div>
593
+ );
@@ -10,7 +10,7 @@ import type { Components } from 'react-markdown';
10
10
  import { useCollapsibleContent } from '../useCollapsibleContent';
11
11
  import type { MarkdownMessageProps } from './types';
12
12
  import { buildSchema, buildUrlTransform } from './sanitize';
13
- import { hasMarkdownSyntax } from './plainText';
13
+ import { looksLikePlainProse } from './plainText';
14
14
  import { createMarkdownComponents } from './components';
15
15
  import { CollapseToggle } from './CollapseToggle';
16
16
  import { applyPreprocess, buildLinkRulesComponent, collectProtocols } from './linkRules';
@@ -51,6 +51,7 @@ export const MarkdownMessage: React.FC<MarkdownMessageProps> = ({
51
51
  className = '',
52
52
  isUser = false,
53
53
  isCompact = false,
54
+ plainText,
54
55
  customComponents,
55
56
  extraHrefProtocols,
56
57
  linkRules,
@@ -121,17 +122,34 @@ export const MarkdownMessage: React.FC<MarkdownMessageProps> = ({
121
122
  const textSizeClass = isCompact ? 'text-xs' : 'text-sm';
122
123
  const proseClass = isCompact ? 'prose-xs' : 'prose-sm';
123
124
 
124
- // Plain-text fast path: skip ReactMarkdown when content is pure
125
- // prose. Bypassed when the caller wired up custom components or
126
- // link rules those need to fire on plain `[label](custom://…)`
127
- // links the fast path would otherwise print verbatim.
128
- const isPlainText =
129
- !effectiveCustomComponents && !hasMarkdownSyntax(displayContent);
125
+ // Resolve plain-vs-markdown branch:
126
+ // 1. Caller-passed `plainText` wins outright (explicit beats clever).
127
+ // 2. Caller-supplied non-`a` customComponents force the markdown
128
+ // pipeline those overrides only matter on real markdown nodes.
129
+ // 3. Otherwise auto-detect via `looksLikePlainProse` (short,
130
+ // single-paragraph, no markdown markers). This is the chat-bubble
131
+ // WhatsApp/Telegram heuristic — "would the human have written
132
+ // this in one keystroke?".
133
+ const customComponentsBeyondLinks = React.useMemo(() => {
134
+ if (!customComponents) return false;
135
+ return Object.keys(customComponents).some((k) => k !== 'a');
136
+ }, [customComponents]);
137
+ const isPlainText = plainText !== undefined
138
+ ? plainText
139
+ : !customComponentsBeyondLinks && looksLikePlainProse(displayContent);
130
140
 
131
141
  if (isPlainText) {
142
+ // <div> + whitespace-pre-wrap: respects newlines AND collapses
143
+ // double spaces, which is what users mean when they hit Enter
144
+ // twice. <span> would break flow inside a flex bubble.
145
+ //
146
+ // leading-snug (1.375), not leading-relaxed (1.625) — `pre-wrap`
147
+ // makes every `\n` a hard line break, so the relaxed leading
148
+ // turns multi-line user messages into airy ladders. snug matches
149
+ // WhatsApp/Telegram bubble density.
132
150
  return (
133
- <span
134
- className={`${textSizeClass} leading-7 break-words whitespace-pre-line font-light ${className}`}
151
+ <div
152
+ className={`${textSizeClass} leading-snug break-words whitespace-pre-wrap ${className}`}
135
153
  >
136
154
  {displayContent}
137
155
  {collapsible && shouldCollapse && (
@@ -147,7 +165,7 @@ export const MarkdownMessage: React.FC<MarkdownMessageProps> = ({
147
165
  />
148
166
  </>
149
167
  )}
150
- </span>
168
+ </div>
151
169
  );
152
170
  }
153
171
 
@@ -157,7 +175,16 @@ export const MarkdownMessage: React.FC<MarkdownMessageProps> = ({
157
175
  className={`
158
176
  prose ${proseClass} max-w-none break-words overflow-hidden ${textSizeClass}
159
177
  ${isUser ? 'prose-invert' : 'dark:prose-invert'}
160
- [&>*]:leading-7
178
+ [&>*]:leading-relaxed
179
+ [&>*:first-child]:mt-0 [&>*:last-child]:mb-0
180
+ [&_p]:my-2
181
+ [&_ul]:my-2 [&_ol]:my-2 [&_ul]:pl-5 [&_ol]:pl-5
182
+ [&_li]:my-1 [&_li>p]:my-0
183
+ [&_pre]:my-3
184
+ [&_h1]:mt-4 [&_h1]:mb-2 [&_h1]:text-base [&_h1]:font-semibold
185
+ [&_h2]:mt-3.5 [&_h2]:mb-1.5 [&_h2]:text-[15px] [&_h2]:font-semibold
186
+ [&_h3]:mt-3 [&_h3]:mb-1 [&_h3]:text-sm [&_h3]:font-medium
187
+ [&_h4]:mt-3 [&_h4]:mb-1 [&_h4]:text-sm [&_h4]:font-medium
161
188
  `}
162
189
  style={{
163
190
  // Inherit colors from parent — fixes issues with external