@djangocfg/ui-tools 2.1.310 → 2.1.313

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 (161) hide show
  1. package/README.md +38 -22
  2. package/dist/{DocsLayout-W5JLRNSZ.mjs → DocsLayout-ESVQZO3V.mjs} +3 -3
  3. package/dist/{DocsLayout-W5JLRNSZ.mjs.map → DocsLayout-ESVQZO3V.mjs.map} +1 -1
  4. package/dist/{DocsLayout-ZXD2CUOH.cjs → DocsLayout-KUPDWJ3G.cjs} +48 -48
  5. package/dist/{DocsLayout-ZXD2CUOH.cjs.map → DocsLayout-KUPDWJ3G.cjs.map} +1 -1
  6. package/dist/Player-M3GC3VPE.mjs +4 -0
  7. package/dist/Player-M3GC3VPE.mjs.map +1 -0
  8. package/dist/Player-ZGQKKOWI.css +65 -0
  9. package/dist/Player-ZGQKKOWI.css.map +1 -0
  10. package/dist/Player-ZL2X5LGG.cjs +13 -0
  11. package/dist/Player-ZL2X5LGG.cjs.map +1 -0
  12. package/dist/{chunk-CXVGN6ZW.cjs → chunk-DFTVB66S.cjs} +7 -6
  13. package/dist/chunk-DFTVB66S.cjs.map +1 -0
  14. package/dist/{chunk-2QY3LJR6.mjs → chunk-EUADAUBQ.mjs} +5 -4
  15. package/dist/chunk-EUADAUBQ.mjs.map +1 -0
  16. package/dist/chunk-FX2QFYWF.mjs +2059 -0
  17. package/dist/chunk-FX2QFYWF.mjs.map +1 -0
  18. package/dist/{chunk-6HNAPVZ2.mjs → chunk-GBLQTHWT.mjs} +11 -13
  19. package/dist/chunk-GBLQTHWT.mjs.map +1 -0
  20. package/dist/{chunk-FYLR232K.cjs → chunk-S44PW6NK.cjs} +11 -13
  21. package/dist/chunk-S44PW6NK.cjs.map +1 -0
  22. package/dist/chunk-ZLQHUZDU.cjs +2061 -0
  23. package/dist/chunk-ZLQHUZDU.cjs.map +1 -0
  24. package/dist/components-WYEZL5TE.cjs +26 -0
  25. package/dist/{components-3RTH76CV.cjs.map → components-WYEZL5TE.cjs.map} +1 -1
  26. package/dist/components-ZAGG2PBO.mjs +5 -0
  27. package/dist/{components-5GVVL2Q6.mjs.map → components-ZAGG2PBO.mjs.map} +1 -1
  28. package/dist/index.cjs +36 -220
  29. package/dist/index.cjs.map +1 -1
  30. package/dist/index.css +65 -0
  31. package/dist/index.css.map +1 -1
  32. package/dist/index.d.cts +44 -500
  33. package/dist/index.d.ts +44 -500
  34. package/dist/index.mjs +16 -62
  35. package/dist/index.mjs.map +1 -1
  36. package/package.json +6 -6
  37. package/src/components/markdown/MarkdownMessage/ActionRow.tsx +48 -0
  38. package/src/components/markdown/MarkdownMessage/ChatMessageRow.tsx +97 -0
  39. package/src/components/markdown/MarkdownMessage/CodeBlock.tsx +9 -13
  40. package/src/components/markdown/MarkdownMessage/MarkdownMessage.story.tsx +77 -2
  41. package/src/components/markdown/MarkdownMessage/MarkdownMessage.tsx +2 -3
  42. package/src/components/markdown/MarkdownMessage/README.md +72 -0
  43. package/src/components/markdown/MarkdownMessage/components.tsx +3 -3
  44. package/src/components/markdown/MarkdownMessage/index.ts +6 -0
  45. package/src/index.ts +2 -11
  46. package/src/tools/AudioPlayer/AudioPlayer.story.tsx +454 -107
  47. package/src/tools/AudioPlayer/Player.tsx +80 -0
  48. package/src/tools/AudioPlayer/PlayerShell.tsx +122 -0
  49. package/src/tools/AudioPlayer/README.md +139 -204
  50. package/src/tools/AudioPlayer/audio/audioContext.ts +39 -0
  51. package/src/tools/AudioPlayer/audio/decodePeaks.ts +36 -0
  52. package/src/tools/AudioPlayer/audio/index.ts +4 -0
  53. package/src/tools/AudioPlayer/audio/mediaElementSourceCache.ts +20 -0
  54. package/src/tools/AudioPlayer/audio/peaksCache.ts +37 -0
  55. package/src/tools/AudioPlayer/context/AudioRefContext.tsx +9 -0
  56. package/src/tools/AudioPlayer/context/ControlsContext.tsx +7 -0
  57. package/src/tools/AudioPlayer/context/LevelsContext.tsx +7 -0
  58. package/src/tools/AudioPlayer/context/MetaContext.tsx +16 -0
  59. package/src/tools/AudioPlayer/context/PlayerProvider.tsx +314 -0
  60. package/src/tools/AudioPlayer/context/StateContext.tsx +7 -0
  61. package/src/tools/AudioPlayer/context/index.ts +16 -15
  62. package/src/tools/AudioPlayer/context/selectors.ts +36 -0
  63. package/src/tools/AudioPlayer/hooks/index.ts +12 -39
  64. package/src/tools/AudioPlayer/hooks/useActivePlayer.ts +31 -0
  65. package/src/tools/AudioPlayer/hooks/useAnalyser.ts +62 -0
  66. package/src/tools/AudioPlayer/hooks/useAudioElementEvents.ts +102 -0
  67. package/src/tools/AudioPlayer/hooks/useKeyboardShortcuts.ts +91 -0
  68. package/src/tools/AudioPlayer/hooks/useMediaSession.ts +74 -0
  69. package/src/tools/AudioPlayer/hooks/usePeaks.ts +83 -0
  70. package/src/tools/AudioPlayer/hooks/usePlayerPreferences.ts +21 -0
  71. package/src/tools/AudioPlayer/hooks/usePlayheadLoop.ts +77 -0
  72. package/src/tools/AudioPlayer/hooks/useResizeObserver.ts +20 -0
  73. package/src/tools/AudioPlayer/hooks/useThemeWatcher.ts +22 -0
  74. package/src/tools/AudioPlayer/index.ts +63 -134
  75. package/src/tools/AudioPlayer/lazy.tsx +8 -97
  76. package/src/tools/AudioPlayer/parts/Controls/ControlsRow.tsx +30 -0
  77. package/src/tools/AudioPlayer/parts/Controls/IconButton.tsx +62 -0
  78. package/src/tools/AudioPlayer/parts/Controls/LoopButton.tsx +33 -0
  79. package/src/tools/AudioPlayer/parts/Controls/PlayButton.tsx +86 -0
  80. package/src/tools/AudioPlayer/parts/Controls/SkipButton.tsx +17 -0
  81. package/src/tools/AudioPlayer/parts/Controls/VolumeControl.tsx +171 -0
  82. package/src/tools/AudioPlayer/parts/Controls/index.ts +6 -0
  83. package/src/tools/AudioPlayer/parts/Cover/Cover.tsx +24 -0
  84. package/src/tools/AudioPlayer/parts/Cover/CoverPlaceholder.tsx +27 -0
  85. package/src/tools/AudioPlayer/parts/Cover/ReactivePulse.tsx +66 -0
  86. package/src/tools/AudioPlayer/parts/Cover/index.ts +3 -0
  87. package/src/tools/AudioPlayer/parts/ErrorState/ErrorState.tsx +35 -0
  88. package/src/tools/AudioPlayer/parts/ErrorState/index.ts +1 -0
  89. package/src/tools/AudioPlayer/parts/Layout/CompactLayout.tsx +25 -0
  90. package/src/tools/AudioPlayer/parts/Layout/DefaultLayout.tsx +48 -0
  91. package/src/tools/AudioPlayer/parts/Layout/index.ts +2 -0
  92. package/src/tools/AudioPlayer/parts/Meta/Artist.tsx +14 -0
  93. package/src/tools/AudioPlayer/parts/Meta/TimeDisplay.tsx +49 -0
  94. package/src/tools/AudioPlayer/parts/Meta/Title.tsx +13 -0
  95. package/src/tools/AudioPlayer/parts/Meta/index.ts +3 -0
  96. package/src/tools/AudioPlayer/parts/Skeleton/CoverSkeleton.tsx +13 -0
  97. package/src/tools/AudioPlayer/parts/Skeleton/MetaSkeleton.tsx +10 -0
  98. package/src/tools/AudioPlayer/parts/Skeleton/index.ts +2 -0
  99. package/src/tools/AudioPlayer/parts/Waveform/BarsWaveform.tsx +48 -0
  100. package/src/tools/AudioPlayer/parts/Waveform/LiveWaveform.tsx +95 -0
  101. package/src/tools/AudioPlayer/parts/Waveform/PeaksWaveform.tsx +100 -0
  102. package/src/tools/AudioPlayer/parts/Waveform/ProgressBar.tsx +76 -0
  103. package/src/tools/AudioPlayer/parts/Waveform/Waveform.tsx +74 -0
  104. package/src/tools/AudioPlayer/parts/Waveform/WaveformSkeleton.tsx +16 -0
  105. package/src/tools/AudioPlayer/parts/Waveform/index.ts +8 -0
  106. package/src/tools/AudioPlayer/parts/Waveform/waveformInteraction.ts +106 -0
  107. package/src/tools/AudioPlayer/parts/Waveform/waveformRenderer.ts +91 -0
  108. package/src/tools/AudioPlayer/parts/index.ts +1 -0
  109. package/src/tools/AudioPlayer/store/activePlayerBus.ts +63 -0
  110. package/src/tools/AudioPlayer/store/createLevelsStore.ts +37 -0
  111. package/src/tools/AudioPlayer/store/index.ts +16 -0
  112. package/src/tools/AudioPlayer/store/preferencesStore.ts +104 -0
  113. package/src/tools/AudioPlayer/styles/webview-safe.css +77 -0
  114. package/src/tools/AudioPlayer/types.ts +95 -0
  115. package/src/tools/AudioPlayer/utils/bucketize.ts +27 -0
  116. package/src/tools/AudioPlayer/utils/clamp.ts +5 -0
  117. package/src/tools/AudioPlayer/utils/dpr.ts +19 -0
  118. package/src/tools/AudioPlayer/utils/formatTime.ts +12 -8
  119. package/src/tools/AudioPlayer/utils/index.ts +4 -5
  120. package/src/tools/AudioPlayer/utils/readCssVar.ts +7 -0
  121. package/src/tools/AudioPlayer/utils/resolveCanvasColor.ts +28 -0
  122. package/src/tools/index.ts +5 -75
  123. package/dist/chunk-2QY3LJR6.mjs.map +0 -1
  124. package/dist/chunk-6HNAPVZ2.mjs.map +0 -1
  125. package/dist/chunk-CXVGN6ZW.cjs.map +0 -1
  126. package/dist/chunk-F2N7P5XU.cjs +0 -30
  127. package/dist/chunk-F2N7P5XU.cjs.map +0 -1
  128. package/dist/chunk-FYLR232K.cjs.map +0 -1
  129. package/dist/chunk-HMHIVEMS.mjs +0 -1619
  130. package/dist/chunk-HMHIVEMS.mjs.map +0 -1
  131. package/dist/chunk-JWB2EWQO.mjs +0 -5
  132. package/dist/chunk-JWB2EWQO.mjs.map +0 -1
  133. package/dist/chunk-YZX6FH3H.cjs +0 -1656
  134. package/dist/chunk-YZX6FH3H.cjs.map +0 -1
  135. package/dist/components-3RTH76CV.cjs +0 -27
  136. package/dist/components-5GVVL2Q6.mjs +0 -5
  137. package/dist/components-CPHOUQ5F.cjs +0 -46
  138. package/dist/components-CPHOUQ5F.cjs.map +0 -1
  139. package/dist/components-OTK43IMD.mjs +0 -6
  140. package/dist/components-OTK43IMD.mjs.map +0 -1
  141. package/src/tools/AudioPlayer/components/HybridAudioPlayer.tsx +0 -225
  142. package/src/tools/AudioPlayer/components/HybridCompactPlayer.tsx +0 -163
  143. package/src/tools/AudioPlayer/components/HybridSimplePlayer.tsx +0 -284
  144. package/src/tools/AudioPlayer/components/HybridWaveform.tsx +0 -286
  145. package/src/tools/AudioPlayer/components/ReactiveCover/AudioReactiveCover.tsx +0 -151
  146. package/src/tools/AudioPlayer/components/ReactiveCover/effects/GlowEffect.tsx +0 -110
  147. package/src/tools/AudioPlayer/components/ReactiveCover/effects/MeshEffect.tsx +0 -58
  148. package/src/tools/AudioPlayer/components/ReactiveCover/effects/OrbsEffect.tsx +0 -45
  149. package/src/tools/AudioPlayer/components/ReactiveCover/effects/SpotlightEffect.tsx +0 -82
  150. package/src/tools/AudioPlayer/components/ReactiveCover/effects/index.ts +0 -8
  151. package/src/tools/AudioPlayer/components/ReactiveCover/index.ts +0 -6
  152. package/src/tools/AudioPlayer/components/index.ts +0 -23
  153. package/src/tools/AudioPlayer/context/HybridAudioProvider.tsx +0 -158
  154. package/src/tools/AudioPlayer/effects/index.ts +0 -412
  155. package/src/tools/AudioPlayer/hooks/useAudioBus.ts +0 -76
  156. package/src/tools/AudioPlayer/hooks/useHybridAudio.ts +0 -403
  157. package/src/tools/AudioPlayer/hooks/useHybridAudioAnalysis.ts +0 -96
  158. package/src/tools/AudioPlayer/hooks/useVisualization.tsx +0 -207
  159. package/src/tools/AudioPlayer/types/effects.ts +0 -73
  160. package/src/tools/AudioPlayer/types/index.ts +0 -27
  161. package/src/tools/AudioPlayer/utils/debug.ts +0 -14
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/ui-tools",
3
- "version": "2.1.310",
3
+ "version": "2.1.313",
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.310",
95
- "@djangocfg/ui-core": "^2.1.310",
94
+ "@djangocfg/i18n": "^2.1.313",
95
+ "@djangocfg/ui-core": "^2.1.313",
96
96
  "consola": "^3.4.2",
97
97
  "lodash-es": "^4.18.1",
98
98
  "lucide-react": "^0.545.0",
@@ -144,10 +144,10 @@
144
144
  "@maplibre/maplibre-gl-geocoder": "^1.7.0"
145
145
  },
146
146
  "devDependencies": {
147
- "@djangocfg/i18n": "^2.1.310",
147
+ "@djangocfg/i18n": "^2.1.313",
148
148
  "@djangocfg/playground": "workspace:*",
149
- "@djangocfg/typescript-config": "^2.1.310",
150
- "@djangocfg/ui-core": "^2.1.310",
149
+ "@djangocfg/typescript-config": "^2.1.313",
150
+ "@djangocfg/ui-core": "^2.1.313",
151
151
  "@types/lodash-es": "^4.17.12",
152
152
  "@types/mapbox__mapbox-gl-draw": "^1.4.8",
153
153
  "@types/node": "^24.7.2",
@@ -0,0 +1,48 @@
1
+ 'use client';
2
+
3
+ import React from 'react';
4
+ import { CopyButton } from '@djangocfg/ui-core/components';
5
+
6
+ interface ActionRowProps {
7
+ /** Raw text to copy — pass the same source string used for render. */
8
+ value: string;
9
+ /** Bubble side. Just controls flex alignment; nothing is mirrored. */
10
+ isUser: boolean;
11
+ /**
12
+ * Whether the row is currently shown. Owner-controlled (typically
13
+ * `<ChatMessageRow>` toggles this on hover with a small close delay).
14
+ * The component is intentionally dumb about visibility — owners
15
+ * have full control over reveal logic (hover, focus, always-on,
16
+ * touch behaviour, etc.).
17
+ */
18
+ visible: boolean;
19
+ }
20
+
21
+ // Action row that lives directly under a chat bubble.
22
+ // Visibility is fully owner-controlled via `visible`.
23
+ //
24
+ // We render at `opacity-0` (not `display:none`) so the fade in/out
25
+ // stays smooth across re-renders. While invisible we drop pointer
26
+ // events so a hidden row can't intercept clicks meant for the page
27
+ // underneath.
28
+ export const ActionRow: React.FC<ActionRowProps> = ({ value, isUser, visible }) => {
29
+ return (
30
+ <div
31
+ className={`flex ${isUser ? 'justify-end' : 'justify-start'}
32
+ transition-opacity duration-150
33
+ ${visible ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'}`}
34
+ >
35
+ <CopyButton
36
+ value={value}
37
+ size="icon"
38
+ variant="ghost"
39
+ iconClassName="h-3.5 w-3.5"
40
+ className="h-7 w-7 text-muted-foreground hover:text-foreground"
41
+ // Hidden rows must not be reachable via Tab — otherwise
42
+ // keyboard users land on an invisible button.
43
+ tabIndex={visible ? 0 : -1}
44
+ aria-hidden={!visible}
45
+ />
46
+ </div>
47
+ );
48
+ };
@@ -0,0 +1,97 @@
1
+ 'use client';
2
+
3
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
4
+ import { useMediaQuery } from '@djangocfg/ui-core/hooks';
5
+
6
+ interface ChatMessageRowProps {
7
+ /** Side of the conversation — drives flex alignment and `isUser`
8
+ * passed to `actions`. */
9
+ isUser: boolean;
10
+ /** The bubble (your own JSX). Anything goes — `<MarkdownMessage>`,
11
+ * custom card, multi-element composition. */
12
+ children: React.ReactNode;
13
+ /** Render-prop for the action row. Receives `visible` so the row
14
+ * knows when to fade in/out. Render `null` to opt out. */
15
+ actions?: (visible: boolean, isUser: boolean) => React.ReactNode;
16
+ /** Close delay in ms after the cursor leaves the row. Long enough
17
+ * to bridge cursor travel from bubble → action button without
18
+ * flicker. Default 250ms (Radix Tooltip-ish). */
19
+ closeDelayMs?: number;
20
+ /** Optional class on the column container. */
21
+ className?: string;
22
+ }
23
+
24
+ // Owner of the bubble + action row layout.
25
+ //
26
+ // Why a state-based hover instead of `group-hover` CSS:
27
+ // - CSS-only hover bridges break when the cursor crosses gaps
28
+ // between the bubble and the (initially-invisible) row — the
29
+ // row's `pointer-events-none` lets the cursor "fall through",
30
+ // `group-hover` flips off, the row vanishes mid-travel.
31
+ // - State + a small close timeout gives Radix-Tooltip-style
32
+ // stability: enter shows immediately, leave waits N ms.
33
+ // - On touch (`@media (hover:none)`) hover doesn't exist; we
34
+ // always pass `visible={true}` so the affordance is reachable.
35
+ //
36
+ // The action row is rendered **absolutely** under the bubble so
37
+ // hidden rows don't allocate vertical space — no dead-air gap
38
+ // between consecutive messages.
39
+ export const ChatMessageRow: React.FC<ChatMessageRowProps> = ({
40
+ isUser,
41
+ children,
42
+ actions,
43
+ closeDelayMs = 250,
44
+ className = '',
45
+ }) => {
46
+ const isTouch = useMediaQuery('(hover: none), (pointer: coarse)');
47
+ const [hovered, setHovered] = useState(false);
48
+ const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
49
+
50
+ const cancelClose = useCallback(() => {
51
+ if (closeTimer.current) {
52
+ clearTimeout(closeTimer.current);
53
+ closeTimer.current = null;
54
+ }
55
+ }, []);
56
+
57
+ const open = useCallback(() => {
58
+ cancelClose();
59
+ setHovered(true);
60
+ }, [cancelClose]);
61
+
62
+ const scheduleClose = useCallback(() => {
63
+ cancelClose();
64
+ closeTimer.current = setTimeout(() => setHovered(false), closeDelayMs);
65
+ }, [cancelClose, closeDelayMs]);
66
+
67
+ useEffect(() => () => cancelClose(), [cancelClose]);
68
+
69
+ // Touch: always-visible. Hover: state-driven.
70
+ const visible = isTouch || hovered;
71
+
72
+ // On touch, `static` positioning so the row claims real layout
73
+ // space (otherwise an absolute always-visible row would still
74
+ // overlap the next message). On hover devices, `absolute` so the
75
+ // hidden row doesn't leave dead air between bubbles.
76
+ const rowPositionClass = isTouch ? 'static mt-1' : 'absolute top-full';
77
+ const rowSideClass = isUser ? 'right-0' : 'left-0';
78
+
79
+ return (
80
+ <div
81
+ className={`relative flex flex-col ${isUser ? 'items-end' : 'items-start'} ${className}`}
82
+ onPointerEnter={isTouch ? undefined : open}
83
+ onPointerLeave={isTouch ? undefined : scheduleClose}
84
+ onFocusCapture={open}
85
+ onBlurCapture={(e) => {
86
+ if (!e.currentTarget.contains(e.relatedTarget as Node | null)) scheduleClose();
87
+ }}
88
+ >
89
+ {children}
90
+ {actions && (
91
+ <div className={`${rowPositionClass} ${rowSideClass}`}>
92
+ {actions(visible, isUser)}
93
+ </div>
94
+ )}
95
+ </div>
96
+ );
97
+ };
@@ -19,27 +19,23 @@ interface CodeBlockProps {
19
19
  * branch below still ships its own button because the plain <pre>
20
20
  * has no toolbar of its own.
21
21
  */
22
- export const CodeBlock: React.FC<CodeBlockProps> = ({ code, language, isCompact = false }) => {
22
+ export const CodeBlock: React.FC<CodeBlockProps> = ({ code, language }) => {
23
23
  const theme = useResolvedTheme();
24
24
 
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';
33
-
25
+ // Chat fences are always rendered in PrettyCode's compact mode:
26
+ // 12px font, tighter padding, line-height 1.4. A fenced block in
27
+ // a chat bubble shouldn't outweigh two paragraphs of body text —
28
+ // the standalone PrettyCode story keeps the larger default for
29
+ // docs/diff viewers.
34
30
  return (
35
- <div className="my-3">
31
+ <div className="my-2">
36
32
  <PrettyCode
37
33
  data={code}
38
34
  language={language}
39
- className={textSizeClass}
35
+ className="text-xs"
40
36
  customBg="bg-code"
41
37
  mode={theme}
42
- isCompact={isCompact}
38
+ isCompact
43
39
  // Disable click-to-scroll-isolation in chat markdown: code
44
40
  // fences here are part of an assistant reply, not a docs
45
41
  // viewer. Forcing the user to click into a small block to
@@ -1,6 +1,8 @@
1
1
  import type React from 'react';
2
2
  import { defineStory } from '@djangocfg/playground';
3
3
  import { MarkdownMessage } from './MarkdownMessage';
4
+ import { ActionRow } from './ActionRow';
5
+ import { ChatMessageRow } from './ChatMessageRow';
4
6
  import type { LinkRule } from './types';
5
7
 
6
8
  export default defineStory({
@@ -329,15 +331,25 @@ function ChatBubble({
329
331
  content,
330
332
  rules,
331
333
  plainText,
334
+ actions,
332
335
  }: {
333
336
  role: 'user' | 'assistant';
334
337
  content: string;
335
338
  rules?: LinkRule[];
336
339
  plainText?: boolean;
340
+ /** Show the copy-on-hover ActionRow under the bubble. */
341
+ actions?: 'copy';
337
342
  }) {
338
343
  const isUser = role === 'user';
339
344
  return (
340
- <div className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}>
345
+ <ChatMessageRow
346
+ isUser={isUser}
347
+ actions={
348
+ actions === 'copy'
349
+ ? (visible) => <ActionRow value={content} isUser={isUser} visible={visible} />
350
+ : undefined
351
+ }
352
+ >
341
353
  <div
342
354
  className={`max-w-[85%] rounded-xl px-3 py-2 ${
343
355
  isUser
@@ -352,7 +364,7 @@ function ChatBubble({
352
364
  plainText={plainText}
353
365
  />
354
366
  </div>
355
- </div>
367
+ </ChatMessageRow>
356
368
  );
357
369
  }
358
370
 
@@ -694,3 +706,66 @@ export const MermaidDiagrams = () => (
694
706
  </div>
695
707
  </div>
696
708
  );
709
+
710
+ // CopyActions — opt-in action row under the bubble. The `<ActionRow>`
711
+ // component lives outside the bubble (so it doesn't fight the
712
+ // saturated `bg-primary` palette on user messages); the `group` class
713
+ // on the parent flex container makes it appear on hover. On touch
714
+ // devices it stays at 50% opacity so the affordance is discoverable
715
+ // without hover.
716
+ export const CopyActions = () => (
717
+ <div className="mx-auto max-w-2xl space-y-6 p-6">
718
+ <div>
719
+ <h3 className="mb-2 text-sm font-semibold">
720
+ Hover any message to reveal the copy button
721
+ </h3>
722
+ <p className="mb-3 text-xs text-muted-foreground">
723
+ <code>ActionRow</code> is exported separately from{' '}
724
+ <code>MarkdownMessage</code> on purpose — placement is a
725
+ layout concern of the bubble, not of the renderer. Wrap your
726
+ bubble + <code>&lt;ActionRow&gt;</code> in a{' '}
727
+ <code>group</code> div, and the row toggles via{' '}
728
+ <code>group-hover</code>. On <code>@media (hover: none)</code>{' '}
729
+ it stays at 50% opacity instead of hiding.
730
+ </p>
731
+ <div className="rounded-lg border border-border bg-card p-4 space-y-8">
732
+ <ChatBubble
733
+ role="user"
734
+ content="How do I install cmdop?"
735
+ plainText
736
+ actions="copy"
737
+ />
738
+ <ChatBubble
739
+ role="assistant"
740
+ content={`Run this from your terminal:
741
+
742
+ \`\`\`bash
743
+ curl -sSL cmdop.com/install.sh | bash
744
+ \`\`\`
745
+
746
+ It auto-detects your shell and adds \`cmdop\` to PATH.`}
747
+ actions="copy"
748
+ />
749
+ <ChatBubble
750
+ role="user"
751
+ content={'Multi\nline\nuser message\nto verify copy preserves all newlines'}
752
+ plainText
753
+ actions="copy"
754
+ />
755
+ </div>
756
+ </div>
757
+
758
+ <div>
759
+ <h3 className="mb-2 text-sm font-semibold">
760
+ Without <code>actions</code> — no extra row, no overhead
761
+ </h3>
762
+ <p className="mb-3 text-xs text-muted-foreground">
763
+ Default behaviour. Existing consumers see no visual change.
764
+ </p>
765
+ <div className="rounded-lg border border-border bg-card p-4 space-y-3">
766
+ <ChatBubble role="user" content="Plain user bubble." plainText />
767
+ <ChatBubble role="assistant" content="Plain assistant bubble." />
768
+ </div>
769
+ </div>
770
+ </div>
771
+ );
@@ -153,7 +153,7 @@ export const MarkdownMessage: React.FC<MarkdownMessageProps> = ({
153
153
  // WhatsApp/Telegram bubble density.
154
154
  return (
155
155
  <div
156
- className={`${textSizeClass} leading-snug break-words whitespace-pre-wrap ${className}`}
156
+ className={`${textSizeClass} font-normal antialiased leading-snug break-words whitespace-pre-wrap ${className}`}
157
157
  >
158
158
  {displayContent}
159
159
  {collapsible && shouldCollapse && (
@@ -177,14 +177,13 @@ export const MarkdownMessage: React.FC<MarkdownMessageProps> = ({
177
177
  <div className={className}>
178
178
  <div
179
179
  className={`
180
- prose ${proseClass} max-w-none break-words overflow-hidden ${textSizeClass}
180
+ prose ${proseClass} max-w-none break-words overflow-hidden ${textSizeClass} font-normal antialiased
181
181
  ${isUser ? 'prose-invert' : 'dark:prose-invert'}
182
182
  [&>*]:leading-relaxed
183
183
  [&>*:first-child]:mt-0 [&>*:last-child]:mb-0
184
184
  [&_p]:my-2
185
185
  [&_ul]:my-2 [&_ol]:my-2 [&_ul]:pl-5 [&_ol]:pl-5
186
186
  [&_li]:my-1 [&_li>p]:my-0
187
- [&_pre]:my-3
188
187
  [&_h1]:mt-4 [&_h1]:mb-2 [&_h1]:text-base [&_h1]:font-semibold
189
188
  [&_h2]:mt-3.5 [&_h2]:mb-1.5 [&_h2]:text-[15px] [&_h2]:font-semibold
190
189
  [&_h3]:mt-3 [&_h3]:mb-1 [&_h3]:text-sm [&_h3]:font-medium
@@ -23,6 +23,7 @@ import { MarkdownMessage } from '@djangocfg/ui-tools';
23
23
  | Custom URL schemes via declarative `linkRules` | `./linkRules.ts` |
24
24
  | Plain-text fast path — short messages skip the markdown pipeline | `./plainText.ts` |
25
25
  | Optional collapsible "Read more" for long replies | `useCollapsibleContent` |
26
+ | Hover-revealed copy action under the bubble (separate `<ActionRow>` export) | `<CopyButton>` (ui-core) |
26
27
  | User vs assistant palette via `isUser` flag | semantic theme tokens |
27
28
 
28
29
  ## Why these plugins
@@ -83,6 +84,74 @@ The plain `a` renderer keeps its palette and typography; `linkRules` only fires
83
84
  when both the `protocols` and the `match` predicate accept the href. See
84
85
  `linkRules.ts` for the sanitize-extension logic.
85
86
 
87
+ ## Copy action (`<ChatMessageRow>` + `<ActionRow>`)
88
+
89
+ Two pieces, two responsibilities:
90
+
91
+ - **`<ActionRow>`** — dumb leaf. Knows the side (`isUser`) and the
92
+ text to copy (`value`). Owner controls visibility via `visible`.
93
+ - **`<ChatMessageRow>`** — opinionated wrapper. Owns the bubble +
94
+ action-row layout, hover state with a 250 ms close delay, touch
95
+ fallback, focus management, and absolute positioning for the row
96
+ so a hidden row never claims vertical space.
97
+
98
+ The split exists because:
99
+
100
+ 1. `MarkdownMessage` doesn't know whether it lives in a saturated
101
+ `bg-primary` bubble or a neutral card — putting the copy icon
102
+ inside would force it to fight the bubble's palette.
103
+ 2. CSS-only `group-hover` bridges flicker when the cursor crosses
104
+ the gap between the bubble and the (initially-invisible) row.
105
+ State + a small close timeout is the same pattern Radix Tooltip
106
+ uses for the same reason.
107
+
108
+ ```tsx
109
+ import { MarkdownMessage, ChatMessageRow, ActionRow } from '@djangocfg/ui-tools';
110
+
111
+ function ChatBubble({ role, content }) {
112
+ const isUser = role === 'user';
113
+ return (
114
+ <ChatMessageRow
115
+ isUser={isUser}
116
+ actions={(visible) => (
117
+ <ActionRow value={content} isUser={isUser} visible={visible} />
118
+ )}
119
+ >
120
+ <div
121
+ className={
122
+ isUser
123
+ ? 'bg-primary text-primary-foreground rounded-xl px-3 py-2'
124
+ : 'bg-card border border-border rounded-xl px-3 py-2'
125
+ }
126
+ >
127
+ <MarkdownMessage content={content} isUser={isUser} plainText={isUser} />
128
+ </div>
129
+ </ChatMessageRow>
130
+ );
131
+ }
132
+ ```
133
+
134
+ Behaviour:
135
+
136
+ - **Desktop:** hidden until you hover anywhere over the row (bubble
137
+ or its action area). 250 ms close delay so cursor travel from
138
+ bubble to button never flickers. Fades over 150 ms.
139
+ - **Touch (`@media (hover: none), (pointer: coarse)`):** always
140
+ visible at full opacity, in-flow under the bubble (so it claims
141
+ honest layout space — touch users have no hover, the affordance
142
+ must always be reachable).
143
+ - **Keyboard:** `focus-within` opens the row, `blur` outside the row
144
+ schedules the same close timeout.
145
+ - **Side:** the row sits on the same side as the bubble (`isUser`
146
+ flips alignment); the icon itself is never mirrored.
147
+
148
+ Pass the original `content` string to `ActionRow.value` so collapsed
149
+ bubbles still copy the full text, not the truncated `displayContent`.
150
+
151
+ The `actions` prop is a render-prop (`(visible, isUser) => ReactNode`)
152
+ so it's already future-proof for richer rows like
153
+ `[Copy] [Like] [Regenerate]`.
154
+
86
155
  ## Files
87
156
 
88
157
  | File | Purpose |
@@ -94,6 +163,8 @@ when both the `protocols` and the `match` predicate accept the href. See
94
163
  | `plainText.ts` | `looksLikePlainProse` heuristic + `extractTextFromChildren` utility |
95
164
  | `sanitize.ts` | Extended rehype-sanitize schema + `urlTransform` builder |
96
165
  | `CollapseToggle.tsx` | "Read more / Show less" affordance for long replies |
166
+ | `ActionRow.tsx` | Dumb action row (copy button); owner toggles `visible` |
167
+ | `ChatMessageRow.tsx` | Bubble + action-row wrapper with hover state and touch fallback |
97
168
  | `MarkdownMessage.story.tsx` | Storybook — kitchen sink, link rules, mermaid, soft breaks, plugin sampler |
98
169
 
99
170
  ## Stories
@@ -109,3 +180,4 @@ Open the storybook and look at:
109
180
  - `Markdown Message / Kitchen Sink` — every block element together, used to spot
110
181
  spacing regressions at a glance.
111
182
  - `Markdown Message / Mermaid Diagrams` — flowchart + sequence diagram in a bubble.
183
+ - `Markdown Message / Copy Actions` — hover-revealed copy button on user and assistant bubbles.
@@ -21,10 +21,10 @@ export function createMarkdownComponents(
21
21
 
22
22
  return {
23
23
  h1: ({ children }) => (
24
- <h1 className={`${headingBase} font-bold mb-2 mt-3 first:mt-0`}>{children}</h1>
24
+ <h1 className={`${headingBase} font-semibold mb-2 mt-3 first:mt-0`}>{children}</h1>
25
25
  ),
26
26
  h2: ({ children }) => (
27
- <h2 className={`${headingSm} font-bold mb-2 mt-3 first:mt-0`}>{children}</h2>
27
+ <h2 className={`${headingSm} font-semibold mb-2 mt-3 first:mt-0`}>{children}</h2>
28
28
  ),
29
29
  h3: ({ children }) => (
30
30
  <h3 className={`${headingSm} font-semibold mb-1 mt-2 first:mt-0`}>{children}</h3>
@@ -40,7 +40,7 @@ export function createMarkdownComponents(
40
40
  ),
41
41
 
42
42
  p: ({ children }) => (
43
- <p className={`${textSize} mb-4 last:mb-0 leading-7 break-words font-light`}>{children}</p>
43
+ <p className={`${textSize} mb-4 last:mb-0 leading-relaxed break-words`}>{children}</p>
44
44
  ),
45
45
 
46
46
  ul: ({ children }) => (
@@ -7,7 +7,13 @@
7
7
  * - `LinkRule` — the declarative link-handling primitive.
8
8
  * - `extractTextFromChildren` — utility for callers that build their
9
9
  * own custom `a` renderers and need a string label.
10
+ * - `ActionRow` — dumb action row (copy button), takes `visible`
11
+ * from its owner.
12
+ * - `ChatMessageRow` — opinionated bubble + action-row wrapper
13
+ * with state-driven hover, close-delay and touch handling.
10
14
  */
11
15
  export { MarkdownMessage, default } from './MarkdownMessage';
12
16
  export type { MarkdownMessageProps, LinkRule } from './types';
13
17
  export { extractTextFromChildren } from './plainText';
18
+ export { ActionRow } from './ActionRow';
19
+ export { ChatMessageRow } from './ChatMessageRow';
package/src/index.ts CHANGED
@@ -79,17 +79,8 @@ export { LazyJsonSchemaForm } from './tools/JsonForm/lazy';
79
79
  // LottiePlayer (~200KB)
80
80
  export { LazyLottiePlayer } from './tools/LottiePlayer/lazy';
81
81
 
82
- // AudioPlayer (~200KB)
83
- export {
84
- LazyHybridAudioPlayer,
85
- LazyHybridSimplePlayer,
86
- LazyHybridCompactPlayer,
87
- } from './tools/AudioPlayer/lazy';
88
- export type { HybridCompactPlayerProps } from './tools/AudioPlayer/lazy';
89
-
90
- // AudioBus — global audio exclusivity (one player at a time)
91
- export { useAudioBus, useAudioBusStore } from './tools/AudioPlayer/hooks/useAudioBus';
92
- export type { UseAudioBusReturn } from './tools/AudioPlayer/hooks/useAudioBus';
82
+ // AudioPlayer v6 — under construction (see @dev/@refactoring6-audioplayer/).
83
+ export { LazyPlayer as LazyAudioPlayer } from './tools/AudioPlayer/lazy';
93
84
 
94
85
  // VideoPlayer (~150KB)
95
86
  export { LazyVideoPlayer } from './tools/VideoPlayer/lazy';